diff --git a/Directory.Packages.props b/Directory.Packages.props
index 55e58f7..e104129 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -58,6 +58,10 @@
+
+
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 407e97b..98082b7 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -38,7 +38,7 @@ Polish items still open before tagging the betas:
- [ ] In-app auto-update (Velopack) + native installers
- [ ] Code signing (Windows / macOS) and Play / TestFlight signing
- [ ] F-Droid packaging
-- [ ] Snapshot share sheet
+- [x] Snapshot share sheet
- [ ] Localization polish (English / Russian)
> **Validation caveat.** Linux / macOS / Android / iOS code paths build and
@@ -55,7 +55,7 @@ and scope live in the planning docs (`dashboard-ideas-roadmap-ru.md`).
|---|---|---|:---:|
| 12 | Streaming hardening | Smart-pause hidden tiles, auto SD/HD, watchdog + backoff, last-frame hold, error tile | ✅ Done |
| 13 | SSH device suite | SSH terminal, SCP file manager, open-in-browser, config push | ✅ Done |
-| 14 | Snapshots & viewer | Always-HD snapshot, snapshot browser, built-in viewer + basic editor | 📋 Planned |
+| 14 | Snapshots & viewer | Always-HD snapshot, snapshot browser, built-in viewer + basic editor | ✅ Done |
| 15 | Local AI analytics | ONNX object detection per camera, auto-record, control center, CPU fallback | 📋 Planned |
| 16 | Archive pro | Fragmented MP4, activity calendar, timeline zoom, clip export | 📋 Planned |
| 17 | Community & app-level | Tabbed layouts, config export/import, notifications, white-label, issue reporter, RBAC | 📋 Planned |
diff --git a/src/OpenIPC.Viewer.Android/Composition.cs b/src/OpenIPC.Viewer.Android/Composition.cs
index c7f9fda..865e2c0 100644
--- a/src/OpenIPC.Viewer.Android/Composition.cs
+++ b/src/OpenIPC.Viewer.Android/Composition.cs
@@ -41,6 +41,7 @@ public static ServiceProvider Build(Context context)
services.AddSingleton(sp =>
new AndroidSecretsStore(context, sp.GetRequiredService().AppDataDir));
services.AddSingleton();
+ services.AddSingleton(_ => new AndroidShareService(context));
// Recording — in-process libavformat (no subprocess on Android) +
// foreground service for OS keep-alive. Phase 9c.
diff --git a/src/OpenIPC.Viewer.Android/Platform/AndroidShareService.cs b/src/OpenIPC.Viewer.Android/Platform/AndroidShareService.cs
new file mode 100644
index 0000000..3b83891
--- /dev/null
+++ b/src/OpenIPC.Viewer.Android/Platform/AndroidShareService.cs
@@ -0,0 +1,44 @@
+using System.Runtime.Versioning;
+using System.Threading;
+using System.Threading.Tasks;
+using Android.Content;
+using AndroidX.Core.Content;
+using OpenIPC.Viewer.Core.Platform;
+
+namespace OpenIPC.Viewer.Android.Platform;
+
+///
+/// Android native share (Phase 14.6 / Phase 11 "snapshot share sheet"). Hands
+/// the receiving app a temporary content:// URI via FileProvider — file:// URIs
+/// throw FileUriExposedException on modern Android.
+///
+[SupportedOSPlatform("android")]
+public sealed class AndroidShareService : IShareService
+{
+ private readonly Context _context;
+
+ public AndroidShareService(Context context) => _context = context;
+
+ public bool SupportsSystemShare => true;
+
+ public Task ShareFileAsync(string path, string? mimeType, CancellationToken ct)
+ {
+ var file = new Java.IO.File(path);
+ var authority = _context.PackageName + ".fileprovider";
+ var uri = FileProvider.GetUriForFile(_context, authority, file);
+
+ var intent = new Intent(Intent.ActionSend);
+ intent.SetType(mimeType ?? "image/jpeg");
+ intent.PutExtra(Intent.ExtraStream, uri);
+ intent.AddFlags(ActivityFlags.GrantReadUriPermission);
+
+ var chooser = Intent.CreateChooser(intent, (string?)null);
+ // Started from the application context (not an Activity), so a new task
+ // is required.
+ chooser?.AddFlags(ActivityFlags.NewTask);
+ _context.StartActivity(chooser);
+ return Task.CompletedTask;
+ }
+
+ public Task RevealInFolderAsync(string path) => Task.CompletedTask;
+}
diff --git a/src/OpenIPC.Viewer.Android/Properties/AndroidManifest.xml b/src/OpenIPC.Viewer.Android/Properties/AndroidManifest.xml
index a5e286f..c5c5f15 100644
--- a/src/OpenIPC.Viewer.Android/Properties/AndroidManifest.xml
+++ b/src/OpenIPC.Viewer.Android/Properties/AndroidManifest.xml
@@ -19,5 +19,14 @@
android:allowBackup="true"
android:supportsRtl="true"
android:name=".MainApplication">
+
+
+
+
diff --git a/src/OpenIPC.Viewer.Android/Resources/xml/file_paths.xml b/src/OpenIPC.Viewer.Android/Resources/xml/file_paths.xml
new file mode 100644
index 0000000..0ad5a58
--- /dev/null
+++ b/src/OpenIPC.Viewer.Android/Resources/xml/file_paths.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
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/DialogService.cs b/src/OpenIPC.Viewer.App/Services/DialogService.cs
index e3ffec0..614cbc0 100644
--- a/src/OpenIPC.Viewer.App/Services/DialogService.cs
+++ b/src/OpenIPC.Viewer.App/Services/DialogService.cs
@@ -8,6 +8,7 @@
using Avalonia.Input;
using Avalonia.Input.Platform;
using Avalonia.Platform.Storage;
+using Avalonia.Threading;
using OpenIPC.Viewer.App.ViewModels.Dialogs;
using OpenIPC.Viewer.App.Views.Dialogs;
@@ -261,6 +262,34 @@ public async Task OpenFileManagerAsync(ViewModels.FileManagerViewModel viewModel
else window.Show(owner);
}
+ public async Task ShowImageViewerAsync(ViewModels.ImageViewerViewModel viewModel)
+ {
+ if (OverlayDialogPresenter.IsMobile)
+ {
+ var content = new ImageViewerContent { DataContext = viewModel };
+ await OverlayDialogPresenter.ShowAsync(content, viewModel.Completion, fullScreen: true).ConfigureAwait(true);
+ viewModel.Cleanup();
+ return;
+ }
+
+ var owner = ResolveOwner();
+ var window = new ImageViewerWindow { DataContext = viewModel };
+ // Bridge both directions: the VM's Close command closes the window, and
+ // closing the window (X / Esc-less) completes the VM so the awaiter
+ // returns and cleanup runs.
+ _ = viewModel.Completion.ContinueWith(
+ _ => Dispatcher.UIThread.Post(window.Close),
+ TaskScheduler.Default);
+ window.Closing += (_, _) => viewModel.RequestClose();
+
+ if (owner is null)
+ window.Show();
+ else
+ await window.ShowDialog(owner).ConfigureAwait(true);
+
+ viewModel.Cleanup();
+ }
+
public async Task OpenUrlAsync(string url)
{
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
diff --git a/src/OpenIPC.Viewer.App/Services/IDialogService.cs b/src/OpenIPC.Viewer.App/Services/IDialogService.cs
index 58494ad..4a6b333 100644
--- a/src/OpenIPC.Viewer.App/Services/IDialogService.cs
+++ b/src/OpenIPC.Viewer.App/Services/IDialogService.cs
@@ -13,6 +13,10 @@ public interface IDialogService
// Opens the remote file manager (Phase 13.4) — window on desktop, overlay on mobile.
Task OpenFileManagerAsync(FileManagerViewModel viewModel);
+ // Opens the in-app snapshot viewer (Phase 14.4) — modal window on desktop,
+ // full-screen overlay on mobile. Completes when the viewer is closed.
+ Task ShowImageViewerAsync(ImageViewerViewModel viewModel);
+
Task ShowCameraEditorAsync(CameraEditorViewModel viewModel);
Task ShowDiscoveryDialogAsync(DiscoveryDialogViewModel viewModel);
diff --git a/src/OpenIPC.Viewer.App/Services/ImageViewerFactory.cs b/src/OpenIPC.Viewer.App/Services/ImageViewerFactory.cs
new file mode 100644
index 0000000..bda5800
--- /dev/null
+++ b/src/OpenIPC.Viewer.App/Services/ImageViewerFactory.cs
@@ -0,0 +1,34 @@
+using System.Collections.Generic;
+using Microsoft.Extensions.Logging;
+using OpenIPC.Viewer.App.ViewModels;
+using OpenIPC.Viewer.Core.Platform;
+using OpenIPC.Viewer.Core.Snapshots;
+
+namespace OpenIPC.Viewer.App.Services;
+
+/// Builds s with their DI dependencies.
+public sealed class ImageViewerFactory
+{
+ private readonly ISnapshotRepository _repo;
+ private readonly ISnapshotService _snapshots;
+ private readonly IDialogService _dialogs;
+ private readonly IShareService _share;
+ private readonly ILoggerFactory _loggerFactory;
+
+ public ImageViewerFactory(
+ ISnapshotRepository repo,
+ ISnapshotService snapshots,
+ IDialogService dialogs,
+ IShareService share,
+ ILoggerFactory loggerFactory)
+ {
+ _repo = repo;
+ _snapshots = snapshots;
+ _dialogs = dialogs;
+ _share = share;
+ _loggerFactory = loggerFactory;
+ }
+
+ public ImageViewerViewModel Create(IReadOnlyList items, int startIndex) =>
+ new(items, startIndex, _repo, _snapshots, _dialogs, _share, _loggerFactory.CreateLogger());
+}
diff --git a/src/OpenIPC.Viewer.App/Services/Localizer.cs b/src/OpenIPC.Viewer.App/Services/Localizer.cs
index b187c29..c265f6c 100644
--- a/src/OpenIPC.Viewer.App/Services/Localizer.cs
+++ b/src/OpenIPC.Viewer.App/Services/Localizer.cs
@@ -290,7 +290,50 @@ 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.",
+
+ ["Viewer.ZoomIn"] = "Zoom in",
+ ["Viewer.ZoomOut"] = "Zoom out",
+ ["Viewer.Fit"] = "Fit",
+ ["Viewer.RotateLeft"] = "Rotate left",
+ ["Viewer.RotateRight"] = "Rotate right",
+ ["Viewer.FlipH"] = "Flip horizontal",
+ ["Viewer.FlipV"] = "Flip vertical",
+ ["Viewer.Slideshow"] = "Slideshow",
+ ["Viewer.Copy"] = "Copy",
+ ["Viewer.Properties"] = "Details",
+ ["Viewer.Prev"] = "Previous",
+ ["Viewer.Next"] = "Next",
+ ["Viewer.Edit"] = "Edit",
+ ["Viewer.Share"] = "Share",
+ ["Viewer.Reveal"] = "Reveal",
+ ["Snapshots.Share"] = "Share",
+ ["Snapshots.Reveal"] = "Reveal",
+
+ ["Edit.Arrow"] = "Arrow",
+ ["Edit.Rect"] = "Box",
+ ["Edit.Text"] = "Text",
+ ["Edit.Crop"] = "Crop",
+ ["Edit.Undo"] = "Undo",
+ ["Edit.Save"] = "Save copy",
+ ["Edit.TextPrompt"] = "Annotation text",
+ ["Edit.AddText"] = "Add",
+
["Grid.EmptySlot"] = "no camera",
+ ["Tile.Snapshot"] = "Snapshot (HD)",
["Welcome.Language"] = "Language",
@@ -542,7 +585,50 @@ 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}? Действие необратимо.",
+
+ ["Viewer.ZoomIn"] = "Увеличить",
+ ["Viewer.ZoomOut"] = "Уменьшить",
+ ["Viewer.Fit"] = "Вписать",
+ ["Viewer.RotateLeft"] = "Повернуть влево",
+ ["Viewer.RotateRight"] = "Повернуть вправо",
+ ["Viewer.FlipH"] = "Отразить по горизонтали",
+ ["Viewer.FlipV"] = "Отразить по вертикали",
+ ["Viewer.Slideshow"] = "Слайд-шоу",
+ ["Viewer.Copy"] = "Копировать",
+ ["Viewer.Properties"] = "Детали",
+ ["Viewer.Prev"] = "Назад",
+ ["Viewer.Next"] = "Вперёд",
+ ["Viewer.Edit"] = "Редактор",
+ ["Viewer.Share"] = "Поделиться",
+ ["Viewer.Reveal"] = "Показать в папке",
+ ["Snapshots.Share"] = "Поделиться",
+ ["Snapshots.Reveal"] = "Показать в папке",
+
+ ["Edit.Arrow"] = "Стрелка",
+ ["Edit.Rect"] = "Рамка",
+ ["Edit.Text"] = "Текст",
+ ["Edit.Crop"] = "Обрезка",
+ ["Edit.Undo"] = "Отменить",
+ ["Edit.Save"] = "Сохранить копию",
+ ["Edit.TextPrompt"] = "Текст подписи",
+ ["Edit.AddText"] = "Добавить",
+
["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/ImageViewerViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/ImageViewerViewModel.cs
new file mode 100644
index 0000000..0313fa4
--- /dev/null
+++ b/src/OpenIPC.Viewer.App/ViewModels/ImageViewerViewModel.cs
@@ -0,0 +1,405 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using Avalonia.Media.Imaging;
+using Avalonia.Threading;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Microsoft.Extensions.Logging;
+using OpenIPC.Viewer.App.Services;
+using OpenIPC.Viewer.Core.Platform;
+using OpenIPC.Viewer.Core.Snapshots;
+
+namespace OpenIPC.Viewer.App.ViewModels;
+
+public sealed record SnapshotViewEntry(Snapshot Snapshot, string CameraName);
+
+public enum EditTool { Arrow, Rectangle, Text, Crop }
+
+///
+/// Phase 14.4 — the in-app image viewer. Navigates prev/next over the browser's
+/// current filtered set, with zoom / pan / rotate / flip / slideshow and
+/// copy / delete. Hosted as a window on desktop and a full-screen overlay on
+/// mobile (see ).
+///
+public sealed partial class ImageViewerViewModel : ViewModelBase
+{
+ public const double MinZoom = 0.1;
+ public const double MaxZoom = 8.0;
+
+ private static readonly TimeSpan SlideshowInterval = TimeSpan.FromSeconds(3);
+
+ // Editor palette (ARGB). Kept small per the phase scope — crop + draw + text.
+ public static readonly uint[] Palette =
+ {
+ 0xFFFF3B30, // red
+ 0xFFFFCC00, // yellow
+ 0xFF34C759, // green
+ 0xFFFFFFFF, // white
+ 0xFF111111, // near-black
+ };
+
+ private readonly List _items;
+ private readonly ISnapshotRepository _repo;
+ private readonly ISnapshotService _snapshots;
+ private readonly IDialogService _dialogs;
+ private readonly IShareService _share;
+ private readonly ILogger _logger;
+ private readonly TaskCompletionSource _closed = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ private DispatcherTimer? _slideshowTimer;
+
+ public Task Completion => _closed.Task;
+
+ /// True if any snapshot was deleted, so the browser reloads on close.
+ public bool AnyChanges { get; private set; }
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(CounterLabel))]
+ [NotifyPropertyChangedFor(nameof(CameraName))]
+ [NotifyPropertyChangedFor(nameof(TimeLabel))]
+ [NotifyPropertyChangedFor(nameof(PropertiesText))]
+ private int _index;
+
+ [ObservableProperty] private Bitmap? _currentImage;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(ScaleX))]
+ [NotifyPropertyChangedFor(nameof(ScaleY))]
+ [NotifyPropertyChangedFor(nameof(ZoomLabel))]
+ private double _zoom = 1.0;
+
+ [ObservableProperty] private double _rotation;
+ [ObservableProperty] private double _translateX;
+ [ObservableProperty] private double _translateY;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(ScaleX))]
+ private bool _flipH;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(ScaleY))]
+ private bool _flipV;
+
+ [ObservableProperty] private bool _isSlideshow;
+ [ObservableProperty] private bool _showProperties;
+
+ public double ScaleX => Zoom * (FlipH ? -1 : 1);
+ public double ScaleY => Zoom * (FlipV ? -1 : 1);
+ public string ZoomLabel => string.Format(CultureInfo.InvariantCulture, "{0:P0}", Zoom);
+
+ public bool HasMultiple => _items.Count > 1;
+ public string CounterLabel => $"{Index + 1} / {_items.Count}";
+
+ private SnapshotViewEntry? Current => Index >= 0 && Index < _items.Count ? _items[Index] : null;
+
+ public string CameraName => Current?.CameraName ?? string.Empty;
+ public string TimeLabel => Current is { } e
+ ? e.Snapshot.TakenAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.CurrentCulture)
+ : string.Empty;
+
+ public string PropertiesText
+ {
+ get
+ {
+ if (Current is not { } e) return string.Empty;
+ var res = e.Snapshot.Width > 0 ? $"{e.Snapshot.Width}×{e.Snapshot.Height}" : "—";
+ var size = "—";
+ try
+ {
+ if (File.Exists(e.Snapshot.Path))
+ {
+ var bytes = new FileInfo(e.Snapshot.Path).Length;
+ size = bytes > 1024 * 1024
+ ? $"{bytes / (1024.0 * 1024):F1} MB"
+ : $"{bytes / 1024.0:F0} KB";
+ }
+ }
+ catch { /* size is best-effort */ }
+ return $"{e.CameraName}\n{res}\n{TimeLabel}\n{size}";
+ }
+ }
+
+ public ImageViewerViewModel(
+ IReadOnlyList items,
+ int startIndex,
+ ISnapshotRepository repo,
+ ISnapshotService snapshots,
+ IDialogService dialogs,
+ IShareService share,
+ ILogger logger)
+ {
+ _items = new List(items);
+ _repo = repo;
+ _snapshots = snapshots;
+ _dialogs = dialogs;
+ _share = share;
+ _logger = logger;
+ _index = Math.Clamp(startIndex, 0, Math.Max(0, _items.Count - 1));
+ LoadCurrent();
+ }
+
+ private void LoadCurrent()
+ {
+ ResetView();
+ var old = CurrentImage;
+ CurrentImage = null;
+ old?.Dispose();
+
+ if (Current is not { } e || !File.Exists(e.Snapshot.Path))
+ return;
+ try
+ {
+ using var stream = File.OpenRead(e.Snapshot.Path);
+ // Cap the decode so a 4K still doesn't blow up mobile memory; still
+ // plenty of detail to zoom into.
+ CurrentImage = Bitmap.DecodeToWidth(stream, 2560);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to decode snapshot {Path}", e.Snapshot.Path);
+ }
+ }
+
+ private void ResetView()
+ {
+ Zoom = 1.0;
+ Rotation = 0;
+ TranslateX = 0;
+ TranslateY = 0;
+ FlipH = false;
+ FlipV = false;
+ }
+
+ // --- view manipulation (called from code-behind gestures) ---
+
+ public void Pan(double dx, double dy)
+ {
+ TranslateX += dx;
+ TranslateY += dy;
+ }
+
+ public void ApplyZoomFactor(double factor)
+ {
+ Zoom = Math.Clamp(Zoom * factor, MinZoom, MaxZoom);
+ }
+
+ public void SetZoom(double zoom)
+ {
+ Zoom = Math.Clamp(zoom, MinZoom, MaxZoom);
+ }
+
+ // --- commands ---
+
+ [RelayCommand]
+ private void Next()
+ {
+ if (_items.Count == 0) return;
+ Index = (Index + 1) % _items.Count;
+ LoadCurrent();
+ }
+
+ [RelayCommand]
+ private void Prev()
+ {
+ if (_items.Count == 0) return;
+ Index = (Index - 1 + _items.Count) % _items.Count;
+ LoadCurrent();
+ }
+
+ [RelayCommand] private void ZoomIn() => ApplyZoomFactor(1.25);
+ [RelayCommand] private void ZoomOut() => ApplyZoomFactor(0.8);
+ [RelayCommand] private void FitToScreen() => ResetView();
+ [RelayCommand] private void RotateLeft() => Rotation = (Rotation - 90) % 360;
+ [RelayCommand] private void RotateRight() => Rotation = (Rotation + 90) % 360;
+ [RelayCommand] private void FlipHorizontal() => FlipH = !FlipH;
+ [RelayCommand] private void FlipVertical() => FlipV = !FlipV;
+
+ [RelayCommand]
+ private void ToggleSlideshow()
+ {
+ IsSlideshow = !IsSlideshow;
+ if (IsSlideshow)
+ {
+ _slideshowTimer ??= new DispatcherTimer { Interval = SlideshowInterval };
+ _slideshowTimer.Tick -= OnSlideshowTick;
+ _slideshowTimer.Tick += OnSlideshowTick;
+ _slideshowTimer.Start();
+ }
+ else
+ {
+ _slideshowTimer?.Stop();
+ }
+ }
+
+ private void OnSlideshowTick(object? sender, EventArgs e) => Next();
+
+ [RelayCommand] private void ToggleProperties() => ShowProperties = !ShowProperties;
+
+ [RelayCommand]
+ private async Task CopyAsync()
+ {
+ if (Current is not { } e) return;
+ try { await _dialogs.CopyFileToClipboardAsync(e.Snapshot.Path).ConfigureAwait(true); }
+ catch (Exception ex) { _logger.LogWarning(ex, "Copy from viewer failed"); }
+ }
+
+ public string ShareLabel =>
+ Localizer.Instance[_share.SupportsSystemShare ? "Viewer.Share" : "Viewer.Reveal"];
+
+ [RelayCommand]
+ private async Task ShareAsync()
+ {
+ if (Current is not { } e) return;
+ try { await _share.ShareFileAsync(e.Snapshot.Path, "image/jpeg", CancellationToken.None).ConfigureAwait(true); }
+ catch (Exception ex) { _logger.LogWarning(ex, "Share from viewer failed"); }
+ }
+
+ [RelayCommand]
+ private async Task DeleteAsync()
+ {
+ if (Current is not { } e) 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;
+
+ try
+ {
+ TryDeleteFile(e.Snapshot.Path);
+ if (!string.IsNullOrEmpty(e.Snapshot.ThumbPath))
+ TryDeleteFile(e.Snapshot.ThumbPath!);
+ await _repo.RemoveAsync(e.Snapshot.Id, CancellationToken.None).ConfigureAwait(true);
+ AnyChanges = true;
+
+ _items.RemoveAt(Index);
+ if (_items.Count == 0) { RequestClose(); return; }
+ if (Index >= _items.Count) Index = _items.Count - 1;
+ OnPropertyChanged(nameof(HasMultiple));
+ LoadCurrent();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Delete from viewer failed for {Id}", e.Snapshot.Id);
+ }
+ }
+
+ // --- editor (14.5) ---
+
+ [ObservableProperty] private bool _isEditing;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(IsToolArrow))]
+ [NotifyPropertyChangedFor(nameof(IsToolRectangle))]
+ [NotifyPropertyChangedFor(nameof(IsToolText))]
+ [NotifyPropertyChangedFor(nameof(IsToolCrop))]
+ private EditTool _currentTool = EditTool.Arrow;
+
+ [ObservableProperty] private uint _editColor = Palette[0];
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(IsThin))]
+ [NotifyPropertyChangedFor(nameof(IsMedium))]
+ [NotifyPropertyChangedFor(nameof(IsThick))]
+ private double _editThickness = 0.006;
+
+ public bool IsToolArrow => CurrentTool == EditTool.Arrow;
+ public bool IsToolRectangle => CurrentTool == EditTool.Rectangle;
+ public bool IsToolText => CurrentTool == EditTool.Text;
+ public bool IsToolCrop => CurrentTool == EditTool.Crop;
+ public bool IsThin => EditThickness <= 0.004;
+ public bool IsMedium => EditThickness > 0.004 && EditThickness < 0.009;
+ public bool IsThick => EditThickness >= 0.009;
+
+ [RelayCommand]
+ private void EnterEdit()
+ {
+ if (Current is null) return;
+ ResetView();
+ IsEditing = true;
+ }
+
+ [RelayCommand]
+ private void CancelEdit() => IsEditing = false;
+
+ [RelayCommand]
+ private void SetTool(string tool) => CurrentTool = tool switch
+ {
+ "rect" => EditTool.Rectangle,
+ "text" => EditTool.Text,
+ "crop" => EditTool.Crop,
+ _ => EditTool.Arrow,
+ };
+
+ [RelayCommand]
+ private void SetColor(string argbHex)
+ {
+ if (uint.TryParse(argbHex, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var c))
+ EditColor = c;
+ }
+
+ [RelayCommand]
+ private void SetThickness(string level) => EditThickness = level switch
+ {
+ "thin" => 0.003,
+ "thick" => 0.011,
+ _ => 0.006,
+ };
+
+ /// Prompts for the text of a text annotation (returns null if cancelled).
+ public Task PromptAnnotationTextAsync() =>
+ _dialogs.PromptAsync(
+ Localizer.Instance["Edit.TextPrompt"],
+ string.Empty,
+ Localizer.Instance["Edit.AddText"],
+ Localizer.Instance["Common.Cancel"]);
+
+ ///
+ /// Renders over the current snapshot as a saved copy
+ /// (via ), inserts it after the
+ /// current one and navigates to it. Called by the editor view once the user
+ /// commits. The original is never modified.
+ ///
+ public async Task ApplyEditAsync(SnapshotEdit edit)
+ {
+ if (Current is not { } e) return;
+ try
+ {
+ var saved = await _snapshots.SaveEditAsync(e.Snapshot, edit, CancellationToken.None).ConfigureAwait(true);
+ AnyChanges = true;
+ var entry = new SnapshotViewEntry(saved, e.CameraName);
+ _items.Insert(Index + 1, entry);
+ Index += 1;
+ OnPropertyChanged(nameof(HasMultiple));
+ IsEditing = false;
+ LoadCurrent();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to save edited snapshot");
+ }
+ }
+
+ [RelayCommand]
+ private void Close() => RequestClose();
+
+ public void RequestClose() => _closed.TrySetResult(true);
+
+ public void Cleanup()
+ {
+ _slideshowTimer?.Stop();
+ var img = CurrentImage;
+ CurrentImage = null;
+ img?.Dispose();
+ }
+
+ private void TryDeleteFile(string path)
+ {
+ try { if (File.Exists(path)) File.Delete(path); }
+ catch (IOException ex) { _logger.LogWarning(ex, "Snapshot file locked: {Path}", path); }
+ }
+}
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/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/ViewModels/SnapshotBrowserPageViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/SnapshotBrowserPageViewModel.cs
new file mode 100644
index 0000000..2284acd
--- /dev/null
+++ b/src/OpenIPC.Viewer.App/ViewModels/SnapshotBrowserPageViewModel.cs
@@ -0,0 +1,357 @@
+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.Platform;
+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 ImageViewerFactory _viewerFactory;
+ private readonly IShareService _share;
+ 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,
+ ImageViewerFactory viewerFactory,
+ IShareService share,
+ ILogger logger)
+ {
+ _repo = repo;
+ _cameras = cameras;
+ _dialogs = dialogs;
+ _viewerFactory = viewerFactory;
+ _share = share;
+ _logger = logger;
+ }
+
+ public string ShareLabel =>
+ Localizer.Instance[_share.SupportsSystemShare ? "Snapshots.Share" : "Snapshots.Reveal"];
+
+ [RelayCommand]
+ private async Task ShareSelectedAsync()
+ {
+ // Native share sheets take one item; share the first selected snapshot
+ // (on desktop this reveals it in the file manager).
+ var first = Items.FirstOrDefault(i => i.IsSelected);
+ if (first is null) return;
+ try { await _share.ShareFileAsync(first.FilePath, "image/jpeg", CancellationToken.None).ConfigureAwait(true); }
+ catch (Exception ex) { _logger.LogWarning(ex, "Share from browser failed"); }
+ }
+
+ 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 async Task OpenAsync(SnapshotItemViewModel? item)
+ {
+ if (item is null) return;
+ var start = Items.IndexOf(item);
+ if (start < 0) return;
+
+ var entries = Items
+ .Select(i => new SnapshotViewEntry(i.Snapshot, i.CameraName))
+ .ToList();
+ var vm = _viewerFactory.Create(entries, start);
+ await _dialogs.ShowImageViewerAsync(vm).ConfigureAwait(true);
+
+ // Deletions inside the viewer are reflected by reloading the gallery.
+ if (vm.AnyChanges)
+ await LoadAsync(CancellationToken.None).ConfigureAwait(true);
+ }
+
+ [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/Dialogs/ImageViewerContent.axaml b/src/OpenIPC.Viewer.App/Views/Dialogs/ImageViewerContent.axaml
new file mode 100644
index 0000000..f7f2f5d
--- /dev/null
+++ b/src/OpenIPC.Viewer.App/Views/Dialogs/ImageViewerContent.axaml
@@ -0,0 +1,192 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/OpenIPC.Viewer.App/Views/Dialogs/ImageViewerContent.axaml.cs b/src/OpenIPC.Viewer.App/Views/Dialogs/ImageViewerContent.axaml.cs
new file mode 100644
index 0000000..b147f26
--- /dev/null
+++ b/src/OpenIPC.Viewer.App/Views/Dialogs/ImageViewerContent.axaml.cs
@@ -0,0 +1,285 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using Avalonia;
+using Avalonia.Collections;
+using Avalonia.Controls;
+using Avalonia.Controls.Shapes;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Media;
+using OpenIPC.Viewer.App.ViewModels;
+using OpenIPC.Viewer.Core.Snapshots;
+
+namespace OpenIPC.Viewer.App.Views.Dialogs;
+
+public sealed partial class ImageViewerContent : UserControl
+{
+ private bool _panning;
+ private Point _lastPointer;
+ private double _pinchStartZoom = 1.0;
+
+ // Editor state (14.5). Annotations are recorded in normalized image coords;
+ // the on-canvas shapes are live preview only — the saved JPEG is composited
+ // by the Skia editor from these annotations.
+ private sealed record EditAction(Control Shape, bool IsCrop);
+ private readonly List _edits = new();
+ private readonly List _actions = new();
+ private double? _cropX, _cropY, _cropW, _cropH;
+ private bool _drawing;
+ private Point _drawStart;
+ private Control? _preview;
+
+ public ImageViewerContent()
+ {
+ InitializeComponent();
+
+ // Pinch-zoom on touch (mobile). The recognizer raises Pinch with a
+ // cumulative scale relative to gesture start.
+ var viewport = this.FindControl("Viewport");
+ if (viewport is not null)
+ {
+ viewport.GestureRecognizers.Add(new PinchGestureRecognizer());
+ viewport.Pinch += OnPinch;
+ viewport.PinchEnded += OnPinchEnded;
+ }
+
+ DataContextChanged += OnDataContextChanged;
+ Loaded += (_, _) => Focus();
+ }
+
+ private ImageViewerViewModel? Vm => DataContext as ImageViewerViewModel;
+
+ private void OnDataContextChanged(object? sender, EventArgs e)
+ {
+ if (Vm is { } vm)
+ vm.PropertyChanged += OnVmPropertyChanged;
+ }
+
+ private void OnVmPropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ // Editing ended (save / cancel / navigation): wipe the preview overlay.
+ if (e.PropertyName == nameof(ImageViewerViewModel.IsEditing) && Vm is { IsEditing: false })
+ ClearEdits();
+ }
+
+ private void OnViewportWheel(object? sender, PointerWheelEventArgs e)
+ {
+ if (Vm is null) return;
+ Vm.ApplyZoomFactor(e.Delta.Y > 0 ? 1.15 : 0.87);
+ e.Handled = true;
+ }
+
+ private void OnViewportPointerPressed(object? sender, PointerPressedEventArgs e)
+ {
+ if (sender is not Visual v) return;
+ _panning = true;
+ _lastPointer = e.GetPosition(v);
+ }
+
+ private void OnViewportPointerMoved(object? sender, PointerEventArgs e)
+ {
+ if (!_panning || Vm is null || sender is not Visual v) return;
+ var p = e.GetPosition(v);
+ Vm.Pan(p.X - _lastPointer.X, p.Y - _lastPointer.Y);
+ _lastPointer = p;
+ }
+
+ private void OnViewportPointerReleased(object? sender, PointerReleasedEventArgs e) => _panning = false;
+
+ private void OnPinch(object? sender, PinchEventArgs e)
+ {
+ if (Vm is null) return;
+ if (e.Scale <= 0) return;
+ // First Pinch tick of a gesture: capture the baseline zoom.
+ if (Math.Abs(e.Scale - 1.0) < 0.001 && !_panning)
+ _pinchStartZoom = Vm.Zoom;
+ Vm.SetZoom(_pinchStartZoom * e.Scale);
+ e.Handled = true;
+ }
+
+ private void OnPinchEnded(object? sender, PinchEndedEventArgs e)
+ {
+ if (Vm is not null) _pinchStartZoom = Vm.Zoom;
+ }
+
+ private void OnActualSizeClick(object? sender, RoutedEventArgs e)
+ {
+ if (Vm?.CurrentImage is not { } bmp) return;
+ var img = this.FindControl("ViewImage");
+ if (img is null || img.Bounds.Width <= 0) return;
+ // The image is laid out Uniform-fit at zoom 1; 1:1 means the fitted
+ // width should equal the bitmap's pixel width.
+ Vm.SetZoom(bmp.PixelSize.Width / img.Bounds.Width);
+ }
+
+ private void OnKeyDown(object? sender, KeyEventArgs e)
+ {
+ if (Vm is null) return;
+ switch (e.Key)
+ {
+ case Key.Left when !Vm.IsEditing: Vm.PrevCommand.Execute(null); e.Handled = true; break;
+ case Key.Right when !Vm.IsEditing: Vm.NextCommand.Execute(null); e.Handled = true; break;
+ case Key.Escape: Vm.CloseCommand.Execute(null); e.Handled = true; break;
+ }
+ }
+
+ // --- editor drawing (14.5) ---
+
+ // The image is shown Uniform-fit at zoom 1 while editing; this is its
+ // letterboxed rectangle within the viewport, in EditCanvas coordinates.
+ private Rect? GetImageRect()
+ {
+ if (Vm?.CurrentImage is not { } bmp) return null;
+ var img = this.FindControl("ViewImage");
+ if (img is null) return null;
+ var b = img.Bounds;
+ double pw = bmp.PixelSize.Width, ph = bmp.PixelSize.Height;
+ if (b.Width <= 0 || b.Height <= 0 || pw <= 0 || ph <= 0) return null;
+ var scale = Math.Min(b.Width / pw, b.Height / ph);
+ double dw = pw * scale, dh = ph * scale;
+ return new Rect(b.X + (b.Width - dw) / 2, b.Y + (b.Height - dh) / 2, dw, dh);
+ }
+
+ private static Point Clamp(Point p, Rect r) => new(
+ Math.Clamp(p.X, r.X, r.Right),
+ Math.Clamp(p.Y, r.Y, r.Bottom));
+
+ private static (double X, double Y) Normalize(Point p, Rect r) => (
+ Math.Clamp((p.X - r.X) / r.Width, 0, 1),
+ Math.Clamp((p.Y - r.Y) / r.Height, 0, 1));
+
+ private IBrush CurrentBrush() => new SolidColorBrush(Color.FromUInt32(Vm?.EditColor ?? 0xFFFF3B30));
+
+ private double OverlayStroke(Rect r) => Math.Max(1.5, (Vm?.EditThickness ?? 0.006) * Math.Max(r.Width, r.Height));
+
+ private async void OnEditPointerPressed(object? sender, PointerPressedEventArgs e)
+ {
+ if (Vm is not { IsEditing: true } vm || GetImageRect() is not { } rect) return;
+ var p = Clamp(e.GetPosition(EditCanvas), rect);
+ e.Handled = true;
+
+ if (vm.CurrentTool == EditTool.Text)
+ {
+ var text = await vm.PromptAnnotationTextAsync();
+ if (string.IsNullOrWhiteSpace(text)) return;
+ var (nx, ny) = Normalize(p, rect);
+ _edits.Add(new SnapshotAnnotation(AnnotationKind.Text, nx, ny, nx, ny, vm.EditColor, vm.EditThickness, text));
+ var fontSize = Math.Max(12, vm.EditThickness * Math.Max(rect.Width, rect.Height) * 6);
+ var tb = new TextBlock { Text = text, Foreground = CurrentBrush(), FontSize = fontSize };
+ Canvas.SetLeft(tb, p.X);
+ Canvas.SetTop(tb, p.Y - fontSize);
+ EditCanvas.Children.Add(tb);
+ _actions.Add(new EditAction(tb, IsCrop: false));
+ return;
+ }
+
+ _drawing = true;
+ _drawStart = p;
+ _preview = vm.CurrentTool switch
+ {
+ EditTool.Rectangle => new Rectangle { Stroke = CurrentBrush(), StrokeThickness = OverlayStroke(rect) },
+ EditTool.Crop => new Rectangle { Stroke = Brushes.White, StrokeThickness = 1.5, StrokeDashArray = new AvaloniaList(4, 3) },
+ _ => new Polyline { Stroke = CurrentBrush(), StrokeThickness = OverlayStroke(rect), StrokeLineCap = PenLineCap.Round, StrokeJoin = PenLineJoin.Round },
+ };
+ EditCanvas.Children.Add(_preview);
+ UpdatePreview(_drawStart, _drawStart);
+ }
+
+ private void OnEditPointerMoved(object? sender, PointerEventArgs e)
+ {
+ if (!_drawing || Vm is null || GetImageRect() is not { } rect) return;
+ UpdatePreview(_drawStart, Clamp(e.GetPosition(EditCanvas), rect));
+ }
+
+ private void OnEditPointerReleased(object? sender, PointerReleasedEventArgs e)
+ {
+ if (!_drawing || Vm is not { } vm || GetImageRect() is not { } rect) { _drawing = false; return; }
+ _drawing = false;
+ var end = Clamp(e.GetPosition(EditCanvas), rect);
+ UpdatePreview(_drawStart, end);
+
+ var (x1, y1) = Normalize(_drawStart, rect);
+ var (x2, y2) = Normalize(end, rect);
+
+ if (vm.CurrentTool == EditTool.Crop)
+ {
+ // Only one crop at a time — drop a previous crop action.
+ var prev = _actions.FindLast(a => a.IsCrop);
+ if (prev is not null) { EditCanvas.Children.Remove(prev.Shape); _actions.Remove(prev); }
+ _cropX = Math.Min(x1, x2);
+ _cropY = Math.Min(y1, y2);
+ _cropW = Math.Abs(x2 - x1);
+ _cropH = Math.Abs(y2 - y1);
+ if (_preview is not null) _actions.Add(new EditAction(_preview, IsCrop: true));
+ }
+ else
+ {
+ var kind = vm.CurrentTool == EditTool.Rectangle ? AnnotationKind.Rectangle : AnnotationKind.Arrow;
+ _edits.Add(new SnapshotAnnotation(kind, x1, y1, x2, y2, vm.EditColor, vm.EditThickness, null));
+ if (_preview is not null) _actions.Add(new EditAction(_preview, IsCrop: false));
+ }
+ _preview = null;
+ }
+
+ private void UpdatePreview(Point a, Point b)
+ {
+ switch (_preview)
+ {
+ case Rectangle rectShape:
+ Canvas.SetLeft(rectShape, Math.Min(a.X, b.X));
+ Canvas.SetTop(rectShape, Math.Min(a.Y, b.Y));
+ rectShape.Width = Math.Abs(b.X - a.X);
+ rectShape.Height = Math.Abs(b.Y - a.Y);
+ break;
+ case Polyline arrow:
+ arrow.Points = ArrowPoints(a, b);
+ break;
+ }
+ }
+
+ private static Points ArrowPoints(Point a, Point b)
+ {
+ var angle = Math.Atan2(b.Y - a.Y, b.X - a.X);
+ var dist = Math.Sqrt((b.X - a.X) * (b.X - a.X) + (b.Y - a.Y) * (b.Y - a.Y));
+ var len = Math.Max(8, dist * 0.18);
+ var a1 = angle + Math.PI - Math.PI / 7;
+ var a2 = angle + Math.PI + Math.PI / 7;
+ var h1 = new Point(b.X + len * Math.Cos(a1), b.Y + len * Math.Sin(a1));
+ var h2 = new Point(b.X + len * Math.Cos(a2), b.Y + len * Math.Sin(a2));
+ return new Points { a, b, h1, b, h2 };
+ }
+
+ private void OnEditUndoClick(object? sender, RoutedEventArgs e)
+ {
+ if (_actions.Count == 0) return;
+ var last = _actions[^1];
+ _actions.RemoveAt(_actions.Count - 1);
+ EditCanvas.Children.Remove(last.Shape);
+ if (last.IsCrop)
+ {
+ _cropX = _cropY = _cropW = _cropH = null;
+ }
+ else if (_edits.Count > 0)
+ {
+ _edits.RemoveAt(_edits.Count - 1);
+ }
+ }
+
+ private async void OnEditSaveClick(object? sender, RoutedEventArgs e)
+ {
+ if (Vm is not { } vm) return;
+ var edit = new SnapshotEdit(_cropX, _cropY, _cropW, _cropH, new List(_edits));
+ await vm.ApplyEditAsync(edit);
+ }
+
+ private void ClearEdits()
+ {
+ _edits.Clear();
+ _actions.Clear();
+ _cropX = _cropY = _cropW = _cropH = null;
+ _preview = null;
+ _drawing = false;
+ EditCanvas?.Children.Clear();
+ }
+}
diff --git a/src/OpenIPC.Viewer.App/Views/Dialogs/ImageViewerWindow.axaml b/src/OpenIPC.Viewer.App/Views/Dialogs/ImageViewerWindow.axaml
new file mode 100644
index 0000000..074d89b
--- /dev/null
+++ b/src/OpenIPC.Viewer.App/Views/Dialogs/ImageViewerWindow.axaml
@@ -0,0 +1,14 @@
+
+
+
+
+
diff --git a/src/OpenIPC.Viewer.App/Views/Dialogs/ImageViewerWindow.axaml.cs b/src/OpenIPC.Viewer.App/Views/Dialogs/ImageViewerWindow.axaml.cs
new file mode 100644
index 0000000..4fce039
--- /dev/null
+++ b/src/OpenIPC.Viewer.App/Views/Dialogs/ImageViewerWindow.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace OpenIPC.Viewer.App.Views.Dialogs;
+
+public sealed partial class ImageViewerWindow : Window
+{
+ public ImageViewerWindow()
+ {
+ InitializeComponent();
+ }
+}
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.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">
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ Foreground="{StaticResource TextSecondaryBrush}"
+ VerticalAlignment="Center"
+ Margin="16,0,16,0" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/OpenIPC.Viewer.App/Views/Pages/SnapshotBrowserView.axaml b/src/OpenIPC.Viewer.App/Views/Pages/SnapshotBrowserView.axaml
new file mode 100644
index 0000000..702dcbf
--- /dev/null
+++ b/src/OpenIPC.Viewer.App/Views/Pages/SnapshotBrowserView.axaml
@@ -0,0 +1,204 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/OpenIPC.Viewer.App/Views/Pages/SnapshotBrowserView.axaml.cs b/src/OpenIPC.Viewer.App/Views/Pages/SnapshotBrowserView.axaml.cs
new file mode 100644
index 0000000..882307a
--- /dev/null
+++ b/src/OpenIPC.Viewer.App/Views/Pages/SnapshotBrowserView.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace OpenIPC.Viewer.App.Views.Pages;
+
+public sealed partial class SnapshotBrowserView : UserControl
+{
+ public SnapshotBrowserView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/src/OpenIPC.Viewer.Composition/SharedComposition.cs b/src/OpenIPC.Viewer.Composition/SharedComposition.cs
index 0e2aa2b..f63353d 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,11 +40,18 @@ public static IServiceCollection AddSharedServices(this IServiceCollection servi
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
// Domain services
services.AddSingleton();
+ services.AddSingleton(sp => sp.GetRequiredService());
services.AddSingleton();
+ // Snapshots (Phase 14): HD-always capture + thumbnail generation.
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+
// Video
services.AddSingleton();
services.AddSingleton();
@@ -82,6 +90,7 @@ public static IServiceCollection AddSharedServices(this IServiceCollection servi
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
// User-tweakable settings (Phase 11). Side-effects (e.g. live Serilog
// level switching) are wired by each platform composition after the
@@ -103,6 +112,7 @@ public static IServiceCollection AddSharedServices(this IServiceCollection servi
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
diff --git a/src/OpenIPC.Viewer.Core/Platform/IShareService.cs b/src/OpenIPC.Viewer.Core/Platform/IShareService.cs
new file mode 100644
index 0000000..0ce5cc8
--- /dev/null
+++ b/src/OpenIPC.Viewer.Core/Platform/IShareService.cs
@@ -0,0 +1,25 @@
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace OpenIPC.Viewer.Core.Platform;
+
+///
+/// Platform sharing for a saved file. On mobile this is the native share sheet;
+/// on desktop there's no system share sheet, so the implementation reveals the
+/// file in the OS file manager. lets the UI
+/// label the action appropriately ("Share" vs "Reveal").
+///
+public interface IShareService
+{
+ /// True when opens a native share sheet.
+ bool SupportsSystemShare { get; }
+
+ ///
+ /// Shares the file via the platform share sheet (mobile) or reveals it in the
+ /// file manager (desktop). defaults to image/jpeg.
+ ///
+ Task ShareFileAsync(string path, string? mimeType, CancellationToken ct);
+
+ /// Reveals/selects the file in the OS file manager (desktop). No-op where unsupported.
+ Task RevealInFolderAsync(string path);
+}
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/IImageEditor.cs b/src/OpenIPC.Viewer.Core/Snapshots/IImageEditor.cs
new file mode 100644
index 0000000..f4baee9
--- /dev/null
+++ b/src/OpenIPC.Viewer.Core/Snapshots/IImageEditor.cs
@@ -0,0 +1,19 @@
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace OpenIPC.Viewer.Core.Snapshots;
+
+///
+/// Renders a over a source image and writes a JPEG
+/// copy. Lives behind an interface so the codec (SkiaSharp) stays out of Core;
+/// the impl ships in the Video project alongside the thumbnail generator.
+///
+public interface IImageEditor
+{
+ ///
+ /// Reads , draws the edit's annotations, applies
+ /// its crop, and writes the result as JPEG to .
+ /// Returns the output image's pixel size. The source is never modified.
+ ///
+ Task RenderAsync(string srcPath, SnapshotEdit edit, string outPath, CancellationToken ct);
+}
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/ISnapshotService.cs b/src/OpenIPC.Viewer.Core/Snapshots/ISnapshotService.cs
new file mode 100644
index 0000000..7d8d7d3
--- /dev/null
+++ b/src/OpenIPC.Viewer.Core/Snapshots/ISnapshotService.cs
@@ -0,0 +1,34 @@
+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);
+
+ ///
+ /// Renders over and saves
+ /// the result as a new *_edited.jpg copy (the original is untouched),
+ /// generating a thumbnail and a DB row. Returns the new snapshot.
+ ///
+ Task SaveEditAsync(Snapshot source, SnapshotEdit edit, 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/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/SnapshotEdit.cs b/src/OpenIPC.Viewer.Core/Snapshots/SnapshotEdit.cs
new file mode 100644
index 0000000..7c524c8
--- /dev/null
+++ b/src/OpenIPC.Viewer.Core/Snapshots/SnapshotEdit.cs
@@ -0,0 +1,36 @@
+using System.Collections.Generic;
+
+namespace OpenIPC.Viewer.Core.Snapshots;
+
+public enum AnnotationKind
+{
+ Arrow = 0,
+ Rectangle = 1,
+ Text = 2,
+}
+
+///
+/// One annotation drawn over a snapshot. Coordinates are normalized to the
+/// full (pre-crop) image: 0..1 on each axis, so the edit is resolution-independent.
+/// is also normalized (fraction of the image's longest
+/// side); the renderer scales it to pixels. is set only for
+/// .
+///
+public sealed record SnapshotAnnotation(
+ AnnotationKind Kind,
+ double X1, double Y1, double X2, double Y2,
+ uint ColorArgb,
+ double Thickness,
+ string? Text);
+
+///
+/// A non-destructive edit applied to a snapshot to produce a saved copy. An
+/// optional normalized crop rectangle (0..1) plus a list of annotations. The
+/// renderer draws annotations onto the full image, then crops.
+///
+public sealed record SnapshotEdit(
+ double? CropX,
+ double? CropY,
+ double? CropW,
+ double? CropH,
+ IReadOnlyList Annotations);
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/SnapshotService.cs b/src/OpenIPC.Viewer.Core/Snapshots/SnapshotService.cs
new file mode 100644
index 0000000..e2911a7
--- /dev/null
+++ b/src/OpenIPC.Viewer.Core/Snapshots/SnapshotService.cs
@@ -0,0 +1,253 @@
+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;
+ private readonly IImageEditor _editor;
+
+ public SnapshotService(
+ IMajesticClient majestic,
+ LiveStreamCoordinator coordinator,
+ ICameraCredentialsProvider credentials,
+ ISnapshotRepository repo,
+ IFileSystem fs,
+ IThumbnailGenerator thumbs,
+ IImageEditor editor)
+ {
+ _majestic = majestic;
+ _coordinator = coordinator;
+ _credentials = credentials;
+ _repo = repo;
+ _fs = fs;
+ _thumbs = thumbs;
+ _editor = editor;
+ }
+
+ public async Task SaveEditAsync(Snapshot source, SnapshotEdit edit, CancellationToken ct)
+ {
+ var id = SnapshotId.New();
+ var dir = Path.GetDirectoryName(source.Path) ?? _fs.SnapshotsDir.FullName;
+ var stem = Path.GetFileNameWithoutExtension(source.Path);
+ var outPath = EnsureUnique(Path.Combine(dir, stem + "_edited.jpg"));
+
+ var size = await _editor.RenderAsync(source.Path, edit, outPath, ct).ConfigureAwait(false);
+
+ var thumbDir = Path.Combine(_fs.SnapshotsDir.FullName, ".thumbs");
+ Directory.CreateDirectory(thumbDir);
+ var thumbPath = Path.Combine(thumbDir, id.ToString() + ".jpg");
+ string? savedThumb = thumbPath;
+ try
+ {
+ var bytes = await ReadAllBytesAsync(outPath, ct).ConfigureAwait(false);
+ await _thumbs.GenerateAsync(bytes, thumbPath, ThumbMaxDim, ct).ConfigureAwait(false);
+ }
+ catch (Exception)
+ {
+ savedThumb = null;
+ }
+
+ var snapshot = new Snapshot(
+ id, source.CameraId, DateTime.UtcNow, outPath, savedThumb,
+ size.Width, size.Height, SnapshotSource.Edited, SnapshotKind.Manual);
+ await _repo.AddAsync(snapshot, ct).ConfigureAwait(false);
+ return snapshot;
+ }
+
+ 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);
+ }
+
+ private static async Task ReadAllBytesAsync(string path, CancellationToken ct)
+ {
+ using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, useAsync: true);
+ var buffer = new byte[fs.Length];
+ var offset = 0;
+ int read;
+ while (offset < buffer.Length &&
+ (read = await fs.ReadAsync(buffer, offset, buffer.Length - offset, ct).ConfigureAwait(false)) > 0)
+ offset += read;
+ return buffer;
+ }
+}
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.Desktop/Composition.cs b/src/OpenIPC.Viewer.Desktop/Composition.cs
index 43fb6b0..8f7776b 100644
--- a/src/OpenIPC.Viewer.Desktop/Composition.cs
+++ b/src/OpenIPC.Viewer.Desktop/Composition.cs
@@ -37,6 +37,9 @@ public static ServiceProvider Build()
AddPlatformServices(services);
+ // Share (Phase 14.6): no native sheet on desktop — reveal in file manager.
+ services.AddSingleton();
+
// Recording backend — ffmpeg subprocess works on all three desktop OSes
// (resolves bundled binary or system PATH per FfmpegSubprocessRecorder).
services.AddSingleton(sp =>
diff --git a/src/OpenIPC.Viewer.Desktop/DesktopShareService.cs b/src/OpenIPC.Viewer.Desktop/DesktopShareService.cs
new file mode 100644
index 0000000..e9d5e31
--- /dev/null
+++ b/src/OpenIPC.Viewer.Desktop/DesktopShareService.cs
@@ -0,0 +1,46 @@
+using System;
+using System.Diagnostics;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using OpenIPC.Viewer.Core.Platform;
+
+namespace OpenIPC.Viewer.Desktop;
+
+///
+/// Desktop has no native share sheet, so "share" reveals the file in the OS
+/// file manager (Explorer / Finder / the default file manager on Linux). The
+/// viewer/browser also offer copy-to-clipboard separately.
+///
+public sealed class DesktopShareService : IShareService
+{
+ public bool SupportsSystemShare => false;
+
+ public Task ShareFileAsync(string path, string? mimeType, CancellationToken ct) => RevealInFolderAsync(path);
+
+ public Task RevealInFolderAsync(string path)
+ {
+ try
+ {
+ if (OperatingSystem.IsWindows())
+ {
+ Process.Start(new ProcessStartInfo("explorer.exe", $"/select,\"{path}\"") { UseShellExecute = true });
+ }
+ else if (OperatingSystem.IsMacOS())
+ {
+ Process.Start(new ProcessStartInfo("open", $"-R \"{path}\""));
+ }
+ else
+ {
+ var dir = Path.GetDirectoryName(path);
+ if (!string.IsNullOrEmpty(dir))
+ Process.Start(new ProcessStartInfo("xdg-open", $"\"{dir}\""));
+ }
+ }
+ catch
+ {
+ // Best-effort — a missing file manager shouldn't surface as an error.
+ }
+ return Task.CompletedTask;
+ }
+}
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; }
+ }
+}
diff --git a/src/OpenIPC.Viewer.Video/Imaging/SkiaImageEditor.cs b/src/OpenIPC.Viewer.Video/Imaging/SkiaImageEditor.cs
new file mode 100644
index 0000000..835ad88
--- /dev/null
+++ b/src/OpenIPC.Viewer.Video/Imaging/SkiaImageEditor.cs
@@ -0,0 +1,104 @@
+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 . Draws annotations onto the full
+/// image, then crops, then encodes JPEG. Coordinates are normalized (0..1) so
+/// the same edit renders identically regardless of source resolution.
+///
+public sealed class SkiaImageEditor : IImageEditor
+{
+ public Task RenderAsync(string srcPath, SnapshotEdit edit, string outPath, CancellationToken ct)
+ {
+ using var src = SKBitmap.Decode(srcPath)
+ ?? throw new InvalidOperationException($"Could not decode {srcPath}");
+
+ int w = src.Width, h = src.Height;
+ var info = new SKImageInfo(w, h, SKColorType.Bgra8888, SKAlphaType.Premul);
+ using var surface = SKSurface.Create(info);
+ var canvas = surface.Canvas;
+ canvas.DrawBitmap(src, 0, 0);
+
+ var longest = Math.Max(w, h);
+ foreach (var a in edit.Annotations)
+ {
+ var color = ToColor(a.ColorArgb);
+ var strokePx = (float)Math.Max(1.0, a.Thickness * longest);
+ using var paint = new SKPaint
+ {
+ Color = color,
+ IsAntialias = true,
+ Style = SKPaintStyle.Stroke,
+ StrokeWidth = strokePx,
+ StrokeCap = SKStrokeCap.Round,
+ StrokeJoin = SKStrokeJoin.Round,
+ };
+
+ float x1 = (float)(a.X1 * w), y1 = (float)(a.Y1 * h);
+ float x2 = (float)(a.X2 * w), y2 = (float)(a.Y2 * h);
+
+ switch (a.Kind)
+ {
+ case AnnotationKind.Rectangle:
+ canvas.DrawRect(
+ SKRect.Create(Math.Min(x1, x2), Math.Min(y1, y2), Math.Abs(x2 - x1), Math.Abs(y2 - y1)),
+ paint);
+ break;
+ case AnnotationKind.Arrow:
+ DrawArrow(canvas, x1, y1, x2, y2, paint, strokePx);
+ break;
+ case AnnotationKind.Text:
+ using (var fill = new SKPaint { Color = color, IsAntialias = true })
+ using (var font = new SKFont(SKTypeface.Default, Math.Max(12f, strokePx * 6f)))
+ canvas.DrawText(a.Text ?? string.Empty, x1, y1, SKTextAlign.Left, font, fill);
+ break;
+ }
+ }
+
+ using var image = surface.Snapshot();
+
+ SKImage final = image;
+ SKImage? cropped = null;
+ if (edit.CropX is { } cx && edit.CropY is { } cy && edit.CropW is { } cw && edit.CropH is { } chh
+ && cw > 0 && chh > 0)
+ {
+ var rx = Math.Clamp((int)(cx * w), 0, w - 1);
+ var ry = Math.Clamp((int)(cy * h), 0, h - 1);
+ var rw = Math.Clamp((int)(cw * w), 1, w - rx);
+ var rh = Math.Clamp((int)(chh * h), 1, h - ry);
+ cropped = image.Subset(SKRectI.Create(rx, ry, rw, rh));
+ if (cropped is not null) final = cropped;
+ }
+
+ using (var data = final.Encode(SKEncodedImageFormat.Jpeg, 92))
+ using (var fs = File.Create(outPath))
+ data.SaveTo(fs);
+
+ var size = new ImageSize(final.Width, final.Height);
+ cropped?.Dispose();
+ return Task.FromResult(size);
+ }
+
+ private static void DrawArrow(SKCanvas c, float x1, float y1, float x2, float y2, SKPaint paint, float stroke)
+ {
+ c.DrawLine(x1, y1, x2, y2, paint);
+ var angle = Math.Atan2(y2 - y1, x2 - x1);
+ var head = Math.Max(stroke * 4f, 12f);
+ var a1 = angle + Math.PI - Math.PI / 7;
+ var a2 = angle + Math.PI + Math.PI / 7;
+ c.DrawLine(x2, y2, (float)(x2 + head * Math.Cos(a1)), (float)(y2 + head * Math.Sin(a1)), paint);
+ c.DrawLine(x2, y2, (float)(x2 + head * Math.Cos(a2)), (float)(y2 + head * Math.Sin(a2)), paint);
+ }
+
+ private static SKColor ToColor(uint argb) => new(
+ (byte)((argb >> 16) & 0xFF),
+ (byte)((argb >> 8) & 0xFF),
+ (byte)(argb & 0xFF),
+ (byte)((argb >> 24) & 0xFF));
+}
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)));
+ }
+}
diff --git a/src/OpenIPC.Viewer.iOS/Composition.cs b/src/OpenIPC.Viewer.iOS/Composition.cs
index e85ccfd..0b8ec37 100644
--- a/src/OpenIPC.Viewer.iOS/Composition.cs
+++ b/src/OpenIPC.Viewer.iOS/Composition.cs
@@ -44,6 +44,7 @@ public static ServiceProvider Build()
return new IosSecretsStore(sp.GetRequiredService().AppDataDir);
});
services.AddSingleton();
+ services.AddSingleton();
// Recording — in-process libavformat. iOS won't let surveillance apps
// run 24/7 in background, so recording is foreground-only (Phase 10
diff --git a/src/OpenIPC.Viewer.iOS/Platform/IosShareService.cs b/src/OpenIPC.Viewer.iOS/Platform/IosShareService.cs
new file mode 100644
index 0000000..7e7a6d6
--- /dev/null
+++ b/src/OpenIPC.Viewer.iOS/Platform/IosShareService.cs
@@ -0,0 +1,67 @@
+using System;
+using System.Runtime.Versioning;
+using System.Threading;
+using System.Threading.Tasks;
+using CoreGraphics;
+using Foundation;
+using OpenIPC.Viewer.Core.Platform;
+using UIKit;
+
+namespace OpenIPC.Viewer.iOS.Platform;
+
+///
+/// iOS native share (Phase 14.6) via ,
+/// presented from the key window's root controller.
+///
+[SupportedOSPlatform("ios")]
+public sealed class IosShareService : IShareService
+{
+ public bool SupportsSystemShare => true;
+
+ public Task ShareFileAsync(string path, string? mimeType, CancellationToken ct)
+ {
+ var url = NSUrl.FromFilename(path);
+ var activity = new UIActivityViewController(new NSObject[] { url }, null);
+
+ var root = KeyRootController();
+ if (root is null)
+ return Task.CompletedTask;
+
+ // iPad presents activity sheets as a popover anchored to a source rect.
+ if (activity.PopoverPresentationController is { } popover && root.View is { } view)
+ {
+ popover.SourceView = view;
+ popover.SourceRect = new CGRect(view.Bounds.GetMidX(), view.Bounds.GetMidY(), 0, 0);
+ popover.PermittedArrowDirections = 0;
+ }
+
+ root.PresentViewController(activity, animated: true, completionHandler: null);
+ return Task.CompletedTask;
+ }
+
+ public Task RevealInFolderAsync(string path) => Task.CompletedTask;
+
+ private static UIViewController? KeyRootController()
+ {
+ // The project targets iOS 16+, so the scene API is always present; the
+ // guard is what teaches the platform-compatibility analyzer that.
+ if (!OperatingSystem.IsIOSVersionAtLeast(13))
+ return null;
+
+ foreach (var scene in UIApplication.SharedApplication.ConnectedScenes)
+ {
+ if (scene is UIWindowScene windowScene)
+ {
+ foreach (var window in windowScene.Windows)
+ {
+ if (window.IsKeyWindow && window.RootViewController is { } vc)
+ return Topmost(vc);
+ }
+ }
+ }
+ return null;
+ }
+
+ private static UIViewController Topmost(UIViewController vc) =>
+ vc.PresentedViewController is { } presented ? Topmost(presented) : vc;
+}
diff --git a/tests/OpenIPC.Viewer.Core.Tests/Snapshots/SnapshotServiceTests.cs b/tests/OpenIPC.Viewer.Core.Tests/Snapshots/SnapshotServiceTests.cs
new file mode 100644
index 0000000..1ff9c79
--- /dev/null
+++ b/tests/OpenIPC.Viewer.Core.Tests/Snapshots/SnapshotServiceTests.cs
@@ -0,0 +1,204 @@
+using System.IO;
+using OpenIPC.Viewer.Core.Entities;
+using OpenIPC.Viewer.Core.Majestic;
+using OpenIPC.Viewer.Core.Platform;
+using OpenIPC.Viewer.Core.Services;
+using OpenIPC.Viewer.Core.Snapshots;
+using OpenIPC.Viewer.Core.Video;
+
+namespace OpenIPC.Viewer.Core.Tests.Snapshots;
+
+public sealed class SnapshotServiceTests
+{
+ [Fact]
+ public async Task PrefersRunningMainstream_OverMajestic()
+ {
+ var (svc, ctx) = Build(majesticBytes: Bytes("HTTP"), freshFrame: null);
+ var liveMain = new FakeSession(Bytes("MAIN"));
+
+ var snap = await svc.CaptureAsync(Cam(majestic: true), liveMain, StreamQuality.Main, CancellationToken.None);
+
+ Assert.Equal(SnapshotSource.LiveMain, snap.Source);
+ Assert.Equal(0, ctx.Majestic.SnapshotCalls); // never reached for a live mainstream
+ Assert.True(File.Exists(snap.Path));
+ Assert.Equal("MAIN", File.ReadAllText(snap.Path));
+ Assert.Single(ctx.Repo.Added);
+ Assert.Equal(1920, snap.Width); // from the fake thumbnail generator
+ }
+
+ [Fact]
+ public async Task UsesMajesticHttp_WhenNoLiveSession()
+ {
+ var (svc, _) = Build(majesticBytes: Bytes("HTTP"), freshFrame: null);
+
+ var snap = await svc.CaptureAsync(Cam(majestic: true), liveSession: null, liveQuality: null, CancellationToken.None);
+
+ Assert.Equal(SnapshotSource.HttpSnapshot, snap.Source);
+ Assert.Equal("HTTP", File.ReadAllText(snap.Path));
+ }
+
+ [Fact]
+ public async Task NeverGrabsSubstream_WhenAnHdSourceExists()
+ {
+ // A substream tile is live AND the camera is Majestic: must take the HD
+ // HTTP snapshot, not the SD frame on screen.
+ var (svc, ctx) = Build(majesticBytes: Bytes("HTTP"), freshFrame: null);
+ var liveSub = new FakeSession(Bytes("SUB"));
+
+ var snap = await svc.CaptureAsync(Cam(majestic: true), liveSub, StreamQuality.Sub, CancellationToken.None);
+
+ Assert.Equal(SnapshotSource.HttpSnapshot, snap.Source);
+ Assert.Equal(0, liveSub.SnapshotCalls);
+ }
+
+ [Fact]
+ public async Task BrieflyOpensMainstream_WhenNoMajesticAndNoLiveMain()
+ {
+ var (svc, _) = Build(majesticBytes: null, freshFrame: Bytes("OPEN"));
+
+ var snap = await svc.CaptureAsync(Cam(majestic: false), liveSession: null, liveQuality: null, CancellationToken.None);
+
+ Assert.Equal(SnapshotSource.OpenedStream, snap.Source);
+ Assert.Equal("OPEN", File.ReadAllText(snap.Path));
+ }
+
+ [Fact]
+ public async Task FallsBackToSubstream_WhenNoHdSourceAvailable()
+ {
+ // No Majestic and the mainstream won't open (engine throws) → the live
+ // substream frame is better than nothing.
+ var (svc, _) = Build(majesticBytes: null, freshFrame: null);
+ var liveSub = new FakeSession(Bytes("SUB"));
+
+ var snap = await svc.CaptureAsync(Cam(majestic: false), liveSub, StreamQuality.Sub, CancellationToken.None);
+
+ Assert.Equal(SnapshotSource.LiveSub, snap.Source);
+ Assert.Equal("SUB", File.ReadAllText(snap.Path));
+ }
+
+ // --- harness ---
+
+ private static byte[] Bytes(string s) => System.Text.Encoding.ASCII.GetBytes(s);
+
+ private static Camera Cam(bool majestic) => new(
+ CameraId.New(), null, "Cam", "10.0.0.5", null, 80,
+ new Uri("rtsp://10.0.0.5/main"), new Uri("rtsp://10.0.0.5/sub"),
+ null, null, false, null, null, null, true, false, majestic, 0,
+ DateTime.UtcNow, DateTime.UtcNow);
+
+ private sealed record Ctx(FakeMajestic Majestic, FakeRepo Repo);
+
+ private static (SnapshotService Service, Ctx Ctx) Build(byte[]? majesticBytes, byte[]? freshFrame)
+ {
+ var majestic = new FakeMajestic(majesticBytes);
+ var repo = new FakeRepo();
+ var fs = new FakeFileSystem();
+ // The engine yields a fresh mainstream session only when freshFrame is set;
+ // otherwise CreateSession throws so the brief-open path fails fast.
+ var engine = new FakeEngine(freshFrame is null ? null : () => new FakeSession(freshFrame));
+ var coordinator = new LiveStreamCoordinator(engine);
+ var svc = new SnapshotService(majestic, coordinator, new FakeCreds(), repo, fs, new FakeThumbs(), new FakeEditor());
+ return (svc, new Ctx(majestic, repo));
+ }
+
+ private sealed class FakeSession : IVideoSession
+ {
+ private readonly byte[]? _frame;
+ public int SnapshotCalls { get; private set; }
+ public FakeSession(byte[]? frame) => _frame = frame;
+ public SessionState State { get; private set; } = SessionState.Idle;
+ public string? LastError => null;
+ public IObservable StateChanged { get; } = new NeverObservable();
+ public IObservable Frames { get; } = new NeverObservable();
+ public IObservable Telemetry { get; } = new NeverObservable();
+ public Task StartAsync(CancellationToken ct) { State = SessionState.Playing; return Task.CompletedTask; }
+ public Task SnapshotAsync(SnapshotFormat format, CancellationToken ct)
+ {
+ SnapshotCalls++;
+ return _frame is null
+ ? throw new InvalidOperationException("No frame available yet")
+ : Task.FromResult(_frame);
+ }
+ public void PauseDecode() { }
+ public void Resume() { }
+ public ValueTask DisposeAsync() => default;
+ }
+
+ private sealed class FakeEngine : IVideoEngine
+ {
+ private readonly Func? _factory;
+ public FakeEngine(Func? factory) => _factory = factory;
+ public IVideoSession CreateSession(VideoSessionOptions options) =>
+ _factory is null ? throw new InvalidOperationException("mainstream unavailable") : _factory();
+ }
+
+ private sealed class FakeMajestic : IMajesticClient
+ {
+ private readonly byte[]? _bytes;
+ public int SnapshotCalls { get; private set; }
+ public FakeMajestic(byte[]? bytes) => _bytes = bytes;
+ public Task SnapshotJpegAsync(MajesticEndpoint endpoint, MajesticSnapshotOptions options, CancellationToken ct)
+ {
+ SnapshotCalls++;
+ return _bytes is null ? throw new InvalidOperationException("not majestic") : Task.FromResult(_bytes);
+ }
+ public Task PingAsync(MajesticEndpoint endpoint, CancellationToken ct) => Task.FromResult(_bytes is not null);
+ public Task GetConfigAsync(MajesticEndpoint endpoint, CancellationToken ct) => throw new NotSupportedException();
+ public Task GetInfoAsync(MajesticEndpoint endpoint, CancellationToken ct) => throw new NotSupportedException();
+ public Task UpdateConfigAsync(MajesticEndpoint endpoint, MajesticConfigPatch patch, CancellationToken ct) => throw new NotSupportedException();
+ public Task UpdateRawConfigAsync(MajesticEndpoint endpoint, string rawJson, CancellationToken ct) => throw new NotSupportedException();
+ public Task SetNightModeAsync(MajesticEndpoint endpoint, NightMode mode, CancellationToken ct) => throw new NotSupportedException();
+ }
+
+ private sealed class FakeCreds : ICameraCredentialsProvider
+ {
+ public Task GetCredentialsAsync(CameraId id, CancellationToken ct) =>
+ Task.FromResult(null);
+ }
+
+ private sealed class FakeRepo : ISnapshotRepository
+ {
+ public List Added { get; } = new();
+ public Task AddAsync(Snapshot snapshot, CancellationToken ct) { Added.Add(snapshot); return Task.CompletedTask; }
+ public Task> ListAsync(CameraId? c, DateTime? s, DateTime? u, int limit, CancellationToken ct) =>
+ Task.FromResult>(Added);
+ public Task GetAsync(SnapshotId id, CancellationToken ct) =>
+ Task.FromResult(Added.Find(x => x.Id == id));
+ public Task RemoveAsync(SnapshotId id, CancellationToken ct) { Added.RemoveAll(x => x.Id == id); return Task.CompletedTask; }
+ }
+
+ private sealed class FakeThumbs : IThumbnailGenerator
+ {
+ public Task GenerateAsync(byte[] jpeg, string thumbPath, int maxDim, CancellationToken ct)
+ {
+ File.WriteAllBytes(thumbPath, jpeg); // stand-in thumbnail
+ return Task.FromResult(new ImageSize(1920, 1080));
+ }
+ }
+
+ private sealed class FakeEditor : IImageEditor
+ {
+ public Task RenderAsync(string srcPath, SnapshotEdit edit, string outPath, CancellationToken ct) =>
+ throw new NotSupportedException();
+ }
+
+ private sealed class FakeFileSystem : IFileSystem
+ {
+ public FakeFileSystem()
+ {
+ var root = Path.Combine(Path.GetTempPath(), "oipc-snap-tests", Guid.NewGuid().ToString("N"));
+ AppDataDir = Directory.CreateDirectory(root);
+ RecordingsDir = Directory.CreateDirectory(Path.Combine(root, "recordings"));
+ SnapshotsDir = Directory.CreateDirectory(Path.Combine(root, "snapshots"));
+ }
+ public DirectoryInfo AppDataDir { get; }
+ public DirectoryInfo RecordingsDir { get; }
+ public DirectoryInfo SnapshotsDir { get; }
+ }
+
+ private sealed class NeverObservable : IObservable
+ {
+ public IDisposable Subscribe(IObserver observer) => new Noop();
+ private sealed class Noop : IDisposable { public void Dispose() { } }
+ }
+}
diff --git a/tests/OpenIPC.Viewer.Infrastructure.Tests/SqliteSnapshotRepositoryTests.cs b/tests/OpenIPC.Viewer.Infrastructure.Tests/SqliteSnapshotRepositoryTests.cs
new file mode 100644
index 0000000..57b3ee7
--- /dev/null
+++ b/tests/OpenIPC.Viewer.Infrastructure.Tests/SqliteSnapshotRepositoryTests.cs
@@ -0,0 +1,99 @@
+using System.IO;
+using Microsoft.Extensions.Logging.Abstractions;
+using OpenIPC.Viewer.Core.Entities;
+using OpenIPC.Viewer.Core.Snapshots;
+using OpenIPC.Viewer.Infrastructure.Persistence;
+
+namespace OpenIPC.Viewer.Infrastructure.Tests;
+
+public sealed class SqliteSnapshotRepositoryTests : IAsyncLifetime, IDisposable
+{
+ private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"oipc-snaps-{Guid.NewGuid():N}.db");
+ private SqliteConnectionFactory _factory = default!;
+ private SqliteSnapshotRepository _repo = default!;
+ private CameraId _camA;
+ private CameraId _camB;
+
+ public async Task InitializeAsync()
+ {
+ _factory = new SqliteConnectionFactory(_dbPath);
+ await new MigrationRunner(_factory, NullLogger.Instance).MigrateAsync(CancellationToken.None);
+
+ var cameras = new SqliteCameraRepository(_factory);
+ _camA = await cameras.AddAsync(Cam("A"), CancellationToken.None);
+ _camB = await cameras.AddAsync(Cam("B"), CancellationToken.None);
+ _repo = new SqliteSnapshotRepository(_factory);
+ }
+
+ public Task DisposeAsync() => Task.CompletedTask;
+
+ public void Dispose()
+ {
+ try { if (File.Exists(_dbPath)) File.Delete(_dbPath); } catch { /* temp file */ }
+ }
+
+ [Fact]
+ public async Task AddAndGet_RoundTrips()
+ {
+ var snap = Snap(_camA, DateTime.UtcNow, SnapshotSource.HttpSnapshot);
+ await _repo.AddAsync(snap, CancellationToken.None);
+
+ var fetched = await _repo.GetAsync(snap.Id, CancellationToken.None);
+
+ Assert.NotNull(fetched);
+ Assert.Equal(snap.Path, fetched!.Path);
+ Assert.Equal(1920, fetched.Width);
+ Assert.Equal(SnapshotSource.HttpSnapshot, fetched.Source);
+ }
+
+ [Fact]
+ public async Task List_FiltersByCamera()
+ {
+ await _repo.AddAsync(Snap(_camA, DateTime.UtcNow, SnapshotSource.HttpSnapshot), CancellationToken.None);
+ await _repo.AddAsync(Snap(_camB, DateTime.UtcNow, SnapshotSource.HttpSnapshot), CancellationToken.None);
+
+ var onlyA = await _repo.ListAsync(_camA, null, null, 100, CancellationToken.None);
+
+ Assert.Single(onlyA);
+ Assert.Equal(_camA, onlyA[0].CameraId);
+ }
+
+ [Fact]
+ public async Task List_FiltersByDateRange_AndSortsNewestFirst()
+ {
+ var t0 = new DateTime(2026, 6, 1, 12, 0, 0, DateTimeKind.Utc);
+ await _repo.AddAsync(Snap(_camA, t0, SnapshotSource.HttpSnapshot), CancellationToken.None);
+ await _repo.AddAsync(Snap(_camA, t0.AddDays(5), SnapshotSource.HttpSnapshot), CancellationToken.None);
+ await _repo.AddAsync(Snap(_camA, t0.AddDays(10), SnapshotSource.HttpSnapshot), CancellationToken.None);
+
+ var since = await _repo.ListAsync(null, t0.AddDays(3), null, 100, CancellationToken.None);
+ Assert.Equal(2, since.Count);
+ Assert.True(since[0].TakenAt > since[1].TakenAt); // newest first
+
+ var window = await _repo.ListAsync(null, t0.AddDays(1), t0.AddDays(9), 100, CancellationToken.None);
+ Assert.Single(window);
+ Assert.Equal(t0.AddDays(5), window[0].TakenAt);
+ }
+
+ [Fact]
+ public async Task Remove_Deletes()
+ {
+ var snap = Snap(_camA, DateTime.UtcNow, SnapshotSource.LiveMain);
+ await _repo.AddAsync(snap, CancellationToken.None);
+
+ await _repo.RemoveAsync(snap.Id, CancellationToken.None);
+
+ Assert.Null(await _repo.GetAsync(snap.Id, CancellationToken.None));
+ }
+
+ private static Snapshot Snap(CameraId cam, DateTime takenAtUtc, SnapshotSource source) => new(
+ SnapshotId.New(), cam, takenAtUtc,
+ $"/snaps/{cam}/{Guid.NewGuid():N}.jpg", "/snaps/.thumbs/t.jpg",
+ 1920, 1080, source, SnapshotKind.Manual);
+
+ private static Camera Cam(string name) => new(
+ CameraId.New(), null, name, "10.0.0.5", null, 80,
+ new Uri("rtsp://10.0.0.5/main"), null,
+ null, null, false, null, null, null, true, false, false, 0,
+ DateTime.UtcNow, DateTime.UtcNow);
+}
diff --git a/tests/OpenIPC.Viewer.Video.Tests/Imaging/SkiaImagingTests.cs b/tests/OpenIPC.Viewer.Video.Tests/Imaging/SkiaImagingTests.cs
new file mode 100644
index 0000000..c02510e
--- /dev/null
+++ b/tests/OpenIPC.Viewer.Video.Tests/Imaging/SkiaImagingTests.cs
@@ -0,0 +1,77 @@
+using System.IO;
+using OpenIPC.Viewer.Core.Snapshots;
+using OpenIPC.Viewer.Video.Imaging;
+using SkiaSharp;
+
+namespace OpenIPC.Viewer.Video.Tests.Imaging;
+
+public sealed class SkiaImagingTests
+{
+ [Fact]
+ public async Task Thumbnail_DownscalesAndReportsFullSize()
+ {
+ var src = MakeJpeg(width: 200, height: 100, SKColors.Red);
+ var thumbPath = TempPath();
+
+ var size = await new SkiaThumbnailGenerator().GenerateAsync(src, thumbPath, maxDim: 64, CancellationToken.None);
+
+ Assert.Equal(200, size.Width);
+ Assert.Equal(100, size.Height);
+ Assert.True(File.Exists(thumbPath));
+ using var thumb = SKBitmap.Decode(thumbPath);
+ Assert.True(Math.Max(thumb.Width, thumb.Height) <= 64);
+ }
+
+ [Fact]
+ public async Task Editor_AppliesCrop()
+ {
+ var src = MakeJpeg(200, 100, SKColors.Red);
+ var srcPath = TempPath();
+ File.WriteAllBytes(srcPath, src);
+ var outPath = TempPath();
+
+ // Crop to the left half.
+ var edit = new SnapshotEdit(0.0, 0.0, 0.5, 1.0, Array.Empty());
+ var size = await new SkiaImageEditor().RenderAsync(srcPath, edit, outPath, CancellationToken.None);
+
+ Assert.InRange(size.Width, 95, 105);
+ Assert.Equal(100, size.Height);
+ }
+
+ [Fact]
+ public async Task Editor_DrawsAnnotationPixels()
+ {
+ var src = MakeJpeg(200, 200, SKColors.Red);
+ var srcPath = TempPath();
+ File.WriteAllBytes(srcPath, src);
+ var outPath = TempPath();
+
+ // A thick green box over a red image: the output must contain green pixels.
+ var box = new SnapshotAnnotation(AnnotationKind.Rectangle, 0.2, 0.2, 0.8, 0.8, 0xFF00FF00, 0.05, null);
+ await new SkiaImageEditor().RenderAsync(
+ srcPath, new SnapshotEdit(null, null, null, null, new[] { box }), outPath, CancellationToken.None);
+
+ using var result = SKBitmap.Decode(outPath);
+ var foundGreen = false;
+ for (var y = 0; y < result.Height && !foundGreen; y++)
+ for (var x = 0; x < result.Width; x++)
+ {
+ var p = result.GetPixel(x, y);
+ if (p.Green > 120 && p.Red < 120) { foundGreen = true; break; }
+ }
+ Assert.True(foundGreen, "Expected the green annotation to be composited onto the image");
+ }
+
+ private static byte[] MakeJpeg(int width, int height, SKColor color)
+ {
+ using var bitmap = new SKBitmap(width, height);
+ using (var canvas = new SKCanvas(bitmap))
+ canvas.Clear(color);
+ using var image = SKImage.FromBitmap(bitmap);
+ using var data = image.Encode(SKEncodedImageFormat.Jpeg, 95);
+ return data.ToArray();
+ }
+
+ private static string TempPath() =>
+ Path.Combine(Path.GetTempPath(), $"oipc-img-{Guid.NewGuid():N}.jpg");
+}
diff --git a/tests/OpenIPC.Viewer.Video.Tests/OpenIPC.Viewer.Video.Tests.csproj b/tests/OpenIPC.Viewer.Video.Tests/OpenIPC.Viewer.Video.Tests.csproj
index 12a55a0..0b8320a 100644
--- a/tests/OpenIPC.Viewer.Video.Tests/OpenIPC.Viewer.Video.Tests.csproj
+++ b/tests/OpenIPC.Viewer.Video.Tests/OpenIPC.Viewer.Video.Tests.csproj
@@ -13,6 +13,8 @@
+
+