From 9d055d4979028fca4f3f5171d38e14a81f291c4a Mon Sep 17 00:00:00 2001 From: CyberoniOntoni Date: Tue, 23 Jun 2026 19:15:34 +0800 Subject: [PATCH] Fix Xbox shell focus, search, and library filter navigation --- src/JellyBox/App.xaml.cs | 11 ++ src/JellyBox/AppServices.cs | 2 + .../Behaviors/FocusFirstItemBehavior.cs | 4 +- src/JellyBox/Behaviors/FocusOnLoadBehavior.cs | 2 + .../Behaviors/ListViewBaseCommandBehavior.cs | 2 + .../Behaviors/ScrollOnFocusBehavior.cs | 2 + .../Behaviors/SectionNavigationBehavior.cs | 7 +- src/JellyBox/CancellableLoad.cs | 12 ++ src/JellyBox/Glyphs.cs | 1 + src/JellyBox/MainPage.xaml | 57 +++++- src/JellyBox/MainPage.xaml.cs | 114 +++++++++++- src/JellyBox/Models/SearchSuggestion.cs | 3 + src/JellyBox/Resources/Styles.xaml | 10 ++ src/JellyBox/Services/CollectionNavigation.cs | 56 ++++++ src/JellyBox/Services/GamepadInput.cs | 2 +- src/JellyBox/Services/NavigationManager.cs | 81 +++++++-- .../ViewModels/ItemDetailsViewModel.cs | 99 ++++++++++- src/JellyBox/ViewModels/MainPageViewModel.cs | 47 +++-- src/JellyBox/ViewModels/SearchViewModel.cs | 132 ++++++++++++++ .../ViewModels/ShellSearchViewModel.cs | 167 ++++++++++++++++++ src/JellyBox/Views/Home.xaml | 2 +- src/JellyBox/Views/Home.xaml.cs | 6 +- src/JellyBox/Views/ItemDetails.xaml | 14 +- src/JellyBox/Views/ItemDetails.xaml.cs | 22 ++- src/JellyBox/Views/Library.xaml | 10 +- src/JellyBox/Views/Library.xaml.cs | 48 ++++- src/JellyBox/Views/Search.xaml | 62 +++++++ src/JellyBox/Views/Search.xaml.cs | 25 +++ src/JellyBox/Views/Video.xaml.cs | 12 +- 29 files changed, 936 insertions(+), 76 deletions(-) create mode 100644 src/JellyBox/Models/SearchSuggestion.cs create mode 100644 src/JellyBox/Services/CollectionNavigation.cs create mode 100644 src/JellyBox/ViewModels/SearchViewModel.cs create mode 100644 src/JellyBox/ViewModels/ShellSearchViewModel.cs create mode 100644 src/JellyBox/Views/Search.xaml create mode 100644 src/JellyBox/Views/Search.xaml.cs diff --git a/src/JellyBox/App.xaml.cs b/src/JellyBox/App.xaml.cs index 3f80085..da5d181 100644 --- a/src/JellyBox/App.xaml.cs +++ b/src/JellyBox/App.xaml.cs @@ -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}"); + } + } + /// protected override void OnLaunched(LaunchActivatedEventArgs args) { diff --git a/src/JellyBox/AppServices.cs b/src/JellyBox/AppServices.cs index 219ee60..fcc24c1 100644 --- a/src/JellyBox/AppServices.cs +++ b/src/JellyBox/AppServices.cs @@ -71,6 +71,8 @@ private AppServices() serviceCollection.AddTransient(); serviceCollection.AddTransient(); serviceCollection.AddTransient(); + serviceCollection.AddTransient(); + serviceCollection.AddTransient(); serviceCollection.AddTransient(); serviceCollection.AddTransient(); serviceCollection.AddTransient(); diff --git a/src/JellyBox/Behaviors/FocusFirstItemBehavior.cs b/src/JellyBox/Behaviors/FocusFirstItemBehavior.cs index 8b5b05d..392bb36 100644 --- a/src/JellyBox/Behaviors/FocusFirstItemBehavior.cs +++ b/src/JellyBox/Behaviors/FocusFirstItemBehavior.cs @@ -8,7 +8,9 @@ namespace JellyBox.Behaviors; /// /// Automatically focuses the first item in a list once items are loaded and containers are realized. /// +#pragma warning disable CA1812 // Instantiated via XAML Interaction.Behaviors. internal sealed class FocusFirstItemBehavior : Behavior +#pragma warning restore CA1812 { private bool _hasFocused; @@ -41,4 +43,4 @@ private async void OnLayoutUpdated(object? sender, object e) AssociatedObject.LayoutUpdated -= OnLayoutUpdated; await FocusManager.TryFocusAsync(firstItem, FocusState.Programmatic); } -} +} \ No newline at end of file diff --git a/src/JellyBox/Behaviors/FocusOnLoadBehavior.cs b/src/JellyBox/Behaviors/FocusOnLoadBehavior.cs index 9dc9fba..3d64350 100644 --- a/src/JellyBox/Behaviors/FocusOnLoadBehavior.cs +++ b/src/JellyBox/Behaviors/FocusOnLoadBehavior.cs @@ -4,7 +4,9 @@ namespace JellyBox.Behaviors; +#pragma warning disable CA1812 // Instantiated via XAML Interaction.Behaviors. internal sealed class FocusOnLoadBehavior : Behavior +#pragma warning restore CA1812 { protected override void OnAttached() { diff --git a/src/JellyBox/Behaviors/ListViewBaseCommandBehavior.cs b/src/JellyBox/Behaviors/ListViewBaseCommandBehavior.cs index efa6568..856c9dd 100644 --- a/src/JellyBox/Behaviors/ListViewBaseCommandBehavior.cs +++ b/src/JellyBox/Behaviors/ListViewBaseCommandBehavior.cs @@ -7,7 +7,9 @@ namespace JellyBox.Behaviors; /// /// Invokes the NavigateCommand on INavigable items when they are clicked in a ListViewBase control. /// +#pragma warning disable CA1812 // Instantiated via XAML Interaction.Behaviors. internal sealed class ListViewBaseCommandBehavior : Behavior +#pragma warning restore CA1812 { protected override void OnAttached() { diff --git a/src/JellyBox/Behaviors/ScrollOnFocusBehavior.cs b/src/JellyBox/Behaviors/ScrollOnFocusBehavior.cs index 93f28de..24e637d 100644 --- a/src/JellyBox/Behaviors/ScrollOnFocusBehavior.cs +++ b/src/JellyBox/Behaviors/ScrollOnFocusBehavior.cs @@ -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. /// +#pragma warning disable CA1812 // Instantiated via XAML Interaction.Behaviors. internal sealed class ScrollOnFocusBehavior : Behavior +#pragma warning restore CA1812 { private ScrollViewer? _scrollViewer; diff --git a/src/JellyBox/Behaviors/SectionNavigationBehavior.cs b/src/JellyBox/Behaviors/SectionNavigationBehavior.cs index ff965d2..34bb7c0 100644 --- a/src/JellyBox/Behaviors/SectionNavigationBehavior.cs +++ b/src/JellyBox/Behaviors/SectionNavigationBehavior.cs @@ -1,3 +1,4 @@ +using JellyBox; using Microsoft.Xaml.Interactivity; using Windows.Foundation; using Windows.UI.Core; @@ -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. /// +#pragma warning disable CA1812 // Instantiated via XAML Interaction.Behaviors. internal sealed class SectionNavigationBehavior : Behavior +#pragma warning restore CA1812 { private ScrollViewer? _scrollViewer; + private MainPage? _mainPage; /// /// The ScrollViewer to use for bringing items into view. @@ -55,6 +59,7 @@ protected override void OnDetaching() private void OnLoaded(object sender, RoutedEventArgs e) { _scrollViewer = ScrollViewer ?? AssociatedObject.FindAncestor(); + _mainPage ??= AssociatedObject.FindAncestor(); } private void OnGotFocus(object sender, RoutedEventArgs e) @@ -100,7 +105,7 @@ private void OnLosingFocus(UIElement sender, LosingFocusEventArgs e) if (targetIndex < 0) { - if (TrapAtTop) + if (TrapAtTop && _mainPage?.TryRedirectFocusToSearch(e) != true) { e.TryCancel(); } diff --git a/src/JellyBox/CancellableLoad.cs b/src/JellyBox/CancellableLoad.cs index 05e3106..2e035bc 100644 --- a/src/JellyBox/CancellableLoad.cs +++ b/src/JellyBox/CancellableLoad.cs @@ -15,6 +15,18 @@ internal sealed class CancellableLoad { private CancellationTokenSource? _cts; + /// + /// Cancels any in-flight load without starting a new one. + /// + public async Task CancelAsync() + { + CancellationTokenSource? previous = Interlocked.Exchange(ref _cts, null); + if (previous is not null) + { + await previous.CancelAsync(); + } + } + /// /// Cancels any prior in-flight load, then runs with a fresh /// cancellation token. If this load is itself superseded by a later call, the resulting diff --git a/src/JellyBox/Glyphs.cs b/src/JellyBox/Glyphs.cs index 0f946f3..e98e90f 100644 --- a/src/JellyBox/Glyphs.cs +++ b/src/JellyBox/Glyphs.cs @@ -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"; diff --git a/src/JellyBox/MainPage.xaml b/src/JellyBox/MainPage.xaml index 39c9a15..5458501 100644 --- a/src/JellyBox/MainPage.xaml +++ b/src/JellyBox/MainPage.xaml @@ -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}"> - + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/JellyBox/MainPage.xaml.cs b/src/JellyBox/MainPage.xaml.cs index adccc69..e9059f9 100644 --- a/src/JellyBox/MainPage.xaml.cs +++ b/src/JellyBox/MainPage.xaml.cs @@ -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; @@ -11,6 +15,8 @@ namespace JellyBox; internal sealed partial class MainPage : Page { private FrameworkElement? _lastFocusedElement; + private bool _ignoreNextQuerySubmitted; + private bool _suppressSearchTextSync; public MainPage() { @@ -18,6 +24,7 @@ public MainPage() ViewModel = AppServices.Instance.ServiceProvider.GetRequiredService(); ViewModel.IsMenuOpenChanged += OnIsMenuOpenChanged; + ViewModel.Search.PropertyChanged += OnSearchPropertyChanged; // Cache the page state so the ContentFrame's BackStack can be preserved NavigationCacheMode = NavigationCacheMode.Required; @@ -33,6 +40,7 @@ public MainPage() Unloaded += (sender, e) => { ContentFrame.Navigated -= ContentFrameNavigated; + ViewModel.Search.PropertyChanged -= OnSearchPropertyChanged; }; } @@ -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) @@ -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); -} +} \ No newline at end of file diff --git a/src/JellyBox/Models/SearchSuggestion.cs b/src/JellyBox/Models/SearchSuggestion.cs new file mode 100644 index 0000000..5abea04 --- /dev/null +++ b/src/JellyBox/Models/SearchSuggestion.cs @@ -0,0 +1,3 @@ +namespace JellyBox.Models; + +internal sealed record SearchSuggestion(string DisplayText, string? SecondaryText, Guid ItemId); \ No newline at end of file diff --git a/src/JellyBox/Resources/Styles.xaml b/src/JellyBox/Resources/Styles.xaml index ac596c0..edda3a3 100644 --- a/src/JellyBox/Resources/Styles.xaml +++ b/src/JellyBox/Resources/Styles.xaml @@ -1008,6 +1008,16 @@ + +