Update README.md with project details, features, requirements, architecture, and data management for PLib video library manager.

This commit is contained in:
Leonid Pershin
2026-08-08 07:08:43 +03:00
parent e767e4a48c
commit ac05ea6b7f
59 changed files with 3010 additions and 0 deletions
+20
View File
@@ -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>
+55
View File
@@ -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;
}
}
+69
View File
@@ -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);
}
}
+201
View File
@@ -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);
}
+114
View File
@@ -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);
}
+38
View File
@@ -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>
+18
View File
@@ -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;
}
}
+58
View File
@@ -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);
}
}
}
+28
View File
@@ -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;
}
}
+121
View File
@@ -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>
+49
View File
@@ -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));
}
}
+21
View File
@@ -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);
}
+234
View File
@@ -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();
}
+18
View File
@@ -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>
+9
View File
@@ -0,0 +1,9 @@
{
"Library": {
"Folders": [],
"ThumbnailWidth": 480,
"ThumbnailPositionRatio": 0.15,
"MaxIndexingConcurrency": 4,
"MinimumFileSizeInBytes": 65536
}
}