feat(media): video + photo libraries with real libVLC playback, now playing clips, gallery + slideshow, device media sync rules (true-parity phase 8)

This commit is contained in:
John Smith
2026-09-09 16:54:22 -04:00
parent f3d95aad4f
commit 833b5ef4ac
46 changed files with 2199 additions and 10 deletions
+7
View File
@@ -31,6 +31,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NotZune.Tests.Application",
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NotZune.Tests.Domain", "tests\NotZune.Tests.Domain\NotZune.Tests.Domain.csproj", "{275BFD54-E360-44EC-8A6C-03D6167413BF}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NotZune.Infrastructure.Video", "src\NotZune.Infrastructure.Video\NotZune.Infrastructure.Video.csproj", "{E7DE99F2-2C5A-46E4-A646-306A59915A2F}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -88,6 +90,10 @@ Global
{275BFD54-E360-44EC-8A6C-03D6167413BF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{275BFD54-E360-44EC-8A6C-03D6167413BF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{275BFD54-E360-44EC-8A6C-03D6167413BF}.Release|Any CPU.Build.0 = Release|Any CPU
{E7DE99F2-2C5A-46E4-A646-306A59915A2F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E7DE99F2-2C5A-46E4-A646-306A59915A2F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E7DE99F2-2C5A-46E4-A646-306A59915A2F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E7DE99F2-2C5A-46E4-A646-306A59915A2F}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{9C687BB5-6AE3-4C94-A12F-6D34C5DAAFFD} = {386DD2E6-7733-4758-9DEF-333286DDB86F}
@@ -102,5 +108,6 @@ Global
{E0F28BF6-1159-41CC-BBEE-1FBAFED53447} = {386DD2E6-7733-4758-9DEF-333286DDB86F}
{17B493BB-CFCB-423D-844F-3ECD6BA3D6CC} = {89103A14-FD5B-4F9F-9AEF-803F5D97F55C}
{275BFD54-E360-44EC-8A6C-03D6167413BF} = {89103A14-FD5B-4F9F-9AEF-803F5D97F55C}
{E7DE99F2-2C5A-46E4-A646-306A59915A2F} = {386DD2E6-7733-4758-9DEF-333286DDB86F}
EndGlobalSection
EndGlobal
@@ -0,0 +1,13 @@
using NotZune.Domain.Models;
namespace NotZune.Application.Interfaces;
public interface IPhotoLibraryService
{
Task<IReadOnlyList<Photo>> GetAllPhotosAsync();
Task ScanDirectoryAsync(string directoryPath, IProgress<double>? progress = null);
/// <summary>Distinct folder paths currently present in the photo library.</summary>
Task<IReadOnlyList<string>> GetFoldersAsync();
}
@@ -0,0 +1,12 @@
using NotZune.Domain.Models;
namespace NotZune.Application.Interfaces;
public interface IVideoLibraryService
{
Task<IReadOnlyList<Video>> GetAllVideosAsync();
Task ScanDirectoryAsync(string directoryPath, IProgress<double>? progress = null);
Task MarkPlayedAsync(Guid videoId);
}
@@ -0,0 +1,30 @@
namespace NotZune.Application.Interfaces;
/// <summary>
/// Real video playback boundary (libVLC-backed). The Avalonia VideoView binds to
/// <see cref="MediaPlayerHandle"/>; all operations no-op safely when unavailable.
/// </summary>
public interface IVideoPlaybackEngine : IDisposable
{
bool IsAvailable { get; }
object? MediaPlayerHandle { get; }
void LoadAndPlay(string filePath);
void Play();
void Pause();
void Stop();
void SetVolume(double volume, bool muted);
void Seek(TimeSpan position);
TimeSpan GetPosition();
TimeSpan GetDuration();
event EventHandler? PlaybackEnded;
}
@@ -42,6 +42,8 @@ public class AppSettings
public int SpaceReservationPercent { get; set; } = 10;
public string MusicSyncRule { get; set; } = "All Music (Automatic Sync)";
public string PodcastSyncRule { get; set; } = "3 Newest Episodes";
public string VideoSyncRule { get; set; } = "All Videos & Pictures";
public string PicturesSyncRule { get; set; } = "Newest 25 Items";
public bool WirelessSyncEnabled { get; set; } = true;
public string NetworkName { get; set; } = "Home-WiFi (WPA2)";
@@ -0,0 +1,37 @@
using NotZune.Application.Interfaces;
using NotZune.Domain.Models;
namespace NotZune.Application.Services;
/// <summary>Default no-op video library used when the persistence layer is not wired (e.g. unit tests).</summary>
public sealed class EmptyVideoLibraryService : IVideoLibraryService
{
public Task<IReadOnlyList<Video>> GetAllVideosAsync() => Task.FromResult<IReadOnlyList<Video>>(new List<Video>());
public Task ScanDirectoryAsync(string directoryPath, IProgress<double>? progress = null) => Task.CompletedTask;
public Task MarkPlayedAsync(Guid videoId) => Task.CompletedTask;
}
/// <summary>Default no-op photo library used when the persistence layer is not wired (e.g. unit tests).</summary>
public sealed class EmptyPhotoLibraryService : IPhotoLibraryService
{
public Task<IReadOnlyList<Photo>> GetAllPhotosAsync() => Task.FromResult<IReadOnlyList<Photo>>(new List<Photo>());
public Task ScanDirectoryAsync(string directoryPath, IProgress<double>? progress = null) => Task.CompletedTask;
public Task<IReadOnlyList<string>> GetFoldersAsync() => Task.FromResult<IReadOnlyList<string>>(new List<string>());
}
/// <summary>Default unavailable video engine (no libVLC) — all operations are safe no-ops.</summary>
public sealed class UnavailableVideoPlaybackEngine : IVideoPlaybackEngine
{
public bool IsAvailable => false;
public object? MediaPlayerHandle => null;
public void LoadAndPlay(string filePath) { }
public void Play() { }
public void Pause() { }
public void Stop() { }
public void SetVolume(double volume, bool muted) { }
public void Seek(TimeSpan position) { }
public TimeSpan GetPosition() => TimeSpan.Zero;
public TimeSpan GetDuration() => TimeSpan.Zero;
public event EventHandler? PlaybackEnded { add { } remove { } }
public void Dispose() { }
}
+27 -1
View File
@@ -10,7 +10,8 @@ using NotZune.Domain.Models;
using NotZune.Infrastructure.Audio;
using NotZune.Infrastructure.Devices;
using NotZune.Infrastructure.External;
using NotZune.Infrastructure.Persistence;using NotZune.UI.Services;
using NotZune.Infrastructure.Persistence;
using NotZune.Infrastructure.Video;using NotZune.UI.Services;
using NotZune.UI.ViewModels;
namespace NotZune.Desktop;
@@ -77,6 +78,9 @@ public partial class App : Avalonia.Application
services.AddSingleton<IExternalMetadataService, ExternalMetadataService>();
services.AddSingleton<IArtistEnrichmentService, ArtistEnrichmentCoordinator>();
services.AddSingleton<ISmartPlaylistService, SmartPlaylistService>();
services.AddSingleton<IVideoLibraryService, VideoLibraryService>();
services.AddSingleton<IPhotoLibraryService, PhotoLibraryService>();
services.AddSingleton<IVideoPlaybackEngine, VideoPlaybackEngine>();
// 3. Audio & Hardware Subsystems
services.AddSingleton<AudioEngine>();
@@ -108,6 +112,28 @@ public partial class App : Avalonia.Application
CreatedAtUtc TEXT NOT NULL,
UpdatedAtUtc TEXT NOT NULL)");
ctx.Database.ExecuteSqlRaw(@"CREATE TABLE IF NOT EXISTS Videos (
Id TEXT NOT NULL PRIMARY KEY,
Title TEXT NOT NULL,
FilePath TEXT NOT NULL,
Duration TEXT NOT NULL,
Year INTEGER,
Genre TEXT NOT NULL,
ArtworkUri TEXT,
PlayCount INTEGER NOT NULL,
LastPlayedAtUtc TEXT,
SizeBytes INTEGER NOT NULL,
AddedAtUtc TEXT NOT NULL)");
ctx.Database.ExecuteSqlRaw(@"CREATE TABLE IF NOT EXISTS Photos (
Id TEXT NOT NULL PRIMARY KEY,
Title TEXT NOT NULL,
FilePath TEXT NOT NULL,
FolderPath TEXT NOT NULL,
TakenDate TEXT,
SizeBytes INTEGER NOT NULL,
AddedAtUtc TEXT NOT NULL)");
if (!ctx.Tracks.Any())
{
// Artists
@@ -2,6 +2,7 @@
<ItemGroup>
<ProjectReference Include="..\NotZune.UI\NotZune.UI.csproj" />
<ProjectReference Include="..\NotZune.Infrastructure.Video\NotZune.Infrastructure.Video.csproj" />
</ItemGroup>
<ItemGroup>
+29
View File
@@ -0,0 +1,29 @@
using System;
namespace NotZune.Domain.Models;
public class Video
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Title { get; set; } = string.Empty;
public string FilePath { get; set; } = string.Empty;
public TimeSpan Duration { get; set; }
public int? Year { get; set; }
public string Genre { get; set; } = string.Empty;
public string? ArtworkUri { get; set; }
public int PlayCount { get; set; }
public DateTime? LastPlayedAtUtc { get; set; }
public long SizeBytes { get; set; }
public DateTime AddedAtUtc { get; set; } = DateTime.UtcNow;
}
public class Photo
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Title { get; set; } = string.Empty;
public string FilePath { get; set; } = string.Empty;
public string FolderPath { get; set; } = string.Empty;
public DateTime? TakenDate { get; set; }
public long SizeBytes { get; set; }
public DateTime AddedAtUtc { get; set; } = DateTime.UtcNow;
}
@@ -12,6 +12,8 @@ public class AppDbContext : DbContext
public DbSet<Playlist> Playlists => Set<Playlist>();
public DbSet<PlayHistoryEntry> PlayHistory => Set<PlayHistoryEntry>();
public DbSet<SmartPlaylist> SmartPlaylists => Set<SmartPlaylist>();
public DbSet<Video> Videos => Set<Video>();
public DbSet<Photo> Photos => Set<Photo>();
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
{
@@ -72,5 +74,19 @@ public class AppDbContext : DbContext
v => System.Text.Json.JsonSerializer.Serialize(v, (System.Text.Json.JsonSerializerOptions?)null),
v => System.Text.Json.JsonSerializer.Deserialize<List<SmartPlaylistRule>>(string.IsNullOrEmpty(v) ? "[]" : v, (System.Text.Json.JsonSerializerOptions?)null) ?? new List<SmartPlaylistRule>());
});
modelBuilder.Entity<Video>(entity =>
{
entity.HasKey(v => v.Id);
entity.HasIndex(v => v.Title);
entity.HasIndex(v => v.FilePath);
});
modelBuilder.Entity<Photo>(entity =>
{
entity.HasKey(p => p.Id);
entity.HasIndex(p => p.FilePath);
entity.HasIndex(p => p.FolderPath);
});
}
}
@@ -0,0 +1,104 @@
using Microsoft.EntityFrameworkCore;
using NotZune.Application.Interfaces;
using NotZune.Domain.Models;
namespace NotZune.Infrastructure.Persistence;
/// <summary>
/// SQLite-backed photo library with recursive folder scanning (PHOTOLIBRARY parity).
/// </summary>
public sealed class PhotoLibraryService : IPhotoLibraryService
{
private static readonly HashSet<string> PhotoExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".jpg", ".jpeg", ".png", ".bmp", ".gif", ".webp"
};
private readonly IDbContextFactory<AppDbContext> _contextFactory;
public PhotoLibraryService(IDbContextFactory<AppDbContext> contextFactory)
{
_contextFactory = contextFactory;
}
public async Task<IReadOnlyList<Photo>> GetAllPhotosAsync()
{
await using var ctx = await _contextFactory.CreateDbContextAsync();
var photos = await ctx.Photos.OrderBy(p => p.FolderPath).ThenBy(p => p.FilePath).ToListAsync();
return photos;
}
public async Task ScanDirectoryAsync(string directoryPath, IProgress<double>? progress = null)
{
if (!Directory.Exists(directoryPath))
{
return;
}
var files = Directory.EnumerateFiles(directoryPath, "*", SearchOption.AllDirectories)
.Where(f => PhotoExtensions.Contains(Path.GetExtension(f)))
.ToList();
await using var ctx = await _contextFactory.CreateDbContextAsync();
var existingPaths = (await ctx.Photos.ToListAsync()).Select(p => p.FilePath).ToHashSet(StringComparer.OrdinalIgnoreCase);
var added = 0;
for (var i = 0; i < files.Count; i++)
{
var file = files[i];
if (existingPaths.Contains(file))
{
continue;
}
var photo = new Photo
{
Title = Path.GetFileNameWithoutExtension(file),
FilePath = file,
FolderPath = Path.GetDirectoryName(file) ?? directoryPath,
SizeBytes = new FileInfo(file).Length,
TakenDate = TryGetTakenDate(file),
AddedAtUtc = DateTime.UtcNow
};
ctx.Photos.Add(photo);
added++;
if (added % 50 == 0)
{
await ctx.SaveChangesAsync();
progress?.Report(files.Count == 0 ? 1.0 : (double)i / files.Count);
}
}
await ctx.SaveChangesAsync();
progress?.Report(1.0);
}
public async Task<IReadOnlyList<string>> GetFoldersAsync()
{
await using var ctx = await _contextFactory.CreateDbContextAsync();
var folders = await ctx.Photos
.Select(p => p.FolderPath)
.Distinct()
.OrderBy(f => f)
.ToListAsync();
return folders;
}
private static DateTime? TryGetTakenDate(string filePath)
{
try
{
// Prefer the file's creation timestamp as a lightweight "taken" approximation;
// full EXIF parsing is deferred (TagLib# image support is format-limited).
var info = new FileInfo(filePath);
var candidate = info.CreationTimeUtc < info.LastWriteTimeUtc ? info.CreationTimeUtc : info.LastWriteTimeUtc;
return candidate;
}
catch
{
return null;
}
}
}
@@ -0,0 +1,100 @@
using Microsoft.EntityFrameworkCore;
using NotZune.Application.Interfaces;
using NotZune.Domain.Models;
namespace NotZune.Infrastructure.Persistence;
/// <summary>
/// SQLite-backed video library with recursive folder scanning (VIDEOLIBRARY parity).
/// </summary>
public sealed class VideoLibraryService : IVideoLibraryService
{
private static readonly HashSet<string> VideoExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".mp4", ".m4v", ".mkv", ".avi", ".mov", ".wmv", ".webm", ".mpg", ".mpeg", ".ts"
};
private readonly IDbContextFactory<AppDbContext> _contextFactory;
public VideoLibraryService(IDbContextFactory<AppDbContext> contextFactory)
{
_contextFactory = contextFactory;
}
public async Task<IReadOnlyList<Video>> GetAllVideosAsync()
{
await using var ctx = await _contextFactory.CreateDbContextAsync();
var videos = await ctx.Videos.OrderBy(v => v.Title).ToListAsync();
return videos;
}
public async Task ScanDirectoryAsync(string directoryPath, IProgress<double>? progress = null)
{
if (!Directory.Exists(directoryPath))
{
return;
}
var files = Directory.EnumerateFiles(directoryPath, "*", SearchOption.AllDirectories)
.Where(f => VideoExtensions.Contains(Path.GetExtension(f)))
.ToList();
var added = 0;
await using var ctx = await _contextFactory.CreateDbContextAsync();
var existingPaths = (await ctx.Videos.ToListAsync()).Select(v => v.FilePath).ToHashSet(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < files.Count; i++)
{
var file = files[i];
if (existingPaths.Contains(file))
{
continue;
}
var video = new Video
{
Title = Path.GetFileNameWithoutExtension(file),
FilePath = file,
SizeBytes = new FileInfo(file).Length,
AddedAtUtc = DateTime.UtcNow
};
try
{
using var tagFile = TagLib.File.Create(file);
if (tagFile.Properties?.Duration.TotalMilliseconds > 0)
{
video.Duration = tagFile.Properties.Duration;
}
}
catch
{
// Unsupported containers (e.g. mkv) simply keep a zero duration until played.
}
ctx.Videos.Add(video);
added++;
if (added % 10 == 0)
{
await ctx.SaveChangesAsync();
progress?.Report(files.Count == 0 ? 1.0 : (double)i / files.Count);
}
}
await ctx.SaveChangesAsync();
progress?.Report(1.0);
}
public async Task MarkPlayedAsync(Guid videoId)
{
await using var ctx = await _contextFactory.CreateDbContextAsync();
var video = await ctx.Videos.FindAsync(videoId);
if (video != null)
{
video.PlayCount++;
video.LastPlayedAtUtc = DateTime.UtcNow;
await ctx.SaveChangesAsync();
}
}
}
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\NotZune.Domain\NotZune.Domain.csproj" />
<ProjectReference Include="..\NotZune.Application\NotZune.Application.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="LibVLCSharp" Version="3.9.5" />
<PackageReference Include="LibVLCSharp.Avalonia" Version="3.9.5" />
<PackageReference Include="VideoLAN.LibVLC.Windows" Version="3.0.23.1" />
<PackageReference Include="TagLibSharp" Version="2.3.0" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
@@ -0,0 +1,77 @@
using System.Runtime.InteropServices;
namespace NotZune.Infrastructure.Video;
/// <summary>
/// Resolves libvlc/libvlccore DllImports to system-installed VLC 3.x libraries on Linux
/// (distros ship versioned sonames like libvlc.so.5 without an unversioned symlink).
/// On Windows the VideoLAN.LibVLC.Windows package ships natives next to the app, so the
/// default probing already succeeds.
/// </summary>
public static class VideoNativeResolver
{
private static bool _registered;
private static readonly object Gate = new();
public static void Register()
{
lock (Gate)
{
if (_registered || !OperatingSystem.IsLinux())
{
_registered = true;
return;
}
_registered = true;
try
{
NativeLibrary.SetDllImportResolver(typeof(LibVLCSharp.Shared.LibVLC).Assembly, Resolve);
}
catch
{
// Resolver registration is best-effort; default probing may still succeed.
}
}
}
private static IntPtr Resolve(string libraryName, System.Reflection.Assembly assembly, DllImportSearchPath? searchPath)
{
if (libraryName != "libvlc" && libraryName != "libvlccore")
{
return IntPtr.Zero;
}
foreach (var candidate in CandidatePaths(libraryName))
{
if (NativeLibrary.TryLoad(candidate, out var handle))
{
return handle;
}
}
return IntPtr.Zero;
}
private static IEnumerable<string> CandidatePaths(string libraryName)
{
var isCore = libraryName == "libvlccore";
var versioned = isCore ? "libvlccore.so.9" : "libvlc.so.5";
var architectures = new[]
{
Environment.Is64BitProcess ? "x86_64-linux-gnu" : "i386-linux-gnu",
Environment.Is64BitProcess ? "x86_64" : "i686",
"aarch64-linux-gnu"
};
foreach (var arch in architectures)
{
yield return $"/usr/lib/{arch}/{versioned}";
}
yield return $"/lib64/{versioned}";
yield return $"/usr/lib64/{versioned}";
yield return $"/usr/lib/{versioned}";
}
}
@@ -0,0 +1,207 @@
using System.Runtime.InteropServices;
using LibVLCSharp.Shared;
using NotZune.Application.Interfaces;
namespace NotZune.Infrastructure.Video;
/// <summary>
/// Real video playback engine built on libVLC (VLC 3.x).
/// Natives: bundled via VideoLAN.LibVLC.Windows on Windows; resolved from the system
/// installation on Linux via <see cref="VideoNativeResolver"/>.
/// Degrades gracefully to <see cref="IsAvailable"/> = false when libVLC is missing.
/// </summary>
public sealed class VideoPlaybackEngine : IVideoPlaybackEngine
{
private static bool _resolverRegistered;
private readonly object _gate = new();
private LibVLC? _libVlc;
private MediaPlayer? _mediaPlayer;
private bool _initialized;
private bool _disposed;
public bool IsAvailable { get; private set; }
/// <summary>The LibVLCSharp MediaPlayer, for Avalonia VideoView binding. Null when unavailable.</summary>
public object? MediaPlayerHandle => IsAvailable ? _mediaPlayer : null;
public event EventHandler? PlaybackEnded;
public VideoPlaybackEngine()
{
RegisterResolver();
}
private static void RegisterResolver()
{
if (_resolverRegistered)
{
return;
}
_resolverRegistered = true;
VideoNativeResolver.Register();
}
private bool EnsureInitialized()
{
if (_initialized)
{
return IsAvailable;
}
_initialized = true;
try
{
_libVlc = new LibVLC(enableDebugLogs: false, "--no-video-title-show", "--no-snapshot-preview");
_mediaPlayer = new MediaPlayer(_libVlc);
_mediaPlayer.EndReached += (_, _) => PlaybackEnded?.Invoke(this, EventArgs.Empty);
IsAvailable = true;
}
catch (Exception)
{
// libVLC natives missing (e.g. Linux without VLC installed) — degrade silently.
IsAvailable = false;
_libVlc = null;
_mediaPlayer = null;
}
return IsAvailable;
}
public void LoadAndPlay(string filePath)
{
if (!EnsureInitialized() || _mediaPlayer == null || _libVlc == null || string.IsNullOrWhiteSpace(filePath))
{
return;
}
lock (_gate)
{
using var media = new Media(_libVlc, new Uri(filePath));
_mediaPlayer.Play(media);
}
}
public void Play()
{
if (!IsAvailable || _mediaPlayer == null)
{
return;
}
lock (_gate)
{
_mediaPlayer.Play();
}
}
public void Pause()
{
if (!IsAvailable || _mediaPlayer == null)
{
return;
}
lock (_gate)
{
_mediaPlayer.Pause();
}
}
public void Stop()
{
if (!IsAvailable || _mediaPlayer == null)
{
return;
}
lock (_gate)
{
_mediaPlayer.Stop();
}
}
public void SetVolume(double volume, bool muted)
{
if (!IsAvailable || _mediaPlayer == null)
{
return;
}
lock (_gate)
{
_mediaPlayer.Volume = muted ? 0 : (int)Math.Clamp(volume * 100, 0, 100);
}
}
public void Seek(TimeSpan position)
{
if (!IsAvailable || _mediaPlayer == null)
{
return;
}
lock (_gate)
{
if (_mediaPlayer.Time >= 0)
{
_mediaPlayer.Time = (long)Math.Max(0, position.TotalMilliseconds);
}
}
}
public TimeSpan GetPosition()
{
if (!IsAvailable || _mediaPlayer == null)
{
return TimeSpan.Zero;
}
lock (_gate)
{
var ms = _mediaPlayer.Time;
return ms < 0 ? TimeSpan.Zero : TimeSpan.FromMilliseconds(ms);
}
}
public TimeSpan GetDuration()
{
if (!IsAvailable || _mediaPlayer == null)
{
return TimeSpan.Zero;
}
lock (_gate)
{
var ms = _mediaPlayer.Length;
return ms <= 0 ? TimeSpan.Zero : TimeSpan.FromMilliseconds(ms);
}
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
try
{
lock (_gate)
{
_mediaPlayer?.Stop();
_mediaPlayer?.Dispose();
_mediaPlayer = null;
_libVlc?.Dispose();
_libVlc = null;
}
}
catch
{
}
GC.SuppressFinalize(this);
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1006 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

@@ -16,6 +16,9 @@ public class ArtworkSourceConverter : IValueConverter
{
public static readonly ArtworkSourceConverter Instance = new();
/// <summary>High-resolution variant for photo zoom/slideshow surfaces.</summary>
public static readonly ArtworkSourceConverter Large = new() { DecodeWidth = 1600 };
public int DecodeWidth { get; set; } = 300;
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
@@ -0,0 +1,25 @@
using System;
using System.Globalization;
using Avalonia.Data.Converters;
using LibVLCSharp.Shared;
namespace NotZune.UI.Converters;
/// <summary>
/// Casts the engine's MediaPlayerHandle (object) to the LibVLCSharp.MediaPlayer
/// that the LibVLCSharp.Avalonia VideoView control binds to.
/// </summary>
public class ObjectToMediaPlayerConverter : IValueConverter
{
public static readonly ObjectToMediaPlayerConverter Instance = new();
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
return value as MediaPlayer;
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
throw new NotSupportedException();
}
}
@@ -0,0 +1,32 @@
using System;
using System.Globalization;
using System.IO;
using Avalonia.Data.Converters;
namespace NotZune.UI.Converters;
/// <summary>
/// Renders the photo folder tree entries: the sentinel shows "All Photos",
/// other entries show the folder's display name.
/// </summary>
public class PhotoFolderPathConverter : IValueConverter
{
public static readonly PhotoFolderPathConverter Instance = new();
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is not string path)
{
return string.Empty;
}
return path == NotZune.UI.ViewModels.PhotoLibraryViewModel.AllFoldersEntry
? "All Photos"
: Path.GetFileName(path.TrimEnd(Path.DirectorySeparatorChar));
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
throw new NotSupportedException();
}
}
+1
View File
@@ -7,6 +7,7 @@
<ProjectReference Include="..\NotZune.Infrastructure.Audio\NotZune.Infrastructure.Audio.csproj" />
<ProjectReference Include="..\NotZune.Infrastructure.Devices\NotZune.Infrastructure.Devices.csproj" />
<ProjectReference Include="..\NotZune.Infrastructure.External\NotZune.Infrastructure.External.csproj" />
<ProjectReference Include="..\NotZune.Infrastructure.Video\NotZune.Infrastructure.Video.csproj" />
<ProjectReference Include="..\NotZune.Plugins.Sdk\NotZune.Plugins.Sdk.csproj" />
</ItemGroup>
+3
View File
@@ -23,6 +23,9 @@ public class ViewLocator : IDataTemplate
MainShellViewModel => new MainShellView(),
ZuneCardViewModel => new ZuneCardView(),
PodcastsViewModel => new PodcastsView(),
PlaylistsViewModel => new PlaylistsView(),
VideoLibraryViewModel => new VideoLibraryView(),
PhotoLibraryViewModel => new PhotoLibraryView(),
CDViewModel => new CDView(),
_ => FindViewByConvention(data)
};
@@ -14,7 +14,9 @@ public enum CollectionSubPivot
Songs,
Genres,
Podcasts,
Playlists
Playlists,
Videos,
Pictures
}
public class CollectionViewModel : ViewModelBase
@@ -48,6 +50,8 @@ public class CollectionViewModel : ViewModelBase
public PodcastsViewModel PodcastsVM { get; }
public PlaylistsViewModel PlaylistsVM { get; }
public VideoLibraryViewModel VideoVM { get; }
public PhotoLibraryViewModel PhotoVM { get; }
public ObservableCollection<Artist> Artists { get; } = new();
public ObservableCollection<Album> Albums { get; } = new();
@@ -69,6 +73,8 @@ public class CollectionViewModel : ViewModelBase
OnPropertyChanged(nameof(IsGenresActive));
OnPropertyChanged(nameof(IsPodcastsActive));
OnPropertyChanged(nameof(IsPlaylistsActive));
OnPropertyChanged(nameof(IsVideosActive));
OnPropertyChanged(nameof(IsPicturesActive));
}
}
}
@@ -79,6 +85,8 @@ public class CollectionViewModel : ViewModelBase
public bool IsGenresActive => _activeSubPivot == CollectionSubPivot.Genres;
public bool IsPodcastsActive => _activeSubPivot == CollectionSubPivot.Podcasts;
public bool IsPlaylistsActive => _activeSubPivot == CollectionSubPivot.Playlists;
public bool IsVideosActive => _activeSubPivot == CollectionSubPivot.Videos;
public bool IsPicturesActive => _activeSubPivot == CollectionSubPivot.Pictures;
public Artist? SelectedArtist
{
@@ -138,6 +146,21 @@ public class CollectionViewModel : ViewModelBase
public bool IsEditMetadataOpen => ActiveEditMetadataVM != null;
private PhotoSlideshowViewModel? _activeSlideshowVM;
public PhotoSlideshowViewModel? ActiveSlideshowVM
{
get => _activeSlideshowVM;
set
{
if (SetProperty(ref _activeSlideshowVM, value))
{
OnPropertyChanged(nameof(IsSlideshowOpen));
}
}
}
public bool IsSlideshowOpen => ActiveSlideshowVM != null;
private string? _findAlbumInfoStatusText;
public string? FindAlbumInfoStatusText
{
@@ -162,7 +185,10 @@ public class CollectionViewModel : ViewModelBase
ISmartDJService? smartDJService = null,
IArtworkCacheService? artworkCache = null,
IExternalMetadataService? metadataService = null,
ISmartPlaylistService? smartPlaylistService = null)
ISmartPlaylistService? smartPlaylistService = null,
IVideoLibraryService? videoLibraryService = null,
IVideoPlaybackEngine? videoEngine = null,
IPhotoLibraryService? photoLibraryService = null)
{
_playerCoordinator = playerCoordinator;
_libraryService = libraryService;
@@ -171,6 +197,18 @@ public class CollectionViewModel : ViewModelBase
_metadataService = metadataService;
var podService = podcastService ?? new NotZune.Application.Services.PodcastService(playerCoordinator);
PodcastsVM = new PodcastsViewModel(podService);
VideoVM = new VideoLibraryViewModel(
videoLibraryService ?? new NotZune.Application.Services.EmptyVideoLibraryService(),
videoEngine ?? new NotZune.Application.Services.UnavailableVideoPlaybackEngine());
PhotoVM = new PhotoLibraryViewModel(
photoLibraryService ?? new NotZune.Application.Services.EmptyPhotoLibraryService());
PhotoVM.SlideshowRequested += (_, photo) =>
{
var startIndex = photo != null ? PhotoVM.GalleryPhotos.IndexOf(photo) : 0;
var slideshow = new PhotoSlideshowViewModel(PhotoVM.GalleryPhotos, Math.Max(0, startIndex));
slideshow.RequestClose += (_, _) => ActiveSlideshowVM = null;
ActiveSlideshowVM = slideshow;
};
PlaylistsVM = new PlaylistsViewModel(libraryService, playerCoordinator, smartPlaylistService);
OpenEditMetadataCommand = new RelayCommand<Track>(track =>
@@ -347,7 +347,10 @@ public class MainShellViewModel : ViewModelBase
IArtworkCacheService? artworkCacheService = null,
IExternalMetadataService? metadataService = null,
IAudioOutputEngine? audioEngine = null,
ISmartPlaylistService? smartPlaylistService = null)
ISmartPlaylistService? smartPlaylistService = null,
IVideoLibraryService? videoLibraryService = null,
IVideoPlaybackEngine? videoEngine = null,
IPhotoLibraryService? photoLibraryService = null)
{
_playerCoordinator = playerCoordinator;
_libraryService = libraryService;
@@ -358,8 +361,8 @@ public class MainShellViewModel : ViewModelBase
// Child ViewModels
QuickplayVM = new QuickplayViewModel(playerCoordinator, libraryService, smartDJService);
CollectionVM = new CollectionViewModel(playerCoordinator, libraryService, podService, smartDJService, artworkCacheService, metadataService, smartPlaylistService);
NowPlayingVM = new NowPlayingViewModel(playerCoordinator, libraryService, enrichmentService, audioEngine);
CollectionVM = new CollectionViewModel(playerCoordinator, libraryService, podService, smartDJService, artworkCacheService, metadataService, smartPlaylistService, videoLibraryService, videoEngine, photoLibraryService);
NowPlayingVM = new NowPlayingViewModel(playerCoordinator, libraryService, enrichmentService, audioEngine, videoLibraryService, videoEngine);
DeviceVM = new DeviceViewModel(deviceSyncService, libraryService);
SettingsVM = new SettingsViewModel(_soundEffectService, folderPickerService, _libraryService, playerCoordinator, deviceSyncService, settingsStore);
@@ -17,7 +17,8 @@ namespace NotZune.UI.ViewModels;
public enum NowPlayingMode
{
ArtistCanvas,
MosaicWall
MosaicWall,
Video
}
public class NowPlayingViewModel : ViewModelBase
@@ -99,12 +100,92 @@ public class NowPlayingViewModel : ViewModelBase
{
OnPropertyChanged(nameof(IsArtistCanvasMode));
OnPropertyChanged(nameof(IsMosaicWallMode));
OnPropertyChanged(nameof(IsVideoMode));
if (value == NowPlayingMode.Video)
{
_ = LoadLibraryVideosAsync();
}
}
}
}
public bool IsArtistCanvasMode => _mode == NowPlayingMode.ArtistCanvas;
public bool IsMosaicWallMode => _mode == NowPlayingMode.MosaicWall;
public bool IsVideoMode => _mode == NowPlayingMode.Video;
private readonly IVideoLibraryService? _videoLibraryService;
private readonly IVideoPlaybackEngine? _videoEngine;
public ObservableCollection<Video> LibraryVideos { get; } = new();
private Video? _selectedVideo;
public Video? SelectedVideo
{
get => _selectedVideo;
set
{
if (SetProperty(ref _selectedVideo, value))
{
OpenVideoPlayback();
}
}
}
private VideoPlaybackViewModel? _videoPlaybackVM;
public VideoPlaybackViewModel? VideoPlaybackVM
{
get => _videoPlaybackVM;
private set
{
if (SetProperty(ref _videoPlaybackVM, value))
{
OnPropertyChanged(nameof(HasVideoPlayback));
}
}
}
public bool HasVideoPlayback => VideoPlaybackVM != null;
private async System.Threading.Tasks.Task LoadLibraryVideosAsync()
{
if (_videoLibraryService == null)
{
return;
}
try
{
var videos = await _videoLibraryService.GetAllVideosAsync();
LibraryVideos.Clear();
foreach (var v in videos)
{
LibraryVideos.Add(v);
}
if (_selectedVideo == null && LibraryVideos.Count > 0)
{
SelectedVideo = LibraryVideos[0];
}
}
catch (Exception)
{
// Video library is best-effort inside Now Playing.
}
}
private void OpenVideoPlayback()
{
if (_videoEngine == null || _selectedVideo == null)
{
VideoPlaybackVM = null;
return;
}
var playback = new VideoPlaybackViewModel(_videoEngine, _selectedVideo);
playback.Start();
VideoPlaybackVM = playback;
}
public bool IsHudVisible
{
@@ -176,12 +257,16 @@ public class NowPlayingViewModel : ViewModelBase
IPlayerCoordinator playerCoordinator,
IMediaLibraryService libraryService,
IArtistEnrichmentService? enrichmentService = null,
IAudioOutputEngine? audioEngine = null)
IAudioOutputEngine? audioEngine = null,
IVideoLibraryService? videoLibraryService = null,
IVideoPlaybackEngine? videoEngine = null)
{
_playerCoordinator = playerCoordinator;
_libraryService = libraryService;
_enrichmentService = enrichmentService;
_audioEngine = audioEngine;
_videoLibraryService = videoLibraryService;
_videoEngine = videoEngine;
_activeBackdrops = new List<string>(_themeBackdrops);
_currentBackdropImage = _activeBackdrops[0];
@@ -266,7 +351,12 @@ public class NowPlayingViewModel : ViewModelBase
ToggleModeCommand = new RelayCommand(() =>
{
Mode = Mode == NowPlayingMode.ArtistCanvas ? NowPlayingMode.MosaicWall : NowPlayingMode.ArtistCanvas;
Mode = Mode switch
{
NowPlayingMode.ArtistCanvas => NowPlayingMode.MosaicWall,
NowPlayingMode.MosaicWall => NowPlayingMode.Video,
_ => NowPlayingMode.ArtistCanvas
};
});
ToggleBioDrawerCommand = new RelayCommand(() =>
@@ -0,0 +1,172 @@
using System;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Input;
using CommunityToolkit.Mvvm.Input;
using NotZune.Application.Interfaces;
using NotZune.Domain.Models;
namespace NotZune.UI.ViewModels;
/// <summary>
/// Photo library browser with folder tree + gallery grid (PHOTOLIBRARY / GALLERYVIEW parity).
/// </summary>
public class PhotoLibraryViewModel : ViewModelBase
{
private readonly IPhotoLibraryService _photoLibraryService;
public ObservableCollection<Photo> AllPhotos { get; } = new();
public ObservableCollection<Photo> GalleryPhotos { get; } = new();
public ObservableCollection<string> Folders { get; } = new();
private string? _selectedFolder;
public string? SelectedFolder
{
get => _selectedFolder;
set
{
if (SetProperty(ref _selectedFolder, value))
{
ApplyFolderFilter();
OnPropertyChanged(nameof(SelectedFolderName));
OnPropertyChanged(nameof(GalleryStatsText));
}
}
}
public string SelectedFolderName => SelectedFolder == AllFoldersEntry ? "All Photos" : Path.GetFileName(SelectedFolder ?? string.Empty);
private Photo? _zoomedPhoto;
public Photo? ZoomedPhoto
{
get => _zoomedPhoto;
set
{
if (SetProperty(ref _zoomedPhoto, value))
{
OnPropertyChanged(nameof(IsZoomOpen));
}
}
}
public bool IsZoomOpen => ZoomedPhoto != null;
private bool _isScanning;
public bool IsScanning
{
get => _isScanning;
set => SetProperty(ref _isScanning, value);
}
private string? _statusText;
public string? StatusText
{
get => _statusText;
set => SetProperty(ref _statusText, value);
}
public const string AllFoldersEntry = "<<all>>";
public event EventHandler<Photo>? SlideshowRequested;
public ICommand ZoomPhotoCommand { get; }
public ICommand CloseZoomCommand { get; }
public ICommand StartSlideshowCommand { get; }
public ICommand ScanFolderCommand { get; }
public PhotoLibraryViewModel(IPhotoLibraryService photoLibraryService)
{
_photoLibraryService = photoLibraryService;
ZoomPhotoCommand = new RelayCommand<Photo>(p => ZoomedPhoto = p);
CloseZoomCommand = new RelayCommand(() => ZoomedPhoto = null);
StartSlideshowCommand = new RelayCommand(OnStartSlideshow);
ScanFolderCommand = new AsyncRelayCommand<string>(OnScanFolderAsync);
_ = LoadPhotosAsync();
}
public async Task LoadPhotosAsync()
{
var photos = await _photoLibraryService.GetAllPhotosAsync();
AllPhotos.Clear();
foreach (var p in photos)
{
AllPhotos.Add(p);
}
var folders = await _photoLibraryService.GetFoldersAsync();
Folders.Clear();
Folders.Add(AllFoldersEntry);
foreach (var f in folders)
{
Folders.Add(f);
}
if (SelectedFolder == null || Folders.All(f => f != SelectedFolder))
{
SelectedFolder = AllFoldersEntry;
}
else
{
ApplyFolderFilter();
}
}
private void ApplyFolderFilter()
{
GalleryPhotos.Clear();
foreach (var p in SelectedFolder == null || SelectedFolder == AllFoldersEntry
? AllPhotos
: AllPhotos.Where(p => p.FolderPath == SelectedFolder))
{
GalleryPhotos.Add(p);
}
OnPropertyChanged(nameof(GalleryStatsText));
OnPropertyChanged(nameof(HasNoPhotos));
OnPropertyChanged(nameof(CanStartSlideshow));
}
public string GalleryStatsText => $"{GalleryPhotos.Count} photo{((GalleryPhotos.Count == 1) ? string.Empty : "s")}";
public bool HasNoPhotos => GalleryPhotos.Count == 0;
public bool CanStartSlideshow => GalleryPhotos.Count > 0;
private void OnStartSlideshow()
{
if (GalleryPhotos.Count > 0)
{
SlideshowRequested?.Invoke(this, GalleryPhotos[0]);
}
}
private async Task OnScanFolderAsync(string? folderPath)
{
if (string.IsNullOrWhiteSpace(folderPath) || !Directory.Exists(folderPath))
{
StatusText = "Enter or select a valid folder to scan for pictures.";
return;
}
IsScanning = true;
StatusText = $"Scanning {folderPath} for pictures...";
try
{
await _photoLibraryService.ScanDirectoryAsync(folderPath);
await LoadPhotosAsync();
StatusText = $"Picture library scan complete — {AllPhotos.Count} photos.";
}
catch (Exception ex)
{
StatusText = $"Picture scan failed: {ex.Message}";
}
finally
{
IsScanning = false;
}
}
}
@@ -0,0 +1,155 @@
using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Timers;
using System.Windows.Input;
using CommunityToolkit.Mvvm.Input;
using NotZune.Domain.Models;
namespace NotZune.UI.ViewModels;
/// <summary>
/// Full-bleed photo slideshow with slow Ken-Burns drift and transport
/// (SLIDESHOWLAND / PHOTOSLIDESHOW parity, authentic SLIDESHOW.* transport assets).
/// </summary>
public class PhotoSlideshowViewModel : ViewModelBase
{
private static readonly Random _random = new();
private readonly System.Timers.Timer _advanceTimer;
public ObservableCollection<Photo> Photos { get; }
private int _index;
private Photo? _currentPhoto;
public Photo? CurrentPhoto
{
get => _currentPhoto;
private set
{
if (SetProperty(ref _currentPhoto, value))
{
OnPropertyChanged(nameof(CurrentTitle));
OnPropertyChanged(nameof(PositionText));
}
}
}
private bool _isPlaying = true;
public bool IsPlaying
{
get => _isPlaying;
private set
{
if (SetProperty(ref _isPlaying, value))
{
OnPropertyChanged(nameof(PlayPauseIcon));
}
}
}
private double _kenBurnsScale = 1.0;
public double KenBurnsScale
{
get => _kenBurnsScale;
set => SetProperty(ref _kenBurnsScale, value);
}
private double _kenBurnsTranslateX;
public double KenBurnsTranslateX
{
get => _kenBurnsTranslateX;
set => SetProperty(ref _kenBurnsTranslateX, value);
}
private double _kenBurnsTranslateY;
public double KenBurnsTranslateY
{
get => _kenBurnsTranslateY;
set => SetProperty(ref _kenBurnsTranslateY, value);
}
public event EventHandler? RequestClose;
public PhotoSlideshowViewModel(ObservableCollection<Photo> photos, int startIndex = 0)
{
Photos = new ObservableCollection<Photo>(photos);
if (Photos.Count > 0)
{
_index = Math.Clamp(startIndex, 0, Photos.Count - 1);
CurrentPhoto = Photos[_index];
RandomizeKenBurns();
}
_advanceTimer = new System.Timers.Timer(6000) { AutoReset = true };
_advanceTimer.Elapsed += (_, _) => Next();
if (Photos.Count > 1)
{
_advanceTimer.Start();
}
NextCommand = new RelayCommand(Next);
BackCommand = new RelayCommand(Back);
PlayPauseCommand = new RelayCommand(OnPlayPause);
CloseCommand = new RelayCommand(OnClose);
}
public string CurrentTitle => CurrentPhoto?.Title ?? "Slideshow";
public string PositionText => Photos.Count == 0 ? string.Empty : $"{_index + 1} of {Photos.Count}";
public string PlayPauseIcon => IsPlaying ? "PAUSE" : "PLAY";
public ICommand NextCommand { get; }
public ICommand BackCommand { get; }
public ICommand PlayPauseCommand { get; }
public ICommand CloseCommand { get; }
private void Next()
{
if (Photos.Count == 0)
{
return;
}
_index = (_index + 1) % Photos.Count;
CurrentPhoto = Photos[_index];
RandomizeKenBurns();
}
private void Back()
{
if (Photos.Count == 0)
{
return;
}
_index = (_index - 1 + Photos.Count) % Photos.Count;
CurrentPhoto = Photos[_index];
RandomizeKenBurns();
}
private void OnPlayPause()
{
IsPlaying = !IsPlaying;
if (IsPlaying)
{
_advanceTimer.Start();
}
else
{
_advanceTimer.Stop();
}
}
private void OnClose()
{
_advanceTimer.Stop();
RequestClose?.Invoke(this, EventArgs.Empty);
}
private void RandomizeKenBurns()
{
KenBurnsScale = 1.0 + (_random.NextDouble() * 0.12);
KenBurnsTranslateX = (_random.NextDouble() * 40) - 20;
KenBurnsTranslateY = (_random.NextDouble() * 30) - 15;
}
}
@@ -700,6 +700,39 @@ public class SettingsViewModel : ViewModelBase
}
}
public ObservableCollection<string> MediaSyncRules { get; } = new()
{
"All Videos & Pictures",
"Newest 25 Items",
"Nothing (Manual Drag and Drop)"
};
private string _videoSyncRule = "All Videos & Pictures";
public string VideoSyncRule
{
get => _videoSyncRule;
set
{
if (SetProperty(ref _videoSyncRule, value))
{
SaveCurrentSettings();
}
}
}
private string _picturesSyncRule = "Newest 25 Items";
public string PicturesSyncRule
{
get => _picturesSyncRule;
set
{
if (SetProperty(ref _picturesSyncRule, value))
{
SaveCurrentSettings();
}
}
}
private bool _wirelessSyncEnabled = true;
public bool WirelessSyncEnabled
{
@@ -889,6 +922,10 @@ public class SettingsViewModel : ViewModelBase
OnPropertyChanged(nameof(MusicSyncRule));
_podcastSyncRule = settings.PodcastSyncRule;
OnPropertyChanged(nameof(PodcastSyncRule));
_videoSyncRule = settings.VideoSyncRule;
OnPropertyChanged(nameof(VideoSyncRule));
_picturesSyncRule = settings.PicturesSyncRule;
OnPropertyChanged(nameof(PicturesSyncRule));
_wirelessSyncEnabled = settings.WirelessSyncEnabled;
OnPropertyChanged(nameof(WirelessSyncEnabled));
_networkName = settings.NetworkName;
@@ -963,6 +1000,8 @@ public class SettingsViewModel : ViewModelBase
SpaceReservationPercent = SpaceReservationPercent,
MusicSyncRule = MusicSyncRule,
PodcastSyncRule = PodcastSyncRule,
VideoSyncRule = VideoSyncRule,
PicturesSyncRule = PicturesSyncRule,
WirelessSyncEnabled = WirelessSyncEnabled,
NetworkName = NetworkName,
SelectedAccentName = SelectedAccent.Name,
@@ -0,0 +1,143 @@
using System;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Input;
using CommunityToolkit.Mvvm.Input;
using NotZune.Application.Interfaces;
using NotZune.Domain.Models;
namespace NotZune.UI.ViewModels;
/// <summary>
/// Video library browser (VIDEOLIBRARY parity).
/// </summary>
public class VideoLibraryViewModel : ViewModelBase
{
private readonly IVideoLibraryService _videoLibraryService;
private readonly IVideoPlaybackEngine _videoEngine;
public ObservableCollection<Video> Videos { get; } = new();
private Video? _selectedVideo;
public Video? SelectedVideo
{
get => _selectedVideo;
set
{
if (SetProperty(ref _selectedVideo, value))
{
OnPropertyChanged(nameof(HasVideos));
}
}
}
public bool HasVideos => Videos.Count > 0;
private bool _isScanning;
public bool IsScanning
{
get => _isScanning;
set => SetProperty(ref _isScanning, value);
}
private string? _statusText;
public string? StatusText
{
get => _statusText;
set => SetProperty(ref _statusText, value);
}
private VideoPlaybackViewModel? _activePlayerVM;
public VideoPlaybackViewModel? ActivePlayerVM
{
get => _activePlayerVM;
set
{
if (SetProperty(ref _activePlayerVM, value))
{
OnPropertyChanged(nameof(IsPlayerOpen));
}
}
}
public bool IsPlayerOpen => ActivePlayerVM != null;
public ICommand PlayVideoCommand { get; }
public ICommand ClosePlayerCommand { get; }
public ICommand ScanFolderCommand { get; }
public VideoLibraryViewModel(IVideoLibraryService videoLibraryService, IVideoPlaybackEngine videoEngine)
{
_videoLibraryService = videoLibraryService;
_videoEngine = videoEngine;
PlayVideoCommand = new AsyncRelayCommand<Video>(OnPlayVideoAsync);
ClosePlayerCommand = new RelayCommand(OnClosePlayer);
ScanFolderCommand = new AsyncRelayCommand<string>(OnScanFolderAsync);
_ = LoadVideosAsync();
}
public async Task LoadVideosAsync()
{
var videos = await _videoLibraryService.GetAllVideosAsync();
Videos.Clear();
foreach (var v in videos)
{
Videos.Add(v);
}
OnPropertyChanged(nameof(HasVideos));
}
private async Task OnPlayVideoAsync(Video? video)
{
if (video == null)
{
return;
}
SelectedVideo = video;
ActivePlayerVM = new VideoPlaybackViewModel(_videoEngine, video);
ActivePlayerVM.RequestClose += async (_, _) =>
{
ActivePlayerVM = null;
await _videoLibraryService.MarkPlayedAsync(video.Id);
};
ActivePlayerVM.Start();
}
private void OnClosePlayer()
{
ActivePlayerVM?.CloseCommand.Execute(null);
ActivePlayerVM = null;
}
private async Task OnScanFolderAsync(string? folderPath)
{
if (string.IsNullOrWhiteSpace(folderPath) || !Directory.Exists(folderPath))
{
StatusText = "Enter or select a valid folder to scan for videos.";
return;
}
IsScanning = true;
StatusText = $"Scanning {folderPath} for videos...";
try
{
await _videoLibraryService.ScanDirectoryAsync(folderPath);
await LoadVideosAsync();
StatusText = $"Video library scan complete — {Videos.Count} videos.";
}
catch (Exception ex)
{
StatusText = $"Video scan failed: {ex.Message}";
}
finally
{
IsScanning = false;
}
}
}
@@ -0,0 +1,171 @@
using System;
using System.Timers;
using System.Windows.Input;
using CommunityToolkit.Mvvm.Input;
using NotZune.Application.Interfaces;
using NotZune.Domain.Models;
namespace NotZune.UI.ViewModels;
/// <summary>
/// Hosts one playing video (NOWPLAYINGCLIPS / video library playback parity).
/// The Avalonia VideoView binds to <see cref="MediaPlayerHandle"/>.
/// </summary>
public class VideoPlaybackViewModel : ViewModelBase
{
private readonly IVideoPlaybackEngine _engine;
private readonly Video? _video;
private readonly System.Timers.Timer _positionTimer;
private bool _isPlaying;
private string? _statusText;
public event EventHandler? RequestClose;
public VideoPlaybackViewModel(IVideoPlaybackEngine engine, Video? video)
{
_engine = engine;
_video = video;
_positionTimer = new System.Timers.Timer(500) { AutoReset = true };
_positionTimer.Elapsed += (_, _) => NotifyTimeProperties();
_positionTimer.Start();
PlayPauseCommand = new RelayCommand(OnPlayPause);
StopCommand = new RelayCommand(OnStop);
CloseCommand = new RelayCommand(OnClose);
SeekCommand = new RelayCommand<double>(OnSeek);
}
/// <summary>The live LibVLCSharp.MediaPlayer for the VideoView surface.</summary>
public object? MediaPlayerHandle => _engine.MediaPlayerHandle;
public string VideoTitle => _video?.Title ?? "Video";
public bool IsVideoAvailable => _engine.IsAvailable && _engine.MediaPlayerHandle != null;
public string UnavailableText => "Video playback is unavailable on this system. Install VLC (libvlc) and restart Not-Zune to enable video.";
public string? StatusText
{
get => _statusText;
set => SetProperty(ref _statusText, value);
}
public bool IsPlaying
{
get => _isPlaying;
private set
{
if (SetProperty(ref _isPlaying, value))
{
OnPropertyChanged(nameof(PlayPauseIcon));
}
}
}
public string PlayPauseIcon => IsPlaying ? "⏸" : "▶";
public TimeSpan Position => _engine.GetPosition();
public TimeSpan Duration
{
get
{
var engineDuration = _engine.GetDuration();
if (engineDuration > TimeSpan.Zero)
{
return engineDuration;
}
return _video?.Duration ?? TimeSpan.Zero;
}
}
public double ProgressFraction
{
get
{
var total = Duration.TotalMilliseconds;
return total <= 0 ? 0 : Math.Clamp(Position.TotalMilliseconds / total, 0, 1);
}
}
/// <summary>Two-way scrub target for the seek slider.</summary>
public double SeekFraction
{
get => ProgressFraction;
set => OnSeek(Math.Clamp(value, 0, 1));
}
public string TimeText => $"{Position:hh\\:mm\\:ss} / {Duration:hh\\:mm\\:ss}";
public ICommand PlayPauseCommand { get; }
public ICommand StopCommand { get; }
public ICommand CloseCommand { get; }
public ICommand SeekCommand { get; }
public void Start()
{
if (_video == null || !IsVideoAvailable)
{
StatusText = IsVideoAvailable ? null : UnavailableText;
return;
}
_engine.LoadAndPlay(_video.FilePath);
_engine.SetVolume(0.8, muted: false);
IsPlaying = true;
NotifyTimeProperties();
}
private void OnPlayPause()
{
if (IsPlaying)
{
_engine.Pause();
IsPlaying = false;
}
else
{
_engine.Play();
IsPlaying = true;
}
}
private void OnStop()
{
_engine.Stop();
IsPlaying = false;
NotifyTimeProperties();
}
private void OnClose()
{
_engine.Stop();
_positionTimer.Stop();
RequestClose?.Invoke(this, EventArgs.Empty);
}
private void OnSeek(double fraction)
{
var total = Duration.TotalMilliseconds;
if (total <= 0)
{
return;
}
_engine.Seek(TimeSpan.FromMilliseconds(fraction * total));
}
private void NotifyTimeProperties()
{
OnPropertyChanged(nameof(Position));
OnPropertyChanged(nameof(Duration));
OnPropertyChanged(nameof(ProgressFraction));
OnPropertyChanged(nameof(TimeText));
if (IsPlaying && _engine.GetDuration() > TimeSpan.Zero && _engine.GetPosition() >= _engine.GetDuration().Subtract(TimeSpan.FromMilliseconds(250)))
{
IsPlaying = false;
}
}
}
+13
View File
@@ -33,6 +33,12 @@
<Button Command="{Binding SelectSubPivotCommand}" CommandParameter="{x:Static vm:CollectionSubPivot.Playlists}">
<TextBlock Text="playlists" Classes="subpivot" Classes.active="{Binding IsPlaylistsActive}" />
</Button>
<Button Command="{Binding SelectSubPivotCommand}" CommandParameter="{x:Static vm:CollectionSubPivot.Videos}">
<TextBlock Text="videos" Classes="subpivot" Classes.active="{Binding IsVideosActive}" />
</Button>
<Button Command="{Binding SelectSubPivotCommand}" CommandParameter="{x:Static vm:CollectionSubPivot.Pictures}">
<TextBlock Text="pictures" Classes="subpivot" Classes.active="{Binding IsPicturesActive}" />
</Button>
</StackPanel>
<!-- Minimal Search Box -->
@@ -394,6 +400,8 @@
<!-- 6. PLAYLISTS SUB-PIVOT -->
<ContentControl IsVisible="{Binding IsPlaylistsActive}" Content="{Binding PlaylistsVM}" />
<ContentControl IsVisible="{Binding IsVideosActive}" Content="{Binding VideoVM}" />
<ContentControl IsVisible="{Binding IsPicturesActive}" Content="{Binding PhotoVM}" />
</Grid>
</Grid>
@@ -412,5 +420,10 @@
<Grid IsVisible="{Binding IsTrackMatchReviewOpen}" Background="#D9000000">
<views:TrackMatchReviewView DataContext="{Binding ActiveTrackMatchReviewVM}" HorizontalAlignment="Center" VerticalAlignment="Center" />
</Grid>
<!-- 10. PHOTO SLIDESHOW OVERLAY (SLIDESHOWLAND parity) -->
<Panel IsVisible="{Binding IsSlideshowOpen}" Background="Black" ZIndex="120">
<views:PhotoSlideshowView DataContext="{Binding ActiveSlideshowVM}" />
</Panel>
</Panel>
</UserControl>
+37 -1
View File
@@ -4,6 +4,7 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:NotZune.UI.ViewModels;assembly=NotZune.UI"
xmlns:models="clr-namespace:NotZune.Domain.Models;assembly=NotZune.Domain"
xmlns:views="clr-namespace:NotZune.UI.Views;assembly=NotZune.UI"
mc:Ignorable="d" d:DesignWidth="1100" d:DesignHeight="700"
x:Class="NotZune.UI.Views.NowPlayingView"
x:DataType="vm:NowPlayingViewModel">
@@ -306,10 +307,45 @@
<Button Command="{Binding ToggleModeCommand}" Classes="zune-action">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="⊞" FontSize="12" Foreground="{DynamicResource ZuneAccentBrush}" VerticalAlignment="Center" />
<TextBlock Text="WALL / CANVAS" Classes="caption" FontWeight="SemiBold" VerticalAlignment="Center" />
<TextBlock Text="WALL / CANVAS / VIDEO" Classes="caption" FontWeight="SemiBold" VerticalAlignment="Center" />
</StackPanel>
</Button>
</StackPanel>
<!-- MODE 3: NOW PLAYING VIDEO CLIPS (NOWPLAYINGCLIPS parity) -->
<Panel IsVisible="{Binding IsVideoMode}" Background="#FF070707">
<Grid RowDefinitions="Auto,*" Margin="40,20,40,80">
<StackPanel Grid.Row="0" Spacing="10">
<TextBlock Text="now playing clips" Classes="hero" FontSize="30" />
<Grid ColumnDefinitions="260,*" Margin="0,0,12,0">
<ListBox Grid.Column="0"
ItemsSource="{Binding LibraryVideos}"
SelectedItem="{Binding SelectedVideo}"
Background="Transparent"
CornerRadius="0"
MaxHeight="300">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="models:Video">
<Border Padding="6,8" CornerRadius="0">
<StackPanel Spacing="2">
<TextBlock Text="{Binding Title}" Classes="body" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" />
<TextBlock Text="{Binding Duration, StringFormat='{}{0:hh\\:mm\\:ss}'}" Classes="caption" FontSize="9" Foreground="{DynamicResource ZuneTextDimmedBrush}" />
</StackPanel>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<Panel Grid.Column="1">
<views:VideoPlaybackView DataContext="{Binding VideoPlaybackVM}" IsVisible="{Binding HasVideoPlayback}" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" />
<TextBlock Text="Select a video from your library to play it right here."
Classes="body" Foreground="{DynamicResource ZuneTextDimmedBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center"
IsVisible="{Binding !HasVideoPlayback}" />
</Panel>
</Grid>
</StackPanel>
</Grid>
</Panel>
</Grid>
</UserControl>
+100
View File
@@ -0,0 +1,100 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
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"
xmlns:models="clr-namespace:NotZune.Domain.Models;assembly=NotZune.Domain"
xmlns:conv="clr-namespace:NotZune.UI.Converters;assembly=NotZune.UI"
mc:Ignorable="d" d:DesignWidth="1000" d:DesignHeight="640"
x:Class="NotZune.UI.Views.PhotoLibraryView"
x:DataType="vm:PhotoLibraryViewModel">
<Panel>
<Grid ColumnDefinitions="240,32,*" Margin="0,16,0,0">
<!-- Left: Folder Tree -->
<Grid Grid.Column="0" RowDefinitions="Auto,*,Auto,Auto,Auto">
<TextBlock Grid.Row="0" Text="folders" Classes="section-header" Margin="0,0,0,8" />
<ListBox Grid.Row="1"
ItemsSource="{Binding Folders}"
SelectedItem="{Binding SelectedFolder}"
Background="Transparent"
CornerRadius="0">
<ListBox.ItemTemplate>
<DataTemplate>
<Border Padding="6,7" CornerRadius="0">
<TextBlock Text="{Binding Converter={x:Static conv:PhotoFolderPathConverter.Instance}}" Classes="body" TextTrimming="CharacterEllipsis" />
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBox Grid.Row="2" Name="PhotoScanPathBox" Watermark="folder to scan (e.g. ~/Pictures)" CornerRadius="0" Margin="0,8,0,0" />
<Button Grid.Row="3" Classes="zune-action" Command="{Binding ScanFolderCommand}" CommandParameter="{Binding #PhotoScanPathBox.Text}" Margin="0,8,0,0">
<TextBlock Text="SCAN FOR PICTURES" Classes="caption" FontWeight="SemiBold" />
</Button>
<Button Grid.Row="4" Classes="zune-action" Command="{Binding StartSlideshowCommand}" Margin="0,8,0,0">
<StackPanel Orientation="Horizontal" Spacing="6">
<Image Source="avares://NotZune.UI/Assets/Zune/Slideshow/SLIDESHOW.PLAY.PNG" Width="16" Height="16" />
<TextBlock Text="START SLIDESHOW" Classes="caption" FontWeight="SemiBold" />
</StackPanel>
</Button>
</Grid>
<!-- Right: Gallery Grid -->
<Grid Grid.Column="2" RowDefinitions="Auto,*">
<StackPanel Grid.Row="0" Spacing="4">
<TextBlock Text="pictures" Classes="hero" />
<TextBlock Text="{Binding GalleryStatsText}" Classes="caption" Foreground="{DynamicResource ZuneTextSecondaryBrush}" />
<TextBlock Text="{Binding StatusText}" Classes="caption" Foreground="{DynamicResource ZuneAccentBrush}" IsVisible="{Binding StatusText, Converter={x:Static ObjectConverters.IsNotNull}}" />
</StackPanel>
<ScrollViewer Grid.Row="1" Margin="0,12,0,0">
<Panel>
<ItemsControl ItemsSource="{Binding GalleryPhotos}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="models:Photo">
<Button Command="{Binding $parent[UserControl].((vm:PhotoLibraryViewModel)DataContext).ZoomPhotoCommand}"
CommandParameter="{Binding}"
Padding="0" Margin="0,0,14,14">
<Border Width="150" Height="150"
Background="{DynamicResource ZuneCardBrush}"
BorderBrush="{DynamicResource ZuneBorderBrush}"
BorderThickness="1"
CornerRadius="0">
<Image Source="{Binding FilePath, Converter={x:Static conv:ArtworkSourceConverter.Instance}}" Stretch="UniformToFill" />
</Border>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<StackPanel IsVisible="{Binding HasNoPhotos}" VerticalAlignment="Center" HorizontalAlignment="Center" Spacing="10">
<TextBlock Text="no pictures yet" Classes="hero" HorizontalAlignment="Center" />
<TextBlock Text="Scan a folder above — JPG, PNG, BMP, GIF and WebP are supported."
Classes="body" Foreground="{DynamicResource ZuneTextSecondaryBrush}" HorizontalAlignment="Center" />
</StackPanel>
</Panel>
</ScrollViewer>
</Grid>
</Grid>
<!-- Zoom Overlay -->
<Grid IsVisible="{Binding IsZoomOpen}" Background="#F0000000">
<Grid RowDefinitions="Auto,*" Margin="24">
<Button Grid.Row="0" Command="{Binding CloseZoomCommand}" HorizontalAlignment="Right" Background="Transparent" BorderThickness="0" Padding="6">
<TextBlock Text="✕" FontSize="14" Foreground="{DynamicResource ZuneTextDimmedBrush}" />
</Button>
<Image Grid.Row="1"
Source="{Binding ZoomedPhoto.FilePath, Converter={x:Static conv:ArtworkSourceConverter.Large}}"
Stretch="Uniform"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Grid>
</Grid>
</Panel>
</UserControl>
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace NotZune.UI.Views;
public partial class PhotoLibraryView : UserControl
{
public PhotoLibraryView()
{
InitializeComponent();
}
}
@@ -0,0 +1,59 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
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"
xmlns:conv="clr-namespace:NotZune.UI.Converters;assembly=NotZune.UI"
mc:Ignorable="d" d:DesignWidth="1000" d:DesignHeight="660"
x:Class="NotZune.UI.Views.PhotoSlideshowView"
x:DataType="vm:PhotoSlideshowViewModel">
<Grid>
<!-- Full-bleed photo with Ken-Burns drift -->
<Panel Background="Black">
<Image Source="{Binding CurrentPhoto.FilePath, Converter={x:Static conv:ArtworkSourceConverter.Large}}"
Stretch="Uniform"
HorizontalAlignment="Center"
VerticalAlignment="Center"
RenderTransformOrigin="0.5,0.5">
<Image.RenderTransform>
<TransformGroup>
<ScaleTransform ScaleX="{Binding KenBurnsScale}" ScaleY="{Binding KenBurnsScale}" />
<TranslateTransform X="{Binding KenBurnsTranslateX}" Y="{Binding KenBurnsTranslateY}" />
</TransformGroup>
</Image.RenderTransform>
</Image>
</Panel>
<!-- Top overlay: title + close -->
<Border VerticalAlignment="Top" Background="#B3000000" Padding="16,8">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="12" VerticalAlignment="Center">
<TextBlock Text="{Binding CurrentTitle}" Classes="body" FontWeight="SemiBold" Foreground="{DynamicResource ZuneTextActiveBrush}" />
<TextBlock Text="{Binding PositionText}" Classes="caption" Foreground="{DynamicResource ZuneTextDimmedBrush}" VerticalAlignment="Center" />
</StackPanel>
<Button Grid.Column="1" Command="{Binding CloseCommand}" Background="Transparent" BorderThickness="0" Padding="6">
<TextBlock Text="✕" FontSize="14" Foreground="{DynamicResource ZuneTextActiveBrush}" />
</Button>
</Grid>
</Border>
<!-- Bottom transport (authentic SLIDESHOW.* assets) -->
<Border VerticalAlignment="Bottom" Background="#B3000000" Padding="16,10">
<StackPanel Orientation="Horizontal" Spacing="16" HorizontalAlignment="Center">
<Button Command="{Binding BackCommand}" Background="Transparent" BorderThickness="0" Padding="4">
<Image Source="avares://NotZune.UI/Assets/Zune/Slideshow/SLIDESHOW.BACK.PNG" Width="24" Height="24" />
</Button>
<Button Command="{Binding PlayPauseCommand}" Background="Transparent" BorderThickness="0" Padding="4">
<Panel>
<Image Source="avares://NotZune.UI/Assets/Zune/Slideshow/SLIDESHOW.PLAY.PNG" Width="24" Height="24" IsVisible="{Binding !IsPlaying}" />
<Image Source="avares://NotZune.UI/Assets/Zune/Slideshow/SLIDESHOW.PAUSE.PNG" Width="24" Height="24" IsVisible="{Binding IsPlaying}" />
</Panel>
</Button>
<Button Command="{Binding NextCommand}" Background="Transparent" BorderThickness="0" Padding="4">
<Image Source="avares://NotZune.UI/Assets/Zune/Slideshow/SLIDESHOW.NEXT.PNG" Width="24" Height="24" />
</Button>
</StackPanel>
</Border>
</Grid>
</UserControl>
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace NotZune.UI.Views;
public partial class PhotoSlideshowView : UserControl
{
public PhotoSlideshowView()
{
InitializeComponent();
}
}
+18
View File
@@ -457,6 +457,24 @@
HorizontalAlignment="Left" />
</StackPanel>
<StackPanel Spacing="10">
<TextBlock Text="VIDEO SYNCHRONIZATION RULE" Classes="section-header" />
<ComboBox ItemsSource="{Binding MediaSyncRules}"
SelectedItem="{Binding VideoSyncRule}"
CornerRadius="0"
Width="380"
HorizontalAlignment="Left" />
</StackPanel>
<StackPanel Spacing="10">
<TextBlock Text="PICTURES SYNCHRONIZATION RULE" Classes="section-header" />
<ComboBox ItemsSource="{Binding MediaSyncRules}"
SelectedItem="{Binding PicturesSyncRule}"
CornerRadius="0"
Width="380"
HorizontalAlignment="Left" />
</StackPanel>
<StackPanel Spacing="10">
<TextBlock Text="GUEST &amp; REVERSE SYNC" Classes="section-header" />
<CheckBox IsChecked="True"
@@ -0,0 +1,84 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
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"
xmlns:models="clr-namespace:NotZune.Domain.Models;assembly=NotZune.Domain"
xmlns:views="clr-namespace:NotZune.UI.Views;assembly=NotZune.UI"
mc:Ignorable="d" d:DesignWidth="1000" d:DesignHeight="640"
x:Class="NotZune.UI.Views.VideoLibraryView"
x:DataType="vm:VideoLibraryViewModel">
<Panel>
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="0,16,0,0">
<!-- Header -->
<StackPanel Grid.Row="0" Spacing="4">
<TextBlock Text="videos" Classes="hero" />
<TextBlock Text="{Binding StatusText}" Classes="caption" Foreground="{DynamicResource ZuneTextSecondaryBrush}" IsVisible="{Binding StatusText, Converter={x:Static ObjectConverters.IsNotNull}}" />
</StackPanel>
<!-- Scan strip -->
<Grid Grid.Row="1" ColumnDefinitions="*,Auto" Margin="0,12,0,12">
<TextBox Grid.Column="0" Name="ScanPathBox" Watermark="folder to scan for videos (e.g. ~/Videos)" CornerRadius="0" />
<Button Grid.Column="1" Classes="zune-action" Margin="8,0,0,0" Command="{Binding ScanFolderCommand}" CommandParameter="{Binding #ScanPathBox.Text}">
<TextBlock Text="SCAN FOR VIDEOS" Classes="caption" FontWeight="SemiBold" />
</Button>
</Grid>
<!-- Video Grid -->
<ScrollViewer Grid.Row="2">
<Panel>
<ItemsControl ItemsSource="{Binding Videos}" IsVisible="{Binding HasVideos}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="models:Video">
<Button Command="{Binding $parent[UserControl].((vm:VideoLibraryViewModel)DataContext).PlayVideoCommand}"
CommandParameter="{Binding}"
Padding="0" Margin="0,0,20,20">
<Border Background="{DynamicResource ZuneSurfaceElevatedBrush}"
BorderBrush="{DynamicResource ZuneBorderBrush}"
BorderThickness="1"
CornerRadius="0"
Width="210"
Height="170"
Padding="10">
<Grid RowDefinitions="*,Auto,Auto">
<Border Grid.Row="0" Background="{DynamicResource ZuneCardBrush}" CornerRadius="0">
<Grid>
<Image Source="avares://NotZune.UI/Assets/Zune/Slideshow/VIDEOOVERLAY.PNG" Width="40" Height="40" HorizontalAlignment="Center" VerticalAlignment="Center" Opacity="0.8" />
<TextBlock Text="▶" FontSize="26" HorizontalAlignment="Center" VerticalAlignment="Center" Foreground="{DynamicResource ZuneAccentBrush}" />
</Grid>
</Border>
<TextBlock Grid.Row="1" Text="{Binding Title}" Classes="body" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" Margin="0,8,0,2" />
<TextBlock Grid.Row="2" Text="{Binding Duration, StringFormat='{}{0:hh\\:mm\\:ss}'}" Classes="caption" Foreground="{DynamicResource ZuneTextSecondaryBrush}" />
</Grid>
</Border>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- Empty State -->
<StackPanel IsVisible="{Binding !HasVideos}" VerticalAlignment="Center" HorizontalAlignment="Center" Spacing="12">
<Image Source="avares://NotZune.UI/Assets/Zune/Slideshow/VIDEOS.EMPTY.PNG" Width="64" Height="48" HorizontalAlignment="Center" Opacity="0.6" />
<TextBlock Text="no videos yet" Classes="hero" HorizontalAlignment="Center" />
<TextBlock Text="Scan a folder above — MP4, MKV, MOV, AVI, WMV and WebM are supported."
Classes="body" Foreground="{DynamicResource ZuneTextSecondaryBrush}" HorizontalAlignment="Center" />
</StackPanel>
</Panel>
</ScrollViewer>
<TextBlock Grid.Row="3" Text="" />
</Grid>
<!-- Video Player Overlay -->
<Grid IsVisible="{Binding IsPlayerOpen}" Background="#E8000000">
<views:VideoPlaybackView DataContext="{Binding ActivePlayerVM}" HorizontalAlignment="Center" VerticalAlignment="Center" />
</Grid>
</Panel>
</UserControl>
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace NotZune.UI.Views;
public partial class VideoLibraryView : UserControl
{
public VideoLibraryView()
{
InitializeComponent();
}
}
@@ -0,0 +1,64 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
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"
xmlns:vlc="clr-namespace:LibVLCSharp.Avalonia;assembly=LibVLCSharp.Avalonia"
xmlns:conv="clr-namespace:NotZune.UI.Converters;assembly=NotZune.UI"
mc:Ignorable="d" d:DesignWidth="860" d:DesignHeight="540"
x:Class="NotZune.UI.Views.VideoPlaybackView"
x:DataType="vm:VideoPlaybackViewModel"
Width="840">
<Border Background="#FF0A0A0A"
BorderBrush="{DynamicResource ZuneBorderBrush}"
BorderThickness="1"
CornerRadius="0"
Padding="16">
<StackPanel Spacing="12">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="{Binding VideoTitle}" Classes="hero" FontSize="22" Foreground="{DynamicResource ZuneTextActiveBrush}" />
<Button Grid.Column="1" Command="{Binding CloseCommand}" Background="Transparent" BorderThickness="0" Padding="6">
<TextBlock Text="✕" FontSize="14" Foreground="{DynamicResource ZuneTextDimmedBrush}" />
</Button>
</Grid>
<!-- Video Surface -->
<Border Background="Black" Height="430" CornerRadius="0">
<Panel>
<vlc:VideoView MediaPlayer="{Binding MediaPlayerHandle, Converter={x:Static conv:ObjectToMediaPlayerConverter.Instance}}" IsVisible="{Binding IsVideoAvailable}" />
<TextBlock Text="{Binding UnavailableText}"
Classes="body"
TextWrapping="Wrap"
Foreground="{DynamicResource ZuneTextDimmedBrush}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Padding="40"
IsVisible="{Binding !IsVideoAvailable}" />
<Image Source="avares://NotZune.UI/Assets/Zune/Slideshow/VIDEOOVERLAY.PNG"
Width="72" Height="72"
HorizontalAlignment="Center" VerticalAlignment="Center"
IsVisible="{Binding !IsPlaying}" Opacity="0.7" />
</Panel>
</Border>
<!-- Transport -->
<StackPanel Spacing="6">
<ProgressBar Minimum="0" Maximum="1" Value="{Binding ProgressFraction, Mode=OneWay}" Height="3" CornerRadius="0" Foreground="{DynamicResource ZuneAccentBrush}" Background="#222222" />
<Grid ColumnDefinitions="Auto,*,Auto">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
<Button Command="{Binding PlayPauseCommand}" Classes="zune-action">
<TextBlock Text="{Binding PlayPauseIcon}" FontSize="14" />
</Button>
<Button Command="{Binding StopCommand}" Classes="zune-action">
<TextBlock Text="⏹" FontSize="13" />
</Button>
<TextBlock Text="{Binding TimeText}" Classes="caption" VerticalAlignment="Center" Foreground="{DynamicResource ZuneTextSecondaryBrush}" />
</StackPanel>
<Slider Grid.Column="1" Minimum="0" Maximum="1" Value="{Binding SeekFraction, Mode=TwoWay}" VerticalAlignment="Center" Margin="12,0" />
<TextBlock Grid.Column="2" Text="NOW PLAYING — VIDEO" Classes="caption" FontSize="9" LetterSpacing="1" VerticalAlignment="Center" Foreground="{DynamicResource ZuneTextDimmedBrush}" />
</Grid>
</StackPanel>
</StackPanel>
</Border>
</UserControl>
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace NotZune.UI.Views;
public partial class VideoPlaybackView : UserControl
{
public VideoPlaybackView()
{
InitializeComponent();
}
}
@@ -0,0 +1,214 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Input;
using Microsoft.EntityFrameworkCore;
using NotZune.Application.Interfaces;
using NotZune.Application.Services;
using NotZune.Domain.Models;
using NotZune.Infrastructure.Persistence;
using NotZune.UI.ViewModels;
using Xunit;
namespace NotZune.Tests.Application;
/// <summary>
/// Phase 8 parity: video/photo libraries, playback view models, and the slideshow.
/// </summary>
public class VideoPhotoParityTests : IDisposable
{
private readonly string _tempDir;
private readonly string _dbPath;
private readonly IDbContextFactory<AppDbContext> _dbFactory;
public VideoPhotoParityTests()
{
_tempDir = Path.Combine(Path.GetTempPath(), $"notzune-video-{Guid.NewGuid():N}");
Directory.CreateDirectory(_tempDir);
_dbPath = Path.Combine(_tempDir, "test.db");
var options = new DbContextOptionsBuilder<AppDbContext>().UseSqlite($"Data Source={_dbPath}").Options;
_dbFactory = new TestDbContextFactory(options);
using var ctx = _dbFactory.CreateDbContext();
ctx.Database.EnsureCreated();
}
public void Dispose()
{
if (Directory.Exists(_tempDir))
{
Directory.Delete(_tempDir, recursive: true);
}
}
private sealed class TestDbContextFactory : IDbContextFactory<AppDbContext>
{
private readonly DbContextOptions<AppDbContext> _options;
public TestDbContextFactory(DbContextOptions<AppDbContext> options) => _options = options;
public AppDbContext CreateDbContext() => new(_options);
public async Task<AppDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default) => await Task.FromResult(new AppDbContext(_options));
}
[Fact]
public async Task VideoLibrary_Scan_PersistsVideosWithDurationGraceful()
{
var fakeVideo = Path.Combine(_tempDir, "Sample Movie.mp4");
File.WriteAllBytes(fakeVideo, new byte[] { 0x00, 0x00, 0x00, 0x18 }); // container bytes; TagLib will fail gracefully
var service = new VideoLibraryService(_dbFactory);
await service.ScanDirectoryAsync(_tempDir);
var videos = await service.GetAllVideosAsync();
var video = Assert.Single(videos);
Assert.Equal("Sample Movie", video.Title);
Assert.Equal(fakeVideo, video.FilePath);
}
[Fact]
public async Task VideoLibrary_MarkPlayed_IncrementsCount()
{
var fakeVideo = Path.Combine(_tempDir, "clip.mp4");
File.WriteAllBytes(fakeVideo, new byte[] { 0x01 });
var service = new VideoLibraryService(_dbFactory);
await service.ScanDirectoryAsync(_tempDir);
var video = (await service.GetAllVideosAsync()).Single();
await service.MarkPlayedAsync(video.Id);
await service.MarkPlayedAsync(video.Id);
var updated = (await service.GetAllVideosAsync()).Single();
Assert.Equal(2, updated.PlayCount);
Assert.NotNull(updated.LastPlayedAtUtc);
}
[Fact]
public async Task PhotoLibrary_Scan_PersistsPhotosAndFolders()
{
var subDir = Path.Combine(_tempDir, "Vacation");
Directory.CreateDirectory(subDir);
File.WriteAllBytes(Path.Combine(subDir, "beach.jpg"), new byte[] { 0xFF, 0xD8 });
File.WriteAllBytes(Path.Combine(subDir, "sunset.png"), new byte[] { 0x89, 0x50 });
var service = new PhotoLibraryService(_dbFactory);
await service.ScanDirectoryAsync(_tempDir);
var photos = await service.GetAllPhotosAsync();
Assert.Equal(2, photos.Count);
Assert.All(photos, p => Assert.Equal(subDir, p.FolderPath));
var folders = await service.GetFoldersAsync();
Assert.Single(folders);
Assert.Equal(subDir, folders[0]);
}
[Fact]
public async Task PhotoLibrary_RescanSkipsDuplicates()
{
File.WriteAllBytes(Path.Combine(_tempDir, "a.jpg"), new byte[] { 0xFF, 0xD8 });
var service = new PhotoLibraryService(_dbFactory);
await service.ScanDirectoryAsync(_tempDir);
await service.ScanDirectoryAsync(_tempDir);
var photos = await service.GetAllPhotosAsync();
Assert.Single(photos);
}
[Fact]
public void VideoPlayback_UnavailableEngine_ShowsGuidanceAndSafeOps()
{
var vm = new VideoPlaybackViewModel(new UnavailableVideoPlaybackEngine(), new Video { Title = "Test", FilePath = "/x.mp4" });
vm.Start();
Assert.False(vm.IsVideoAvailable);
Assert.Contains("libvlc", vm.UnavailableText);
Assert.Equal(TimeSpan.Zero, vm.Position);
Assert.Equal(TimeSpan.Zero, vm.Duration);
Assert.Equal(0.0, vm.SeekFraction);
}
[Fact]
public async Task PhotoLibraryVM_FolderFilter_AndStats()
{
var subDir = Path.Combine(_tempDir, "Trip");
Directory.CreateDirectory(subDir);
File.WriteAllBytes(Path.Combine(subDir, "p1.jpg"), new byte[] { 0xFF, 0xD8 });
File.WriteAllBytes(Path.Combine(_tempDir, "p2.jpg"), new byte[] { 0xFF, 0xD8 });
var service = new PhotoLibraryService(_dbFactory);
await service.ScanDirectoryAsync(_tempDir);
var vm = new PhotoLibraryViewModel(service);
await vm.LoadPhotosAsync();
Assert.Equal(2, vm.GalleryPhotos.Count); // All Photos default
vm.SelectedFolder = subDir;
Assert.Single(vm.GalleryPhotos);
Assert.Equal("Trip", vm.SelectedFolderName);
Assert.False(vm.HasNoPhotos);
vm.SelectedFolder = PhotoLibraryViewModel.AllFoldersEntry;
Assert.Equal(2, vm.GalleryPhotos.Count);
}
[Fact]
public void PhotoSlideshow_CyclesAndPauses()
{
var photos = new ObservableCollection<Photo>(
new[] { "a.jpg", "b.jpg", "c.jpg" }.Select(f => new Photo { Title = f, FilePath = f }));
var vm = new PhotoSlideshowViewModel(photos, 0);
Assert.Equal("a.jpg", vm.CurrentPhoto!.Title);
((RelayCommand)vm.NextCommand).Execute(null);
Assert.Equal("b.jpg", vm.CurrentPhoto!.Title);
((RelayCommand)vm.BackCommand).Execute(null);
Assert.Equal("a.jpg", vm.CurrentPhoto!.Title);
Assert.True(vm.IsPlaying);
((RelayCommand)vm.PlayPauseCommand).Execute(null);
Assert.False(vm.IsPlaying);
Assert.Equal("PLAY", vm.PlayPauseIcon);
}
[Fact]
public void VideoLibraryVM_EmptyLibrary_ShowsEmptyState()
{
var vm = new VideoLibraryViewModel(new EmptyVideoLibraryService(), new UnavailableVideoPlaybackEngine());
Assert.False(vm.HasVideos);
Assert.False(vm.IsPlayerOpen);
}
[Fact]
public void VideoEngine_InitializesOrDegradesGracefully()
{
// Exercises the full libVLC native path: either a real VLC installation initializes
// (Windows natives from the NuGet package, Linux system libvlc via the resolver),
// or the engine degrades to unavailable (headless/CI without VLC).
using var engine = new NotZune.Infrastructure.Video.VideoPlaybackEngine();
engine.LoadAndPlay("/nonexistent/video.mp4");
if (engine.IsAvailable)
{
Assert.Equal(TimeSpan.Zero, engine.GetPosition());
Assert.NotNull(engine.MediaPlayerHandle);
}
else
{
Assert.Null(engine.MediaPlayerHandle);
}
engine.SetVolume(0.5, muted: false);
engine.Seek(TimeSpan.FromSeconds(1));
engine.Pause();
engine.Play();
engine.Stop();
}
}