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..cc61881 100644 --- a/src/JellyBox/MainPage.xaml +++ b/src/JellyBox/MainPage.xaml @@ -2,16 +2,75 @@ 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..7e1a309 100644 --- a/src/JellyBox/MainPage.xaml.cs +++ b/src/JellyBox/MainPage.xaml.cs @@ -1,16 +1,28 @@ +using System; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using JellyBox.Models; +using JellyBox.Services; using JellyBox.ViewModels; +using JellyBox.Views; using Microsoft.Extensions.DependencyInjection; using Windows.UI.Core; using Windows.UI.Xaml; using Windows.UI.Xaml.Controls; using Windows.UI.Xaml.Input; +using Windows.UI.Xaml.Media; using Windows.UI.Xaml.Navigation; namespace JellyBox; internal sealed partial class MainPage : Page { + private static readonly TimeSpan OskDismissQuerySubmitSuppressWindow = TimeSpan.FromMilliseconds(500); + private static readonly TimeSpan SuggestionPickQuerySubmitSuppressWindow = TimeSpan.FromMilliseconds(300); + private FrameworkElement? _lastFocusedElement; + private DateTimeOffset _suppressQuerySubmittedUntil; + private bool _suppressSearchTextSync; public MainPage() { @@ -18,6 +30,8 @@ public MainPage() ViewModel = AppServices.Instance.ServiceProvider.GetRequiredService(); ViewModel.IsMenuOpenChanged += OnIsMenuOpenChanged; + ViewModel.Search.PropertyChanged += OnSearchPropertyChanged; + PreviewKeyDown += MainPage_PreviewKeyDown; // Cache the page state so the ContentFrame's BackStack can be preserved NavigationCacheMode = NavigationCacheMode.Required; @@ -33,6 +47,8 @@ public MainPage() Unloaded += (sender, e) => { ContentFrame.Navigated -= ContentFrameNavigated; + ViewModel.Search.PropertyChanged -= OnSearchPropertyChanged; + PreviewKeyDown -= MainPage_PreviewKeyDown; }; } @@ -45,6 +61,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 +129,188 @@ 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) + { + // Keep the search box in the focus order while suggestions are open so the user + // can dismiss the OSK with B and navigate the list with the d-pad. + if (ViewModel.Search.Suggestions.Count > 0) + { + return; + } + + SearchBox.IsTabStop = false; + } + + private void MainPage_PreviewKeyDown(object sender, KeyRoutedEventArgs e) + { + if (!IsSearchSuggestionsActive()) + { + return; + } + + if (GamepadInput.IsBackKey(e.Key)) + { + // On Xbox, dismissing the OSK with B fires QuerySubmitted for the first suggestion. + SuppressQuerySubmitted(OskDismissQuerySubmitSuppressWindow); + return; + } + + if (GamepadInput.IsAcceptKey(e.Key) && TryGetFocusedSuggestion(out SearchSuggestion? suggestion)) + { + // Handle gamepad selection here instead of SuggestionChosen, which throws + // InvalidCastException for custom suggestion items inside AutoSuggestBox on Xbox. + e.Handled = true; + OpenSearchSuggestion(suggestion); + } + } + + private void SearchSuggestionItem_Tapped(object sender, TappedRoutedEventArgs e) + { + if (sender is FrameworkElement element && element.DataContext is SearchSuggestion suggestion) + { + e.Handled = true; + OpenSearchSuggestion(suggestion); + } + } + 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) + { + ClearQuerySubmittedSuppression(); + ViewModel.Search.Query = sender.Text ?? string.Empty; + } + } + + private void SearchBox_QuerySubmitted(AutoSuggestBox sender, AutoSuggestBoxQuerySubmittedEventArgs args) + { + if (IsQuerySubmittedSuppressed()) + { + return; + } + + if (args.ChosenSuggestion is SearchSuggestion suggestion) + { + OpenSearchSuggestion(suggestion); + return; + } + + ViewModel.Search.SubmitQuery(args.QueryText); + } + + private void OpenSearchSuggestion(SearchSuggestion suggestion) + { + SuppressQuerySubmitted(SuggestionPickQuerySubmitSuppressWindow); + ViewModel.Search.SelectSuggestion(suggestion); + } + + private bool IsQuerySubmittedSuppressed() + => DateTimeOffset.UtcNow < _suppressQuerySubmittedUntil; + + private void SuppressQuerySubmitted(TimeSpan window) + { + DateTimeOffset until = DateTimeOffset.UtcNow + window; + if (until > _suppressQuerySubmittedUntil) + { + _suppressQuerySubmittedUntil = until; + } + } + + private void ClearQuerySubmittedSuppression() + => _suppressQuerySubmittedUntil = DateTimeOffset.MinValue; + + 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; + } + } + + private bool IsSearchSuggestionsActive() + { + if (ViewModel.Search.Suggestions.Count == 0) + { + return false; + } + + return IsSearchBoxFocused() || TryGetFocusedSuggestion(out _); + } + + private bool IsSearchBoxFocused() + { + for (DependencyObject? current = FocusManager.GetFocusedElement() as DependencyObject; + current is not null; + current = VisualTreeHelper.GetParent(current)) + { + if (current == SearchBox) + { + return true; + } + } + + return false; + } + + private static bool TryGetFocusedSuggestion([NotNullWhen(true)] out SearchSuggestion? suggestion) + { + suggestion = null; + if (FocusManager.GetFocusedElement() is not DependencyObject focused) + { + return false; + } + + for (DependencyObject? current = focused; current is not null; current = VisualTreeHelper.GetParent(current)) + { + if (current is FrameworkElement { DataContext: SearchSuggestion context }) + { + suggestion = context; + return true; + } + } + + return 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 @@ + +