diff --git a/src/JellyBox/Behaviors/SectionNavigationBehavior.cs b/src/JellyBox/Behaviors/SectionNavigationBehavior.cs new file mode 100644 index 0000000..ff965d2 --- /dev/null +++ b/src/JellyBox/Behaviors/SectionNavigationBehavior.cs @@ -0,0 +1,215 @@ +using Microsoft.Xaml.Interactivity; +using Windows.Foundation; +using Windows.UI.Core; +using Windows.UI.Xaml; +using Windows.UI.Xaml.Controls; +using Windows.UI.Xaml.Input; + +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. +/// +internal sealed class SectionNavigationBehavior : Behavior +{ + private ScrollViewer? _scrollViewer; + + /// + /// The ScrollViewer to use for bringing items into view. + /// If not set, attempts to find an ancestor ScrollViewer. + /// + public ScrollViewer? ScrollViewer { get; set; } + + /// + /// Whether to trap focus at the top edge (cancel up navigation from first row). + /// + public bool TrapAtTop { get; set; } = true; + + /// + /// Whether to trap focus at the bottom edge (cancel down navigation from last row). + /// + public bool TrapAtBottom { get; set; } = true; + + /// + /// Margin from viewport edge for scroll adjustments. + /// + public double SafeZoneMargin { get; set; } = 48; + + protected override void OnAttached() + { + base.OnAttached(); + AssociatedObject.Loaded += OnLoaded; + AssociatedObject.GotFocus += OnGotFocus; + AssociatedObject.LosingFocus += OnLosingFocus; + } + + protected override void OnDetaching() + { + base.OnDetaching(); + AssociatedObject.Loaded -= OnLoaded; + AssociatedObject.GotFocus -= OnGotFocus; + AssociatedObject.LosingFocus -= OnLosingFocus; + } + + private void OnLoaded(object sender, RoutedEventArgs e) + { + _scrollViewer = ScrollViewer ?? AssociatedObject.FindAncestor(); + } + + private void OnGotFocus(object sender, RoutedEventArgs e) + { + if (e.OriginalSource is FrameworkElement focusedElement) + { + BringIntoViewIfNeeded(focusedElement); + } + } + + private void OnLosingFocus(UIElement sender, LosingFocusEventArgs e) + { + // Only handle vertical navigation + if (e.Direction is not (FocusNavigationDirection.Down or FocusNavigationDirection.Up)) + { + return; + } + + if (e.OldFocusedElement is not FrameworkElement oldElement) + { + return; + } + + ListView? fromListView = oldElement.FindAncestor(); + if (fromListView is null) + { + return; + } + + // Find all visible ListViews with items in visual order + List listViews = AssociatedObject.FindAllDescendants(); + listViews.RemoveAll(lv => lv.Visibility != Visibility.Visible || lv.Items.Count == 0); + + int currentIndex = listViews.IndexOf(fromListView); + if (currentIndex < 0) + { + return; + } + + // Get target ListView + bool isDown = e.Direction == FocusNavigationDirection.Down; + int targetIndex = isDown ? currentIndex + 1 : currentIndex - 1; + + if (targetIndex < 0) + { + if (TrapAtTop) + { + e.TryCancel(); + } + + return; + } + + if (targetIndex >= listViews.Count) + { + if (TrapAtBottom) + { + e.TryCancel(); + } + + return; + } + + ListView targetListView = listViews[targetIndex]; + + // Find the item in the target ListView closest to the current horizontal position + double fromCenterX = GetElementCenterX(oldElement); + FrameworkElement? closestItem = null; + double closestDistance = double.MaxValue; + + for (int i = 0; i < targetListView.Items.Count; i++) + { + if (targetListView.ContainerFromIndex(i) is FrameworkElement container) + { + double distance = Math.Abs(GetElementCenterX(container) - fromCenterX); + if (distance < closestDistance) + { + closestDistance = distance; + closestItem = container; + } + } + } + + // If containers aren't realized yet, scroll to first item and try again after layout + if (closestItem is null) + { + e.TryCancel(); + targetListView.ScrollIntoView(targetListView.Items[0]); + _ = AssociatedObject.Dispatcher.RunAsync(CoreDispatcherPriority.Normal, async () => + { + if (targetListView.ContainerFromIndex(0) is FrameworkElement container) + { + await FocusManager.TryFocusAsync(container, FocusState.Keyboard); + if (FocusManager.GetFocusedElement() is FrameworkElement fe) + { + BringIntoViewIfNeeded(fe); + } + } + }); + return; + } + + // Redirect focus to the closest item + e.TrySetNewFocusedElement(closestItem); + } + + private double GetElementCenterX(FrameworkElement element) + { + try + { + var transform = element.TransformToVisual(AssociatedObject); + var position = transform.TransformPoint(new Point(0, 0)); + return position.X + (element.ActualWidth / 2); + } + catch + { + return 0; + } + } + + private void BringIntoViewIfNeeded(FrameworkElement element) + { + if (_scrollViewer is null) + { + return; + } + + try + { + // Use the parent ListView's section (title + list) for scroll calculations + // so the section title is also visible when scrolling down to a row + ListView? listView = element.FindAncestor(); + FrameworkElement scrollTarget = listView?.FindAncestor() ?? element; + + var transform = scrollTarget.TransformToVisual(_scrollViewer); + var position = transform.TransformPoint(new Point(0, 0)); + + double viewportHeight = _scrollViewer.ViewportHeight; + double elementTop = position.Y - scrollTarget.Margin.Top; + double elementBottom = position.Y + scrollTarget.ActualHeight + scrollTarget.Margin.Bottom; + + if (elementTop < SafeZoneMargin) + { + double newOffset = _scrollViewer.VerticalOffset + elementTop - SafeZoneMargin; + _scrollViewer.ChangeView(null, Math.Max(0, newOffset), null, disableAnimation: false); + } + else if (elementBottom > viewportHeight - SafeZoneMargin) + { + double newOffset = _scrollViewer.VerticalOffset + (elementBottom - (viewportHeight - SafeZoneMargin)); + _scrollViewer.ChangeView(null, newOffset, null, disableAnimation: false); + } + } + catch + { + // Element may have been removed from visual tree + } + } +} diff --git a/src/JellyBox/DependencyObjectExtensions.cs b/src/JellyBox/DependencyObjectExtensions.cs index e548a54..aac273b 100644 --- a/src/JellyBox/DependencyObjectExtensions.cs +++ b/src/JellyBox/DependencyObjectExtensions.cs @@ -51,6 +51,30 @@ internal static class DependencyObjectExtensions return null; } + /// + /// Finds the first descendant of the specified type matching the predicate using depth-first search. + /// + public static T? FindFirstDescendant(this DependencyObject parent, Func predicate) where T : DependencyObject + { + int childCount = VisualTreeHelper.GetChildrenCount(parent); + for (int i = 0; i < childCount; i++) + { + DependencyObject child = VisualTreeHelper.GetChild(parent, i); + if (child is T match && predicate(match)) + { + return match; + } + + T? descendant = child.FindFirstDescendant(predicate); + if (descendant is not null) + { + return descendant; + } + } + + return null; + } + /// /// Finds all descendants of the specified type. /// @@ -75,4 +99,23 @@ private static void FindAllDescendantsRecursive(DependencyObject parent, List FindAllDescendantsRecursive(child, results); } } + + /// + /// Checks whether the element is a descendant of the specified ancestor in the visual tree. + /// + public static bool IsDescendantOf(this DependencyObject? element, DependencyObject ancestor) + { + DependencyObject? current = element; + while (current is not null) + { + if (ReferenceEquals(current, ancestor)) + { + return true; + } + + current = VisualTreeHelper.GetParent(current); + } + + return false; + } } diff --git a/src/JellyBox/Resources/Styles.xaml b/src/JellyBox/Resources/Styles.xaml index 8cfea21..b339367 100644 --- a/src/JellyBox/Resources/Styles.xaml +++ b/src/JellyBox/Resources/Styles.xaml @@ -1,4 +1,4 @@ - - + @@ -349,11 +349,6 @@ - - - + To="1.15" + Duration="0:0:0.2"> @@ -411,8 +406,8 @@ + To="1.15" + Duration="0:0:0.2"> @@ -430,56 +425,109 @@ + diff --git a/src/JellyBox/ViewModels/ItemDetailsViewModel.cs b/src/JellyBox/ViewModels/ItemDetailsViewModel.cs index b0920e5..51a579c 100644 --- a/src/JellyBox/ViewModels/ItemDetailsViewModel.cs +++ b/src/JellyBox/ViewModels/ItemDetailsViewModel.cs @@ -52,6 +52,12 @@ internal sealed partial class ItemDetailsViewModel : ObservableObject [ObservableProperty] public partial MediaSourceInfoWrapper? SelectedSourceContainer { get; set; } + [ObservableProperty] + public partial bool HasMultipleSources { get; set; } + + [ObservableProperty] + public partial bool HasSingleSource { get; set; } + [ObservableProperty] public partial ObservableCollection? VideoStreams { get; set; } @@ -64,12 +70,24 @@ internal sealed partial class ItemDetailsViewModel : ObservableObject [ObservableProperty] public partial MediaStreamOption? SelectedAudioStream { get; set; } + [ObservableProperty] + public partial bool HasMultipleAudioStreams { get; set; } + + [ObservableProperty] + public partial bool HasSingleAudioStream { get; set; } + [ObservableProperty] public partial ObservableCollection? SubtitleStreams { get; set; } [ObservableProperty] public partial MediaStreamOption? SelectedSubtitleStream { get; set; } + [ObservableProperty] + public partial bool HasMultipleSubtitleStreams { get; set; } + + [ObservableProperty] + public partial bool HasSingleSubtitleStream { get; set; } + [ObservableProperty] public partial string? TagLine { get; set; } @@ -169,6 +187,8 @@ internal async void HandleParameters(ItemDetails.Parameters parameters) if (Item.MediaSources is not null && Item.MediaSources.Count > 0) { SourceContainers = new ObservableCollection(Item.MediaSources.Select(s => new MediaSourceInfoWrapper(s.Name!, s))); + HasMultipleSources = SourceContainers.Count > 1; + HasSingleSource = SourceContainers.Count == 1; // This will trigger OnSelectedSourceContainerChanged, which populates the video, audio, and subtitle drop-downs. SelectedSourceContainer = SourceContainers[0]; @@ -280,6 +300,8 @@ private void DetermineAudioOptions(MediaSourceInfo mediaSourceInfo) } AudioStreams = new ObservableCollection(options); + HasMultipleAudioStreams = AudioStreams.Count > 1; + HasSingleAudioStream = AudioStreams.Count == 1; SelectedAudioStream = selectedOption; } @@ -318,6 +340,8 @@ private void DetermineSubtitleOptions(MediaSourceInfo mediaSourceInfo) } SubtitleStreams = new ObservableCollection(options); + HasMultipleSubtitleStreams = SubtitleStreams.Count > 1; + HasSingleSubtitleStream = SubtitleStreams.Count == 1; SelectedSubtitleStream = selectedOption; } diff --git a/src/JellyBox/Views/Home.xaml b/src/JellyBox/Views/Home.xaml index 6e57206..13192c8 100644 --- a/src/JellyBox/Views/Home.xaml +++ b/src/JellyBox/Views/Home.xaml @@ -4,6 +4,8 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:Interactivity="using:Microsoft.Xaml.Interactivity" + xmlns:Behaviors="using:JellyBox.Behaviors" xmlns:controls="using:JellyBox.Controls" mc:Ignorable="d" Background="{StaticResource BackgroundBase}"> @@ -12,15 +14,19 @@ + XYFocusKeyboardNavigation="Enabled"> + + + diff --git a/src/JellyBox/Views/Home.xaml.cs b/src/JellyBox/Views/Home.xaml.cs index 8ca4bfa..7b33064 100644 --- a/src/JellyBox/Views/Home.xaml.cs +++ b/src/JellyBox/Views/Home.xaml.cs @@ -1,7 +1,5 @@ using JellyBox.ViewModels; using Microsoft.Extensions.DependencyInjection; -using Windows.Foundation; -using Windows.UI.Core; using Windows.UI.Xaml; using Windows.UI.Xaml.Controls; using Windows.UI.Xaml.Input; @@ -19,7 +17,6 @@ public Home() ViewModel = AppServices.Instance.ServiceProvider.GetRequiredService(); SectionsControl.LayoutUpdated += SectionsControl_LayoutUpdated; - SectionsControl.LosingFocus += SectionsControl_LosingFocus; } protected override void OnNavigatedTo(NavigationEventArgs e) => ViewModel.Initialize(); @@ -48,132 +45,4 @@ private async void SectionsControl_LayoutUpdated(object? sender, object e) _hasFocusedFirstItem = true; await FocusManager.TryFocusAsync(firstItem, FocusState.Programmatic); } - - private void SectionsControl_GotFocus(object sender, RoutedEventArgs e) - { - if (e.OriginalSource is FrameworkElement focusedElement) - { - BringIntoViewIfNeeded(focusedElement); - } - } - - private void SectionsControl_LosingFocus(UIElement sender, LosingFocusEventArgs e) - { - // Only handle vertical navigation - if (e.Direction is not (FocusNavigationDirection.Down or FocusNavigationDirection.Up)) - { - return; - } - - if (e.OldFocusedElement is not FrameworkElement oldElement) - { - return; - } - - ListView? fromListView = oldElement.FindAncestor(); - if (fromListView is null) - { - return; - } - - // Find all visible ListViews with items in visual order - List listViews = SectionsControl.FindAllDescendants(); - listViews.RemoveAll(lv => lv.Visibility != Visibility.Visible || lv.Items.Count == 0); - - int currentIndex = listViews.IndexOf(fromListView); - if (currentIndex < 0) - { - return; - } - - // Get target ListView - bool isDown = e.Direction == FocusNavigationDirection.Down; - int targetIndex = isDown ? currentIndex + 1 : currentIndex - 1; - if (targetIndex < 0 || targetIndex >= listViews.Count) - { - // At edge - cancel navigation to stay on current item - e.TryCancel(); - return; - } - - ListView targetListView = listViews[targetIndex]; - - // Find the item in the target ListView closest to the current horizontal position - double fromCenterX = GetElementCenterX(oldElement); - FrameworkElement? closestItem = null; - double closestDistance = double.MaxValue; - - for (int i = 0; i < targetListView.Items.Count; i++) - { - if (targetListView.ContainerFromIndex(i) is FrameworkElement container) - { - double distance = Math.Abs(GetElementCenterX(container) - fromCenterX); - if (distance < closestDistance) - { - closestDistance = distance; - closestItem = container; - } - } - } - - // If containers aren't realized yet, scroll to first item and try again after layout - if (closestItem is null) - { - e.TryCancel(); - targetListView.ScrollIntoView(targetListView.Items[0]); - _ = Dispatcher.RunAsync(CoreDispatcherPriority.Normal, async () => - { - if (targetListView.ContainerFromIndex(0) is { } container) - { - await FocusManager.TryFocusAsync(container, FocusState.Keyboard); - if (FocusManager.GetFocusedElement() is FrameworkElement fe) - { - BringIntoViewIfNeeded(fe); - } - } - }); - return; - } - - // Redirect focus to the closest item - if (e.TrySetNewFocusedElement(closestItem)) - { - _ = Dispatcher.RunAsync(CoreDispatcherPriority.Low, () => BringIntoViewIfNeeded(closestItem)); - } - } - - private double GetElementCenterX(FrameworkElement element) - { - try - { - var transform = element.TransformToVisual(this); - var position = transform.TransformPoint(new Point(0, 0)); - return position.X + (element.ActualWidth / 2); - } - catch - { - return 0; - } - } - - private void BringIntoViewIfNeeded(FrameworkElement element) - { - var transform = element.TransformToVisual(ContentScrollViewer); - var position = transform.TransformPoint(new Point(0, 0)); - - double viewportTop = ContentScrollViewer.VerticalOffset; - double viewportBottom = viewportTop + ContentScrollViewer.ViewportHeight; - - double elementTop = position.Y + ContentScrollViewer.VerticalOffset; - double elementBottom = elementTop + element.ActualHeight; - - if (elementTop < viewportTop) - { - ContentScrollViewer.ChangeView(null, elementTop - 20, null, false); - } - else if (elementBottom > viewportBottom) - { - ContentScrollViewer.ChangeView(null, elementBottom - ContentScrollViewer.ViewportHeight + 20, null, false); - } - } } diff --git a/src/JellyBox/Views/ItemDetails.xaml b/src/JellyBox/Views/ItemDetails.xaml index 336cd26..fd2557e 100644 --- a/src/JellyBox/Views/ItemDetails.xaml +++ b/src/JellyBox/Views/ItemDetails.xaml @@ -14,252 +14,419 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d" Background="{StaticResource BackgroundBase}"> - + + + Stretch="UniformToFill" + Opacity="0.35"> - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - + Width="340" + Height="510" /> + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - + Text="{x:Bind Text}" + FontSize="{StaticResource FontM}" + Foreground="{StaticResource TextMuted}" /> + + + + + + + + + + + + + - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + Text="Subtitles" + Foreground="{StaticResource TextMuted}" + VerticalAlignment="Center" + FontSize="{StaticResource FontS}" /> - - - - - - - - - - - - - - - - - - - - - + Grid.Column="1" + Text="{x:Bind ViewModel.SelectedSubtitleStream.DisplayText, Mode=OneWay}" + Foreground="{StaticResource TextPrimary}" + FontSize="{StaticResource FontS}" /> + - - + + + + + + + + + + + + + + + + + diff --git a/src/JellyBox/Views/ItemDetails.xaml.cs b/src/JellyBox/Views/ItemDetails.xaml.cs index e1adf90..9840174 100644 --- a/src/JellyBox/Views/ItemDetails.xaml.cs +++ b/src/JellyBox/Views/ItemDetails.xaml.cs @@ -1,17 +1,33 @@ using JellyBox.ViewModels; using Microsoft.Extensions.DependencyInjection; +using Windows.Foundation; +using Windows.UI.Xaml; using Windows.UI.Xaml.Controls; +using Windows.UI.Xaml.Input; using Windows.UI.Xaml.Navigation; namespace JellyBox.Views; internal sealed partial class ItemDetails : Page { + private bool _hasFocusedInitial; + private FocusNavigationDirection? _lastNavigationDirection; + public ItemDetails() { InitializeComponent(); ViewModel = AppServices.Instance.ServiceProvider.GetRequiredService(); + + // Populate flyouts when they open + VersionFlyout.Opening += (s, e) => PopulateVersionFlyout(); + AudioFlyout.Opening += (s, e) => PopulateAudioFlyout(); + SubtitleFlyout.Opening += (s, e) => PopulateSubtitleFlyout(); + + // Single handler for all vertical zone transitions + ContentGrid.LosingFocus += ContentGrid_LosingFocus; + ContentInfoPanel.LayoutUpdated += ContentInfoPanel_LayoutUpdated; + ContentInfoPanel.GotFocus += ContentInfoPanel_GotFocus; } internal ItemDetailsViewModel ViewModel { get; } @@ -19,4 +35,282 @@ public ItemDetails() protected override void OnNavigatedTo(NavigationEventArgs e) => ViewModel.HandleParameters((Parameters)e.Parameter); internal sealed record Parameters(Guid ItemId); + + #region Initial Focus + + private async void ContentInfoPanel_LayoutUpdated(object? sender, object e) + { + if (_hasFocusedInitial) + { + return; + } + + Button? target = FindFirstVisible(PlayButton, TrailerButton, PlayedButton, FavoriteButton, VersionButton, AudioButton, SubtitleButton); + if (target is null) + { + return; + } + + _hasFocusedInitial = true; + ContentInfoPanel.LayoutUpdated -= ContentInfoPanel_LayoutUpdated; + await FocusManager.TryFocusAsync(target, FocusState.Programmatic); + } + + private void ContentInfoPanel_GotFocus(object sender, RoutedEventArgs e) + { + // Scroll to top only when navigating up to action buttons (not when restoring focus) + if (IsActionButton(e.OriginalSource) && _lastNavigationDirection == FocusNavigationDirection.Up) + { + ContentScrollViewer.ChangeView(null, 0, null, disableAnimation: false); + } + + _lastNavigationDirection = null; + } + + #endregion + + #region Vertical Zone Navigation + + /// + /// Single handler for all vertical (Up/Down) navigation between zones. + /// Zones: Action Buttons → Stream Selection → Sections. + /// Within-section navigation is handled by SectionNavigationBehavior. + /// + private void ContentGrid_LosingFocus(UIElement sender, LosingFocusEventArgs e) + { + if (e.Direction is not (FocusNavigationDirection.Up or FocusNavigationDirection.Down)) + { + return; + } + + _lastNavigationDirection = e.Direction; + + bool oldInSections = e.OldFocusedElement.IsDescendantOf(SectionsControl); + bool newInSections = e.NewFocusedElement.IsDescendantOf(SectionsControl); + + // Within-section navigation already handled by SectionNavigationBehavior + if (oldInSections && newInSections) + { + return; + } + + if (e.Direction == FocusNavigationDirection.Down && !oldInSections) + { + HandleDownFromContent(e); + } + else if (e.Direction == FocusNavigationDirection.Up && oldInSections && !newInSections) + { + HandleUpFromSections(e); + } + } + + private void HandleDownFromContent(LosingFocusEventArgs e) + { + // From action buttons → first stream selection button, or first section + if (IsActionButton(e.OldFocusedElement)) + { + Button? streamButton = FindFirstVisible(VersionButton, AudioButton, SubtitleButton); + if (streamButton is not null) + { + e.TrySetNewFocusedElement(streamButton); + return; + } + + // No stream selection visible - go to first section + if (!TryFocusFirstSectionItem(e)) + { + e.TryCancel(); + } + } + // From last visible stream button → first section + else if (IsLastVisibleStreamButton(e.OldFocusedElement)) + { + if (!TryFocusFirstSectionItem(e)) + { + e.TryCancel(); + } + } + } + + private void HandleUpFromSections(LosingFocusEventArgs e) + { + // Collect all visible content buttons (bottom-most row first, then action buttons) + List