feat(ui/playback): complete Zune 4.8 Phase 2 rebuild (mini-player, pinning, smart DJ, shortcuts, showlist, folder watcher, settings)

This commit is contained in:
John Smith
2026-09-07 01:02:47 -04:00
parent 17eca5b548
commit f348f7dbab
16 changed files with 680 additions and 11 deletions
@@ -50,6 +50,11 @@ public interface IMediaLibraryService
Task<IReadOnlyList<Track>> GetPlaylistTracksAsync(Guid playlistId);
Task ExportPlaylistToZplAsync(Guid playlistId, string targetFilePath);
Task UpdateTrackMetadataAsync(Guid trackId, string title, string artistName, string albumTitle, int? year, string genre, int trackNumber, int discNumber);
Task<IReadOnlyList<Album>> GetPinnedAlbumsAsync();
Task PinAlbumAsync(Guid albumId);
Task UnpinAlbumAsync(Guid albumId);
void StartDirectoryWatcher(string directoryPath);
void StopDirectoryWatcher();
event EventHandler? LibraryUpdated;
}
@@ -25,10 +25,25 @@ public class SmartDJEngine : ISmartDJService
seedTrack = libraryTracks.FirstOrDefault(t => t.Id == seed.SeedTrackId.Value);
}
Guid? targetAlbumId = seed.SeedAlbumId ?? seedTrack?.AlbumId;
Guid? targetArtistId = seed.SeedArtistId ?? seedTrack?.ArtistId;
string? targetGenre = !string.IsNullOrEmpty(seed.SeedGenre) ? seed.SeedGenre : seedTrack?.Genre;
if (!targetArtistId.HasValue && targetAlbumId.HasValue)
{
var albumTrack = libraryTracks.FirstOrDefault(t => t.AlbumId == targetAlbumId.Value);
if (albumTrack != null)
{
targetArtistId = albumTrack.ArtistId;
if (string.IsNullOrEmpty(targetGenre))
{
targetGenre = albumTrack.Genre;
}
}
}
// 3. Score candidates based on similarity:
// - Same album: high weight (+12)
// - Same artist: high weight (+10)
// - Same genre: medium weight (+5)
// - Favorite rating: bonus (+4)
@@ -36,6 +51,10 @@ public class SmartDJEngine : ISmartDJService
var scoredList = candidates.Select(track =>
{
double score = 0;
if (targetAlbumId.HasValue && track.AlbumId == targetAlbumId.Value)
{
score += 12.0;
}
if (targetArtistId.HasValue && track.ArtistId == targetArtistId.Value)
{
score += 10.0;
+93 -1
View File
@@ -1,5 +1,6 @@
using System;
using Avalonia.Controls;
using Avalonia.Input;
using NotZune.UI.ViewModels;
namespace NotZune.Desktop;
@@ -23,6 +24,97 @@ public partial class MainWindow : Window
}
}
protected override void OnKeyDown(KeyEventArgs e)
{
base.OnKeyDown(e);
if (DataContext is not MainShellViewModel vm)
return;
var focused = FocusManager?.GetFocusedElement();
bool isTextBoxFocused = focused is TextBox;
// Ctrl shortcuts (work even if text box is focused)
if (e.KeyModifiers.HasFlag(KeyModifiers.Control))
{
switch (e.Key)
{
case Key.P:
vm.PlayPauseCommand.Execute(null);
e.Handled = true;
return;
case Key.F:
vm.NextCommand.Execute(null);
e.Handled = true;
return;
case Key.B:
vm.PreviousCommand.Execute(null);
e.Handled = true;
return;
case Key.H:
vm.ToggleShuffleCommand.Execute(null);
e.Handled = true;
return;
case Key.T:
vm.ToggleRepeatCommand.Execute(null);
e.Handled = true;
return;
case Key.M:
vm.ToggleCompactModeCommand.Execute(null);
e.Handled = true;
return;
case Key.E:
vm.ActivePivot = NavigationPivot.Collection;
e.Handled = true;
return;
}
}
// Non-Ctrl shortcuts (only if NOT typing in a TextBox)
if (!isTextBoxFocused)
{
switch (e.Key)
{
case Key.Space:
vm.PlayPauseCommand.Execute(null);
e.Handled = true;
return;
case Key.F7:
// Mute / Unmute
vm.Volume = vm.Volume > 0 ? 0 : 75;
e.Handled = true;
return;
case Key.F8:
// Volume down
vm.Volume = Math.Max(0, vm.Volume - 5);
e.Handled = true;
return;
case Key.F9:
// Volume up
vm.Volume = Math.Min(100, vm.Volume + 5);
e.Handled = true;
return;
case Key.Escape:
if (vm.IsCompactMode)
{
vm.ToggleCompactModeCommand.Execute(null);
e.Handled = true;
}
else if (vm.IsNowPlayingActive)
{
vm.ActivePivot = NavigationPivot.Collection;
e.Handled = true;
}
else if (vm.HasHeaderSearchQuery)
{
vm.HeaderSearchQuery = string.Empty;
e.Handled = true;
}
return;
}
}
}
private void OnCompactModeChanged(object? sender, bool isCompact)
{
if (isCompact)
@@ -33,7 +125,7 @@ public partial class MainWindow : Window
MinHeight = 110;
Width = 420;
Height = 130;
Topmost = true;
Topmost = (DataContext is MainShellViewModel vm) ? vm.SettingsVM.CompactModeAlwaysOnTop : true;
CanResize = false;
}
else
+3
View File
@@ -36,6 +36,8 @@ public class Album
public string Genre { get; set; } = string.Empty;
public string? ArtworkUri { get; set; }
public int TrackCount { get; set; }
public bool IsPinned { get; set; }
public DateTime? PinnedAtUtc { get; set; }
public List<Track> Tracks { get; set; } = new();
}
@@ -81,6 +83,7 @@ public class ZuneDevice
public class SmartDJSeed
{
public Guid? SeedTrackId { get; set; }
public Guid? SeedAlbumId { get; set; }
public Guid? SeedArtistId { get; set; }
public string? SeedGenre { get; set; }
public int TargetTrackCount { get; set; } = 25;
@@ -293,6 +293,7 @@ public class MediaLibraryService : IMediaLibraryService
}
await ctx.SaveChangesAsync();
StartDirectoryWatcher(directoryPath);
LibraryUpdated?.Invoke(this, EventArgs.Empty);
}
@@ -501,4 +502,132 @@ public class MediaLibraryService : IMediaLibraryService
await ctx.SaveChangesAsync();
LibraryUpdated?.Invoke(this, EventArgs.Empty);
}
public async Task<IReadOnlyList<Album>> GetPinnedAlbumsAsync()
{
await using var ctx = await _contextFactory.CreateDbContextAsync();
var pinned = await ctx.Albums
.Include(a => a.Tracks)
.Where(a => a.IsPinned)
.OrderByDescending(a => a.PinnedAtUtc)
.ToListAsync();
if (pinned.Count == 0)
{
// Fall back to first 6 albums so quickplay is never blank on initial launch
return await ctx.Albums
.Include(a => a.Tracks)
.OrderBy(a => a.Title)
.Take(6)
.ToListAsync();
}
return pinned;
}
public async Task PinAlbumAsync(Guid albumId)
{
await using var ctx = await _contextFactory.CreateDbContextAsync();
var album = await ctx.Albums.FindAsync(albumId);
if (album != null)
{
album.IsPinned = true;
album.PinnedAtUtc = DateTime.UtcNow;
await ctx.SaveChangesAsync();
LibraryUpdated?.Invoke(this, EventArgs.Empty);
}
}
public async Task UnpinAlbumAsync(Guid albumId)
{
await using var ctx = await _contextFactory.CreateDbContextAsync();
var album = await ctx.Albums.FindAsync(albumId);
if (album != null)
{
album.IsPinned = false;
album.PinnedAtUtc = null;
await ctx.SaveChangesAsync();
LibraryUpdated?.Invoke(this, EventArgs.Empty);
}
}
private FileSystemWatcher? _watcher;
private System.Threading.Timer? _debounceTimer;
private string? _watchedDirectory;
private readonly object _watcherLock = new();
public void StartDirectoryWatcher(string directoryPath)
{
lock (_watcherLock)
{
StopDirectoryWatcher();
if (string.IsNullOrWhiteSpace(directoryPath) || !Directory.Exists(directoryPath))
{
return;
}
_watchedDirectory = directoryPath;
try
{
_watcher = new FileSystemWatcher(directoryPath)
{
IncludeSubdirectories = true,
NotifyFilter = NotifyFilters.FileName | NotifyFilters.DirectoryName | NotifyFilters.LastWrite | NotifyFilters.Size
};
_watcher.Created += (_, _) => ScheduleDebouncedScan();
_watcher.Changed += (_, _) => ScheduleDebouncedScan();
_watcher.Deleted += (_, _) => ScheduleDebouncedScan();
_watcher.Renamed += (_, _) => ScheduleDebouncedScan();
_watcher.EnableRaisingEvents = true;
}
catch (Exception ex)
{
Console.WriteLine($"[FileSystemWatcher] Could not start watcher for '{directoryPath}': {ex.Message}");
}
}
}
public void StopDirectoryWatcher()
{
lock (_watcherLock)
{
if (_watcher != null)
{
try
{
_watcher.EnableRaisingEvents = false;
_watcher.Dispose();
}
catch { }
_watcher = null;
}
_debounceTimer?.Dispose();
_debounceTimer = null;
}
}
private void ScheduleDebouncedScan()
{
lock (_watcherLock)
{
_debounceTimer?.Dispose();
_debounceTimer = new System.Threading.Timer(async _ =>
{
if (!string.IsNullOrEmpty(_watchedDirectory) && Directory.Exists(_watchedDirectory))
{
try
{
await ScanDirectoryAsync(_watchedDirectory);
}
catch (Exception ex)
{
Console.WriteLine($"[FileSystemWatcher] Auto-scan error: {ex.Message}");
}
}
}, null, 1500, System.Threading.Timeout.Infinite);
}
}
}
@@ -21,6 +21,7 @@ public class CollectionViewModel : ViewModelBase
{
private readonly IPlayerCoordinator _playerCoordinator;
private readonly IMediaLibraryService _libraryService;
private readonly ISmartDJService _smartDJService;
private CollectionSubPivot _activeSubPivot = CollectionSubPivot.Artists;
private string _searchQuery = string.Empty;
@@ -113,6 +114,11 @@ public class CollectionViewModel : ViewModelBase
public ICommand ToggleDislikeCommand { get; }
public ICommand OpenEditMetadataCommand { get; }
public ICommand CloseEditMetadataCommand { get; }
public ICommand PinAlbumCommand { get; }
public ICommand UnpinAlbumCommand { get; }
public ICommand StartSmartDjFromTrackCommand { get; }
public ICommand StartSmartDjFromAlbumCommand { get; }
public ICommand StartSmartDjFromArtistCommand { get; }
private MetadataEditViewModel? _activeEditMetadataVM;
public MetadataEditViewModel? ActiveEditMetadataVM
@@ -132,10 +138,12 @@ public class CollectionViewModel : ViewModelBase
public CollectionViewModel(
IPlayerCoordinator playerCoordinator,
IMediaLibraryService libraryService,
IPodcastService? podcastService = null)
IPodcastService? podcastService = null,
ISmartDJService? smartDJService = null)
{
_playerCoordinator = playerCoordinator;
_libraryService = libraryService;
_smartDJService = smartDJService ?? new NotZune.Application.Services.SmartDJEngine();
var podService = podcastService ?? new NotZune.Application.Services.PodcastService(playerCoordinator);
PodcastsVM = new PodcastsViewModel(podService);
PlaylistsVM = new PlaylistsViewModel(libraryService, playerCoordinator);
@@ -169,6 +177,19 @@ public class CollectionViewModel : ViewModelBase
ToggleFavoriteCommand = new AsyncRelayCommand<Track>(OnToggleFavoriteAsync);
ToggleDislikeCommand = new AsyncRelayCommand<Track>(OnToggleDislikeAsync);
PinAlbumCommand = new AsyncRelayCommand<Album>(async album =>
{
if (album != null) await _libraryService.PinAlbumAsync(album.Id);
});
UnpinAlbumCommand = new AsyncRelayCommand<Album>(async album =>
{
if (album != null) await _libraryService.UnpinAlbumAsync(album.Id);
});
StartSmartDjFromTrackCommand = new AsyncRelayCommand<Track>(OnStartSmartDjFromTrackAsync);
StartSmartDjFromAlbumCommand = new AsyncRelayCommand<Album>(OnStartSmartDjFromAlbumAsync);
StartSmartDjFromArtistCommand = new AsyncRelayCommand<Artist>(OnStartSmartDjFromArtistAsync);
_ = RefreshDataAsync();
}
@@ -354,4 +375,59 @@ public class CollectionViewModel : ViewModelBase
await _playerCoordinator.SetRatingAsync(track.Id, newRating);
await _libraryService.SetTrackRatingAsync(track.Id, newRating);
}
private async Task OnStartSmartDjFromTrackAsync(Track? track)
{
if (track == null) return;
var allTracks = await _libraryService.GetAllTracksAsync();
var seed = new SmartDJSeed
{
SeedTrackId = track.Id,
SeedArtistId = track.ArtistId,
SeedGenre = track.Genre,
TargetTrackCount = 25,
ExcludeDisliked = true
};
var mix = await _smartDJService.GenerateMixAsync(seed, allTracks);
if (mix.Count > 0)
{
await _playerCoordinator.PlayTrackAsync(mix[0], mix);
}
}
private async Task OnStartSmartDjFromAlbumAsync(Album? album)
{
if (album == null) return;
var allTracks = await _libraryService.GetAllTracksAsync();
var seed = new SmartDJSeed
{
SeedAlbumId = album.Id,
SeedArtistId = album.ArtistId,
SeedGenre = album.Genre,
TargetTrackCount = 25,
ExcludeDisliked = true
};
var mix = await _smartDJService.GenerateMixAsync(seed, allTracks);
if (mix.Count > 0)
{
await _playerCoordinator.PlayTrackAsync(mix[0], mix);
}
}
private async Task OnStartSmartDjFromArtistAsync(Artist? artist)
{
if (artist == null) return;
var allTracks = await _libraryService.GetAllTracksAsync();
var seed = new SmartDJSeed
{
SeedArtistId = artist.Id,
TargetTrackCount = 25,
ExcludeDisliked = true
};
var mix = await _smartDJService.GenerateMixAsync(seed, allTracks);
if (mix.Count > 0)
{
await _playerCoordinator.PlayTrackAsync(mix[0], mix);
}
}
}
@@ -255,7 +255,7 @@ public class MainShellViewModel : ViewModelBase
// Child ViewModels
QuickplayVM = new QuickplayViewModel(playerCoordinator, libraryService, smartDJService);
CollectionVM = new CollectionViewModel(playerCoordinator, libraryService, podService);
CollectionVM = new CollectionViewModel(playerCoordinator, libraryService, podService, smartDJService);
NowPlayingVM = new NowPlayingViewModel(playerCoordinator, libraryService);
DeviceVM = new DeviceViewModel(deviceSyncService);
SettingsVM = new SettingsViewModel(_soundEffectService, folderPickerService, _libraryService);
@@ -24,8 +24,10 @@ public class NowPlayingViewModel : ViewModelBase
private NowPlayingMode _mode = NowPlayingMode.ArtistCanvas;
private bool _isHudVisible = true;
private bool _isBioDrawerOpen = false;
private bool _isShowlistOpen = false;
public ObservableCollection<Album> MosaicWallAlbums { get; } = new();
public ObservableCollection<Track> UpcomingQueue { get; } = new();
public NowPlayingMode Mode
{
@@ -55,6 +57,14 @@ public class NowPlayingViewModel : ViewModelBase
set => SetProperty(ref _isBioDrawerOpen, value);
}
public bool IsShowlistOpen
{
get => _isShowlistOpen;
set => SetProperty(ref _isShowlistOpen, value);
}
public int UpcomingQueueCount => UpcomingQueue.Count;
public Track? CurrentTrack => _playerCoordinator.CurrentTrack;
public string TrackTitle => CurrentTrack?.Title ?? "No Track Playing";
public string ArtistName => CurrentTrack?.ArtistName ?? "Zune Player";
@@ -86,6 +96,8 @@ public class NowPlayingViewModel : ViewModelBase
public ICommand ToggleModeCommand { get; }
public ICommand ToggleBioDrawerCommand { get; }
public ICommand ToggleShowlistCommand { get; }
public ICommand PlayQueueTrackCommand { get; }
public ICommand ResetHudTimerCommand { get; }
public ICommand PlayPauseCommand { get; }
public ICommand NextCommand { get; }
@@ -118,6 +130,28 @@ public class NowPlayingViewModel : ViewModelBase
ToggleBioDrawerCommand = new RelayCommand(() =>
{
IsBioDrawerOpen = !IsBioDrawerOpen;
if (IsBioDrawerOpen) IsShowlistOpen = false;
TriggerHudActivity();
});
ToggleShowlistCommand = new RelayCommand(() =>
{
IsShowlistOpen = !IsShowlistOpen;
if (IsShowlistOpen)
{
IsBioDrawerOpen = false;
UpdateUpcomingQueue();
}
TriggerHudActivity();
});
PlayQueueTrackCommand = new AsyncRelayCommand<Track>(async track =>
{
if (track != null)
{
await _playerCoordinator.PlayTrackAsync(track);
TriggerHudActivity();
}
});
ResetHudTimerCommand = new RelayCommand(TriggerHudActivity);
@@ -167,6 +201,7 @@ public class NowPlayingViewModel : ViewModelBase
_playerCoordinator.RatingChanged += OnRatingChanged;
_ = LoadMosaicWallAsync();
UpdateUpcomingQueue();
TriggerHudActivity();
}
@@ -187,6 +222,16 @@ public class NowPlayingViewModel : ViewModelBase
}
}
public void UpdateUpcomingQueue()
{
UpcomingQueue.Clear();
foreach (var track in _playerCoordinator.Queue)
{
UpcomingQueue.Add(track);
}
OnPropertyChanged(nameof(UpcomingQueueCount));
}
private void OnTrackChanged(object? sender, TrackChangedEventArgs e)
{
OnPropertyChanged(nameof(CurrentTrack));
@@ -203,6 +248,7 @@ public class NowPlayingViewModel : ViewModelBase
OnPropertyChanged(nameof(IsFavorite));
OnPropertyChanged(nameof(IsDisliked));
OnPropertyChanged(nameof(BiographyText));
UpdateUpcomingQueue();
TriggerHudActivity();
}
@@ -74,19 +74,28 @@ public class QuickplayViewModel : ViewModelBase
PlayDiscoveryMixCommand = new AsyncRelayCommand(OnPlayDiscoveryMixAsync);
PlayHistoryItemCommand = new AsyncRelayCommand<PlayHistoryEntry>(OnPlayHistoryItemAsync);
PlayAlbumCommand = new AsyncRelayCommand<Album>(OnPlayAlbumAsync);
UnpinAlbumCommand = new RelayCommand<Album>(album =>
UnpinAlbumCommand = new AsyncRelayCommand<Album>(async album =>
{
if (album != null) Pins.Remove(album);
if (album != null)
{
Pins.Remove(album);
await _libraryService.UnpinAlbumAsync(album.Id);
}
});
_libraryService.LibraryUpdated += async (_, _) =>
{
await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(LoadInitialDataAsync);
};
_ = LoadInitialDataAsync();
}
public async Task LoadInitialDataAsync()
{
var allAlbums = await _libraryService.GetAllAlbumsAsync();
var pinnedAlbums = await _libraryService.GetPinnedAlbumsAsync();
Pins.Clear();
foreach (var album in allAlbums.Take(6))
foreach (var album in pinnedAlbums)
{
Pins.Add(album);
}
@@ -80,6 +80,47 @@ public class SettingsViewModel : ViewModelBase
}
}
private bool _volumeLevelingEnabled = true;
public bool VolumeLevelingEnabled
{
get => _volumeLevelingEnabled;
set => SetProperty(ref _volumeLevelingEnabled, value);
}
private bool _compactModeAlwaysOnTop = true;
public bool CompactModeAlwaysOnTop
{
get => _compactModeAlwaysOnTop;
set => SetProperty(ref _compactModeAlwaysOnTop, value);
}
private bool _autoWatchFolder = true;
public bool AutoWatchFolder
{
get => _autoWatchFolder;
set
{
if (SetProperty(ref _autoWatchFolder, value))
{
if (value)
{
_libraryService?.StartDirectoryWatcher(NormalizePath(MusicFolderPath));
}
else
{
_libraryService?.StopDirectoryWatcher();
}
}
}
}
private string _startupView = "Quickplay";
public string StartupView
{
get => _startupView;
set => SetProperty(ref _startupView, value);
}
private string _musicFolderPath = Environment.GetFolderPath(Environment.SpecialFolder.MyMusic) is { Length: > 0 } myMusic
? myMusic
: "~/Music";
+46
View File
@@ -94,6 +94,15 @@
<TextBlock Text="PLAY ARTIST" FontWeight="SemiBold" FontSize="11" VerticalAlignment="Center" />
</StackPanel>
</Button>
<Button Classes="zune-action"
Command="{Binding StartSmartDjFromArtistCommand}"
CommandParameter="{Binding SelectedArtist}"
VerticalAlignment="Center">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="🔀" Foreground="{DynamicResource ZuneAccentBrush}" FontSize="11" VerticalAlignment="Center" />
<TextBlock Text="START SMART DJ" FontWeight="SemiBold" FontSize="11" VerticalAlignment="Center" />
</StackPanel>
</Button>
</StackPanel>
</StackPanel>
</Grid>
@@ -117,6 +126,23 @@
BorderBrush="{DynamicResource ZuneBorderBrush}"
BorderThickness="1"
CornerRadius="0">
<Border.ContextMenu>
<ContextMenu>
<MenuItem Header="Play Album"
Command="{Binding $parent[UserControl].((vm:CollectionViewModel)DataContext).PlayAlbumCommand}"
CommandParameter="{Binding}" />
<MenuItem Header="Start Smart DJ Mix"
Command="{Binding $parent[UserControl].((vm:CollectionViewModel)DataContext).StartSmartDjFromAlbumCommand}"
CommandParameter="{Binding}" />
<Separator Background="{DynamicResource ZuneBorderBrush}" />
<MenuItem Header="Pin to Quickplay"
Command="{Binding $parent[UserControl].((vm:CollectionViewModel)DataContext).PinAlbumCommand}"
CommandParameter="{Binding}" />
<MenuItem Header="Unpin from Quickplay"
Command="{Binding $parent[UserControl].((vm:CollectionViewModel)DataContext).UnpinAlbumCommand}"
CommandParameter="{Binding}" />
</ContextMenu>
</Border.ContextMenu>
<Grid>
<TextBlock Text="♫" FontSize="44" HorizontalAlignment="Center" VerticalAlignment="Center" Foreground="{DynamicResource ZuneTextDimmedBrush}" />
</Grid>
@@ -192,6 +218,23 @@
CommandParameter="{Binding}"
Padding="0"
Margin="0,0,24,24">
<Button.ContextMenu>
<ContextMenu>
<MenuItem Header="Play Album"
Command="{Binding $parent[UserControl].((vm:CollectionViewModel)DataContext).PlayAlbumCommand}"
CommandParameter="{Binding}" />
<MenuItem Header="Start Smart DJ Mix"
Command="{Binding $parent[UserControl].((vm:CollectionViewModel)DataContext).StartSmartDjFromAlbumCommand}"
CommandParameter="{Binding}" />
<Separator Background="{DynamicResource ZuneBorderBrush}" />
<MenuItem Header="Pin to Quickplay"
Command="{Binding $parent[UserControl].((vm:CollectionViewModel)DataContext).PinAlbumCommand}"
CommandParameter="{Binding}" />
<MenuItem Header="Unpin from Quickplay"
Command="{Binding $parent[UserControl].((vm:CollectionViewModel)DataContext).UnpinAlbumCommand}"
CommandParameter="{Binding}" />
</ContextMenu>
</Button.ContextMenu>
<Border Background="{DynamicResource ZuneSurfaceElevatedBrush}"
BorderBrush="{DynamicResource ZuneBorderBrush}"
BorderThickness="1"
@@ -253,6 +296,9 @@
<MenuItem Header="Add to Queue"
Command="{Binding $parent[UserControl].((vm:CollectionViewModel)DataContext).EnqueueTrackCommand}"
CommandParameter="{Binding}" />
<MenuItem Header="Start Smart DJ Mix"
Command="{Binding $parent[UserControl].((vm:CollectionViewModel)DataContext).StartSmartDjFromTrackCommand}"
CommandParameter="{Binding}" />
<Separator Background="{DynamicResource ZuneBorderBrush}" />
<MenuItem Header="Favorite (Heart)"
Command="{Binding $parent[UserControl].((vm:CollectionViewModel)DataContext).ToggleFavoriteCommand}"
@@ -8,13 +8,13 @@
x:DataType="vm:MainShellViewModel"
Background="{DynamicResource ZuneSurfaceElevatedBrush}">
<Grid RowDefinitions="Auto,*,Auto" PointerPressed="OnMiniBarPointerPressed">
<Grid RowDefinitions="Auto,Auto,*" PointerPressed="OnMiniBarPointerPressed">
<!-- 1. Top Mini System Bar: Brand Wordmark & Restore/Close Window Controls -->
<Grid Grid.Row="0" ColumnDefinitions="Auto,*,Auto" Margin="12,6,8,2">
<!-- Mini Brand Mark -->
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
<Image Source="avares://NotZune.UI/Assets/Zune/Branding/ZUNECOLORLOGO.PNG" Width="14" Height="15" VerticalAlignment="Center" />
<TextBlock Text="ZUNE MINI"
<Image Source="avares://NotZune.UI/Assets/xune_32.png" Width="14" Height="14" VerticalAlignment="Center" />
<TextBlock Text="ZUNE"
FontFamily="{StaticResource ZunePivotFontFamily}"
FontSize="10"
FontWeight="Bold"
@@ -45,7 +45,7 @@
Background="#1E1E1E" />
<!-- 3. Player Content & Transport Strip -->
<Grid Grid.Row="1" ColumnDefinitions="Auto,*,Auto" Margin="12,10,12,6" VerticalAlignment="Center">
<Grid Grid.Row="2" ColumnDefinitions="Auto,*,Auto" Margin="12,8,12,6" VerticalAlignment="Center">
<!-- Left: Mini Square Album Art & Rating Badge -->
<Border Grid.Column="0"
Width="44" Height="44"
+65
View File
@@ -82,6 +82,55 @@
</ScrollViewer>
</Border>
<!-- Slide-out Drawer: Showlist (Upcoming Queue) -->
<Border HorizontalAlignment="Right"
Width="360"
Background="{DynamicResource ZuneSurfaceElevatedBrush}"
BorderBrush="{DynamicResource ZuneBorderBrush}"
BorderThickness="1,0,0,0"
CornerRadius="0"
IsVisible="{Binding IsShowlistOpen}"
Padding="24">
<Grid RowDefinitions="Auto,*">
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="0,0,0,16">
<StackPanel Grid.Column="0">
<TextBlock Text="NOW PLAYING SHOWLIST" Classes="caption" FontWeight="Bold" Foreground="{DynamicResource ZuneAccentBrush}" LetterSpacing="2" />
<TextBlock Text="{Binding UpcomingQueueCount, StringFormat={}{0} tracks queued}" Classes="caption" Foreground="{DynamicResource ZuneTextDimmedBrush}" Margin="0,4,0,0" />
</StackPanel>
<Button Grid.Column="1" Command="{Binding ToggleShowlistCommand}" Padding="6" Classes="window-control">
<TextBlock Text="✕" FontSize="11" Foreground="{DynamicResource ZuneTextDimmedBrush}" />
</Button>
</Grid>
<ScrollViewer Grid.Row="1">
<ItemsControl ItemsSource="{Binding UpcomingQueue}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="models:Track">
<Button Command="{Binding $parent[UserControl].((vm:NowPlayingViewModel)DataContext).PlayQueueTrackCommand}"
CommandParameter="{Binding}"
HorizontalAlignment="Stretch"
Padding="0">
<Border Background="Transparent"
BorderBrush="{DynamicResource ZuneBorderBrush}"
BorderThickness="0,0,0,1"
CornerRadius="0"
Padding="10,8">
<Grid ColumnDefinitions="*,50">
<StackPanel Grid.Column="0" Spacing="2">
<TextBlock Text="{Binding Title}" Classes="body" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" />
<TextBlock Text="{Binding ArtistName}" Classes="caption" Foreground="{DynamicResource ZuneTextSecondaryBrush}" TextTrimming="CharacterEllipsis" />
</StackPanel>
<TextBlock Grid.Column="1" Text="{Binding Duration, StringFormat={}{0:m\\:ss}}" Classes="caption" Foreground="{DynamicResource ZuneTextDimmedBrush}" HorizontalAlignment="Right" VerticalAlignment="Center" />
</Grid>
</Border>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Grid>
</Border>
<!-- Floating Auto-Hiding Transport HUD -->
<Border VerticalAlignment="Bottom"
HorizontalAlignment="Center"
@@ -127,6 +176,14 @@
</Panel>
</Button>
<!-- Showlist Toggle Button -->
<Button Command="{Binding ToggleShowlistCommand}" Padding="4" ToolTip.Tip="Showlist (Upcoming Queue)">
<Panel Width="20" Height="20">
<Image Source="avares://NotZune.UI/Assets/Zune/Transport/TRANSPORT.SHOWLIST.ON.PNG" IsVisible="{Binding IsShowlistOpen}" Width="20" Height="20" />
<Image Source="avares://NotZune.UI/Assets/Zune/Transport/TRANSPORT.SHOWLIST.OFF.PNG" IsVisible="{Binding !IsShowlistOpen}" Width="20" Height="20" />
</Panel>
</Button>
<!-- Timer -->
<StackPanel Orientation="Horizontal" Spacing="6" VerticalAlignment="Center" Margin="8,0,0,0">
<TextBlock Text="{Binding ElapsedTimeText}" Classes="caption" VerticalAlignment="Center" />
@@ -183,6 +240,14 @@
HorizontalAlignment="Right"
VerticalAlignment="Top"
Margin="0,20,40,0">
<!-- Showlist Queue Toggle -->
<Button Command="{Binding ToggleShowlistCommand}" Classes="zune-action">
<StackPanel Orientation="Horizontal" Spacing="6">
<Image Source="avares://NotZune.UI/Assets/Zune/Transport/TRANSPORT.SHOWLIST.ON.PNG" Width="13" Height="13" VerticalAlignment="Center" />
<TextBlock Text="SHOWLIST" Classes="caption" FontWeight="SemiBold" VerticalAlignment="Center" />
</StackPanel>
</Button>
<!-- Lyrics & Bio Toggle -->
<Button Command="{Binding ToggleBioDrawerCommand}" Classes="zune-action">
<StackPanel Orientation="Horizontal" Spacing="6">
+21
View File
@@ -89,6 +89,27 @@
</StackPanel>
</StackPanel>
<!-- Section: Playback & Mini-Player Preferences -->
<StackPanel Spacing="12">
<TextBlock Text="PLAYBACK &amp; MINI-PLAYER" Classes="section-header" />
<TextBlock Text="Audio playback leveling and compact player behavior." Classes="caption" />
<StackPanel Spacing="10" Margin="0,4,0,0">
<CheckBox IsChecked="{Binding VolumeLevelingEnabled}"
Content="Volume Leveling (ReplayGain normalization across tracks)"
CornerRadius="0"
Foreground="{DynamicResource ZuneTextActiveBrush}" />
<CheckBox IsChecked="{Binding AutoWatchFolder}"
Content="Background Library Watcher (Auto-detect newly added music files)"
CornerRadius="0"
Foreground="{DynamicResource ZuneTextActiveBrush}" />
<CheckBox IsChecked="{Binding CompactModeAlwaysOnTop}"
Content="Always Keep Compact Mini-Player on Top"
CornerRadius="0"
Foreground="{DynamicResource ZuneTextActiveBrush}" />
</StackPanel>
</StackPanel>
<!-- Section: Library Folders -->
<StackPanel Spacing="12">
<TextBlock Text="MUSIC COLLECTION FOLDER" Classes="section-header" />
@@ -118,6 +118,11 @@ public class NavigationAndPlaybackUiTests
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<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() { }
#pragma warning disable CS0067
public event EventHandler? LibraryUpdated;
#pragma warning restore CS0067
@@ -55,6 +55,22 @@ public class ZuneFeatureGapTests
LastUpdatedMetadata = (trackId, title, artistName, albumTitle, year, genre, trackNumber, discNumber);
return Task.CompletedTask;
}
public List<Album> PinnedAlbums { get; set; } = new();
public Task<IReadOnlyList<Album>> GetPinnedAlbumsAsync() => Task.FromResult<IReadOnlyList<Album>>(PinnedAlbums);
public Task PinAlbumAsync(Guid albumId)
{
var album = PinnedAlbums.FirstOrDefault(a => a.Id == albumId);
if (album != null) album.IsPinned = true;
return Task.CompletedTask;
}
public Task UnpinAlbumAsync(Guid albumId)
{
var album = PinnedAlbums.FirstOrDefault(a => a.Id == albumId);
if (album != null) album.IsPinned = false;
return Task.CompletedTask;
}
public void StartDirectoryWatcher(string directoryPath) { }
public void StopDirectoryWatcher() { }
#pragma warning disable CS0067
public event EventHandler? LibraryUpdated;
#pragma warning restore CS0067
@@ -427,5 +443,101 @@ public class ZuneFeatureGapTests
Assert.Equal(5, lib.LastUpdatedMetadata.Value.trackNum);
Assert.Equal(2, lib.LastUpdatedMetadata.Value.discNum);
}
[Fact]
public async Task QuickplayPinning_PinsAndUnpinsAlbum()
{
var lib = new TestMediaLibraryService();
var album = new Album
{
Id = Guid.NewGuid(),
Title = "Subdivisions",
ArtistName = "Rush",
IsPinned = false
};
lib.PinnedAlbums.Add(album);
// Pin album
await lib.PinAlbumAsync(album.Id);
Assert.True(album.IsPinned);
// Unpin album
await lib.UnpinAlbumAsync(album.Id);
Assert.False(album.IsPinned);
}
[Fact]
public async Task SmartDJ_GeneratesMixFromAlbumSeed()
{
var engine = new NotZune.Application.Services.SmartDJEngine();
var albumId = Guid.NewGuid();
var artistId = Guid.NewGuid();
var libraryTracks = new List<Track>
{
new Track { Id = Guid.NewGuid(), Title = "Track 1", AlbumId = albumId, ArtistId = artistId, Genre = "Prog Rock" },
new Track { Id = Guid.NewGuid(), Title = "Track 2", AlbumId = albumId, ArtistId = artistId, Genre = "Prog Rock" },
new Track { Id = Guid.NewGuid(), Title = "Track 3", AlbumId = Guid.NewGuid(), ArtistId = artistId, Genre = "Prog Rock" },
new Track { Id = Guid.NewGuid(), Title = "Track 4", AlbumId = Guid.NewGuid(), ArtistId = Guid.NewGuid(), Genre = "Pop" }
};
var seed = new SmartDJSeed
{
SeedAlbumId = albumId,
TargetTrackCount = 3,
ExcludeDisliked = true
};
var mix = await engine.GenerateMixAsync(seed, libraryTracks);
Assert.NotEmpty(mix);
// The top candidate should match the album seed
Assert.Equal(albumId, mix[0].AlbumId);
}
[Fact]
public void NowPlaying_Showlist_TogglesAndUpdatesQueue()
{
var player = new PlaybackQueueCoordinator();
var lib = new TestMediaLibraryService();
var vm = new NowPlayingViewModel(player, lib);
Assert.False(vm.IsShowlistOpen);
Assert.Equal(0, vm.UpcomingQueueCount);
// Enqueue tracks
var t1 = new Track { Id = Guid.NewGuid(), Title = "Song A", ArtistName = "Artist A" };
var t2 = new Track { Id = Guid.NewGuid(), Title = "Song B", ArtistName = "Artist B" };
player.Enqueue(new[] { t1, t2 });
// Toggle showlist
vm.ToggleShowlistCommand.Execute(null);
Assert.True(vm.IsShowlistOpen);
Assert.Equal(2, vm.UpcomingQueueCount);
Assert.Equal("Song A", vm.UpcomingQueue[0].Title);
// Toggle closed
vm.ToggleShowlistCommand.Execute(null);
Assert.False(vm.IsShowlistOpen);
}
[Fact]
public void Settings_ExpandedPreferences_UpdateState()
{
var vm = new SettingsViewModel();
Assert.True(vm.VolumeLevelingEnabled);
Assert.True(vm.CompactModeAlwaysOnTop);
Assert.True(vm.AutoWatchFolder);
vm.VolumeLevelingEnabled = false;
Assert.False(vm.VolumeLevelingEnabled);
vm.CompactModeAlwaysOnTop = false;
Assert.False(vm.CompactModeAlwaysOnTop);
vm.AutoWatchFolder = false;
Assert.False(vm.AutoWatchFolder);
}
}