From 913e169b665c92fb427664fad8a0255a32023d94 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 3 Dec 2025 05:27:08 +0000 Subject: [PATCH 1/3] Initial plan From 83ce43be909928c793ac83bd2ba6d3fd8aab31a6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 3 Dec 2025 05:34:44 +0000 Subject: [PATCH 2/3] Fix infinite scroll pagination for albums - Add paginated API method GetAlbumsPagedAsync to IJellyfinApiService and JellyFinApiService - Add pagination state and LoadMoreAlbumsCommand to LibraryViewModel - Update AlbumsListView.axaml with ListBox name and loading indicator - Improve ScrollViewer detection in AlbumsListView.axaml.cs to search more thoroughly (descendants, ancestors, and visual tree traversal) Co-authored-by: adrianstevens <5965865+adrianstevens@users.noreply.github.com> --- .../Interfaces/IJellyfinApiService.cs | 3 + .../Services/JellyFinApiService.cs | 33 +++++ .../ViewModels/LibraryViewModel.cs | 120 ++++++++++++++++-- .../Views/UserControls/AlbumsListView.axaml | 13 ++ .../UserControls/AlbumsListView.axaml.cs | 80 ++++++++++++ 5 files changed, 235 insertions(+), 14 deletions(-) diff --git a/Source/JamBox.Core/Services/Interfaces/IJellyfinApiService.cs b/Source/JamBox.Core/Services/Interfaces/IJellyfinApiService.cs index 49bc060..a90861b 100644 --- a/Source/JamBox.Core/Services/Interfaces/IJellyfinApiService.cs +++ b/Source/JamBox.Core/Services/Interfaces/IJellyfinApiService.cs @@ -26,6 +26,9 @@ public interface IJellyfinApiService Task> GetAlbumsAsync(string libraryId); + Task<(List Albums, int TotalCount)> GetAlbumsPagedAsync( + string libraryId, int startIndex = 0, int limit = 50, string? sortBy = null, string? sortOrder = null); + Task> GetAlbumsByArtistAsync(string artistId); Task> GetTracksAsync(string libraryId); diff --git a/Source/JamBox.Core/Services/JellyFinApiService.cs b/Source/JamBox.Core/Services/JellyFinApiService.cs index 0f6655b..a0b4ede 100644 --- a/Source/JamBox.Core/Services/JellyFinApiService.cs +++ b/Source/JamBox.Core/Services/JellyFinApiService.cs @@ -267,6 +267,39 @@ public async Task> GetAlbumsAsync(string libraryId) return result?.Items ?? []; } + public async Task<(List Albums, int TotalCount)> GetAlbumsPagedAsync( + string libraryId, int startIndex = 0, int limit = 50, string? sortBy = null, string? sortOrder = null) + { + if (!IsAuthenticated || _httpClient is null) + { + return ([], 0); + } + + var queryString = new StringBuilder("Items") + .Append("?IncludeItemTypes=MusicAlbum") + .Append($"&ParentId={libraryId}") + .Append("&Recursive=true") + .Append($"&StartIndex={startIndex}") + .Append($"&Limit={limit}"); + + if (!string.IsNullOrEmpty(sortBy)) + { + queryString.Append($"&SortBy={sortBy}"); + } + + if (!string.IsNullOrEmpty(sortOrder)) + { + queryString.Append($"&SortOrder={sortOrder}"); + } + + var response = await _httpClient.GetAsync(queryString.ToString()); + response.EnsureSuccessStatusCode(); + var stream = await response.Content.ReadAsStreamAsync(); + var result = await JsonSerializer.DeserializeAsync(stream, AppJsonSerializerContext.Default.JellyfinResponseAlbum); + + return (result?.Items ?? [], result?.TotalRecordCount ?? 0); + } + public async Task> GetAlbumsByArtistAsync(string artistId) { if (!IsAuthenticated || _httpClient is null) diff --git a/Source/JamBox.Core/ViewModels/LibraryViewModel.cs b/Source/JamBox.Core/ViewModels/LibraryViewModel.cs index 38f3f6b..2d04aa9 100644 --- a/Source/JamBox.Core/ViewModels/LibraryViewModel.cs +++ b/Source/JamBox.Core/ViewModels/LibraryViewModel.cs @@ -14,6 +14,20 @@ public class LibraryViewModel : ViewModelBase private MediaCollectionItem? _selectedLibrary; + // Pagination state for albums + private int _albumsStartIndex = 0; + private int _totalAlbumCount = 0; + private const int AlbumsPageSize = 50; + private bool _isLoadingMoreAlbums = false; + + public bool HasMoreAlbums => Albums.Count < _totalAlbumCount; + + public bool IsLoadingMoreAlbums + { + get => _isLoadingMoreAlbums; + set => this.RaiseAndSetIfChanged(ref _isLoadingMoreAlbums, value); + } + /// /// The PlaybackViewModel handles all playback-related state and commands. /// @@ -115,6 +129,7 @@ public string TrackSortStatus public ReactiveCommand ResetArtistsSelectionCommand { get; } public ReactiveCommand ResetAlbumSelectionCommand { get; } public ReactiveCommand JukeBoxModeCommand { get; } + public ReactiveCommand LoadMoreAlbumsCommand { get; } public LibraryViewModel( PlaybackViewModel playbackViewModel, @@ -134,6 +149,7 @@ public LibraryViewModel( ResetArtistsSelectionCommand = ReactiveCommand.CreateFromTask(ResetArtistsSelectionAsync); ResetAlbumSelectionCommand = ReactiveCommand.CreateFromTask(ResetAlbumSelectionAsync); + LoadMoreAlbumsCommand = ReactiveCommand.CreateFromTask(LoadMoreAlbumsAsync); var canPlay = this.WhenAnyValue(vm => vm.SelectedTrack).Select(t => t != null); PlayCommand = ReactiveCommand.CreateFromTask(PlaySelectedTrackAsync, canPlay); @@ -204,42 +220,118 @@ private async Task LoadAlbumsAsync() { if (_selectedLibrary is null) { return; } - List? albums = []; + // Reset pagination state when loading fresh + _albumsStartIndex = 0; + _totalAlbumCount = 0; + + List? albums; if (SelectedArtist == null) { - albums = await _jellyfinApiService.GetAlbumsAsync(_selectedLibrary.Id); + // Use paginated API for library albums with server-side sorting + var (sortBy, sortOrder) = GetSortParameters(); + var (pagedAlbums, totalCount) = await _jellyfinApiService.GetAlbumsPagedAsync( + _selectedLibrary.Id, _albumsStartIndex, AlbumsPageSize, sortBy, sortOrder); + albums = pagedAlbums; + _totalAlbumCount = totalCount; } else { + // For artist-specific albums, load all (typically fewer albums) albums = await _jellyfinApiService.GetAlbumsByArtistAsync(SelectedArtist.Id); + _totalAlbumCount = albums.Count; + + // Apply client-side sorting for artist albums + if (AlbumSortStatus == "A-Z") + { + albums = albums.OrderBy(a => a.Title).ToList(); + } + else if (AlbumSortStatus == "BY RELEASE YEAR") + { + albums = albums.OrderByDescending(a => a.ProductionYear).ToList(); + } + else if (AlbumSortStatus == "BY RATING") + { + albums = albums.OrderByDescending(a => a.UserData.IsFavorite).ToList(); + } } - if (AlbumSortStatus == "A-Z") + // Prepare all album data first (URLs, subtitles) before updating collection + PrepareAlbumData(albums); + + // Batch update: replace entire collection at once to avoid multiple UI updates + Albums = new ObservableCollection(albums); + this.RaisePropertyChanged(nameof(Albums)); + this.RaisePropertyChanged(nameof(HasMoreAlbums)); + + UpdateAlbumCount(); + } + + private async Task LoadMoreAlbumsAsync() + { + if (_selectedLibrary is null || SelectedArtist != null || !HasMoreAlbums || IsLoadingMoreAlbums) { - albums = albums.OrderBy(a => a.Title).ToList(); + return; } - else if (AlbumSortStatus == "BY RELEASE YEAR") + + try { - albums = albums.OrderByDescending(a => a.ProductionYear).ToList(); + IsLoadingMoreAlbums = true; + + _albumsStartIndex += AlbumsPageSize; + + var (sortBy, sortOrder) = GetSortParameters(); + var (albums, _) = await _jellyfinApiService.GetAlbumsPagedAsync( + _selectedLibrary.Id, _albumsStartIndex, AlbumsPageSize, sortBy, sortOrder); + + // Prepare album data + PrepareAlbumData(albums); + + // Append to existing collection + foreach (var album in albums) + { + Albums.Add(album); + } + + this.RaisePropertyChanged(nameof(HasMoreAlbums)); + UpdateAlbumCount(); } - else if (AlbumSortStatus == "BY RATING") + finally { - albums = albums.OrderByDescending(a => a.UserData.IsFavorite).ToList(); + IsLoadingMoreAlbums = false; } + } - // Prepare all album data first (URLs, subtitles) before updating collection + private (string? sortBy, string? sortOrder) GetSortParameters() + { + return AlbumSortStatus switch + { + "A-Z" => ("SortName", "Ascending"), + "BY RELEASE YEAR" => ("ProductionYear", "Descending"), + "BY RATING" => ("IsFavorite", "Descending"), + _ => (null, null) + }; + } + + private void PrepareAlbumData(List albums) + { foreach (var album in albums) { album.AlbumArtUrl = album.GetPrimaryImageUrl(_jellyfinApiService.ServerUrl ?? "", _jellyfinApiService.CurrentAccessToken ?? "") ?? ""; album.AlbumSubtitle = SelectedArtist == null ? album.AlbumArtist : album.ProductionYear.ToString(); } + } - // Batch update: replace entire collection at once to avoid multiple UI updates - Albums = new ObservableCollection(albums); - this.RaisePropertyChanged(nameof(Albums)); - - AlbumCount = $"{Albums.Count} ALBUMS"; + private void UpdateAlbumCount() + { + if (HasMoreAlbums) + { + AlbumCount = $"{Albums.Count} OF {_totalAlbumCount} ALBUMS"; + } + else + { + AlbumCount = $"{Albums.Count} ALBUMS"; + } } private async Task LoadTracksAsync() diff --git a/Source/JamBox.Core/Views/UserControls/AlbumsListView.axaml b/Source/JamBox.Core/Views/UserControls/AlbumsListView.axaml index 2e71666..911fa0a 100644 --- a/Source/JamBox.Core/Views/UserControls/AlbumsListView.axaml +++ b/Source/JamBox.Core/Views/UserControls/AlbumsListView.axaml @@ -28,6 +28,7 @@ @@ -84,6 +85,18 @@ + + + + + \ No newline at end of file diff --git a/Source/JamBox.Core/Views/UserControls/AlbumsListView.axaml.cs b/Source/JamBox.Core/Views/UserControls/AlbumsListView.axaml.cs index 3183cc1..7049b3c 100644 --- a/Source/JamBox.Core/Views/UserControls/AlbumsListView.axaml.cs +++ b/Source/JamBox.Core/Views/UserControls/AlbumsListView.axaml.cs @@ -1,11 +1,91 @@ +using Avalonia; using Avalonia.Controls; +using Avalonia.VisualTree; +using JamBox.Core.ViewModels; +using System.Reactive.Linq; namespace JamBox.Core.Views.UserControls; public partial class AlbumsListView : UserControl { + private const double ScrollThresholdPixels = 200; + private ScrollViewer? _scrollViewer; + public AlbumsListView() { InitializeComponent(); + + this.AttachedToVisualTree += OnAttachedToVisualTree; + this.DetachedFromVisualTree += OnDetachedFromVisualTree; + } + + private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + // The ListBox with WrapPanel may not have a standard internal ScrollViewer, + // so we need to search more thoroughly + var listBox = this.FindControl("AlbumsListBox"); + + // First try to find ScrollViewer in descendants of the ListBox + _scrollViewer = listBox?.GetVisualDescendants().OfType().FirstOrDefault(); + + // If not found in descendants, try ancestors (in case the control is inside a parent ScrollViewer) + if (_scrollViewer == null) + { + _scrollViewer = this.GetVisualAncestors().OfType().FirstOrDefault(); + } + + // As a last resort, search the entire visual tree from the ListBox upward + if (_scrollViewer == null && listBox != null) + { + // Walk up to find any ScrollViewer that might contain us + var current = listBox.GetVisualParent(); + while (current != null) + { + if (current is ScrollViewer sv) + { + _scrollViewer = sv; + break; + } + current = current.GetVisualParent(); + } + } + + if (_scrollViewer != null) + { + _scrollViewer.ScrollChanged += OnScrollChanged; + System.Diagnostics.Debug.WriteLine($"AlbumsListView: Found ScrollViewer: {_scrollViewer.GetType().Name}"); + } + else + { + System.Diagnostics.Debug.WriteLine("AlbumsListView: WARNING - No ScrollViewer found!"); + } + } + + private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + if (_scrollViewer != null) + { + _scrollViewer.ScrollChanged -= OnScrollChanged; + _scrollViewer = null; + } + } + + private void OnScrollChanged(object? sender, ScrollChangedEventArgs e) + { + if (_scrollViewer == null) return; + + // Check if we're near the bottom + var distanceFromBottom = _scrollViewer.Extent.Height - _scrollViewer.Offset.Y - _scrollViewer.Viewport.Height; + + System.Diagnostics.Debug.WriteLine($"Scroll: Extent={_scrollViewer.Extent.Height}, Offset={_scrollViewer.Offset.Y}, Viewport={_scrollViewer.Viewport.Height}, DistanceFromBottom={distanceFromBottom}"); + + if (distanceFromBottom < ScrollThresholdPixels) + { + if (DataContext is LibraryViewModel vm && vm.HasMoreAlbums && !vm.IsLoadingMoreAlbums) + { + System.Diagnostics.Debug.WriteLine("Triggering LoadMoreAlbumsCommand"); + vm.LoadMoreAlbumsCommand?.Execute().Subscribe(_ => { }, _ => { }); + } + } } } \ No newline at end of file From 1b2e822216c6b72e87fe8c4887fd8f642a3c34ed Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 3 Dec 2025 05:37:53 +0000 Subject: [PATCH 3/3] Address code review: wrap debug statements in #if DEBUG, improve error handling Co-authored-by: adrianstevens <5965865+adrianstevens@users.noreply.github.com> --- .../Views/UserControls/AlbumsListView.axaml.cs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/Source/JamBox.Core/Views/UserControls/AlbumsListView.axaml.cs b/Source/JamBox.Core/Views/UserControls/AlbumsListView.axaml.cs index 7049b3c..b8c0358 100644 --- a/Source/JamBox.Core/Views/UserControls/AlbumsListView.axaml.cs +++ b/Source/JamBox.Core/Views/UserControls/AlbumsListView.axaml.cs @@ -53,12 +53,16 @@ private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArg if (_scrollViewer != null) { _scrollViewer.ScrollChanged += OnScrollChanged; +#if DEBUG System.Diagnostics.Debug.WriteLine($"AlbumsListView: Found ScrollViewer: {_scrollViewer.GetType().Name}"); +#endif } +#if DEBUG else { System.Diagnostics.Debug.WriteLine("AlbumsListView: WARNING - No ScrollViewer found!"); } +#endif } private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e) @@ -77,14 +81,20 @@ private void OnScrollChanged(object? sender, ScrollChangedEventArgs e) // Check if we're near the bottom var distanceFromBottom = _scrollViewer.Extent.Height - _scrollViewer.Offset.Y - _scrollViewer.Viewport.Height; +#if DEBUG System.Diagnostics.Debug.WriteLine($"Scroll: Extent={_scrollViewer.Extent.Height}, Offset={_scrollViewer.Offset.Y}, Viewport={_scrollViewer.Viewport.Height}, DistanceFromBottom={distanceFromBottom}"); +#endif if (distanceFromBottom < ScrollThresholdPixels) { if (DataContext is LibraryViewModel vm && vm.HasMoreAlbums && !vm.IsLoadingMoreAlbums) { +#if DEBUG System.Diagnostics.Debug.WriteLine("Triggering LoadMoreAlbumsCommand"); - vm.LoadMoreAlbumsCommand?.Execute().Subscribe(_ => { }, _ => { }); +#endif + vm.LoadMoreAlbumsCommand?.Execute().Subscribe( + _ => { }, + ex => Console.WriteLine($"Error loading more albums: {ex.Message}")); } } }