Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions src/JellyBox/AppServices.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,10 @@ private AppServices()
// View Models
serviceCollection.AddTransient<HomeViewModel>();
serviceCollection.AddTransient<ItemDetailsViewModel>();
serviceCollection.AddTransient<LibraryViewModel>();
serviceCollection.AddTransient<LoginViewModel>();
serviceCollection.AddTransient<MainPageViewModel>();
serviceCollection.AddTransient<MoviesViewModel>();
serviceCollection.AddTransient<ServerSelectionViewModel>();
serviceCollection.AddTransient<ShowsViewModel>();
serviceCollection.AddTransient<VideoViewModel>();
serviceCollection.AddTransient<WebVideoViewModel>();

Expand Down
110 changes: 0 additions & 110 deletions src/JellyBox/Behaviors/HorizontalScrollOnFocusBehavior.cs

This file was deleted.

173 changes: 173 additions & 0 deletions src/JellyBox/Behaviors/ScrollOnFocusBehavior.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
using Microsoft.Xaml.Interactivity;
using Windows.Foundation;
using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls;
using Windows.UI.Xaml.Controls.Primitives;
using Windows.UI.Xaml.Input;

namespace JellyBox.Behaviors;

/// <summary>
/// Adjusts scroll position to keep focused items within the TV-safe zone.
/// Supports both horizontal carousels and full grid layouts.
/// </summary>
internal sealed class ScrollOnFocusBehavior : Behavior<ListViewBase>
{
private ScrollViewer? _scrollViewer;

/// <summary>
/// The minimum distance from the viewport edge that items should maintain.
/// </summary>
public double SafeZoneMargin { get; set; } = 48;

/// <summary>
/// Additional margin at the top of the viewport (e.g., for a title overlay).
/// Only used when <see cref="EnableVerticalScroll"/> is true.
/// </summary>
public double TopOffset { get; set; }

/// <summary>
/// Whether to adjust vertical scroll position. Default is false (horizontal only).
/// </summary>
public bool EnableVerticalScroll { get; set; }

/// <summary>
/// Whether to trap focus at the right edge. Default is true (for carousels).
/// Set to false for grids that should allow wrap navigation.
/// </summary>
public bool TrapFocusAtRightEdge { get; set; } = true;

protected override void OnAttached()
{
base.OnAttached();
AssociatedObject.Loaded += OnLoaded;
AssociatedObject.GotFocus += OnGotFocus;

if (TrapFocusAtRightEdge)
{
AssociatedObject.LosingFocus += OnLosingFocus;
}
}

protected override void OnDetaching()
{
base.OnDetaching();
AssociatedObject.Loaded -= OnLoaded;
AssociatedObject.GotFocus -= OnGotFocus;

if (TrapFocusAtRightEdge)
{
AssociatedObject.LosingFocus -= OnLosingFocus;
}
}

private void OnLoaded(object sender, RoutedEventArgs e)
{
_scrollViewer = AssociatedObject.FindDescendant<ScrollViewer>();
}

private void OnGotFocus(object sender, RoutedEventArgs e)
{
if (_scrollViewer is null)
{
return;
}

// Find the item container that belongs to this specific ListViewBase
// Both ListViewItem and GridViewItem inherit from SelectorItem
var container = (e.OriginalSource as DependencyObject)?.FindAncestor<SelectorItem>();

// Verify this container belongs to our AssociatedObject, not a nested list
if (container is not null && AssociatedObject.IndexFromContainer(container) >= 0)
{
AdjustScrollPosition(container);
}
}

private void OnLosingFocus(UIElement sender, LosingFocusEventArgs e)
{
// Only trap right navigation at the right edge
if (e.Direction != FocusNavigationDirection.Right
|| e.OldFocusedElement is not DependencyObject oldElement)
{
return;
}

// Find the container for the element losing focus
var container = oldElement.FindAncestor<SelectorItem>();
if (container is null)
{
return;
}

// Check if we're at the right edge
int focusedIndex = AssociatedObject.IndexFromContainer(container);
if (focusedIndex != AssociatedObject.Items.Count - 1)
{
return;
}

// Cancel if focus is trying to leave this list
if (e.NewFocusedElement is DependencyObject newElement
&& newElement.FindAncestor<ListViewBase>() != AssociatedObject)
{
e.TryCancel();
}
}

private void AdjustScrollPosition(FrameworkElement container)
{
if (_scrollViewer is null)
{
return;
}

var transform = container.TransformToVisual(_scrollViewer);
var position = transform.TransformPoint(new Point(0, 0));

double? newHorizontalOffset = null;
double? newVerticalOffset = null;

// Horizontal adjustment
double itemLeft = position.X;
double itemRight = position.X + container.ActualWidth;
double viewportWidth = _scrollViewer.ViewportWidth;

if (itemLeft < SafeZoneMargin)
{
newHorizontalOffset = Math.Max(0, _scrollViewer.HorizontalOffset - (SafeZoneMargin - itemLeft));
}
else if (itemRight > viewportWidth - SafeZoneMargin)
{
newHorizontalOffset = Math.Min(
_scrollViewer.ScrollableWidth,
_scrollViewer.HorizontalOffset + (itemRight - (viewportWidth - SafeZoneMargin)));
}

// Vertical adjustment (only if enabled)
if (EnableVerticalScroll)
{
double topMargin = SafeZoneMargin + TopOffset;
double itemTop = position.Y;
double itemBottom = position.Y + container.ActualHeight;
double viewportHeight = _scrollViewer.ViewportHeight;

if (itemTop < topMargin)
{
newVerticalOffset = Math.Max(0, _scrollViewer.VerticalOffset - (topMargin - itemTop));
}
else if (itemBottom > viewportHeight - SafeZoneMargin)
{
newVerticalOffset = Math.Min(
_scrollViewer.ScrollableHeight,
_scrollViewer.VerticalOffset + (itemBottom - (viewportHeight - SafeZoneMargin)));
}
}

// Apply scroll with smooth animation
if (newHorizontalOffset.HasValue || newVerticalOffset.HasValue)
{
_scrollViewer.ChangeView(newHorizontalOffset, newVerticalOffset, null, disableAnimation: false);
}
}
}
2 changes: 1 addition & 1 deletion src/JellyBox/Resources/Templates.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
XYFocusKeyboardNavigation="Enabled">
<Interactivity:Interaction.Behaviors>
<Behaviors:ListViewBaseCommandBehavior />
<Behaviors:HorizontalScrollOnFocusBehavior SafeZoneMargin="52" />
<Behaviors:ScrollOnFocusBehavior SafeZoneMargin="52" />
</Interactivity:Interaction.Behaviors>
<ListView.ItemsPanel>
<ItemsPanelTemplate>
Expand Down
4 changes: 2 additions & 2 deletions src/JellyBox/Services/NavigationManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,13 +85,13 @@ public void NavigateToItem(BaseItemDto item)
case BaseItemDto_CollectionType.Movies:
{
CurrentItem = itemId;
NavigateContentFrame<Movies>(new Movies.Parameters(itemId));
NavigateContentFrame<Library>(new Library.Parameters(itemId, BaseItemKind.Movie, item.Name ?? "Movies"));
return;
}
case BaseItemDto_CollectionType.Tvshows:
{
CurrentItem = itemId;
NavigateContentFrame<Shows>(new Shows.Parameters(itemId));
NavigateContentFrame<Library>(new Library.Parameters(itemId, BaseItemKind.Series, item.Name ?? "TV Shows"));
return;
}
}
Expand Down
Loading