diff --git a/src/JellyBox/AppServices.cs b/src/JellyBox/AppServices.cs index 7d796c7..f71cd22 100644 --- a/src/JellyBox/AppServices.cs +++ b/src/JellyBox/AppServices.cs @@ -68,11 +68,10 @@ private AppServices() // View Models serviceCollection.AddTransient(); serviceCollection.AddTransient(); + serviceCollection.AddTransient(); serviceCollection.AddTransient(); serviceCollection.AddTransient(); - serviceCollection.AddTransient(); serviceCollection.AddTransient(); - serviceCollection.AddTransient(); serviceCollection.AddTransient(); serviceCollection.AddTransient(); diff --git a/src/JellyBox/Behaviors/HorizontalScrollOnFocusBehavior.cs b/src/JellyBox/Behaviors/HorizontalScrollOnFocusBehavior.cs deleted file mode 100644 index 1d1e9f5..0000000 --- a/src/JellyBox/Behaviors/HorizontalScrollOnFocusBehavior.cs +++ /dev/null @@ -1,110 +0,0 @@ -using Microsoft.Xaml.Interactivity; -using Windows.UI.Xaml; -using Windows.UI.Xaml.Controls; -using Windows.UI.Xaml.Input; - -namespace JellyBox.Behaviors; - -/// -/// Manages focus behavior for horizontal lists: -/// - Adjusts scroll position to keep focused items within the TV-safe zone -/// - Prevents focus from escaping at the right edge -/// -internal sealed class HorizontalScrollOnFocusBehavior : Behavior -{ - private ScrollViewer? _scrollViewer; - - /// - /// The minimum distance from the viewport edge that items should maintain. - /// - 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 = AssociatedObject.FindDescendant(); - } - - private void OnGotFocus(object sender, RoutedEventArgs e) - { - if (_scrollViewer is null) - { - return; - } - - // Find the focused item container - FrameworkElement? container = - (e.OriginalSource as DependencyObject)?.FindAncestor() as FrameworkElement - ?? (e.OriginalSource as DependencyObject)?.FindAncestor(); - - if (container is not null) - { - 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 FrameworkElement oldElement) - { - return; - } - - // Check if we're at the right edge - int focusedIndex = AssociatedObject.IndexFromContainer(oldElement); - if (focusedIndex < 0 || focusedIndex < AssociatedObject.Items.Count - 1) - { - return; - } - - // Cancel if focus is trying to leave this list - if (e.NewFocusedElement is DependencyObject newElement - && newElement.FindAncestor() != AssociatedObject) - { - e.TryCancel(); - } - } - - private void AdjustScrollPosition(FrameworkElement container) - { - if (_scrollViewer is null) - { - return; - } - - var transform = container.TransformToVisual(_scrollViewer); - var position = transform.TransformPoint(new Windows.Foundation.Point(0, 0)); - - double itemLeft = position.X; - double itemRight = position.X + container.ActualWidth; - double viewportWidth = _scrollViewer.ViewportWidth; - - if (itemLeft < SafeZoneMargin) - { - double newOffset = _scrollViewer.HorizontalOffset - (SafeZoneMargin - itemLeft); - _scrollViewer.ChangeView(Math.Max(0, newOffset), null, null, false); - } - else if (itemRight > viewportWidth - SafeZoneMargin) - { - double newOffset = _scrollViewer.HorizontalOffset + (itemRight - (viewportWidth - SafeZoneMargin)); - _scrollViewer.ChangeView(Math.Min(_scrollViewer.ScrollableWidth, newOffset), null, null, false); - } - } -} diff --git a/src/JellyBox/Behaviors/ScrollOnFocusBehavior.cs b/src/JellyBox/Behaviors/ScrollOnFocusBehavior.cs new file mode 100644 index 0000000..93f28de --- /dev/null +++ b/src/JellyBox/Behaviors/ScrollOnFocusBehavior.cs @@ -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; + +/// +/// Adjusts scroll position to keep focused items within the TV-safe zone. +/// Supports both horizontal carousels and full grid layouts. +/// +internal sealed class ScrollOnFocusBehavior : Behavior +{ + private ScrollViewer? _scrollViewer; + + /// + /// The minimum distance from the viewport edge that items should maintain. + /// + public double SafeZoneMargin { get; set; } = 48; + + /// + /// Additional margin at the top of the viewport (e.g., for a title overlay). + /// Only used when is true. + /// + public double TopOffset { get; set; } + + /// + /// Whether to adjust vertical scroll position. Default is false (horizontal only). + /// + public bool EnableVerticalScroll { get; set; } + + /// + /// Whether to trap focus at the right edge. Default is true (for carousels). + /// Set to false for grids that should allow wrap navigation. + /// + 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(); + } + + 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(); + + // 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(); + 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() != 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); + } + } +} diff --git a/src/JellyBox/Resources/Templates.xaml b/src/JellyBox/Resources/Templates.xaml index f3b7b62..86d9400 100644 --- a/src/JellyBox/Resources/Templates.xaml +++ b/src/JellyBox/Resources/Templates.xaml @@ -35,7 +35,7 @@ XYFocusKeyboardNavigation="Enabled"> - + diff --git a/src/JellyBox/Services/NavigationManager.cs b/src/JellyBox/Services/NavigationManager.cs index 17c568a..09880bd 100644 --- a/src/JellyBox/Services/NavigationManager.cs +++ b/src/JellyBox/Services/NavigationManager.cs @@ -85,13 +85,13 @@ public void NavigateToItem(BaseItemDto item) case BaseItemDto_CollectionType.Movies: { CurrentItem = itemId; - NavigateContentFrame(new Movies.Parameters(itemId)); + NavigateContentFrame(new Library.Parameters(itemId, BaseItemKind.Movie, item.Name ?? "Movies")); return; } case BaseItemDto_CollectionType.Tvshows: { CurrentItem = itemId; - NavigateContentFrame(new Shows.Parameters(itemId)); + NavigateContentFrame(new Library.Parameters(itemId, BaseItemKind.Series, item.Name ?? "TV Shows")); return; } } diff --git a/src/JellyBox/ViewModels/MoviesViewModel.cs b/src/JellyBox/ViewModels/LibraryViewModel.cs similarity index 56% rename from src/JellyBox/ViewModels/MoviesViewModel.cs rename to src/JellyBox/ViewModels/LibraryViewModel.cs index e5d014f..28b5ca3 100644 --- a/src/JellyBox/ViewModels/MoviesViewModel.cs +++ b/src/JellyBox/ViewModels/LibraryViewModel.cs @@ -1,4 +1,3 @@ -using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using JellyBox.Models; using JellyBox.Views; @@ -8,20 +7,22 @@ namespace JellyBox.ViewModels; #pragma warning disable CA1812 // Avoid uninstantiated internal classes. Used via dependency injection. -internal sealed partial class MoviesViewModel : ObservableObject, ILoadingViewModel +internal sealed partial class LibraryViewModel : ObservableObject, ILoadingViewModel #pragma warning restore CA1812 // Avoid uninstantiated internal classes { private readonly JellyfinApiClient _jellyfinApiClient; private readonly CardFactory _cardFactory; - private Guid? _collectionItemId; - [ObservableProperty] public partial bool IsLoading { get; set; } - public ObservableCollection Movies { get; } = new(); + [ObservableProperty] + public partial string? Title { get; set; } + + [ObservableProperty] + public partial IReadOnlyList? Items { get; set; } - public MoviesViewModel( + public LibraryViewModel( JellyfinApiClient jellyfinApiClient, CardFactory cardFactory) { @@ -29,30 +30,29 @@ public MoviesViewModel( _cardFactory = cardFactory; } - public void HandleParameters(Movies.Parameters parameters) + public void HandleParameters(Library.Parameters parameters) { - _collectionItemId = parameters.CollectionItemId; - _ = InitializeMoviesAsync(); + Title = parameters.Title; + Items = null; + _ = InitializeAsync(parameters.CollectionItemId, parameters.ItemKind); } - private async Task InitializeMoviesAsync() + private async Task InitializeAsync(Guid collectionItemId, BaseItemKind itemKind) { - // Uninitialized - if (_collectionItemId is null) - { - return; - } - IsLoading = true; + try { // TODO: Paginate? BaseItemDtoQueryResult? result = await _jellyfinApiClient.Items.GetAsync(parameters => { - parameters.QueryParameters.ParentId = _collectionItemId; - parameters.QueryParameters.SortBy = [ItemSortBy.SortName, ItemSortBy.ProductionYear]; + parameters.QueryParameters.ParentId = collectionItemId; + parameters.QueryParameters.SortBy = itemKind == BaseItemKind.Movie + ? [ItemSortBy.SortName, ItemSortBy.ProductionYear] + : [ItemSortBy.SortName]; parameters.QueryParameters.SortOrder = [SortOrder.Ascending]; - parameters.QueryParameters.IncludeItemTypes = [BaseItemKind.Movie]; + parameters.QueryParameters.IncludeItemTypes = [itemKind]; + parameters.QueryParameters.Recursive = itemKind == BaseItemKind.Series; parameters.QueryParameters.Fields = [ItemFields.PrimaryImageAspectRatio, ItemFields.MediaSourceCount]; parameters.QueryParameters.ImageTypeLimit = 1; parameters.QueryParameters.EnableImageTypes = [ImageType.Primary, ImageType.Backdrop, ImageType.Banner, ImageType.Thumb]; @@ -60,6 +60,7 @@ private async Task InitializeMoviesAsync() if (result?.Items is not null) { + List items = new(result.Items.Count); foreach (BaseItemDto item in result.Items) { if (!item.Id.HasValue) @@ -67,13 +68,19 @@ private async Task InitializeMoviesAsync() continue; } - Movies.Add(_cardFactory.CreateFromItem(item, CardShape.Portrait, preferredImageType: null)); + items.Add(_cardFactory.CreateFromItem(item, CardShape.Portrait, preferredImageType: null)); } + + Items = items; } } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error in LibraryViewModel.InitializeAsync: {ex}"); + } finally { IsLoading = false; } } -} \ No newline at end of file +} diff --git a/src/JellyBox/ViewModels/ShowsViewModel.cs b/src/JellyBox/ViewModels/ShowsViewModel.cs deleted file mode 100644 index 7be5d8f..0000000 --- a/src/JellyBox/ViewModels/ShowsViewModel.cs +++ /dev/null @@ -1,80 +0,0 @@ -using System.Collections.ObjectModel; -using CommunityToolkit.Mvvm.ComponentModel; -using JellyBox.Models; -using JellyBox.Views; -using Jellyfin.Sdk; -using Jellyfin.Sdk.Generated.Models; - -namespace JellyBox.ViewModels; - -#pragma warning disable CA1812 // Avoid uninstantiated internal classes. Used via dependency injection. -internal sealed partial class ShowsViewModel : ObservableObject, ILoadingViewModel -#pragma warning restore CA1812 // Avoid uninstantiated internal classes -{ - private readonly JellyfinApiClient _jellyfinApiClient; - private readonly CardFactory _cardFactory; - - private Guid? _collectionItemId; - - [ObservableProperty] - public partial bool IsLoading { get; set; } - - public ObservableCollection Shows { get; } = new(); - - public ShowsViewModel( - JellyfinApiClient jellyfinApiClient, - CardFactory cardFactory) - { - _jellyfinApiClient = jellyfinApiClient; - _cardFactory = cardFactory; - } - - public void HandleParameters(Shows.Parameters parameters) - { - _collectionItemId = parameters.CollectionItemId; - _ = InitializeShowsAsync(); - } - - private async Task InitializeShowsAsync() - { - // Uninitialized - if (_collectionItemId is null) - { - return; - } - - IsLoading = true; - try - { - // TODO: Paginate? - BaseItemDtoQueryResult? result = await _jellyfinApiClient.Items.GetAsync(parameters => - { - parameters.QueryParameters.ParentId = _collectionItemId; - parameters.QueryParameters.SortBy = [ItemSortBy.SortName]; - parameters.QueryParameters.SortOrder = [SortOrder.Ascending]; - parameters.QueryParameters.IncludeItemTypes = [BaseItemKind.Series]; - parameters.QueryParameters.Recursive = true; - parameters.QueryParameters.Fields = [ItemFields.PrimaryImageAspectRatio]; - parameters.QueryParameters.ImageTypeLimit = 1; - parameters.QueryParameters.EnableImageTypes = [ImageType.Primary, ImageType.Backdrop, ImageType.Banner, ImageType.Thumb]; - }); - - if (result?.Items is not null) - { - foreach (BaseItemDto item in result.Items) - { - if (!item.Id.HasValue) - { - continue; - } - - Shows.Add(_cardFactory.CreateFromItem(item, CardShape.Portrait, preferredImageType: null)); - } - } - } - finally - { - IsLoading = false; - } - } -} \ No newline at end of file diff --git a/src/JellyBox/Views/ItemDetails.xaml b/src/JellyBox/Views/ItemDetails.xaml index 0abccbe..336cd26 100644 --- a/src/JellyBox/Views/ItemDetails.xaml +++ b/src/JellyBox/Views/ItemDetails.xaml @@ -14,33 +14,18 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d" Background="{StaticResource BackgroundBase}"> - - - - - - - - - - - - - - - - - + + + + + + + + + - diff --git a/src/JellyBox/Views/Library.xaml b/src/JellyBox/Views/Library.xaml new file mode 100644 index 0000000..d6d3f9f --- /dev/null +++ b/src/JellyBox/Views/Library.xaml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/JellyBox/Views/Movies.xaml.cs b/src/JellyBox/Views/Library.xaml.cs similarity index 54% rename from src/JellyBox/Views/Movies.xaml.cs rename to src/JellyBox/Views/Library.xaml.cs index 968015f..07c145c 100644 --- a/src/JellyBox/Views/Movies.xaml.cs +++ b/src/JellyBox/Views/Library.xaml.cs @@ -1,22 +1,22 @@ -using JellyBox.ViewModels; +using JellyBox.ViewModels; +using Jellyfin.Sdk.Generated.Models; using Microsoft.Extensions.DependencyInjection; using Windows.UI.Xaml.Controls; using Windows.UI.Xaml.Navigation; namespace JellyBox.Views; -internal sealed partial class Movies : Page +internal sealed partial class Library : Page { - public Movies() + public Library() { InitializeComponent(); - - ViewModel = AppServices.Instance.ServiceProvider.GetRequiredService(); + ViewModel = AppServices.Instance.ServiceProvider.GetRequiredService(); } - internal MoviesViewModel ViewModel { get; } + internal LibraryViewModel ViewModel { get; } protected override void OnNavigatedTo(NavigationEventArgs e) => ViewModel.HandleParameters((Parameters)e.Parameter); - internal sealed record Parameters(Guid CollectionItemId); + internal sealed record Parameters(Guid CollectionItemId, BaseItemKind ItemKind, string Title); } diff --git a/src/JellyBox/Views/Login.xaml b/src/JellyBox/Views/Login.xaml index f8faf04..14d4f67 100644 --- a/src/JellyBox/Views/Login.xaml +++ b/src/JellyBox/Views/Login.xaml @@ -10,76 +10,62 @@ mc:Ignorable="d" Background="{StaticResource BackgroundBase}"> - - - - - - - - - - - - - - - - - - -