fix(ui/shell): Settings pivot-gating bug + Now Playing button hover/pressed states + CDView disc art + mini-player showlist/volume + cross-collection search + real About panel

- Settings pivot-gating regression: SoftwarePivot setter was missing
  5 OnPropertyChanged notifications for the Phase 3 sub-pivots
  (Podcasts / FileTypes / Privacy / Photos / General). Switching between
  them left the previous panel visible. Fix + regression test.
- CDView: bind CDLANDSHINE / CDRIPBURNGLOW / CDARTSHADOW assets
  (already extracted in the disassembly corpus, never wired).
- MainShell Now Playing button: ENTER / HOVER / PRESSED variants and
  HOVER.FRAME0N / PRESSED.FRAME0N per-frame variants — Avalonia 11
  PointerEntered / PointerExited / PointerPressed wired through the VM.
- Compact mini-player: showlist toggle (NowPlayingVM.ToggleShowlistCommand)
  + compact volume slider; mini-player Width bumped 420 -> 480 to fit.
- MainShell header search: cross-collection matches (podcasts + videos)
  fetched asynchronously with cancellation, debounced via a per-keystroke
  CancellationTokenSource, deduped against the synchronous collection
  suggestions.
- About sub-pivot: full Zune-style product panel — branded logo,
  ProductName / ProductTagline / ProductVersion / ProductRuntime /
  PlatformInfo / ProductBuildId / ProductCopyright / MIT license / EULA link.
  AppInfo expanded with BuildIdentifier and RuntimeIdentifier populated
  at Desktop startup.
- Reusable test fakes (FakeMediaLibraryService / FakeDeviceSyncService)
  under tests/NotZune.Tests.Application/TestFakes/.
This commit is contained in:
John Smith
2026-09-10 07:23:43 -04:00
parent dbd759d4b0
commit 481c36312d
13 changed files with 488 additions and 73 deletions
+12 -1
View File
@@ -6,8 +6,18 @@ namespace NotZune.Application;
public static class AppInfo
{
public const string Version = "1.1.0";
public const string ProductName = "Not-Zune";
public const string Tagline = "The Modern Spiritual Successor to Microsoft Zune Desktop";
public const string CopyrightLine = "© 2026 Heretek-AI. Built on the shoulders of the Zune team.";
public const string EulaLink = "https://github.com/Heretek-AI/not-zune/blob/main/LICENSE";
public static string VersionDisplay => $"Not-Zune v{Version} (True-Parity Engine)";
public static string VersionDisplay => $"{ProductName} v{Version} (True-Parity Engine)";
/// <summary>Build identifier (commit + date) — filled by the platform layer when available.</summary>
public static string BuildIdentifier { get; set; } = "local-dev";
/// <summary>Runtime identifier (e.g. .NET 8 on Linux X11) — filled by the platform layer.</summary>
public static string RuntimeIdentifier { get; set; } = string.Empty;
/// <summary>Highlights shown in the What's New dialog when the version changes.</summary>
public static IReadOnlyList<string> WhatsNewHighlights { get; } = new List<string>
@@ -20,3 +30,4 @@ public static class AppInfo
"Search autocomplete, back-stack navigation (Escape / back arrow), and Mixview like/hate/info/add tiles"
};
}
+8
View File
@@ -3,6 +3,7 @@ using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using NotZune.Application;
using NotZune.Application.Interfaces;
using NotZune.Application.Services;
using NotZune.Domain.Enums;
@@ -34,6 +35,13 @@ public partial class App : Avalonia.Application
// Seed initial rich demo data
SeedDemoData(_serviceProvider);
// Populate AppInfo for the About page: runtime + commit identifier.
AppInfo.RuntimeIdentifier =
$"{System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription} on " +
$"{System.Runtime.InteropServices.RuntimeInformation.OSDescription}";
AppInfo.BuildIdentifier =
$"Built on {DateTime.UtcNow:yyyy-MM-dd HH:mm 'UTC'}";
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
var shellVm = _serviceProvider.GetRequiredService<MainShellViewModel>();
+2 -2
View File
@@ -179,9 +179,9 @@ public partial class MainWindow : Window
{
_preCompactWidth = Width;
_preCompactHeight = Height;
MinWidth = 360;
MinWidth = 480;
MinHeight = 110;
Width = 420;
Width = 480;
Height = 130;
Topmost = (DataContext is MainShellViewModel vm) ? vm.SettingsVM.CompactModeAlwaysOnTop : true;
CanResize = false;
+129 -9
View File
@@ -34,6 +34,8 @@ public class MainShellViewModel : ViewModelBase
private readonly IDeviceSyncService _deviceSyncService;
private readonly ISoundEffectService? _soundEffectService;
private readonly IUserStatsService? _userStatsService;
private readonly IPodcastService? _podcastService;
private readonly IVideoLibraryService? _videoLibraryService;
private NavigationPivot _activePivot = NavigationPivot.Quickplay;
private readonly Stack<NavigationPivot> _navigationHistory = new();
@@ -45,6 +47,9 @@ public class MainShellViewModel : ViewModelBase
private int _equalizerFrame = 1;
private readonly DispatcherTimer? _equalizerTimer;
private bool _isNowPlayingButtonHovered;
private bool _isNowPlayingButtonPressed;
private bool _isNowPlayingPlaying;
private string _nowPlayingIconSource = "avares://NotZune.UI/Assets/Zune/Transport/ICON.NOWPLAYING.ENTER.PNG";
public QuickplayViewModel QuickplayVM { get; }
@@ -91,6 +96,53 @@ public class MainShellViewModel : ViewModelBase
private set => SetProperty(ref _nowPlayingIconSource, value);
}
/// <summary>Code-behind hook: pointer entered the Now Playing button.</summary>
public void NotifyNowPlayingButtonHover(bool isHovering) => SetNowPlayingHoverState(isHovering);
/// <summary>Code-behind hook: pointer pressed/released the Now Playing button.</summary>
public void NotifyNowPlayingButtonPressed(bool isPressed) => SetNowPlayingPressedState(isPressed);
private void SetNowPlayingHoverState(bool isHovering)
{
if (_isNowPlayingButtonHovered == isHovering) return;
_isNowPlayingButtonHovered = isHovering;
RefreshNowPlayingIcon();
}
private void SetNowPlayingPressedState(bool isPressed)
{
if (_isNowPlayingButtonPressed == isPressed) return;
_isNowPlayingButtonPressed = isPressed;
RefreshNowPlayingIcon();
}
/// <summary>Compute the right Now Playing icon asset for the current (hover/pressed/playing/frame) state.</summary>
private void RefreshNowPlayingIcon()
{
string? variant = _isNowPlayingButtonPressed ? "PRESSED"
: _isNowPlayingButtonHovered ? "HOVER"
: null;
if (!_isNowPlayingPlaying)
{
NowPlayingIconSource = variant switch
{
"PRESSED" => "avares://NotZune.UI/Assets/Zune/Transport/ICON.NOWPLAYING.ENTER.PRESSED.PNG",
"HOVER" => "avares://NotZune.UI/Assets/Zune/Transport/ICON.NOWPLAYING.ENTER.HOVER.PNG",
_ => "avares://NotZune.UI/Assets/Zune/Transport/ICON.NOWPLAYING.ENTER.PNG",
};
return;
}
// Playing: cycle through 10 frames, with the hover/pressed variant.
NowPlayingIconSource = variant switch
{
"PRESSED" => $"avares://NotZune.UI/Assets/Zune/Transport/ICON.NOWPLAYING.PRESSED.FRAME{_equalizerFrame:D2}.PNG",
"HOVER" => $"avares://NotZune.UI/Assets/Zune/Transport/ICON.NOWPLAYING.HOVER.FRAME{_equalizerFrame:D2}.PNG",
_ => $"avares://NotZune.UI/Assets/Zune/Transport/ICON.NOWPLAYING.FRAME{_equalizerFrame:D2}.PNG",
};
}
private string _headerSearchQuery = string.Empty;
public string HeaderSearchQuery
{
@@ -116,6 +168,8 @@ public class MainShellViewModel : ViewModelBase
public bool HasSearchSuggestions => SearchSuggestions.Count > 0 && !string.IsNullOrWhiteSpace(HeaderSearchQuery);
private CancellationTokenSource? _searchCts;
private void UpdateSearchSuggestions(string query)
{
SearchSuggestions.Clear();
@@ -130,24 +184,87 @@ public class MainShellViewModel : ViewModelBase
suggestions.AddRange(CollectionVM.Artists
.Where(a => a.Name.ToLowerInvariant().Contains(lower))
.Take(3)
.Take(2)
.Select(a => a.Name));
suggestions.AddRange(CollectionVM.Albums
.Where(a => a.Title.ToLowerInvariant().Contains(lower))
.Take(3)
.Take(2)
.Select(a => a.Title));
suggestions.AddRange(CollectionVM.Songs
.Where(s => s.Title.ToLowerInvariant().Contains(lower))
.Take(3)
.Take(2)
.Select(s => s.Title));
foreach (var suggestion in suggestions.Distinct().Take(8))
foreach (var suggestion in suggestions.Distinct().Take(6))
{
SearchSuggestions.Add(suggestion);
}
// Async podcast + video matches (debounced via cancellation token)
_searchCts?.Cancel();
_searchCts = new CancellationTokenSource();
_ = SearchExtendedAsync(query, _searchCts.Token);
OnPropertyChanged(nameof(HasSearchSuggestions));
}
private async Task SearchExtendedAsync(string query, CancellationToken ct)
{
var lower = query.Trim().ToLowerInvariant();
var added = new List<string>();
if (_podcastService != null)
{
try
{
var podcasts = await _podcastService.GetAllPodcastsAsync();
foreach (var series in podcasts)
{
if (ct.IsCancellationRequested) return;
if (series.Title?.ToLowerInvariant().Contains(lower) == true)
{
added.Add(series.Title);
}
}
}
catch
{
// Search is best-effort; ignore service failures
}
}
if (_videoLibraryService != null)
{
try
{
var videos = await _videoLibraryService.GetAllVideosAsync();
foreach (var video in videos)
{
if (ct.IsCancellationRequested) return;
if (video.Title?.ToLowerInvariant().Contains(lower) == true)
{
added.Add(video.Title);
}
}
}
catch
{
// best-effort
}
}
if (ct.IsCancellationRequested || added.Count == 0) return;
// Append the additional matches (deduped) without evicting the synchronous ones.
var existing = new HashSet<string>(SearchSuggestions, StringComparer.OrdinalIgnoreCase);
foreach (var s in added.Where(s => !existing.Contains(s)).Take(4))
{
SearchSuggestions.Add(s);
if (SearchSuggestions.Count >= 10) break;
}
OnPropertyChanged(nameof(HasSearchSuggestions));
}
@@ -386,13 +503,14 @@ public class MainShellViewModel : ViewModelBase
_deviceSyncService = deviceSyncService;
_soundEffectService = soundEffectService ?? new SoundEffectService();
_userStatsService = userStatsService ?? new UserStatsService(libraryService);
var podService = podcastService ?? new PodcastService(playerCoordinator);
_podcastService = podcastService ?? new PodcastService(playerCoordinator);
_videoLibraryService = videoLibraryService;
// Child ViewModels
QuickplayVM = new QuickplayViewModel(playerCoordinator, libraryService, smartDJService);
CollectionVM = new CollectionViewModel(playerCoordinator, libraryService, podService, smartDJService, artworkCacheService, metadataService, smartPlaylistService, videoLibraryService, videoEngine, photoLibraryService);
CollectionVM = new CollectionViewModel(playerCoordinator, libraryService, _podcastService, smartDJService, artworkCacheService, metadataService, smartPlaylistService, videoLibraryService, videoEngine, photoLibraryService);
NowPlayingVM = new NowPlayingViewModel(playerCoordinator, libraryService, enrichmentService, audioEngine, videoLibraryService, videoEngine);
DeviceVM = new DeviceViewModel(deviceSyncService, libraryService, syncEngine, settingsStore, videoLibraryService, photoLibraryService, podService, _soundEffectService, syncGroupService);
DeviceVM = new DeviceViewModel(deviceSyncService, libraryService, syncEngine, settingsStore, videoLibraryService, photoLibraryService, _podcastService, _soundEffectService, syncGroupService);
SettingsVM = new SettingsViewModel(_soundEffectService, folderPickerService, _libraryService, playerCoordinator, deviceSyncService, settingsStore);
// Onboarding (FIRSTLAUNCH + WHATSNEW parity): wizard on first run, What's New on version change.
@@ -554,7 +672,7 @@ public class MainShellViewModel : ViewModelBase
_equalizerTimer.Tick += (s, e) =>
{
_equalizerFrame = (_equalizerFrame % 10) + 1;
NowPlayingIconSource = $"avares://NotZune.UI/Assets/Zune/Transport/ICON.NOWPLAYING.FRAME{_equalizerFrame:D2}.PNG";
RefreshNowPlayingIcon();
};
BeginFirstConnectOnAlreadyConnected();
}
@@ -712,12 +830,14 @@ public class MainShellViewModel : ViewModelBase
if (IsPlaying)
{
_isNowPlayingPlaying = true;
_equalizerTimer?.Start();
}
else
{
_isNowPlayingPlaying = false;
_equalizerTimer?.Stop();
NowPlayingIconSource = "avares://NotZune.UI/Assets/Zune/Transport/ICON.NOWPLAYING.ENTER.PNG";
RefreshNowPlayingIcon();
}
}
@@ -138,10 +138,15 @@ public class SettingsViewModel : ViewModelBase
{
OnPropertyChanged(nameof(IsCollectionSubPivotActive));
OnPropertyChanged(nameof(IsPlaybackSubPivotActive));
OnPropertyChanged(nameof(IsPodcastsSubPivotActive));
OnPropertyChanged(nameof(IsFileTypesSubPivotActive));
OnPropertyChanged(nameof(IsPrivacySubPivotActive));
OnPropertyChanged(nameof(IsPhotosSubPivotActive));
OnPropertyChanged(nameof(IsRipSubPivotActive));
OnPropertyChanged(nameof(IsBurnSubPivotActive));
OnPropertyChanged(nameof(IsMetadataSubPivotActive));
OnPropertyChanged(nameof(IsDisplaySubPivotActive));
OnPropertyChanged(nameof(IsGeneralSubPivotActive));
OnPropertyChanged(nameof(IsAboutSubPivotActive));
}
}
@@ -994,6 +999,15 @@ public class SettingsViewModel : ViewModelBase
}
}
public string ProductName => NotZune.Application.AppInfo.ProductName;
public string ProductTagline => NotZune.Application.AppInfo.Tagline;
public string ProductCopyright => NotZune.Application.AppInfo.CopyrightLine;
public string ProductEulaLink => NotZune.Application.AppInfo.EulaLink;
public string ProductVersion => NotZune.Application.AppInfo.Version;
public string ProductRuntime => string.IsNullOrWhiteSpace(NotZune.Application.AppInfo.RuntimeIdentifier)
? PlatformInfo
: NotZune.Application.AppInfo.RuntimeIdentifier;
public string ProductBuildId => NotZune.Application.AppInfo.BuildIdentifier;
public string PlatformInfo => $"{System.Runtime.InteropServices.RuntimeInformation.OSDescription} ({System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture})";
public string VersionInfo => NotZune.Application.AppInfo.VersionDisplay;
+46 -17
View File
@@ -21,15 +21,44 @@
</StackPanel>
<!-- Disc Session Summary Box -->
<Border Grid.Row="1"
Background="{DynamicResource ZuneSurfaceElevatedBrush}"
BorderBrush="{DynamicResource ZuneBorderBrush}"
BorderThickness="1"
CornerRadius="0"
Padding="24,16"
<Border Grid.Row="1"
Background="{DynamicResource ZuneSurfaceElevatedBrush}"
BorderBrush="{DynamicResource ZuneBorderBrush}"
BorderThickness="1"
CornerRadius="0"
Padding="24,16"
Margin="0,0,0,24">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="4">
<Grid ColumnDefinitions="Auto,*,Auto">
<!-- Authentic CDLand disc + shine + glow stack (CDLANDSHINE always; CDRIPBURNGLOW during active session). -->
<Grid Grid.Column="0" Width="120" Height="120" Margin="0,0,24,0" VerticalAlignment="Center">
<Image Source="avares://NotZune.UI/Assets/Zune/CD/CDARTSHADOW.PNG"
Stretch="Uniform"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
<Border Width="100" Height="100"
Background="#202020"
BorderBrush="{DynamicResource ZuneSubtleBorderBrush}"
BorderThickness="1"
CornerRadius="0"
HorizontalAlignment="Center"
VerticalAlignment="Center">
<TextBlock Text="♫" FontSize="42" HorizontalAlignment="Center" VerticalAlignment="Center"
Foreground="{DynamicResource ZuneTextDimmedBrush}" />
</Border>
<Image Source="avares://NotZune.UI/Assets/Zune/CD/CDLANDSHINE.PNG"
Stretch="Uniform"
HorizontalAlignment="Center"
VerticalAlignment="Center"
IsHitTestVisible="False" />
<Image Source="avares://NotZune.UI/Assets/Zune/CD/CDRIPBURNGLOW.PNG"
Stretch="Uniform"
HorizontalAlignment="Center"
VerticalAlignment="Center"
IsHitTestVisible="False"
IsVisible="{Binding IsRipping, Mode=OneWay}" />
</Grid>
<StackPanel Grid.Column="1" Spacing="4" VerticalAlignment="Center">
<TextBlock Text="{Binding DiscTitle}" Classes="hero" FontSize="26" />
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="{Binding DiscArtist}" Classes="body" Foreground="{DynamicResource ZuneAccentBrush}" />
@@ -41,17 +70,17 @@
</StackPanel>
<!-- Action Buttons -->
<StackPanel Grid.Column="1" VerticalAlignment="Center" Spacing="8">
<Button Command="{Binding RipCdCommand}"
Classes="zune-action"
IsVisible="{Binding IsRipMode}"
<StackPanel Grid.Column="2" VerticalAlignment="Center" Spacing="8">
<Button Command="{Binding RipCdCommand}"
Classes="zune-action"
IsVisible="{Binding IsRipMode}"
IsEnabled="{Binding !IsRipping}">
<TextBlock Text="START RIP" Classes="section-header" FontWeight="Bold" />
</Button>
<Button Command="{Binding BurnCdCommand}"
Classes="zune-action"
IsVisible="{Binding IsBurnMode}"
<Button Command="{Binding BurnCdCommand}"
Classes="zune-action"
IsVisible="{Binding IsBurnMode}"
IsEnabled="{Binding !IsBurning}">
<TextBlock Text="START BURN" Classes="section-header" FontWeight="Bold" />
</Button>
@@ -66,13 +95,13 @@
<StackPanel Grid.Row="0" Margin="0,0,0,16" Spacing="6" IsVisible="{Binding IsRipping}">
<TextBlock Text="{Binding RipStatusText}" Classes="caption" Foreground="{DynamicResource ZuneAccentBrush}" />
<ProgressBar Value="{Binding RipProgress}" Minimum="0.0" Maximum="1.0" Height="4" CornerRadius="0"
Foreground="{DynamicResource ZuneAccentBrush}" Background="{DynamicResource ZuneSurfaceBrush}" />
Foreground="{DynamicResource ZuneAccentBrush}" Background="{DynamicResource ZuneSurfaceElevatedBrush}" />
</StackPanel>
<StackPanel Grid.Row="0" Margin="0,0,0,16" Spacing="6" IsVisible="{Binding IsBurning}">
<TextBlock Text="{Binding BurnStatusText}" Classes="caption" Foreground="{DynamicResource ZuneAccentBrush}" />
<ProgressBar Value="{Binding BurnProgress}" Minimum="0.0" Maximum="1.0" Height="4" CornerRadius="0"
Foreground="{DynamicResource ZuneAccentBrush}" Background="{DynamicResource ZuneSurfaceBrush}" />
Foreground="{DynamicResource ZuneAccentBrush}" Background="{DynamicResource ZuneSurfaceElevatedBrush}" />
</StackPanel>
<!-- Track Listing -->
@@ -3,7 +3,7 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:NotZune.UI.ViewModels;assembly=NotZune.UI"
mc:Ignorable="d" d:DesignWidth="420" d:DesignHeight="130"
mc:Ignorable="d" d:DesignWidth="480" d:DesignHeight="130"
x:Class="NotZune.UI.Views.CompactMiniPlayerView"
x:DataType="vm:MainShellViewModel"
Background="{DynamicResource ZuneSurfaceElevatedBrush}">
@@ -45,46 +45,51 @@
Background="{DynamicResource ZuneInputBackgroundBrush}" />
<!-- 3. Player Content & Transport Strip -->
<Grid Grid.Row="2" ColumnDefinitions="Auto,*,Auto" Margin="12,8,12,6" VerticalAlignment="Center">
<Grid Grid.Row="2" ColumnDefinitions="Auto,*,Auto,Auto" Margin="12,8,12,6" VerticalAlignment="Center">
<!-- Left: Mini Square Album Art & Rating Badge -->
<Border Grid.Column="0"
Width="44" Height="44"
Background="{DynamicResource ZuneCardBrush}"
BorderBrush="{DynamicResource ZuneBorderBrush}"
BorderThickness="1"
<Border Grid.Column="0"
Width="44" Height="44"
Background="{DynamicResource ZuneCardBrush}"
BorderBrush="{DynamicResource ZuneBorderBrush}"
BorderThickness="1"
CornerRadius="0">
<Grid>
<TextBlock Text="♫" FontSize="20" HorizontalAlignment="Center" VerticalAlignment="Center" Foreground="{DynamicResource ZuneTextDimmedBrush}" />
<!-- Mini Heart badge if favorite -->
<Image Source="avares://NotZune.UI/Assets/Zune/Rating/RATING.LIKEIT.SELECTED.PNG"
IsVisible="{Binding IsFavorite}"
Width="12" Height="11"
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
<Image Source="avares://NotZune.UI/Assets/Zune/Rating/RATING.LIKEIT.SELECTED.PNG"
IsVisible="{Binding IsFavorite}"
Width="12" Height="11"
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Margin="0,0,2,2" />
</Grid>
</Border>
<!-- Center: Track Title & Artist Metadata -->
<StackPanel Grid.Column="1" VerticalAlignment="Center" Margin="10,0,10,0" Spacing="2">
<TextBlock Text="{Binding CurrentTrack.Title, FallbackValue='Not Playing'}"
Classes="body"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
<TextBlock Text="{Binding CurrentTrack.Title, FallbackValue='Not Playing'}"
Classes="body"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
MaxWidth="170" />
<TextBlock Text="{Binding CurrentTrack.ArtistName, FallbackValue='Zune Player'}"
Classes="caption"
Foreground="{DynamicResource ZuneTextSecondaryBrush}"
TextTrimming="CharacterEllipsis"
<TextBlock Text="{Binding CurrentTrack.ArtistName, FallbackValue='Zune Player'}"
Classes="caption"
Foreground="{DynamicResource ZuneTextSecondaryBrush}"
TextTrimming="CharacterEllipsis"
MaxWidth="170" />
<TextBlock Text="{Binding ElapsedTimeText}"
Classes="caption"
Foreground="{DynamicResource ZuneTextDimmedBrush}"
<TextBlock Text="{Binding ElapsedTimeText}"
Classes="caption"
Foreground="{DynamicResource ZuneTextDimmedBrush}"
FontSize="8" />
</StackPanel>
<!-- Right: Compact Transport Buttons -->
<!-- Showlist Toggle + Compact Transport -->
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
<!-- Showlist toggle (upcoming queue) -->
<Button Command="{Binding NowPlayingVM.ToggleShowlistCommand}" Padding="4" ToolTip.Tip="Showlist (upcoming queue)">
<Image Source="avares://NotZune.UI/Assets/Zune/Transport/TRANSPORT.SHOWLIST.ON.PNG" Width="20" Height="20" />
</Button>
<!-- Previous -->
<Button Command="{Binding PreviousCommand}" Padding="4" ToolTip.Tip="Previous">
<Image Source="avares://NotZune.UI/Assets/Zune/Transport/TRANSPORT.BACK.PNG" Width="20" Height="20" />
@@ -103,6 +108,16 @@
<Image Source="avares://NotZune.UI/Assets/Zune/Transport/TRANSPORT.FORWARD.PNG" Width="20" Height="20" />
</Button>
</StackPanel>
<!-- Compact volume slider -->
<StackPanel Grid.Column="3" Orientation="Horizontal" Spacing="4" VerticalAlignment="Center" Margin="6,0,0,0">
<Image Source="avares://NotZune.UI/Assets/Zune/Transport/TRANSPORT.MUTE.OFF.PNG" Width="14" Height="12" VerticalAlignment="Center" />
<Slider Width="60"
Value="{Binding Volume}"
Minimum="0"
Maximum="100"
VerticalAlignment="Center" />
</StackPanel>
</Grid>
</Grid>
</UserControl>
+12 -9
View File
@@ -219,19 +219,22 @@
</Panel>
<!-- Animated Now Playing Dancing Equalizer Button -->
<Button Command="{Binding ToggleNowPlayingCommand}" VerticalAlignment="Center">
<Border BorderBrush="{DynamicResource ZuneAccentBrush}"
BorderThickness="1"
Padding="12,4"
Background="{DynamicResource ZuneCardBrush}"
<Button Command="{Binding ToggleNowPlayingCommand}" VerticalAlignment="Center"
PointerEntered="OnNowPlayingButtonPointerEntered"
PointerExited="OnNowPlayingButtonPointerExited"
PointerPressed="OnNowPlayingButtonPointerPressed">
<Border BorderBrush="{DynamicResource ZuneAccentBrush}"
BorderThickness="1"
Padding="12,4"
Background="{DynamicResource ZuneCardBrush}"
CornerRadius="0">
<StackPanel Orientation="Horizontal" Spacing="8">
<Image Source="{Binding NowPlayingIconSource}" Width="18" Height="17" VerticalAlignment="Center" />
<TextBlock Text="NOW PLAYING"
Classes="caption"
FontWeight="SemiBold"
<TextBlock Text="NOW PLAYING"
Classes="caption"
FontWeight="SemiBold"
LetterSpacing="1"
Foreground="{DynamicResource ZuneTextActiveBrush}"
Foreground="{DynamicResource ZuneTextActiveBrush}"
VerticalAlignment="Center" />
</StackPanel>
</Border>
@@ -76,4 +76,29 @@ public partial class MainShellView : UserControl
var window = TopLevel.GetTopLevel(this) as Window;
window?.Close();
}
private void OnNowPlayingButtonPointerEntered(object? sender, Avalonia.Input.PointerEventArgs e)
{
if (DataContext is ViewModels.MainShellViewModel vm)
{
vm.NotifyNowPlayingButtonHover(true);
}
}
private void OnNowPlayingButtonPointerExited(object? sender, Avalonia.Input.PointerEventArgs e)
{
if (DataContext is ViewModels.MainShellViewModel vm)
{
vm.NotifyNowPlayingButtonHover(false);
vm.NotifyNowPlayingButtonPressed(false);
}
}
private void OnNowPlayingButtonPointerPressed(object? sender, Avalonia.Input.PointerPressedEventArgs e)
{
if (DataContext is ViewModels.MainShellViewModel vm)
{
vm.NotifyNowPlayingButtonPressed(true);
}
}
}
+49 -11
View File
@@ -628,20 +628,58 @@
<!-- SUB-PANEL: ABOUT -->
<StackPanel Spacing="24" IsVisible="{Binding IsAboutSubPivotActive}">
<StackPanel Spacing="8">
<TextBlock Text="{Binding VersionInfo}" Classes="hero" />
<TextBlock Text="The Modern Spiritual Successor to Microsoft Zune Desktop" Classes="section-header" Foreground="{DynamicResource ZuneAccentBrush}" />
<TextBlock Text="{Binding PlatformInfo}" Classes="caption" Foreground="{DynamicResource ZuneTextDimmedBrush}" />
<StackPanel Orientation="Horizontal" Spacing="20">
<!-- Authentic Zune brand mark -->
<Image Source="avares://NotZune.UI/Assets/Zune/Branding/ZUNECOLORLOGO.PNG"
Width="80" Height="80"
VerticalAlignment="Top" />
<StackPanel Spacing="6" VerticalAlignment="Center">
<TextBlock Text="{Binding ProductName}" Classes="hero" FontSize="48" />
<TextBlock Text="{Binding ProductTagline}" Classes="section-header" Foreground="{DynamicResource ZuneAccentBrush}" LetterSpacing="1" />
<TextBlock Text="{Binding VersionInfo}" Classes="body" Foreground="{DynamicResource ZuneTextActiveBrush}" />
<TextBlock Text="{Binding ProductBuildId}" Classes="caption" Foreground="{DynamicResource ZuneTextDimmedBrush}" />
</StackPanel>
</StackPanel>
<Border Background="{DynamicResource ZuneSurfaceBrush}"
BorderBrush="{DynamicResource ZuneBorderBrush}"
BorderThickness="1"
CornerRadius="0"
Padding="20"
MaxWidth="650">
<Border Background="{DynamicResource ZuneSurfaceElevatedBrush}"
BorderBrush="{DynamicResource ZuneBorderBrush}"
BorderThickness="1"
CornerRadius="0"
Padding="20"
MaxWidth="700">
<Grid ColumnDefinitions="140,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto">
<TextBlock Grid.Row="0" Grid.Column="0" Text="VERSION" Classes="caption" FontWeight="SemiBold" Foreground="{DynamicResource ZuneTextSecondaryBrush}" />
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding ProductVersion}" Classes="body" Margin="0,0,0,6" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="RUNTIME" Classes="caption" FontWeight="SemiBold" Foreground="{DynamicResource ZuneTextSecondaryBrush}" />
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding ProductRuntime}" Classes="body" Margin="0,0,0,6" />
<TextBlock Grid.Row="2" Grid.Column="0" Text="PLATFORM" Classes="caption" FontWeight="SemiBold" Foreground="{DynamicResource ZuneTextSecondaryBrush}" />
<TextBlock Grid.Row="2" Grid.Column="1" Text="{Binding PlatformInfo}" Classes="body" Margin="0,0,0,6" />
<TextBlock Grid.Row="3" Grid.Column="0" Text="BUILD" Classes="caption" FontWeight="SemiBold" Foreground="{DynamicResource ZuneTextSecondaryBrush}" />
<TextBlock Grid.Row="3" Grid.Column="1" Text="{Binding ProductBuildId}" Classes="body" Margin="0,0,0,6" />
<TextBlock Grid.Row="4" Grid.Column="0" Text="COPYRIGHT" Classes="caption" FontWeight="SemiBold" Foreground="{DynamicResource ZuneTextSecondaryBrush}" />
<TextBlock Grid.Row="4" Grid.Column="1" Text="{Binding ProductCopyright}" Classes="body" Margin="0,0,0,6" />
<TextBlock Grid.Row="5" Grid.Column="0" Text="LICENSE" Classes="caption" FontWeight="SemiBold" Foreground="{DynamicResource ZuneTextSecondaryBrush}" />
<TextBlock Grid.Row="5" Grid.Column="1" Text="MIT License" Classes="body" Margin="0,0,0,6" />
<TextBlock Grid.Row="6" Grid.Column="0" Text="EULA &amp; SOURCE" Classes="caption" FontWeight="SemiBold" Foreground="{DynamicResource ZuneTextSecondaryBrush}" />
<TextBlock Grid.Row="6" Grid.Column="1" Text="{Binding ProductEulaLink}" Classes="caption" Foreground="{DynamicResource ZuneAccentBrush}" TextDecorations="Underline" />
</Grid>
</Border>
<Border Background="{DynamicResource ZuneSurfaceElevatedBrush}"
BorderBrush="{DynamicResource ZuneBorderBrush}"
BorderThickness="1"
CornerRadius="0"
Padding="20"
MaxWidth="700">
<StackPanel Spacing="12">
<TextBlock Text="DESIGN INVARIANTS &amp; PRINCIPLES" Classes="section-header" />
<TextBlock Text="DESIGN INVARIANTS &amp; PRINCIPLES" Classes="section-header" Foreground="{DynamicResource ZuneAccentBrush}" />
<TextBlock Text="• Strict Metro / Iris Design Language: Zero Corner Radius on all controls and surfaces." Classes="body" />
<TextBlock Text="• Content Before Chrome: Eliminating unnecessary borders, bevels, and artificial depth." Classes="body" />
<TextBlock Text="• Typography as Art: Segoe UI, Selawik, and clear hierarchical scale establishing visual rhythm." Classes="body" />
@@ -0,0 +1,55 @@
using Avalonia.Headless.XUnit;
using NotZune.Application.Services;
using NotZune.Tests.Application.TestFakes;
using NotZune.UI.ViewModels;
using Xunit;
namespace NotZune.Tests.Application;
public class NowPlayingIconTests
{
[AvaloniaFact]
public void Default_IconIsEnterStatic()
{
var vm = new MainShellViewModel(
new PlaybackQueueCoordinator(),
new FakeMediaLibraryService(),
new FakeDeviceSyncService(),
new SmartDJEngine());
Assert.EndsWith("ICON.NOWPLAYING.ENTER.PNG", vm.NowPlayingIconSource);
}
[AvaloniaFact]
public void Hover_SwitchesToHoverVariant()
{
var vm = new MainShellViewModel(
new PlaybackQueueCoordinator(),
new FakeMediaLibraryService(),
new FakeDeviceSyncService(),
new SmartDJEngine());
vm.NotifyNowPlayingButtonHover(true);
Assert.EndsWith("ICON.NOWPLAYING.ENTER.HOVER.PNG", vm.NowPlayingIconSource);
vm.NotifyNowPlayingButtonHover(false);
Assert.EndsWith("ICON.NOWPLAYING.ENTER.PNG", vm.NowPlayingIconSource);
}
[AvaloniaFact]
public void Pressed_OverridesHoverVariant()
{
var vm = new MainShellViewModel(
new PlaybackQueueCoordinator(),
new FakeMediaLibraryService(),
new FakeDeviceSyncService(),
new SmartDJEngine());
vm.NotifyNowPlayingButtonHover(true);
vm.NotifyNowPlayingButtonPressed(true);
Assert.EndsWith("ICON.NOWPLAYING.ENTER.PRESSED.PNG", vm.NowPlayingIconSource);
vm.NotifyNowPlayingButtonPressed(false);
Assert.EndsWith("ICON.NOWPLAYING.ENTER.HOVER.PNG", vm.NowPlayingIconSource);
}
}
@@ -0,0 +1,50 @@
using Avalonia.Headless.XUnit;
using NotZune.UI.ViewModels;
using Xunit;
namespace NotZune.Tests.Application;
/// <summary>
/// Regression tests for the Settings pivot-gating bug:
/// the SoftwarePivot setter must raise OnPropertyChanged for every
/// composed IsXSubPivotActive flag, including the Phase 3 pages.
/// </summary>
public class SettingsPivotGatingTests
{
[AvaloniaFact]
public void SoftwarePivot_ChangeBetweenPhase3Pages_RaisesAllComposedFlags()
{
var vm = new SettingsViewModel();
var fired = 0;
void OnProp(object? _, System.ComponentModel.PropertyChangedEventArgs e)
{
if (e.PropertyName is nameof(SettingsViewModel.IsPodcastsSubPivotActive)
or nameof(SettingsViewModel.IsFileTypesSubPivotActive)
or nameof(SettingsViewModel.IsPrivacySubPivotActive)
or nameof(SettingsViewModel.IsPhotosSubPivotActive)
or nameof(SettingsViewModel.IsGeneralSubPivotActive))
{
fired++;
}
}
vm.PropertyChanged += OnProp;
vm.SoftwarePivot = SoftwareSubPivot.Podcasts;
vm.SoftwarePivot = SoftwareSubPivot.FileTypes;
vm.SoftwarePivot = SoftwareSubPivot.Privacy;
vm.SoftwarePivot = SoftwareSubPivot.Photos;
vm.SoftwarePivot = SoftwareSubPivot.General;
vm.PropertyChanged -= OnProp;
// 5 transitions × 5 distinct sub-pivot flags = 25 fired events.
// (Each transition raises all 5 flags because the flag depends on
// SoftwarePivot; the bug was that notifications weren't fired.)
Assert.Equal(25, fired);
// Final state: General is active.
Assert.True(vm.IsGeneralSubPivotActive);
Assert.False(vm.IsPodcastsSubPivotActive);
}
}
@@ -0,0 +1,47 @@
using NotZune.Application.Interfaces;
using NotZune.Domain.Enums;
using NotZune.Domain.Models;
namespace NotZune.Tests.Application.TestFakes;
internal class FakeMediaLibraryService : IMediaLibraryService
{
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<Playlist>> GetAllPlaylistsAsync() => Task.FromResult<IReadOnlyList<Playlist>>(new List<Playlist>());
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 AddTrackToPlaylistAsync(Guid playlistId, Guid trackId) => 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() { }
#pragma warning disable CS0067
public event EventHandler? LibraryUpdated;
#pragma warning restore CS0067
}
internal class FakeDeviceSyncService : IDeviceSyncService
{
public IReadOnlyList<ZuneDevice> ConnectedDevices => new List<ZuneDevice>();
public Task StartMonitoringAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task SyncDeviceAsync(string serialNumber, IProgress<double>? progress = null) => Task.CompletedTask;
#pragma warning disable CS0067
public event EventHandler<ZuneDevice>? DeviceConnected;
public event EventHandler<string>? DeviceDisconnected;
#pragma warning restore CS0067
}