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/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"> + + + + + - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +