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}" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
+ Foreground="{StaticResource TextMuted}"
+ VerticalAlignment="Center" />
-
-
-
-
-
-
-
+ Text="{x:Bind ViewModel.SelectedSourceContainer.Value.Name, Mode=OneWay}"
+ VerticalAlignment="Center" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
-
-
-
-
-
-
+ Text="{x:Bind ViewModel.SelectedAudioStream.DisplayText, Mode=OneWay}"
+ VerticalAlignment="Center" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
-
-
-
-
-
-
+ Text="{x:Bind ViewModel.SelectedSubtitleStream.DisplayText, Mode=OneWay}"
+ VerticalAlignment="Center" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+ 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 candidates = [];
+ AddIfVisible(candidates, SubtitleButton, AudioButton, VersionButton);
+
+ if (candidates.Count > 0)
+ {
+ // Stream buttons are stacked vertically — pick the bottom-most (first in list)
+ e.TrySetNewFocusedElement(candidates[0]);
+ return;
+ }
+
+ // Fall back to action buttons (horizontal row — pick closest)
+ AddIfVisible(candidates, PlayButton, TrailerButton, PlayedButton, FavoriteButton);
+
+ if (candidates.Count == 0)
+ {
+ return;
+ }
+
+ if (e.OldFocusedElement is FrameworkElement oldElement)
+ {
+ Button closest = FindClosestHorizontally(oldElement, candidates);
+ e.TrySetNewFocusedElement(closest);
+ }
+ else
+ {
+ e.TrySetNewFocusedElement(candidates[0]);
+ }
+ }
+
+ private Button FindClosestHorizontally(FrameworkElement source, List candidates)
+ {
+ double sourceCenterX = GetElementCenterX(source);
+ Button closest = candidates[0];
+ double closestDistance = double.MaxValue;
+
+ foreach (Button button in candidates)
+ {
+ double distance = Math.Abs(GetElementCenterX(button) - sourceCenterX);
+ if (distance < closestDistance)
+ {
+ closestDistance = distance;
+ closest = button;
+ }
+ }
+
+ return closest;
+ }
+
+ private double GetElementCenterX(FrameworkElement element)
+ {
+ try
+ {
+ var transform = element.TransformToVisual(ContentGrid);
+ var position = transform.TransformPoint(new Point(0, 0));
+ return position.X + (element.ActualWidth / 2);
+ }
+ catch
+ {
+ return 0;
+ }
+ }
+
+ private static void AddIfVisible(List list, params Button[] buttons)
+ {
+ foreach (Button button in buttons)
+ {
+ if (button.Visibility is Visibility.Visible)
+ {
+ list.Add(button);
+ }
+ }
+ }
+
+ #endregion
+
+ #region Helpers
+
+ private bool IsActionButton(object? element)
+ => ReferenceEquals(element, PlayButton)
+ || ReferenceEquals(element, TrailerButton)
+ || ReferenceEquals(element, PlayedButton)
+ || ReferenceEquals(element, FavoriteButton);
+
+ private bool IsLastVisibleStreamButton(object? element)
+ {
+ // Check from bottom to top - the first visible one is the "last" (bottom-most)
+ if (SubtitleButton.Visibility is Visibility.Visible)
+ {
+ return ReferenceEquals(element, SubtitleButton);
+ }
+
+ if (AudioButton.Visibility is Visibility.Visible)
+ {
+ return ReferenceEquals(element, AudioButton);
+ }
+
+ if (VersionButton.Visibility is Visibility.Visible)
+ {
+ return ReferenceEquals(element, VersionButton);
+ }
+
+ return false;
+ }
+
+ private bool TryFocusFirstSectionItem(LosingFocusEventArgs e)
+ {
+ ListView? firstListView = SectionsControl.FindFirstDescendant(
+ lv => lv.Visibility is Visibility.Visible && lv.Items.Count > 0);
+
+ if (firstListView is null)
+ {
+ return false;
+ }
+
+ DependencyObject? firstItem = firstListView.ContainerFromIndex(0);
+ if (firstItem is null)
+ {
+ return false;
+ }
+
+ return e.TrySetNewFocusedElement(firstItem);
+ }
+
+ private static Button? FindFirstVisible(params Button[] buttons)
+ {
+ foreach (Button button in buttons)
+ {
+ if (button.Visibility == Visibility.Visible)
+ {
+ return button;
+ }
+ }
+
+ return null;
+ }
+
+ #endregion
+
+ #region Flyout Population
+
+ private void PopulateVersionFlyout()
+ => PopulateFlyout(
+ VersionFlyout,
+ ViewModel.SourceContainers,
+ s => s.Value?.Name ?? "Unknown",
+ s => ViewModel.SelectedSourceContainer = s);
+
+ private void PopulateAudioFlyout()
+ => PopulateFlyout(
+ AudioFlyout,
+ ViewModel.AudioStreams,
+ s => s.DisplayText,
+ s => ViewModel.SelectedAudioStream = s);
+
+ private void PopulateSubtitleFlyout()
+ => PopulateFlyout(
+ SubtitleFlyout,
+ ViewModel.SubtitleStreams,
+ s => s.DisplayText,
+ s => ViewModel.SelectedSubtitleStream = s);
+
+ private static void PopulateFlyout(MenuFlyout flyout, IEnumerable? items, Func textSelector, Action onSelect)
+ {
+ flyout.Items.Clear();
+ if (items is null)
+ {
+ return;
+ }
+
+ foreach (T item in items)
+ {
+ var menuItem = new MenuFlyoutItem { Text = textSelector(item) };
+ menuItem.Click += (s, e) => onSelect(item);
+ flyout.Items.Add(menuItem);
+ }
+ }
+
+ #endregion
}