mirror of
https://github.com/Heretek-AI/not-zune.git
synced 2026-10-11 21:19:55 +00:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { } }
|
||||
}
|
||||
Reference in New Issue
Block a user