Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/JellyBox/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,22 @@ public App()

InitializeComponent();

UnhandledException += OnUnhandledException;

Current.RequiresPointerMode = ApplicationRequiresPointerMode.WhenRequested;

Suspending += OnSuspending;
}

private void OnUnhandledException(object sender, Windows.UI.Xaml.UnhandledExceptionEventArgs e)
{
System.Diagnostics.Debug.WriteLine($"Unhandled UI exception: {e.Exception}");
if (e.Exception.InnerException is not null)
{
System.Diagnostics.Debug.WriteLine($"Inner exception: {e.Exception.InnerException}");
}
}

/// <inheritdoc/>
protected override void OnLaunched(LaunchActivatedEventArgs args)
{
Expand Down
2 changes: 2 additions & 0 deletions src/JellyBox/AppServices.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ private AppServices()
serviceCollection.AddTransient<LibraryViewModel>();
serviceCollection.AddTransient<LoginViewModel>();
serviceCollection.AddTransient<MainPageViewModel>();
serviceCollection.AddTransient<SearchViewModel>();
serviceCollection.AddTransient<ShellSearchViewModel>();
serviceCollection.AddTransient<ServerSelectionViewModel>();
serviceCollection.AddTransient<VideoViewModel>();
serviceCollection.AddTransient<WebVideoViewModel>();
Expand Down
4 changes: 3 additions & 1 deletion src/JellyBox/Behaviors/FocusFirstItemBehavior.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ namespace JellyBox.Behaviors;
/// <summary>
/// Automatically focuses the first item in a list once items are loaded and containers are realized.
/// </summary>
#pragma warning disable CA1812 // Instantiated via XAML Interaction.Behaviors.
internal sealed class FocusFirstItemBehavior : Behavior<ListViewBase>
#pragma warning restore CA1812
{
private bool _hasFocused;

Expand Down Expand Up @@ -41,4 +43,4 @@ private async void OnLayoutUpdated(object? sender, object e)
AssociatedObject.LayoutUpdated -= OnLayoutUpdated;
await FocusManager.TryFocusAsync(firstItem, FocusState.Programmatic);
}
}
}
2 changes: 2 additions & 0 deletions src/JellyBox/Behaviors/FocusOnLoadBehavior.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@

namespace JellyBox.Behaviors;

#pragma warning disable CA1812 // Instantiated via XAML Interaction.Behaviors.
internal sealed class FocusOnLoadBehavior : Behavior<Control>
#pragma warning restore CA1812
{
protected override void OnAttached()
{
Expand Down
2 changes: 2 additions & 0 deletions src/JellyBox/Behaviors/ListViewBaseCommandBehavior.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ namespace JellyBox.Behaviors;
/// <summary>
/// Invokes the NavigateCommand on INavigable items when they are clicked in a ListViewBase control.
/// </summary>
#pragma warning disable CA1812 // Instantiated via XAML Interaction.Behaviors.
internal sealed class ListViewBaseCommandBehavior : Behavior<ListViewBase>
#pragma warning restore CA1812
{
protected override void OnAttached()
{
Expand Down
2 changes: 2 additions & 0 deletions src/JellyBox/Behaviors/ScrollOnFocusBehavior.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ namespace JellyBox.Behaviors;
/// Adjusts scroll position to keep focused items within the TV-safe zone.
/// Supports both horizontal carousels and full grid layouts.
/// </summary>
#pragma warning disable CA1812 // Instantiated via XAML Interaction.Behaviors.
internal sealed class ScrollOnFocusBehavior : Behavior<ListViewBase>
#pragma warning restore CA1812
{
private ScrollViewer? _scrollViewer;

Expand Down
7 changes: 6 additions & 1 deletion src/JellyBox/Behaviors/SectionNavigationBehavior.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using JellyBox;
using Microsoft.Xaml.Interactivity;
using Windows.Foundation;
using Windows.UI.Core;
Expand All @@ -11,9 +12,12 @@ namespace JellyBox.Behaviors;
/// Handles vertical navigation between horizontal list rows within a sections container.
/// Maintains horizontal position when moving between rows and handles edge trapping.
/// </summary>
#pragma warning disable CA1812 // Instantiated via XAML Interaction.Behaviors.
internal sealed class SectionNavigationBehavior : Behavior<ItemsControl>
#pragma warning restore CA1812
{
private ScrollViewer? _scrollViewer;
private MainPage? _mainPage;

/// <summary>
/// The ScrollViewer to use for bringing items into view.
Expand Down Expand Up @@ -55,6 +59,7 @@ protected override void OnDetaching()
private void OnLoaded(object sender, RoutedEventArgs e)
{
_scrollViewer = ScrollViewer ?? AssociatedObject.FindAncestor<ScrollViewer>();
_mainPage ??= AssociatedObject.FindAncestor<MainPage>();
}

private void OnGotFocus(object sender, RoutedEventArgs e)
Expand Down Expand Up @@ -100,7 +105,7 @@ private void OnLosingFocus(UIElement sender, LosingFocusEventArgs e)

if (targetIndex < 0)
{
if (TrapAtTop)
if (TrapAtTop && _mainPage?.TryRedirectFocusToSearch(e) != true)
{
e.TryCancel();
}
Expand Down
12 changes: 12 additions & 0 deletions src/JellyBox/CancellableLoad.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,18 @@ internal sealed class CancellableLoad
{
private CancellationTokenSource? _cts;

/// <summary>
/// Cancels any in-flight load without starting a new one.
/// </summary>
public async Task CancelAsync()
{
CancellationTokenSource? previous = Interlocked.Exchange(ref _cts, null);
if (previous is not null)
{
await previous.CancelAsync();
}
}

/// <summary>
/// Cancels any prior in-flight load, then runs <paramref name="operation"/> with a fresh
/// cancellation token. If this load is itself superseded by a later call, the resulting
Expand Down
1 change: 1 addition & 0 deletions src/JellyBox/Glyphs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ internal static class Glyphs
{
// Navigation
public const string Home = "\uE80F";
public const string Search = "\uE721";
public const string Library = "\uE8F1";
public const string Switch = "\uE895";
public const string SignOut = "\uE8BB";
Expand Down
57 changes: 56 additions & 1 deletion src/JellyBox/MainPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,71 @@
x:Class="JellyBox.MainPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:models="using:JellyBox.Models"
xmlns:g="using:JellyBox"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
Background="{StaticResource BackgroundBase}">

<Grid XYFocusKeyboardNavigation="Enabled">
<Frame x:Name="ContentFrame" />
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>

<Grid
Grid.Row="0"
Padding="48,20,48,12"
Background="{StaticResource BackgroundBase}"
XYFocusKeyboardNavigation="Enabled">
<AutoSuggestBox
x:Name="SearchBox"
MaxWidth="720"
HorizontalAlignment="Stretch"
IsTabStop="False"
PlaceholderText="Search movies and TV shows"
Style="{StaticResource SearchAutoSuggestBox}"
ItemsSource="{x:Bind ViewModel.Search.Suggestions, Mode=OneWay}"
TextChanged="SearchBox_TextChanged"
QuerySubmitted="SearchBox_QuerySubmitted"
SuggestionChosen="SearchBox_SuggestionChosen"
LostFocus="SearchBox_LostFocus"
XYFocusKeyboardNavigation="Enabled">
<AutoSuggestBox.QueryIcon>
<SymbolIcon Symbol="Find" />
</AutoSuggestBox.QueryIcon>
<AutoSuggestBox.ItemTemplate>
<DataTemplate x:DataType="models:SearchSuggestion">
<StackPanel Orientation="Horizontal" Spacing="12" Padding="4,10">
<FontIcon
FontFamily="{StaticResource SegoeIcons}"
Glyph="{x:Bind g:Glyphs.Search}"
FontSize="16"
Foreground="{StaticResource TextMuted}"
VerticalAlignment="Center" />
<StackPanel Spacing="2" VerticalAlignment="Center">
<TextBlock
Text="{x:Bind DisplayText}"
FontSize="{StaticResource FontS}"
Foreground="{StaticResource TextPrimary}" />
<TextBlock
Text="{x:Bind SecondaryText}"
FontSize="{StaticResource FontXS}"
Foreground="{StaticResource TextMuted}" />
</StackPanel>
</StackPanel>
</DataTemplate>
</AutoSuggestBox.ItemTemplate>
</AutoSuggestBox>
</Grid>

<Frame x:Name="ContentFrame" Grid.Row="1" />

<Grid
x:Name="NavigationOverlay"
Grid.Row="0"
Grid.RowSpan="2"
Visibility="Collapsed"
XYFocusKeyboardNavigation="Enabled">

Expand Down
114 changes: 113 additions & 1 deletion src/JellyBox/MainPage.xaml.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
using System.ComponentModel;
using JellyBox.Models;
using JellyBox.Services;
using JellyBox.ViewModels;
using JellyBox.Views;
using Microsoft.Extensions.DependencyInjection;
using Windows.UI.Core;
using Windows.UI.Xaml;
Expand All @@ -11,13 +15,16 @@ namespace JellyBox;
internal sealed partial class MainPage : Page
{
private FrameworkElement? _lastFocusedElement;
private bool _ignoreNextQuerySubmitted;
private bool _suppressSearchTextSync;

public MainPage()
{
InitializeComponent();

ViewModel = AppServices.Instance.ServiceProvider.GetRequiredService<MainPageViewModel>();
ViewModel.IsMenuOpenChanged += OnIsMenuOpenChanged;
ViewModel.Search.PropertyChanged += OnSearchPropertyChanged;

// Cache the page state so the ContentFrame's BackStack can be preserved
NavigationCacheMode = NavigationCacheMode.Required;
Expand All @@ -33,6 +40,7 @@ public MainPage()
Unloaded += (sender, e) =>
{
ContentFrame.Navigated -= ContentFrameNavigated;
ViewModel.Search.PropertyChanged -= OnSearchPropertyChanged;
};
}

Expand All @@ -45,6 +53,18 @@ protected override void OnNavigatedTo(NavigationEventArgs e)
base.OnNavigatedTo(e);
}

internal void OnContentNavigated()
{
// FocusState.Unfocused is invalid for Control.Focus and throws on Xbox.
SearchBox.IsTabStop = false;
}

internal bool TryRedirectFocusToSearch(LosingFocusEventArgs e)
{
SearchBox.IsTabStop = true;
return e.TrySetNewFocusedElement(SearchBox);
}

private void OnIsMenuOpenChanged(bool isOpen)
{
if (isOpen)
Expand Down Expand Up @@ -101,15 +121,107 @@ private void ContentFrameNavigated(object sender, NavigationEventArgs e)
CoreDispatcherPriority.Normal,
() =>
{
OnContentNavigated();

if (e.SourcePageType != typeof(Search))
{
ClearSearchField();
}

ViewModel.CloseNavigationCommand.Execute(null);
ViewModel.UpdateSelectedMenuItem();
});
}

private void SearchBox_LostFocus(object sender, RoutedEventArgs e)
{
SearchBox.IsTabStop = false;
}

private void CloseNavigation(object sender, TappedRoutedEventArgs e)
{
ViewModel.CloseNavigationCommand.Execute(null);
}

private void OnSearchPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (_suppressSearchTextSync
|| e.PropertyName is not nameof(ShellSearchViewModel.Query)
|| SearchBox.Text == ViewModel.Search.Query)
{
return;
}

SearchBox.Text = ViewModel.Search.Query;
}

private void SearchBox_TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args)
{
if (args.Reason == AutoSuggestionBoxTextChangeReason.UserInput)
{
ViewModel.Search.Query = sender.Text ?? string.Empty;
}
}

private void SearchBox_QuerySubmitted(AutoSuggestBox sender, AutoSuggestBoxQuerySubmittedEventArgs args)
{
if (_ignoreNextQuerySubmitted)
{
_ignoreNextQuerySubmitted = false;
return;
}

if (args.ChosenSuggestion is SearchSuggestion suggestion)
{
OpenSearchSuggestion(suggestion);
return;
}

ViewModel.Search.SubmitQuery(args.QueryText);
}

private void SearchBox_SuggestionChosen(AutoSuggestBox sender, AutoSuggestBoxSuggestionChosenEventArgs args)
{
if (args.SelectedItem is SearchSuggestion suggestion)
{
OpenSearchSuggestion(suggestion);
}
}

private void OpenSearchSuggestion(SearchSuggestion suggestion)
{
_ignoreNextQuerySubmitted = true;

_suppressSearchTextSync = true;
try
{
SearchBox.Text = string.Empty;
ViewModel.Search.SelectSuggestion(suggestion);
}
finally
{
_suppressSearchTextSync = false;
}
}

private void ClearSearchField()
{
if (string.IsNullOrEmpty(ViewModel.Search.Query) && string.IsNullOrEmpty(SearchBox.Text))
{
return;
}

_suppressSearchTextSync = true;
try
{
ViewModel.Search.ClearQuery();
SearchBox.Text = string.Empty;
}
finally
{
_suppressSearchTextSync = false;
}
}

internal sealed record Parameters(Action DeferredNavigationAction);
}
}
3 changes: 3 additions & 0 deletions src/JellyBox/Models/SearchSuggestion.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
namespace JellyBox.Models;

internal sealed record SearchSuggestion(string DisplayText, string? SecondaryText, Guid ItemId);
10 changes: 10 additions & 0 deletions src/JellyBox/Resources/Styles.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -1008,6 +1008,16 @@
</Setter>
</Style>

<Style x:Key="SearchAutoSuggestBox" TargetType="AutoSuggestBox">
<Setter Property="MinHeight" Value="48" />
<Setter Property="Foreground" Value="{StaticResource TextPrimary}" />
<Setter Property="FontSize" Value="{StaticResource FontM}" />
<Setter Property="UseSystemFocusVisuals" Value="True" />
<Setter Property="FocusVisualPrimaryBrush" Value="{StaticResource FocusBorder}" />
<Setter Property="FocusVisualSecondaryBrush" Value="Transparent" />
<Setter Property="FocusVisualPrimaryThickness" Value="3" />
</Style>

<Style x:Key="PrimaryTextBox" TargetType="TextBox">
<Setter Property="MinWidth" Value="{ThemeResource TextControlThemeMinWidth}" />
<Setter Property="MinHeight" Value="40" />
Expand Down
Loading