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
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NotZune.Tests.Domain", "tests\NotZune.Tests.Domain\NotZune.Tests.Domain.csproj", "{275BFD54-E360-44EC-8A6C-03D6167413BF}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NotZune.Tests.Domain", "tests\NotZune.Tests.Domain\NotZune.Tests.Domain.csproj", "{275BFD54-E360-44EC-8A6C-03D6167413BF}"
|
||||||
EndProject
|
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
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
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}.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.ActiveCfg = Release|Any CPU
|
||||||
{275BFD54-E360-44EC-8A6C-03D6167413BF}.Release|Any CPU.Build.0 = 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
|
EndGlobalSection
|
||||||
GlobalSection(NestedProjects) = preSolution
|
GlobalSection(NestedProjects) = preSolution
|
||||||
{9C687BB5-6AE3-4C94-A12F-6D34C5DAAFFD} = {386DD2E6-7733-4758-9DEF-333286DDB86F}
|
{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}
|
{E0F28BF6-1159-41CC-BBEE-1FBAFED53447} = {386DD2E6-7733-4758-9DEF-333286DDB86F}
|
||||||
{17B493BB-CFCB-423D-844F-3ECD6BA3D6CC} = {89103A14-FD5B-4F9F-9AEF-803F5D97F55C}
|
{17B493BB-CFCB-423D-844F-3ECD6BA3D6CC} = {89103A14-FD5B-4F9F-9AEF-803F5D97F55C}
|
||||||
{275BFD54-E360-44EC-8A6C-03D6167413BF} = {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
|
EndGlobalSection
|
||||||
EndGlobal
|
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 int SpaceReservationPercent { get; set; } = 10;
|
||||||
public string MusicSyncRule { get; set; } = "All Music (Automatic Sync)";
|
public string MusicSyncRule { get; set; } = "All Music (Automatic Sync)";
|
||||||
public string PodcastSyncRule { get; set; } = "3 Newest Episodes";
|
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 bool WirelessSyncEnabled { get; set; } = true;
|
||||||
public string NetworkName { get; set; } = "Home-WiFi (WPA2)";
|
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.Audio;
|
||||||
using NotZune.Infrastructure.Devices;
|
using NotZune.Infrastructure.Devices;
|
||||||
using NotZune.Infrastructure.External;
|
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;
|
using NotZune.UI.ViewModels;
|
||||||
|
|
||||||
namespace NotZune.Desktop;
|
namespace NotZune.Desktop;
|
||||||
@@ -77,6 +78,9 @@ public partial class App : Avalonia.Application
|
|||||||
services.AddSingleton<IExternalMetadataService, ExternalMetadataService>();
|
services.AddSingleton<IExternalMetadataService, ExternalMetadataService>();
|
||||||
services.AddSingleton<IArtistEnrichmentService, ArtistEnrichmentCoordinator>();
|
services.AddSingleton<IArtistEnrichmentService, ArtistEnrichmentCoordinator>();
|
||||||
services.AddSingleton<ISmartPlaylistService, SmartPlaylistService>();
|
services.AddSingleton<ISmartPlaylistService, SmartPlaylistService>();
|
||||||
|
services.AddSingleton<IVideoLibraryService, VideoLibraryService>();
|
||||||
|
services.AddSingleton<IPhotoLibraryService, PhotoLibraryService>();
|
||||||
|
services.AddSingleton<IVideoPlaybackEngine, VideoPlaybackEngine>();
|
||||||
|
|
||||||
// 3. Audio & Hardware Subsystems
|
// 3. Audio & Hardware Subsystems
|
||||||
services.AddSingleton<AudioEngine>();
|
services.AddSingleton<AudioEngine>();
|
||||||
@@ -108,6 +112,28 @@ public partial class App : Avalonia.Application
|
|||||||
CreatedAtUtc TEXT NOT NULL,
|
CreatedAtUtc TEXT NOT NULL,
|
||||||
UpdatedAtUtc 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())
|
if (!ctx.Tracks.Any())
|
||||||
{
|
{
|
||||||
// Artists
|
// Artists
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\NotZune.UI\NotZune.UI.csproj" />
|
<ProjectReference Include="..\NotZune.UI\NotZune.UI.csproj" />
|
||||||
|
<ProjectReference Include="..\NotZune.Infrastructure.Video\NotZune.Infrastructure.Video.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<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<Playlist> Playlists => Set<Playlist>();
|
||||||
public DbSet<PlayHistoryEntry> PlayHistory => Set<PlayHistoryEntry>();
|
public DbSet<PlayHistoryEntry> PlayHistory => Set<PlayHistoryEntry>();
|
||||||
public DbSet<SmartPlaylist> SmartPlaylists => Set<SmartPlaylist>();
|
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)
|
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.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>());
|
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();
|
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 int DecodeWidth { get; set; } = 300;
|
||||||
|
|
||||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
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.Audio\NotZune.Infrastructure.Audio.csproj" />
|
||||||
<ProjectReference Include="..\NotZune.Infrastructure.Devices\NotZune.Infrastructure.Devices.csproj" />
|
<ProjectReference Include="..\NotZune.Infrastructure.Devices\NotZune.Infrastructure.Devices.csproj" />
|
||||||
<ProjectReference Include="..\NotZune.Infrastructure.External\NotZune.Infrastructure.External.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" />
|
<ProjectReference Include="..\NotZune.Plugins.Sdk\NotZune.Plugins.Sdk.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ public class ViewLocator : IDataTemplate
|
|||||||
MainShellViewModel => new MainShellView(),
|
MainShellViewModel => new MainShellView(),
|
||||||
ZuneCardViewModel => new ZuneCardView(),
|
ZuneCardViewModel => new ZuneCardView(),
|
||||||
PodcastsViewModel => new PodcastsView(),
|
PodcastsViewModel => new PodcastsView(),
|
||||||
|
PlaylistsViewModel => new PlaylistsView(),
|
||||||
|
VideoLibraryViewModel => new VideoLibraryView(),
|
||||||
|
PhotoLibraryViewModel => new PhotoLibraryView(),
|
||||||
CDViewModel => new CDView(),
|
CDViewModel => new CDView(),
|
||||||
_ => FindViewByConvention(data)
|
_ => FindViewByConvention(data)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ public enum CollectionSubPivot
|
|||||||
Songs,
|
Songs,
|
||||||
Genres,
|
Genres,
|
||||||
Podcasts,
|
Podcasts,
|
||||||
Playlists
|
Playlists,
|
||||||
|
Videos,
|
||||||
|
Pictures
|
||||||
}
|
}
|
||||||
|
|
||||||
public class CollectionViewModel : ViewModelBase
|
public class CollectionViewModel : ViewModelBase
|
||||||
@@ -48,6 +50,8 @@ public class CollectionViewModel : ViewModelBase
|
|||||||
|
|
||||||
public PodcastsViewModel PodcastsVM { get; }
|
public PodcastsViewModel PodcastsVM { get; }
|
||||||
public PlaylistsViewModel PlaylistsVM { get; }
|
public PlaylistsViewModel PlaylistsVM { get; }
|
||||||
|
public VideoLibraryViewModel VideoVM { get; }
|
||||||
|
public PhotoLibraryViewModel PhotoVM { get; }
|
||||||
|
|
||||||
public ObservableCollection<Artist> Artists { get; } = new();
|
public ObservableCollection<Artist> Artists { get; } = new();
|
||||||
public ObservableCollection<Album> Albums { get; } = new();
|
public ObservableCollection<Album> Albums { get; } = new();
|
||||||
@@ -69,6 +73,8 @@ public class CollectionViewModel : ViewModelBase
|
|||||||
OnPropertyChanged(nameof(IsGenresActive));
|
OnPropertyChanged(nameof(IsGenresActive));
|
||||||
OnPropertyChanged(nameof(IsPodcastsActive));
|
OnPropertyChanged(nameof(IsPodcastsActive));
|
||||||
OnPropertyChanged(nameof(IsPlaylistsActive));
|
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 IsGenresActive => _activeSubPivot == CollectionSubPivot.Genres;
|
||||||
public bool IsPodcastsActive => _activeSubPivot == CollectionSubPivot.Podcasts;
|
public bool IsPodcastsActive => _activeSubPivot == CollectionSubPivot.Podcasts;
|
||||||
public bool IsPlaylistsActive => _activeSubPivot == CollectionSubPivot.Playlists;
|
public bool IsPlaylistsActive => _activeSubPivot == CollectionSubPivot.Playlists;
|
||||||
|
public bool IsVideosActive => _activeSubPivot == CollectionSubPivot.Videos;
|
||||||
|
public bool IsPicturesActive => _activeSubPivot == CollectionSubPivot.Pictures;
|
||||||
|
|
||||||
public Artist? SelectedArtist
|
public Artist? SelectedArtist
|
||||||
{
|
{
|
||||||
@@ -138,6 +146,21 @@ public class CollectionViewModel : ViewModelBase
|
|||||||
|
|
||||||
public bool IsEditMetadataOpen => ActiveEditMetadataVM != null;
|
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;
|
private string? _findAlbumInfoStatusText;
|
||||||
public string? FindAlbumInfoStatusText
|
public string? FindAlbumInfoStatusText
|
||||||
{
|
{
|
||||||
@@ -162,7 +185,10 @@ public class CollectionViewModel : ViewModelBase
|
|||||||
ISmartDJService? smartDJService = null,
|
ISmartDJService? smartDJService = null,
|
||||||
IArtworkCacheService? artworkCache = null,
|
IArtworkCacheService? artworkCache = null,
|
||||||
IExternalMetadataService? metadataService = null,
|
IExternalMetadataService? metadataService = null,
|
||||||
ISmartPlaylistService? smartPlaylistService = null)
|
ISmartPlaylistService? smartPlaylistService = null,
|
||||||
|
IVideoLibraryService? videoLibraryService = null,
|
||||||
|
IVideoPlaybackEngine? videoEngine = null,
|
||||||
|
IPhotoLibraryService? photoLibraryService = null)
|
||||||
{
|
{
|
||||||
_playerCoordinator = playerCoordinator;
|
_playerCoordinator = playerCoordinator;
|
||||||
_libraryService = libraryService;
|
_libraryService = libraryService;
|
||||||
@@ -171,6 +197,18 @@ public class CollectionViewModel : ViewModelBase
|
|||||||
_metadataService = metadataService;
|
_metadataService = metadataService;
|
||||||
var podService = podcastService ?? new NotZune.Application.Services.PodcastService(playerCoordinator);
|
var podService = podcastService ?? new NotZune.Application.Services.PodcastService(playerCoordinator);
|
||||||
PodcastsVM = new PodcastsViewModel(podService);
|
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);
|
PlaylistsVM = new PlaylistsViewModel(libraryService, playerCoordinator, smartPlaylistService);
|
||||||
|
|
||||||
OpenEditMetadataCommand = new RelayCommand<Track>(track =>
|
OpenEditMetadataCommand = new RelayCommand<Track>(track =>
|
||||||
|
|||||||
@@ -347,7 +347,10 @@ public class MainShellViewModel : ViewModelBase
|
|||||||
IArtworkCacheService? artworkCacheService = null,
|
IArtworkCacheService? artworkCacheService = null,
|
||||||
IExternalMetadataService? metadataService = null,
|
IExternalMetadataService? metadataService = null,
|
||||||
IAudioOutputEngine? audioEngine = null,
|
IAudioOutputEngine? audioEngine = null,
|
||||||
ISmartPlaylistService? smartPlaylistService = null)
|
ISmartPlaylistService? smartPlaylistService = null,
|
||||||
|
IVideoLibraryService? videoLibraryService = null,
|
||||||
|
IVideoPlaybackEngine? videoEngine = null,
|
||||||
|
IPhotoLibraryService? photoLibraryService = null)
|
||||||
{
|
{
|
||||||
_playerCoordinator = playerCoordinator;
|
_playerCoordinator = playerCoordinator;
|
||||||
_libraryService = libraryService;
|
_libraryService = libraryService;
|
||||||
@@ -358,8 +361,8 @@ public class MainShellViewModel : ViewModelBase
|
|||||||
|
|
||||||
// Child ViewModels
|
// Child ViewModels
|
||||||
QuickplayVM = new QuickplayViewModel(playerCoordinator, libraryService, smartDJService);
|
QuickplayVM = new QuickplayViewModel(playerCoordinator, libraryService, smartDJService);
|
||||||
CollectionVM = new CollectionViewModel(playerCoordinator, libraryService, podService, smartDJService, artworkCacheService, metadataService, smartPlaylistService);
|
CollectionVM = new CollectionViewModel(playerCoordinator, libraryService, podService, smartDJService, artworkCacheService, metadataService, smartPlaylistService, videoLibraryService, videoEngine, photoLibraryService);
|
||||||
NowPlayingVM = new NowPlayingViewModel(playerCoordinator, libraryService, enrichmentService, audioEngine);
|
NowPlayingVM = new NowPlayingViewModel(playerCoordinator, libraryService, enrichmentService, audioEngine, videoLibraryService, videoEngine);
|
||||||
DeviceVM = new DeviceViewModel(deviceSyncService, libraryService);
|
DeviceVM = new DeviceViewModel(deviceSyncService, libraryService);
|
||||||
SettingsVM = new SettingsViewModel(_soundEffectService, folderPickerService, _libraryService, playerCoordinator, deviceSyncService, settingsStore);
|
SettingsVM = new SettingsViewModel(_soundEffectService, folderPickerService, _libraryService, playerCoordinator, deviceSyncService, settingsStore);
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ namespace NotZune.UI.ViewModels;
|
|||||||
public enum NowPlayingMode
|
public enum NowPlayingMode
|
||||||
{
|
{
|
||||||
ArtistCanvas,
|
ArtistCanvas,
|
||||||
MosaicWall
|
MosaicWall,
|
||||||
|
Video
|
||||||
}
|
}
|
||||||
|
|
||||||
public class NowPlayingViewModel : ViewModelBase
|
public class NowPlayingViewModel : ViewModelBase
|
||||||
@@ -99,12 +100,92 @@ public class NowPlayingViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(IsArtistCanvasMode));
|
OnPropertyChanged(nameof(IsArtistCanvasMode));
|
||||||
OnPropertyChanged(nameof(IsMosaicWallMode));
|
OnPropertyChanged(nameof(IsMosaicWallMode));
|
||||||
|
OnPropertyChanged(nameof(IsVideoMode));
|
||||||
|
|
||||||
|
if (value == NowPlayingMode.Video)
|
||||||
|
{
|
||||||
|
_ = LoadLibraryVideosAsync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IsArtistCanvasMode => _mode == NowPlayingMode.ArtistCanvas;
|
public bool IsArtistCanvasMode => _mode == NowPlayingMode.ArtistCanvas;
|
||||||
public bool IsMosaicWallMode => _mode == NowPlayingMode.MosaicWall;
|
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
|
public bool IsHudVisible
|
||||||
{
|
{
|
||||||
@@ -176,12 +257,16 @@ public class NowPlayingViewModel : ViewModelBase
|
|||||||
IPlayerCoordinator playerCoordinator,
|
IPlayerCoordinator playerCoordinator,
|
||||||
IMediaLibraryService libraryService,
|
IMediaLibraryService libraryService,
|
||||||
IArtistEnrichmentService? enrichmentService = null,
|
IArtistEnrichmentService? enrichmentService = null,
|
||||||
IAudioOutputEngine? audioEngine = null)
|
IAudioOutputEngine? audioEngine = null,
|
||||||
|
IVideoLibraryService? videoLibraryService = null,
|
||||||
|
IVideoPlaybackEngine? videoEngine = null)
|
||||||
{
|
{
|
||||||
_playerCoordinator = playerCoordinator;
|
_playerCoordinator = playerCoordinator;
|
||||||
_libraryService = libraryService;
|
_libraryService = libraryService;
|
||||||
_enrichmentService = enrichmentService;
|
_enrichmentService = enrichmentService;
|
||||||
_audioEngine = audioEngine;
|
_audioEngine = audioEngine;
|
||||||
|
_videoLibraryService = videoLibraryService;
|
||||||
|
_videoEngine = videoEngine;
|
||||||
|
|
||||||
_activeBackdrops = new List<string>(_themeBackdrops);
|
_activeBackdrops = new List<string>(_themeBackdrops);
|
||||||
_currentBackdropImage = _activeBackdrops[0];
|
_currentBackdropImage = _activeBackdrops[0];
|
||||||
@@ -266,7 +351,12 @@ public class NowPlayingViewModel : ViewModelBase
|
|||||||
|
|
||||||
ToggleModeCommand = new RelayCommand(() =>
|
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(() =>
|
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;
|
private bool _wirelessSyncEnabled = true;
|
||||||
public bool WirelessSyncEnabled
|
public bool WirelessSyncEnabled
|
||||||
{
|
{
|
||||||
@@ -889,6 +922,10 @@ public class SettingsViewModel : ViewModelBase
|
|||||||
OnPropertyChanged(nameof(MusicSyncRule));
|
OnPropertyChanged(nameof(MusicSyncRule));
|
||||||
_podcastSyncRule = settings.PodcastSyncRule;
|
_podcastSyncRule = settings.PodcastSyncRule;
|
||||||
OnPropertyChanged(nameof(PodcastSyncRule));
|
OnPropertyChanged(nameof(PodcastSyncRule));
|
||||||
|
_videoSyncRule = settings.VideoSyncRule;
|
||||||
|
OnPropertyChanged(nameof(VideoSyncRule));
|
||||||
|
_picturesSyncRule = settings.PicturesSyncRule;
|
||||||
|
OnPropertyChanged(nameof(PicturesSyncRule));
|
||||||
_wirelessSyncEnabled = settings.WirelessSyncEnabled;
|
_wirelessSyncEnabled = settings.WirelessSyncEnabled;
|
||||||
OnPropertyChanged(nameof(WirelessSyncEnabled));
|
OnPropertyChanged(nameof(WirelessSyncEnabled));
|
||||||
_networkName = settings.NetworkName;
|
_networkName = settings.NetworkName;
|
||||||
@@ -963,6 +1000,8 @@ public class SettingsViewModel : ViewModelBase
|
|||||||
SpaceReservationPercent = SpaceReservationPercent,
|
SpaceReservationPercent = SpaceReservationPercent,
|
||||||
MusicSyncRule = MusicSyncRule,
|
MusicSyncRule = MusicSyncRule,
|
||||||
PodcastSyncRule = PodcastSyncRule,
|
PodcastSyncRule = PodcastSyncRule,
|
||||||
|
VideoSyncRule = VideoSyncRule,
|
||||||
|
PicturesSyncRule = PicturesSyncRule,
|
||||||
WirelessSyncEnabled = WirelessSyncEnabled,
|
WirelessSyncEnabled = WirelessSyncEnabled,
|
||||||
NetworkName = NetworkName,
|
NetworkName = NetworkName,
|
||||||
SelectedAccentName = SelectedAccent.Name,
|
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}">
|
<Button Command="{Binding SelectSubPivotCommand}" CommandParameter="{x:Static vm:CollectionSubPivot.Playlists}">
|
||||||
<TextBlock Text="playlists" Classes="subpivot" Classes.active="{Binding IsPlaylistsActive}" />
|
<TextBlock Text="playlists" Classes="subpivot" Classes.active="{Binding IsPlaylistsActive}" />
|
||||||
</Button>
|
</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>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- Minimal Search Box -->
|
<!-- Minimal Search Box -->
|
||||||
@@ -394,6 +400,8 @@
|
|||||||
|
|
||||||
<!-- 6. PLAYLISTS SUB-PIVOT -->
|
<!-- 6. PLAYLISTS SUB-PIVOT -->
|
||||||
<ContentControl IsVisible="{Binding IsPlaylistsActive}" Content="{Binding PlaylistsVM}" />
|
<ContentControl IsVisible="{Binding IsPlaylistsActive}" Content="{Binding PlaylistsVM}" />
|
||||||
|
<ContentControl IsVisible="{Binding IsVideosActive}" Content="{Binding VideoVM}" />
|
||||||
|
<ContentControl IsVisible="{Binding IsPicturesActive}" Content="{Binding PhotoVM}" />
|
||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
@@ -412,5 +420,10 @@
|
|||||||
<Grid IsVisible="{Binding IsTrackMatchReviewOpen}" Background="#D9000000">
|
<Grid IsVisible="{Binding IsTrackMatchReviewOpen}" Background="#D9000000">
|
||||||
<views:TrackMatchReviewView DataContext="{Binding ActiveTrackMatchReviewVM}" HorizontalAlignment="Center" VerticalAlignment="Center" />
|
<views:TrackMatchReviewView DataContext="{Binding ActiveTrackMatchReviewVM}" HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
<!-- 10. PHOTO SLIDESHOW OVERLAY (SLIDESHOWLAND parity) -->
|
||||||
|
<Panel IsVisible="{Binding IsSlideshowOpen}" Background="Black" ZIndex="120">
|
||||||
|
<views:PhotoSlideshowView DataContext="{Binding ActiveSlideshowVM}" />
|
||||||
|
</Panel>
|
||||||
</Panel>
|
</Panel>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
xmlns:vm="clr-namespace:NotZune.UI.ViewModels;assembly=NotZune.UI"
|
xmlns:vm="clr-namespace:NotZune.UI.ViewModels;assembly=NotZune.UI"
|
||||||
xmlns:models="clr-namespace:NotZune.Domain.Models;assembly=NotZune.Domain"
|
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"
|
mc:Ignorable="d" d:DesignWidth="1100" d:DesignHeight="700"
|
||||||
x:Class="NotZune.UI.Views.NowPlayingView"
|
x:Class="NotZune.UI.Views.NowPlayingView"
|
||||||
x:DataType="vm:NowPlayingViewModel">
|
x:DataType="vm:NowPlayingViewModel">
|
||||||
@@ -306,10 +307,45 @@
|
|||||||
<Button Command="{Binding ToggleModeCommand}" Classes="zune-action">
|
<Button Command="{Binding ToggleModeCommand}" Classes="zune-action">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
<TextBlock Text="⊞" FontSize="12" Foreground="{DynamicResource ZuneAccentBrush}" VerticalAlignment="Center" />
|
<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>
|
</StackPanel>
|
||||||
</Button>
|
</Button>
|
||||||
</StackPanel>
|
</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>
|
</Grid>
|
||||||
</UserControl>
|
</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" />
|
HorizontalAlignment="Left" />
|
||||||
</StackPanel>
|
</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">
|
<StackPanel Spacing="10">
|
||||||
<TextBlock Text="GUEST & REVERSE SYNC" Classes="section-header" />
|
<TextBlock Text="GUEST & REVERSE SYNC" Classes="section-header" />
|
||||||
<CheckBox IsChecked="True"
|
<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