mirror of
https://github.com/Heretek-AI/not-zune.git
synced 2026-10-12 02:04:04 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -89,6 +89,27 @@
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Section: Playback & Mini-Player Preferences -->
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="PLAYBACK & 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user