From 2358ba99bf5c3626617e709777c9831d4499246d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 3 Dec 2025 00:57:02 +0000 Subject: [PATCH 1/3] Initial plan From 9420245ab8343709bb04aead4630de41b1b5e9c5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 3 Dec 2025 01:04:36 +0000 Subject: [PATCH 2/3] Implement infinite scroll pagination for albums 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 | 31 +++++ 5 files changed, 186 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..4b4b1ab 100644 --- a/Source/JamBox.Core/Views/UserControls/AlbumsListView.axaml.cs +++ b/Source/JamBox.Core/Views/UserControls/AlbumsListView.axaml.cs @@ -1,4 +1,7 @@ using Avalonia.Controls; +using Avalonia.VisualTree; +using JamBox.Core.ViewModels; +using System.Reactive.Linq; namespace JamBox.Core.Views.UserControls; @@ -7,5 +10,33 @@ public partial class AlbumsListView : UserControl public AlbumsListView() { InitializeComponent(); + + // Find the ScrollViewer inside the ListBox and subscribe to scroll events + this.AttachedToVisualTree += (_, _) => + { + var listBox = this.FindControl("AlbumsListBox"); + var scrollViewer = listBox?.GetVisualDescendants().OfType().FirstOrDefault(); + + if (scrollViewer != null) + { + scrollViewer.ScrollChanged += OnScrollChanged; + } + }; + } + + private void OnScrollChanged(object? sender, ScrollChangedEventArgs e) + { + if (sender is not ScrollViewer sv) return; + + // Check if we're near the bottom (within 200 pixels) + var distanceFromBottom = sv.Extent.Height - sv.Offset.Y - sv.Viewport.Height; + + if (distanceFromBottom < 200) + { + if (DataContext is LibraryViewModel vm && vm.HasMoreAlbums && !vm.IsLoadingMoreAlbums) + { + vm.LoadMoreAlbumsCommand?.Execute().Subscribe(); + } + } } } \ No newline at end of file From 43e614e6ca65c4c012cdedba7fd037e51ed8f097 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 3 Dec 2025 01:07:41 +0000 Subject: [PATCH 3/3] Extract scroll threshold to constant and improve error handling Co-authored-by: adrianstevens <5965865+adrianstevens@users.noreply.github.com> --- .../Views/UserControls/AlbumsListView.axaml.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Source/JamBox.Core/Views/UserControls/AlbumsListView.axaml.cs b/Source/JamBox.Core/Views/UserControls/AlbumsListView.axaml.cs index 4b4b1ab..48870d4 100644 --- a/Source/JamBox.Core/Views/UserControls/AlbumsListView.axaml.cs +++ b/Source/JamBox.Core/Views/UserControls/AlbumsListView.axaml.cs @@ -7,6 +7,8 @@ namespace JamBox.Core.Views.UserControls; public partial class AlbumsListView : UserControl { + private const double ScrollThresholdPixels = 200; + public AlbumsListView() { InitializeComponent(); @@ -28,14 +30,14 @@ private void OnScrollChanged(object? sender, ScrollChangedEventArgs e) { if (sender is not ScrollViewer sv) return; - // Check if we're near the bottom (within 200 pixels) + // Check if we're near the bottom var distanceFromBottom = sv.Extent.Height - sv.Offset.Y - sv.Viewport.Height; - if (distanceFromBottom < 200) + if (distanceFromBottom < ScrollThresholdPixels) { if (DataContext is LibraryViewModel vm && vm.HasMoreAlbums && !vm.IsLoadingMoreAlbums) { - vm.LoadMoreAlbumsCommand?.Execute().Subscribe(); + vm.LoadMoreAlbumsCommand?.Execute().Subscribe(_ => { }, _ => { }); } } }