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 @@
+
+