Skip to content
Draft
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: 3 additions & 0 deletions Source/JamBox.Core/Services/Interfaces/IJellyfinApiService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ public interface IJellyfinApiService

Task<List<Album>> GetAlbumsAsync(string libraryId);

Task<(List<Album> Albums, int TotalCount)> GetAlbumsPagedAsync(
string libraryId, int startIndex = 0, int limit = 50, string? sortBy = null, string? sortOrder = null);

Task<List<Album>> GetAlbumsByArtistAsync(string artistId);

Task<List<Track>> GetTracksAsync(string libraryId);
Expand Down
33 changes: 33 additions & 0 deletions Source/JamBox.Core/Services/JellyFinApiService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,39 @@ public async Task<List<Album>> GetAlbumsAsync(string libraryId)
return result?.Items ?? [];
}

public async Task<(List<Album> 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<List<Album>> GetAlbumsByArtistAsync(string artistId)
{
if (!IsAuthenticated || _httpClient is null)
Expand Down
120 changes: 106 additions & 14 deletions Source/JamBox.Core/ViewModels/LibraryViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/// <summary>
/// The PlaybackViewModel handles all playback-related state and commands.
/// </summary>
Expand Down Expand Up @@ -115,6 +129,7 @@ public string TrackSortStatus
public ReactiveCommand<Unit, Unit> ResetArtistsSelectionCommand { get; }
public ReactiveCommand<Unit, Unit> ResetAlbumSelectionCommand { get; }
public ReactiveCommand<Unit, Unit> JukeBoxModeCommand { get; }
public ReactiveCommand<Unit, Unit> LoadMoreAlbumsCommand { get; }

public LibraryViewModel(
PlaybackViewModel playbackViewModel,
Expand All @@ -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);
Expand Down Expand Up @@ -204,42 +220,118 @@ private async Task LoadAlbumsAsync()
{
if (_selectedLibrary is null) { return; }

List<Album>? albums = [];
// Reset pagination state when loading fresh
_albumsStartIndex = 0;
_totalAlbumCount = 0;

List<Album>? 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<Album>(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<Album> 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<Album>(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()
Expand Down
13 changes: 13 additions & 0 deletions Source/JamBox.Core/Views/UserControls/AlbumsListView.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
<Grid
Grid.Row="1">
<ListBox
x:Name="AlbumsListBox"
ItemsSource="{Binding Albums}"
SelectedItem="{Binding SelectedAlbum}"
SelectionMode="Single">
Expand Down Expand Up @@ -84,6 +85,18 @@
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>

<!-- Loading indicator at bottom -->
<StackPanel
VerticalAlignment="Bottom"
HorizontalAlignment="Center"
IsVisible="{Binding IsLoadingMoreAlbums}"
Margin="0,0,0,10">
<TextBlock
Text="Loading more albums..."
Foreground="#EDF3F9"
Opacity="0.7"/>
</StackPanel>
</Grid>
</Grid>
</UserControl>
90 changes: 90 additions & 0 deletions Source/JamBox.Core/Views/UserControls/AlbumsListView.axaml.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,101 @@
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<ListBox>("AlbumsListBox");

// First try to find ScrollViewer in descendants of the ListBox
_scrollViewer = listBox?.GetVisualDescendants().OfType<ScrollViewer>().FirstOrDefault();

// If not found in descendants, try ancestors (in case the control is inside a parent ScrollViewer)
if (_scrollViewer == null)
{
_scrollViewer = this.GetVisualAncestors().OfType<ScrollViewer>().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;
#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)
{
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;

#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");
#endif
vm.LoadMoreAlbumsCommand?.Execute().Subscribe(
_ => { },
ex => Console.WriteLine($"Error loading more albums: {ex.Message}"));
}
}
}
}