Update README.md with project details, features, requirements, architecture, and data management for PLib video library manager.
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
<Application xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="PLib.Desktop.App"
|
||||
RequestedThemeVariant="Dark">
|
||||
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ResourceInclude Source="avares://PLib/Themes/Palette.axaml" />
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
|
||||
<Application.Styles>
|
||||
<StyleInclude Source="avares://Semi.Avalonia/Index.axaml" />
|
||||
<StyleInclude Source="avares://Material.Icons.Avalonia/MaterialIconStyles.axaml" />
|
||||
<StyleInclude Source="avares://PLib/Themes/LibraryStyles.axaml" />
|
||||
</Application.Styles>
|
||||
|
||||
</Application>
|
||||
@@ -0,0 +1,55 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Threading;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using PLib.Desktop.Controls;
|
||||
using PLib.Desktop.Imaging;
|
||||
using PLib.Desktop.ViewModels;
|
||||
using PLib.Desktop.Views;
|
||||
|
||||
namespace PLib.Desktop;
|
||||
|
||||
// Fully qualified: the PLib.Application namespace shadows the Application type in this assembly.
|
||||
public sealed class App : Avalonia.Application
|
||||
{
|
||||
private IHost? _host;
|
||||
|
||||
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
_host = AppHost.Create(desktop.Args ?? []);
|
||||
_host.Start();
|
||||
|
||||
AsyncImage.Loader = _host.Services.GetRequiredService<ThumbnailCache>();
|
||||
|
||||
var viewModel = _host.Services.GetRequiredService<MainWindowViewModel>();
|
||||
desktop.MainWindow = new MainWindow { DataContext = viewModel };
|
||||
desktop.Exit += OnExit;
|
||||
|
||||
// Kick the first load off once the dispatcher is running, so a failure surfaces
|
||||
// through the view model instead of disappearing into an unobserved task.
|
||||
Dispatcher.UIThread.Post(
|
||||
() => viewModel.InitializeCommand.Execute(null),
|
||||
DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
|
||||
private void OnExit(object? sender, ControlledApplicationLifetimeExitEventArgs e)
|
||||
{
|
||||
if (_host is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_host.StopAsync(TimeSpan.FromSeconds(3)).GetAwaiter().GetResult();
|
||||
_host.Dispose();
|
||||
_host = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PLib.Desktop.Imaging;
|
||||
using PLib.Desktop.Services;
|
||||
using PLib.Desktop.ViewModels;
|
||||
using PLib.Infrastructure;
|
||||
using PLib.Infrastructure.Storage;
|
||||
using Serilog;
|
||||
|
||||
namespace PLib.Desktop;
|
||||
|
||||
/// <summary>
|
||||
/// Composition root. Everything the application is made of is wired up here and nowhere else.
|
||||
/// </summary>
|
||||
internal static class AppHost
|
||||
{
|
||||
public static IHost Create(string[] args)
|
||||
{
|
||||
// The paths are needed to locate the user settings file, which is itself a
|
||||
// configuration source — so they are built before the container exists and then
|
||||
// handed to it as an instance.
|
||||
var paths = new AppPaths();
|
||||
|
||||
// A desktop app is launched from arbitrary working directories, so the content root
|
||||
// has to be the folder the executable lives in rather than Environment.CurrentDirectory.
|
||||
var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings
|
||||
{
|
||||
Args = args,
|
||||
ContentRootPath = AppContext.BaseDirectory,
|
||||
});
|
||||
|
||||
builder.Configuration.AddJsonFile(
|
||||
Path.Combine(paths.DataDirectory, "settings.json"),
|
||||
optional: true,
|
||||
reloadOnChange: true);
|
||||
|
||||
ConfigureLogging(builder, paths);
|
||||
|
||||
builder.Services.AddSingleton<IAppPaths>(paths);
|
||||
builder.Services.AddPLibInfrastructure(builder.Configuration);
|
||||
|
||||
builder.Services.AddSingleton<ThumbnailCache>();
|
||||
builder.Services.AddSingleton<IThumbnailLoader>(sp => sp.GetRequiredService<ThumbnailCache>());
|
||||
builder.Services.AddSingleton<ILibrarySettingsStore, JsonLibrarySettingsStore>();
|
||||
builder.Services.AddSingleton<IFolderPicker, StorageProviderFolderPicker>();
|
||||
builder.Services.AddSingleton<ISystemShell, SystemShell>();
|
||||
builder.Services.AddSingleton<IThemeService, ThemeService>();
|
||||
builder.Services.AddSingleton<MainWindowViewModel>();
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
private static void ConfigureLogging(HostApplicationBuilder builder, IAppPaths paths)
|
||||
{
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Information()
|
||||
.WriteTo.Console()
|
||||
.WriteTo.File(
|
||||
Path.Combine(paths.DataDirectory, "logs", "plib-.log"),
|
||||
rollingInterval: RollingInterval.Day,
|
||||
retainedFileCountLimit: 7)
|
||||
.CreateLogger();
|
||||
|
||||
builder.Logging.ClearProviders();
|
||||
builder.Logging.AddSerilog(Log.Logger, dispose: true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Animation;
|
||||
using Avalonia.Animation.Easings;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Threading;
|
||||
using PLib.Desktop.Imaging;
|
||||
|
||||
namespace PLib.Desktop.Controls;
|
||||
|
||||
/// <summary>
|
||||
/// Draws a poster frame that is loaded only while the control is actually on screen.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A virtualised grid recycles containers as the user scrolls, so binding a decoded
|
||||
/// <see cref="Bitmap"/> straight into the view model would keep every frame the user has ever
|
||||
/// passed alive. Instead the control asks the shared loader when it is attached to the visual
|
||||
/// tree and drops its reference when it is detached, which keeps memory proportional to what
|
||||
/// is visible rather than to the size of the library.
|
||||
/// </remarks>
|
||||
public sealed class AsyncImage : Control
|
||||
{
|
||||
public static readonly StyledProperty<string?> SourceProperty =
|
||||
AvaloniaProperty.Register<AsyncImage, string?>(nameof(Source));
|
||||
|
||||
public static readonly StyledProperty<int> DecodeWidthProperty =
|
||||
AvaloniaProperty.Register<AsyncImage, int>(nameof(DecodeWidth), defaultValue: 400);
|
||||
|
||||
public static readonly StyledProperty<IBrush?> PlaceholderBrushProperty =
|
||||
AvaloniaProperty.Register<AsyncImage, IBrush?>(nameof(PlaceholderBrush));
|
||||
|
||||
/// <summary>
|
||||
/// Shared loader, assigned once by the composition root. A control is created by the XAML
|
||||
/// runtime and therefore cannot take constructor dependencies.
|
||||
/// </summary>
|
||||
public static IThumbnailLoader? Loader { get; set; }
|
||||
|
||||
private CancellationTokenSource? _pending;
|
||||
private Bitmap? _bitmap;
|
||||
private bool _isAttached;
|
||||
|
||||
static AsyncImage()
|
||||
{
|
||||
AffectsRender<AsyncImage>(SourceProperty, PlaceholderBrushProperty);
|
||||
}
|
||||
|
||||
public AsyncImage()
|
||||
{
|
||||
Opacity = 0;
|
||||
Transitions =
|
||||
[
|
||||
new DoubleTransition
|
||||
{
|
||||
Property = OpacityProperty,
|
||||
Duration = TimeSpan.FromMilliseconds(220),
|
||||
Easing = new CubicEaseOut(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
public string? Source
|
||||
{
|
||||
get => GetValue(SourceProperty);
|
||||
set => SetValue(SourceProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>Target width in pixels for decoding; smaller means less memory per card.</summary>
|
||||
public int DecodeWidth
|
||||
{
|
||||
get => GetValue(DecodeWidthProperty);
|
||||
set => SetValue(DecodeWidthProperty, value);
|
||||
}
|
||||
|
||||
public IBrush? PlaceholderBrush
|
||||
{
|
||||
get => GetValue(PlaceholderBrushProperty);
|
||||
set => SetValue(PlaceholderBrushProperty, value);
|
||||
}
|
||||
|
||||
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
base.OnAttachedToVisualTree(e);
|
||||
_isAttached = true;
|
||||
BeginLoad();
|
||||
}
|
||||
|
||||
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
base.OnDetachedFromVisualTree(e);
|
||||
_isAttached = false;
|
||||
Release();
|
||||
}
|
||||
|
||||
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
|
||||
{
|
||||
base.OnPropertyChanged(change);
|
||||
|
||||
if (change.Property == SourceProperty || change.Property == DecodeWidthProperty)
|
||||
{
|
||||
Release();
|
||||
BeginLoad();
|
||||
}
|
||||
}
|
||||
|
||||
public override void Render(DrawingContext context)
|
||||
{
|
||||
var bounds = new Rect(Bounds.Size);
|
||||
|
||||
if (bounds.Width <= 0 || bounds.Height <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_bitmap is null)
|
||||
{
|
||||
if (PlaceholderBrush is { } placeholder)
|
||||
{
|
||||
context.FillRectangle(placeholder, bounds);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
context.DrawImage(_bitmap, CoverSourceRect(_bitmap.PixelSize, bounds), bounds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Picks the largest centred crop of the source that has the same aspect ratio as the
|
||||
/// destination, i.e. CSS <c>object-fit: cover</c>: fill the card, never letterbox.
|
||||
/// </summary>
|
||||
private static Rect CoverSourceRect(PixelSize source, Rect destination)
|
||||
{
|
||||
var scale = Math.Max(destination.Width / source.Width, destination.Height / source.Height);
|
||||
var width = destination.Width / scale;
|
||||
var height = destination.Height / scale;
|
||||
|
||||
return new Rect((source.Width - width) / 2, (source.Height - height) / 2, width, height);
|
||||
}
|
||||
|
||||
private void BeginLoad()
|
||||
{
|
||||
if (!_isAttached || Loader is not { } loader || string.IsNullOrEmpty(Source))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
_pending = cts;
|
||||
|
||||
_ = LoadAsync(loader, Source, DecodeWidth, cts);
|
||||
}
|
||||
|
||||
private async Task LoadAsync(IThumbnailLoader loader, string path, int decodeWidth, CancellationTokenSource cts)
|
||||
{
|
||||
try
|
||||
{
|
||||
var bitmap = await loader.GetAsync(path, decodeWidth, cts.Token);
|
||||
|
||||
if (bitmap is null || cts.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
// The container may have been recycled onto a different item while we decoded.
|
||||
if (cts.IsCancellationRequested || !ReferenceEquals(_pending, cts))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_bitmap = bitmap;
|
||||
Opacity = 1;
|
||||
InvalidateVisual();
|
||||
});
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Scrolled away before the frame was ready; nothing to show and nothing to report.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancels any in-flight decode and drops the reference to the bitmap. The bitmap itself
|
||||
/// belongs to the cache and is never disposed here.
|
||||
/// </summary>
|
||||
private void Release()
|
||||
{
|
||||
if (_pending is { } cts)
|
||||
{
|
||||
_pending = null;
|
||||
cts.Cancel();
|
||||
cts.Dispose();
|
||||
}
|
||||
|
||||
_bitmap = null;
|
||||
Opacity = 0;
|
||||
InvalidateVisual();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using Avalonia.Media.Imaging;
|
||||
|
||||
namespace PLib.Desktop.Imaging;
|
||||
|
||||
/// <summary>The view-side contract for turning a poster frame path into a drawable bitmap.</summary>
|
||||
public interface IThumbnailLoader
|
||||
{
|
||||
Task<Bitmap?> GetAsync(string path, int decodeWidth, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using Avalonia.Media.Imaging;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace PLib.Desktop.Imaging;
|
||||
|
||||
/// <summary>
|
||||
/// Decodes poster frames off the UI thread and keeps the most recently used ones in memory.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A library can hold thousands of files, so decoded bitmaps cannot all stay alive. Entries
|
||||
/// are evicted by least-recent use but never disposed: a card that is still on screen may be
|
||||
/// rendering the very bitmap we drop, and the garbage collector is the only party that knows
|
||||
/// when the last reference is gone.
|
||||
/// </remarks>
|
||||
public sealed class ThumbnailCache(ILogger<ThumbnailCache> logger) : IThumbnailLoader
|
||||
{
|
||||
private const int Capacity = 256;
|
||||
|
||||
/// <summary>Decoding is CPU bound; a couple of workers keeps scrolling smooth without thrashing.</summary>
|
||||
private readonly SemaphoreSlim _decodeSlots = new(Math.Max(2, Environment.ProcessorCount / 2));
|
||||
|
||||
private readonly Lock _gate = new();
|
||||
private readonly Dictionary<CacheKey, LinkedListNode<CacheEntry>> _entries = [];
|
||||
private readonly LinkedList<CacheEntry> _recency = [];
|
||||
|
||||
public async Task<Bitmap?> GetAsync(string path, int decodeWidth, CancellationToken cancellationToken)
|
||||
{
|
||||
var key = new CacheKey(path, decodeWidth);
|
||||
|
||||
if (TryTouch(key, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
await _decodeSlots.WaitAsync(cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
// Another card may have decoded the same frame while we waited for a slot.
|
||||
if (TryTouch(key, out cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var bitmap = await Task.Run(() => Decode(path, decodeWidth), cancellationToken);
|
||||
|
||||
if (bitmap is not null)
|
||||
{
|
||||
Store(key, bitmap);
|
||||
}
|
||||
|
||||
return bitmap;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_decodeSlots.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private Bitmap? Decode(string path, int decodeWidth)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(path);
|
||||
return Bitmap.DecodeToWidth(stream, decodeWidth, BitmapInterpolationMode.HighQuality);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogDebug(ex, "Could not decode thumbnail {Path}", path);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryTouch(CacheKey key, out Bitmap? bitmap)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_entries.TryGetValue(key, out var node))
|
||||
{
|
||||
_recency.Remove(node);
|
||||
_recency.AddFirst(node);
|
||||
bitmap = node.Value.Bitmap;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
bitmap = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
private void Store(CacheKey key, Bitmap bitmap)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_entries.ContainsKey(key))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_entries[key] = _recency.AddFirst(new CacheEntry(key, bitmap));
|
||||
|
||||
while (_recency.Count > Capacity)
|
||||
{
|
||||
var evicted = _recency.Last!;
|
||||
_recency.RemoveLast();
|
||||
_entries.Remove(evicted.Value.Key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private readonly record struct CacheKey(string Path, int DecodeWidth);
|
||||
|
||||
private readonly record struct CacheEntry(CacheKey Key, Bitmap Bitmap);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<RootNamespace>PLib.Desktop</RootNamespace>
|
||||
<AssemblyName>PLib</AssemblyName>
|
||||
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="Assets\**" />
|
||||
<None Update="appsettings.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" />
|
||||
<PackageReference Include="Avalonia.Desktop" />
|
||||
<PackageReference Include="Avalonia.Controls.ItemsRepeater" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" />
|
||||
<PackageReference Include="Semi.Avalonia" />
|
||||
<PackageReference Include="Material.Icons.Avalonia" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
<PackageReference Include="Serilog" />
|
||||
<PackageReference Include="Serilog.Extensions.Logging" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" />
|
||||
<PackageReference Include="Serilog.Sinks.File" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\PLib.Infrastructure\PLib.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,18 @@
|
||||
using Avalonia;
|
||||
|
||||
namespace PLib.Desktop;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
// Avalonia must be initialised before anything touches its types, so keep Main free of
|
||||
// any other work and let App own the application host.
|
||||
[STAThread]
|
||||
public static void Main(string[] args) => BuildAvaloniaApp()
|
||||
.StartWithClassicDesktopLifetime(args);
|
||||
|
||||
/// <summary>Also used by the XAML previewer, which requires this exact signature.</summary>
|
||||
public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect()
|
||||
.WithInterFont()
|
||||
.LogToTrace();
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace PLib.Desktop.Services;
|
||||
|
||||
/// <summary>Asks the user for a folder. Returns <c>null</c> when the dialog is dismissed.</summary>
|
||||
public interface IFolderPicker
|
||||
{
|
||||
Task<string?> PickFolderAsync(string title, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace PLib.Desktop.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Persists the parts of <see cref="Application.Library.LibraryOptions"/> the user can change
|
||||
/// at runtime. Writes land in a JSON file that is also a configuration source, so
|
||||
/// <c>IOptionsMonitor</c> picks the change up without a restart.
|
||||
/// </summary>
|
||||
public interface ILibrarySettingsStore
|
||||
{
|
||||
Task SaveFoldersAsync(IReadOnlyList<string> folders, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using PLib.Application.Library;
|
||||
using PLib.Infrastructure.Storage;
|
||||
|
||||
namespace PLib.Desktop.Services;
|
||||
|
||||
/// <inheritdoc cref="ILibrarySettingsStore"/>
|
||||
public sealed class JsonLibrarySettingsStore(IAppPaths paths) : ILibrarySettingsStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions WriteOptions = new() { WriteIndented = true };
|
||||
|
||||
private readonly SemaphoreSlim _writeLock = new(1, 1);
|
||||
|
||||
private string SettingsFile => Path.Combine(paths.DataDirectory, "settings.json");
|
||||
|
||||
public async Task SaveFoldersAsync(
|
||||
IReadOnlyList<string> folders,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _writeLock.WaitAsync(cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
var root = await ReadRootAsync(cancellationToken);
|
||||
|
||||
if (root[LibraryOptions.SectionName] is not JsonObject section)
|
||||
{
|
||||
section = [];
|
||||
root[LibraryOptions.SectionName] = section;
|
||||
}
|
||||
|
||||
section["Folders"] = new JsonArray([.. folders.Select(f => (JsonNode)JsonValue.Create(f))]);
|
||||
|
||||
// Write through a temp file so an interrupted save cannot corrupt the settings.
|
||||
var staging = SettingsFile + ".tmp";
|
||||
await File.WriteAllTextAsync(staging, root.ToJsonString(WriteOptions), cancellationToken);
|
||||
File.Move(staging, SettingsFile, overwrite: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<JsonObject> ReadRootAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(SettingsFile))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(SettingsFile, cancellationToken);
|
||||
return JsonNode.Parse(json) as JsonObject ?? [];
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// A hand-edited, broken settings file should not stop the app from saving.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Platform.Storage;
|
||||
|
||||
namespace PLib.Desktop.Services;
|
||||
|
||||
/// <inheritdoc cref="IFolderPicker"/>
|
||||
public sealed class StorageProviderFolderPicker : IFolderPicker
|
||||
{
|
||||
public async Task<string?> PickFolderAsync(string title, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (Avalonia.Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime
|
||||
{
|
||||
MainWindow.StorageProvider: { } storageProvider,
|
||||
})
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var folders = await storageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
|
||||
{
|
||||
Title = title,
|
||||
AllowMultiple = false,
|
||||
});
|
||||
|
||||
return folders.Count > 0 ? folders[0].TryGetLocalPath() : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace PLib.Desktop.Services;
|
||||
|
||||
/// <summary>Hands a file over to whatever the operating system uses to open or show it.</summary>
|
||||
public interface ISystemShell
|
||||
{
|
||||
void OpenFile(string path);
|
||||
|
||||
void RevealInFileManager(string path);
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="ISystemShell"/>
|
||||
public sealed class SystemShell(ILogger<SystemShell> logger) : ISystemShell
|
||||
{
|
||||
public void OpenFile(string path) => Start(new ProcessStartInfo(path) { UseShellExecute = true }, path);
|
||||
|
||||
public void RevealInFileManager(string path)
|
||||
{
|
||||
ProcessStartInfo startInfo;
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
startInfo = new ProcessStartInfo("explorer.exe", $"/select,\"{path}\"");
|
||||
}
|
||||
else if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
startInfo = new ProcessStartInfo("open", ["-R", path]);
|
||||
}
|
||||
else
|
||||
{
|
||||
var folder = Path.GetDirectoryName(path);
|
||||
|
||||
if (folder is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
startInfo = new ProcessStartInfo("xdg-open", [folder]);
|
||||
}
|
||||
|
||||
Start(startInfo, path);
|
||||
}
|
||||
|
||||
private void Start(ProcessStartInfo startInfo, string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var process = Process.Start(startInfo);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Nothing actionable for the user here; a missing handler is not a crash.
|
||||
logger.LogWarning(ex, "Could not hand {Path} to the shell", path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Styling;
|
||||
|
||||
namespace PLib.Desktop.Services;
|
||||
|
||||
/// <summary>Switches the application between the light and dark variants.</summary>
|
||||
public interface IThemeService
|
||||
{
|
||||
void Toggle();
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="IThemeService"/>
|
||||
public sealed class ThemeService : IThemeService
|
||||
{
|
||||
public void Toggle()
|
||||
{
|
||||
if (Avalonia.Application.Current is not { } application)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// ActualThemeVariant resolves "follow the system" to whatever is on screen right now,
|
||||
// which is what the user is actually toggling away from.
|
||||
application.RequestedThemeVariant = application.ActualThemeVariant == ThemeVariant.Dark
|
||||
? ThemeVariant.Light
|
||||
: ThemeVariant.Dark;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<Styles xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
|
||||
<!-- ============================ Card ============================ -->
|
||||
|
||||
<!--
|
||||
The card is a Button so that keyboard focus, Enter/Space and the pointer all activate it
|
||||
for free. Its template is reduced to a single surface Border because the Semi button
|
||||
chrome would fight the artwork.
|
||||
-->
|
||||
<Style Selector="Button.card">
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<!-- Without these the card keeps its desired size inside the grid cell the layout
|
||||
hands it, which leaves the cards small and the grid full of gaps. -->
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="VerticalAlignment" Value="Stretch" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Stretch" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="RenderTransform" Value="none" />
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<TransformOperationsTransition Property="RenderTransform" Duration="0:0:0.16" Easing="CubicEaseOut" />
|
||||
</Transitions>
|
||||
</Setter>
|
||||
<Setter Property="Template">
|
||||
<ControlTemplate>
|
||||
<Border Name="PART_Surface"
|
||||
Background="{DynamicResource CardBackgroundBrush}"
|
||||
BorderBrush="{DynamicResource CardBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="14"
|
||||
Padding="8,8,8,10">
|
||||
<Border.Transitions>
|
||||
<Transitions>
|
||||
<BrushTransition Property="BorderBrush" Duration="0:0:0.16" />
|
||||
</Transitions>
|
||||
</Border.Transitions>
|
||||
<ContentPresenter Content="{TemplateBinding Content}"
|
||||
ContentTemplate="{TemplateBinding ContentTemplate}"
|
||||
HorizontalContentAlignment="Stretch"
|
||||
VerticalContentAlignment="Stretch" />
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.card:pointerover">
|
||||
<Setter Property="RenderTransform" Value="scale(1.025)" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.card:pointerover /template/ Border#PART_Surface">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource CardHoverBorderBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.card:pressed">
|
||||
<Setter Property="RenderTransform" Value="scale(0.99)" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.card:focus-visible /template/ Border#PART_Surface">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AccentBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- Play affordance: invisible until the pointer is over the card. -->
|
||||
<Style Selector="Border.playOverlay">
|
||||
<Setter Property="Opacity" Value="0" />
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<DoubleTransition Property="Opacity" Duration="0:0:0.16" Easing="CubicEaseOut" />
|
||||
</Transitions>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.card:pointerover Border.playOverlay">
|
||||
<Setter Property="Opacity" Value="1" />
|
||||
</Style>
|
||||
|
||||
<!-- ============================ Text ============================ -->
|
||||
|
||||
<Style Selector="TextBlock.cardTitle">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="LineHeight" Value="18" />
|
||||
<Setter Property="MaxLines" Value="2" />
|
||||
<Setter Property="TextWrapping" Value="Wrap" />
|
||||
<Setter Property="TextTrimming" Value="CharacterEllipsis" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.cardMeta">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextTertiaryBrush}" />
|
||||
<Setter Property="FontSize" Value="11.5" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.sectionTitle">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
|
||||
<Setter Property="FontSize" Value="17" />
|
||||
<Setter Property="FontWeight" Value="Bold" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.subtle">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextSecondaryBrush}" />
|
||||
<Setter Property="FontSize" Value="12.5" />
|
||||
</Style>
|
||||
|
||||
<!-- ============================ Badge ============================ -->
|
||||
|
||||
<Style Selector="Border.badge">
|
||||
<Setter Property="Background" Value="{DynamicResource BadgeBackgroundBrush}" />
|
||||
<Setter Property="CornerRadius" Value="6" />
|
||||
<Setter Property="Padding" Value="6,2" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.badge > TextBlock">
|
||||
<Setter Property="Foreground" Value="{DynamicResource BadgeForegroundBrush}" />
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
|
||||
</Styles>
|
||||
@@ -0,0 +1,49 @@
|
||||
<ResourceDictionary xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
|
||||
<!--
|
||||
Semantic brushes owned by this application. Semi.Avalonia styles the controls; the
|
||||
surfaces the library grid is built from are defined here so the card design does not
|
||||
depend on the internal resource keys of a third-party theme.
|
||||
-->
|
||||
<ResourceDictionary.ThemeDictionaries>
|
||||
|
||||
<ResourceDictionary x:Key="Dark">
|
||||
<SolidColorBrush x:Key="PageBackgroundBrush" Color="#0E1014" />
|
||||
<SolidColorBrush x:Key="SurfaceBrush" Color="#14171D" />
|
||||
<SolidColorBrush x:Key="SurfaceBorderBrush" Color="#20252E" />
|
||||
<SolidColorBrush x:Key="CardBackgroundBrush" Color="#171B22" />
|
||||
<SolidColorBrush x:Key="CardBorderBrush" Color="#242A34" />
|
||||
<SolidColorBrush x:Key="CardHoverBorderBrush" Color="#3F4B5E" />
|
||||
<SolidColorBrush x:Key="ThumbnailPlaceholderBrush" Color="#1C222B" />
|
||||
<SolidColorBrush x:Key="TextPrimaryBrush" Color="#E8EDF4" />
|
||||
<SolidColorBrush x:Key="TextSecondaryBrush" Color="#97A2B2" />
|
||||
<SolidColorBrush x:Key="TextTertiaryBrush" Color="#69737F" />
|
||||
<SolidColorBrush x:Key="AccentBrush" Color="#5B8CFF" />
|
||||
<SolidColorBrush x:Key="AccentSoftBrush" Color="#1E2A44" />
|
||||
<SolidColorBrush x:Key="BadgeBackgroundBrush" Color="#B3000000" />
|
||||
<SolidColorBrush x:Key="BadgeForegroundBrush" Color="#F2F5FA" />
|
||||
<SolidColorBrush x:Key="OverlayBrush" Color="#59000000" />
|
||||
</ResourceDictionary>
|
||||
|
||||
<ResourceDictionary x:Key="Light">
|
||||
<SolidColorBrush x:Key="PageBackgroundBrush" Color="#F4F6F9" />
|
||||
<SolidColorBrush x:Key="SurfaceBrush" Color="#FFFFFF" />
|
||||
<SolidColorBrush x:Key="SurfaceBorderBrush" Color="#E6E9EF" />
|
||||
<SolidColorBrush x:Key="CardBackgroundBrush" Color="#FFFFFF" />
|
||||
<SolidColorBrush x:Key="CardBorderBrush" Color="#E4E7EC" />
|
||||
<SolidColorBrush x:Key="CardHoverBorderBrush" Color="#B9C3D3" />
|
||||
<SolidColorBrush x:Key="ThumbnailPlaceholderBrush" Color="#E9EDF2" />
|
||||
<SolidColorBrush x:Key="TextPrimaryBrush" Color="#111621" />
|
||||
<SolidColorBrush x:Key="TextSecondaryBrush" Color="#586274" />
|
||||
<SolidColorBrush x:Key="TextTertiaryBrush" Color="#8A93A2" />
|
||||
<SolidColorBrush x:Key="AccentBrush" Color="#2F6BFF" />
|
||||
<SolidColorBrush x:Key="AccentSoftBrush" Color="#E5EDFF" />
|
||||
<SolidColorBrush x:Key="BadgeBackgroundBrush" Color="#B3101418" />
|
||||
<SolidColorBrush x:Key="BadgeForegroundBrush" Color="#FFFFFF" />
|
||||
<SolidColorBrush x:Key="OverlayBrush" Color="#4D000000" />
|
||||
</ResourceDictionary>
|
||||
|
||||
</ResourceDictionary.ThemeDictionaries>
|
||||
|
||||
</ResourceDictionary>
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace PLib.Desktop.ViewModels;
|
||||
|
||||
/// <summary>Turns raw numbers into the short strings the cards show.</summary>
|
||||
internal static class DisplayText
|
||||
{
|
||||
private static readonly string[] SizeUnits = ["Б", "КБ", "МБ", "ГБ", "ТБ"];
|
||||
|
||||
public static string Duration(TimeSpan? duration) => duration switch
|
||||
{
|
||||
null or { TotalSeconds: < 1 } => "—",
|
||||
{ TotalHours: >= 1 } value => value.ToString(@"h\:mm\:ss", CultureInfo.InvariantCulture),
|
||||
var value => value.Value.ToString(@"m\:ss", CultureInfo.InvariantCulture),
|
||||
};
|
||||
|
||||
public static string FileSize(long bytes)
|
||||
{
|
||||
double value = bytes;
|
||||
var unit = 0;
|
||||
|
||||
while (value >= 1024 && unit < SizeUnits.Length - 1)
|
||||
{
|
||||
value /= 1024;
|
||||
unit++;
|
||||
}
|
||||
|
||||
var precision = value < 10 && unit > 0 ? 1 : 0;
|
||||
return string.Create(CultureInfo.CurrentCulture, $"{Math.Round(value, precision)} {SizeUnits[unit]}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Names the vertical resolution the way people talk about it, falling back to the raw
|
||||
/// dimensions for anything that does not match a familiar tier.
|
||||
/// </summary>
|
||||
public static string? Quality(int? width, int? height) => (width, height) switch
|
||||
{
|
||||
(null, _) or (_, null) => null,
|
||||
( >= 7000, _) => "8K",
|
||||
( >= 3500, _) => "4K",
|
||||
(_, >= 2000) => "1440p",
|
||||
(_, >= 1000) => "1080p",
|
||||
(_, >= 700) => "720p",
|
||||
(_, >= 460) => "480p",
|
||||
var (w, h) => $"{w}×{h}",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using Avalonia.Threading;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using PLib.Application.Library;
|
||||
using PLib.Desktop.Services;
|
||||
|
||||
namespace PLib.Desktop.ViewModels;
|
||||
|
||||
public sealed partial class MainWindowViewModel : ObservableObject
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly IOptionsMonitor<LibraryOptions> _options;
|
||||
private readonly ILibrarySettingsStore _settingsStore;
|
||||
private readonly IFolderPicker _folderPicker;
|
||||
private readonly ISystemShell _shell;
|
||||
private readonly IThemeService _theme;
|
||||
private readonly ILogger<MainWindowViewModel> _logger;
|
||||
|
||||
/// <summary>Every card we know about; <see cref="Videos"/> is the filtered, sorted view of it.</summary>
|
||||
private readonly List<VideoCardViewModel> _all = [];
|
||||
private readonly Dictionary<Guid, VideoCardViewModel> _byId = [];
|
||||
|
||||
public MainWindowViewModel(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptionsMonitor<LibraryOptions> options,
|
||||
ILibrarySettingsStore settingsStore,
|
||||
IFolderPicker folderPicker,
|
||||
ISystemShell shell,
|
||||
IThemeService theme,
|
||||
ILogger<MainWindowViewModel> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_options = options;
|
||||
_settingsStore = settingsStore;
|
||||
_folderPicker = folderPicker;
|
||||
_shell = shell;
|
||||
_theme = theme;
|
||||
_logger = logger;
|
||||
|
||||
SelectedSort = SortOption.All[0];
|
||||
}
|
||||
|
||||
public ObservableCollection<VideoCardViewModel> Videos { get; } = [];
|
||||
|
||||
public IReadOnlyList<SortOption> SortOptions => SortOption.All;
|
||||
|
||||
public IReadOnlyList<string> Folders => [.. _options.CurrentValue.Folders];
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string SearchText { get; set; } = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
public partial SortOption SelectedSort { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool IsScanning { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string StatusText { get; set; } = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
public partial double ScanProgress { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool IsProgressIndeterminate { get; set; }
|
||||
|
||||
/// <summary>True when the library is empty and there is nothing to show but the call to action.</summary>
|
||||
public bool IsEmpty => Videos.Count == 0 && !IsScanning;
|
||||
|
||||
public bool HasFolders => _options.CurrentValue.Folders.Count > 0;
|
||||
|
||||
partial void OnSearchTextChanged(string value) => RebuildView();
|
||||
|
||||
partial void OnSelectedSortChanged(SortOption value) => RebuildView();
|
||||
|
||||
partial void OnIsScanningChanged(bool value) => OnPropertyChanged(nameof(IsEmpty));
|
||||
|
||||
/// <summary>Loads whatever is already in the database, then refreshes it against disk.</summary>
|
||||
[RelayCommand]
|
||||
private async Task InitializeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
||||
|
||||
foreach (var item in await library.GetLibraryAsync())
|
||||
{
|
||||
var card = new VideoCardViewModel(item, _shell);
|
||||
_all.Add(card);
|
||||
_byId[card.Id] = card;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// An unobserved failure here would tear the process down through the command's
|
||||
// task; the user gets a message instead and can still pick a folder.
|
||||
_logger.LogError(ex, "Could not load the stored library");
|
||||
StatusText = "Не удалось открыть базу библиотеки — подробности в журнале";
|
||||
return;
|
||||
}
|
||||
|
||||
RebuildView();
|
||||
|
||||
if (HasFolders)
|
||||
{
|
||||
await ScanCommand.ExecuteAsync(null);
|
||||
}
|
||||
else
|
||||
{
|
||||
StatusText = "Библиотека пуста — добавьте папку с видео";
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand(IncludeCancelCommand = true)]
|
||||
private async Task ScanAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var folders = _options.CurrentValue.Folders.ToArray();
|
||||
|
||||
if (folders.Length == 0)
|
||||
{
|
||||
StatusText = "Не выбрано ни одной папки";
|
||||
return;
|
||||
}
|
||||
|
||||
IsScanning = true;
|
||||
IsProgressIndeterminate = true;
|
||||
ScanProgress = 0;
|
||||
StatusText = "Поиск файлов…";
|
||||
|
||||
try
|
||||
{
|
||||
// Task.Run detaches the whole pipeline from the UI synchronisation context, so
|
||||
// scanning, probing and database work never touch the render thread. Every event
|
||||
// is marshalled back explicitly.
|
||||
await Task.Run(
|
||||
async () =>
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
||||
|
||||
await foreach (var scanEvent in library.ScanAsync(folders, cancellationToken))
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() => Handle(scanEvent));
|
||||
}
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
StatusText = "Сканирование отменено";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Library scan failed");
|
||||
StatusText = "Не удалось просканировать библиотеку — подробности в журнале";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsScanning = false;
|
||||
IsProgressIndeterminate = false;
|
||||
RebuildView();
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task AddFolderAsync()
|
||||
{
|
||||
var folder = await _folderPicker.PickFolderAsync("Выберите папку с видео");
|
||||
|
||||
if (folder is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var folders = _options.CurrentValue.Folders.ToList();
|
||||
|
||||
if (folders.Contains(folder, LibraryPathComparer.Instance))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
folders.Add(folder);
|
||||
await _settingsStore.SaveFoldersAsync(folders);
|
||||
|
||||
// IOptionsMonitor reloads from the file asynchronously; wait for it so the scan below
|
||||
// sees the folder we just added instead of racing the file watcher.
|
||||
await WaitForFolderAsync(folder);
|
||||
|
||||
OnPropertyChanged(nameof(Folders));
|
||||
OnPropertyChanged(nameof(HasFolders));
|
||||
|
||||
await ScanCommand.ExecuteAsync(null);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ToggleTheme() => _theme.Toggle();
|
||||
|
||||
private async Task WaitForFolderAsync(string folder)
|
||||
{
|
||||
for (var attempt = 0; attempt < 20; attempt++)
|
||||
{
|
||||
if (_options.CurrentValue.Folders.Contains(folder, LibraryPathComparer.Instance))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await Task.Delay(50);
|
||||
}
|
||||
|
||||
_logger.LogWarning("Configuration did not pick up the new folder {Folder} in time", folder);
|
||||
}
|
||||
|
||||
private void Handle(LibraryScanEvent scanEvent)
|
||||
{
|
||||
switch (scanEvent)
|
||||
{
|
||||
case LibraryScanEvent.DiscoveryCompleted discovery:
|
||||
StatusText = $"Найдено файлов: {discovery.FilesFound}";
|
||||
break;
|
||||
|
||||
case LibraryScanEvent.ItemAdded added:
|
||||
{
|
||||
var card = new VideoCardViewModel(added.Item, _shell);
|
||||
_all.Add(card);
|
||||
_byId[card.Id] = card;
|
||||
InsertIntoView(card);
|
||||
break;
|
||||
}
|
||||
|
||||
case LibraryScanEvent.ItemUpdated updated
|
||||
when _byId.TryGetValue(updated.Item.Id, out var existing):
|
||||
existing.Apply(updated.Item);
|
||||
break;
|
||||
|
||||
case LibraryScanEvent.ItemRemoved removed
|
||||
when _byId.Remove(removed.Id, out var dropped):
|
||||
_all.Remove(dropped);
|
||||
Videos.Remove(dropped);
|
||||
OnPropertyChanged(nameof(IsEmpty));
|
||||
break;
|
||||
|
||||
case LibraryScanEvent.IndexingProgress progress:
|
||||
IsProgressIndeterminate = false;
|
||||
ScanProgress = progress.Total == 0 ? 100 : progress.Processed * 100.0 / progress.Total;
|
||||
StatusText = $"Обработка превью: {progress.Processed} из {progress.Total}";
|
||||
break;
|
||||
|
||||
case LibraryScanEvent.Completed completed:
|
||||
ScanProgress = 100;
|
||||
StatusText = completed.LibrarySize == 0
|
||||
? "В выбранных папках не нашлось видео"
|
||||
: $"В библиотеке {completed.LibrarySize} видео";
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void InsertIntoView(VideoCardViewModel card)
|
||||
{
|
||||
if (!PassesFilter(card))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Appending keeps the grid stable while a scan streams in; the final RebuildView
|
||||
// puts everything in the requested order once the scan settles.
|
||||
Videos.Add(card);
|
||||
OnPropertyChanged(nameof(IsEmpty));
|
||||
}
|
||||
|
||||
private bool PassesFilter(VideoCardViewModel card) =>
|
||||
string.IsNullOrWhiteSpace(SearchText) || card.Matches(SearchText.Trim());
|
||||
|
||||
private void RebuildView()
|
||||
{
|
||||
var visible = _all.Where(PassesFilter);
|
||||
|
||||
visible = SelectedSort.Sort switch
|
||||
{
|
||||
LibrarySort.TitleAscending => visible.OrderBy(x => x.Title, StringComparer.CurrentCultureIgnoreCase),
|
||||
LibrarySort.LongestFirst => visible.OrderByDescending(x => x.RawDuration ?? TimeSpan.Zero),
|
||||
LibrarySort.LargestFirst => visible.OrderByDescending(x => x.RawSizeInBytes),
|
||||
_ => visible.OrderByDescending(x => x.AddedAt),
|
||||
};
|
||||
|
||||
Videos.Clear();
|
||||
|
||||
foreach (var card in visible)
|
||||
{
|
||||
Videos.Add(card);
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(IsEmpty));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace PLib.Desktop.ViewModels;
|
||||
|
||||
public enum LibrarySort
|
||||
{
|
||||
RecentlyAdded,
|
||||
TitleAscending,
|
||||
LongestFirst,
|
||||
LargestFirst,
|
||||
}
|
||||
|
||||
/// <summary>A sort order together with the label the combo box shows for it.</summary>
|
||||
public sealed record SortOption(LibrarySort Sort, string Label)
|
||||
{
|
||||
public static IReadOnlyList<SortOption> All { get; } =
|
||||
[
|
||||
new(LibrarySort.RecentlyAdded, "Недавно добавленные"),
|
||||
new(LibrarySort.TitleAscending, "По названию"),
|
||||
new(LibrarySort.LongestFirst, "Сначала длинные"),
|
||||
new(LibrarySort.LargestFirst, "Сначала большие"),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using PLib.Desktop.Services;
|
||||
using PLib.Domain.Videos;
|
||||
|
||||
namespace PLib.Desktop.ViewModels;
|
||||
|
||||
/// <summary>One card in the library grid.</summary>
|
||||
public sealed partial class VideoCardViewModel : ObservableObject
|
||||
{
|
||||
private readonly ISystemShell _shell;
|
||||
|
||||
public VideoCardViewModel(VideoItem item, ISystemShell shell)
|
||||
{
|
||||
_shell = shell;
|
||||
Id = item.Id;
|
||||
FullPath = item.FullPath;
|
||||
Title = item.Title;
|
||||
Apply(item);
|
||||
}
|
||||
|
||||
public Guid Id { get; }
|
||||
|
||||
public string FullPath { get; }
|
||||
|
||||
public DateTimeOffset AddedAt { get; private set; }
|
||||
|
||||
public TimeSpan? RawDuration { get; private set; }
|
||||
|
||||
public long RawSizeInBytes { get; private set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string Title { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string? ThumbnailPath { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string DurationText { get; set; } = "—";
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string SizeText { get; set; } = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string? QualityText { get; set; }
|
||||
|
||||
/// <summary>True while the poster frame has not been produced yet.</summary>
|
||||
[ObservableProperty]
|
||||
public partial bool IsPending { get; set; } = true;
|
||||
|
||||
/// <summary>Copies the current state of the entity into the card.</summary>
|
||||
public void Apply(VideoItem item)
|
||||
{
|
||||
Title = item.Title;
|
||||
ThumbnailPath = item.ThumbnailPath;
|
||||
DurationText = DisplayText.Duration(item.Duration);
|
||||
SizeText = DisplayText.FileSize(item.SizeInBytes);
|
||||
QualityText = DisplayText.Quality(item.Width, item.Height);
|
||||
AddedAt = item.AddedAt;
|
||||
RawDuration = item.Duration;
|
||||
RawSizeInBytes = item.SizeInBytes;
|
||||
IsPending = item.ThumbnailPath is null;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Play() => _shell.OpenFile(FullPath);
|
||||
|
||||
[RelayCommand]
|
||||
private void Reveal() => _shell.RevealInFileManager(FullPath);
|
||||
|
||||
public bool Matches(string term) =>
|
||||
Title.Contains(term, StringComparison.CurrentCultureIgnoreCase) ||
|
||||
FullPath.Contains(term, StringComparison.CurrentCultureIgnoreCase);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:controls="clr-namespace:PLib.Desktop.Controls"
|
||||
xmlns:icons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
|
||||
xmlns:vm="clr-namespace:PLib.Desktop.ViewModels"
|
||||
x:Class="PLib.Desktop.Views.MainWindow"
|
||||
x:DataType="vm:MainWindowViewModel"
|
||||
Title="PLib — видеотека"
|
||||
Width="1180"
|
||||
Height="760"
|
||||
MinWidth="640"
|
||||
MinHeight="420"
|
||||
Background="{DynamicResource PageBackgroundBrush}"
|
||||
WindowStartupLocation="CenterScreen">
|
||||
|
||||
<Window.Resources>
|
||||
|
||||
<!-- ======================= Video card ======================= -->
|
||||
<DataTemplate x:Key="VideoCardTemplate" DataType="vm:VideoCardViewModel">
|
||||
<Button Classes="card" Command="{Binding PlayCommand}" ToolTip.Tip="{Binding FullPath}">
|
||||
|
||||
<Button.ContextMenu>
|
||||
<ContextMenu>
|
||||
<MenuItem Header="Воспроизвести" Command="{Binding PlayCommand}" />
|
||||
<MenuItem Header="Показать в папке" Command="{Binding RevealCommand}" />
|
||||
</ContextMenu>
|
||||
</Button.ContextMenu>
|
||||
|
||||
<Grid RowDefinitions="*,Auto">
|
||||
|
||||
<!-- Poster frame -->
|
||||
<Border Grid.Row="0"
|
||||
CornerRadius="9"
|
||||
ClipToBounds="True"
|
||||
Background="{DynamicResource ThumbnailPlaceholderBrush}">
|
||||
<Panel>
|
||||
<controls:AsyncImage Source="{Binding ThumbnailPath}"
|
||||
DecodeWidth="480"
|
||||
PlaceholderBrush="{DynamicResource ThumbnailPlaceholderBrush}" />
|
||||
|
||||
<!-- Shown until ffmpeg has produced a frame for this file. -->
|
||||
<icons:MaterialIcon Kind="FilmstripBoxMultiple"
|
||||
Width="30"
|
||||
Height="30"
|
||||
Opacity="0.35"
|
||||
Foreground="{DynamicResource TextTertiaryBrush}"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
IsVisible="{Binding IsPending}" />
|
||||
|
||||
<Border Classes="badge"
|
||||
Margin="8"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Top"
|
||||
IsVisible="{Binding QualityText, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||
<TextBlock Text="{Binding QualityText}" />
|
||||
</Border>
|
||||
|
||||
<Border Classes="badge"
|
||||
Margin="8"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Bottom">
|
||||
<TextBlock Text="{Binding DurationText}" />
|
||||
</Border>
|
||||
|
||||
<Border Classes="playOverlay" Background="{DynamicResource OverlayBrush}">
|
||||
<Border Width="46"
|
||||
Height="46"
|
||||
CornerRadius="23"
|
||||
Background="{DynamicResource AccentBrush}"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center">
|
||||
<icons:MaterialIcon Kind="Play" Width="24" Height="24" Foreground="White" />
|
||||
</Border>
|
||||
</Border>
|
||||
</Panel>
|
||||
</Border>
|
||||
|
||||
<!-- Caption -->
|
||||
<StackPanel Grid.Row="1" Margin="4,10,4,2" Spacing="3">
|
||||
<TextBlock Classes="cardTitle" Text="{Binding Title}" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Classes="cardMeta" Text="{Binding SizeText}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
</Grid>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
|
||||
</Window.Resources>
|
||||
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
|
||||
<!-- ======================= Header ======================= -->
|
||||
<Border Grid.Row="0"
|
||||
Background="{DynamicResource SurfaceBrush}"
|
||||
BorderBrush="{DynamicResource SurfaceBorderBrush}"
|
||||
BorderThickness="0,0,0,1"
|
||||
Padding="20,14">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="20">
|
||||
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
|
||||
<Border Width="34"
|
||||
Height="34"
|
||||
CornerRadius="9"
|
||||
Background="{DynamicResource AccentSoftBrush}">
|
||||
<icons:MaterialIcon Kind="VideoVintage"
|
||||
Width="19"
|
||||
Height="19"
|
||||
Foreground="{DynamicResource AccentBrush}" />
|
||||
</Border>
|
||||
<StackPanel VerticalAlignment="Center">
|
||||
<TextBlock Classes="sectionTitle" Text="Видеотека" />
|
||||
<TextBlock Classes="cardMeta" Text="{Binding Videos.Count, StringFormat='{}{0} видео'}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<TextBox Grid.Column="1"
|
||||
MaxWidth="420"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Center"
|
||||
PlaceholderText="Поиск по названию…"
|
||||
Text="{Binding SearchText}" />
|
||||
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
||||
|
||||
<ComboBox MinWidth="180"
|
||||
ItemsSource="{Binding SortOptions}"
|
||||
SelectedItem="{Binding SelectedSort}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SortOption">
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
|
||||
<Button Command="{Binding ScanCommand}"
|
||||
IsEnabled="{Binding !IsScanning}"
|
||||
ToolTip.Tip="Пересканировать библиотеку">
|
||||
<icons:MaterialIcon Kind="Refresh" Width="17" Height="17" />
|
||||
</Button>
|
||||
|
||||
<Button Command="{Binding ToggleThemeCommand}" ToolTip.Tip="Сменить тему">
|
||||
<icons:MaterialIcon Kind="ThemeLightDark" Width="17" Height="17" />
|
||||
</Button>
|
||||
|
||||
<Button Classes="Primary" Command="{Binding AddFolderCommand}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="7">
|
||||
<icons:MaterialIcon Kind="FolderPlusOutline" Width="17" Height="17" />
|
||||
<TextBlock Text="Добавить папку" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ======================= Grid ======================= -->
|
||||
<Panel Grid.Row="1">
|
||||
|
||||
<ScrollViewer Padding="20,18" HorizontalScrollBarVisibility="Disabled">
|
||||
<ItemsRepeater ItemsSource="{Binding Videos}" ItemTemplate="{StaticResource VideoCardTemplate}">
|
||||
<ItemsRepeater.Layout>
|
||||
<UniformGridLayout ItemsStretch="Fill"
|
||||
MinItemWidth="230"
|
||||
MinItemHeight="212"
|
||||
MinColumnSpacing="16"
|
||||
MinRowSpacing="16" />
|
||||
</ItemsRepeater.Layout>
|
||||
</ItemsRepeater>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- Empty state -->
|
||||
<StackPanel HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="14"
|
||||
MaxWidth="420"
|
||||
IsVisible="{Binding IsEmpty}">
|
||||
<icons:MaterialIcon Kind="VideoBoxOff"
|
||||
Width="52"
|
||||
Height="52"
|
||||
Opacity="0.35"
|
||||
Foreground="{DynamicResource TextTertiaryBrush}"
|
||||
HorizontalAlignment="Center" />
|
||||
<TextBlock Classes="sectionTitle"
|
||||
HorizontalAlignment="Center"
|
||||
Text="Здесь пока пусто" />
|
||||
<TextBlock Classes="subtle"
|
||||
HorizontalAlignment="Center"
|
||||
TextAlignment="Center"
|
||||
TextWrapping="Wrap"
|
||||
Text="Укажите папку с видеофайлами — PLib пройдётся по ней, соберёт превью и покажет всё карточками." />
|
||||
<Button Classes="Primary"
|
||||
HorizontalAlignment="Center"
|
||||
Command="{Binding AddFolderCommand}"
|
||||
Content="Выбрать папку" />
|
||||
</StackPanel>
|
||||
|
||||
</Panel>
|
||||
|
||||
<!-- ======================= Status bar ======================= -->
|
||||
<Border Grid.Row="2"
|
||||
Background="{DynamicResource SurfaceBrush}"
|
||||
BorderBrush="{DynamicResource SurfaceBorderBrush}"
|
||||
BorderThickness="0,1,0,0"
|
||||
Padding="20,9">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="14">
|
||||
|
||||
<ProgressBar Grid.Column="0"
|
||||
Width="160"
|
||||
VerticalAlignment="Center"
|
||||
Minimum="0"
|
||||
Maximum="100"
|
||||
Value="{Binding ScanProgress}"
|
||||
IsIndeterminate="{Binding IsProgressIndeterminate}"
|
||||
IsVisible="{Binding IsScanning}" />
|
||||
|
||||
<TextBlock Grid.Column="1"
|
||||
Classes="subtle"
|
||||
VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
Text="{Binding StatusText}" />
|
||||
|
||||
<Button Grid.Column="2"
|
||||
Content="Отмена"
|
||||
Command="{Binding ScanCancelCommand}"
|
||||
IsVisible="{Binding IsScanning}" />
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,8 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace PLib.Desktop.Views;
|
||||
|
||||
public sealed partial class MainWindow : Window
|
||||
{
|
||||
public MainWindow() => InitializeComponent();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="0.1.0.0" name="PLib.Desktop" />
|
||||
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2</dpiAwareness>
|
||||
</windowsSettings>
|
||||
</application>
|
||||
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<!-- Windows 10 / 11 -->
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
</assembly>
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Library": {
|
||||
"Folders": [],
|
||||
"ThumbnailWidth": 480,
|
||||
"ThumbnailPositionRatio": 0.15,
|
||||
"MaxIndexingConcurrency": 4,
|
||||
"MinimumFileSizeInBytes": 65536
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user