Implement animated previews and perceptual hashing in PLib video library manager. Introduce IAnimatedPreviewGenerator and IVideoPerceptualHasher interfaces, enhancing video item metadata with animated preview paths and perceptual hashes. Update LibraryService to manage indexing in three passes: metadata, animated previews, and perceptual hashes. Revise UI components to display animated previews and manage duplicate video detection. Enhance README.md to document these new features and usage instructions.

This commit is contained in:
Leonid Pershin
2026-08-09 07:48:45 +03:00
parent 10c66baea8
commit 3c77baced7
36 changed files with 2711 additions and 471 deletions
+261
View File
@@ -0,0 +1,261 @@
using Avalonia;
using Avalonia.Animation;
using Avalonia.Animation.Easings;
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
namespace PLib.Desktop.Controls;
/// <summary>
/// Plays an animated preview: one image holding several frames stacked on top of each other,
/// drawn a slice at a time.
/// </summary>
/// <remarks>
/// The strip is loaded when playback starts and disposed when it stops, rather than kept in
/// the shared thumbnail cache. A strip weighs as much as its frame count put together, and
/// only the card under the pointer is ever playing — caching them the way poster frames are
/// cached would trade a bounded cost for one that grows with how much of the library the
/// user has swept across.
/// </remarks>
public sealed class FilmstripImage : Control
{
public static readonly StyledProperty<string?> SourceProperty =
AvaloniaProperty.Register<FilmstripImage, string?>(nameof(Source));
public static readonly StyledProperty<int> FrameCountProperty =
AvaloniaProperty.Register<FilmstripImage, int>(nameof(FrameCount));
public static readonly StyledProperty<bool> IsPlayingProperty =
AvaloniaProperty.Register<FilmstripImage, bool>(nameof(IsPlaying));
/// <summary>Slow enough to read as a preview rather than a flicker, and cheap to draw.</summary>
private static readonly TimeSpan FrameInterval = TimeSpan.FromMilliseconds(125);
/// <summary>
/// How long the pointer has to rest before anything is decoded. Without it, dragging the
/// pointer across the grid would start a decode for every card it crossed.
/// </summary>
private static readonly TimeSpan StartDelay = TimeSpan.FromMilliseconds(250);
private readonly DispatcherTimer _timer = new() { Interval = FrameInterval };
private CancellationTokenSource? _pending;
private Bitmap? _strip;
private int _frame;
private bool _isAttached;
static FilmstripImage()
{
AffectsRender<FilmstripImage>(SourceProperty, FrameCountProperty);
}
public FilmstripImage()
{
Opacity = 0;
Transitions =
[
new DoubleTransition
{
Property = OpacityProperty,
Duration = TimeSpan.FromMilliseconds(180),
Easing = new CubicEaseOut(),
},
];
_timer.Tick += OnTick;
}
/// <summary>Absolute path of the stacked image.</summary>
public string? Source
{
get => GetValue(SourceProperty);
set => SetValue(SourceProperty, value);
}
/// <summary>How many frames the image is made of; below two there is nothing to animate.</summary>
public int FrameCount
{
get => GetValue(FrameCountProperty);
set => SetValue(FrameCountProperty, value);
}
/// <summary>Set by a style while the pointer is over the card.</summary>
public bool IsPlaying
{
get => GetValue(IsPlayingProperty);
set => SetValue(IsPlayingProperty, value);
}
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnAttachedToVisualTree(e);
_isAttached = true;
Restart();
}
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnDetachedFromVisualTree(e);
_isAttached = false;
Stop();
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == IsPlayingProperty ||
change.Property == SourceProperty ||
change.Property == FrameCountProperty)
{
Restart();
}
}
public override void Render(DrawingContext context)
{
var bounds = new Rect(Bounds.Size);
if (_strip is null || bounds.Width <= 0 || bounds.Height <= 0)
{
return;
}
var frames = FrameCount;
var frameHeight = frames < 2 ? 0 : _strip.PixelSize.Height / frames;
if (frameHeight <= 0)
{
return;
}
var slice = new Rect(
0,
Math.Min(_frame, frames - 1) * frameHeight,
_strip.PixelSize.Width,
frameHeight);
context.DrawImage(_strip, Cover(slice, bounds), bounds);
}
/// <summary>
/// The largest centred part of <paramref name="source"/> that has the same aspect ratio as
/// the destination — CSS <c>object-fit: cover</c>, matching how the poster frame is drawn
/// so that the preview does not jump when it fades in over it.
/// </summary>
private static Rect Cover(Rect source, Rect destination)
{
var scale = Math.Max(destination.Width / source.Width, destination.Height / source.Height);
var width = destination.Width / scale;
var height = destination.Height / scale;
return new Rect(
source.X + ((source.Width - width) / 2),
source.Y + ((source.Height - height) / 2),
width,
height);
}
private void OnTick(object? sender, EventArgs e)
{
var frames = FrameCount;
if (frames < 2)
{
return;
}
_frame = (_frame + 1) % frames;
InvalidateVisual();
}
private void Restart()
{
Stop();
if (!_isAttached || !IsPlaying || FrameCount < 2 || string.IsNullOrEmpty(Source))
{
return;
}
var cts = new CancellationTokenSource();
_pending = cts;
_ = LoadAsync(Source, cts);
}
private async Task LoadAsync(string path, CancellationTokenSource cts)
{
try
{
await Task.Delay(StartDelay, cts.Token);
var strip = await Task.Run(() => Decode(path), cts.Token);
if (strip is null)
{
return;
}
await Dispatcher.UIThread.InvokeAsync(() =>
{
// The card may have been left, or its container recycled onto another video,
// while the strip was being decoded.
if (cts.IsCancellationRequested || !ReferenceEquals(_pending, cts))
{
strip.Dispose();
return;
}
_strip = strip;
_frame = 0;
Opacity = 1;
_timer.Start();
InvalidateVisual();
});
}
catch (OperationCanceledException)
{
// The pointer left before the preview was ready; nothing to show, nothing to report.
}
}
private static Bitmap? Decode(string path)
{
try
{
using var stream = File.OpenRead(path);
// Decoded at its natural size: the strip is already rendered small, and scaling it
// would only blur frames that are about to be drawn card-sized anyway.
return new Bitmap(stream);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException)
{
return null;
}
}
private void Stop()
{
_timer.Stop();
if (_pending is { } cts)
{
_pending = null;
cts.Cancel();
cts.Dispose();
}
// Owned here, unlike the poster frames, so it is disposed rather than left to the
// garbage collector — otherwise a browse through the library would pile up strips.
_strip?.Dispose();
_strip = null;
_frame = 0;
Opacity = 0;
InvalidateVisual();
}
}
+49 -1
View File
@@ -1,5 +1,6 @@
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="clr-namespace:PLib.Desktop.Controls">
<!-- ============================ Card ============================ -->
@@ -76,6 +77,15 @@
<Setter Property="Opacity" Value="1" />
</Style>
<!--
The animated preview follows the same hover state as the play affordance, rather than
watching the pointer itself: the pointer is over the card even while it is over the
caption, and a preview that stopped when the pointer crossed the title would flicker.
-->
<Style Selector="Button.card:pointerover controls|FilmstripImage">
<Setter Property="IsPlaying" Value="True" />
</Style>
<!-- ============================ Text ============================ -->
<Style Selector="TextBlock.cardTitle">
@@ -188,6 +198,44 @@
<Setter Property="Fill" Value="{DynamicResource PanelDividerBrush}" />
</Style>
<!-- ========================== Task panel ========================== -->
<!--
Anchored to the bottom-right corner above the status bar. Kept in the tree and hidden by
opacity so the slide has something to animate from, the same trick the settings panel uses.
-->
<Style Selector="Border.taskPanel">
<Setter Property="Width" Value="330" />
<Setter Property="Margin" Value="0,0,16,8" />
<Setter Property="HorizontalAlignment" Value="Right" />
<Setter Property="VerticalAlignment" Value="Bottom" />
<Setter Property="Background" Value="{DynamicResource SurfaceBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource PanelDividerBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="12" />
<Setter Property="Padding" Value="14,12" />
<Setter Property="Opacity" Value="0" />
<Setter Property="IsHitTestVisible" Value="False" />
<Setter Property="RenderTransform" Value="translateY(10px)" />
<Setter Property="Transitions">
<Transitions>
<DoubleTransition Property="Opacity" Duration="0:0:0.16" Easing="CubicEaseOut" />
<TransformOperationsTransition Property="RenderTransform" Duration="0:0:0.18" Easing="CubicEaseOut" />
</Transitions>
</Setter>
</Style>
<Style Selector="Border.taskPanel.open">
<Setter Property="Opacity" Value="1" />
<Setter Property="IsHitTestVisible" Value="True" />
<Setter Property="RenderTransform" Value="none" />
</Style>
<Style Selector="TextBlock.taskTitle">
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
<Setter Property="FontSize" Value="12.5" />
</Style>
<!-- ============================ Badge ============================ -->
<Style Selector="Border.badge">
@@ -0,0 +1,74 @@
using ReactiveUI;
using ReactiveUI.SourceGenerators;
namespace PLib.Desktop.ViewModels;
public enum BackgroundTaskState
{
/// <summary>Known to be coming, not started.</summary>
Queued,
Running,
Completed,
Cancelled,
Failed,
}
/// <summary>
/// One unit of background work as the user thinks of it — "poster frames", "fingerprints" —
/// rather than one per file.
/// </summary>
/// <remarks>
/// The status bar can only ever describe the newest event, which during a scan means it
/// flickers between phases and hides what is still queued. A task list says what the whole
/// run consists of, where it has got to, and what is still to come.
/// </remarks>
public sealed partial class BackgroundTaskViewModel : ReactiveObject
{
public BackgroundTaskViewModel(string title, bool indeterminate = false)
{
Title = title;
IsIndeterminate = indeterminate;
}
public string Title { get; }
[Reactive]
public partial BackgroundTaskState State { get; set; }
/// <summary>Progress in percent; meaningless while <see cref="IsIndeterminate"/> is set.</summary>
[Reactive]
public partial double Progress { get; set; }
[Reactive]
public partial bool IsIndeterminate { get; set; }
/// <summary>Short line under the title: counts, results, or why it stopped.</summary>
[Reactive]
public partial string? Detail { get; set; }
public bool IsFinished => State is not (BackgroundTaskState.Queued or BackgroundTaskState.Running);
public void Advance(int processed, int total)
{
State = BackgroundTaskState.Running;
IsIndeterminate = false;
Progress = total == 0 ? 100 : processed * 100.0 / total;
Detail = $"{processed} из {total}";
}
public void Finish(string? detail = null)
{
// Leave a completed bar full rather than wherever the last report left it.
State = BackgroundTaskState.Completed;
IsIndeterminate = false;
Progress = 100;
Detail = detail ?? Detail;
}
public void Stop(BackgroundTaskState state, string? detail = null)
{
State = state;
IsIndeterminate = false;
Detail = detail ?? Detail;
}
}
@@ -23,6 +23,12 @@ namespace PLib.Desktop.ViewModels;
public sealed partial class MainWindowViewModel : ViewModelBase
{
/// <summary>
/// How many differing bits still count as the same video. Re-encodes of the same source
/// land within a few bits; unrelated videos are typically far past twenty.
/// </summary>
private const int DuplicateDistance = 6;
/// <summary>How long typing has to pause before the grid is re-filtered.</summary>
private static readonly TimeSpan SearchDebounce = TimeSpan.FromMilliseconds(200);
@@ -88,6 +94,8 @@ public sealed partial class MainWindowViewModel : ViewModelBase
InitializeCommand = ReactiveCommand.CreateFromTask(InitializeAsync);
AddFolderCommand = ReactiveCommand.CreateFromTask(AddFolderAsync);
ToggleThemeCommand = ReactiveCommand.CreateFromTask(ToggleThemeAsync);
ToggleDuplicatesCommand = ReactiveCommand.CreateFromTask(ToggleDuplicatesAsync);
ToggleTaskPanelCommand = ReactiveCommand.Create(() => { IsTaskPanelOpen = !IsTaskPanelOpen; });
_isSettingsOpen = this
.WhenAnyValue(x => x.SettingsPanel)
@@ -159,6 +167,24 @@ public sealed partial class MainWindowViewModel : ViewModelBase
public ReactiveCommand<RxVoid, RxVoid> ToggleThemeCommand { get; }
public ReactiveCommand<RxVoid, RxVoid> ToggleDuplicatesCommand { get; }
public ReactiveCommand<RxVoid, RxVoid> ToggleTaskPanelCommand { get; }
/// <summary>What the application is busy with, as whole jobs rather than per file.</summary>
public ObservableCollection<BackgroundTaskViewModel> Tasks { get; } = [];
[Reactive]
public partial bool IsTaskPanelOpen { get; set; }
/// <summary>The job to show on the collapsed pill, or <c>null</c> when nothing is running.</summary>
[Reactive]
public partial BackgroundTaskViewModel? ActiveTask { get; set; }
/// <summary>True while the grid is narrowed down to videos that look like each other.</summary>
[Reactive]
public partial bool ShowingDuplicatesOnly { get; set; }
public ReactiveCommand<RxVoid, RxVoid> OpenSettingsCommand { get; }
public ReactiveCommand<RxVoid, RxVoid> CloseSettingsCommand { get; }
@@ -215,6 +241,9 @@ public sealed partial class MainWindowViewModel : ViewModelBase
// Throttle swallows the initial value, and the grid must not start out blank.
.StartWith(SearchText)
.DistinctUntilChanged()
// The duplicates toggle is a second input to the same predicate, so it has to
// re-emit it — the search term alone would leave the grid on the old filter.
.CombineLatest(this.WhenAnyValue(x => x.ShowingDuplicatesOnly), (term, _) => term)
.Select(BuildFilter);
var comparerChanged = this
@@ -264,15 +293,50 @@ public sealed partial class MainWindowViewModel : ViewModelBase
})
.AddTo(Subscriptions);
private static Func<VideoCardViewModel, bool> BuildFilter(string? term)
/// <summary>
/// Identifiers of the videos that have a look-alike. Empty means the duplicates filter
/// is off; the filter reads it rather than recomputing distances per card.
/// </summary>
private readonly HashSet<Guid> _duplicates = [];
private async Task ToggleDuplicatesAsync()
{
if (string.IsNullOrWhiteSpace(term))
if (ShowingDuplicatesOnly)
{
return _ => true;
_duplicates.Clear();
ShowingDuplicatesOnly = false;
StatusText = $"В библиотеке {_library.Count} видео";
return;
}
var trimmed = term.Trim();
return card => card.Matches(trimmed);
await using var scope = _scopeFactory.CreateAsyncScope();
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
var groups = await library.FindDuplicatesAsync(DuplicateDistance);
_duplicates.Clear();
foreach (var id in groups.SelectMany(group => group).Select(item => item.Id))
{
_duplicates.Add(id);
}
ShowingDuplicatesOnly = true;
StatusText = groups.Count == 0
? "Похожих видео не найдено"
: $"Групп похожих видео: {groups.Count}, файлов: {_duplicates.Count}";
}
private Func<VideoCardViewModel, bool> BuildFilter(string? term)
{
var trimmed = string.IsNullOrWhiteSpace(term) ? null : term.Trim();
var duplicatesOnly = ShowingDuplicatesOnly;
var duplicates = duplicatesOnly ? _duplicates.ToHashSet() : [];
return card =>
(!duplicatesOnly || duplicates.Contains(card.Id)) &&
(trimmed is null || card.Matches(trimmed));
}
/// <summary>
@@ -287,6 +351,8 @@ public sealed partial class MainWindowViewModel : ViewModelBase
CancelScanCommand.ThrownExceptions,
AddFolderCommand.ThrownExceptions,
ToggleThemeCommand.ThrownExceptions,
ToggleDuplicatesCommand.ThrownExceptions,
ToggleTaskPanelCommand.ThrownExceptions,
OpenSettingsCommand.ThrownExceptions,
CloseSettingsCommand.ThrownExceptions,
ClosePlayerCommand.ThrownExceptions)
@@ -334,6 +400,8 @@ public sealed partial class MainWindowViewModel : ViewModelBase
ScanProgress = 0;
StatusText = "Поиск файлов…";
BeginTaskRun();
// Re-armed on every scan so a folder added or removed in settings is picked up
// without any separate plumbing.
_watcher.Watch(folders);
@@ -359,10 +427,17 @@ public sealed partial class MainWindowViewModel : ViewModelBase
catch (OperationCanceledException)
{
StatusText = "Сканирование отменено";
StopUnfinishedTasks(BackgroundTaskState.Cancelled, "отменено");
}
catch
{
StopUnfinishedTasks(BackgroundTaskState.Failed, "не удалось");
throw;
}
finally
{
IsProgressIndeterminate = false;
ActiveTask = Tasks.FirstOrDefault(task => !task.IsFinished);
if (folders.Length == 0)
{
@@ -472,12 +547,55 @@ public sealed partial class MainWindowViewModel : ViewModelBase
_logger.LogWarning("Configuration did not reload in time after saving settings");
}
private static double Percent(int processed, int total) =>
total == 0 ? 100 : processed * 100.0 / total;
private BackgroundTaskViewModel _discovery = new("Поиск файлов", indeterminate: true);
private BackgroundTaskViewModel _thumbnails = new("Превью");
private BackgroundTaskViewModel _animations = new("Анимированные превью");
private BackgroundTaskViewModel _fingerprints = new("Отпечатки для поиска дублей");
/// <summary>
/// Starts a fresh set of jobs for one scan. The list is rebuilt rather than appended to,
/// so it always describes the run in progress instead of the history of every run.
/// </summary>
private void BeginTaskRun()
{
Tasks.Clear();
_discovery = new BackgroundTaskViewModel("Поиск файлов", indeterminate: true)
{
State = BackgroundTaskState.Running,
};
_thumbnails = new BackgroundTaskViewModel("Превью");
_animations = new BackgroundTaskViewModel("Анимированные превью");
_fingerprints = new BackgroundTaskViewModel("Отпечатки для поиска дублей");
Tasks.Add(_discovery);
Tasks.Add(_thumbnails);
Tasks.Add(_animations);
Tasks.Add(_fingerprints);
ActiveTask = _discovery;
}
private void StopUnfinishedTasks(BackgroundTaskState state, string detail)
{
foreach (var task in Tasks.Where(task => !task.IsFinished))
{
task.Stop(state, detail);
}
}
private void Handle(LibraryScanEvent scanEvent)
{
switch (scanEvent)
{
case LibraryScanEvent.DiscoveryCompleted discovery:
StatusText = $"Найдено файлов: {discovery.FilesFound}";
_discovery.Finish($"найдено {discovery.FilesFound}");
ActiveTask = _thumbnails;
break;
case LibraryScanEvent.ItemAdded added:
@@ -496,12 +614,42 @@ public sealed partial class MainWindowViewModel : ViewModelBase
case LibraryScanEvent.IndexingProgress progress:
IsProgressIndeterminate = false;
ScanProgress = progress.Total == 0 ? 100 : progress.Processed * 100.0 / progress.Total;
StatusText = $"Обработка превью: {progress.Processed} из {progress.Total}";
ScanProgress = Percent(progress.Processed, progress.Total);
StatusText = $"Превью: {progress.Processed} из {progress.Total}";
_thumbnails.Advance(progress.Processed, progress.Total);
ActiveTask = _thumbnails;
break;
case LibraryScanEvent.PreviewProgress progress:
IsProgressIndeterminate = false;
ScanProgress = Percent(progress.Processed, progress.Total);
StatusText = $"Анимированные превью: {progress.Processed} из {progress.Total}";
// Reaching a later pass is the only reliable signal that the earlier one is
// over: a pass with nothing to do emits no progress at all.
_thumbnails.Finish();
_animations.Advance(progress.Processed, progress.Total);
ActiveTask = _animations;
break;
case LibraryScanEvent.HashingProgress progress:
IsProgressIndeterminate = false;
ScanProgress = Percent(progress.Processed, progress.Total);
StatusText = $"Отпечатки: {progress.Processed} из {progress.Total}";
_thumbnails.Finish();
_animations.Finish();
_fingerprints.Advance(progress.Processed, progress.Total);
ActiveTask = _fingerprints;
break;
case LibraryScanEvent.Completed completed:
ScanProgress = 100;
_discovery.Finish();
_thumbnails.Finish(_thumbnails.Detail ?? "нечего обновлять");
_animations.Finish(_animations.Detail ?? "нечего собирать");
_fingerprints.Finish(_fingerprints.Detail ?? "нечего считать");
ActiveTask = null;
StatusText = completed.LibrarySize == 0
? "В выбранных папках не нашлось видео"
: $"В библиотеке {completed.LibrarySize} видео";
@@ -45,6 +45,13 @@ public sealed partial class VideoCardViewModel : ReactiveObject
[Reactive]
public partial string? ThumbnailPath { get; set; }
/// <summary>The stacked frames played while the pointer rests on the card.</summary>
[Reactive]
public partial string? PreviewPath { get; set; }
[Reactive]
public partial int PreviewFrameCount { get; set; }
[Reactive]
public partial string DurationText { get; set; }
@@ -95,11 +102,15 @@ public sealed partial class VideoCardViewModel : ReactiveObject
public int PlayCount { get; private set; }
public ulong? PerceptualHash { get; private set; }
/// <summary>Copies the current state of the entity into the card.</summary>
public void Apply(VideoItem item)
{
Title = item.Title;
ThumbnailPath = item.ThumbnailPath;
PreviewPath = item.PreviewPath;
PreviewFrameCount = item.PreviewFrameCount;
DurationText = DisplayText.Duration(item.Duration);
SizeText = DisplayText.FileSize(item.SizeInBytes);
QualityText = DisplayText.Quality(item.Width, item.Height);
@@ -109,6 +120,7 @@ public sealed partial class VideoCardViewModel : ReactiveObject
VideoCodec = item.VideoCodec;
LastPlayedAt = item.LastPlayedAt;
PlayCount = item.PlayCount;
PerceptualHash = item.PerceptualHash;
RawDuration = item.Duration;
RawSizeInBytes = item.SizeInBytes;
IsPending = item.ThumbnailPath is null;
@@ -206,6 +206,13 @@ public sealed partial class VideoPlayerViewModel : ViewModelBase
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;
+64 -8
View File
@@ -48,6 +48,11 @@
DecodeWidth="480"
PlaceholderBrush="{DynamicResource ThumbnailPlaceholderBrush}" />
<!-- Fades in over the poster frame while the pointer rests on the card. Placed
directly above it, so the badges and the play affordance stay on top. -->
<controls:FilmstripImage Source="{Binding PreviewPath}"
FrameCount="{Binding PreviewFrameCount}" />
<!-- Shown until ffmpeg has produced a frame for this file. -->
<icons:MaterialIcon Kind="FilmstripBoxMultiple"
Width="30"
@@ -175,6 +180,12 @@
<icons:MaterialIcon Kind="Refresh" Width="17" Height="17" />
</Button>
<ToggleButton IsChecked="{Binding ShowingDuplicatesOnly, Mode=OneWay}"
Command="{Binding ToggleDuplicatesCommand}"
ToolTip.Tip="Показать похожие видео">
<icons:MaterialIcon Kind="ContentDuplicate" Width="17" Height="17" />
</ToggleButton>
<Button Command="{Binding ToggleThemeCommand}" ToolTip.Tip="Сменить тему">
<icons:MaterialIcon Kind="ThemeLightDark" Width="17" Height="17" />
</Button>
@@ -260,20 +271,65 @@
<Rectangle Grid.Column="1" Classes="panelDivider" IsVisible="{Binding IsSettingsOpen}" />
<!-- ======================= Tasks ======================= -->
<Border Grid.Column="0"
Classes="taskPanel"
Classes.open="{Binding IsTaskPanelOpen}">
<StackPanel Spacing="10">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Classes="panelTitle" Text="Фоновые задачи" />
<Button Grid.Column="1"
Classes="transport"
Padding="4"
Command="{Binding ToggleTaskPanelCommand}">
<icons:MaterialIcon Kind="Close" Width="13" Height="13" />
</Button>
</Grid>
<ItemsControl ItemsSource="{Binding Tasks}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:BackgroundTaskViewModel">
<StackPanel Spacing="4" Margin="0,0,0,10">
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="8">
<TextBlock Grid.Column="0" Classes="taskTitle" Text="{Binding Title}" />
<TextBlock Grid.Column="1" Classes="cardMeta" Text="{Binding Detail}" />
</Grid>
<ProgressBar Height="3"
Minimum="0"
Maximum="100"
Value="{Binding Progress}"
IsIndeterminate="{Binding IsIndeterminate}" />
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
</Grid>
<!-- ======================= Status bar ======================= -->
<Border Grid.Row="2" Classes="statusBar" IsVisible="{Binding !IsVideoFullScreen}">
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="14">
<ProgressBar Grid.Column="0"
Width="160"
VerticalAlignment="Center"
Minimum="0"
Maximum="100"
Value="{Binding ScanProgress}"
IsIndeterminate="{Binding IsProgressIndeterminate}"
IsVisible="{Binding IsScanning}" />
<Button Grid.Column="0"
Classes="transport"
Padding="8,4"
Command="{Binding ToggleTaskPanelCommand}"
ToolTip.Tip="Фоновые задачи">
<StackPanel Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<icons:MaterialIcon Kind="FormatListChecks" Width="15" Height="15" />
<ProgressBar Width="120"
VerticalAlignment="Center"
Minimum="0"
Maximum="100"
Value="{Binding ScanProgress}"
IsIndeterminate="{Binding IsProgressIndeterminate}"
IsVisible="{Binding IsScanning}" />
</StackPanel>
</Button>
<TextBlock Grid.Column="1"
Classes="subtle"
+2
View File
@@ -3,6 +3,8 @@
"Folders": [],
"ThumbnailWidth": 480,
"ThumbnailPositionRatio": 0.15,
"PreviewFrameCount": 12,
"PreviewWidth": 240,
"MaxIndexingConcurrency": 4,
"MinimumFileSizeInBytes": 65536
},