Show the collected content: thumbnails and a gallery
Until now a collected image was a row of text, and after a restart it was not
visible at all. The collect list gains a row thumbnail, and a Gallery page
browses the whole store with filters by source, format and address, paged at
120 tiles, with a built-in viewer showing the full size beside its provenance.
Thumbnails decode straight to the width they are drawn at. That is the whole
memory story: a 4000x3000 JPEG is about 48 MB once decoded, so decoding full
size and scaling afterwards runs out of memory long before the user finishes
scrolling. The cache is bounded and owns its bitmaps, which means its capacity
has to comfortably exceed a page - a bitmap evicted while still on screen would
be disposed out from under the renderer.
Paged rather than infinite-scrolled for the same reason: how much to decode is a
decision the page should make, not one the archive's size makes for it.
Video is not previewed and will not be. Extracting a first frame means FFmpeg,
which is a media stack in exchange for one picture per tile; those tiles show a
format badge instead. The refusal happens before touching the disk, because
attempting it would be an exception per tile.
Rendering the page caught the viewer overlay being see-through: it named a brush
that does not exist, and an unresolved DynamicResource fails silently - the
property just keeps its default. That is the second silent-reference bug to
reach a screenshot, so both kinds now have guards: one resolves every
{DynamicResource} in the XAML against both themes, the other checks every
{l:Loc} key exists. Both were confirmed to fail before being kept.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f8744c930a
commit
ceacec79e2
@@ -241,6 +241,21 @@ throttle 250 мс — пул дёргается на каждый исход л
|
||||
свежая CVE не роняла сборку кода, который никто не трогал.
|
||||
- Тестовые послабления анализаторов — в `tests/Directory.Build.props`, не в самих тестах.
|
||||
|
||||
## Отображение медиа
|
||||
|
||||
- **Миниатюры декодируются в нужную ширину**, а не декодируются целиком и потом масштабируются.
|
||||
На архиве это разница между «работает» и «кончилась память».
|
||||
- **Кэш владеет своими `Bitmap` и удаляет их при вытеснении**, поэтому вызывающий не должен их
|
||||
освобождать — и поэтому ёмкость кэша обязана заметно превышать страницу галереи: вытесненная
|
||||
картинка, которая ещё на экране, освободилась бы под рендерером.
|
||||
- **Видео не декодируется**, и попытка была бы исключением на каждой плитке. `MediaKinds.IsImage`
|
||||
отсекает это до всякого обращения к диску.
|
||||
- **`{DynamicResource}` с несуществующим ключом молча не срабатывает** — свойство остаётся со
|
||||
значением по умолчанию, фон не красится, кисть прозрачная. Это уже дважды доезжало до
|
||||
скриншота; ловит `ResourceKeyTests` в обеих темах.
|
||||
- **`{l:Loc}` с ключом, которого нет в обоих resx, тест паритета не поймает** — файлы согласованы
|
||||
между собой. Ловит `LocalizationCoverageTests`.
|
||||
|
||||
## Границы, выбранные намеренно
|
||||
|
||||
- **robots.txt не читается.** Оба источника v1 либо принадлежат пользователю, либо введены им
|
||||
@@ -254,3 +269,4 @@ throttle 250 мс — пул дёргается на каждый исход л
|
||||
- **SVG не поддерживается сознательно** — это текст, он умеет исполнять скрипты и несёт XXE.
|
||||
BMP/ICO/HEIC/JPEG-XL просто отложены.
|
||||
- **Перцептивных хешей нет.** Дедуп точный, по SHA-256; «похожие» картинки — отдельная задача.
|
||||
- **Кадры из видео не извлекаются.** Это FFmpeg целиком ради одной картинки в плитке.
|
||||
|
||||
@@ -225,6 +225,20 @@ proxifly-запись с `"protocol": "https"` — это всё равно HTTP
|
||||
томами жёстких ссылок нет — тогда происходит откат на копию, расход диска удваивается, и
|
||||
действующий режим виден в настройках.
|
||||
|
||||
### Галерея
|
||||
|
||||
Страница **Галерея** показывает то, что уже лежит в хранилище, — с фильтрами по источнику, формату
|
||||
и подстроке адреса, постранично по 120 плиток. Клик открывает встроенный просмотр: полный размер
|
||||
плюс откуда, когда, каким форматом и с каким хешем.
|
||||
|
||||
Миниатюры декодируются сразу в нужную ширину и кэшируются: полноразмерный JPEG 4000×3000 занимает
|
||||
в памяти около 48 МБ, и пары сотен таких хватило бы, чтобы приложение кончилось раньше, чем
|
||||
пользователь долистает.
|
||||
|
||||
**Видео не превьюится.** Показать первый кадр mp4 или webm нечем без FFmpeg, тащить который в
|
||||
десктопное приложение ради превью несоразмерно; плитка получает значок формата. Гифки показываются
|
||||
первым кадром — Avalonia не анимирует GIF без стороннего пакета.
|
||||
|
||||
### Чистка
|
||||
|
||||
Кнопка на странице сбора удаляет то, что собрал выбранный источник. Файл, на который ссылается и
|
||||
|
||||
@@ -46,6 +46,72 @@ public sealed record PurgeOptions(string SourceId)
|
||||
/// <param name="BytesFreed">Bytes reclaimed on disk.</param>
|
||||
public readonly record struct PurgeResult(int ItemsRemoved, int BlobsRemoved, long BytesFreed);
|
||||
|
||||
/// <summary>One stored item as the gallery needs it.</summary>
|
||||
/// <param name="Sha256">Content hash; also how the file is found on disk.</param>
|
||||
/// <param name="Kind">Format.</param>
|
||||
/// <param name="Extension">Stored extension, leading dot included.</param>
|
||||
/// <param name="Length">Size in bytes.</param>
|
||||
/// <param name="SourceId">Which source collected it.</param>
|
||||
/// <param name="Url">Where it came from.</param>
|
||||
/// <param name="CollectedUtc">When it was collected.</param>
|
||||
public sealed record StoredMedia(
|
||||
string Sha256,
|
||||
MediaKind Kind,
|
||||
string Extension,
|
||||
long Length,
|
||||
string SourceId,
|
||||
string Url,
|
||||
DateTimeOffset CollectedUtc
|
||||
)
|
||||
{
|
||||
/// <summary>Pixel width, when it was cheap to read.</summary>
|
||||
public int? Width { get; init; }
|
||||
|
||||
/// <summary>Pixel height, when it was cheap to read.</summary>
|
||||
public int? Height { get; init; }
|
||||
|
||||
/// <summary>Whether the content has more than one frame.</summary>
|
||||
public bool IsAnimated { get; init; }
|
||||
|
||||
/// <summary>Path of the browsable copy, relative to the showcase root.</summary>
|
||||
public string? ShowcasePath { get; init; }
|
||||
|
||||
/// <summary>Proxy it came through, or null when the connection was direct.</summary>
|
||||
public string? ProxyKey { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>What slice of the store to look at.</summary>
|
||||
/// <remarks>
|
||||
/// Paged rather than streamed: the gallery draws tiles, and a tile costs a decoded bitmap. Handing
|
||||
/// the UI an unbounded sequence would mean deciding how much to decode in the view, which is the
|
||||
/// wrong place for that decision.
|
||||
/// </remarks>
|
||||
public sealed record MediaBrowseQuery
|
||||
{
|
||||
/// <summary>Only this source; null for all of them.</summary>
|
||||
public string? SourceId { get; init; }
|
||||
|
||||
/// <summary>Formats to include.</summary>
|
||||
public MediaKindFilter Kinds { get; init; } = MediaKindFilter.All;
|
||||
|
||||
/// <summary>Only content with more than one frame.</summary>
|
||||
public bool AnimatedOnly { get; init; }
|
||||
|
||||
/// <summary>Substring match against the address it came from.</summary>
|
||||
public string? Search { get; init; }
|
||||
|
||||
/// <summary>How many items to skip.</summary>
|
||||
public int Skip { get; init; }
|
||||
|
||||
/// <summary>How many items to return.</summary>
|
||||
public int Take { get; init; } = 120;
|
||||
}
|
||||
|
||||
/// <summary>One page of the store, plus how much there is in total.</summary>
|
||||
/// <param name="Items">The page.</param>
|
||||
/// <param name="Total">How many items match the filter overall.</param>
|
||||
public sealed record MediaPage(IReadOnlyList<StoredMedia> Items, int Total);
|
||||
|
||||
/// <summary>Totals for the dashboard.</summary>
|
||||
/// <param name="BlobCount">Distinct byte sequences held.</param>
|
||||
/// <param name="ItemCount">Provenance rows across every source.</param>
|
||||
@@ -141,4 +207,10 @@ public interface IMediaStore
|
||||
|
||||
/// <summary>Totals across the whole store.</summary>
|
||||
Task<MediaStoreStats> GetStatsAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Reads one page of what is held, newest first.</summary>
|
||||
Task<MediaPage> BrowseAsync(MediaBrowseQuery query, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Every source that has contributed something, for the filter.</summary>
|
||||
Task<IReadOnlyList<string>> GetSourceIdsAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -165,6 +165,18 @@ public sealed class MediaStore(
|
||||
public Task<MediaStoreStats> GetStatsAsync(CancellationToken cancellationToken = default) =>
|
||||
_index.GetStatsAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<MediaPage> BrowseAsync(MediaBrowseQuery query, CancellationToken cancellationToken = default) =>
|
||||
_index.BrowseAsync(query, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<string>> GetSourceIdsAsync(CancellationToken cancellationToken = default) =>
|
||||
_index.GetSourceIdsAsync(cancellationToken);
|
||||
|
||||
/// <summary>Absolute path of a stored item on disk.</summary>
|
||||
/// <remarks>Exposed so the UI can show and decode a file without knowing the shard layout.</remarks>
|
||||
public string PathFor(string sha256, string extension) => _blobs.PathFor(sha256, extension);
|
||||
|
||||
/// <summary>Removes the files a committed removal orphaned.</summary>
|
||||
/// <returns>Bytes reclaimed.</returns>
|
||||
private long Unlink(RemovalPlan plan)
|
||||
|
||||
@@ -608,6 +608,133 @@ public sealed class SqliteMediaIndex : IDisposable
|
||||
return new MediaStoreStats(reader.GetInt32(0), reader.GetInt32(1), reader.GetInt64(2), reader.GetInt32(3));
|
||||
}
|
||||
|
||||
/// <summary>Reads one page of what is held, newest first.</summary>
|
||||
/// <remarks>
|
||||
/// The filter is assembled rather than written out because the combinations multiply, but every
|
||||
/// value still goes through a parameter — a source id or a search term reaching the SQL text
|
||||
/// would be an injection hole in a table the app itself fills from remote listings.
|
||||
/// </remarks>
|
||||
public async Task<MediaPage> BrowseAsync(MediaBrowseQuery query, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
var conditions = new List<string>();
|
||||
var parameters = new List<(string Name, object Value)>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.SourceId))
|
||||
{
|
||||
conditions.Add("i.source_id = $source");
|
||||
parameters.Add(("$source", query.SourceId));
|
||||
}
|
||||
|
||||
if (query.AnimatedOnly)
|
||||
{
|
||||
conditions.Add("b.is_animated = 1");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Search))
|
||||
{
|
||||
conditions.Add("i.url LIKE $search");
|
||||
parameters.Add(("$search", $"%{query.Search.Trim()}%"));
|
||||
}
|
||||
|
||||
var kinds = MediaKindFilters.ToSet(query.Kinds);
|
||||
if (kinds.Count < Enum.GetValues<MediaKind>().Length - 1)
|
||||
{
|
||||
conditions.Add(
|
||||
$"b.kind IN ({string.Join(',', kinds.Select(kind => ((int)kind).ToString(CultureInfo.InvariantCulture)))})"
|
||||
);
|
||||
}
|
||||
|
||||
var where = conditions.Count == 0 ? string.Empty : "WHERE " + string.Join(" AND ", conditions);
|
||||
|
||||
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
int total;
|
||||
await using (var count = connection.CreateCommand())
|
||||
{
|
||||
count.CommandText = $"SELECT COUNT(*) FROM item i JOIN blob b ON b.sha256 = i.sha256 {where};";
|
||||
Bind(count, parameters);
|
||||
|
||||
total = Convert.ToInt32(
|
||||
await count.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false),
|
||||
CultureInfo.InvariantCulture
|
||||
);
|
||||
}
|
||||
|
||||
var items = new List<StoredMedia>(Math.Max(0, query.Take));
|
||||
|
||||
await using (var page = connection.CreateCommand())
|
||||
{
|
||||
page.CommandText = $"""
|
||||
SELECT i.sha256, b.kind, b.extension, b.length, b.width, b.height, b.is_animated,
|
||||
i.source_id, i.url, i.collected_utc, i.showcase_path, i.proxy_key
|
||||
FROM item i JOIN blob b ON b.sha256 = i.sha256
|
||||
{where}
|
||||
ORDER BY i.collected_utc DESC, i.id DESC
|
||||
LIMIT $take OFFSET $skip;
|
||||
""";
|
||||
Bind(page, parameters);
|
||||
page.Parameters.AddWithValue("$take", Math.Clamp(query.Take, 1, 2000));
|
||||
page.Parameters.AddWithValue("$skip", Math.Max(0, query.Skip));
|
||||
|
||||
await using var reader = await page.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
items.Add(
|
||||
new StoredMedia(
|
||||
reader.GetString(0),
|
||||
(MediaKind)reader.GetInt32(1),
|
||||
reader.GetString(2),
|
||||
reader.GetInt64(3),
|
||||
reader.GetString(7),
|
||||
reader.GetString(8),
|
||||
DateTimeOffset.Parse(
|
||||
reader.GetString(9),
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.RoundtripKind
|
||||
)
|
||||
)
|
||||
{
|
||||
Width = reader.IsDBNull(4) ? null : reader.GetInt32(4),
|
||||
Height = reader.IsDBNull(5) ? null : reader.GetInt32(5),
|
||||
IsAnimated = reader.GetInt32(6) != 0,
|
||||
ShowcasePath = reader.IsDBNull(10) ? null : reader.GetString(10),
|
||||
ProxyKey = reader.IsDBNull(11) ? null : reader.GetString(11),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return new MediaPage(items, total);
|
||||
}
|
||||
|
||||
/// <summary>Every source that has contributed something.</summary>
|
||||
public async Task<IReadOnlyList<string>> GetSourceIdsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sources = new List<string>();
|
||||
|
||||
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = "SELECT DISTINCT source_id FROM item ORDER BY source_id;";
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
sources.Add(reader.GetString(0));
|
||||
}
|
||||
|
||||
return sources;
|
||||
}
|
||||
|
||||
private static void Bind(SqliteCommand command, List<(string Name, object Value)> parameters)
|
||||
{
|
||||
foreach (var (name, value) in parameters)
|
||||
{
|
||||
command.Parameters.AddWithValue(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Recomputes every reference count and reports how many were wrong.</summary>
|
||||
/// <remarks>
|
||||
/// <c>ref_count</c> is denormalised so that purging can find orphans by index instead of
|
||||
|
||||
@@ -3,6 +3,7 @@ using AvParser.Core.Proxies;
|
||||
using AvParser.Core.Settings;
|
||||
using AvParser.Infrastructure.Proxies;
|
||||
using AvParser.Infrastructure.Storage;
|
||||
using AvParser.UI.Media;
|
||||
using AvParser.UI.Navigation;
|
||||
using AvParser.UI.Services;
|
||||
using AvParser.UI.ViewModels;
|
||||
@@ -26,6 +27,10 @@ public static class UiServiceCollectionExtensions
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
services.AddSingleton<ViewLocator>();
|
||||
services.AddSingleton<IThumbnailCache>(static sp => new ThumbnailCache(
|
||||
sp.GetRequiredService<IAppPaths>(),
|
||||
sp.GetRequiredService<ILogger<ThumbnailCache>>()
|
||||
));
|
||||
services.AddSingleton<IThemeService, ThemeService>();
|
||||
services.AddSingleton<ILocalizationService, LocalizationService>();
|
||||
services.AddSingleton<INavigationService, NavigationService>();
|
||||
@@ -45,9 +50,15 @@ public static class UiServiceCollectionExtensions
|
||||
sp.GetRequiredService<IProxyPool>(),
|
||||
sp.GetRequiredService<ICollectRunner>(),
|
||||
sp.GetRequiredService<IMediaStore>(),
|
||||
sp.GetRequiredService<IThumbnailCache>(),
|
||||
sp,
|
||||
sp.GetRequiredService<ILogger<CollectViewModel>>()
|
||||
));
|
||||
services.AddSingleton<GalleryViewModel>(static sp => new GalleryViewModel(
|
||||
sp.GetRequiredService<IMediaStore>(),
|
||||
sp.GetRequiredService<IThumbnailCache>(),
|
||||
sp.GetRequiredService<ILogger<GalleryViewModel>>()
|
||||
));
|
||||
services.AddSingleton<ProxiesViewModel>(static sp => new ProxiesViewModel(
|
||||
sp.GetRequiredService<IProxyPool>(),
|
||||
sp.GetRequiredService<IMutableProxySource>(),
|
||||
@@ -59,6 +70,7 @@ public static class UiServiceCollectionExtensions
|
||||
// Order here is the order of the navigation rail; the first entry is the landing page.
|
||||
services.AddSingleton<PageViewModel>(static sp => sp.GetRequiredService<DashboardViewModel>());
|
||||
services.AddSingleton<PageViewModel>(static sp => sp.GetRequiredService<CollectViewModel>());
|
||||
services.AddSingleton<PageViewModel>(static sp => sp.GetRequiredService<GalleryViewModel>());
|
||||
services.AddSingleton<PageViewModel>(static sp => sp.GetRequiredService<ProxiesViewModel>());
|
||||
services.AddSingleton<PageViewModel>(static sp => sp.GetRequiredService<SettingsViewModel>());
|
||||
services.AddSingleton<PageViewModel>(static sp => sp.GetRequiredService<AboutViewModel>());
|
||||
|
||||
@@ -634,4 +634,70 @@
|
||||
<data name="Dashboard.OpenCollector" xml:space="preserve">
|
||||
<value>Open the collector</value>
|
||||
</data>
|
||||
<data name="Page.Gallery" xml:space="preserve">
|
||||
<value>Gallery</value>
|
||||
</data>
|
||||
<data name="Gallery.Source" xml:space="preserve">
|
||||
<value>SOURCE</value>
|
||||
</data>
|
||||
<data name="Gallery.AllSources" xml:space="preserve">
|
||||
<value>All sources</value>
|
||||
</data>
|
||||
<data name="Gallery.Kind" xml:space="preserve">
|
||||
<value>FORMAT</value>
|
||||
</data>
|
||||
<data name="Gallery.Search" xml:space="preserve">
|
||||
<value>ADDRESS CONTAINS</value>
|
||||
</data>
|
||||
<data name="Gallery.SearchPlaceholder" xml:space="preserve">
|
||||
<value>part of the address</value>
|
||||
</data>
|
||||
<data name="Gallery.AnimatedOnly" xml:space="preserve">
|
||||
<value>Animated only</value>
|
||||
</data>
|
||||
<data name="Gallery.Refresh" xml:space="preserve">
|
||||
<value>Refresh</value>
|
||||
</data>
|
||||
<data name="Gallery.Animated" xml:space="preserve">
|
||||
<value>ANIM</value>
|
||||
</data>
|
||||
<data name="Gallery.Empty" xml:space="preserve">
|
||||
<value>Nothing here yet. Collect something, or loosen the filters.</value>
|
||||
</data>
|
||||
<data name="Gallery.Failed" xml:space="preserve">
|
||||
<value>Could not read the store: {0}</value>
|
||||
</data>
|
||||
<data name="Gallery.Page" xml:space="preserve">
|
||||
<value>Page {0} of {1}</value>
|
||||
</data>
|
||||
<data name="Gallery.NoPreview" xml:space="preserve">
|
||||
<value>Nothing here can show this format. The file is on disk.</value>
|
||||
</data>
|
||||
<data name="Gallery.Address" xml:space="preserve">
|
||||
<value>ADDRESS</value>
|
||||
</data>
|
||||
<data name="Gallery.Collected" xml:space="preserve">
|
||||
<value>COLLECTED</value>
|
||||
</data>
|
||||
<data name="Gallery.Format" xml:space="preserve">
|
||||
<value>FORMAT</value>
|
||||
</data>
|
||||
<data name="Gallery.Hash" xml:space="preserve">
|
||||
<value>CONTENT HASH</value>
|
||||
</data>
|
||||
<data name="Gallery.Close" xml:space="preserve">
|
||||
<value>Close</value>
|
||||
</data>
|
||||
<data name="Enum.MediaKindFilter.All" xml:space="preserve">
|
||||
<value>Everything</value>
|
||||
</data>
|
||||
<data name="Enum.MediaKindFilter.Images" xml:space="preserve">
|
||||
<value>Images</value>
|
||||
</data>
|
||||
<data name="Enum.MediaKindFilter.Videos" xml:space="preserve">
|
||||
<value>Video</value>
|
||||
</data>
|
||||
<data name="Collect.Preview" xml:space="preserve">
|
||||
<value>PREVIEW</value>
|
||||
</data>
|
||||
</root>
|
||||
|
||||
@@ -634,4 +634,70 @@
|
||||
<data name="Dashboard.OpenCollector" xml:space="preserve">
|
||||
<value>Открыть сбор</value>
|
||||
</data>
|
||||
<data name="Page.Gallery" xml:space="preserve">
|
||||
<value>Галерея</value>
|
||||
</data>
|
||||
<data name="Gallery.Source" xml:space="preserve">
|
||||
<value>ИСТОЧНИК</value>
|
||||
</data>
|
||||
<data name="Gallery.AllSources" xml:space="preserve">
|
||||
<value>Все источники</value>
|
||||
</data>
|
||||
<data name="Gallery.Kind" xml:space="preserve">
|
||||
<value>ФОРМАТ</value>
|
||||
</data>
|
||||
<data name="Gallery.Search" xml:space="preserve">
|
||||
<value>АДРЕС СОДЕРЖИТ</value>
|
||||
</data>
|
||||
<data name="Gallery.SearchPlaceholder" xml:space="preserve">
|
||||
<value>часть адреса</value>
|
||||
</data>
|
||||
<data name="Gallery.AnimatedOnly" xml:space="preserve">
|
||||
<value>Только анимированные</value>
|
||||
</data>
|
||||
<data name="Gallery.Refresh" xml:space="preserve">
|
||||
<value>Обновить</value>
|
||||
</data>
|
||||
<data name="Gallery.Animated" xml:space="preserve">
|
||||
<value>АНИМ</value>
|
||||
</data>
|
||||
<data name="Gallery.Empty" xml:space="preserve">
|
||||
<value>Пока пусто. Соберите что-нибудь или ослабьте фильтры.</value>
|
||||
</data>
|
||||
<data name="Gallery.Failed" xml:space="preserve">
|
||||
<value>Не удалось прочитать хранилище: {0}</value>
|
||||
</data>
|
||||
<data name="Gallery.Page" xml:space="preserve">
|
||||
<value>Страница {0} из {1}</value>
|
||||
</data>
|
||||
<data name="Gallery.NoPreview" xml:space="preserve">
|
||||
<value>Этот формат показать нечем. Файл лежит на диске.</value>
|
||||
</data>
|
||||
<data name="Gallery.Address" xml:space="preserve">
|
||||
<value>АДРЕС</value>
|
||||
</data>
|
||||
<data name="Gallery.Collected" xml:space="preserve">
|
||||
<value>СОБРАНО</value>
|
||||
</data>
|
||||
<data name="Gallery.Format" xml:space="preserve">
|
||||
<value>ФОРМАТ</value>
|
||||
</data>
|
||||
<data name="Gallery.Hash" xml:space="preserve">
|
||||
<value>ХЕШ СОДЕРЖИМОГО</value>
|
||||
</data>
|
||||
<data name="Gallery.Close" xml:space="preserve">
|
||||
<value>Закрыть</value>
|
||||
</data>
|
||||
<data name="Enum.MediaKindFilter.All" xml:space="preserve">
|
||||
<value>Всё</value>
|
||||
</data>
|
||||
<data name="Enum.MediaKindFilter.Images" xml:space="preserve">
|
||||
<value>Изображения</value>
|
||||
</data>
|
||||
<data name="Enum.MediaKindFilter.Videos" xml:space="preserve">
|
||||
<value>Видео</value>
|
||||
</data>
|
||||
<data name="Collect.Preview" xml:space="preserve">
|
||||
<value>ПРЕВЬЮ</value>
|
||||
</data>
|
||||
</root>
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
using Avalonia.Media.Imaging;
|
||||
using AvParser.Core.Collecting;
|
||||
using AvParser.Infrastructure.Storage;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AvParser.UI.Media;
|
||||
|
||||
/// <summary>Decodes stored blobs into bitmaps small enough to show many at once.</summary>
|
||||
public interface IThumbnailCache
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns a thumbnail for stored content, or <see langword="null"/> when there is nothing to
|
||||
/// show — a video, a missing file, or something no decoder recognises.
|
||||
/// </summary>
|
||||
Task<Bitmap?> GetAsync(string sha256, string extension, MediaKind kind, int width, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Drops everything, disposing the bitmaps.</summary>
|
||||
void Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A bounded, least-recently-used cache of decoded thumbnails.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Bounded because the alternative does not survive contact with a real archive: a 4000×3000 JPEG
|
||||
/// costs about 48 MB once decoded, so a few hundred full-size decodes exhaust memory long before
|
||||
/// the user has finished scrolling. Everything is decoded to the requested width instead, which is
|
||||
/// both far smaller and much faster than decoding and then scaling.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The cache owns its bitmaps and disposes them on eviction, so callers must never dispose what
|
||||
/// they are handed. That is safe only while the capacity comfortably exceeds one screenful — a
|
||||
/// bitmap evicted while still on screen would be disposed out from under the renderer — which is
|
||||
/// why <see cref="Capacity"/> is several times the gallery's page size rather than a tuned number.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ThumbnailCache(IAppPaths paths, ILogger<ThumbnailCache> logger) : IThumbnailCache, IDisposable
|
||||
{
|
||||
/// <summary>Comfortably more than a screenful, for the reason given on the type.</summary>
|
||||
public const int Capacity = 600;
|
||||
|
||||
private readonly IAppPaths _paths = paths ?? throw new ArgumentNullException(nameof(paths));
|
||||
private readonly ILogger<ThumbnailCache> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
private readonly Dictionary<string, LinkedListNode<Entry>> _byKey = new(StringComparer.Ordinal);
|
||||
private readonly LinkedList<Entry> _order = new();
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Bitmap?> GetAsync(
|
||||
string sha256,
|
||||
string extension,
|
||||
MediaKind kind,
|
||||
int width,
|
||||
CancellationToken ct = default
|
||||
)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sha256);
|
||||
|
||||
// Nothing here can decode a video container, and pretending otherwise would mean an
|
||||
// exception per tile. The caller shows a format badge instead.
|
||||
if (!MediaKinds.IsImage(kind))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var key = $"{sha256}@{width.ToString(System.Globalization.CultureInfo.InvariantCulture)}";
|
||||
|
||||
await _gate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_byKey.TryGetValue(key, out var existing))
|
||||
{
|
||||
Touch(existing);
|
||||
return existing.Value.Bitmap;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
|
||||
var bitmap = await Task.Run(() => Decode(sha256, extension, width), ct).ConfigureAwait(false);
|
||||
|
||||
if (bitmap is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
await _gate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
// Another caller may have decoded the same blob while this one was working.
|
||||
if (_byKey.TryGetValue(key, out var raced))
|
||||
{
|
||||
bitmap.Dispose();
|
||||
Touch(raced);
|
||||
return raced.Value.Bitmap;
|
||||
}
|
||||
|
||||
var node = _order.AddFirst(new Entry(key, bitmap));
|
||||
_byKey[key] = node;
|
||||
Evict();
|
||||
|
||||
return bitmap;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Clear()
|
||||
{
|
||||
_gate.Wait();
|
||||
try
|
||||
{
|
||||
foreach (var entry in _order)
|
||||
{
|
||||
entry.Bitmap.Dispose();
|
||||
}
|
||||
|
||||
_order.Clear();
|
||||
_byKey.Clear();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
Clear();
|
||||
_gate.Dispose();
|
||||
}
|
||||
|
||||
private Bitmap? Decode(string sha256, string extension, int width)
|
||||
{
|
||||
var path = Path.Combine(
|
||||
_paths.BlobDirectory,
|
||||
new MediaBlob(sha256, MediaKind.Unknown, extension, 0).RelativePath
|
||||
);
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(path);
|
||||
|
||||
// Decoding straight to the target width never materialises the full-size surface.
|
||||
return Bitmap.DecodeToWidth(stream, width, BitmapInterpolationMode.MediumQuality);
|
||||
}
|
||||
catch (Exception ex)
|
||||
when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException)
|
||||
{
|
||||
// A blob the decoder cannot read is a blank tile, not a broken page.
|
||||
_logger.LogDebug(ex, "Could not decode {Path} for a thumbnail", path);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void Touch(LinkedListNode<Entry> node)
|
||||
{
|
||||
_order.Remove(node);
|
||||
_order.AddFirst(node);
|
||||
}
|
||||
|
||||
private void Evict()
|
||||
{
|
||||
while (_order.Count > Capacity)
|
||||
{
|
||||
var oldest = _order.Last!;
|
||||
_order.RemoveLast();
|
||||
_byKey.Remove(oldest.Value.Key);
|
||||
oldest.Value.Bitmap.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private readonly record struct Entry(string Key, Bitmap Bitmap);
|
||||
}
|
||||
@@ -31,6 +31,8 @@
|
||||
|
||||
<StreamGeometry x:Key="IconBack">M20 11H7.8l5.6-5.6L12 4l-8 8 8 8 1.4-1.4L7.8 13H20v-2z</StreamGeometry>
|
||||
|
||||
<StreamGeometry x:Key="IconForward">M4 11h12.2l-5.6-5.6L12 4l8 8-8 8-1.4-1.4 5.6-5.6H4v-2z</StreamGeometry>
|
||||
|
||||
<StreamGeometry x:Key="IconPlay">M8 5v14l11-7z</StreamGeometry>
|
||||
|
||||
<StreamGeometry x:Key="IconStop">M6.5 6.5h11v11h-11z</StreamGeometry>
|
||||
|
||||
@@ -6,6 +6,7 @@ using AvParser.Core.Parsing;
|
||||
using AvParser.Core.Proxies;
|
||||
using AvParser.Core.Settings;
|
||||
using AvParser.UI.Localization;
|
||||
using AvParser.UI.Media;
|
||||
using AvParser.UI.Navigation;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -38,6 +39,7 @@ public partial class CollectViewModel : PageViewModel, IDisposable
|
||||
private readonly IProxyPool _proxyPool;
|
||||
private readonly ICollectRunner _runner;
|
||||
private readonly IMediaStore _store;
|
||||
private readonly IThumbnailCache _thumbnails;
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly ILogger<CollectViewModel> _logger;
|
||||
private readonly ISequencer _mainThread;
|
||||
@@ -95,6 +97,7 @@ public partial class CollectViewModel : PageViewModel, IDisposable
|
||||
/// <param name="proxyPool">Consulted for the live count that gates network sources.</param>
|
||||
/// <param name="runner">Runs the collection.</param>
|
||||
/// <param name="store">Consulted for totals, and asked to purge.</param>
|
||||
/// <param name="thumbnails">Decodes row previews.</param>
|
||||
/// <param name="services">Resolves the navigation service lazily, to keep pages acyclic.</param>
|
||||
/// <param name="logger">Diagnostics.</param>
|
||||
/// <param name="mainThread">
|
||||
@@ -107,6 +110,7 @@ public partial class CollectViewModel : PageViewModel, IDisposable
|
||||
IProxyPool proxyPool,
|
||||
ICollectRunner runner,
|
||||
IMediaStore store,
|
||||
IThumbnailCache thumbnails,
|
||||
IServiceProvider services,
|
||||
ILogger<CollectViewModel> logger,
|
||||
ISequencer? mainThread = null
|
||||
@@ -117,6 +121,7 @@ public partial class CollectViewModel : PageViewModel, IDisposable
|
||||
_proxyPool = proxyPool ?? throw new ArgumentNullException(nameof(proxyPool));
|
||||
_runner = runner ?? throw new ArgumentNullException(nameof(runner));
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_thumbnails = thumbnails ?? throw new ArgumentNullException(nameof(thumbnails));
|
||||
_services = services ?? throw new ArgumentNullException(nameof(services));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_mainThread = mainThread ?? RxSchedulers.MainThreadScheduler;
|
||||
@@ -308,7 +313,7 @@ public partial class CollectViewModel : PageViewModel, IDisposable
|
||||
|
||||
if (stored + duplicates + skipped <= MaxDisplayedItems)
|
||||
{
|
||||
itemBuffer.Add(new CollectedItemViewModel(item));
|
||||
itemBuffer.Add(new CollectedItemViewModel(item, _thumbnails));
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -1,13 +1,37 @@
|
||||
using System.Globalization;
|
||||
using Avalonia.Media.Imaging;
|
||||
using AvParser.Core.Collecting;
|
||||
using AvParser.UI.Localization;
|
||||
using AvParser.UI.Media;
|
||||
using ReactiveUI;
|
||||
using ReactiveUI.SourceGenerators;
|
||||
|
||||
namespace AvParser.UI.ViewModels;
|
||||
|
||||
/// <summary>One row of the collected list.</summary>
|
||||
public sealed class CollectedItemViewModel(CollectedItem item) : ReactiveObject
|
||||
public sealed partial class CollectedItemViewModel(CollectedItem item, IThumbnailCache? thumbnails = null)
|
||||
: ReactiveObject
|
||||
{
|
||||
/// <summary>Row thumbnails are small; the list is a log, not a gallery.</summary>
|
||||
public const int ThumbnailWidth = 64;
|
||||
|
||||
/// <summary>Borrowed from the cache, which owns and disposes it. Never dispose this.</summary>
|
||||
[Reactive]
|
||||
public partial Bitmap? Thumbnail { get; private set; }
|
||||
|
||||
/// <summary>Loads the row thumbnail, if there is a cache and something to decode.</summary>
|
||||
public async Task LoadThumbnailAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (thumbnails is null || Item.Status == CollectStatus.Skipped)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Thumbnail = await thumbnails
|
||||
.GetAsync(Item.Blob.Sha256, Item.Blob.Extension, Item.Blob.Kind, ThumbnailWidth, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>The underlying result.</summary>
|
||||
public CollectedItem Item { get; } = item ?? throw new ArgumentNullException(nameof(item));
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
using System.Globalization;
|
||||
using Avalonia.Media.Imaging;
|
||||
using AvParser.Core.Collecting;
|
||||
using AvParser.UI.Localization;
|
||||
using AvParser.UI.Media;
|
||||
using ReactiveUI;
|
||||
using ReactiveUI.SourceGenerators;
|
||||
|
||||
namespace AvParser.UI.ViewModels;
|
||||
|
||||
/// <summary>One tile in the gallery.</summary>
|
||||
/// <remarks>
|
||||
/// The bitmap is loaded on demand rather than in the constructor: a page is a hundred-odd tiles,
|
||||
/// and decoding them all before the first one appears would make the page look frozen. It is also
|
||||
/// borrowed, never owned — <see cref="IThumbnailCache"/> disposes it, so nothing here may.
|
||||
/// </remarks>
|
||||
public partial class GalleryItemViewModel(StoredMedia media, IThumbnailCache thumbnails) : ReactiveObject
|
||||
{
|
||||
/// <summary>Width thumbnails are decoded to. Matches the tile so nothing is scaled twice.</summary>
|
||||
public const int ThumbnailWidth = 220;
|
||||
|
||||
private readonly IThumbnailCache _thumbnails = thumbnails ?? throw new ArgumentNullException(nameof(thumbnails));
|
||||
private bool _requested;
|
||||
|
||||
/// <summary>The stored item.</summary>
|
||||
public StoredMedia Media { get; } = media ?? throw new ArgumentNullException(nameof(media));
|
||||
|
||||
/// <summary>Decoded thumbnail; null until loaded, or for content nothing can decode.</summary>
|
||||
[Reactive]
|
||||
public partial Bitmap? Thumbnail { get; private set; }
|
||||
|
||||
/// <summary>Whether a decode has been attempted and produced nothing.</summary>
|
||||
[Reactive]
|
||||
public partial bool HasNoPreview { get; private set; }
|
||||
|
||||
/// <summary>Address it came from.</summary>
|
||||
public string Address => Media.Url;
|
||||
|
||||
/// <summary>Short hash, enough to recognise a blob without filling the tile.</summary>
|
||||
public string ShortHash => Media.Sha256.Length >= 10 ? Media.Sha256[..10] : Media.Sha256;
|
||||
|
||||
/// <summary>Format name, uppercased.</summary>
|
||||
public string KindText => Media.Kind == MediaKind.Unknown ? "?" : Media.Kind.ToString().ToUpperInvariant();
|
||||
|
||||
/// <summary>Human-readable size.</summary>
|
||||
public string SizeText => CollectedItemViewModel.FormatSize(Media.Length);
|
||||
|
||||
/// <summary>Pixel dimensions, when they were cheap to read.</summary>
|
||||
public string DimensionsText =>
|
||||
Media is { Width: { } width, Height: { } height }
|
||||
? $"{width.ToString(CultureInfo.CurrentCulture)}×{height.ToString(CultureInfo.CurrentCulture)}"
|
||||
: string.Empty;
|
||||
|
||||
/// <summary>Whether the content has more than one frame.</summary>
|
||||
public bool IsAnimated => Media.IsAnimated;
|
||||
|
||||
/// <summary>When it was collected, in the current culture's short form.</summary>
|
||||
public string CollectedText => Media.CollectedUtc.ToLocalTime().ToString("g", CultureInfo.CurrentCulture);
|
||||
|
||||
/// <summary>Which source brought it in.</summary>
|
||||
public string SourceText => Localizer.Instance.GetOrDefault($"Source.{Media.SourceId}.Name", Media.SourceId);
|
||||
|
||||
/// <summary>Decodes the thumbnail, at most once per tile.</summary>
|
||||
public async Task LoadThumbnailAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_requested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_requested = true;
|
||||
|
||||
var bitmap = await _thumbnails
|
||||
.GetAsync(Media.Sha256, Media.Extension, Media.Kind, ThumbnailWidth, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
Thumbnail = bitmap;
|
||||
HasNoPreview = bitmap is null;
|
||||
}
|
||||
|
||||
/// <summary>Re-reads everything derived from the current language or culture.</summary>
|
||||
public void Refresh()
|
||||
{
|
||||
this.RaisePropertyChanged(nameof(SizeText));
|
||||
this.RaisePropertyChanged(nameof(DimensionsText));
|
||||
this.RaisePropertyChanged(nameof(CollectedText));
|
||||
this.RaisePropertyChanged(nameof(SourceText));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using Avalonia.Media.Imaging;
|
||||
using AvParser.Core.Collecting;
|
||||
using AvParser.Infrastructure.Media;
|
||||
using AvParser.UI.Localization;
|
||||
using AvParser.UI.Media;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ReactiveUI;
|
||||
using ReactiveUI.Primitives;
|
||||
using ReactiveUI.Primitives.Concurrency;
|
||||
using ReactiveUI.SourceGenerators;
|
||||
|
||||
namespace AvParser.UI.ViewModels;
|
||||
|
||||
/// <summary>Browses what the store holds.</summary>
|
||||
/// <remarks>
|
||||
/// Paged rather than scrolled-forever: a tile costs a decoded bitmap, so the number on screen has
|
||||
/// to be something the page decides rather than something the archive's size decides.
|
||||
/// </remarks>
|
||||
public partial class GalleryViewModel : PageViewModel, IDisposable
|
||||
{
|
||||
/// <summary>Tiles per page. Well under the thumbnail cache's capacity, on purpose.</summary>
|
||||
private const int PageSize = 120;
|
||||
|
||||
/// <summary>Width the preview is decoded to — large enough to look at, small enough to be quick.</summary>
|
||||
private const int PreviewWidth = 1400;
|
||||
|
||||
private readonly IMediaStore _store;
|
||||
private readonly IThumbnailCache _thumbnails;
|
||||
private readonly MediaStore? _paths;
|
||||
private readonly ILogger<GalleryViewModel> _logger;
|
||||
private readonly ISequencer _mainThread;
|
||||
|
||||
private CancellationTokenSource? _loading;
|
||||
|
||||
/// <summary>Filter by source; empty means all of them.</summary>
|
||||
[Reactive]
|
||||
public partial string? SelectedSourceId { get; set; }
|
||||
|
||||
/// <summary>Filter by format.</summary>
|
||||
[Reactive]
|
||||
public partial LocalizedOption<MediaKindFilter> SelectedKinds { get; set; }
|
||||
|
||||
/// <summary>Only content with more than one frame.</summary>
|
||||
[Reactive]
|
||||
public partial bool AnimatedOnly { get; set; }
|
||||
|
||||
/// <summary>Substring match against the address.</summary>
|
||||
[Reactive]
|
||||
public partial string SearchText { get; set; }
|
||||
|
||||
/// <summary>Zero-based page currently shown.</summary>
|
||||
[Reactive]
|
||||
public partial int PageIndex { get; set; }
|
||||
|
||||
/// <summary>How many items match the filter overall.</summary>
|
||||
[Reactive]
|
||||
public partial int TotalCount { get; set; }
|
||||
|
||||
/// <summary>Whether a page is being read.</summary>
|
||||
[Reactive]
|
||||
public partial bool IsLoading { get; set; }
|
||||
|
||||
/// <summary>Tile the user opened, or null when the viewer is closed.</summary>
|
||||
[Reactive]
|
||||
public partial GalleryItemViewModel? Selected { get; set; }
|
||||
|
||||
/// <summary>Full-size bitmap of <see cref="Selected"/>; null while loading or unviewable.</summary>
|
||||
[Reactive]
|
||||
public partial Bitmap? Preview { get; private set; }
|
||||
|
||||
/// <summary>Absolute path of the selected item on disk.</summary>
|
||||
[Reactive]
|
||||
public partial string? SelectedPath { get; set; }
|
||||
|
||||
/// <summary>Message shown when nothing matches.</summary>
|
||||
[Reactive]
|
||||
public partial string? StatusMessage { get; set; }
|
||||
|
||||
/// <summary>Creates the page.</summary>
|
||||
/// <param name="store">Where the content is.</param>
|
||||
/// <param name="thumbnails">Decodes and caches tile bitmaps.</param>
|
||||
/// <param name="logger">Diagnostics.</param>
|
||||
/// <param name="mainThread">Scheduler for UI-affine updates; tests pass an immediate one.</param>
|
||||
public GalleryViewModel(
|
||||
IMediaStore store,
|
||||
IThumbnailCache thumbnails,
|
||||
ILogger<GalleryViewModel> logger,
|
||||
ISequencer? mainThread = null
|
||||
)
|
||||
{
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_thumbnails = thumbnails ?? throw new ArgumentNullException(nameof(thumbnails));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_mainThread = mainThread ?? RxSchedulers.MainThreadScheduler;
|
||||
|
||||
// Only the concrete store knows the shard layout; the viewer needs a real path to show.
|
||||
_paths = store as MediaStore;
|
||||
|
||||
SearchText = string.Empty;
|
||||
SelectedKinds = KindFilters[0];
|
||||
|
||||
var idle = this.WhenAnyValue(x => x.IsLoading).Select(static loading => !loading);
|
||||
|
||||
RefreshCommand = ReactiveCommand.CreateFromTask(() => LoadAsync(0), idle, _mainThread);
|
||||
|
||||
NextPageCommand = ReactiveCommand.CreateFromTask(
|
||||
() => LoadAsync(PageIndex + 1),
|
||||
this.WhenAnyValue(x => x.PageIndex, x => x.TotalCount, x => x.IsLoading, HasNextPage),
|
||||
_mainThread
|
||||
);
|
||||
|
||||
PreviousPageCommand = ReactiveCommand.CreateFromTask(
|
||||
() => LoadAsync(PageIndex - 1),
|
||||
this.WhenAnyValue(x => x.PageIndex, x => x.IsLoading, static (page, loading) => page > 0 && !loading),
|
||||
_mainThread
|
||||
);
|
||||
|
||||
CloseViewerCommand = ReactiveCommand.Create(() => Selected = null, outputScheduler: _mainThread);
|
||||
|
||||
// Changing a filter starts again from the first page: staying on page seven of a different
|
||||
// result set shows nothing and looks broken.
|
||||
this.WhenAnyValue(
|
||||
x => x.SelectedSourceId,
|
||||
x => x.SelectedKinds,
|
||||
x => x.AnimatedOnly,
|
||||
x => x.SearchText,
|
||||
(_, _, _, _) => RxVoid.Default
|
||||
)
|
||||
// Skipping the first: WhenAnyValue publishes the current values on subscription, and
|
||||
// treating that as a filter change would load the first page twice on every open.
|
||||
.Skip(1)
|
||||
.Throttle(TimeSpan.FromMilliseconds(250), _mainThread)
|
||||
.ObserveOn(_mainThread)
|
||||
.Subscribe(change => Forget(LoadAsync(0)));
|
||||
|
||||
this.WhenAnyValue(x => x.Selected).ObserveOn(_mainThread).Subscribe(item => Forget(OpenAsync(item)));
|
||||
|
||||
foreach (var command in new[] { RefreshCommand, NextPageCommand, PreviousPageCommand })
|
||||
{
|
||||
command.ThrownExceptions.Subscribe(error => _logger.LogError(error, "The gallery failed to load"));
|
||||
}
|
||||
|
||||
Forget(LoadAsync(0));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts work nothing waits for.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A named method rather than a discard at the call site: <c>_ => _ = ...</c> reads as a
|
||||
/// discard but assigns to the lambda's parameter, which the compiler accepts in some shapes and
|
||||
/// rejects in others. Both of these paths already report their own failures.
|
||||
/// </remarks>
|
||||
private static void Forget(Task task) => _ = task;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string TitleKey => "Page.Gallery";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string IconKey => "IconImage";
|
||||
|
||||
/// <summary>Tiles on the current page.</summary>
|
||||
public ObservableCollection<GalleryItemViewModel> Items { get; } = [];
|
||||
|
||||
/// <summary>Sources that have contributed something, for the picker.</summary>
|
||||
public ObservableCollection<string> Sources { get; } = [];
|
||||
|
||||
/// <summary>Format filters offered by the picker.</summary>
|
||||
public IReadOnlyList<LocalizedOption<MediaKindFilter>> KindFilters { get; } =
|
||||
LocalizedOption<MediaKindFilter>.For(MediaKindFilter.All, MediaKindFilter.Images, MediaKindFilter.Videos);
|
||||
|
||||
/// <summary>Re-reads the current page.</summary>
|
||||
public ReactiveCommand<RxVoid, RxVoid> RefreshCommand { get; }
|
||||
|
||||
/// <summary>Moves forward one page.</summary>
|
||||
public ReactiveCommand<RxVoid, RxVoid> NextPageCommand { get; }
|
||||
|
||||
/// <summary>Moves back one page.</summary>
|
||||
public ReactiveCommand<RxVoid, RxVoid> PreviousPageCommand { get; }
|
||||
|
||||
/// <summary>Closes the viewer.</summary>
|
||||
public ReactiveCommand<RxVoid, GalleryItemViewModel?> CloseViewerCommand { get; }
|
||||
|
||||
/// <summary>Whether the viewer is open.</summary>
|
||||
public bool IsViewerOpen => Selected is not null;
|
||||
|
||||
/// <summary>Which page of how many, for the pager.</summary>
|
||||
public string PageText =>
|
||||
TotalCount == 0
|
||||
? string.Empty
|
||||
: Localizer.Instance.Format(
|
||||
"Gallery.Page",
|
||||
PageIndex + 1,
|
||||
Math.Max(1, (TotalCount + PageSize - 1) / PageSize)
|
||||
);
|
||||
|
||||
/// <summary>Reads one page and rebuilds the tiles.</summary>
|
||||
public async Task LoadAsync(int page, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// A filter changed while the previous page was still reading; that page is now wrong.
|
||||
var previous = Interlocked.Exchange(
|
||||
ref _loading,
|
||||
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)
|
||||
);
|
||||
previous?.Cancel();
|
||||
previous?.Dispose();
|
||||
|
||||
var token = _loading!.Token;
|
||||
OnUi(() => IsLoading = true);
|
||||
|
||||
try
|
||||
{
|
||||
var query = new MediaBrowseQuery
|
||||
{
|
||||
SourceId = string.IsNullOrWhiteSpace(SelectedSourceId) ? null : SelectedSourceId,
|
||||
Kinds = SelectedKinds.Value,
|
||||
AnimatedOnly = AnimatedOnly,
|
||||
Search = SearchText,
|
||||
Skip = Math.Max(0, page) * PageSize,
|
||||
Take = PageSize,
|
||||
};
|
||||
|
||||
var result = await _store.BrowseAsync(query, token).ConfigureAwait(false);
|
||||
var sources = await _store.GetSourceIdsAsync(token).ConfigureAwait(false);
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
var tiles = result.Items.Select(item => new GalleryItemViewModel(item, _thumbnails)).ToArray();
|
||||
|
||||
OnUi(() =>
|
||||
{
|
||||
Items.Clear();
|
||||
foreach (var tile in tiles)
|
||||
{
|
||||
Items.Add(tile);
|
||||
}
|
||||
|
||||
Sources.Clear();
|
||||
foreach (var source in sources)
|
||||
{
|
||||
Sources.Add(source);
|
||||
}
|
||||
|
||||
PageIndex = Math.Max(0, page);
|
||||
TotalCount = result.Total;
|
||||
StatusMessage = result.Total == 0 ? Localizer.Instance["Gallery.Empty"] : null;
|
||||
|
||||
this.RaisePropertyChanged(nameof(PageText));
|
||||
});
|
||||
|
||||
// Decoding after the tiles are on screen, so the page appears immediately and fills in.
|
||||
foreach (var tile in tiles)
|
||||
{
|
||||
if (token.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await tile.LoadThumbnailAsync(token).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Superseded by a newer load.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Could not read the gallery");
|
||||
OnUi(() => StatusMessage = Localizer.Instance.Format("Gallery.Failed", ex.Message));
|
||||
}
|
||||
finally
|
||||
{
|
||||
OnUi(() => IsLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Loads the full-size view of the opened tile.</summary>
|
||||
private async Task OpenAsync(GalleryItemViewModel? item)
|
||||
{
|
||||
this.RaisePropertyChanged(nameof(IsViewerOpen));
|
||||
|
||||
if (item is null)
|
||||
{
|
||||
OnUi(() =>
|
||||
{
|
||||
Preview = null;
|
||||
SelectedPath = null;
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var path = _paths?.PathFor(item.Media.Sha256, item.Media.Extension);
|
||||
OnUi(() => SelectedPath = path);
|
||||
|
||||
var bitmap = await _thumbnails
|
||||
.GetAsync(item.Media.Sha256, item.Media.Extension, item.Media.Kind, PreviewWidth)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
OnUi(() => Preview = bitmap);
|
||||
}
|
||||
|
||||
private static bool HasNextPage(int page, int total, bool loading) => !loading && (page + 1) * PageSize < total;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnLanguageChanged()
|
||||
{
|
||||
base.OnLanguageChanged();
|
||||
|
||||
this.RaisePropertyChanged(nameof(PageText));
|
||||
|
||||
foreach (var item in Items)
|
||||
{
|
||||
item.Refresh();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
_loading?.Cancel();
|
||||
_loading?.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private void OnUi(Action action) => _mainThread.Schedule(action);
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
<UserControl
|
||||
xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:l="clr-namespace:AvParser.UI.Localization"
|
||||
xmlns:vm="clr-namespace:AvParser.UI.ViewModels"
|
||||
xmlns:conv="clr-namespace:AvParser.UI.Converters"
|
||||
x:Class="AvParser.UI.Views.GalleryView"
|
||||
x:DataType="vm:GalleryViewModel"
|
||||
>
|
||||
<UserControl.Resources>
|
||||
<DataTemplate x:Key="LocalizedOptionTemplate" x:DataType="l:LocalizedOption">
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</UserControl.Resources>
|
||||
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
<!-- ===== Filters ===== -->
|
||||
<Border Grid.Row="0" Classes="card" Margin="0,0,0,12">
|
||||
<WrapPanel Orientation="Horizontal">
|
||||
<StackPanel Spacing="4" Margin="0,0,16,8" MinWidth="200">
|
||||
<TextBlock Classes="caption" Text="{l:Loc Gallery.Source}" />
|
||||
<ComboBox
|
||||
ItemsSource="{Binding Sources}"
|
||||
SelectedItem="{Binding SelectedSourceId}"
|
||||
PlaceholderText="{l:Loc Gallery.AllSources}"
|
||||
HorizontalAlignment="Stretch"
|
||||
/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="4" Margin="0,0,16,8" MinWidth="180">
|
||||
<TextBlock Classes="caption" Text="{l:Loc Gallery.Kind}" />
|
||||
<ComboBox
|
||||
ItemsSource="{Binding KindFilters}"
|
||||
SelectedItem="{Binding SelectedKinds}"
|
||||
ItemTemplate="{StaticResource LocalizedOptionTemplate}"
|
||||
HorizontalAlignment="Stretch"
|
||||
/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="4" Margin="0,0,16,8" MinWidth="260">
|
||||
<TextBlock Classes="caption" Text="{l:Loc Gallery.Search}" />
|
||||
<TextBox Text="{Binding SearchText}" PlaceholderText="{l:Loc Gallery.SearchPlaceholder}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="4" Margin="0,0,16,8" VerticalAlignment="Bottom">
|
||||
<CheckBox IsChecked="{Binding AnimatedOnly}" Content="{l:Loc Gallery.AnimatedOnly}" Margin="0,0,0,6" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="4" Margin="0,0,0,8" VerticalAlignment="Bottom">
|
||||
<Button Command="{Binding RefreshCommand}" Margin="0,0,0,4">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<PathIcon Classes="glyph" Data="{DynamicResource IconRefresh}" />
|
||||
<TextBlock Text="{l:Loc Gallery.Refresh}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</WrapPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ===== Tiles, with the viewer layered over them ===== -->
|
||||
<Grid Grid.Row="1">
|
||||
<Border Classes="card" Padding="0">
|
||||
<DockPanel LastChildFill="True">
|
||||
<TextBlock
|
||||
DockPanel.Dock="Top"
|
||||
Classes="muted"
|
||||
Margin="16,12"
|
||||
Text="{Binding StatusMessage}"
|
||||
TextWrapping="Wrap"
|
||||
IsVisible="{Binding StatusMessage, Converter={x:Static ObjectConverters.IsNotNull}}"
|
||||
/>
|
||||
|
||||
<ProgressBar DockPanel.Dock="Top" IsIndeterminate="True" Height="4" IsVisible="{Binding IsLoading}" />
|
||||
|
||||
<ListBox
|
||||
x:Name="Tiles"
|
||||
ItemsSource="{Binding Items}"
|
||||
SelectedItem="{Binding Selected}"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
SelectionMode="Single"
|
||||
>
|
||||
<ListBox.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel Orientation="Horizontal" />
|
||||
</ItemsPanelTemplate>
|
||||
</ListBox.ItemsPanel>
|
||||
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:GalleryItemViewModel">
|
||||
<Border Classes="card interactive" Width="220" Margin="0,0,10,10" Padding="0">
|
||||
<StackPanel>
|
||||
<Panel Height="165" Background="{DynamicResource AppSurfaceSunkenBrush}">
|
||||
<Image
|
||||
Source="{Binding Thumbnail}"
|
||||
Stretch="UniformToFill"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch"
|
||||
/>
|
||||
|
||||
<!-- Nothing here decodes a video container, so say so rather than show a gap. -->
|
||||
<StackPanel
|
||||
VerticalAlignment="Center"
|
||||
HorizontalAlignment="Center"
|
||||
Spacing="6"
|
||||
IsVisible="{Binding HasNoPreview}"
|
||||
>
|
||||
<PathIcon Classes="glyph" Data="{DynamicResource IconImage}" Opacity="0.4" />
|
||||
<TextBlock Classes="mono caption" Text="{Binding KindText}" HorizontalAlignment="Center" />
|
||||
</StackPanel>
|
||||
|
||||
<Border
|
||||
Classes="chip accent"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Top"
|
||||
Margin="6"
|
||||
IsVisible="{Binding IsAnimated}"
|
||||
>
|
||||
<TextBlock Classes="mono caption" Text="{l:Loc Gallery.Animated}" />
|
||||
</Border>
|
||||
</Panel>
|
||||
|
||||
<StackPanel Spacing="2" Margin="10,8,10,10">
|
||||
<TextBlock
|
||||
Classes="caption"
|
||||
Text="{Binding Address}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
ToolTip.Tip="{Binding Address}"
|
||||
/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Classes="muted caption" Text="{Binding KindText}" />
|
||||
<TextBlock Classes="muted caption" Text="{Binding DimensionsText}" />
|
||||
<TextBlock Classes="muted caption" Text="{Binding SizeText}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ===== Viewer ===== -->
|
||||
<Border x:Name="Viewer" Background="{DynamicResource AppSurfaceSunkenBrush}" IsVisible="{Binding IsViewerOpen}">
|
||||
<Grid ColumnDefinitions="*,320">
|
||||
<Panel Grid.Column="0" Margin="16">
|
||||
<Image Source="{Binding Preview}" Stretch="Uniform" />
|
||||
<TextBlock
|
||||
Classes="muted"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Text="{l:Loc Gallery.NoPreview}"
|
||||
IsVisible="{Binding Preview, Converter={x:Static ObjectConverters.IsNull}}"
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
<Border Grid.Column="1" Classes="card" Margin="0,16,16,16">
|
||||
<ScrollViewer>
|
||||
<StackPanel Spacing="14" DataContext="{Binding Selected}" x:DataType="vm:GalleryItemViewModel">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="caption" Text="{l:Loc Gallery.Address}" />
|
||||
<SelectableTextBlock Classes="mono" Text="{Binding Address}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="caption" Text="{l:Loc Gallery.Source}" />
|
||||
<TextBlock Text="{Binding SourceText}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="caption" Text="{l:Loc Gallery.Collected}" />
|
||||
<TextBlock Text="{Binding CollectedText}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="caption" Text="{l:Loc Gallery.Format}" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Text="{Binding KindText}" />
|
||||
<TextBlock Classes="muted" Text="{Binding DimensionsText}" />
|
||||
<TextBlock Classes="muted" Text="{Binding SizeText}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="caption" Text="{l:Loc Gallery.Hash}" />
|
||||
<SelectableTextBlock Classes="mono caption" Text="{Binding Media.Sha256}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
<StackPanel
|
||||
Grid.Column="1"
|
||||
Orientation="Horizontal"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Bottom"
|
||||
Margin="0,0,32,32"
|
||||
Spacing="8"
|
||||
>
|
||||
<Button Classes="primary" Command="{Binding CloseViewerCommand}">
|
||||
<TextBlock Text="{l:Loc Gallery.Close}" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<!-- ===== Pager ===== -->
|
||||
<StackPanel
|
||||
Grid.Row="2"
|
||||
Orientation="Horizontal"
|
||||
Spacing="12"
|
||||
Margin="0,12,0,0"
|
||||
HorizontalAlignment="Center"
|
||||
IsVisible="{Binding TotalCount, Converter={x:Static conv:AppConverters.IsPositive}}"
|
||||
>
|
||||
<Button Command="{Binding PreviousPageCommand}">
|
||||
<PathIcon Classes="glyph" Data="{DynamicResource IconBack}" />
|
||||
</Button>
|
||||
<TextBlock Classes="muted" Text="{Binding PageText}" VerticalAlignment="Center" />
|
||||
<Button Command="{Binding NextPageCommand}">
|
||||
<PathIcon Classes="glyph" Data="{DynamicResource IconForward}" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,13 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace AvParser.UI.Views;
|
||||
|
||||
/// <summary>Tiles over the store, with a viewer layered above them.</summary>
|
||||
public partial class GalleryView : UserControl
|
||||
{
|
||||
/// <summary>Creates the view.</summary>
|
||||
public GalleryView() => InitializeComponent();
|
||||
|
||||
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
@@ -71,6 +71,7 @@ public class CollectViewTests
|
||||
new ProxyPool([], new FakeProxyProbe(), new ProxyOptions()),
|
||||
new IdleRunner(),
|
||||
new FakeMediaStore(),
|
||||
new FakeThumbnailCache(),
|
||||
new EmptyServiceProvider(),
|
||||
NullLogger<CollectViewModel>.Instance,
|
||||
ImmediateSequencer.Instance
|
||||
|
||||
@@ -9,6 +9,16 @@ internal sealed class FakeMediaStore : IMediaStore
|
||||
|
||||
public MediaStoreStats Stats { get; set; } = new(0, 0, 0, 0);
|
||||
|
||||
public List<StoredMedia> Browse { get; } = [];
|
||||
|
||||
public List<string> SourceIds { get; } = [];
|
||||
|
||||
public MediaBrowseQuery? LastBrowse { get; private set; }
|
||||
|
||||
public int? TotalOverride { get; set; }
|
||||
|
||||
public bool BrowseThrows { get; set; }
|
||||
|
||||
public PurgeResult PurgeResult { get; set; } = new(3, 2, 4096);
|
||||
|
||||
public ShowcaseMode Mode { get; private set; } = ShowcaseMode.HardLink;
|
||||
@@ -61,4 +71,16 @@ internal sealed class FakeMediaStore : IMediaStore
|
||||
Task.FromResult(0);
|
||||
|
||||
public Task<MediaStoreStats> GetStatsAsync(CancellationToken cancellationToken = default) => Task.FromResult(Stats);
|
||||
|
||||
public Task<MediaPage> BrowseAsync(MediaBrowseQuery query, CancellationToken cancellationToken = default)
|
||||
{
|
||||
LastBrowse = query;
|
||||
|
||||
return BrowseThrows
|
||||
? throw new InvalidOperationException("the index is unreadable")
|
||||
: Task.FromResult(new MediaPage(Browse, TotalOverride ?? Browse.Count));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<string>> GetSourceIdsAsync(CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<IReadOnlyList<string>>(SourceIds);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using Avalonia.Media.Imaging;
|
||||
using AvParser.Core.Collecting;
|
||||
using AvParser.UI.Media;
|
||||
|
||||
namespace AvParser.UI.HeadlessTests;
|
||||
|
||||
/// <summary>
|
||||
/// A cache that decodes nothing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Returning null is exactly what the real cache does for a video or a missing blob, so the view
|
||||
/// models are already required to cope with it — which is what makes this an honest stand-in
|
||||
/// rather than a convenient one. It records what was asked for, so tests can assert that a page
|
||||
/// requested previews at all.
|
||||
/// </remarks>
|
||||
internal sealed class FakeThumbnailCache : IThumbnailCache
|
||||
{
|
||||
public List<(string Sha256, int Width)> Requested { get; } = [];
|
||||
|
||||
public Task<Bitmap?> GetAsync(
|
||||
string sha256,
|
||||
string extension,
|
||||
MediaKind kind,
|
||||
int width,
|
||||
CancellationToken ct = default
|
||||
)
|
||||
{
|
||||
lock (Requested)
|
||||
{
|
||||
Requested.Add((sha256, width));
|
||||
}
|
||||
|
||||
return Task.FromResult<Bitmap?>(null);
|
||||
}
|
||||
|
||||
public void Clear() => Requested.Clear();
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Avalonia;
|
||||
using Avalonia.Headless.XUnit;
|
||||
using Avalonia.Styling;
|
||||
|
||||
namespace AvParser.UI.HeadlessTests;
|
||||
|
||||
/// <summary>
|
||||
/// Every resource key the XAML asks for must actually resolve.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A <c>DynamicResource</c> naming a key that does not exist fails silently: the property simply
|
||||
/// keeps its default, so a background never paints and a brush is transparent. That shipped twice —
|
||||
/// most recently as a viewer overlay you could see straight through, caught only by looking at a
|
||||
/// screenshot. Both themes are checked, because a key can exist in one dictionary and not the other.
|
||||
/// </remarks>
|
||||
public partial class ResourceKeyTests
|
||||
{
|
||||
[GeneratedRegex(@"\{(?:Dynamic|Static)Resource\s+([A-Za-z0-9_.]+)\s*\}", RegexOptions.Compiled)]
|
||||
private static partial Regex ResourceMarkup();
|
||||
|
||||
/// <summary>Keys defined inside a view's own <c>Resources</c> block rather than in a theme.</summary>
|
||||
private static readonly HashSet<string> Local = new(StringComparer.Ordinal) { "LocalizedOptionTemplate" };
|
||||
|
||||
private static DirectoryInfo RepositoryRoot()
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
|
||||
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "AvParser.slnx")))
|
||||
{
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
return directory ?? throw new InvalidOperationException("Could not find the repository root.");
|
||||
}
|
||||
|
||||
[AvaloniaTheory]
|
||||
[InlineData("Light")]
|
||||
[InlineData("Dark")]
|
||||
public void Every_resource_key_used_in_xaml_resolves(string theme)
|
||||
{
|
||||
var application = Application.Current.ShouldNotBeNull();
|
||||
application.RequestedThemeVariant = theme == "Light" ? ThemeVariant.Light : ThemeVariant.Dark;
|
||||
|
||||
var views = Path.Combine(RepositoryRoot().FullName, "src", "AvParser.UI");
|
||||
var files = Directory.GetFiles(views, "*.axaml", SearchOption.AllDirectories);
|
||||
|
||||
files.ShouldNotBeEmpty();
|
||||
|
||||
var missing = new SortedSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
foreach (Match match in ResourceMarkup().Matches(File.ReadAllText(file)))
|
||||
{
|
||||
var key = match.Groups[1].Value;
|
||||
|
||||
if (Local.Contains(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!application.TryGetResource(key, application.ActualThemeVariant, out _))
|
||||
{
|
||||
missing.Add($"{key} ({Path.GetFileName(file)})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
missing.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,7 @@ public class CollectViewModelTests
|
||||
proxyPool ?? new ProxyPool([], new FakeProxyProbe(), new ProxyOptions()),
|
||||
runner,
|
||||
store,
|
||||
new FakeThumbnailCache(),
|
||||
new EmptyServiceProvider(),
|
||||
NullLogger<CollectViewModel>.Instance,
|
||||
ImmediateSequencer.Instance
|
||||
|
||||
@@ -9,6 +9,16 @@ internal sealed class FakeMediaStore : IMediaStore
|
||||
|
||||
public MediaStoreStats Stats { get; set; } = new(0, 0, 0, 0);
|
||||
|
||||
public List<StoredMedia> Browse { get; } = [];
|
||||
|
||||
public List<string> SourceIds { get; } = [];
|
||||
|
||||
public MediaBrowseQuery? LastBrowse { get; private set; }
|
||||
|
||||
public int? TotalOverride { get; set; }
|
||||
|
||||
public bool BrowseThrows { get; set; }
|
||||
|
||||
public PurgeResult PurgeResult { get; set; } = new(3, 2, 4096);
|
||||
|
||||
public ShowcaseMode Mode { get; private set; } = ShowcaseMode.HardLink;
|
||||
@@ -61,4 +71,16 @@ internal sealed class FakeMediaStore : IMediaStore
|
||||
Task.FromResult(0);
|
||||
|
||||
public Task<MediaStoreStats> GetStatsAsync(CancellationToken cancellationToken = default) => Task.FromResult(Stats);
|
||||
|
||||
public Task<MediaPage> BrowseAsync(MediaBrowseQuery query, CancellationToken cancellationToken = default)
|
||||
{
|
||||
LastBrowse = query;
|
||||
|
||||
return BrowseThrows
|
||||
? throw new InvalidOperationException("the index is unreadable")
|
||||
: Task.FromResult(new MediaPage(Browse, TotalOverride ?? Browse.Count));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<string>> GetSourceIdsAsync(CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<IReadOnlyList<string>>(SourceIds);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using Avalonia.Media.Imaging;
|
||||
using AvParser.Core.Collecting;
|
||||
using AvParser.UI.Media;
|
||||
|
||||
namespace AvParser.UI.Tests.Fakes;
|
||||
|
||||
/// <summary>
|
||||
/// A cache that decodes nothing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Returning null is exactly what the real cache does for a video or a missing blob, so the view
|
||||
/// models are already required to cope with it — which is what makes this an honest stand-in
|
||||
/// rather than a convenient one. It records what was asked for, so tests can assert that a page
|
||||
/// requested previews at all.
|
||||
/// </remarks>
|
||||
internal sealed class FakeThumbnailCache : IThumbnailCache
|
||||
{
|
||||
public List<(string Sha256, int Width)> Requested { get; } = [];
|
||||
|
||||
public Task<Bitmap?> GetAsync(
|
||||
string sha256,
|
||||
string extension,
|
||||
MediaKind kind,
|
||||
int width,
|
||||
CancellationToken ct = default
|
||||
)
|
||||
{
|
||||
lock (Requested)
|
||||
{
|
||||
Requested.Add((sha256, width));
|
||||
}
|
||||
|
||||
return Task.FromResult<Bitmap?>(null);
|
||||
}
|
||||
|
||||
public void Clear() => Requested.Clear();
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
using AvParser.Core.Collecting;
|
||||
using AvParser.UI.Tests.Fakes;
|
||||
using AvParser.UI.ViewModels;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ReactiveUI.Primitives;
|
||||
using ReactiveUI.Primitives.Concurrency;
|
||||
|
||||
namespace AvParser.UI.Tests;
|
||||
|
||||
public class GalleryViewModelTests
|
||||
{
|
||||
private static StoredMedia Media(string url, MediaKind kind = MediaKind.Png, bool animated = false) =>
|
||||
new(
|
||||
Guid.NewGuid().ToString("N") + Guid.NewGuid().ToString("N"),
|
||||
kind,
|
||||
MediaKinds.ExtensionFor(kind),
|
||||
4096,
|
||||
"url-list",
|
||||
url,
|
||||
DateTimeOffset.UtcNow
|
||||
)
|
||||
{
|
||||
Width = 800,
|
||||
Height = 600,
|
||||
IsAnimated = animated,
|
||||
};
|
||||
|
||||
private static (GalleryViewModel Page, FakeMediaStore Store, FakeThumbnailCache Thumbnails) Build(
|
||||
params StoredMedia[] items
|
||||
)
|
||||
{
|
||||
var store = new FakeMediaStore();
|
||||
store.Browse.AddRange(items);
|
||||
store.SourceIds.Add("url-list");
|
||||
|
||||
var thumbnails = new FakeThumbnailCache();
|
||||
var page = new GalleryViewModel(
|
||||
store,
|
||||
thumbnails,
|
||||
NullLogger<GalleryViewModel>.Instance,
|
||||
ImmediateSequencer.Instance
|
||||
);
|
||||
|
||||
return (page, store, thumbnails);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task What_the_store_holds_becomes_tiles()
|
||||
{
|
||||
var (page, _, _) = Build(Media("https://a.test/1.png"), Media("https://a.test/2.gif", MediaKind.Gif));
|
||||
|
||||
await page.LoadAsync(0, TestContext.Current.CancellationToken);
|
||||
|
||||
page.Items.Count.ShouldBe(2);
|
||||
page.TotalCount.ShouldBe(2);
|
||||
page.StatusMessage.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Every_tile_asks_for_a_thumbnail()
|
||||
{
|
||||
var (page, _, thumbnails) = Build(Media("https://a.test/1.png"), Media("https://a.test/2.png"));
|
||||
|
||||
await page.LoadAsync(0, TestContext.Current.CancellationToken);
|
||||
|
||||
// By distinct hash rather than call count: the page also loads once on construction, and
|
||||
// what matters is that every tile got asked for, at the tile's own width.
|
||||
thumbnails.Requested.Select(r => r.Sha256).Distinct().Count().ShouldBe(2);
|
||||
thumbnails.Requested.ShouldAllBe(r => r.Width == GalleryItemViewModel.ThumbnailWidth);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_nothing_can_decode_is_flagged_rather_than_left_blank()
|
||||
{
|
||||
// The fake decodes nothing, which is exactly what the real cache does for a video.
|
||||
var (page, _, _) = Build(Media("https://a.test/clip.mp4", MediaKind.Mp4));
|
||||
|
||||
await page.LoadAsync(0, TestContext.Current.CancellationToken);
|
||||
|
||||
var tile = page.Items.ShouldHaveSingleItem();
|
||||
tile.Thumbnail.ShouldBeNull();
|
||||
tile.HasNoPreview.ShouldBeTrue();
|
||||
tile.KindText.ShouldBe("MP4");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_empty_store_says_so_instead_of_showing_a_blank_page()
|
||||
{
|
||||
var (page, _, _) = Build();
|
||||
|
||||
await page.LoadAsync(0, TestContext.Current.CancellationToken);
|
||||
|
||||
page.Items.ShouldBeEmpty();
|
||||
page.StatusMessage.ShouldNotBeNull().ShouldContain("Nothing here yet");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_filters_reach_the_query()
|
||||
{
|
||||
var (page, store, _) = Build(Media("https://a.test/1.png"));
|
||||
|
||||
page.SelectedSourceId = "url-list";
|
||||
page.AnimatedOnly = true;
|
||||
page.SearchText = "kitten";
|
||||
page.SelectedKinds = page.KindFilters.Single(option => option.Value == MediaKindFilter.Images);
|
||||
|
||||
await page.LoadAsync(0, TestContext.Current.CancellationToken);
|
||||
|
||||
var query = store.LastBrowse.ShouldNotBeNull();
|
||||
query.SourceId.ShouldBe("url-list");
|
||||
query.AnimatedOnly.ShouldBeTrue();
|
||||
query.Search.ShouldBe("kitten");
|
||||
query.Kinds.ShouldBe(MediaKindFilter.Images);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_unset_source_filter_means_all_of_them()
|
||||
{
|
||||
var (page, store, _) = Build(Media("https://a.test/1.png"));
|
||||
|
||||
page.SelectedSourceId = " ";
|
||||
await page.LoadAsync(0, TestContext.Current.CancellationToken);
|
||||
|
||||
store.LastBrowse.ShouldNotBeNull().SourceId.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Paging_moves_the_offset_and_stops_at_the_ends()
|
||||
{
|
||||
var (page, store, _) = Build(Media("https://a.test/1.png"));
|
||||
store.TotalOverride = 250;
|
||||
|
||||
await page.LoadAsync(0, TestContext.Current.CancellationToken);
|
||||
page.PageText.ShouldContain("1");
|
||||
|
||||
await page.LoadAsync(1, TestContext.Current.CancellationToken);
|
||||
store.LastBrowse!.Skip.ShouldBe(120);
|
||||
|
||||
// A negative page is a clamp, not a crash.
|
||||
await page.LoadAsync(-3, TestContext.Current.CancellationToken);
|
||||
page.PageIndex.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Opening_a_tile_opens_the_viewer_and_closing_it_clears_the_selection()
|
||||
{
|
||||
var (page, _, _) = Build(Media("https://a.test/1.png"));
|
||||
await page.LoadAsync(0, TestContext.Current.CancellationToken);
|
||||
|
||||
page.IsViewerOpen.ShouldBeFalse();
|
||||
|
||||
page.Selected = page.Items[0];
|
||||
page.IsViewerOpen.ShouldBeTrue();
|
||||
|
||||
await page.CloseViewerCommand.Execute().ToTask(TestContext.Current.CancellationToken);
|
||||
|
||||
page.Selected.ShouldBeNull();
|
||||
page.IsViewerOpen.ShouldBeFalse();
|
||||
page.Preview.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_store_that_throws_reports_it_rather_than_taking_the_page_down()
|
||||
{
|
||||
var (page, store, _) = Build(Media("https://a.test/1.png"));
|
||||
store.BrowseThrows = true;
|
||||
|
||||
await page.LoadAsync(0, TestContext.Current.CancellationToken);
|
||||
|
||||
page.StatusMessage.ShouldNotBeNull().ShouldContain("Could not read the store");
|
||||
page.IsLoading.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using AvParser.Core.Collecting;
|
||||
using AvParser.Infrastructure.Storage;
|
||||
using AvParser.UI.Media;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace AvParser.UI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The cache's refusals.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only the paths that answer before touching a decoder are covered here: decoding needs an
|
||||
/// Avalonia rendering platform, which this project deliberately does not start. The decode itself
|
||||
/// is one framework call; what is worth pinning is that the cache never reaches it for content it
|
||||
/// cannot handle, because doing so would mean an exception per tile.
|
||||
/// </remarks>
|
||||
public sealed class ThumbnailCacheTests : IDisposable
|
||||
{
|
||||
private readonly string _root = Path.Combine(Path.GetTempPath(), "AvParserTests", Guid.NewGuid().ToString("N"));
|
||||
private readonly ThumbnailCache _cache;
|
||||
|
||||
public ThumbnailCacheTests()
|
||||
{
|
||||
var paths = new AppPaths(_root);
|
||||
paths.EnsureCreated();
|
||||
|
||||
_cache = new ThumbnailCache(paths, NullLogger<ThumbnailCache>.Instance);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cache.Dispose();
|
||||
|
||||
if (Directory.Exists(_root))
|
||||
{
|
||||
Directory.Delete(_root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(MediaKind.Mp4)]
|
||||
[InlineData(MediaKind.WebM)]
|
||||
public async Task A_video_is_refused_without_looking_at_the_disk(MediaKind kind)
|
||||
{
|
||||
var result = await _cache.GetAsync(
|
||||
new string('a', 64),
|
||||
".mp4",
|
||||
kind,
|
||||
220,
|
||||
TestContext.Current.CancellationToken
|
||||
);
|
||||
|
||||
result.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_missing_blob_is_a_blank_tile_rather_than_a_throw()
|
||||
{
|
||||
var result = await _cache.GetAsync(
|
||||
new string('b', 64),
|
||||
".png",
|
||||
MediaKind.Png,
|
||||
220,
|
||||
TestContext.Current.CancellationToken
|
||||
);
|
||||
|
||||
result.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clearing_an_empty_cache_is_harmless() => _cache.Clear();
|
||||
|
||||
[Fact]
|
||||
public void The_capacity_leaves_room_for_more_than_one_screenful()
|
||||
{
|
||||
// The cache disposes what it evicts, so a capacity near the page size would dispose
|
||||
// bitmaps that are still on screen.
|
||||
ThumbnailCache.Capacity.ShouldBeGreaterThan(120 * 2);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user