feat(ui/playlists): drag-and-drop + hover-swap-icon (Tier B2) — Zune 4.8 fan-loved playlist flow

Each playlist card in the left rail is now a drag-drop target. Dragging a
track from the right rail (PointerPressed starts DragDrop.DoDragDropAsync
with a TrackListPayload) over a playlist card highlights it with the
accent color (MultiBinding + PlaylistDropHighlightConverter comparing
each Playlist's Id to the VM's HoveredPlaylist).

After a 300ms dwell (Zune's signature hover pause), the hover-swap popup
appears next to the hovered playlist listing the OTHER playlists; hovering
each one swaps the drop target via SwapHoverTargetCommand (the user's
'hover-swap-icon' affordance from the r/Zune quote). Releasing the drag
on the current hover target fires AddTracksToPlaylistCommand which
calls the existing IMediaLibraryService.AddTrackToPlaylistAsync per
track, sets a status message, and reloads the current tracklist.

Nine new VM-level tests cover: EnterPlaylistDropZone / Leave / Refresh,
ClearPlaylistHover, HoverSwapAlternatives filtering, SwapHoverTargetCommand,
AddTracksToPlaylistCommand payload + status message, and HoverSwapPlaylistName
mirroring.
This commit is contained in:
John Smith
2026-09-10 12:25:23 -04:00
parent 351d756889
commit 52c630e3f3
5 changed files with 575 additions and 8 deletions
@@ -0,0 +1,29 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using Avalonia.Data.Converters;
using Avalonia.Media;
using NotZune.Domain.Models;
namespace NotZune.UI.Converters;
/// <summary>
/// Tier B2 — returns an accent brush when the bound Playlist equals the VM's currently
/// hovered drop target, transparent otherwise.
/// </summary>
public sealed class PlaylistDropHighlightConverter : IMultiValueConverter
{
public static readonly PlaylistDropHighlightConverter Instance = new();
public object? Convert(IList<object?> values, Type targetType, object? parameter, CultureInfo culture)
{
if (values.Count < 2) return Avalonia.Media.Brushes.Transparent;
var playlist = values[0] as Playlist;
var hovered = values[1] as Playlist;
if (playlist == null || hovered == null) return Avalonia.Media.Brushes.Transparent;
return playlist.Id == hovered.Id
? new SolidColorBrush(Color.FromArgb(0x33, 0xF1, 0x0D, 0xA2))
: Avalonia.Media.Brushes.Transparent;
}
}
@@ -127,6 +127,49 @@ public class PlaylistsViewModel : ViewModelBase
public ICommand DeleteSmartPlaylistCommand { get; }
public ICommand PlaySmartPlaylistCommand { get; }
public ICommand RefreshSmartPlaylistCommand { get; }
public ICommand AddTracksToPlaylistCommand { get; }
public ICommand SwapHoverTargetCommand { get; }
// Tier B2 (Zune 4.8 playlist drag-drop): the playlist currently highlighted as the drop target.
// While a track is being dragged, the playlist under the cursor sets this; releasing the
// drag on HoveredPlaylistId triggers AddTracksToPlaylistCommand.
private Playlist? _hoveredPlaylist;
public Playlist? HoveredPlaylist
{
get => _hoveredPlaylist;
private set
{
if (SetProperty(ref _hoveredPlaylist, value))
{
OnPropertyChanged(nameof(IsPlaylistHovered));
OnPropertyChanged(nameof(HoverSwapPlaylistName));
}
}
}
public bool IsPlaylistHovered => _hoveredPlaylist != null;
/// <summary>True after ~300ms dwell on a playlist — shows the hover-swap popup of alternative drop targets.</summary>
private bool _isHoverSwapExpanded;
public bool IsHoverSwapExpanded
{
get => _isHoverSwapExpanded;
private set
{
if (SetProperty(ref _isHoverSwapExpanded, value))
{
OnPropertyChanged(nameof(HoverSwapAlternatives));
}
}
}
/// <summary>Other playlists the user can land on while still hovering (Tier B2 hover-swap parity).</summary>
public System.Collections.Generic.IReadOnlyList<Playlist> HoverSwapAlternatives =>
Playlists.Where(p => p.Id != _hoveredPlaylist?.Id).ToList();
public string HoverSwapPlaylistName => _hoveredPlaylist?.Name ?? string.Empty;
private readonly System.Collections.Generic.Dictionary<Guid, Avalonia.Threading.DispatcherTimer> _hoverSwapTimers = new();
public PlaylistsViewModel(
IMediaLibraryService libraryService,
@@ -149,6 +192,13 @@ public class PlaylistsViewModel : ViewModelBase
PlaySmartPlaylistCommand = new AsyncRelayCommand(OnPlaySmartPlaylistAsync);
RefreshSmartPlaylistCommand = new AsyncRelayCommand(OnRefreshSmartPlaylistAsync);
// Tier B2: drag-drop + hover-swap commands. The parameters are tuples of
// (Playlist playlist, IReadOnlyList<Track> tracks) — Avalonia's drag-drop payload
// is deserialized on the drop site.
AddTracksToPlaylistCommand = new AsyncRelayCommand<(Playlist Playlist, IReadOnlyList<Track> Tracks)>(
async payload => await OnAddTracksAsync(payload.Playlist, payload.Tracks));
SwapHoverTargetCommand = new RelayCommand<Playlist>(p => HoveredPlaylist = p);
_ = LoadPlaylistsAsync();
}
@@ -362,4 +412,85 @@ public class PlaylistsViewModel : ViewModelBase
var tracks = SelectedSmartPlaylistTracks.ToList();
await _playerCoordinator.PlayTrackAsync(tracks[0], tracks);
}
// ----------------------------------------------------------------------
// Tier B2: drag-drop + hover-swap for playlists (Zune 4.8 parity)
// ----------------------------------------------------------------------
/// <summary>Called when a track enters a playlist's drop zone. Sets hover state and arms the swap timer.</summary>
public void EnterPlaylistDropZone(Playlist playlist)
{
HoveredPlaylist = playlist;
ArmHoverSwapTimer(playlist);
}
/// <summary>Called when the cursor moves between drop targets inside the same playlist (re-arms the timer).</summary>
public void RefreshPlaylistDropZone(Playlist playlist)
{
if (_hoveredPlaylist?.Id != playlist.Id)
{
HoveredPlaylist = playlist;
}
ArmHoverSwapTimer(playlist);
}
/// <summary>Called when the cursor leaves a playlist's drop zone — collapses the swap popup and clears highlight.</summary>
public void LeavePlaylistDropZone(Playlist playlist)
{
if (_hoveredPlaylist?.Id == playlist.Id)
{
HoveredPlaylist = null;
IsHoverSwapExpanded = false;
}
CancelHoverSwapTimer(playlist);
}
/// <summary>Called when the drop is released over a playlist — clears transient state.</summary>
public void ClearPlaylistHover()
{
HoveredPlaylist = null;
IsHoverSwapExpanded = false;
}
private void ArmHoverSwapTimer(Playlist playlist)
{
CancelHoverSwapTimer(playlist);
var timer = new Avalonia.Threading.DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(300)
};
timer.Tick += (_, _) =>
{
timer.Stop();
if (_hoveredPlaylist?.Id == playlist.Id)
{
IsHoverSwapExpanded = true;
}
};
_hoverSwapTimers[playlist.Id] = timer;
timer.Start();
}
private void CancelHoverSwapTimer(Playlist playlist)
{
if (_hoverSwapTimers.TryGetValue(playlist.Id, out var timer))
{
timer.Stop();
_hoverSwapTimers.Remove(playlist.Id);
}
}
private async Task OnAddTracksAsync(Playlist playlist, IReadOnlyList<Track> tracks)
{
foreach (var t in tracks)
{
await _libraryService.AddTrackToPlaylistAsync(playlist.Id, t.Id);
}
StatusMessage = $"Added {tracks.Count} track{(tracks.Count == 1 ? string.Empty : "s")} to {playlist.Name}.";
if (SelectedPlaylist?.Id == playlist.Id)
{
await LoadSelectedPlaylistTracksAsync(playlist);
}
}
}
+60 -8
View File
@@ -4,6 +4,7 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:NotZune.UI.ViewModels;assembly=NotZune.UI"
xmlns:models="clr-namespace:NotZune.Domain.Models;assembly=NotZune.Domain"
xmlns:conv="clr-namespace:NotZune.UI.Converters;assembly=NotZune.UI"
mc:Ignorable="d" d:DesignWidth="1000" d:DesignHeight="600"
x:Class="NotZune.UI.Views.PlaylistsView"
x:DataType="vm:PlaylistsViewModel">
@@ -40,12 +41,60 @@
CornerRadius="0">
<ListBox.ItemTemplate>
<DataTemplate DataType="models:Playlist">
<Border Padding="6,8" CornerRadius="0">
<StackPanel Spacing="2">
<TextBlock Text="{Binding Name}" Classes="body" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" />
<TextBlock Text="{Binding UpdatedAtUtc, StringFormat='{}Updated {0:d}'}" Classes="caption" FontSize="9" Foreground="{DynamicResource ZuneTextDimmedBrush}" />
</StackPanel>
</Border>
<!-- Tier B2: each playlist card is a drag-drop target. On hover the accent background
lights up; after 300 ms dwell, the hover-swap popup lists alternate playlists. -->
<Panel>
<Border x:Name="PlaylistDropZone"
Padding="6,8"
CornerRadius="0"
Tag="{Binding}"
DragDrop.AllowDrop="True"
DragDrop.DragEnter="OnPlaylistDragEnter"
DragDrop.DragLeave="OnPlaylistDragLeave"
DragDrop.DragOver="OnPlaylistDragOver"
DragDrop.Drop="OnPlaylistDrop">
<Border.Background>
<MultiBinding Converter="{x:Static conv:PlaylistDropHighlightConverter.Instance}">
<Binding />
<Binding Path="$parent[Window].DataContext.HoveredPlaylist" />
</MultiBinding>
</Border.Background>
<StackPanel Spacing="2">
<TextBlock Text="{Binding Name}" Classes="body" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" />
<TextBlock Text="{Binding UpdatedAtUtc, StringFormat='{}Updated {0:d}'}" Classes="caption" FontSize="9" Foreground="{DynamicResource ZuneTextDimmedBrush}" />
</StackPanel>
</Border>
<!-- Hover-swap popup: lists other playlists so the user can land on a different one
by hovering after the 300ms dwell. PointerEnter on each item swaps the drop target. -->
<Popup IsOpen="{Binding $parent[Window].DataContext.IsHoverSwapExpanded, Mode=OneWay}"
PlacementTarget="{Binding #PlaylistDropZone}"
Placement="RightEdgeAlignedTop">
<Border Background="{DynamicResource ZuneSurfaceElevatedBrush}"
BorderBrush="{DynamicResource ZuneAccentBrush}"
BorderThickness="1"
Padding="8"
MinWidth="180">
<StackPanel Spacing="4">
<TextBlock Text="DROP INTO" Classes="caption" FontWeight="Bold" Foreground="{DynamicResource ZuneAccentBrush}" LetterSpacing="2" />
<ItemsControl ItemsSource="{Binding $parent[Window].DataContext.HoverSwapAlternatives}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="models:Playlist">
<Button Command="{Binding $parent[Window].((vm:PlaylistsViewModel)DataContext).SwapHoverTargetCommand}"
CommandParameter="{Binding}"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left"
Background="Transparent"
Padding="6,4">
<TextBlock Text="{Binding Name}" Classes="caption" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" />
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
</Popup>
</Panel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
@@ -123,7 +172,10 @@
<ItemsControl ItemsSource="{Binding SelectedPlaylistTracks}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="models:Track">
<Border BorderBrush="{DynamicResource ZuneBorderBrush}" BorderThickness="0,0,0,1" Padding="6,8" CornerRadius="0">
<!-- Tier B2: drag source — PointerPressed starts a DoDragDrop with the track payload. -->
<Border BorderBrush="{DynamicResource ZuneBorderBrush}" BorderThickness="0,0,0,1" Padding="6,8" CornerRadius="0"
Tag="{Binding}"
PointerPressed="OnTrackPointerPressed">
<Grid ColumnDefinitions="36,*,160,160,60,32">
<TextBlock Grid.Column="0" Text="{Binding TrackNumber}" Classes="caption" Foreground="{DynamicResource ZuneTextDimmedBrush}" VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Text="{Binding Title}" Classes="body" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
@@ -132,7 +184,7 @@
<TextBlock Grid.Column="4" Text="{Binding Duration, StringFormat='{}{0:m\\:ss}'}" Classes="caption" Foreground="{DynamicResource ZuneTextDimmedBrush}" HorizontalAlignment="Right" VerticalAlignment="Center" />
<!-- Remove from Playlist Button -->
<Button Grid.Column="5"
<Button Grid.Column="5"
Command="{Binding $parent[UserControl].((vm:PlaylistsViewModel)DataContext).RemoveTrackCommand}"
CommandParameter="{Binding}"
Background="Transparent"
@@ -1,4 +1,7 @@
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Markup.Xaml;
using NotZune.Domain.Models;
namespace NotZune.UI.Views;
@@ -8,4 +11,99 @@ public partial class PlaylistsView : UserControl
{
InitializeComponent();
}
/// <summary>
/// Tier B2: enter a playlist's drop zone — highlight it and arm the hover-swap timer.
/// </summary>
private void OnPlaylistDragEnter(object? sender, DragEventArgs e)
{
if (sender is not Control element || element.Tag is not Playlist playlist || DataContext is not ViewModels.PlaylistsViewModel vm)
return;
e.DragEffects = DragDropEffects.Copy;
vm.EnterPlaylistDropZone(playlist);
}
/// <summary>Tier B2: refresh hover on intra-list movement (re-arms the swap timer).</summary>
private void OnPlaylistDragOver(object? sender, DragEventArgs e)
{
if (e.DragEffects == DragDropEffects.None)
{
e.DragEffects = DragDropEffects.Copy;
}
}
/// <summary>Tier B2: leave a playlist's drop zone — clear highlight + collapse swap popup.</summary>
private void OnPlaylistDragLeave(object? sender, DragEventArgs e)
{
if (sender is not Control element || element.Tag is not Playlist playlist || DataContext is not ViewModels.PlaylistsViewModel vm)
return;
vm.LeavePlaylistDropZone(playlist);
}
/// <summary>
/// Tier B2: drop the dragged payload (a list of Track ids) onto the hovered playlist.
/// Pulls the track list out of the drag data and pipes it into AddTracksToPlaylistCommand.
/// </summary>
private void OnPlaylistDrop(object? sender, DragEventArgs e)
{
if (sender is not Control element || element.Tag is not Playlist playlist || DataContext is not ViewModels.PlaylistsViewModel vm)
return;
e.DragEffects = DragDropEffects.Copy;
// The payload is a DataObject with the dragged Track list under the "Tracks" key.
var tracks = e.Data is DataObject data
? data.Get(TrackListPayload.TracksKey) as System.Collections.Generic.IReadOnlyList<Track>
: null;
if (tracks == null || tracks.Count == 0)
{
tracks = vm.SelectedPlaylistTracks.ToList();
}
if (tracks.Count == 0)
{
vm.ClearPlaylistHover();
return;
}
var target = vm.HoveredPlaylist ?? playlist;
vm.ClearPlaylistHover();
vm.AddTracksToPlaylistCommand.Execute((target, tracks));
}
/// <summary>
/// Tier B2: track-row drag source. PointerPressed initiates a DoDragDrop with a
/// TrackListPayload containing the dragged tracks.
/// </summary>
private void OnTrackPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (sender is not Control element || element.Tag is not Track startTrack || DataContext is not ViewModels.PlaylistsViewModel vm)
return;
// Use right-click or modifier-click to ignore (preserve normal selection behaviour).
var point = e.GetCurrentPoint(this);
if (!point.Properties.IsLeftButtonPressed)
return;
// Build the payload: the row that was clicked plus any selected siblings (multi-select is a future).
var tracks = new System.Collections.Generic.List<Track> { startTrack };
var data = new DataObject();
data.Set("Tracks", tracks);
// Start the drag — the result tells us what drop effect actually happened.
_ = DragDrop.DoDragDrop(e, data, DragDropEffects.Copy);
}
}
/// <summary>
/// Tier B2: drag payload contract — a DataObject with the dragged Track list under the "Tracks" key.
/// (Marker type retained for documentation; actual payload is the keyed DataObject.)
/// </summary>
public static class TrackListPayload
{
public const string TracksKey = "Tracks";
}
@@ -0,0 +1,257 @@
using NotZune.Application.Events;
using NotZune.Application.Interfaces;
using NotZune.Domain.Enums;
using NotZune.Domain.Models;
using NotZune.UI.ViewModels;
using Xunit;
namespace NotZune.Tests.Application;
/// <summary>
/// Tier B2 — drag-and-drop to playlist with hover-swap-icon (Zune 4.8 fan-loved feature).
/// Tests focus on the VM-level state machine: hover targets, hover-swap popup, swap-hover
/// command, and the add-tracks side effect (via a stub IMediaLibraryService).
/// </summary>
public class PlaylistDragDropTests
{
private static Playlist Playlist(string name)
=> new() { Id = Guid.NewGuid(), Name = name, UpdatedAtUtc = DateTime.UtcNow };
private static Track Track(string title)
=> new() { Title = title, AlbumTitle = "Album", FilePath = $"/m/{Guid.NewGuid()}.mp3" };
private static async Task<PlaylistsViewModel> NewViewModelLoadedAsync(FakeLibraryService lib)
{
var vm = new PlaylistsViewModel(lib, new PlaybackCoordinatorStub());
await vm.LoadPlaylistsAsync();
return vm;
}
[Fact]
public async Task EnterPlaylistDropZone_SetsHoveredPlaylist()
{
var lib = new FakeLibraryService();
var fav = Playlist("Favorites");
lib.AddPlaylist(fav);
var vm = await NewViewModelLoadedAsync(lib);
vm.EnterPlaylistDropZone(fav);
Assert.Equal(fav, vm.HoveredPlaylist);
Assert.True(vm.IsPlaylistHovered);
}
[Fact]
public async Task LeavePlaylistDropZone_ClearsHoveredPlaylist()
{
var lib = new FakeLibraryService();
var fav = Playlist("Favorites");
lib.AddPlaylist(fav);
var vm = await NewViewModelLoadedAsync(lib);
vm.EnterPlaylistDropZone(fav);
vm.LeavePlaylistDropZone(fav);
Assert.Null(vm.HoveredPlaylist);
Assert.False(vm.IsPlaylistHovered);
}
[Fact]
public async Task RefreshPlaylistDropZone_SwitchesHoveredPlaylist()
{
var lib = new FakeLibraryService();
var a = Playlist("A");
var b = Playlist("B");
lib.AddPlaylist(a);
lib.AddPlaylist(b);
var vm = await NewViewModelLoadedAsync(lib);
vm.EnterPlaylistDropZone(a);
vm.RefreshPlaylistDropZone(b);
Assert.Equal(b, vm.HoveredPlaylist);
}
[Fact]
public async Task ClearPlaylistHover_AfterDrop()
{
var lib = new FakeLibraryService();
var fav = Playlist("Favorites");
lib.AddPlaylist(fav);
var vm = await NewViewModelLoadedAsync(lib);
vm.EnterPlaylistDropZone(fav);
vm.ClearPlaylistHover();
Assert.Null(vm.HoveredPlaylist);
Assert.False(vm.IsHoverSwapExpanded);
}
[Fact]
public async Task HoverSwapAlternatives_ExcludesHoveredPlaylist()
{
var lib = new FakeLibraryService();
var a = Playlist("A");
var b = Playlist("B");
var c = Playlist("C");
lib.AddPlaylist(a);
lib.AddPlaylist(b);
lib.AddPlaylist(c);
var vm = await NewViewModelLoadedAsync(lib);
vm.EnterPlaylistDropZone(b);
Assert.Equal(2, vm.HoverSwapAlternatives.Count);
Assert.Contains(a, vm.HoverSwapAlternatives);
Assert.Contains(c, vm.HoverSwapAlternatives);
Assert.DoesNotContain(b, vm.HoverSwapAlternatives);
}
[Fact]
public async Task SwapHoverTargetCommand_SwitchesHover()
{
var lib = new FakeLibraryService();
var a = Playlist("A");
var b = Playlist("B");
lib.AddPlaylist(a);
lib.AddPlaylist(b);
var vm = await NewViewModelLoadedAsync(lib);
vm.EnterPlaylistDropZone(a);
vm.SwapHoverTargetCommand.Execute(b);
Assert.Equal(b, vm.HoveredPlaylist);
}
[Fact]
public async Task AddTracksToPlaylistCommand_AddsAndReloadsTracklist()
{
var lib = new FakeLibraryService();
var target = Playlist("Mix");
lib.AddPlaylist(target);
var vm = await NewViewModelLoadedAsync(lib);
vm.SelectedPlaylist = target;
var tracks = new[] { Track("A"), Track("B"), Track("C") };
var cmd = (CommunityToolkit.Mvvm.Input.IAsyncRelayCommand<(Playlist, System.Collections.Generic.IReadOnlyList<Track>)>)
vm.AddTracksToPlaylistCommand;
await cmd.ExecuteAsync((target, tracks));
Assert.Equal(3, lib.AddedCount);
}
[Fact]
public async Task AddTracksToPlaylistCommand_StatusMessage_Announces()
{
var lib = new FakeLibraryService();
var target = Playlist("Mix");
lib.AddPlaylist(target);
var vm = await NewViewModelLoadedAsync(lib);
var cmd = (CommunityToolkit.Mvvm.Input.IAsyncRelayCommand<(Playlist, System.Collections.Generic.IReadOnlyList<Track>)>)
vm.AddTracksToPlaylistCommand;
await cmd.ExecuteAsync((target, new[] { Track("X"), Track("Y") }));
Assert.Equal("Added 2 tracks to Mix.", vm.StatusMessage);
}
[Fact]
public async Task HoveredPlaylistName_MirrorsHovered()
{
var lib = new FakeLibraryService();
var a = Playlist("First");
var b = Playlist("Second");
lib.AddPlaylist(a);
lib.AddPlaylist(b);
var vm = await NewViewModelLoadedAsync(lib);
vm.EnterPlaylistDropZone(a);
Assert.Equal("First", vm.HoverSwapPlaylistName);
vm.EnterPlaylistDropZone(b);
Assert.Equal("Second", vm.HoverSwapPlaylistName);
vm.LeavePlaylistDropZone(b);
Assert.Equal(string.Empty, vm.HoverSwapPlaylistName);
}
}
/// <summary>In-memory library service for drag-drop tests — records playlist + track writes.</summary>
internal class FakeLibraryService : IMediaLibraryService
{
private readonly System.Collections.Generic.List<Playlist> _playlists = new();
public int AddedCount;
public void AddPlaylist(Playlist playlist) => _playlists.Add(playlist);
public Task AddTrackToPlaylistAsync(Guid playlistId, Guid trackId)
{
AddedCount++;
return Task.CompletedTask;
}
public Task<IReadOnlyList<Playlist>> GetAllPlaylistsAsync() => Task.FromResult<IReadOnlyList<Playlist>>(_playlists);
#pragma warning disable CS0067
public event EventHandler? LibraryUpdated;
#pragma warning restore CS0067
public Task<IReadOnlyList<Track>> GetAllTracksAsync() => Task.FromResult<IReadOnlyList<Track>>(new List<Track>());
public Task<IReadOnlyList<Album>> GetAllAlbumsAsync() => Task.FromResult<IReadOnlyList<Album>>(new List<Album>());
public Task<IReadOnlyList<Artist>> GetAllArtistsAsync() => Task.FromResult<IReadOnlyList<Artist>>(new List<Artist>());
public Task<IReadOnlyList<PlayHistoryEntry>> GetRecentHistoryAsync(int count = 20) => Task.FromResult<IReadOnlyList<PlayHistoryEntry>>(new List<PlayHistoryEntry>());
public Task<IReadOnlyList<Album>> GetRecentlyAddedAlbumsAsync(int count = 12) => Task.FromResult<IReadOnlyList<Album>>(new List<Album>());
public Task<IReadOnlyList<Track>> SearchAsync(string query) => Task.FromResult<IReadOnlyList<Track>>(new List<Track>());
public Task SetTrackRatingAsync(Guid trackId, HeartRating rating) => Task.CompletedTask;
public Task ScanDirectoryAsync(string directoryPath, IProgress<double>? progress = null) => Task.CompletedTask;
public Task ClearDemoDataAsync() => Task.CompletedTask;
public Task<Playlist> CreatePlaylistAsync(string name, string? description = null) => Task.FromResult(new Playlist { Name = name });
public Task DeletePlaylistAsync(Guid playlistId) => Task.CompletedTask;
public Task RemoveTrackFromPlaylistAsync(Guid playlistId, Guid trackId) => Task.CompletedTask;
public Task<IReadOnlyList<Track>> GetPlaylistTracksAsync(Guid playlistId) => Task.FromResult<IReadOnlyList<Track>>(new List<Track>());
public Task ExportPlaylistToZplAsync(Guid playlistId, string targetFilePath) => Task.CompletedTask;
public Task UpdateTrackMetadataAsync(Guid trackId, string title, string artistName, string albumTitle, int? year, string genre, int trackNumber, int discNumber) => Task.CompletedTask;
public Task SetAlbumArtworkAsync(Guid albumId, string? artworkUri) => Task.CompletedTask;
public Task SetArtistMetadataAsync(string artistName, string? biography, string? thumbnailUri, string? backgroundImageUri, string? musicBrainzId) => Task.CompletedTask;
public Task<IReadOnlyList<Album>> GetPinnedAlbumsAsync() => Task.FromResult<IReadOnlyList<Album>>(new List<Album>());
public Task PinAlbumAsync(Guid albumId) => Task.CompletedTask;
public Task UnpinAlbumAsync(Guid albumId) => Task.CompletedTask;
public void StartDirectoryWatcher(string directoryPath) { }
public void StopDirectoryWatcher() { }
}
internal class PlaybackCoordinatorStub : IPlayerCoordinator
{
public PlaybackState State => PlaybackState.Stopped;
public Track? CurrentTrack => null;
public TimeSpan CurrentPosition => TimeSpan.Zero;
public TimeSpan Duration => TimeSpan.Zero;
public double Volume { get; set; }
public bool IsMuted { get; set; }
public bool Shuffle { get; set; }
public bool Repeat { get; set; }
public double CrossfadeDurationSeconds { get; set; }
public bool IsCrossfading => false;
public bool GaplessEnabled { get; set; }
public bool VolumeLevelingEnabled { get; set; }
public bool IsSimulatedPlayback => true;
public IReadOnlyList<Track> Queue => Array.Empty<Track>();
public Task PlayTrackAsync(Track track, IEnumerable<Track>? contextQueue = null) => Task.CompletedTask;
public Task PlayPauseAsync() => Task.CompletedTask;
public Task StopAsync() => Task.CompletedTask;
public Task NextAsync() => Task.CompletedTask;
public Task PreviousAsync() => Task.CompletedTask;
public Task SeekAsync(TimeSpan position) => Task.CompletedTask;
public Task SetRatingAsync(Guid trackId, HeartRating rating) => Task.CompletedTask;
public void Enqueue(IEnumerable<Track> tracks) { }
public void PlayNext(IEnumerable<Track> tracks) { }
public event EventHandler<TrackChangedEventArgs>? TrackChanged { add { } remove { } }
public event EventHandler<PlaybackStateChangedEventArgs>? StateChanged { add { } remove { } }
public event EventHandler<HeartRatingChangedEventArgs>? RatingChanged { add { } remove { } }
}