diff --git a/src/JellyBox/Behaviors/FocusFirstItemBehavior.cs b/src/JellyBox/Behaviors/FocusFirstItemBehavior.cs index aff91fd..8b5b05d 100644 --- a/src/JellyBox/Behaviors/FocusFirstItemBehavior.cs +++ b/src/JellyBox/Behaviors/FocusFirstItemBehavior.cs @@ -38,6 +38,7 @@ private async void OnLayoutUpdated(object? sender, object e) } _hasFocused = true; + AssociatedObject.LayoutUpdated -= OnLayoutUpdated; await FocusManager.TryFocusAsync(firstItem, FocusState.Programmatic); } } diff --git a/src/JellyBox/MainPage.xaml.cs b/src/JellyBox/MainPage.xaml.cs index 5bfd0ff..ac0c745 100644 --- a/src/JellyBox/MainPage.xaml.cs +++ b/src/JellyBox/MainPage.xaml.cs @@ -89,8 +89,8 @@ private void SlideOutCompleted(object sender, object e) { NavigationOverlay.Visibility = Visibility.Collapsed; - // Restore focus to previously focused element - if (_lastFocusedElement is Control control) + // Restore focus to previously focused element (if it's still in the visual tree) + if (_lastFocusedElement is Control control && control.IsLoaded) { control.Focus(FocusState.Keyboard); } @@ -121,6 +121,12 @@ private void CloseNavigation(object sender, TappedRoutedEventArgs e) /// private void OnKeyDown(object sender, KeyRoutedEventArgs e) { + // Don't intercept keys when a text input control has focus + if (FocusManager.GetFocusedElement() is TextBox or PasswordBox) + { + return; + } + switch (e.Key) { // Back gesture - close navigation if open diff --git a/src/JellyBox/Services/NavigationManager.cs b/src/JellyBox/Services/NavigationManager.cs index 09880bd..8e46030 100644 --- a/src/JellyBox/Services/NavigationManager.cs +++ b/src/JellyBox/Services/NavigationManager.cs @@ -5,6 +5,7 @@ using Windows.UI.Input; using Windows.UI.Xaml; using Windows.UI.Xaml.Controls; +using Windows.UI.Xaml.Input; namespace JellyBox.Services; @@ -15,6 +16,8 @@ internal sealed class NavigationManager // Fake item id used to identify the home page public static readonly Guid HomeId = new Guid("CDF95D47-90C2-4057-B12C-BA81C34F2CB9"); + public event Action? MenuOpenRequested; + private Frame _appFrame = null!; // TODO private Frame? _contentFrame; @@ -69,6 +72,8 @@ public void ClearHistory() public void NavigateToLogin() => NavigateAppFrame(); + public void RequestOpenMenu() => MenuOpenRequested?.Invoke(); + public void NavigateToHome() { CurrentItem = HomeId; @@ -148,7 +153,7 @@ private void NavigateContentFrame(object? parameter = null) private static void NavigateFrame(Frame frame, ref object? currentParameter, object? parameter = null) { - // Only navigate if the selected page isn't currently loaded. + // Only navigate if the selected page isn't currently loaded with the same parameter. Type pageType = typeof(TPage); if (frame.CurrentSourcePageType == pageType && Equals(currentParameter, parameter)) { @@ -230,6 +235,12 @@ private void AcceleratorKeyActivated(CoreDispatcher sender, AcceleratorKeyEventA return; } + // Don't intercept navigation keys when a text input control has focus + if (FocusManager.GetFocusedElement() is TextBox or PasswordBox) + { + return; + } + CoreWindow coreWindow = Window.Current.CoreWindow; CoreVirtualKeyStates downState = CoreVirtualKeyStates.Down; bool menuKey = (coreWindow.GetKeyState(VirtualKey.Menu) & downState) == downState; diff --git a/src/JellyBox/ViewModels/ItemDetailsViewModel.cs b/src/JellyBox/ViewModels/ItemDetailsViewModel.cs index 51a579c..43cd467 100644 --- a/src/JellyBox/ViewModels/ItemDetailsViewModel.cs +++ b/src/JellyBox/ViewModels/ItemDetailsViewModel.cs @@ -345,6 +345,8 @@ private void DetermineSubtitleOptions(MediaSourceInfo mediaSourceInfo) SelectedSubtitleStream = selectedOption; } + public void RequestOpenMenu() => _navigationManager.RequestOpenMenu(); + public void Play() { // TODO: Support playlists diff --git a/src/JellyBox/ViewModels/MainPageViewModel.cs b/src/JellyBox/ViewModels/MainPageViewModel.cs index c62a329..361a97e 100644 --- a/src/JellyBox/ViewModels/MainPageViewModel.cs +++ b/src/JellyBox/ViewModels/MainPageViewModel.cs @@ -14,6 +14,8 @@ internal sealed partial class MainPageViewModel : ObservableObject private readonly JellyfinApiClient _jellyfinApiClient; private readonly NavigationManager _navigationManager; + private bool _isUpdatingSelection; + [ObservableProperty] public partial bool IsMenuOpen { get; set; } @@ -32,8 +34,7 @@ public MainPageViewModel( _appSettings = appSettings; _jellyfinApiClient = jellyfinApiClient; _navigationManager = navigationManager; - - _ = InitializeNavigationItemsAsync(); + _navigationManager.MenuOpenRequested += () => IsMenuOpen = true; } [RelayCommand] @@ -49,6 +50,8 @@ public void HandleParameters(MainPage.Parameters? parameters, Frame contentFrame { _navigationManager.RegisterContentFrame(contentFrame); + _ = InitializeNavigationItemsAsync(); + if (parameters is not null) { parameters.DeferredNavigationAction(); @@ -62,6 +65,12 @@ public void HandleParameters(MainPage.Parameters? parameters, Frame contentFrame public void NavigationItemSelected(NavigationView _, NavigationViewItemInvokedEventArgs args) { + // Setting IsSelected programmatically can trigger ItemInvoked in UWP; ignore those. + if (_isUpdatingSelection) + { + return; + } + if (args.InvokedItemContainer?.Tag is NavigationViewItemContext context) { context.NavigateAction(); @@ -79,17 +88,25 @@ public void UpdateSelectedMenuItem() if (NavigationItems is not null) { - foreach (NavigationViewItemBase item in NavigationItems) + _isUpdatingSelection = true; + try { - if (item.Tag is NavigationViewItemContext context) + foreach (NavigationViewItemBase item in NavigationItems) { - if (context.ItemId == currentItem) + if (item.Tag is NavigationViewItemContext context) { - item.IsSelected = true; - break; + if (context.ItemId == currentItem) + { + item.IsSelected = true; + break; + } } } } + finally + { + _isUpdatingSelection = false; + } } } diff --git a/src/JellyBox/Views/Home.xaml b/src/JellyBox/Views/Home.xaml index 13192c8..ba5268c 100644 --- a/src/JellyBox/Views/Home.xaml +++ b/src/JellyBox/Views/Home.xaml @@ -14,6 +14,7 @@ + Visibility="{x:Bind ViewModel.CanPlay, Mode=OneWay}"> @@ -196,8 +195,7 @@ x:Name="FavoriteButton" Style="{StaticResource IconButton}" Click="{x:Bind ViewModel.ToggleFavorite}" - Visibility="{x:Bind ViewModel.CanMarkFavorite, Mode=OneWay}" - XYFocusRight="{x:Bind FavoriteButton}"> + Visibility="{x:Bind ViewModel.CanMarkFavorite, Mode=OneWay}"> private void ContentGrid_LosingFocus(UIElement sender, LosingFocusEventArgs e) { + // Trap focus at left/right edges of action buttons so left opens nav menu + if (e.Direction is FocusNavigationDirection.Left or FocusNavigationDirection.Right + && IsActionButton(e.OldFocusedElement) + && !IsActionButton(e.NewFocusedElement)) + { + e.TryCancel(); + + // XY focus consumes the key even when cancelled, so MainPage.OnKeyDown never fires. + // Open the nav menu directly for left-edge presses. + if (e.Direction == FocusNavigationDirection.Left) + { + ViewModel.RequestOpenMenu(); + } + + return; + } + if (e.Direction is not (FocusNavigationDirection.Up or FocusNavigationDirection.Down)) { return; diff --git a/src/JellyBox/Views/Login.xaml b/src/JellyBox/Views/Login.xaml index 14d4f67..d3b3e93 100644 --- a/src/JellyBox/Views/Login.xaml +++ b/src/JellyBox/Views/Login.xaml @@ -10,14 +10,14 @@ mc:Ignorable="d" Background="{StaticResource BackgroundBase}"> - + @@ -51,6 +51,7 @@ Command="{x:Bind ViewModel.SignInCommand}" IsEnabled="{x:Bind ViewModel.IsInteractable, Mode=OneWay}" Style="{StaticResource PrimaryButton}" + HorizontalAlignment="Stretch" Margin="0 0 0 10" />