From 3e8cc606c746a154b58265c067671d9a942fb189 Mon Sep 17 00:00:00 2001 From: keyldev Date: Fri, 19 Jun 2026 19:35:08 +0300 Subject: [PATCH 1/8] feat(snapshots): persistence foundation (phase 14.2) - Snapshot entity + SnapshotId/Source/Kind in Core - ISnapshotRepository + SqliteSnapshotRepository (Dapper, UTC ISO-8601) - migration 009_snapshots with (CameraId, TakenAt) index - DI registration in SharedComposition --- .../SharedComposition.cs | 2 + .../Snapshots/ISnapshotRepository.cs | 27 ++++ src/OpenIPC.Viewer.Core/Snapshots/Snapshot.cs | 22 ++++ .../Snapshots/SnapshotId.cs | 12 ++ .../Snapshots/SnapshotKind.cs | 15 +++ .../Snapshots/SnapshotSource.cs | 23 ++++ .../Persistence/Migrations/009_snapshots.sql | 18 +++ .../Persistence/SqliteSnapshotRepository.cs | 115 ++++++++++++++++++ 8 files changed, 234 insertions(+) create mode 100644 src/OpenIPC.Viewer.Core/Snapshots/ISnapshotRepository.cs create mode 100644 src/OpenIPC.Viewer.Core/Snapshots/Snapshot.cs create mode 100644 src/OpenIPC.Viewer.Core/Snapshots/SnapshotId.cs create mode 100644 src/OpenIPC.Viewer.Core/Snapshots/SnapshotKind.cs create mode 100644 src/OpenIPC.Viewer.Core/Snapshots/SnapshotSource.cs create mode 100644 src/OpenIPC.Viewer.Infrastructure/Persistence/Migrations/009_snapshots.sql create mode 100644 src/OpenIPC.Viewer.Infrastructure/Persistence/SqliteSnapshotRepository.cs diff --git a/src/OpenIPC.Viewer.Composition/SharedComposition.cs b/src/OpenIPC.Viewer.Composition/SharedComposition.cs index 0e2aa2b..d851528 100644 --- a/src/OpenIPC.Viewer.Composition/SharedComposition.cs +++ b/src/OpenIPC.Viewer.Composition/SharedComposition.cs @@ -10,6 +10,7 @@ using OpenIPC.Viewer.Core.Platform; using OpenIPC.Viewer.Core.Recording; using OpenIPC.Viewer.Core.Services; +using OpenIPC.Viewer.Core.Snapshots; using OpenIPC.Viewer.Core.Ssh; using OpenIPC.Viewer.Core.Video; using OpenIPC.Viewer.Devices.Majestic; @@ -39,6 +40,7 @@ public static IServiceCollection AddSharedServices(this IServiceCollection servi services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); // Domain services services.AddSingleton(); diff --git a/src/OpenIPC.Viewer.Core/Snapshots/ISnapshotRepository.cs b/src/OpenIPC.Viewer.Core/Snapshots/ISnapshotRepository.cs new file mode 100644 index 0000000..0991de0 --- /dev/null +++ b/src/OpenIPC.Viewer.Core/Snapshots/ISnapshotRepository.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using OpenIPC.Viewer.Core.Entities; + +namespace OpenIPC.Viewer.Core.Snapshots; + +public interface ISnapshotRepository +{ + /// + /// Newest-first. / + /// are an inclusive lower / exclusive upper bound on . + /// + Task> ListAsync( + CameraId? cameraId, + DateTime? sinceUtc, + DateTime? untilUtc, + int limit, + CancellationToken ct); + + Task AddAsync(Snapshot snapshot, CancellationToken ct); + + Task GetAsync(SnapshotId id, CancellationToken ct); + + Task RemoveAsync(SnapshotId id, CancellationToken ct); +} diff --git a/src/OpenIPC.Viewer.Core/Snapshots/Snapshot.cs b/src/OpenIPC.Viewer.Core/Snapshots/Snapshot.cs new file mode 100644 index 0000000..65b6ef1 --- /dev/null +++ b/src/OpenIPC.Viewer.Core/Snapshots/Snapshot.cs @@ -0,0 +1,22 @@ +using System; +using OpenIPC.Viewer.Core.Entities; + +namespace OpenIPC.Viewer.Core.Snapshots; + +/// +/// One indexed snapshot. is UTC (the UI converts to +/// local). points at the full-resolution JPEG; +/// at the cached gallery thumbnail (null if not yet +/// generated). / are the full-image +/// pixel dimensions, used to prove HD capture. +/// +public sealed record Snapshot( + SnapshotId Id, + CameraId CameraId, + DateTime TakenAt, + string Path, + string? ThumbPath, + int Width, + int Height, + SnapshotSource Source, + SnapshotKind Kind); diff --git a/src/OpenIPC.Viewer.Core/Snapshots/SnapshotId.cs b/src/OpenIPC.Viewer.Core/Snapshots/SnapshotId.cs new file mode 100644 index 0000000..4c7ee2b --- /dev/null +++ b/src/OpenIPC.Viewer.Core/Snapshots/SnapshotId.cs @@ -0,0 +1,12 @@ +using System; + +namespace OpenIPC.Viewer.Core.Snapshots; + +public readonly record struct SnapshotId(Guid Value) +{ + public static SnapshotId New() => new(Guid.NewGuid()); + + public static SnapshotId Parse(string text) => new(Guid.Parse(text)); + + public override string ToString() => Value.ToString("D"); +} diff --git a/src/OpenIPC.Viewer.Core/Snapshots/SnapshotKind.cs b/src/OpenIPC.Viewer.Core/Snapshots/SnapshotKind.cs new file mode 100644 index 0000000..6e628e6 --- /dev/null +++ b/src/OpenIPC.Viewer.Core/Snapshots/SnapshotKind.cs @@ -0,0 +1,15 @@ +namespace OpenIPC.Viewer.Core.Snapshots; + +/// +/// Distinguishes user-captured snapshots from images owned by other features +/// (e.g. Phase 7 event thumbnails). The Browser can surface both without the +/// Snapshots table duplicating that storage. +/// +public enum SnapshotKind +{ + /// Captured by the user via the snapshot button. + Manual = 0, + + /// An event thumbnail surfaced in the browser (not owned here). + Event = 1, +} diff --git a/src/OpenIPC.Viewer.Core/Snapshots/SnapshotSource.cs b/src/OpenIPC.Viewer.Core/Snapshots/SnapshotSource.cs new file mode 100644 index 0000000..866b82a --- /dev/null +++ b/src/OpenIPC.Viewer.Core/Snapshots/SnapshotSource.cs @@ -0,0 +1,23 @@ +namespace OpenIPC.Viewer.Core.Snapshots; + +/// +/// Where the snapshot pixels came from. Lets the UI/diagnostics reason about +/// quality — an HD-always snapshot should never report . +/// +public enum SnapshotSource +{ + /// Grabbed from an already-running mainstream session (HD). + LiveMain = 0, + + /// Majestic HTTP /image.jpg — always full-resolution. + HttpSnapshot = 1, + + /// Mainstream was opened briefly just to grab a keyframe (HD). + OpenedStream = 2, + + /// Grabbed from a running substream session (SD) — fallback only. + LiveSub = 3, + + /// Produced by the in-app editor (a saved copy). + Edited = 4, +} diff --git a/src/OpenIPC.Viewer.Infrastructure/Persistence/Migrations/009_snapshots.sql b/src/OpenIPC.Viewer.Infrastructure/Persistence/Migrations/009_snapshots.sql new file mode 100644 index 0000000..65a7db9 --- /dev/null +++ b/src/OpenIPC.Viewer.Infrastructure/Persistence/Migrations/009_snapshots.sql @@ -0,0 +1,18 @@ +-- Phase 14 — snapshot index. One row per captured still. +-- TakenAt stores UTC ISO-8601; the UI converts to local for the timeline. +-- Width/Height are the full-image pixel dimensions, so the browser can prove +-- an HD-always capture. Source/Kind are the SnapshotSource/SnapshotKind enums. +-- ThumbPath may be NULL until the gallery thumbnail has been generated. +CREATE TABLE Snapshots ( + Id TEXT NOT NULL PRIMARY KEY, + CameraId TEXT NOT NULL REFERENCES Cameras(Id) ON DELETE CASCADE, + TakenAt TEXT NOT NULL, + Path TEXT NOT NULL, + ThumbPath TEXT, + Width INTEGER NOT NULL, + Height INTEGER NOT NULL, + Source INTEGER NOT NULL, + Kind INTEGER NOT NULL +); +CREATE INDEX Idx_Snapshots_Camera_Taken ON Snapshots(CameraId, TakenAt DESC); +CREATE INDEX Idx_Snapshots_Taken ON Snapshots(TakenAt DESC); diff --git a/src/OpenIPC.Viewer.Infrastructure/Persistence/SqliteSnapshotRepository.cs b/src/OpenIPC.Viewer.Infrastructure/Persistence/SqliteSnapshotRepository.cs new file mode 100644 index 0000000..23663ee --- /dev/null +++ b/src/OpenIPC.Viewer.Infrastructure/Persistence/SqliteSnapshotRepository.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Dapper; +using OpenIPC.Viewer.Core.Entities; +using OpenIPC.Viewer.Core.Snapshots; + +namespace OpenIPC.Viewer.Infrastructure.Persistence; + +public sealed class SqliteSnapshotRepository : ISnapshotRepository +{ + private readonly IDbConnectionFactory _factory; + + public SqliteSnapshotRepository(IDbConnectionFactory factory) + { + _factory = factory; + } + + public async Task> ListAsync( + CameraId? cameraId, DateTime? sinceUtc, DateTime? untilUtc, int limit, CancellationToken ct) + { + var sql = new StringBuilder("SELECT * FROM Snapshots WHERE 1=1"); + var p = new DynamicParameters(); + if (cameraId is { } cid) + { + sql.Append(" AND CameraId = @cid"); + p.Add("cid", cid.ToString()); + } + if (sinceUtc is { } s) + { + sql.Append(" AND TakenAt >= @since"); + p.Add("since", s.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture)); + } + if (untilUtc is { } u) + { + sql.Append(" AND TakenAt < @until"); + p.Add("until", u.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture)); + } + sql.Append(" ORDER BY TakenAt DESC LIMIT @limit;"); + p.Add("limit", limit); + + await using var conn = _factory.OpenConnection(); + var rows = await conn.QueryAsync(sql.ToString(), p).ConfigureAwait(false); + return rows.Select(Map).ToList(); + } + + public async Task AddAsync(Snapshot snapshot, CancellationToken ct) + { + await using var conn = _factory.OpenConnection(); + await conn.ExecuteAsync( + """ + INSERT INTO Snapshots (Id, CameraId, TakenAt, Path, ThumbPath, Width, Height, Source, Kind) + VALUES (@Id, @CameraId, @TakenAt, @Path, @ThumbPath, @Width, @Height, @Source, @Kind); + """, + ToRow(snapshot)).ConfigureAwait(false); + } + + public async Task GetAsync(SnapshotId id, CancellationToken ct) + { + await using var conn = _factory.OpenConnection(); + var row = await conn.QuerySingleOrDefaultAsync( + "SELECT * FROM Snapshots WHERE Id = @id;", + new { id = id.ToString() }).ConfigureAwait(false); + return row is null ? null : Map(row); + } + + public async Task RemoveAsync(SnapshotId id, CancellationToken ct) + { + await using var conn = _factory.OpenConnection(); + await conn.ExecuteAsync( + "DELETE FROM Snapshots WHERE Id = @id;", + new { id = id.ToString() }).ConfigureAwait(false); + } + + private static Snapshot Map(Row r) => new( + Id: SnapshotId.Parse(r.Id), + CameraId: CameraId.Parse(r.CameraId), + TakenAt: DateTime.Parse(r.TakenAt, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind), + Path: r.Path, + ThumbPath: r.ThumbPath, + Width: r.Width, + Height: r.Height, + Source: (SnapshotSource)r.Source, + Kind: (SnapshotKind)r.Kind); + + private static object ToRow(Snapshot s) => new + { + Id = s.Id.ToString(), + CameraId = s.CameraId.ToString(), + TakenAt = s.TakenAt.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture), + s.Path, + s.ThumbPath, + s.Width, + s.Height, + Source = (int)s.Source, + Kind = (int)s.Kind, + }; + + private sealed class Row + { + public string Id { get; init; } = default!; + public string CameraId { get; init; } = default!; + public string TakenAt { get; init; } = default!; + public string Path { get; init; } = default!; + public string? ThumbPath { get; init; } + public int Width { get; init; } + public int Height { get; init; } + public int Source { get; init; } + public int Kind { get; init; } + } +} From 20b95136390fd4813871031e276fdf110e2843ab Mon Sep 17 00:00:00 2001 From: keyldev Date: Fri, 19 Jun 2026 19:50:43 +0300 Subject: [PATCH 2/8] feat(snapshots): HD-always capture service (phase 14.1) - SnapshotService in Core picks the HD source by priority: live mainstream -> Majestic HTTP -> brief mainstream open -> SD fallback - thumbnails via SkiaThumbnailGenerator (Video); indexes each capture - ICameraCredentialsProvider seam keeps the service unit-testable - snapshot button on grid tiles + reuse in single-camera page - IconCamera/IconImage lucide glyphs --- src/OpenIPC.Viewer.App/Services/Localizer.cs | 2 + .../Services/SingleCameraPageFactory.cs | 10 +- src/OpenIPC.Viewer.App/Themes/Theme.axaml | 4 + .../ViewModels/CameraTileViewModel.cs | 25 +++ .../ViewModels/GridPageViewModel.cs | 8 +- .../ViewModels/SingleCameraPageViewModel.cs | 48 +--- .../Views/Pages/GridPage.axaml | 16 ++ .../SharedComposition.cs | 5 + .../Services/CameraDirectoryService.cs | 2 +- .../Services/ICameraCredentialsProvider.cs | 16 ++ .../Snapshots/ISnapshotService.cs | 27 +++ .../Snapshots/IThumbnailGenerator.cs | 22 ++ .../Snapshots/SnapshotService.cs | 208 ++++++++++++++++++ .../Imaging/SkiaThumbnailGenerator.cs | 44 ++++ 14 files changed, 392 insertions(+), 45 deletions(-) create mode 100644 src/OpenIPC.Viewer.Core/Services/ICameraCredentialsProvider.cs create mode 100644 src/OpenIPC.Viewer.Core/Snapshots/ISnapshotService.cs create mode 100644 src/OpenIPC.Viewer.Core/Snapshots/IThumbnailGenerator.cs create mode 100644 src/OpenIPC.Viewer.Core/Snapshots/SnapshotService.cs create mode 100644 src/OpenIPC.Viewer.Video/Imaging/SkiaThumbnailGenerator.cs diff --git a/src/OpenIPC.Viewer.App/Services/Localizer.cs b/src/OpenIPC.Viewer.App/Services/Localizer.cs index b187c29..f6adbc0 100644 --- a/src/OpenIPC.Viewer.App/Services/Localizer.cs +++ b/src/OpenIPC.Viewer.App/Services/Localizer.cs @@ -291,6 +291,7 @@ private static LangCode DetectSystem() ["Snapshot.SaveAsTitle"] = "Save snapshot", ["Grid.EmptySlot"] = "no camera", + ["Tile.Snapshot"] = "Snapshot (HD)", ["Welcome.Language"] = "Language", @@ -543,6 +544,7 @@ private static LangCode DetectSystem() ["Snapshot.SaveAsTitle"] = "Сохранить снимок", ["Grid.EmptySlot"] = "нет камеры", + ["Tile.Snapshot"] = "Снимок (HD)", ["Welcome.Language"] = "Язык", diff --git a/src/OpenIPC.Viewer.App/Services/SingleCameraPageFactory.cs b/src/OpenIPC.Viewer.App/Services/SingleCameraPageFactory.cs index 2c97d52..772f5a2 100644 --- a/src/OpenIPC.Viewer.App/Services/SingleCameraPageFactory.cs +++ b/src/OpenIPC.Viewer.App/Services/SingleCameraPageFactory.cs @@ -3,9 +3,9 @@ using OpenIPC.Viewer.Core.Entities; using OpenIPC.Viewer.Core.Majestic; using OpenIPC.Viewer.Core.Onvif; -using OpenIPC.Viewer.Core.Platform; using OpenIPC.Viewer.Core.Recording; using OpenIPC.Viewer.Core.Services; +using OpenIPC.Viewer.Core.Snapshots; using OpenIPC.Viewer.Core.Video; namespace OpenIPC.Viewer.App.Services; @@ -18,9 +18,9 @@ public sealed class SingleCameraPageFactory private readonly IMajesticClient _majestic; private readonly IMajesticSshConfigClient _majesticSsh; private readonly RecordingService _recordings; - private readonly IFileSystem _fs; private readonly UserSettingsService _userSettings; private readonly IDialogService _dialogs; + private readonly ISnapshotService _snapshots; private readonly ILoggerFactory _loggerFactory; public SingleCameraPageFactory( @@ -30,9 +30,9 @@ public SingleCameraPageFactory( IMajesticClient majestic, IMajesticSshConfigClient majesticSsh, RecordingService recordings, - IFileSystem fs, UserSettingsService userSettings, IDialogService dialogs, + ISnapshotService snapshots, ILoggerFactory loggerFactory) { _coordinator = coordinator; @@ -41,12 +41,12 @@ public SingleCameraPageFactory( _majestic = majestic; _majesticSsh = majesticSsh; _recordings = recordings; - _fs = fs; _userSettings = userSettings; _dialogs = dialogs; + _snapshots = snapshots; _loggerFactory = loggerFactory; } public SingleCameraPageViewModel Create(Camera camera) => - new(camera, _coordinator, _directory, _onvif, _majestic, _majesticSsh, _recordings, _fs, _userSettings, _dialogs, _loggerFactory.CreateLogger()); + new(camera, _coordinator, _directory, _onvif, _majestic, _majesticSsh, _recordings, _userSettings, _dialogs, _snapshots, _loggerFactory.CreateLogger()); } diff --git a/src/OpenIPC.Viewer.App/Themes/Theme.axaml b/src/OpenIPC.Viewer.App/Themes/Theme.axaml index 77a0385..03f8eb7 100644 --- a/src/OpenIPC.Viewer.App/Themes/Theme.axaml +++ b/src/OpenIPC.Viewer.App/Themes/Theme.axaml @@ -82,5 +82,9 @@ M4,17 L10,11 L4,5 M12,19 H20 M19,5 L22,2 M2,22 L5,19 M6.3,20.3 a2.4,2.4 0 0 0 3.4,0 L12,18 L6,12 L3.7,14.3 a2.4,2.4 0 0 0 0,3.4 Z M7.5,13.5 L10,11 M10.5,16.5 L13,14 M12,6 L18,12 L20.3,9.7 a2.4,2.4 0 0 0 0,-3.4 L17.7,3.7 a2.4,2.4 0 0 0 -3.4,0 Z + + M14.5,4 H9.5 L7,7 H4 A2,2 0 0 0 2,9 V18 A2,2 0 0 0 4,20 H20 A2,2 0 0 0 22,18 V9 A2,2 0 0 0 20,7 H17 L14.5,4 Z M15,13 A3,3 0 1 1 9,13 A3,3 0 1 1 15,13 Z + + M5,3 H19 A2,2 0 0 1 21,5 V19 A2,2 0 0 1 19,21 H5 A2,2 0 0 1 3,19 V5 A2,2 0 0 1 5,3 Z M10,9 A1.5,1.5 0 1 1 7,9 A1.5,1.5 0 1 1 10,9 Z M21,15 L16,10 L5,21 diff --git a/src/OpenIPC.Viewer.App/ViewModels/CameraTileViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/CameraTileViewModel.cs index d3813b8..5d9e01d 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/CameraTileViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/CameraTileViewModel.cs @@ -11,6 +11,7 @@ using OpenIPC.Viewer.App.Services; using OpenIPC.Viewer.Core.Entities; using OpenIPC.Viewer.Core.Services; +using OpenIPC.Viewer.Core.Snapshots; using OpenIPC.Viewer.Core.Video; namespace OpenIPC.Viewer.App.ViewModels; @@ -20,6 +21,7 @@ public sealed partial class CameraTileViewModel : ViewModelBase, IAsyncDisposabl private readonly LiveStreamCoordinator _coordinator; private readonly CameraDirectoryService _directory; private readonly UserSettingsService _userSettings; + private readonly ISnapshotService _snapshots; private readonly ILogger _logger; // Auto SD/HD (Phase 12.2): substream in the grid, mainstream when a single @@ -113,12 +115,14 @@ public CameraTileViewModel( LiveStreamCoordinator coordinator, CameraDirectoryService directory, UserSettingsService userSettings, + ISnapshotService snapshots, ILogger logger) { Camera = camera; _coordinator = coordinator; _directory = directory; _userSettings = userSettings; + _snapshots = snapshots; _logger = logger; _coordinator.Invalidated += OnCoordinatorInvalidated; @@ -227,6 +231,27 @@ private void OnCoordinatorInvalidated(object? sender, EventArgs e) private void OpenSingle() => WeakReferenceMessenger.Default.Send(new OpenCameraMessage(Camera.Id)); + // Brief "captured" confirmation flashed over the tile after a snapshot. The + // tile streams the substream, but SnapshotService still grabs an HD source + // (Majestic HTTP or a brief mainstream open) — never the SD frame on screen. + [ObservableProperty] private bool _snapshotFlash; + + [RelayCommand] + private async Task SnapshotAsync() + { + try + { + await _snapshots.CaptureAsync(Camera, Session, _quality, CancellationToken.None).ConfigureAwait(true); + SnapshotFlash = true; + await Task.Delay(900).ConfigureAwait(true); + SnapshotFlash = false; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Tile snapshot failed for {Camera}", Camera.Name); + } + } + // Retry button on the error cell. Mirrors OnCoordinatorInvalidated: drop the // dead session, reset state, and re-run the full Activate path. [RelayCommand] diff --git a/src/OpenIPC.Viewer.App/ViewModels/GridPageViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/GridPageViewModel.cs index 145e198..b3fcfd2 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/GridPageViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/GridPageViewModel.cs @@ -12,6 +12,7 @@ using OpenIPC.Viewer.App.Services; using OpenIPC.Viewer.Core.Entities; using OpenIPC.Viewer.Core.Services; +using OpenIPC.Viewer.Core.Snapshots; using OpenIPC.Viewer.Core.Video; namespace OpenIPC.Viewer.App.ViewModels; @@ -25,6 +26,7 @@ public sealed partial class GridPageViewModel : ViewModelBase, private readonly CameraDirectoryService _directory; private readonly LiveStreamCoordinator _coordinator; private readonly UserSettingsService _userSettings; + private readonly ISnapshotService _snapshots; private readonly ILoggerFactory _loggerFactory; private readonly ILogger _logger; @@ -49,11 +51,13 @@ public GridPageViewModel( CameraDirectoryService directory, LiveStreamCoordinator coordinator, UserSettingsService userSettings, + ISnapshotService snapshots, ILoggerFactory loggerFactory) { _directory = directory; _coordinator = coordinator; _userSettings = userSettings; + _snapshots = snapshots; _loggerFactory = loggerFactory; _logger = loggerFactory.CreateLogger(); @@ -136,7 +140,7 @@ private async Task RefreshTilesAsync(CancellationToken ct) try { await existing.DisposeAsync().ConfigureAwait(true); } catch (Exception ex) { _logger.LogWarning(ex, "Error releasing stale tile for {Camera}", camera.Name); } - var rebuilt = new CameraTileViewModel(camera, _coordinator, _directory, _userSettings, _loggerFactory.CreateLogger()); + var rebuilt = new CameraTileViewModel(camera, _coordinator, _directory, _userSettings, _snapshots, _loggerFactory.CreateLogger()); rebuilt.SetInitialQuality(quality); Tiles.Insert(idx, rebuilt); try { await rebuilt.ActivateAsync(ct).ConfigureAwait(true); } @@ -144,7 +148,7 @@ private async Task RefreshTilesAsync(CancellationToken ct) continue; } - var tile = new CameraTileViewModel(camera, _coordinator, _directory, _userSettings, _loggerFactory.CreateLogger()); + var tile = new CameraTileViewModel(camera, _coordinator, _directory, _userSettings, _snapshots, _loggerFactory.CreateLogger()); tile.SetInitialQuality(quality); Tiles.Add(tile); try { await tile.ActivateAsync(ct).ConfigureAwait(true); } diff --git a/src/OpenIPC.Viewer.App/ViewModels/SingleCameraPageViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/SingleCameraPageViewModel.cs index 3c1c220..4dc1a96 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/SingleCameraPageViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/SingleCameraPageViewModel.cs @@ -13,9 +13,9 @@ using OpenIPC.Viewer.Core.Entities; using OpenIPC.Viewer.Core.Majestic; using OpenIPC.Viewer.Core.Onvif; -using OpenIPC.Viewer.Core.Platform; using OpenIPC.Viewer.Core.Recording; using OpenIPC.Viewer.Core.Services; +using OpenIPC.Viewer.Core.Snapshots; using OpenIPC.Viewer.Core.Video; using Avalonia.Threading; @@ -29,9 +29,9 @@ public sealed partial class SingleCameraPageViewModel : ViewModelBase, IAsyncDis private readonly IMajesticClient _majestic; private readonly IMajesticSshConfigClient _majesticSsh; private readonly RecordingService _recordings; - private readonly IFileSystem _fs; private readonly UserSettingsService _userSettings; private readonly IDialogService _dialogs; + private readonly ISnapshotService _snapshots; private readonly ILogger _logger; private Camera _camera; private DispatcherTimer? _recTimer; @@ -144,9 +144,9 @@ public SingleCameraPageViewModel( IMajesticClient majestic, IMajesticSshConfigClient majesticSsh, RecordingService recordings, - IFileSystem fs, UserSettingsService userSettings, IDialogService dialogs, + ISnapshotService snapshots, ILogger logger) { _camera = camera; @@ -156,9 +156,9 @@ public SingleCameraPageViewModel( _majestic = majestic; _majesticSsh = majesticSsh; _recordings = recordings; - _fs = fs; _userSettings = userSettings; _dialogs = dialogs; + _snapshots = snapshots; _logger = logger; IsRecording = _recordings.IsRecording(_camera.Id); @@ -668,27 +668,13 @@ private async Task SnapshotAsync() { try { - byte[] bytes; - if (IsMajestic) - { - // /image.jpg is ~50–100ms vs ~hundreds of ms decoding a video frame. - var creds = await _directory.GetCredentialsAsync(_camera.Id, CancellationToken.None).ConfigureAwait(true); - var endpoint = new MajesticEndpoint(_camera.Host, _camera.HttpPort, creds); - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - bytes = await _majestic.SnapshotJpegAsync(endpoint, new MajesticSnapshotOptions(), cts.Token).ConfigureAwait(true); - } - else if (Session is not null) - { - bytes = await Session.SnapshotAsync(SnapshotFormat.Jpeg, CancellationToken.None).ConfigureAwait(true); - } - else return; - - var dir = Path.Combine(_fs.SnapshotsDir.FullName, SafeFileName(_camera.Name)); - Directory.CreateDirectory(dir); - var path = Path.Combine(dir, $"{DateTime.Now:yyyy-MM-dd-HHmmss}.jpg"); - await File.WriteAllBytesAsync(path, bytes).ConfigureAwait(true); - SnapshotPath = path; - _logger.LogInformation("Snapshot saved {Path}", path); + // The shared service picks the HD source (live mainstream → Majestic + // HTTP → brief mainstream open) and indexes the result; we just hand + // it our live session so an already-decoded frame is grabbed for free. + var snapshot = await _snapshots + .CaptureAsync(_camera, Session, _quality, CancellationToken.None) + .ConfigureAwait(true); + SnapshotPath = snapshot.Path; } catch (Exception ex) { @@ -793,18 +779,6 @@ public async ValueTask DisposeAsync() // explicitly stops it (or app exits, see RecordingService.DisposeAsync). } - private static string SafeFileName(string name) - { - var invalid = Path.GetInvalidFileNameChars(); - var chars = name.ToCharArray(); - for (var i = 0; i < chars.Length; i++) - { - if (Array.IndexOf(invalid, chars[i]) >= 0) - chars[i] = '_'; - } - return new string(chars); - } - private static RtspTransport ParseTransport(string? s) => s?.ToLowerInvariant() switch { "udp" => RtspTransport.Udp, diff --git a/src/OpenIPC.Viewer.App/Views/Pages/GridPage.axaml b/src/OpenIPC.Viewer.App/Views/Pages/GridPage.axaml index ad4483b..4ee99ef 100644 --- a/src/OpenIPC.Viewer.App/Views/Pages/GridPage.axaml +++ b/src/OpenIPC.Viewer.App/Views/Pages/GridPage.axaml @@ -97,6 +97,22 @@ FontFamily="{StaticResource FontMono}" FontSize="10" Foreground="White" /> + + + + + + diff --git a/src/OpenIPC.Viewer.Composition/SharedComposition.cs b/src/OpenIPC.Viewer.Composition/SharedComposition.cs index d851528..baeef8c 100644 --- a/src/OpenIPC.Viewer.Composition/SharedComposition.cs +++ b/src/OpenIPC.Viewer.Composition/SharedComposition.cs @@ -44,8 +44,13 @@ public static IServiceCollection AddSharedServices(this IServiceCollection servi // Domain services services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(); + // Snapshots (Phase 14): HD-always capture + thumbnail generation. + services.AddSingleton(); + services.AddSingleton(); + // Video services.AddSingleton(); services.AddSingleton(); diff --git a/src/OpenIPC.Viewer.Core/Services/CameraDirectoryService.cs b/src/OpenIPC.Viewer.Core/Services/CameraDirectoryService.cs index e4d7edd..f15ce38 100644 --- a/src/OpenIPC.Viewer.Core/Services/CameraDirectoryService.cs +++ b/src/OpenIPC.Viewer.Core/Services/CameraDirectoryService.cs @@ -8,7 +8,7 @@ namespace OpenIPC.Viewer.Core.Services; -public sealed class CameraDirectoryService +public sealed class CameraDirectoryService : ICameraCredentialsProvider { private readonly ICameraRepository _cameras; private readonly IGroupRepository _groups; diff --git a/src/OpenIPC.Viewer.Core/Services/ICameraCredentialsProvider.cs b/src/OpenIPC.Viewer.Core/Services/ICameraCredentialsProvider.cs new file mode 100644 index 0000000..81b6193 --- /dev/null +++ b/src/OpenIPC.Viewer.Core/Services/ICameraCredentialsProvider.cs @@ -0,0 +1,16 @@ +using System.Threading; +using System.Threading.Tasks; +using OpenIPC.Viewer.Core.Entities; + +namespace OpenIPC.Viewer.Core.Services; + +/// +/// Narrow seam over so credential-needing +/// Core services (e.g. SnapshotService) can resolve a camera's login +/// without taking a dependency on the whole directory/secrets stack — and can +/// be unit-tested with a trivial fake. +/// +public interface ICameraCredentialsProvider +{ + Task GetCredentialsAsync(CameraId id, CancellationToken ct); +} diff --git a/src/OpenIPC.Viewer.Core/Snapshots/ISnapshotService.cs b/src/OpenIPC.Viewer.Core/Snapshots/ISnapshotService.cs new file mode 100644 index 0000000..fa7e64c --- /dev/null +++ b/src/OpenIPC.Viewer.Core/Snapshots/ISnapshotService.cs @@ -0,0 +1,27 @@ +using System.Threading; +using System.Threading.Tasks; +using OpenIPC.Viewer.Core.Entities; +using OpenIPC.Viewer.Core.Video; + +namespace OpenIPC.Viewer.Core.Snapshots; + +/// +/// Captures a still and indexes it. Always prefers an HD source: an +/// already-running mainstream session, else the Majestic HTTP snapshot, else a +/// briefly-opened mainstream — only falling back to a running substream when +/// none of those are available. See . +/// +public interface ISnapshotService +{ + /// + /// Captures, saves, thumbnails and DB-indexes a snapshot for + /// . / + /// describe the caller's currently-open + /// session (if any) so a live mainstream frame can be grabbed for free. + /// + Task CaptureAsync( + Camera camera, + IVideoSession? liveSession, + StreamQuality? liveQuality, + CancellationToken ct); +} diff --git a/src/OpenIPC.Viewer.Core/Snapshots/IThumbnailGenerator.cs b/src/OpenIPC.Viewer.Core/Snapshots/IThumbnailGenerator.cs new file mode 100644 index 0000000..575e357 --- /dev/null +++ b/src/OpenIPC.Viewer.Core/Snapshots/IThumbnailGenerator.cs @@ -0,0 +1,22 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace OpenIPC.Viewer.Core.Snapshots; + +/// Full-image pixel dimensions. +public readonly record struct ImageSize(int Width, int Height); + +/// +/// Decodes a captured JPEG to produce a cached gallery thumbnail. Lives behind +/// an interface so the codec (SkiaSharp) stays out of Core — the impl ships in +/// the Video project where Skia already lives. +/// +public interface IThumbnailGenerator +{ + /// + /// Decodes , writes a downscaled JPEG thumbnail + /// (longest side ≤ ) to , + /// and returns the full image's pixel size. Throws if the bytes don't decode. + /// + Task GenerateAsync(byte[] jpeg, string thumbPath, int maxDim, CancellationToken ct); +} diff --git a/src/OpenIPC.Viewer.Core/Snapshots/SnapshotService.cs b/src/OpenIPC.Viewer.Core/Snapshots/SnapshotService.cs new file mode 100644 index 0000000..cc252be --- /dev/null +++ b/src/OpenIPC.Viewer.Core/Snapshots/SnapshotService.cs @@ -0,0 +1,208 @@ +using System; +using System.Globalization; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using OpenIPC.Viewer.Core.Entities; +using OpenIPC.Viewer.Core.Majestic; +using OpenIPC.Viewer.Core.Platform; +using OpenIPC.Viewer.Core.Services; +using OpenIPC.Viewer.Core.Video; + +namespace OpenIPC.Viewer.Core.Snapshots; + +/// +/// Phase 14.1 — the one place that captures a still. Centralises the HD-source +/// priority so the grid tile, the single-camera page and any future caller all +/// behave identically and never persist an SD frame when an HD source exists. +/// +public sealed class SnapshotService : ISnapshotService +{ + // Gallery thumbnails: longest side. Big enough to look crisp on a HiDPI + // tile, small enough that thousands decode cheaply. + private const int ThumbMaxDim = 320; + + private static readonly TimeSpan MajesticTimeout = TimeSpan.FromSeconds(5); + private static readonly TimeSpan FreshOpenFrameTimeout = TimeSpan.FromSeconds(8); + + private readonly IMajesticClient _majestic; + private readonly LiveStreamCoordinator _coordinator; + private readonly ICameraCredentialsProvider _credentials; + private readonly ISnapshotRepository _repo; + private readonly IFileSystem _fs; + private readonly IThumbnailGenerator _thumbs; + + public SnapshotService( + IMajesticClient majestic, + LiveStreamCoordinator coordinator, + ICameraCredentialsProvider credentials, + ISnapshotRepository repo, + IFileSystem fs, + IThumbnailGenerator thumbs) + { + _majestic = majestic; + _coordinator = coordinator; + _credentials = credentials; + _repo = repo; + _fs = fs; + _thumbs = thumbs; + } + + public async Task CaptureAsync( + Camera camera, IVideoSession? liveSession, StreamQuality? liveQuality, CancellationToken ct) + { + var (jpeg, source) = await GrabAsync(camera, liveSession, liveQuality, ct).ConfigureAwait(false); + return await SaveAsync(camera, jpeg, source, SnapshotKind.Manual, ct).ConfigureAwait(false); + } + + private async Task<(byte[] Jpeg, SnapshotSource Source)> GrabAsync( + Camera camera, IVideoSession? liveSession, StreamQuality? liveQuality, CancellationToken ct) + { + // 1. Already-running mainstream — grab the last decoded frame for free. + if (liveSession is not null && liveQuality == StreamQuality.Main) + { + var b = await TryGrabAsync(liveSession, ct).ConfigureAwait(false); + if (b is not null) return (b, SnapshotSource.LiveMain); + } + + // 2. Majestic HTTP /image.jpg — always full-resolution, ~50–100 ms, and + // far cheaper than opening a fresh RTSP session. + if (camera.IsMajestic) + { + try + { + var creds = await _credentials.GetCredentialsAsync(camera.Id, ct).ConfigureAwait(false); + var endpoint = new MajesticEndpoint(camera.Host, camera.HttpPort, creds); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(MajesticTimeout); + var b = await _majestic.SnapshotJpegAsync(endpoint, new MajesticSnapshotOptions(), cts.Token) + .ConfigureAwait(false); + if (b.Length > 0) return (b, SnapshotSource.HttpSnapshot); + } + catch (Exception) + { + // Majestic unreachable / returned a non-image — fall through. + } + } + + // 3. Briefly open the mainstream, grab a keyframe, release. + try + { + var b = await GrabFromFreshMainAsync(camera, ct).ConfigureAwait(false); + if (b is not null) return (b, SnapshotSource.OpenedStream); + } + catch (Exception) + { + // Couldn't open the mainstream — fall through to the SD fallback. + } + + // 4. Last resort: a running substream (SD) is better than no snapshot. + if (liveSession is not null && liveQuality == StreamQuality.Sub) + { + var b = await TryGrabAsync(liveSession, ct).ConfigureAwait(false); + if (b is not null) return (b, SnapshotSource.LiveSub); + } + + throw new InvalidOperationException($"No snapshot source available for camera {camera.Id}"); + } + + private async Task TryGrabAsync(IVideoSession session, CancellationToken ct) + { + try + { + var b = await session.SnapshotAsync(SnapshotFormat.Jpeg, ct).ConfigureAwait(false); + return b.Length > 0 ? b : null; + } + catch (Exception) + { + // Most commonly "No frame available yet" — let the caller fall through. + return null; + } + } + + private async Task GrabFromFreshMainAsync(Camera camera, CancellationToken ct) + { + var creds = await _credentials.GetCredentialsAsync(camera.Id, ct).ConfigureAwait(false); + // No auto-reconnect: this is a one-shot grab, we don't want a lingering + // reconnect loop after we release. + var options = VideoSessionOptions.Default(camera.RtspMainUri, creds) with { AutoReconnect = false }; + + // Acquire is ref-counted: if a mainstream is already up (another viewer), + // we share it and grab immediately; otherwise we own a fresh one. + var session = _coordinator.Acquire(camera.Id, StreamQuality.Main, options); + try + { + if (session.State == SessionState.Idle) + await session.StartAsync(ct).ConfigureAwait(false); + + // Poll until the first keyframe decodes (SnapshotAsync throws until + // then). Avoids an Rx dependency in Core just to await one frame. + var deadline = DateTime.UtcNow + FreshOpenFrameTimeout; + while (DateTime.UtcNow < deadline) + { + var b = await TryGrabAsync(session, ct).ConfigureAwait(false); + if (b is not null) return b; + await Task.Delay(150, ct).ConfigureAwait(false); + } + return null; + } + finally + { + await _coordinator.ReleaseAsync(camera.Id, StreamQuality.Main).ConfigureAwait(false); + } + } + + private async Task SaveAsync( + Camera camera, byte[] jpeg, SnapshotSource source, SnapshotKind kind, CancellationToken ct) + { + var id = SnapshotId.New(); + var takenAt = DateTime.UtcNow; + + var dir = Path.Combine(_fs.SnapshotsDir.FullName, camera.Id.ToString()); + Directory.CreateDirectory(dir); + var fileName = takenAt.ToLocalTime().ToString("yyyy-MM-dd_HH-mm-ss", CultureInfo.InvariantCulture) + ".jpg"; + var path = EnsureUnique(Path.Combine(dir, fileName)); + await WriteAllBytesAsync(path, jpeg, ct).ConfigureAwait(false); + + var thumbDir = Path.Combine(_fs.SnapshotsDir.FullName, ".thumbs"); + Directory.CreateDirectory(thumbDir); + var thumbPath = Path.Combine(thumbDir, id.ToString() + ".jpg"); + + ImageSize size = default; + string? savedThumb = thumbPath; + try + { + size = await _thumbs.GenerateAsync(jpeg, thumbPath, ThumbMaxDim, ct).ConfigureAwait(false); + } + catch (Exception) + { + // A missing thumbnail just means the gallery decodes the full image; + // never let it sink the capture. + savedThumb = null; + } + + var snapshot = new Snapshot(id, camera.Id, takenAt, path, savedThumb, size.Width, size.Height, source, kind); + await _repo.AddAsync(snapshot, ct).ConfigureAwait(false); + return snapshot; + } + + private static string EnsureUnique(string path) + { + if (!File.Exists(path)) return path; + var dir = Path.GetDirectoryName(path)!; + var stem = Path.GetFileNameWithoutExtension(path); + var ext = Path.GetExtension(path); + for (var i = 2; ; i++) + { + var candidate = Path.Combine(dir, $"{stem}_{i}{ext}"); + if (!File.Exists(candidate)) return candidate; + } + } + + private static async Task WriteAllBytesAsync(string path, byte[] bytes, CancellationToken ct) + { + // File.WriteAllBytesAsync isn't on netstandard2.1; stream it instead. + using var fs = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, 4096, useAsync: true); + await fs.WriteAsync(bytes, 0, bytes.Length, ct).ConfigureAwait(false); + } +} diff --git a/src/OpenIPC.Viewer.Video/Imaging/SkiaThumbnailGenerator.cs b/src/OpenIPC.Viewer.Video/Imaging/SkiaThumbnailGenerator.cs new file mode 100644 index 0000000..6b840b6 --- /dev/null +++ b/src/OpenIPC.Viewer.Video/Imaging/SkiaThumbnailGenerator.cs @@ -0,0 +1,44 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using OpenIPC.Viewer.Core.Snapshots; +using SkiaSharp; + +namespace OpenIPC.Viewer.Video.Imaging; + +/// +/// SkiaSharp-backed . Lives in the Video +/// project because that's where SkiaSharp is already referenced (frame → JPEG +/// encoding) and the pinned Skia version is managed. Cross-platform: Skia runs +/// on every Avalonia head (desktop + Android/iOS). +/// +public sealed class SkiaThumbnailGenerator : IThumbnailGenerator +{ + public Task GenerateAsync(byte[] jpeg, string thumbPath, int maxDim, CancellationToken ct) + { + using var original = SKBitmap.Decode(jpeg) + ?? throw new InvalidOperationException("Image bytes did not decode"); + + var fullSize = new ImageSize(original.Width, original.Height); + + var (tw, th) = Scale(original.Width, original.Height, maxDim); + var sampling = new SKSamplingOptions(SKCubicResampler.Mitchell); + using var thumb = original.Resize(new SKImageInfo(tw, th), sampling) + ?? throw new InvalidOperationException("Thumbnail resize failed"); + using var image = SKImage.FromBitmap(thumb); + using var data = image.Encode(SKEncodedImageFormat.Jpeg, quality: 80); + + using (var fs = new FileStream(thumbPath, FileMode.Create, FileAccess.Write, FileShare.None)) + data.SaveTo(fs); + + return Task.FromResult(fullSize); + } + + private static (int W, int H) Scale(int w, int h, int maxDim) + { + if (w <= maxDim && h <= maxDim) return (w, h); + var ratio = (double)maxDim / Math.Max(w, h); + return (Math.Max(1, (int)Math.Round(w * ratio)), Math.Max(1, (int)Math.Round(h * ratio))); + } +} From a2a4b10fa77478939ca9934934897f72bf1966f0 Mon Sep 17 00:00:00 2001 From: keyldev Date: Fri, 19 Jun 2026 20:00:40 +0300 Subject: [PATCH 3/8] feat(snapshots): snapshot browser under Recordings (phase 14.3) - Recordings page gains a segmented Recordings/Snapshots switch - SnapshotBrowserPageViewModel: camera + date-preset filters, sort, per-day timeline, multi-select with batch delete - gallery via WrapPanel; thumbnails decode capped at 320px - en/ru strings --- .../Converters/PathToThumbnailConverter.cs | 39 ++ src/OpenIPC.Viewer.App/Services/Localizer.cs | 30 ++ .../ViewModels/RecordingsPageViewModel.cs | 32 ++ .../SnapshotBrowserPageViewModel.cs | 338 ++++++++++++++++++ .../Views/Pages/RecordingsPage.axaml | 266 ++++++++------ .../Views/Pages/SnapshotBrowserView.axaml | 196 ++++++++++ .../Views/Pages/SnapshotBrowserView.axaml.cs | 11 + .../SharedComposition.cs | 1 + 8 files changed, 797 insertions(+), 116 deletions(-) create mode 100644 src/OpenIPC.Viewer.App/Converters/PathToThumbnailConverter.cs create mode 100644 src/OpenIPC.Viewer.App/ViewModels/SnapshotBrowserPageViewModel.cs create mode 100644 src/OpenIPC.Viewer.App/Views/Pages/SnapshotBrowserView.axaml create mode 100644 src/OpenIPC.Viewer.App/Views/Pages/SnapshotBrowserView.axaml.cs diff --git a/src/OpenIPC.Viewer.App/Converters/PathToThumbnailConverter.cs b/src/OpenIPC.Viewer.App/Converters/PathToThumbnailConverter.cs new file mode 100644 index 0000000..045817b --- /dev/null +++ b/src/OpenIPC.Viewer.App/Converters/PathToThumbnailConverter.cs @@ -0,0 +1,39 @@ +using System; +using System.Globalization; +using System.IO; +using Avalonia.Data.Converters; +using Avalonia.Media.Imaging; + +namespace OpenIPC.Viewer.App.Converters; + +/// +/// Turns a file-path string into a decoded for an +/// Image.Source binding. Decodes capped at so +/// the gallery never loads full-resolution stills even when a thumbnail is +/// missing. Returns null (blank cell) on any failure. Only the realized +/// (visible) ItemsRepeater items hit this, so virtualization keeps it cheap. +/// +public sealed class PathToThumbnailConverter : IValueConverter +{ + public static readonly PathToThumbnailConverter Instance = new(); + + private const int DecodeWidth = 320; + + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is not string path || string.IsNullOrEmpty(path) || !File.Exists(path)) + return null; + try + { + using var stream = File.OpenRead(path); + return Bitmap.DecodeToWidth(stream, DecodeWidth); + } + catch + { + return null; + } + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => + throw new NotSupportedException(); +} diff --git a/src/OpenIPC.Viewer.App/Services/Localizer.cs b/src/OpenIPC.Viewer.App/Services/Localizer.cs index f6adbc0..015b942 100644 --- a/src/OpenIPC.Viewer.App/Services/Localizer.cs +++ b/src/OpenIPC.Viewer.App/Services/Localizer.cs @@ -290,6 +290,21 @@ private static LangCode DetectSystem() ["Snapshot.SaveAsTitle"] = "Save snapshot", + ["Snapshots.Title"] = "Snapshots", + ["Snapshots.AllCameras"] = "All cameras", + ["Snapshots.PresetAll"] = "All", + ["Snapshots.PresetToday"] = "Today", + ["Snapshots.Preset7"] = "7 days", + ["Snapshots.Preset30"] = "30 days", + ["Snapshots.SortNewest"] = "Newest first", + ["Snapshots.SortOldest"] = "Oldest first", + ["Snapshots.Empty"] = "No snapshots yet", + ["Snapshots.LoadError"] = "Couldn't load snapshots", + ["Snapshots.SelectedFormat"] = "{0} selected", + ["Snapshots.Dialog.DeleteTitle"] = "Delete snapshots", + ["Snapshots.Dialog.DeleteOneMessage"] = "Delete this snapshot? This can't be undone.", + ["Snapshots.Dialog.DeleteManyMessageFormat"] = "Delete {0} snapshots? This can't be undone.", + ["Grid.EmptySlot"] = "no camera", ["Tile.Snapshot"] = "Snapshot (HD)", @@ -543,6 +558,21 @@ private static LangCode DetectSystem() ["Snapshot.SaveAsTitle"] = "Сохранить снимок", + ["Snapshots.Title"] = "Снимки", + ["Snapshots.AllCameras"] = "Все камеры", + ["Snapshots.PresetAll"] = "Все", + ["Snapshots.PresetToday"] = "Сегодня", + ["Snapshots.Preset7"] = "7 дней", + ["Snapshots.Preset30"] = "30 дней", + ["Snapshots.SortNewest"] = "Сначала новые", + ["Snapshots.SortOldest"] = "Сначала старые", + ["Snapshots.Empty"] = "Снимков пока нет", + ["Snapshots.LoadError"] = "Не удалось загрузить снимки", + ["Snapshots.SelectedFormat"] = "Выбрано: {0}", + ["Snapshots.Dialog.DeleteTitle"] = "Удаление снимков", + ["Snapshots.Dialog.DeleteOneMessage"] = "Удалить этот снимок? Действие необратимо.", + ["Snapshots.Dialog.DeleteManyMessageFormat"] = "Удалить снимков: {0}? Действие необратимо.", + ["Grid.EmptySlot"] = "нет камеры", ["Tile.Snapshot"] = "Снимок (HD)", diff --git a/src/OpenIPC.Viewer.App/ViewModels/RecordingsPageViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/RecordingsPageViewModel.cs index 4b92696..fb4698c 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/RecordingsPageViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/RecordingsPageViewModel.cs @@ -14,6 +14,8 @@ namespace OpenIPC.Viewer.App.ViewModels; +public enum MediaTab { Recordings, Snapshots } + public sealed partial class RecordingsPageViewModel : ViewModelBase { private readonly IRecordingRepository _repo; @@ -23,6 +25,23 @@ public sealed partial class RecordingsPageViewModel : ViewModelBase public string Title => Localizer.Instance["Nav.Recordings"]; + // Phase 14: the Recordings page doubles as the captured-media browser. A + // segmented header flips between the recordings list and the snapshot + // gallery; the snapshot tab loads lazily on first view. + public SnapshotBrowserPageViewModel Snapshots { get; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowRecordings))] + [NotifyPropertyChangedFor(nameof(ShowSnapshots))] + [NotifyPropertyChangedFor(nameof(IsRecordingsTabActive))] + [NotifyPropertyChangedFor(nameof(IsSnapshotsTabActive))] + private MediaTab _selectedTab = MediaTab.Recordings; + + public bool ShowRecordings => SelectedTab == MediaTab.Recordings; + public bool ShowSnapshots => SelectedTab == MediaTab.Snapshots; + public bool IsRecordingsTabActive => SelectedTab == MediaTab.Recordings; + public bool IsSnapshotsTabActive => SelectedTab == MediaTab.Snapshots; + public ObservableCollection Items { get; } = new(); [ObservableProperty] @@ -47,14 +66,27 @@ public RecordingsPageViewModel( IRecordingRepository repo, CameraDirectoryService cameras, IDialogService dialogs, + SnapshotBrowserPageViewModel snapshots, ILogger logger) { _repo = repo; _cameras = cameras; _dialogs = dialogs; + Snapshots = snapshots; _logger = logger; } + [RelayCommand] + private void SelectRecordings() => SelectedTab = MediaTab.Recordings; + + [RelayCommand] + private async Task SelectSnapshotsAsync() + { + SelectedTab = MediaTab.Snapshots; + if (!Snapshots.IsLoaded && !Snapshots.IsLoading) + await Snapshots.LoadAsync(CancellationToken.None).ConfigureAwait(true); + } + public async Task LoadAsync(CancellationToken ct) { IsLoading = true; diff --git a/src/OpenIPC.Viewer.App/ViewModels/SnapshotBrowserPageViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/SnapshotBrowserPageViewModel.cs new file mode 100644 index 0000000..4dc7e86 --- /dev/null +++ b/src/OpenIPC.Viewer.App/ViewModels/SnapshotBrowserPageViewModel.cs @@ -0,0 +1,338 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Microsoft.Extensions.Logging; +using OpenIPC.Viewer.App.Services; +using OpenIPC.Viewer.Core.Entities; +using OpenIPC.Viewer.Core.Services; +using OpenIPC.Viewer.Core.Snapshots; + +namespace OpenIPC.Viewer.App.ViewModels; + +public enum SnapshotDatePreset { All, Today, Last7Days, Last30Days } + +public enum SnapshotSortOrder { Newest, Oldest } + +public sealed partial class SnapshotBrowserPageViewModel : ViewModelBase +{ + // Generous cap — the gallery virtualizes, but we still bound the in-memory + // list so a pathological library can't balloon. CountsByDay is computed off + // the loaded set, so this also bounds the timeline. + private const int LoadLimit = 5000; + + private readonly ISnapshotRepository _repo; + private readonly CameraDirectoryService _cameras; + private readonly IDialogService _dialogs; + private readonly ILogger _logger; + + // Guards the auto-reload that SelectedCamera's change handler triggers, so + // populating the dropdown during a load doesn't recurse. + private bool _initializing; + + // A specific day picked from the timeline; overrides the date preset until + // cleared (or a preset / camera change resets it). + private DateTime? _dayFilter; + + public ObservableCollection Items { get; } = new(); + public ObservableCollection CameraOptions { get; } = new(); + public ObservableCollection DayCounts { get; } = new(); + + [ObservableProperty] private CameraFilterOption? _selectedCamera; + [ObservableProperty] private SnapshotDatePreset _datePreset = SnapshotDatePreset.All; + [ObservableProperty] private SnapshotSortOrder _sort = SnapshotSortOrder.Newest; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsEmpty))] + private bool _isLoaded; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsEmpty))] + private bool _isLoading; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasLoadError))] + [NotifyPropertyChangedFor(nameof(IsEmpty))] + private string? _loadError; + + public bool IsEmpty => IsLoaded && !IsLoading && LoadError is null && Items.Count == 0; + public bool HasLoadError => LoadError is not null; + + public int SelectedCount => Items.Count(i => i.IsSelected); + public bool HasSelection => SelectedCount > 0; + public string SelectionLabel => + string.Format(CultureInfo.CurrentCulture, Localizer.Instance["Snapshots.SelectedFormat"], SelectedCount); + + public bool IsPresetAll => DatePreset == SnapshotDatePreset.All && _dayFilter is null; + public bool IsPresetToday => DatePreset == SnapshotDatePreset.Today && _dayFilter is null; + public bool IsPreset7 => DatePreset == SnapshotDatePreset.Last7Days && _dayFilter is null; + public bool IsPreset30 => DatePreset == SnapshotDatePreset.Last30Days && _dayFilter is null; + public bool IsSortNewest => Sort == SnapshotSortOrder.Newest; + public string SortLabel => + Localizer.Instance[IsSortNewest ? "Snapshots.SortNewest" : "Snapshots.SortOldest"]; + + public SnapshotBrowserPageViewModel( + ISnapshotRepository repo, + CameraDirectoryService cameras, + IDialogService dialogs, + ILogger logger) + { + _repo = repo; + _cameras = cameras; + _dialogs = dialogs; + _logger = logger; + } + + public async Task LoadAsync(CancellationToken ct) + { + IsLoading = true; + LoadError = null; + try + { + var cams = await _cameras.ListAsync(ct).ConfigureAwait(true); + var nameById = new Dictionary(); + foreach (var c in cams) nameById[c.Id] = c.Name; + + if (CameraOptions.Count == 0) + { + _initializing = true; + CameraOptions.Add(new CameraFilterOption(null, Localizer.Instance["Snapshots.AllCameras"])); + foreach (var c in cams.OrderBy(c => c.Name, StringComparer.CurrentCultureIgnoreCase)) + CameraOptions.Add(new CameraFilterOption(c.Id, c.Name)); + SelectedCamera = CameraOptions[0]; + _initializing = false; + } + + var camId = SelectedCamera?.CameraId; + var (since, until) = ResolveRange(); + var snaps = await _repo.ListAsync(camId, since, until, LoadLimit, ct).ConfigureAwait(true); + if (Sort == SnapshotSortOrder.Oldest) + snaps = snaps.Reverse().ToList(); + + Items.Clear(); + foreach (var s in snaps) + { + var name = nameById.TryGetValue(s.CameraId, out var n) ? n : Localizer.Instance["Common.Unknown"]; + Items.Add(new SnapshotItemViewModel(s, name, OnItemSelectionChanged)); + } + + RebuildDayCounts(snaps); + IsLoaded = true; + OnSelectionChanged(); + OnPropertyChanged(nameof(IsEmpty)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to load snapshots"); + LoadError = Localizer.Instance["Snapshots.LoadError"]; + } + finally + { + IsLoading = false; + } + } + + private (DateTime? Since, DateTime? Until) ResolveRange() + { + if (_dayFilter is { } day) + return (day.ToUniversalTime(), day.AddDays(1).ToUniversalTime()); + + var today = DateTime.Now.Date; + return DatePreset switch + { + SnapshotDatePreset.Today => (today.ToUniversalTime(), (DateTime?)null), + SnapshotDatePreset.Last7Days => (today.AddDays(-6).ToUniversalTime(), (DateTime?)null), + SnapshotDatePreset.Last30Days => (today.AddDays(-29).ToUniversalTime(), (DateTime?)null), + _ => ((DateTime?)null, (DateTime?)null), + }; + } + + private void RebuildDayCounts(IReadOnlyList snaps) + { + DayCounts.Clear(); + var groups = snaps + .GroupBy(s => s.TakenAt.ToLocalTime().Date) + .OrderByDescending(g => g.Key) + .Select(g => new SnapshotDayCount(g.Key, g.Count(), g.Key == _dayFilter)); + foreach (var g in groups) + DayCounts.Add(g); + } + + private void OnItemSelectionChanged() => OnSelectionChanged(); + + private void OnSelectionChanged() + { + OnPropertyChanged(nameof(SelectedCount)); + OnPropertyChanged(nameof(HasSelection)); + OnPropertyChanged(nameof(SelectionLabel)); + } + + private void RaiseFilterFlags() + { + OnPropertyChanged(nameof(IsPresetAll)); + OnPropertyChanged(nameof(IsPresetToday)); + OnPropertyChanged(nameof(IsPreset7)); + OnPropertyChanged(nameof(IsPreset30)); + OnPropertyChanged(nameof(IsSortNewest)); + OnPropertyChanged(nameof(SortLabel)); + } + + partial void OnSelectedCameraChanged(CameraFilterOption? value) + { + if (_initializing) return; + _ = LoadAsync(CancellationToken.None); + } + + [RelayCommand] + private Task ReloadAsync() => LoadAsync(CancellationToken.None); + + [RelayCommand] + private Task SetPresetAsync(string preset) + { + DatePreset = preset switch + { + "today" => SnapshotDatePreset.Today, + "7" => SnapshotDatePreset.Last7Days, + "30" => SnapshotDatePreset.Last30Days, + _ => SnapshotDatePreset.All, + }; + _dayFilter = null; + RaiseFilterFlags(); + return LoadAsync(CancellationToken.None); + } + + [RelayCommand] + private Task ToggleSortAsync() + { + Sort = Sort == SnapshotSortOrder.Newest ? SnapshotSortOrder.Oldest : SnapshotSortOrder.Newest; + RaiseFilterFlags(); + return LoadAsync(CancellationToken.None); + } + + [RelayCommand] + private Task FilterByDayAsync(SnapshotDayCount? day) + { + if (day is null) return Task.CompletedTask; + _dayFilter = _dayFilter == day.Day ? null : day.Day; + RaiseFilterFlags(); + return LoadAsync(CancellationToken.None); + } + + [RelayCommand] + private void Open(SnapshotItemViewModel? item) + { + // Placeholder until the in-app viewer (14.4) replaces this — opens with + // the OS image viewer on desktop. + if (item is null || string.IsNullOrEmpty(item.FilePath)) return; + try + { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = item.FilePath, + UseShellExecute = true, + }); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Open snapshot failed"); + } + } + + [RelayCommand] + private async Task DeleteAsync(SnapshotItemViewModel? item) + { + if (item is null) return; + var confirmed = await _dialogs.ConfirmAsync( + title: Localizer.Instance["Snapshots.Dialog.DeleteTitle"], + message: Localizer.Instance["Snapshots.Dialog.DeleteOneMessage"], + confirmLabel: Localizer.Instance["Common.Delete"], + cancelLabel: Localizer.Instance["Common.Cancel"]).ConfigureAwait(true); + if (!confirmed) return; + await DeleteItemsAsync(new[] { item }).ConfigureAwait(true); + } + + [RelayCommand] + private async Task DeleteSelectedAsync() + { + var selected = Items.Where(i => i.IsSelected).ToList(); + if (selected.Count == 0) return; + var confirmed = await _dialogs.ConfirmAsync( + title: Localizer.Instance["Snapshots.Dialog.DeleteTitle"], + message: string.Format(CultureInfo.CurrentCulture, + Localizer.Instance["Snapshots.Dialog.DeleteManyMessageFormat"], selected.Count), + confirmLabel: Localizer.Instance["Common.Delete"], + cancelLabel: Localizer.Instance["Common.Cancel"]).ConfigureAwait(true); + if (!confirmed) return; + await DeleteItemsAsync(selected).ConfigureAwait(true); + } + + private async Task DeleteItemsAsync(IReadOnlyList items) + { + foreach (var item in items) + { + try + { + TryDeleteFile(item.Snapshot.Path); + if (!string.IsNullOrEmpty(item.Snapshot.ThumbPath)) + TryDeleteFile(item.Snapshot.ThumbPath!); + await _repo.RemoveAsync(item.Snapshot.Id, CancellationToken.None).ConfigureAwait(true); + Items.Remove(item); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to delete snapshot {Id}", item.Snapshot.Id); + } + } + RebuildDayCounts(Items.Select(i => i.Snapshot).ToList()); + OnSelectionChanged(); + OnPropertyChanged(nameof(IsEmpty)); + } + + private void TryDeleteFile(string path) + { + try { if (File.Exists(path)) File.Delete(path); } + catch (IOException ex) { _logger.LogWarning(ex, "Snapshot file locked: {Path}", path); } + } +} + +public sealed record CameraFilterOption(CameraId? CameraId, string Name) +{ + public override string ToString() => Name; +} + +public sealed record SnapshotDayCount(DateTime Day, int Count, bool IsActive) +{ + public string DayLabel => Day.ToString("MMM d", CultureInfo.CurrentCulture); +} + +public sealed partial class SnapshotItemViewModel : ObservableObject +{ + private readonly Action _onSelectionChanged; + + public Snapshot Snapshot { get; } + public string CameraName { get; } + + // Prefer the cached thumbnail; fall back to the full image if it's missing. + public string ThumbSource => Snapshot.ThumbPath ?? Snapshot.Path; + public string FilePath => Snapshot.Path; + public DateTime TakenAtLocal => Snapshot.TakenAt.ToLocalTime(); + public string TimeLabel => TakenAtLocal.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.CurrentCulture); + public string ResolutionLabel => Snapshot.Width > 0 ? $"{Snapshot.Width}×{Snapshot.Height}" : string.Empty; + + [ObservableProperty] private bool _isSelected; + + partial void OnIsSelectedChanged(bool value) => _onSelectionChanged(); + + public SnapshotItemViewModel(Snapshot snapshot, string cameraName, Action onSelectionChanged) + { + Snapshot = snapshot; + CameraName = cameraName; + _onSelectionChanged = onSelectionChanged; + } +} diff --git a/src/OpenIPC.Viewer.App/Views/Pages/RecordingsPage.axaml b/src/OpenIPC.Viewer.App/Views/Pages/RecordingsPage.axaml index af6ca72..3c0ee0e 100644 --- a/src/OpenIPC.Viewer.App/Views/Pages/RecordingsPage.axaml +++ b/src/OpenIPC.Viewer.App/Views/Pages/RecordingsPage.axaml @@ -2,129 +2,163 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:vm="using:OpenIPC.Viewer.App.ViewModels" xmlns:svc="using:OpenIPC.Viewer.App.Services" + xmlns:pages="using:OpenIPC.Viewer.App.Views.Pages" x:Class="OpenIPC.Viewer.App.Views.Pages.RecordingsPage" x:DataType="vm:RecordingsPageViewModel"> + + + + + - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -104,35 +113,76 @@ - -