Refactor ILibraryService and LibraryService to support new metadata handling features, including remote image management and enhanced label summaries. Update LabelSummary to include ImagePath for better visual representation. Revise MetadataMatchViewModel and MetadataScanViewModel to accommodate new image loading logic. Enhance README.md to document these updates and new functionalities.
This commit is contained in:
+82
-81
@@ -1,81 +1,82 @@
|
||||
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.Settings;
|
||||
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.AddOptions<AppearanceOptions>()
|
||||
.Bind(builder.Configuration.GetSection(AppearanceOptions.SectionName));
|
||||
|
||||
builder.Services.AddOptions<PlaybackOptions>()
|
||||
.Bind(builder.Configuration.GetSection(PlaybackOptions.SectionName))
|
||||
.ValidateDataAnnotations();
|
||||
builder.Services.AddPLibInfrastructure(builder.Configuration);
|
||||
|
||||
builder.Services.AddSingleton<ThumbnailCache>();
|
||||
builder.Services.AddSingleton<IThumbnailLoader>(sp => sp.GetRequiredService<ThumbnailCache>());
|
||||
builder.Services.AddSingleton<IAppSettingsStore, JsonAppSettingsStore>();
|
||||
builder.Services.AddSingleton<IFolderPicker, StorageProviderFolderPicker>();
|
||||
builder.Services.AddSingleton<ISystemShell, SystemShell>();
|
||||
builder.Services.AddSingleton<IThemeService, ThemeService>();
|
||||
builder.Services.AddSingleton<MainWindowViewModel>();
|
||||
|
||||
// Scoped, not singleton: the settings dialog edits a working copy, so each opening
|
||||
// gets its own model and a cancelled edit never leaks into the next one.
|
||||
builder.Services.AddScoped<SettingsViewModel>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
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.Settings;
|
||||
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.AddOptions<AppearanceOptions>()
|
||||
.Bind(builder.Configuration.GetSection(AppearanceOptions.SectionName));
|
||||
|
||||
builder.Services.AddOptions<PlaybackOptions>()
|
||||
.Bind(builder.Configuration.GetSection(PlaybackOptions.SectionName))
|
||||
.ValidateDataAnnotations();
|
||||
builder.Services.AddPLibInfrastructure(builder.Configuration);
|
||||
|
||||
builder.Services.AddSingleton<ThumbnailCache>();
|
||||
builder.Services.AddSingleton<IThumbnailLoader>(sp => sp.GetRequiredService<ThumbnailCache>());
|
||||
builder.Services.AddSingleton<IAppSettingsStore, JsonAppSettingsStore>();
|
||||
builder.Services.AddSingleton<IFolderPicker, StorageProviderFolderPicker>();
|
||||
builder.Services.AddSingleton<ISystemShell, SystemShell>();
|
||||
builder.Services.AddSingleton<IThemeService, ThemeService>();
|
||||
builder.Services.AddSingleton<RemoteImageLoader>();
|
||||
builder.Services.AddSingleton<MainWindowViewModel>();
|
||||
|
||||
// Scoped, not singleton: the settings dialog edits a working copy, so each opening
|
||||
// gets its own model and a cancelled edit never leaks into the next one.
|
||||
builder.Services.AddScoped<SettingsViewModel>();
|
||||
|
||||
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,85 @@
|
||||
using System.Reactive.Linq;
|
||||
using System.Reactive.Subjects;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PLib.Application.Library;
|
||||
|
||||
namespace PLib.Desktop.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the pictures a metadata source pointed at, behind whatever is already on screen.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Nothing waits on this. A candidate is shown the moment the source answers, and its cover
|
||||
/// appears when it appears — the alternative, fetching first, put a stranger's picture host on
|
||||
/// the critical path of showing a result, and one that stalled froze the whole run.
|
||||
///
|
||||
/// Concurrency is capped because a run can produce results faster than pictures download, and
|
||||
/// an unbounded fan-out would open a connection per candidate to the same host.
|
||||
/// </remarks>
|
||||
public sealed class RemoteImageLoader(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<RemoteImageLoader> logger) : IDisposable
|
||||
{
|
||||
private readonly SemaphoreSlim _slots = new(4);
|
||||
private readonly Subject<string> _problems = new();
|
||||
|
||||
/// <summary>
|
||||
/// Reasons pictures are not arriving, for whatever page is on screen to show.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// An empty square tells the user nothing — it looks the same whether the source has no
|
||||
/// picture or the whole host is unreachable. The cache raises one of these only once it
|
||||
/// has given up on a host, so this is a handful of lines, not one per candidate.
|
||||
/// </remarks>
|
||||
public IObservable<string> Problems => _problems.AsObservable();
|
||||
|
||||
public async Task<string?> LoadAsync(string? imageUrl, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(imageUrl))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _slots.WaitAsync(cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
||||
|
||||
var image = await library.FetchImageAsync(imageUrl, cancellationToken);
|
||||
|
||||
if (image.Problem is { } problem)
|
||||
{
|
||||
_problems.OnNext(problem);
|
||||
}
|
||||
|
||||
return image.Path;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_slots.Release();
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A missing cover costs a thumbnail. Nothing above this is waiting for an answer,
|
||||
// so there is nobody to report it to but the log.
|
||||
logger.LogDebug(ex, "Could not fetch the picture at {Url}", imageUrl);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_problems.Dispose();
|
||||
_slots.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -136,14 +136,16 @@
|
||||
|
||||
<!-- One tag, performer, studio or collection in a browsing tab. -->
|
||||
<Style Selector="Button.entity">
|
||||
<Setter Property="Padding" Value="12,10" />
|
||||
<Setter Property="Padding" Value="8,8,8,10" />
|
||||
<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="Background" Value="{DynamicResource CardBackgroundBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource CardBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.entity:pointerover">
|
||||
|
||||
@@ -18,6 +18,12 @@ public sealed class LabelSummaryViewModel
|
||||
Kind = summary.Kind;
|
||||
VideoCount = summary.VideoCount;
|
||||
CountText = VideoCount.ToString(CultureInfo.CurrentCulture);
|
||||
ImagePath = summary.ImagePath;
|
||||
|
||||
// Stands in for the picture. Tags never have one — stash-box holds no image for a tag
|
||||
// — and a performer only gets one once a match brings it, so the fallback is the
|
||||
// normal case rather than an error state.
|
||||
Initial = Name.Length > 0 ? Name[..1].ToUpperInvariant() : "?";
|
||||
|
||||
OpenCommand = ReactiveCommand.Create(() => open(this));
|
||||
}
|
||||
@@ -32,6 +38,12 @@ public sealed class LabelSummaryViewModel
|
||||
|
||||
public string CountText { get; }
|
||||
|
||||
/// <summary>Cached picture of this label, or <c>null</c> when there is none.</summary>
|
||||
public string? ImagePath { get; }
|
||||
|
||||
/// <summary>First letter, drawn when there is no picture.</summary>
|
||||
public string Initial { get; }
|
||||
|
||||
/// <summary>Narrows the video grid down to this label and switches to it.</summary>
|
||||
public ReactiveCommand<RxVoid, RxVoid> OpenCommand { get; }
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
using PLib.Application.Metadata;
|
||||
using ReactiveUI;
|
||||
using ReactiveUI.SourceGenerators;
|
||||
using RxVoid = ReactiveUI.Primitives.RxVoid;
|
||||
|
||||
namespace PLib.Desktop.ViewModels;
|
||||
@@ -9,7 +10,7 @@ namespace PLib.Desktop.ViewModels;
|
||||
/// A match is never applied on arrival. Fingerprints collide across re-encodes and trailers,
|
||||
/// and a source confidently overwriting a title is far harder to undo than a button is to press.
|
||||
/// </remarks>
|
||||
public sealed class MetadataMatchViewModel
|
||||
public sealed partial class MetadataMatchViewModel : ReactiveObject
|
||||
{
|
||||
public MetadataMatchViewModel(VideoMetadataMatch match, Func<VideoMetadataMatch, Task> apply)
|
||||
{
|
||||
@@ -19,6 +20,7 @@ public sealed class MetadataMatchViewModel
|
||||
SourceName = match.SourceName;
|
||||
Title = match.Title;
|
||||
Description = match.Description;
|
||||
ImageUrl = match.ImageUrl;
|
||||
|
||||
Studios = Join(match.Studios);
|
||||
Performers = Join(match.Performers);
|
||||
@@ -35,6 +37,18 @@ public sealed class MetadataMatchViewModel
|
||||
|
||||
public string? Description { get; }
|
||||
|
||||
/// <summary>Where the candidate's cover lives at the source, or <c>null</c>.</summary>
|
||||
public string? ImageUrl { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The cover once it is on disk. Reactive and initially empty: the row is shown as soon as
|
||||
/// the source answers, and the picture fills in behind it. A title and a tag list say very
|
||||
/// little about whether this is the right video; a frame says it at a glance — but not at
|
||||
/// the price of the row waiting for it.
|
||||
/// </summary>
|
||||
[Reactive]
|
||||
public partial string? ImagePath { get; set; }
|
||||
|
||||
/// <summary>Comma-separated for display; the lists themselves stay on <see cref="Match"/>.</summary>
|
||||
public string? Studios { get; }
|
||||
|
||||
@@ -44,6 +58,6 @@ public sealed class MetadataMatchViewModel
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> ApplyCommand { get; }
|
||||
|
||||
private static string? Join(IReadOnlyList<string> values) =>
|
||||
values.Count == 0 ? null : string.Join(", ", values);
|
||||
private static string? Join(IReadOnlyList<MetadataEntity> values) =>
|
||||
values.Count == 0 ? null : string.Join(", ", values.Select(entity => entity.Name));
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PLib.Application.Library;
|
||||
using PLib.Application.Metadata;
|
||||
using PLib.Desktop.Services;
|
||||
using ReactiveUI;
|
||||
using ReactiveUI.SourceGenerators;
|
||||
using RxVoid = ReactiveUI.Primitives.RxVoid;
|
||||
@@ -22,6 +23,7 @@ namespace PLib.Desktop.ViewModels;
|
||||
public sealed partial class MetadataScanViewModel : ViewModelBase
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly RemoteImageLoader _images;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
/// <summary>Called after the run so the grid can pick up renamed videos and new labels.</summary>
|
||||
@@ -31,10 +33,12 @@ public sealed partial class MetadataScanViewModel : ViewModelBase
|
||||
|
||||
public MetadataScanViewModel(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
RemoteImageLoader images,
|
||||
Func<Task> refreshLibrary,
|
||||
ILogger logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_images = images;
|
||||
_refreshLibrary = refreshLibrary;
|
||||
_logger = logger;
|
||||
|
||||
@@ -46,6 +50,12 @@ public sealed partial class MetadataScanViewModel : ViewModelBase
|
||||
() => _running?.Cancel(),
|
||||
this.WhenAnyValue(x => x.IsRunning));
|
||||
|
||||
// Pictures fail on their own schedule, long after the source answered, so this is
|
||||
// a subscription rather than something the run reports.
|
||||
_images.Problems
|
||||
.Subscribe(problem => Dispatcher.UIThread.Post(() => Report(problem)))
|
||||
.AddTo(Subscriptions);
|
||||
|
||||
ObserveCommandFailures();
|
||||
}
|
||||
|
||||
@@ -134,11 +144,16 @@ public sealed partial class MetadataScanViewModel : ViewModelBase
|
||||
break;
|
||||
|
||||
case MetadataScanEvent.Matched matched:
|
||||
Results.Insert(0, new MetadataScanResultViewModel(matched, ApplyAsync));
|
||||
var result = new MetadataScanResultViewModel(matched, ApplyAsync);
|
||||
Results.Insert(0, result);
|
||||
|
||||
// Started, not awaited: the row is on screen already, and the covers
|
||||
// arrive when the picture host gets round to them.
|
||||
LoadCovers(result.Matches);
|
||||
break;
|
||||
|
||||
case MetadataScanEvent.SourceAbandoned abandoned:
|
||||
Problems.Add($"{abandoned.SourceName}: {abandoned.Reason}");
|
||||
Report($"{abandoned.SourceName}: {abandoned.Reason}");
|
||||
break;
|
||||
|
||||
case MetadataScanEvent.Completed completed:
|
||||
@@ -153,6 +168,35 @@ public sealed partial class MetadataScanViewModel : ViewModelBase
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Adds a line unless it is already there; the same host fails repeatedly.</summary>
|
||||
private void Report(string problem)
|
||||
{
|
||||
if (!Problems.Contains(problem))
|
||||
{
|
||||
Problems.Add(problem);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fills in the covers behind rows that are already visible. Nothing awaits this:
|
||||
/// fetching before showing is what made a stalled picture host freeze the whole run.
|
||||
/// </summary>
|
||||
private void LoadCovers(IEnumerable<MetadataMatchViewModel> matches)
|
||||
{
|
||||
foreach (var match in matches.Where(match => match.ImageUrl is not null))
|
||||
{
|
||||
_ = LoadCoverAsync(match);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadCoverAsync(MetadataMatchViewModel match)
|
||||
{
|
||||
if (await _images.LoadAsync(match.ImageUrl) is { } path)
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() => match.ImagePath = path);
|
||||
}
|
||||
}
|
||||
|
||||
private static string Described(int count) =>
|
||||
count == 0 ? "Совпадений пока не было." : $"Совпадений найдено: {count}.";
|
||||
|
||||
|
||||
@@ -1,315 +1,321 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Reactive.Disposables;
|
||||
using System.Reactive.Linq;
|
||||
using System.Reactive.Subjects;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PLib.Application.Library;
|
||||
using PLib.Application.Metadata;
|
||||
using PLib.Desktop.Services;
|
||||
using PLib.Desktop.Settings;
|
||||
using ReactiveUI;
|
||||
using ReactiveUI.SourceGenerators;
|
||||
using RxVoid = ReactiveUI.Primitives.RxVoid;
|
||||
|
||||
namespace PLib.Desktop.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// Edits a working copy of the settings and only writes it back when the user confirms, so
|
||||
/// cancelling leaves both the file and the running configuration untouched.
|
||||
/// </summary>
|
||||
public sealed partial class SettingsViewModel : ViewModelBase
|
||||
{
|
||||
private const double BytesPerMegabyte = 1024 * 1024;
|
||||
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly IAppSettingsStore _settingsStore;
|
||||
private readonly IFolderPicker _folderPicker;
|
||||
private readonly IThemeService _theme;
|
||||
private readonly ILogger<SettingsViewModel> _logger;
|
||||
|
||||
/// <summary>The state the dialog opened with; the baseline every change is compared to.</summary>
|
||||
private readonly AppSettings _original;
|
||||
|
||||
private readonly Subject<SettingsDialogOutcome> _closed = new();
|
||||
|
||||
/// <summary>Set once derived data has been wiped, which always forces a rescan on close.</summary>
|
||||
private bool _dataWasReset;
|
||||
|
||||
public SettingsViewModel(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IAppSettingsStore settingsStore,
|
||||
IFolderPicker folderPicker,
|
||||
IThemeService theme,
|
||||
ILogger<SettingsViewModel> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_settingsStore = settingsStore;
|
||||
_folderPicker = folderPicker;
|
||||
_theme = theme;
|
||||
_logger = logger;
|
||||
|
||||
// Read on construction rather than cached anywhere: the panel can be reopened after
|
||||
// a save, and a stale snapshot would show the settings the application started with.
|
||||
_original = settingsStore.Current;
|
||||
|
||||
Folders = [.. _original.Folders.Select(CreateEntry)];
|
||||
MetadataSources = [.. _original.MetadataSources.Select(CreateEntry)];
|
||||
ThumbnailWidth = _original.ThumbnailWidth;
|
||||
ThumbnailPositionPercent = ToPercent(_original.ThumbnailPositionRatio);
|
||||
MaxIndexingConcurrency = _original.MaxIndexingConcurrency;
|
||||
MinimumFileSizeMegabytes = ToMegabytes(_original.MinimumFileSizeInBytes);
|
||||
SelectedTheme = ThemeOptions.First(option => option.Mode == _original.Theme);
|
||||
|
||||
AddFolderCommand = ReactiveCommand.CreateFromTask(AddFolderAsync);
|
||||
AddMetadataSourceCommand = ReactiveCommand.Create(AddMetadataSource);
|
||||
RefreshUsageCommand = ReactiveCommand.CreateFromTask(RefreshUsageAsync);
|
||||
|
||||
// One gate for every clearing button: they all talk to the same database and the same
|
||||
// cache directories, so letting a second one start mid-flight buys nothing.
|
||||
var idle = this.WhenAnyValue(x => x.IsBusy).Select(busy => !busy);
|
||||
|
||||
DataKinds =
|
||||
[
|
||||
new(
|
||||
LibraryDataKind.Thumbnails,
|
||||
"Постеры",
|
||||
"Кадр-обложка карточки. Соберутся заново при следующем сканировании.",
|
||||
ClearAsync,
|
||||
idle),
|
||||
new(
|
||||
LibraryDataKind.AnimatedPreviews,
|
||||
"Анимированные превью",
|
||||
"Кадры, которые прокручиваются под курсором. Самое объёмное на диске.",
|
||||
ClearAsync,
|
||||
idle),
|
||||
new(
|
||||
LibraryDataKind.PerceptualHashes,
|
||||
"Отпечатки",
|
||||
"Нужны только для поиска дублей. Считаются дольше всего: два десятка кадров на файл.",
|
||||
ClearAsync,
|
||||
idle),
|
||||
new(
|
||||
LibraryDataKind.TechnicalMetadata,
|
||||
"Технические метаданные",
|
||||
"Длительность, разрешение и кодек. Без них карточка не считается готовой, поэтому файл будет переиндексирован целиком.",
|
||||
ClearAsync,
|
||||
idle),
|
||||
];
|
||||
|
||||
ClearAllCommand = ReactiveCommand.CreateFromTask(() => ClearAsync(LibraryDataKind.All), idle);
|
||||
SaveCommand = ReactiveCommand.CreateFromTask(SaveAsync);
|
||||
CancelCommand = ReactiveCommand.Create(() => _closed.OnNext(SettingsDialogOutcome.Cancelled));
|
||||
|
||||
// A binding cannot negate an int, so the "nothing added yet" hint reads a bool that
|
||||
// is re-raised whenever the list changes.
|
||||
Folders.CollectionChanged += OnFoldersChanged;
|
||||
Disposable
|
||||
.Create(() => Folders.CollectionChanged -= OnFoldersChanged)
|
||||
.AddTo(Subscriptions);
|
||||
|
||||
ObserveCommandFailures();
|
||||
}
|
||||
|
||||
/// <summary>Fires once, when the dialog should close.</summary>
|
||||
public IObservable<SettingsDialogOutcome> Closed => _closed;
|
||||
|
||||
public ObservableCollection<FolderEntryViewModel> Folders { get; }
|
||||
|
||||
public bool HasFolders => Folders.Count > 0;
|
||||
|
||||
public ObservableCollection<MetadataSourceEntryViewModel> MetadataSources { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> AddMetadataSourceCommand { get; }
|
||||
|
||||
public IReadOnlyList<ThemeOption> ThemeOptions { get; } = ThemeOption.All;
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> AddFolderCommand { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> RefreshUsageCommand { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> ClearAllCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Everything a scan can rebuild, one row each. Deliberately does not include titles,
|
||||
/// tags or watch progress: those are the user's, and no rescan would bring them back.
|
||||
/// </summary>
|
||||
public IReadOnlyList<LibraryDataViewModel> DataKinds { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> SaveCommand { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> CancelCommand { get; }
|
||||
|
||||
/// <summary>Width of generated poster frames; height follows the source aspect ratio.</summary>
|
||||
[Reactive]
|
||||
public partial int ThumbnailWidth { get; set; }
|
||||
|
||||
/// <summary>How far into the video the poster frame is taken, in percent.</summary>
|
||||
[Reactive]
|
||||
public partial double ThumbnailPositionPercent { get; set; }
|
||||
|
||||
[Reactive]
|
||||
public partial int MaxIndexingConcurrency { get; set; }
|
||||
|
||||
[Reactive]
|
||||
public partial double MinimumFileSizeMegabytes { get; set; }
|
||||
|
||||
[Reactive]
|
||||
public partial ThemeOption SelectedTheme { get; set; }
|
||||
|
||||
[Reactive]
|
||||
public partial bool IsBusy { get; set; }
|
||||
|
||||
[Reactive]
|
||||
public partial string? Message { get; set; }
|
||||
|
||||
// Built from the snapshot the panel opened with, not from scratch: anything this screen
|
||||
// does not edit — playback volume, for one — has to survive being saved from here.
|
||||
private AppSettings CurrentDraft => _original with
|
||||
{
|
||||
Folders = [.. Folders.Select(entry => entry.Path)],
|
||||
ThumbnailWidth = ThumbnailWidth,
|
||||
|
||||
// Both of these are shown in a friendlier unit than they are stored in, and that
|
||||
// conversion is lossy: 65 536 bytes displays as 0,06 MB and converts back to 62 915.
|
||||
// An untouched field therefore keeps the original value verbatim — otherwise merely
|
||||
// opening the panel and pressing Save would rewrite settings and force a rescan.
|
||||
ThumbnailPositionRatio = ThumbnailPositionPercent == ToPercent(_original.ThumbnailPositionRatio)
|
||||
? _original.ThumbnailPositionRatio
|
||||
: Math.Round(ThumbnailPositionPercent / 100, 4),
|
||||
|
||||
MaxIndexingConcurrency = MaxIndexingConcurrency,
|
||||
|
||||
MinimumFileSizeInBytes = MinimumFileSizeMegabytes == ToMegabytes(_original.MinimumFileSizeInBytes)
|
||||
? _original.MinimumFileSizeInBytes
|
||||
: (long)Math.Round(MinimumFileSizeMegabytes * BytesPerMegabyte),
|
||||
|
||||
Theme = SelectedTheme.Mode,
|
||||
|
||||
// Rows with nothing in them are what a half-finished edit looks like, and saving them
|
||||
// would put empty entries in the file for the next opening to show again.
|
||||
MetadataSources =
|
||||
[
|
||||
.. MetadataSources
|
||||
.Select(entry => entry.ToOptions())
|
||||
.Where(source => !string.IsNullOrWhiteSpace(source.Endpoint))
|
||||
],
|
||||
};
|
||||
|
||||
private static double ToPercent(double ratio) => Math.Round(ratio * 100);
|
||||
|
||||
private static double ToMegabytes(long bytes) => Math.Round(bytes / BytesPerMegabyte, 2);
|
||||
|
||||
private async Task AddFolderAsync()
|
||||
{
|
||||
var folder = await _folderPicker.PickFolderAsync("Выберите папку с видео");
|
||||
|
||||
if (folder is null || Folders.Any(entry => LibraryPathComparer.Instance.Equals(entry.Path, folder)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Folders.Add(CreateEntry(folder));
|
||||
}
|
||||
|
||||
private void OnFoldersChanged(object? sender, EventArgs e) => this.RaisePropertyChanged(nameof(HasFolders));
|
||||
|
||||
private FolderEntryViewModel CreateEntry(string path) =>
|
||||
new(path, entry => Folders.Remove(entry));
|
||||
|
||||
private MetadataSourceEntryViewModel CreateEntry(MetadataSourceOptions source) =>
|
||||
new(source, entry => MetadataSources.Remove(entry));
|
||||
|
||||
private void AddMetadataSource() =>
|
||||
MetadataSources.Add(CreateEntry(new MetadataSourceOptions()));
|
||||
|
||||
private async Task RefreshUsageAsync()
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
||||
|
||||
var usage = await library.GetDataUsageAsync();
|
||||
|
||||
foreach (var entry in usage)
|
||||
{
|
||||
DataKinds.FirstOrDefault(row => row.Kind == entry.Kind)?.Apply(entry);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ClearAsync(LibraryDataKind kinds)
|
||||
{
|
||||
IsBusy = true;
|
||||
|
||||
try
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
||||
|
||||
await library.ResetAsync(kinds);
|
||||
await RefreshUsageAsync();
|
||||
|
||||
// The data is gone from disk and from the library, so the grid has to be rebuilt
|
||||
// regardless of what else the user changes before closing.
|
||||
_dataWasReset = true;
|
||||
Message = "Очищено — недостающее соберётся при следующем сканировании";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
var draft = CurrentDraft;
|
||||
|
||||
await _settingsStore.SaveAsync(draft);
|
||||
|
||||
// The theme is not read from configuration again while the app runs, so apply it here.
|
||||
_theme.Apply(draft.Theme);
|
||||
|
||||
var rescan = _dataWasReset || draft.RequiresRescanComparedTo(_original);
|
||||
_closed.OnNext(new SettingsDialogOutcome(Saved: true, rescan, draft));
|
||||
}
|
||||
|
||||
private void ObserveCommandFailures() =>
|
||||
Observable
|
||||
.Merge<Exception>(
|
||||
[
|
||||
AddFolderCommand.ThrownExceptions,
|
||||
AddMetadataSourceCommand.ThrownExceptions,
|
||||
RefreshUsageCommand.ThrownExceptions,
|
||||
ClearAllCommand.ThrownExceptions,
|
||||
SaveCommand.ThrownExceptions,
|
||||
CancelCommand.ThrownExceptions,
|
||||
|
||||
// The per-kind buttons are commands too, and an unobserved failure in any of
|
||||
// them would be rethrown on the UI thread by ReactiveUI's default handler.
|
||||
.. DataKinds.Select(row => row.ClearCommand.ThrownExceptions),
|
||||
])
|
||||
.Subscribe(ex =>
|
||||
{
|
||||
_logger.LogError(ex, "A settings command failed");
|
||||
Message = "Не удалось выполнить действие — подробности в журнале";
|
||||
})
|
||||
.AddTo(Subscriptions);
|
||||
}
|
||||
|
||||
/// <summary>What the settings dialog left behind once it closed.</summary>
|
||||
/// <param name="Saved">False when the user cancelled or closed the window.</param>
|
||||
/// <param name="RescanRequired">True when the change affects what the library contains.</param>
|
||||
/// <param name="Settings">The snapshot that was written, so the caller can wait for it to load.</param>
|
||||
public sealed record SettingsDialogOutcome(bool Saved, bool RescanRequired, AppSettings? Settings)
|
||||
{
|
||||
public static SettingsDialogOutcome Cancelled { get; } = new(false, false, null);
|
||||
}
|
||||
|
||||
public sealed record ThemeOption(ThemeMode Mode, string Label)
|
||||
{
|
||||
public static IReadOnlyList<ThemeOption> All { get; } =
|
||||
[
|
||||
new(ThemeMode.System, "Как в системе"),
|
||||
new(ThemeMode.Light, "Светлая"),
|
||||
new(ThemeMode.Dark, "Тёмная"),
|
||||
];
|
||||
}
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Reactive.Disposables;
|
||||
using System.Reactive.Linq;
|
||||
using System.Reactive.Subjects;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PLib.Application.Library;
|
||||
using PLib.Application.Metadata;
|
||||
using PLib.Desktop.Services;
|
||||
using PLib.Desktop.Settings;
|
||||
using ReactiveUI;
|
||||
using ReactiveUI.SourceGenerators;
|
||||
using RxVoid = ReactiveUI.Primitives.RxVoid;
|
||||
|
||||
namespace PLib.Desktop.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// Edits a working copy of the settings and only writes it back when the user confirms, so
|
||||
/// cancelling leaves both the file and the running configuration untouched.
|
||||
/// </summary>
|
||||
public sealed partial class SettingsViewModel : ViewModelBase
|
||||
{
|
||||
private const double BytesPerMegabyte = 1024 * 1024;
|
||||
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly IAppSettingsStore _settingsStore;
|
||||
private readonly IFolderPicker _folderPicker;
|
||||
private readonly IThemeService _theme;
|
||||
private readonly ILogger<SettingsViewModel> _logger;
|
||||
|
||||
/// <summary>The state the dialog opened with; the baseline every change is compared to.</summary>
|
||||
private readonly AppSettings _original;
|
||||
|
||||
private readonly Subject<SettingsDialogOutcome> _closed = new();
|
||||
|
||||
/// <summary>Set once derived data has been wiped, which always forces a rescan on close.</summary>
|
||||
private bool _dataWasReset;
|
||||
|
||||
public SettingsViewModel(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IAppSettingsStore settingsStore,
|
||||
IFolderPicker folderPicker,
|
||||
IThemeService theme,
|
||||
ILogger<SettingsViewModel> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_settingsStore = settingsStore;
|
||||
_folderPicker = folderPicker;
|
||||
_theme = theme;
|
||||
_logger = logger;
|
||||
|
||||
// Read on construction rather than cached anywhere: the panel can be reopened after
|
||||
// a save, and a stale snapshot would show the settings the application started with.
|
||||
_original = settingsStore.Current;
|
||||
|
||||
Folders = [.. _original.Folders.Select(CreateEntry)];
|
||||
MetadataSources = [.. _original.MetadataSources.Select(CreateEntry)];
|
||||
ThumbnailWidth = _original.ThumbnailWidth;
|
||||
ThumbnailPositionPercent = ToPercent(_original.ThumbnailPositionRatio);
|
||||
MaxIndexingConcurrency = _original.MaxIndexingConcurrency;
|
||||
MinimumFileSizeMegabytes = ToMegabytes(_original.MinimumFileSizeInBytes);
|
||||
SelectedTheme = ThemeOptions.First(option => option.Mode == _original.Theme);
|
||||
|
||||
AddFolderCommand = ReactiveCommand.CreateFromTask(AddFolderAsync);
|
||||
AddMetadataSourceCommand = ReactiveCommand.Create(AddMetadataSource);
|
||||
RefreshUsageCommand = ReactiveCommand.CreateFromTask(RefreshUsageAsync);
|
||||
|
||||
// One gate for every clearing button: they all talk to the same database and the same
|
||||
// cache directories, so letting a second one start mid-flight buys nothing.
|
||||
var idle = this.WhenAnyValue(x => x.IsBusy).Select(busy => !busy);
|
||||
|
||||
DataKinds =
|
||||
[
|
||||
new(
|
||||
LibraryDataKind.Thumbnails,
|
||||
"Постеры",
|
||||
"Кадр-обложка карточки. Соберутся заново при следующем сканировании.",
|
||||
ClearAsync,
|
||||
idle),
|
||||
new(
|
||||
LibraryDataKind.AnimatedPreviews,
|
||||
"Анимированные превью",
|
||||
"Кадры, которые прокручиваются под курсором. Самое объёмное на диске.",
|
||||
ClearAsync,
|
||||
idle),
|
||||
new(
|
||||
LibraryDataKind.PerceptualHashes,
|
||||
"Отпечатки",
|
||||
"Нужны только для поиска дублей. Считаются дольше всего: два десятка кадров на файл.",
|
||||
ClearAsync,
|
||||
idle),
|
||||
new(
|
||||
LibraryDataKind.TechnicalMetadata,
|
||||
"Технические метаданные",
|
||||
"Длительность, разрешение и кодек. Без них карточка не считается готовой, поэтому файл будет переиндексирован целиком.",
|
||||
ClearAsync,
|
||||
idle),
|
||||
new(
|
||||
LibraryDataKind.RemoteImages,
|
||||
"Изображения меток",
|
||||
"Фото актёров и логотипы студий. Скачиваются из источника метаданных, а не из файлов, поэтому сканирование их не вернёт — только повторное применение совпадения.",
|
||||
ClearAsync,
|
||||
idle),
|
||||
];
|
||||
|
||||
ClearAllCommand = ReactiveCommand.CreateFromTask(() => ClearAsync(LibraryDataKind.All), idle);
|
||||
SaveCommand = ReactiveCommand.CreateFromTask(SaveAsync);
|
||||
CancelCommand = ReactiveCommand.Create(() => _closed.OnNext(SettingsDialogOutcome.Cancelled));
|
||||
|
||||
// A binding cannot negate an int, so the "nothing added yet" hint reads a bool that
|
||||
// is re-raised whenever the list changes.
|
||||
Folders.CollectionChanged += OnFoldersChanged;
|
||||
Disposable
|
||||
.Create(() => Folders.CollectionChanged -= OnFoldersChanged)
|
||||
.AddTo(Subscriptions);
|
||||
|
||||
ObserveCommandFailures();
|
||||
}
|
||||
|
||||
/// <summary>Fires once, when the dialog should close.</summary>
|
||||
public IObservable<SettingsDialogOutcome> Closed => _closed;
|
||||
|
||||
public ObservableCollection<FolderEntryViewModel> Folders { get; }
|
||||
|
||||
public bool HasFolders => Folders.Count > 0;
|
||||
|
||||
public ObservableCollection<MetadataSourceEntryViewModel> MetadataSources { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> AddMetadataSourceCommand { get; }
|
||||
|
||||
public IReadOnlyList<ThemeOption> ThemeOptions { get; } = ThemeOption.All;
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> AddFolderCommand { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> RefreshUsageCommand { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> ClearAllCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Everything a scan can rebuild, one row each. Deliberately does not include titles,
|
||||
/// tags or watch progress: those are the user's, and no rescan would bring them back.
|
||||
/// </summary>
|
||||
public IReadOnlyList<LibraryDataViewModel> DataKinds { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> SaveCommand { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> CancelCommand { get; }
|
||||
|
||||
/// <summary>Width of generated poster frames; height follows the source aspect ratio.</summary>
|
||||
[Reactive]
|
||||
public partial int ThumbnailWidth { get; set; }
|
||||
|
||||
/// <summary>How far into the video the poster frame is taken, in percent.</summary>
|
||||
[Reactive]
|
||||
public partial double ThumbnailPositionPercent { get; set; }
|
||||
|
||||
[Reactive]
|
||||
public partial int MaxIndexingConcurrency { get; set; }
|
||||
|
||||
[Reactive]
|
||||
public partial double MinimumFileSizeMegabytes { get; set; }
|
||||
|
||||
[Reactive]
|
||||
public partial ThemeOption SelectedTheme { get; set; }
|
||||
|
||||
[Reactive]
|
||||
public partial bool IsBusy { get; set; }
|
||||
|
||||
[Reactive]
|
||||
public partial string? Message { get; set; }
|
||||
|
||||
// Built from the snapshot the panel opened with, not from scratch: anything this screen
|
||||
// does not edit — playback volume, for one — has to survive being saved from here.
|
||||
private AppSettings CurrentDraft => _original with
|
||||
{
|
||||
Folders = [.. Folders.Select(entry => entry.Path)],
|
||||
ThumbnailWidth = ThumbnailWidth,
|
||||
|
||||
// Both of these are shown in a friendlier unit than they are stored in, and that
|
||||
// conversion is lossy: 65 536 bytes displays as 0,06 MB and converts back to 62 915.
|
||||
// An untouched field therefore keeps the original value verbatim — otherwise merely
|
||||
// opening the panel and pressing Save would rewrite settings and force a rescan.
|
||||
ThumbnailPositionRatio = ThumbnailPositionPercent == ToPercent(_original.ThumbnailPositionRatio)
|
||||
? _original.ThumbnailPositionRatio
|
||||
: Math.Round(ThumbnailPositionPercent / 100, 4),
|
||||
|
||||
MaxIndexingConcurrency = MaxIndexingConcurrency,
|
||||
|
||||
MinimumFileSizeInBytes = MinimumFileSizeMegabytes == ToMegabytes(_original.MinimumFileSizeInBytes)
|
||||
? _original.MinimumFileSizeInBytes
|
||||
: (long)Math.Round(MinimumFileSizeMegabytes * BytesPerMegabyte),
|
||||
|
||||
Theme = SelectedTheme.Mode,
|
||||
|
||||
// Rows with nothing in them are what a half-finished edit looks like, and saving them
|
||||
// would put empty entries in the file for the next opening to show again.
|
||||
MetadataSources =
|
||||
[
|
||||
.. MetadataSources
|
||||
.Select(entry => entry.ToOptions())
|
||||
.Where(source => !string.IsNullOrWhiteSpace(source.Endpoint))
|
||||
],
|
||||
};
|
||||
|
||||
private static double ToPercent(double ratio) => Math.Round(ratio * 100);
|
||||
|
||||
private static double ToMegabytes(long bytes) => Math.Round(bytes / BytesPerMegabyte, 2);
|
||||
|
||||
private async Task AddFolderAsync()
|
||||
{
|
||||
var folder = await _folderPicker.PickFolderAsync("Выберите папку с видео");
|
||||
|
||||
if (folder is null || Folders.Any(entry => LibraryPathComparer.Instance.Equals(entry.Path, folder)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Folders.Add(CreateEntry(folder));
|
||||
}
|
||||
|
||||
private void OnFoldersChanged(object? sender, EventArgs e) => this.RaisePropertyChanged(nameof(HasFolders));
|
||||
|
||||
private FolderEntryViewModel CreateEntry(string path) =>
|
||||
new(path, entry => Folders.Remove(entry));
|
||||
|
||||
private MetadataSourceEntryViewModel CreateEntry(MetadataSourceOptions source) =>
|
||||
new(source, entry => MetadataSources.Remove(entry));
|
||||
|
||||
private void AddMetadataSource() =>
|
||||
MetadataSources.Add(CreateEntry(new MetadataSourceOptions()));
|
||||
|
||||
private async Task RefreshUsageAsync()
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
||||
|
||||
var usage = await library.GetDataUsageAsync();
|
||||
|
||||
foreach (var entry in usage)
|
||||
{
|
||||
DataKinds.FirstOrDefault(row => row.Kind == entry.Kind)?.Apply(entry);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ClearAsync(LibraryDataKind kinds)
|
||||
{
|
||||
IsBusy = true;
|
||||
|
||||
try
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
||||
|
||||
await library.ResetAsync(kinds);
|
||||
await RefreshUsageAsync();
|
||||
|
||||
// The data is gone from disk and from the library, so the grid has to be rebuilt
|
||||
// regardless of what else the user changes before closing.
|
||||
_dataWasReset = true;
|
||||
Message = "Очищено — недостающее соберётся при следующем сканировании";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
var draft = CurrentDraft;
|
||||
|
||||
await _settingsStore.SaveAsync(draft);
|
||||
|
||||
// The theme is not read from configuration again while the app runs, so apply it here.
|
||||
_theme.Apply(draft.Theme);
|
||||
|
||||
var rescan = _dataWasReset || draft.RequiresRescanComparedTo(_original);
|
||||
_closed.OnNext(new SettingsDialogOutcome(Saved: true, rescan, draft));
|
||||
}
|
||||
|
||||
private void ObserveCommandFailures() =>
|
||||
Observable
|
||||
.Merge<Exception>(
|
||||
[
|
||||
AddFolderCommand.ThrownExceptions,
|
||||
AddMetadataSourceCommand.ThrownExceptions,
|
||||
RefreshUsageCommand.ThrownExceptions,
|
||||
ClearAllCommand.ThrownExceptions,
|
||||
SaveCommand.ThrownExceptions,
|
||||
CancelCommand.ThrownExceptions,
|
||||
|
||||
// The per-kind buttons are commands too, and an unobserved failure in any of
|
||||
// them would be rethrown on the UI thread by ReactiveUI's default handler.
|
||||
.. DataKinds.Select(row => row.ClearCommand.ThrownExceptions),
|
||||
])
|
||||
.Subscribe(ex =>
|
||||
{
|
||||
_logger.LogError(ex, "A settings command failed");
|
||||
Message = "Не удалось выполнить действие — подробности в журнале";
|
||||
})
|
||||
.AddTo(Subscriptions);
|
||||
}
|
||||
|
||||
/// <summary>What the settings dialog left behind once it closed.</summary>
|
||||
/// <param name="Saved">False when the user cancelled or closed the window.</param>
|
||||
/// <param name="RescanRequired">True when the change affects what the library contains.</param>
|
||||
/// <param name="Settings">The snapshot that was written, so the caller can wait for it to load.</param>
|
||||
public sealed record SettingsDialogOutcome(bool Saved, bool RescanRequired, AppSettings? Settings)
|
||||
{
|
||||
public static SettingsDialogOutcome Cancelled { get; } = new(false, false, null);
|
||||
}
|
||||
|
||||
public sealed record ThemeOption(ThemeMode Mode, string Label)
|
||||
{
|
||||
public static IReadOnlyList<ThemeOption> All { get; } =
|
||||
[
|
||||
new(ThemeMode.System, "Как в системе"),
|
||||
new(ThemeMode.Light, "Светлая"),
|
||||
new(ThemeMode.Dark, "Тёмная"),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,445 +1,470 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Reactive.Concurrency;
|
||||
using System.Reactive.Linq;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PLib.Application.Library;
|
||||
using PLib.Application.Metadata;
|
||||
using PLib.Desktop.Services;
|
||||
using PLib.Domain.Videos;
|
||||
using ReactiveUI;
|
||||
using ReactiveUI.SourceGenerators;
|
||||
using RxVoid = ReactiveUI.Primitives.RxVoid;
|
||||
|
||||
namespace PLib.Desktop.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// The media page: one video with its player, its details and its labels.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Transport state stays on the player control; what the page owns is the video's identity,
|
||||
/// everything shown around the picture, and the settings that outlive the page.
|
||||
/// </remarks>
|
||||
public sealed partial class VideoPlayerViewModel : ViewModelBase
|
||||
{
|
||||
/// <summary>
|
||||
/// How long the volume has to sit still before it is written. Dragging the slider
|
||||
/// produces a value per pixel, and each one would otherwise be a file write.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan SaveDebounce = TimeSpan.FromMilliseconds(400);
|
||||
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly IAppSettingsStore _settingsStore;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public VideoPlayerViewModel(
|
||||
VideoCardViewModel card,
|
||||
ISystemShell shell,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IAppSettingsStore settingsStore,
|
||||
ILogger logger,
|
||||
Action close)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_settingsStore = settingsStore;
|
||||
_logger = logger;
|
||||
|
||||
Card = card;
|
||||
VideoId = card.Id;
|
||||
Title = card.Title;
|
||||
FullPath = card.FullPath;
|
||||
Source = new Uri(card.FullPath);
|
||||
ResumeFrom = card.ResumePosition;
|
||||
Details = BuildDetails(card);
|
||||
|
||||
Subtitle = string.Join(
|
||||
" · ",
|
||||
new[] { card.QualityText, card.DurationText, card.SizeText }
|
||||
.Where(part => !string.IsNullOrWhiteSpace(part)));
|
||||
|
||||
var settings = settingsStore.Current;
|
||||
Volume = settings.Volume;
|
||||
IsMuted = settings.IsMuted;
|
||||
|
||||
CloseCommand = ReactiveCommand.Create(close);
|
||||
ToggleFullScreenCommand = ReactiveCommand.Create(() => { IsFullScreen = !IsFullScreen; });
|
||||
ToggleMuteCommand = ReactiveCommand.Create(() => { IsMuted = !IsMuted; });
|
||||
ToggleDetailsCommand = ReactiveCommand.Create(() => { AreDetailsVisible = !AreDetailsVisible; });
|
||||
OpenExternallyCommand = ReactiveCommand.Create(() => shell.OpenFile(FullPath));
|
||||
RevealCommand = ReactiveCommand.Create(() => shell.RevealInFileManager(FullPath));
|
||||
|
||||
AddTagCommand = ReactiveCommand.CreateFromTask(() => AttachAsync(NewTag, LabelKind.Tag));
|
||||
AddCollectionCommand = ReactiveCommand.CreateFromTask(() => AttachAsync(NewCollection, LabelKind.Collection));
|
||||
LoadLabelsCommand = ReactiveCommand.CreateFromTask(LoadLabelsAsync);
|
||||
|
||||
// Only ever from this button: the whole point of the feature is that nothing talks to
|
||||
// a remote service about the user's library on its own.
|
||||
LookupMetadataCommand = ReactiveCommand.CreateFromTask(
|
||||
LookupMetadataAsync,
|
||||
this.WhenAnyValue(x => x.IsLookingUp).Select(busy => !busy));
|
||||
|
||||
this.WhenAnyValue(x => x.Volume, x => x.IsMuted, (volume, muted) => (volume, muted))
|
||||
// Skip the values we just restored: they are already what is on disk.
|
||||
.Skip(1)
|
||||
.Throttle(SaveDebounce, TaskPoolScheduler.Default)
|
||||
.DistinctUntilChanged()
|
||||
.Subscribe(state => Persist(state.volume, state.muted))
|
||||
.AddTo(Subscriptions);
|
||||
|
||||
ObserveCommandFailures();
|
||||
}
|
||||
|
||||
/// <summary>The card this page was opened from; refreshed in place as progress is saved.</summary>
|
||||
public VideoCardViewModel Card { get; }
|
||||
|
||||
public Guid VideoId { get; }
|
||||
|
||||
/// <summary>Reactive because applying a match renames the video under the open page.</summary>
|
||||
[Reactive]
|
||||
public partial string Title { get; set; }
|
||||
|
||||
public string FullPath { get; }
|
||||
|
||||
/// <summary>What the player plays; a <c>file://</c> URI built from the path.</summary>
|
||||
public Uri Source { get; }
|
||||
|
||||
/// <summary>Quality, duration and size on one line, for the page header.</summary>
|
||||
public string Subtitle { get; }
|
||||
|
||||
/// <summary>Where to start playback, or <c>null</c> to start from the beginning.</summary>
|
||||
public TimeSpan? ResumeFrom { get; }
|
||||
|
||||
public IReadOnlyList<MetadataRow> Details { get; }
|
||||
|
||||
public ObservableCollection<LabelViewModel> Tags { get; } = [];
|
||||
|
||||
public ObservableCollection<LabelViewModel> Collections { get; } = [];
|
||||
|
||||
public ObservableCollection<LabelViewModel> Performers { get; } = [];
|
||||
|
||||
public ObservableCollection<LabelViewModel> Studios { get; } = [];
|
||||
|
||||
/// <summary>Candidates from the last lookup; empty until the button is pressed.</summary>
|
||||
public ObservableCollection<MetadataMatchViewModel> Matches { get; } = [];
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> CloseCommand { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> ToggleFullScreenCommand { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> ToggleMuteCommand { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> ToggleDetailsCommand { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> OpenExternallyCommand { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> RevealCommand { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> AddTagCommand { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> AddCollectionCommand { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> LoadLabelsCommand { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> LookupMetadataCommand { get; }
|
||||
|
||||
[Reactive]
|
||||
public partial bool IsLookingUp { get; set; }
|
||||
|
||||
/// <summary>What the last lookup came to, including which sources failed.</summary>
|
||||
[Reactive]
|
||||
public partial string? MetadataMessage { get; set; }
|
||||
|
||||
/// <summary>Free text about the video, once a match has supplied one.</summary>
|
||||
[Reactive]
|
||||
public partial string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True while the window is given over to the video. The page hides its own header and
|
||||
/// the window hides its chrome.
|
||||
/// </summary>
|
||||
[Reactive]
|
||||
public partial bool IsFullScreen { get; set; }
|
||||
|
||||
/// <summary>The details and labels panel beside the video.</summary>
|
||||
[Reactive]
|
||||
public partial bool AreDetailsVisible { get; set; }
|
||||
|
||||
/// <summary>Volume as a fraction; restored on open and remembered across restarts.</summary>
|
||||
[Reactive]
|
||||
public partial double Volume { get; set; }
|
||||
|
||||
[Reactive]
|
||||
public partial bool IsMuted { get; set; }
|
||||
|
||||
[Reactive]
|
||||
public partial string NewTag { get; set; } = string.Empty;
|
||||
|
||||
[Reactive]
|
||||
public partial string NewCollection { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Records where playback stopped and refreshes the card behind the page, so the grid
|
||||
/// shows the new progress without waiting for a rescan.
|
||||
/// </summary>
|
||||
public async Task SaveProgressAsync(TimeSpan position)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
||||
|
||||
await library.SaveProgressAsync(VideoId, position);
|
||||
|
||||
if (await library.GetVideoWithLabelsAsync(VideoId) is { } refreshed)
|
||||
{
|
||||
Card.Apply(refreshed);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A lost resume position is not worth surfacing to someone who just closed a video.
|
||||
_logger.LogWarning(ex, "Could not save playback progress for {Path}", FullPath);
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<MetadataRow> BuildDetails(VideoCardViewModel card)
|
||||
{
|
||||
var rows = new List<MetadataRow>
|
||||
{
|
||||
new("Длительность", card.DurationText),
|
||||
new("Размер", card.SizeText),
|
||||
};
|
||||
|
||||
if (card.Width is { } width && card.Height is { } height)
|
||||
{
|
||||
rows.Add(new MetadataRow("Разрешение", $"{width} × {height}"));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(card.VideoCodec))
|
||||
{
|
||||
rows.Add(new MetadataRow("Кодек", card.VideoCodec));
|
||||
}
|
||||
|
||||
rows.Add(new MetadataRow("Добавлено", card.AddedAt.LocalDateTime.ToString("g", CultureInfo.CurrentCulture)));
|
||||
|
||||
if (card.LastPlayedAt is { } lastPlayed)
|
||||
{
|
||||
rows.Add(new MetadataRow(
|
||||
"Последний просмотр",
|
||||
lastPlayed.LocalDateTime.ToString("g", CultureInfo.CurrentCulture)));
|
||||
}
|
||||
|
||||
if (card.PlayCount > 0)
|
||||
{
|
||||
rows.Add(new MetadataRow("Просмотров", card.PlayCount.ToString(CultureInfo.CurrentCulture)));
|
||||
}
|
||||
|
||||
if (card.PerceptualHash is { } hash)
|
||||
{
|
||||
// Printed as hex: it is a bit pattern compared by Hamming distance, and the
|
||||
// decimal form of a 64-bit value tells nobody anything.
|
||||
rows.Add(new MetadataRow("pHash", hash.ToString("x16", CultureInfo.InvariantCulture)));
|
||||
}
|
||||
|
||||
rows.Add(new MetadataRow("Файл", card.FullPath));
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private async Task LoadLabelsAsync()
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
||||
|
||||
var video = await library.GetVideoWithLabelsAsync(VideoId);
|
||||
|
||||
Tags.Clear();
|
||||
Collections.Clear();
|
||||
Performers.Clear();
|
||||
Studios.Clear();
|
||||
|
||||
if (video is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Title = video.Title;
|
||||
Description = video.Description;
|
||||
|
||||
// The grid filters by label, so the card behind this page has to hear about every
|
||||
// label added or removed here — otherwise a tag would not narrow the grid until the
|
||||
// library was reloaded.
|
||||
Card.ApplyLabels(video.Labels);
|
||||
|
||||
foreach (var label in video.Labels.OrderBy(x => x.Name, StringComparer.CurrentCultureIgnoreCase))
|
||||
{
|
||||
Target(label.Kind).Add(new LabelViewModel(label, entry => _ = DetachAsync(entry)));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LookupMetadataAsync()
|
||||
{
|
||||
IsLookingUp = true;
|
||||
Matches.Clear();
|
||||
|
||||
// The results land in the side panel, so opening it is part of running the lookup —
|
||||
// otherwise the button would appear to do nothing.
|
||||
AreDetailsVisible = true;
|
||||
|
||||
try
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
||||
|
||||
var result = await library.FindMetadataAsync(VideoId);
|
||||
|
||||
if (!result.HasPerceptualHash)
|
||||
{
|
||||
MetadataMessage = "Отпечаток ещё не посчитан — дождитесь окончания сканирования";
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var match in result.Matches)
|
||||
{
|
||||
Matches.Add(new MetadataMatchViewModel(match, ApplyMetadataAsync));
|
||||
}
|
||||
|
||||
MetadataMessage = Describe(result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsLookingUp = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Says what came back and what did not. A source that failed is reported even when the
|
||||
/// others found something, because "one match" and "one match, and StashDB was down" call
|
||||
/// for different next steps.
|
||||
/// </summary>
|
||||
private static string Describe(MetadataLookupResult result)
|
||||
{
|
||||
var found = result.Matches.Count == 0
|
||||
? "Совпадений не найдено"
|
||||
: $"Найдено совпадений: {result.Matches.Count}";
|
||||
|
||||
return result.Failures.Count == 0
|
||||
? found
|
||||
: $"{found}. Не ответили — {string.Join("; ", result.Failures)}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handed to every match as its apply action. It swallows failures on purpose: the
|
||||
/// commands live on the match rows, which come and go with each lookup, so there is no
|
||||
/// stable place to observe their exceptions — and an unobserved one takes the process down.
|
||||
/// </summary>
|
||||
private async Task ApplyMetadataAsync(VideoMetadataMatch match)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
||||
|
||||
await library.ApplyMetadataAsync(VideoId, match);
|
||||
await LoadLabelsAsync();
|
||||
|
||||
// The grid behind the page shows the old title until the card is told otherwise.
|
||||
if (await library.GetVideoWithLabelsAsync(VideoId) is { } refreshed)
|
||||
{
|
||||
Card.Apply(refreshed);
|
||||
}
|
||||
|
||||
Matches.Clear();
|
||||
MetadataMessage = $"Применено: {match.SourceName}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Could not apply metadata from {Source}", match.SourceName);
|
||||
MetadataMessage = "Не удалось применить — подробности в журнале";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AttachAsync(string name, LabelKind kind)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
||||
|
||||
await library.AttachLabelAsync(VideoId, name, kind);
|
||||
|
||||
if (kind == LabelKind.Tag)
|
||||
{
|
||||
NewTag = string.Empty;
|
||||
}
|
||||
else
|
||||
{
|
||||
NewCollection = string.Empty;
|
||||
}
|
||||
|
||||
await LoadLabelsAsync();
|
||||
}
|
||||
|
||||
private async Task DetachAsync(LabelViewModel label)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
||||
|
||||
await library.DetachLabelAsync(VideoId, label.Id);
|
||||
Target(label.Kind).Remove(label);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not remove the label {Name}", label.Name);
|
||||
}
|
||||
}
|
||||
|
||||
private ObservableCollection<LabelViewModel> Target(LabelKind kind) => kind switch
|
||||
{
|
||||
LabelKind.Collection => Collections,
|
||||
LabelKind.Performer => Performers,
|
||||
LabelKind.Studio => Studios,
|
||||
_ => Tags,
|
||||
};
|
||||
|
||||
private void Persist(double volume, bool isMuted) => _ = PersistAsync(volume, isMuted);
|
||||
|
||||
private async Task PersistAsync(double volume, bool isMuted)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _settingsStore.SaveAsync(_settingsStore.Current with { Volume = volume, IsMuted = isMuted });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Losing a volume level is not worth interrupting playback over.
|
||||
_logger.LogWarning(ex, "Could not save the playback volume");
|
||||
}
|
||||
}
|
||||
|
||||
private void ObserveCommandFailures() =>
|
||||
Observable
|
||||
.Merge(
|
||||
CloseCommand.ThrownExceptions,
|
||||
ToggleFullScreenCommand.ThrownExceptions,
|
||||
ToggleMuteCommand.ThrownExceptions,
|
||||
ToggleDetailsCommand.ThrownExceptions,
|
||||
OpenExternallyCommand.ThrownExceptions,
|
||||
RevealCommand.ThrownExceptions,
|
||||
AddTagCommand.ThrownExceptions,
|
||||
AddCollectionCommand.ThrownExceptions,
|
||||
LoadLabelsCommand.ThrownExceptions,
|
||||
LookupMetadataCommand.ThrownExceptions)
|
||||
.Subscribe(ex =>
|
||||
{
|
||||
_logger.LogError(ex, "A media page command failed");
|
||||
MetadataMessage = "Что-то пошло не так — подробности в журнале";
|
||||
})
|
||||
.AddTo(Subscriptions);
|
||||
}
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Reactive.Concurrency;
|
||||
using System.Reactive.Linq;
|
||||
using Avalonia.Threading;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PLib.Application.Library;
|
||||
using PLib.Application.Metadata;
|
||||
using PLib.Desktop.Services;
|
||||
using PLib.Domain.Videos;
|
||||
using ReactiveUI;
|
||||
using ReactiveUI.SourceGenerators;
|
||||
using RxVoid = ReactiveUI.Primitives.RxVoid;
|
||||
|
||||
namespace PLib.Desktop.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// The media page: one video with its player, its details and its labels.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Transport state stays on the player control; what the page owns is the video's identity,
|
||||
/// everything shown around the picture, and the settings that outlive the page.
|
||||
/// </remarks>
|
||||
public sealed partial class VideoPlayerViewModel : ViewModelBase
|
||||
{
|
||||
/// <summary>
|
||||
/// How long the volume has to sit still before it is written. Dragging the slider
|
||||
/// produces a value per pixel, and each one would otherwise be a file write.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan SaveDebounce = TimeSpan.FromMilliseconds(400);
|
||||
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly IAppSettingsStore _settingsStore;
|
||||
private readonly RemoteImageLoader _images;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public VideoPlayerViewModel(
|
||||
VideoCardViewModel card,
|
||||
ISystemShell shell,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IAppSettingsStore settingsStore,
|
||||
RemoteImageLoader images,
|
||||
ILogger logger,
|
||||
Action close)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_settingsStore = settingsStore;
|
||||
_images = images;
|
||||
_logger = logger;
|
||||
|
||||
Card = card;
|
||||
VideoId = card.Id;
|
||||
Title = card.Title;
|
||||
FullPath = card.FullPath;
|
||||
Source = new Uri(card.FullPath);
|
||||
ResumeFrom = card.ResumePosition;
|
||||
Details = BuildDetails(card);
|
||||
|
||||
Subtitle = string.Join(
|
||||
" · ",
|
||||
new[] { card.QualityText, card.DurationText, card.SizeText }
|
||||
.Where(part => !string.IsNullOrWhiteSpace(part)));
|
||||
|
||||
var settings = settingsStore.Current;
|
||||
Volume = settings.Volume;
|
||||
IsMuted = settings.IsMuted;
|
||||
|
||||
CloseCommand = ReactiveCommand.Create(close);
|
||||
ToggleFullScreenCommand = ReactiveCommand.Create(() => { IsFullScreen = !IsFullScreen; });
|
||||
ToggleMuteCommand = ReactiveCommand.Create(() => { IsMuted = !IsMuted; });
|
||||
ToggleDetailsCommand = ReactiveCommand.Create(() => { AreDetailsVisible = !AreDetailsVisible; });
|
||||
OpenExternallyCommand = ReactiveCommand.Create(() => shell.OpenFile(FullPath));
|
||||
RevealCommand = ReactiveCommand.Create(() => shell.RevealInFileManager(FullPath));
|
||||
|
||||
AddTagCommand = ReactiveCommand.CreateFromTask(() => AttachAsync(NewTag, LabelKind.Tag));
|
||||
AddCollectionCommand = ReactiveCommand.CreateFromTask(() => AttachAsync(NewCollection, LabelKind.Collection));
|
||||
LoadLabelsCommand = ReactiveCommand.CreateFromTask(LoadLabelsAsync);
|
||||
|
||||
// Only ever from this button: the whole point of the feature is that nothing talks to
|
||||
// a remote service about the user's library on its own.
|
||||
LookupMetadataCommand = ReactiveCommand.CreateFromTask(
|
||||
LookupMetadataAsync,
|
||||
this.WhenAnyValue(x => x.IsLookingUp).Select(busy => !busy));
|
||||
|
||||
this.WhenAnyValue(x => x.Volume, x => x.IsMuted, (volume, muted) => (volume, muted))
|
||||
// Skip the values we just restored: they are already what is on disk.
|
||||
.Skip(1)
|
||||
.Throttle(SaveDebounce, TaskPoolScheduler.Default)
|
||||
.DistinctUntilChanged()
|
||||
.Subscribe(state => Persist(state.volume, state.muted))
|
||||
.AddTo(Subscriptions);
|
||||
|
||||
_images.Problems
|
||||
.Subscribe(problem => Dispatcher.UIThread.Post(() => ImageProblem = problem))
|
||||
.AddTo(Subscriptions);
|
||||
|
||||
ObserveCommandFailures();
|
||||
}
|
||||
|
||||
/// <summary>The card this page was opened from; refreshed in place as progress is saved.</summary>
|
||||
public VideoCardViewModel Card { get; }
|
||||
|
||||
public Guid VideoId { get; }
|
||||
|
||||
/// <summary>Reactive because applying a match renames the video under the open page.</summary>
|
||||
[Reactive]
|
||||
public partial string Title { get; set; }
|
||||
|
||||
public string FullPath { get; }
|
||||
|
||||
/// <summary>What the player plays; a <c>file://</c> URI built from the path.</summary>
|
||||
public Uri Source { get; }
|
||||
|
||||
/// <summary>Quality, duration and size on one line, for the page header.</summary>
|
||||
public string Subtitle { get; }
|
||||
|
||||
/// <summary>Where to start playback, or <c>null</c> to start from the beginning.</summary>
|
||||
public TimeSpan? ResumeFrom { get; }
|
||||
|
||||
public IReadOnlyList<MetadataRow> Details { get; }
|
||||
|
||||
public ObservableCollection<LabelViewModel> Tags { get; } = [];
|
||||
|
||||
public ObservableCollection<LabelViewModel> Collections { get; } = [];
|
||||
|
||||
public ObservableCollection<LabelViewModel> Performers { get; } = [];
|
||||
|
||||
public ObservableCollection<LabelViewModel> Studios { get; } = [];
|
||||
|
||||
/// <summary>Candidates from the last lookup; empty until the button is pressed.</summary>
|
||||
public ObservableCollection<MetadataMatchViewModel> Matches { get; } = [];
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> CloseCommand { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> ToggleFullScreenCommand { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> ToggleMuteCommand { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> ToggleDetailsCommand { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> OpenExternallyCommand { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> RevealCommand { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> AddTagCommand { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> AddCollectionCommand { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> LoadLabelsCommand { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> LookupMetadataCommand { get; }
|
||||
|
||||
[Reactive]
|
||||
public partial bool IsLookingUp { get; set; }
|
||||
|
||||
/// <summary>What the last lookup came to, including which sources failed.</summary>
|
||||
[Reactive]
|
||||
public partial string? MetadataMessage { get; set; }
|
||||
|
||||
/// <summary>Why the candidates have no pictures, when that is the reason.</summary>
|
||||
[Reactive]
|
||||
public partial string? ImageProblem { get; set; }
|
||||
|
||||
/// <summary>Free text about the video, once a match has supplied one.</summary>
|
||||
[Reactive]
|
||||
public partial string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True while the window is given over to the video. The page hides its own header and
|
||||
/// the window hides its chrome.
|
||||
/// </summary>
|
||||
[Reactive]
|
||||
public partial bool IsFullScreen { get; set; }
|
||||
|
||||
/// <summary>The details and labels panel beside the video.</summary>
|
||||
[Reactive]
|
||||
public partial bool AreDetailsVisible { get; set; }
|
||||
|
||||
/// <summary>Volume as a fraction; restored on open and remembered across restarts.</summary>
|
||||
[Reactive]
|
||||
public partial double Volume { get; set; }
|
||||
|
||||
[Reactive]
|
||||
public partial bool IsMuted { get; set; }
|
||||
|
||||
[Reactive]
|
||||
public partial string NewTag { get; set; } = string.Empty;
|
||||
|
||||
[Reactive]
|
||||
public partial string NewCollection { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Records where playback stopped and refreshes the card behind the page, so the grid
|
||||
/// shows the new progress without waiting for a rescan.
|
||||
/// </summary>
|
||||
public async Task SaveProgressAsync(TimeSpan position)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
||||
|
||||
await library.SaveProgressAsync(VideoId, position);
|
||||
|
||||
if (await library.GetVideoWithLabelsAsync(VideoId) is { } refreshed)
|
||||
{
|
||||
Card.Apply(refreshed);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A lost resume position is not worth surfacing to someone who just closed a video.
|
||||
_logger.LogWarning(ex, "Could not save playback progress for {Path}", FullPath);
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<MetadataRow> BuildDetails(VideoCardViewModel card)
|
||||
{
|
||||
var rows = new List<MetadataRow>
|
||||
{
|
||||
new("Длительность", card.DurationText),
|
||||
new("Размер", card.SizeText),
|
||||
};
|
||||
|
||||
if (card.Width is { } width && card.Height is { } height)
|
||||
{
|
||||
rows.Add(new MetadataRow("Разрешение", $"{width} × {height}"));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(card.VideoCodec))
|
||||
{
|
||||
rows.Add(new MetadataRow("Кодек", card.VideoCodec));
|
||||
}
|
||||
|
||||
rows.Add(new MetadataRow("Добавлено", card.AddedAt.LocalDateTime.ToString("g", CultureInfo.CurrentCulture)));
|
||||
|
||||
if (card.LastPlayedAt is { } lastPlayed)
|
||||
{
|
||||
rows.Add(new MetadataRow(
|
||||
"Последний просмотр",
|
||||
lastPlayed.LocalDateTime.ToString("g", CultureInfo.CurrentCulture)));
|
||||
}
|
||||
|
||||
if (card.PlayCount > 0)
|
||||
{
|
||||
rows.Add(new MetadataRow("Просмотров", card.PlayCount.ToString(CultureInfo.CurrentCulture)));
|
||||
}
|
||||
|
||||
if (card.PerceptualHash is { } hash)
|
||||
{
|
||||
// Printed as hex: it is a bit pattern compared by Hamming distance, and the
|
||||
// decimal form of a 64-bit value tells nobody anything.
|
||||
rows.Add(new MetadataRow("pHash", hash.ToString("x16", CultureInfo.InvariantCulture)));
|
||||
}
|
||||
|
||||
rows.Add(new MetadataRow("Файл", card.FullPath));
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private async Task LoadLabelsAsync()
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
||||
|
||||
var video = await library.GetVideoWithLabelsAsync(VideoId);
|
||||
|
||||
Tags.Clear();
|
||||
Collections.Clear();
|
||||
Performers.Clear();
|
||||
Studios.Clear();
|
||||
|
||||
if (video is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Title = video.Title;
|
||||
Description = video.Description;
|
||||
|
||||
// The grid filters by label, so the card behind this page has to hear about every
|
||||
// label added or removed here — otherwise a tag would not narrow the grid until the
|
||||
// library was reloaded.
|
||||
Card.ApplyLabels(video.Labels);
|
||||
|
||||
foreach (var label in video.Labels.OrderBy(x => x.Name, StringComparer.CurrentCultureIgnoreCase))
|
||||
{
|
||||
Target(label.Kind).Add(new LabelViewModel(label, entry => _ = DetachAsync(entry)));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LookupMetadataAsync()
|
||||
{
|
||||
IsLookingUp = true;
|
||||
Matches.Clear();
|
||||
ImageProblem = null;
|
||||
|
||||
// The results land in the side panel, so opening it is part of running the lookup —
|
||||
// otherwise the button would appear to do nothing.
|
||||
AreDetailsVisible = true;
|
||||
|
||||
try
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
||||
|
||||
var result = await library.FindMetadataAsync(VideoId);
|
||||
|
||||
if (!result.HasPerceptualHash)
|
||||
{
|
||||
MetadataMessage = "Отпечаток ещё не посчитан — дождитесь окончания сканирования";
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var match in result.Matches)
|
||||
{
|
||||
var row = new MetadataMatchViewModel(match, ApplyMetadataAsync);
|
||||
Matches.Add(row);
|
||||
|
||||
// Started, not awaited: the candidate is on screen, and its cover follows.
|
||||
_ = LoadCoverAsync(row);
|
||||
}
|
||||
|
||||
MetadataMessage = Describe(result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsLookingUp = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Says what came back and what did not. A source that failed is reported even when the
|
||||
/// others found something, because "one match" and "one match, and StashDB was down" call
|
||||
/// for different next steps.
|
||||
/// </summary>
|
||||
private async Task LoadCoverAsync(MetadataMatchViewModel match)
|
||||
{
|
||||
if (await _images.LoadAsync(match.ImageUrl) is { } path)
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() => match.ImagePath = path);
|
||||
}
|
||||
}
|
||||
|
||||
private static string Describe(MetadataLookupResult result)
|
||||
{
|
||||
var found = result.Matches.Count == 0
|
||||
? "Совпадений не найдено"
|
||||
: $"Найдено совпадений: {result.Matches.Count}";
|
||||
|
||||
return result.Failures.Count == 0
|
||||
? found
|
||||
: $"{found}. Не ответили — {string.Join("; ", result.Failures)}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handed to every match as its apply action. It swallows failures on purpose: the
|
||||
/// commands live on the match rows, which come and go with each lookup, so there is no
|
||||
/// stable place to observe their exceptions — and an unobserved one takes the process down.
|
||||
/// </summary>
|
||||
private async Task ApplyMetadataAsync(VideoMetadataMatch match)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
||||
|
||||
await library.ApplyMetadataAsync(VideoId, match);
|
||||
await LoadLabelsAsync();
|
||||
|
||||
// The grid behind the page shows the old title until the card is told otherwise.
|
||||
if (await library.GetVideoWithLabelsAsync(VideoId) is { } refreshed)
|
||||
{
|
||||
Card.Apply(refreshed);
|
||||
}
|
||||
|
||||
Matches.Clear();
|
||||
MetadataMessage = $"Применено: {match.SourceName}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Could not apply metadata from {Source}", match.SourceName);
|
||||
MetadataMessage = "Не удалось применить — подробности в журнале";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AttachAsync(string name, LabelKind kind)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
||||
|
||||
await library.AttachLabelAsync(VideoId, name, kind);
|
||||
|
||||
if (kind == LabelKind.Tag)
|
||||
{
|
||||
NewTag = string.Empty;
|
||||
}
|
||||
else
|
||||
{
|
||||
NewCollection = string.Empty;
|
||||
}
|
||||
|
||||
await LoadLabelsAsync();
|
||||
}
|
||||
|
||||
private async Task DetachAsync(LabelViewModel label)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
||||
|
||||
await library.DetachLabelAsync(VideoId, label.Id);
|
||||
Target(label.Kind).Remove(label);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not remove the label {Name}", label.Name);
|
||||
}
|
||||
}
|
||||
|
||||
private ObservableCollection<LabelViewModel> Target(LabelKind kind) => kind switch
|
||||
{
|
||||
LabelKind.Collection => Collections,
|
||||
LabelKind.Performer => Performers,
|
||||
LabelKind.Studio => Studios,
|
||||
_ => Tags,
|
||||
};
|
||||
|
||||
private void Persist(double volume, bool isMuted) => _ = PersistAsync(volume, isMuted);
|
||||
|
||||
private async Task PersistAsync(double volume, bool isMuted)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _settingsStore.SaveAsync(_settingsStore.Current with { Volume = volume, IsMuted = isMuted });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Losing a volume level is not worth interrupting playback over.
|
||||
_logger.LogWarning(ex, "Could not save the playback volume");
|
||||
}
|
||||
}
|
||||
|
||||
private void ObserveCommandFailures() =>
|
||||
Observable
|
||||
.Merge(
|
||||
CloseCommand.ThrownExceptions,
|
||||
ToggleFullScreenCommand.ThrownExceptions,
|
||||
ToggleMuteCommand.ThrownExceptions,
|
||||
ToggleDetailsCommand.ThrownExceptions,
|
||||
OpenExternallyCommand.ThrownExceptions,
|
||||
RevealCommand.ThrownExceptions,
|
||||
AddTagCommand.ThrownExceptions,
|
||||
AddCollectionCommand.ThrownExceptions,
|
||||
LoadLabelsCommand.ThrownExceptions,
|
||||
LookupMetadataCommand.ThrownExceptions)
|
||||
.Subscribe(ex =>
|
||||
{
|
||||
_logger.LogError(ex, "A media page command failed");
|
||||
MetadataMessage = "Что-то пошло не так — подробности в журнале";
|
||||
})
|
||||
.AddTo(Subscriptions);
|
||||
}
|
||||
|
||||
@@ -309,25 +309,51 @@
|
||||
<ItemsRepeater ItemsSource="{Binding Entities}">
|
||||
<ItemsRepeater.Layout>
|
||||
<UniformGridLayout ItemsStretch="Fill"
|
||||
MinItemWidth="220"
|
||||
MinItemHeight="56"
|
||||
MinColumnSpacing="12"
|
||||
MinRowSpacing="12" />
|
||||
MinItemWidth="164"
|
||||
MinItemHeight="212"
|
||||
MinColumnSpacing="14"
|
||||
MinRowSpacing="14" />
|
||||
</ItemsRepeater.Layout>
|
||||
<ItemsRepeater.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:LabelSummaryViewModel">
|
||||
<Button Classes="entity" Command="{Binding OpenCommand}">
|
||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="10">
|
||||
<TextBlock Grid.Column="0"
|
||||
VerticalAlignment="Center"
|
||||
Text="{Binding Name}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
ToolTip.Tip="{Binding Name}"
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource TextPrimaryBrush}" />
|
||||
<Border Grid.Column="1" Classes="badge" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding CountText}" />
|
||||
<Grid RowDefinitions="*,Auto" RowSpacing="8">
|
||||
|
||||
<Border Grid.Row="0"
|
||||
CornerRadius="9"
|
||||
ClipToBounds="True"
|
||||
Background="{DynamicResource ThumbnailPlaceholderBrush}">
|
||||
<Panel>
|
||||
<!-- The initial sits underneath, so it shows through for every
|
||||
label without a picture and while one is still decoding. -->
|
||||
<TextBlock Text="{Binding Initial}"
|
||||
FontSize="34"
|
||||
FontWeight="SemiBold"
|
||||
Opacity="0.35"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource TextTertiaryBrush}" />
|
||||
|
||||
<controls:AsyncImage Source="{Binding ImagePath}" DecodeWidth="320" />
|
||||
|
||||
<Border Classes="badge"
|
||||
Margin="6"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Bottom">
|
||||
<TextBlock Text="{Binding CountText}" />
|
||||
</Border>
|
||||
</Panel>
|
||||
</Border>
|
||||
|
||||
<TextBlock Grid.Row="1"
|
||||
Text="{Binding Name}"
|
||||
ToolTip.Tip="{Binding Name}"
|
||||
FontSize="12.5"
|
||||
MaxLines="2"
|
||||
TextWrapping="Wrap"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
Foreground="{DynamicResource TextPrimaryBrush}" />
|
||||
|
||||
</Grid>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:icons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
|
||||
xmlns:controls="clr-namespace:PLib.Desktop.Controls"
|
||||
xmlns:vm="clr-namespace:PLib.Desktop.ViewModels"
|
||||
x:Class="PLib.Desktop.Views.MetadataScanView"
|
||||
x:DataType="vm:MetadataScanViewModel">
|
||||
@@ -96,8 +97,22 @@
|
||||
<ItemsControl ItemsSource="{Binding Matches}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:MetadataMatchViewModel">
|
||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="10" Margin="0,0,0,6">
|
||||
<StackPanel Grid.Column="0" Spacing="2">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="10" Margin="0,0,0,6">
|
||||
|
||||
<!-- The candidate's own cover. Absent for a source that has none, and
|
||||
the row simply loses the column rather than reserving a blank. -->
|
||||
<Border Grid.Column="0"
|
||||
Width="128"
|
||||
Height="72"
|
||||
CornerRadius="7"
|
||||
ClipToBounds="True"
|
||||
VerticalAlignment="Top"
|
||||
Background="{DynamicResource ThumbnailPlaceholderBrush}"
|
||||
IsVisible="{Binding ImagePath, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||
<controls:AsyncImage Source="{Binding ImagePath}" DecodeWidth="256" />
|
||||
</Border>
|
||||
|
||||
<StackPanel Grid.Column="1" Spacing="2">
|
||||
<TextBlock Text="{Binding Title}"
|
||||
FontSize="12.5"
|
||||
TextWrapping="Wrap"
|
||||
@@ -117,7 +132,7 @@
|
||||
IsVisible="{Binding Tags, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||
</StackPanel>
|
||||
|
||||
<Button Grid.Column="1"
|
||||
<Button Grid.Column="2"
|
||||
VerticalAlignment="Center"
|
||||
Command="{Binding ApplyCommand}"
|
||||
Content="Применить" />
|
||||
|
||||
@@ -1,310 +1,328 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:icons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
|
||||
xmlns:controls="clr-namespace:PLib.Desktop.Controls"
|
||||
xmlns:vm="clr-namespace:PLib.Desktop.ViewModels"
|
||||
x:Class="PLib.Desktop.Views.VideoPlayerView"
|
||||
x:DataType="vm:VideoPlayerViewModel">
|
||||
|
||||
<UserControl.Resources>
|
||||
<DataTemplate x:Key="LabelChipTemplate" x:DataType="vm:LabelViewModel">
|
||||
<Border Background="{DynamicResource AccentSoftBrush}"
|
||||
CornerRadius="12"
|
||||
Padding="9,3"
|
||||
Margin="0,0,6,6">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Text="{Binding Name}"
|
||||
FontSize="12"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource AccentBrush}" />
|
||||
<Button Command="{Binding RemoveCommand}"
|
||||
Classes="transport"
|
||||
Padding="2"
|
||||
ToolTip.Tip="Убрать">
|
||||
<icons:MaterialIcon Kind="Close" Width="11" Height="11" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</UserControl.Resources>
|
||||
|
||||
<UserControl.Styles>
|
||||
<Style Selector="Button.transport">
|
||||
<Setter Property="Padding" Value="8" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.time">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextSecondaryBrush}" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="MinWidth" Value="46" />
|
||||
<Setter Property="TextAlignment" Value="Center" />
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
|
||||
<!-- ======================= Page header ======================= -->
|
||||
<Border Grid.Row="0" Classes="panelHeader" Padding="16,10" IsVisible="{Binding !IsFullScreen}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="12">
|
||||
|
||||
<Button Grid.Column="0"
|
||||
Classes="transport"
|
||||
Command="{Binding CloseCommand}"
|
||||
ToolTip.Tip="Назад к библиотеке">
|
||||
<icons:MaterialIcon Kind="ArrowLeft" Width="18" Height="18" />
|
||||
</Button>
|
||||
|
||||
<StackPanel Grid.Column="1" VerticalAlignment="Center">
|
||||
<TextBlock Classes="panelTitle"
|
||||
Text="{Binding Title}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
ToolTip.Tip="{Binding FullPath}" />
|
||||
<TextBlock Classes="cardMeta" Text="{Binding Subtitle}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="transport"
|
||||
Command="{Binding ToggleDetailsCommand}"
|
||||
ToolTip.Tip="Сведения и метки">
|
||||
<icons:MaterialIcon Kind="InformationOutline" Width="17" Height="17" />
|
||||
</Button>
|
||||
<Button Classes="transport"
|
||||
Command="{Binding LookupMetadataCommand}"
|
||||
ToolTip.Tip="Найти метаданные по отпечатку (pHash)">
|
||||
<icons:MaterialIcon Kind="DatabaseSearchOutline" Width="17" Height="17" />
|
||||
</Button>
|
||||
<Button Classes="transport"
|
||||
Command="{Binding OpenExternallyCommand}"
|
||||
ToolTip.Tip="Открыть во внешнем плеере">
|
||||
<icons:MaterialIcon Kind="OpenInNew" Width="17" Height="17" />
|
||||
</Button>
|
||||
<Button Classes="transport"
|
||||
Command="{Binding RevealCommand}"
|
||||
ToolTip.Tip="Показать в папке">
|
||||
<icons:MaterialIcon Kind="FolderOpenOutline" Width="17" Height="17" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ======================= Video ======================= -->
|
||||
<Grid Grid.Row="1" ColumnDefinitions="*,Auto">
|
||||
|
||||
<Panel Grid.Column="0" Name="VideoArea" Background="Black">
|
||||
<controls:VlcVideoView Name="Player"
|
||||
Source="{Binding Source}"
|
||||
AutoPlay="True"
|
||||
Volume="{Binding Volume}"
|
||||
IsMuted="{Binding IsMuted}" />
|
||||
|
||||
<Border Name="ErrorBar"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
MaxWidth="460"
|
||||
CornerRadius="10"
|
||||
Background="{DynamicResource SurfaceBrush}"
|
||||
Padding="16,12"
|
||||
IsVisible="False">
|
||||
<TextBlock Name="ErrorText"
|
||||
TextWrapping="Wrap"
|
||||
TextAlignment="Center"
|
||||
Foreground="{DynamicResource TextSecondaryBrush}" />
|
||||
</Border>
|
||||
</Panel>
|
||||
|
||||
<!-- ======================= Details and labels ======================= -->
|
||||
<Border Grid.Column="1"
|
||||
Width="320"
|
||||
IsVisible="{Binding AreDetailsVisible}"
|
||||
Background="{DynamicResource PanelBackgroundBrush}">
|
||||
<ScrollViewer Padding="16,14">
|
||||
<StackPanel Spacing="16">
|
||||
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="panelTitle" Text="Сведения" />
|
||||
<ItemsControl ItemsSource="{Binding Details}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:MetadataRow">
|
||||
<Grid ColumnDefinitions="130,*" Margin="0,0,0,6">
|
||||
<TextBlock Grid.Column="0"
|
||||
Text="{Binding Label}"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource TextTertiaryBrush}" />
|
||||
<TextBlock Grid.Column="1"
|
||||
Text="{Binding Value}"
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextPrimaryBrush}" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Metadata lookup: nothing here until the button in the header is pressed. -->
|
||||
<StackPanel Spacing="8"
|
||||
IsVisible="{Binding MetadataMessage, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||
<TextBlock Classes="panelTitle" Text="Метаданные" />
|
||||
<TextBlock Classes="cardMeta" TextWrapping="Wrap" Text="{Binding MetadataMessage}" />
|
||||
|
||||
<ItemsControl ItemsSource="{Binding Matches}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:MetadataMatchViewModel">
|
||||
<Border Background="{DynamicResource CardBackgroundBrush}"
|
||||
BorderBrush="{DynamicResource CardBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="8"
|
||||
Padding="10,8"
|
||||
Margin="0,0,0,8">
|
||||
<StackPanel Spacing="5">
|
||||
<TextBlock Text="{Binding Title}"
|
||||
FontSize="12.5"
|
||||
FontWeight="SemiBold"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextPrimaryBrush}" />
|
||||
<TextBlock Classes="cardMeta" Text="{Binding SourceName}" />
|
||||
|
||||
<TextBlock Classes="cardMeta"
|
||||
TextWrapping="Wrap"
|
||||
Text="{Binding Studios, StringFormat='Студия: {0}'}"
|
||||
IsVisible="{Binding Studios, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||
<TextBlock Classes="cardMeta"
|
||||
TextWrapping="Wrap"
|
||||
Text="{Binding Performers, StringFormat='Актёры: {0}'}"
|
||||
IsVisible="{Binding Performers, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||
<TextBlock Classes="cardMeta"
|
||||
TextWrapping="Wrap"
|
||||
Text="{Binding Tags, StringFormat='Теги: {0}'}"
|
||||
IsVisible="{Binding Tags, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||
<TextBlock Classes="cardMeta"
|
||||
TextWrapping="Wrap"
|
||||
MaxLines="4"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
Text="{Binding Description}"
|
||||
IsVisible="{Binding Description, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||
|
||||
<Button HorizontalAlignment="Left"
|
||||
Command="{Binding ApplyCommand}"
|
||||
Content="Применить" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="8"
|
||||
IsVisible="{Binding Description, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||
<TextBlock Classes="panelTitle" Text="Описание" />
|
||||
<TextBlock Text="{Binding Description}"
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextPrimaryBrush}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="8" IsVisible="{Binding Performers.Count}">
|
||||
<TextBlock Classes="panelTitle" Text="Актёры" />
|
||||
<ItemsControl ItemsSource="{Binding Performers}" ItemTemplate="{StaticResource LabelChipTemplate}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="8" IsVisible="{Binding Studios.Count}">
|
||||
<TextBlock Classes="panelTitle" Text="Студии" />
|
||||
<ItemsControl ItemsSource="{Binding Studios}" ItemTemplate="{StaticResource LabelChipTemplate}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="panelTitle" Text="Теги" />
|
||||
<ItemsControl ItemsSource="{Binding Tags}" ItemTemplate="{StaticResource LabelChipTemplate}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
</ItemsControl>
|
||||
<TextBox PlaceholderText="Добавить тег…" Text="{Binding NewTag}">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding AddTagCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="panelTitle" Text="Коллекции" />
|
||||
<ItemsControl ItemsSource="{Binding Collections}" ItemTemplate="{StaticResource LabelChipTemplate}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
</ItemsControl>
|
||||
<TextBox PlaceholderText="Добавить в коллекцию…" Text="{Binding NewCollection}">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding AddCollectionCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
|
||||
<!-- ======================= Transport ======================= -->
|
||||
<Border Grid.Row="2" Classes="panelFooter" Padding="16,10">
|
||||
<Grid ColumnDefinitions="Auto,Auto,*,Auto,Auto,Auto,Auto" ColumnSpacing="10">
|
||||
|
||||
<Button Grid.Column="0" Name="PlayPauseButton" Classes="transport">
|
||||
<icons:MaterialIcon Name="PlayPauseIcon" Kind="Pause" Width="20" Height="20" />
|
||||
</Button>
|
||||
|
||||
<TextBlock Grid.Column="1" Name="PositionText" Classes="time" Text="0:00" />
|
||||
|
||||
<Slider Grid.Column="2"
|
||||
Name="Seek"
|
||||
Minimum="0"
|
||||
Maximum="1"
|
||||
VerticalAlignment="Center" />
|
||||
|
||||
<TextBlock Grid.Column="3" Name="DurationText" Classes="time" Text="0:00" />
|
||||
|
||||
<Button Grid.Column="4" Classes="transport" Command="{Binding ToggleMuteCommand}">
|
||||
<icons:MaterialIcon Name="MuteIcon" Kind="VolumeHigh" Width="18" Height="18" />
|
||||
</Button>
|
||||
|
||||
<Slider Grid.Column="5"
|
||||
Width="90"
|
||||
Minimum="0"
|
||||
Maximum="1"
|
||||
Value="{Binding Volume}"
|
||||
VerticalAlignment="Center" />
|
||||
|
||||
<Button Grid.Column="6"
|
||||
Classes="transport"
|
||||
Command="{Binding ToggleFullScreenCommand}"
|
||||
ToolTip.Tip="Во весь экран (F11)">
|
||||
<icons:MaterialIcon Name="FullScreenIcon" Kind="Fullscreen" Width="19" Height="19" />
|
||||
</Button>
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
</UserControl>
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:icons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
|
||||
xmlns:controls="clr-namespace:PLib.Desktop.Controls"
|
||||
xmlns:vm="clr-namespace:PLib.Desktop.ViewModels"
|
||||
x:Class="PLib.Desktop.Views.VideoPlayerView"
|
||||
x:DataType="vm:VideoPlayerViewModel">
|
||||
|
||||
<UserControl.Resources>
|
||||
<DataTemplate x:Key="LabelChipTemplate" x:DataType="vm:LabelViewModel">
|
||||
<Border Background="{DynamicResource AccentSoftBrush}"
|
||||
CornerRadius="12"
|
||||
Padding="9,3"
|
||||
Margin="0,0,6,6">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Text="{Binding Name}"
|
||||
FontSize="12"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource AccentBrush}" />
|
||||
<Button Command="{Binding RemoveCommand}"
|
||||
Classes="transport"
|
||||
Padding="2"
|
||||
ToolTip.Tip="Убрать">
|
||||
<icons:MaterialIcon Kind="Close" Width="11" Height="11" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</UserControl.Resources>
|
||||
|
||||
<UserControl.Styles>
|
||||
<Style Selector="Button.transport">
|
||||
<Setter Property="Padding" Value="8" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.time">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextSecondaryBrush}" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="MinWidth" Value="46" />
|
||||
<Setter Property="TextAlignment" Value="Center" />
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
|
||||
<!-- ======================= Page header ======================= -->
|
||||
<Border Grid.Row="0" Classes="panelHeader" Padding="16,10" IsVisible="{Binding !IsFullScreen}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="12">
|
||||
|
||||
<Button Grid.Column="0"
|
||||
Classes="transport"
|
||||
Command="{Binding CloseCommand}"
|
||||
ToolTip.Tip="Назад к библиотеке">
|
||||
<icons:MaterialIcon Kind="ArrowLeft" Width="18" Height="18" />
|
||||
</Button>
|
||||
|
||||
<StackPanel Grid.Column="1" VerticalAlignment="Center">
|
||||
<TextBlock Classes="panelTitle"
|
||||
Text="{Binding Title}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
ToolTip.Tip="{Binding FullPath}" />
|
||||
<TextBlock Classes="cardMeta" Text="{Binding Subtitle}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="transport"
|
||||
Command="{Binding ToggleDetailsCommand}"
|
||||
ToolTip.Tip="Сведения и метки">
|
||||
<icons:MaterialIcon Kind="InformationOutline" Width="17" Height="17" />
|
||||
</Button>
|
||||
<Button Classes="transport"
|
||||
Command="{Binding LookupMetadataCommand}"
|
||||
ToolTip.Tip="Найти метаданные по отпечатку (pHash)">
|
||||
<icons:MaterialIcon Kind="DatabaseSearchOutline" Width="17" Height="17" />
|
||||
</Button>
|
||||
<Button Classes="transport"
|
||||
Command="{Binding OpenExternallyCommand}"
|
||||
ToolTip.Tip="Открыть во внешнем плеере">
|
||||
<icons:MaterialIcon Kind="OpenInNew" Width="17" Height="17" />
|
||||
</Button>
|
||||
<Button Classes="transport"
|
||||
Command="{Binding RevealCommand}"
|
||||
ToolTip.Tip="Показать в папке">
|
||||
<icons:MaterialIcon Kind="FolderOpenOutline" Width="17" Height="17" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ======================= Video ======================= -->
|
||||
<Grid Grid.Row="1" ColumnDefinitions="*,Auto">
|
||||
|
||||
<Panel Grid.Column="0" Name="VideoArea" Background="Black">
|
||||
<controls:VlcVideoView Name="Player"
|
||||
Source="{Binding Source}"
|
||||
AutoPlay="True"
|
||||
Volume="{Binding Volume}"
|
||||
IsMuted="{Binding IsMuted}" />
|
||||
|
||||
<Border Name="ErrorBar"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
MaxWidth="460"
|
||||
CornerRadius="10"
|
||||
Background="{DynamicResource SurfaceBrush}"
|
||||
Padding="16,12"
|
||||
IsVisible="False">
|
||||
<TextBlock Name="ErrorText"
|
||||
TextWrapping="Wrap"
|
||||
TextAlignment="Center"
|
||||
Foreground="{DynamicResource TextSecondaryBrush}" />
|
||||
</Border>
|
||||
</Panel>
|
||||
|
||||
<!-- ======================= Details and labels ======================= -->
|
||||
<Border Grid.Column="1"
|
||||
Width="320"
|
||||
IsVisible="{Binding AreDetailsVisible}"
|
||||
Background="{DynamicResource PanelBackgroundBrush}">
|
||||
<ScrollViewer Padding="16,14">
|
||||
<StackPanel Spacing="16">
|
||||
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="panelTitle" Text="Сведения" />
|
||||
<ItemsControl ItemsSource="{Binding Details}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:MetadataRow">
|
||||
<Grid ColumnDefinitions="130,*" Margin="0,0,0,6">
|
||||
<TextBlock Grid.Column="0"
|
||||
Text="{Binding Label}"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource TextTertiaryBrush}" />
|
||||
<TextBlock Grid.Column="1"
|
||||
Text="{Binding Value}"
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextPrimaryBrush}" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Metadata lookup: nothing here until the button in the header is pressed. -->
|
||||
<StackPanel Spacing="8"
|
||||
IsVisible="{Binding MetadataMessage, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||
<TextBlock Classes="panelTitle" Text="Метаданные" />
|
||||
<TextBlock Classes="cardMeta" TextWrapping="Wrap" Text="{Binding MetadataMessage}" />
|
||||
|
||||
<!-- Why the candidates have no pictures. An empty square looks the same whether
|
||||
the source has none or the host is unreachable, so it has to be said. -->
|
||||
<TextBlock Classes="cardMeta"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextSecondaryBrush}"
|
||||
Text="{Binding ImageProblem}"
|
||||
IsVisible="{Binding ImageProblem, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||
|
||||
<ItemsControl ItemsSource="{Binding Matches}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:MetadataMatchViewModel">
|
||||
<Border Background="{DynamicResource CardBackgroundBrush}"
|
||||
BorderBrush="{DynamicResource CardBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="8"
|
||||
Padding="10,8"
|
||||
Margin="0,0,0,8">
|
||||
<StackPanel Spacing="5">
|
||||
<!-- The candidate's own cover, across the top: the panel is narrow, so
|
||||
side by side would leave neither the picture nor the text usable. -->
|
||||
<Border Height="112"
|
||||
CornerRadius="6"
|
||||
ClipToBounds="True"
|
||||
Background="{DynamicResource ThumbnailPlaceholderBrush}"
|
||||
IsVisible="{Binding ImagePath, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||
<controls:AsyncImage Source="{Binding ImagePath}" DecodeWidth="320" />
|
||||
</Border>
|
||||
|
||||
<TextBlock Text="{Binding Title}"
|
||||
FontSize="12.5"
|
||||
FontWeight="SemiBold"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextPrimaryBrush}" />
|
||||
<TextBlock Classes="cardMeta" Text="{Binding SourceName}" />
|
||||
|
||||
<TextBlock Classes="cardMeta"
|
||||
TextWrapping="Wrap"
|
||||
Text="{Binding Studios, StringFormat='Студия: {0}'}"
|
||||
IsVisible="{Binding Studios, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||
<TextBlock Classes="cardMeta"
|
||||
TextWrapping="Wrap"
|
||||
Text="{Binding Performers, StringFormat='Актёры: {0}'}"
|
||||
IsVisible="{Binding Performers, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||
<TextBlock Classes="cardMeta"
|
||||
TextWrapping="Wrap"
|
||||
Text="{Binding Tags, StringFormat='Теги: {0}'}"
|
||||
IsVisible="{Binding Tags, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||
<TextBlock Classes="cardMeta"
|
||||
TextWrapping="Wrap"
|
||||
MaxLines="4"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
Text="{Binding Description}"
|
||||
IsVisible="{Binding Description, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||
|
||||
<Button HorizontalAlignment="Left"
|
||||
Command="{Binding ApplyCommand}"
|
||||
Content="Применить" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="8"
|
||||
IsVisible="{Binding Description, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||
<TextBlock Classes="panelTitle" Text="Описание" />
|
||||
<TextBlock Text="{Binding Description}"
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextPrimaryBrush}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="8" IsVisible="{Binding Performers.Count}">
|
||||
<TextBlock Classes="panelTitle" Text="Актёры" />
|
||||
<ItemsControl ItemsSource="{Binding Performers}" ItemTemplate="{StaticResource LabelChipTemplate}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="8" IsVisible="{Binding Studios.Count}">
|
||||
<TextBlock Classes="panelTitle" Text="Студии" />
|
||||
<ItemsControl ItemsSource="{Binding Studios}" ItemTemplate="{StaticResource LabelChipTemplate}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="panelTitle" Text="Теги" />
|
||||
<ItemsControl ItemsSource="{Binding Tags}" ItemTemplate="{StaticResource LabelChipTemplate}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
</ItemsControl>
|
||||
<TextBox PlaceholderText="Добавить тег…" Text="{Binding NewTag}">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding AddTagCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="panelTitle" Text="Коллекции" />
|
||||
<ItemsControl ItemsSource="{Binding Collections}" ItemTemplate="{StaticResource LabelChipTemplate}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
</ItemsControl>
|
||||
<TextBox PlaceholderText="Добавить в коллекцию…" Text="{Binding NewCollection}">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding AddCollectionCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
|
||||
<!-- ======================= Transport ======================= -->
|
||||
<Border Grid.Row="2" Classes="panelFooter" Padding="16,10">
|
||||
<Grid ColumnDefinitions="Auto,Auto,*,Auto,Auto,Auto,Auto" ColumnSpacing="10">
|
||||
|
||||
<Button Grid.Column="0" Name="PlayPauseButton" Classes="transport">
|
||||
<icons:MaterialIcon Name="PlayPauseIcon" Kind="Pause" Width="20" Height="20" />
|
||||
</Button>
|
||||
|
||||
<TextBlock Grid.Column="1" Name="PositionText" Classes="time" Text="0:00" />
|
||||
|
||||
<Slider Grid.Column="2"
|
||||
Name="Seek"
|
||||
Minimum="0"
|
||||
Maximum="1"
|
||||
VerticalAlignment="Center" />
|
||||
|
||||
<TextBlock Grid.Column="3" Name="DurationText" Classes="time" Text="0:00" />
|
||||
|
||||
<Button Grid.Column="4" Classes="transport" Command="{Binding ToggleMuteCommand}">
|
||||
<icons:MaterialIcon Name="MuteIcon" Kind="VolumeHigh" Width="18" Height="18" />
|
||||
</Button>
|
||||
|
||||
<Slider Grid.Column="5"
|
||||
Width="90"
|
||||
Minimum="0"
|
||||
Maximum="1"
|
||||
Value="{Binding Volume}"
|
||||
VerticalAlignment="Center" />
|
||||
|
||||
<Button Grid.Column="6"
|
||||
Classes="transport"
|
||||
Command="{Binding ToggleFullScreenCommand}"
|
||||
ToolTip.Tip="Во весь экран (F11)">
|
||||
<icons:MaterialIcon Name="FullScreenIcon" Kind="Fullscreen" Width="19" Height="19" />
|
||||
</Button>
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
</UserControl>
|
||||
|
||||
Reference in New Issue
Block a user