mirror of
https://github.com/Heretek-AI/not-zune.git
synced 2026-10-11 21:19:55 +00:00
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:
@@ -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() { }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 & 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user