diff --git a/src/NotZune.UI/Converters/PlaylistDropHighlightConverter.cs b/src/NotZune.UI/Converters/PlaylistDropHighlightConverter.cs new file mode 100644 index 0000000..2efb353 --- /dev/null +++ b/src/NotZune.UI/Converters/PlaylistDropHighlightConverter.cs @@ -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; + +/// +/// Tier B2 — returns an accent brush when the bound Playlist equals the VM's currently +/// hovered drop target, transparent otherwise. +/// +public sealed class PlaylistDropHighlightConverter : IMultiValueConverter +{ + public static readonly PlaylistDropHighlightConverter Instance = new(); + + public object? Convert(IList 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; + } +} diff --git a/src/NotZune.UI/ViewModels/PlaylistsViewModel.cs b/src/NotZune.UI/ViewModels/PlaylistsViewModel.cs index 1157023..3659041 100644 --- a/src/NotZune.UI/ViewModels/PlaylistsViewModel.cs +++ b/src/NotZune.UI/ViewModels/PlaylistsViewModel.cs @@ -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; + + /// True after ~300ms dwell on a playlist — shows the hover-swap popup of alternative drop targets. + private bool _isHoverSwapExpanded; + public bool IsHoverSwapExpanded + { + get => _isHoverSwapExpanded; + private set + { + if (SetProperty(ref _isHoverSwapExpanded, value)) + { + OnPropertyChanged(nameof(HoverSwapAlternatives)); + } + } + } + + /// Other playlists the user can land on while still hovering (Tier B2 hover-swap parity). + public System.Collections.Generic.IReadOnlyList HoverSwapAlternatives => + Playlists.Where(p => p.Id != _hoveredPlaylist?.Id).ToList(); + + public string HoverSwapPlaylistName => _hoveredPlaylist?.Name ?? string.Empty; + + private readonly System.Collections.Generic.Dictionary _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 tracks) — Avalonia's drag-drop payload + // is deserialized on the drop site. + AddTracksToPlaylistCommand = new AsyncRelayCommand<(Playlist Playlist, IReadOnlyList Tracks)>( + async payload => await OnAddTracksAsync(payload.Playlist, payload.Tracks)); + SwapHoverTargetCommand = new RelayCommand(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) + // ---------------------------------------------------------------------- + + /// Called when a track enters a playlist's drop zone. Sets hover state and arms the swap timer. + public void EnterPlaylistDropZone(Playlist playlist) + { + HoveredPlaylist = playlist; + ArmHoverSwapTimer(playlist); + } + + /// Called when the cursor moves between drop targets inside the same playlist (re-arms the timer). + public void RefreshPlaylistDropZone(Playlist playlist) + { + if (_hoveredPlaylist?.Id != playlist.Id) + { + HoveredPlaylist = playlist; + } + ArmHoverSwapTimer(playlist); + } + + /// Called when the cursor leaves a playlist's drop zone — collapses the swap popup and clears highlight. + public void LeavePlaylistDropZone(Playlist playlist) + { + if (_hoveredPlaylist?.Id == playlist.Id) + { + HoveredPlaylist = null; + IsHoverSwapExpanded = false; + } + CancelHoverSwapTimer(playlist); + } + + /// Called when the drop is released over a playlist — clears transient state. + 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 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); + } + } } diff --git a/src/NotZune.UI/Views/PlaylistsView.axaml b/src/NotZune.UI/Views/PlaylistsView.axaml index f708bdc..b3c33b8 100644 --- a/src/NotZune.UI/Views/PlaylistsView.axaml +++ b/src/NotZune.UI/Views/PlaylistsView.axaml @@ -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"> - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -123,7 +172,10 @@ - + + @@ -132,7 +184,7 @@ -