diff --git a/src/OpenIPC.Viewer.App/Services/Localizer.cs b/src/OpenIPC.Viewer.App/Services/Localizer.cs index 4b6d1ee..5297666 100644 --- a/src/OpenIPC.Viewer.App/Services/Localizer.cs +++ b/src/OpenIPC.Viewer.App/Services/Localizer.cs @@ -497,6 +497,7 @@ private static LangCode DetectSystem() ["Grid.EmptySlot"] = "no camera", ["Grid.Tooltip.Fullscreen"] = "Fullscreen grid (F11) — Esc to exit", + ["Grid.Tooltip.Stills"] = "Stills mode — periodic snapshots instead of live video", ["Tile.Snapshot"] = "Snapshot (HD)", ["Welcome.Language"] = "Language", @@ -961,6 +962,7 @@ private static LangCode DetectSystem() ["Grid.EmptySlot"] = "нет камеры", ["Grid.Tooltip.Fullscreen"] = "Сетка на весь экран (F11) — Esc для выхода", + ["Grid.Tooltip.Stills"] = "Режим кадров — периодические снимки вместо живого видео", ["Tile.Snapshot"] = "Снимок (HD)", ["Welcome.Language"] = "Язык", diff --git a/src/OpenIPC.Viewer.App/Services/UserSettings.cs b/src/OpenIPC.Viewer.App/Services/UserSettings.cs index ebf542b..89b7dac 100644 --- a/src/OpenIPC.Viewer.App/Services/UserSettings.cs +++ b/src/OpenIPC.Viewer.App/Services/UserSettings.cs @@ -80,7 +80,12 @@ public sealed record UserSettings( int? WindowY = null, double WindowWidth = 1200, double WindowHeight = 780, - bool WindowMaximized = false) + bool WindowMaximized = false, + // Grid "stills" mode: show periodic HTTP snapshots instead of a live RTSP + // session in every grid tile — far cheaper on CPU/bandwidth for big walls. + // Interval is seconds between grabs. A per-camera override lands later. + bool GridStillsMode = false, + int GridStillsIntervalSeconds = 10) { public static UserSettings Default => new(); } diff --git a/src/OpenIPC.Viewer.App/ViewModels/CameraTileViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/CameraTileViewModel.cs index 4914c2e..06720e5 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/CameraTileViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/CameraTileViewModel.cs @@ -1,8 +1,10 @@ using System; using System.Collections.Generic; +using System.IO; using System.Reactive.Linq; using System.Threading; using System.Threading.Tasks; +using Avalonia.Media.Imaging; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; @@ -31,8 +33,23 @@ public sealed partial class CameraTileViewModel : ViewModelBase, IAsyncDisposabl private readonly AudioMonitor _audio; private readonly IReachabilityProbe _reachability; private readonly CameraStatusRegistry _statusRegistry; + private readonly ISnapshotFrameSource _frameSource; private readonly ILogger _logger; + // "Stills" mode: instead of a live RTSP session this tile shows a periodic + // HTTP snapshot (StillFrame), refreshed every _stillsIntervalSeconds. Set at + // construction from the grid setting; the tile is rebuilt when it changes. + private readonly bool _stillsMode; + private readonly int _stillsIntervalSeconds; + private CancellationTokenSource? _stillsCts; + + [ObservableProperty] private Bitmap? _stillFrame; + + // Drives the template: Image (stills) vs RtspVideoView (live). A camera with + // no cheap HTTP still (non-Majestic) stays on live RTSP even when the grid + // stills toggle is on, rather than blanking to an empty tile. + public bool StillsMode => _stillsMode && _frameSource.Supports(Camera); + // On a stream fault we TCP-probe the camera so the status policy can tell a // wedged-but-alive camera (Attention) from a truly unreachable one (Offline). // Short timeout: a screen of dead tiles must settle quickly. @@ -221,6 +238,9 @@ public CameraTileViewModel( AudioMonitor audio, IReachabilityProbe reachability, CameraStatusRegistry statusRegistry, + ISnapshotFrameSource frameSource, + bool stillsMode, + int stillsIntervalSeconds, ILogger logger) { Camera = camera; @@ -233,6 +253,9 @@ public CameraTileViewModel( _audio = audio; _reachability = reachability; _statusRegistry = statusRegistry; + _frameSource = frameSource; + _stillsMode = stillsMode; + _stillsIntervalSeconds = stillsIntervalSeconds; _logger = logger; _coordinator.Invalidated += OnCoordinatorInvalidated; @@ -279,6 +302,14 @@ public async Task ActivateAsync(CancellationToken ct) if (_started) return; _started = true; + // Stills mode: no decoder, just poll an HTTP snapshot on an interval. + // Only for cameras that expose one — others fall through to live RTSP. + if (StillsMode) + { + StartStillsLoop(); + return; + } + var streamUri = _quality == StreamQuality.Main ? Camera.RtspMainUri : (Camera.RtspSubUri ?? Camera.RtspMainUri); @@ -472,9 +503,65 @@ public void Resume() Session?.Resume(); } + // --- Stills mode -------------------------------------------------------- + + private void StartStillsLoop() + { + _stillsCts = new CancellationTokenSource(); + _ = RunStillsAsync(_stillsCts.Token); + } + + private async Task RunStillsAsync(CancellationToken ct) + { + var interval = TimeSpan.FromSeconds(Math.Max(1, _stillsIntervalSeconds)); + while (!ct.IsCancellationRequested) + { + try + { + var bytes = await _frameSource.GrabAsync(Camera, ct).ConfigureAwait(false); + var bmp = bytes is { Length: > 0 } ? DecodeJpeg(bytes) : null; + if (bmp is not null) + { + Dispatcher.UIThread.Post(() => + { + var old = StillFrame; + StillFrame = bmp; + old?.Dispose(); + }); + } + } + catch (OperationCanceledException) { break; } + catch (Exception ex) + { + _logger.LogDebug(ex, "Stills grab failed for {Name}", Camera.Name); + } + + try { await Task.Delay(interval, ct).ConfigureAwait(false); } + catch (OperationCanceledException) { break; } + } + } + + private static Bitmap? DecodeJpeg(byte[] bytes) + { + try + { + using var ms = new MemoryStream(bytes); + return new Bitmap(ms); + } + catch + { + return null; // a truncated / non-image body — keep the previous frame + } + } + public async ValueTask DisposeAsync() { _disposed = true; + _stillsCts?.Cancel(); + _stillsCts?.Dispose(); + _stillsCts = null; + StillFrame?.Dispose(); + StillFrame = null; // No live session for this camera anymore — clear our signal so the // registry falls back to the reachability probe alone. _statusRegistry.ReportSession(Camera.Id, null); diff --git a/src/OpenIPC.Viewer.App/ViewModels/GridPageViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/GridPageViewModel.cs index dcc35f1..c409300 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/GridPageViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/GridPageViewModel.cs @@ -34,6 +34,7 @@ public sealed partial class GridPageViewModel : ViewModelBase, private readonly AudioMonitor _audio; private readonly IReachabilityProbe _reachability; private readonly OpenIPC.Viewer.Core.Status.CameraStatusRegistry _statusRegistry; + private readonly OpenIPC.Viewer.Core.Snapshots.ISnapshotFrameSource _frameSource; private readonly OpenIPC.Viewer.Core.Persistence.ILayoutRepository _layouts; private readonly IDialogService _dialogs; private readonly ILoggerFactory _loggerFactory; @@ -101,6 +102,7 @@ public GridPageViewModel( AudioMonitor audio, IReachabilityProbe reachability, OpenIPC.Viewer.Core.Status.CameraStatusRegistry statusRegistry, + OpenIPC.Viewer.Core.Snapshots.ISnapshotFrameSource frameSource, OpenIPC.Viewer.Core.Persistence.ILayoutRepository layouts, IDialogService dialogs, ILoggerFactory loggerFactory) @@ -114,11 +116,15 @@ public GridPageViewModel( _audio = audio; _reachability = reachability; _statusRegistry = statusRegistry; + _frameSource = frameSource; _layouts = layouts; _dialogs = dialogs; _loggerFactory = loggerFactory; _logger = loggerFactory.CreateLogger(); + _stillsMode = _userSettings.Current.GridStillsMode; + _stillsIntervalSeconds = _userSettings.Current.GridStillsIntervalSeconds; + WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); @@ -316,6 +322,52 @@ private void ReplaceLayoutInList(GridLayout updated) if (Layouts[i].Id == updated.Id) { Layouts[i] = updated; break; } } + // --- Stills mode (grid-wide) -------------------------------------------- + // Every tile shows a periodic HTTP snapshot instead of a live RTSP session. + [ObservableProperty] private bool _stillsMode; + [ObservableProperty] private int _stillsIntervalSeconds; + + public int[] StillsIntervalOptions { get; } = { 2, 5, 10, 30, 60 }; + + partial void OnStillsModeChanged(bool value) => _ = ApplyStillsChangeAsync(); + + partial void OnStillsIntervalSecondsChanged(int value) => _ = ApplyStillsChangeAsync(); + + private async Task ApplyStillsChangeAsync() + { + await PersistStillsAsync().ConfigureAwait(true); + await RebuildTilesAsync().ConfigureAwait(true); + } + + private async Task PersistStillsAsync() + { + _suppressSettingsRefresh = true; + try + { + await _userSettings.UpdateAsync(_userSettings.Current with + { + GridStillsMode = StillsMode, + GridStillsIntervalSeconds = StillsIntervalSeconds, + }).ConfigureAwait(true); + } + catch (Exception ex) { _logger.LogWarning(ex, "Persisting stills settings failed"); } + finally { _suppressSettingsRefresh = false; } + } + + // Full teardown + rebuild so tiles pick up the new stills mode/interval — + // RefreshTilesAsync reuses live tiles and wouldn't re-read the flag. + private async Task RebuildTilesAsync() + { + for (var i = Tiles.Count - 1; i >= 0; i--) + { + var tile = Tiles[i]; + Tiles.RemoveAt(i); + try { await tile.DisposeAsync().ConfigureAwait(true); } + catch (Exception ex) { _logger.LogWarning(ex, "Error disposing tile during stills rebuild"); } + } + await RefreshTilesAsync(CancellationToken.None).ConfigureAwait(true); + } + private async Task RefreshTilesAsync(CancellationToken ct) { // One page = the visual grid (LayoutSize² cells). Within a page two caps @@ -406,7 +458,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, _snapshots, _analytics, _analyticsBootstrap, _audio, _reachability, _statusRegistry, _loggerFactory.CreateLogger()); + var rebuilt = new CameraTileViewModel(camera, _coordinator, _directory, _userSettings, _snapshots, _analytics, _analyticsBootstrap, _audio, _reachability, _statusRegistry, _frameSource, StillsMode, StillsIntervalSeconds, _loggerFactory.CreateLogger()); rebuilt.SetInitialQuality(quality); Tiles.Insert(idx, rebuilt); try { await rebuilt.ActivateAsync(ct).ConfigureAwait(true); } @@ -414,7 +466,7 @@ private async Task RefreshTilesAsync(CancellationToken ct) continue; } - var tile = new CameraTileViewModel(camera, _coordinator, _directory, _userSettings, _snapshots, _analytics, _analyticsBootstrap, _audio, _reachability, _statusRegistry, _loggerFactory.CreateLogger()); + var tile = new CameraTileViewModel(camera, _coordinator, _directory, _userSettings, _snapshots, _analytics, _analyticsBootstrap, _audio, _reachability, _statusRegistry, _frameSource, StillsMode, StillsIntervalSeconds, _loggerFactory.CreateLogger()); tile.SetInitialQuality(quality); Tiles.Add(tile); try { await tile.ActivateAsync(ct).ConfigureAwait(true); } diff --git a/src/OpenIPC.Viewer.App/Views/Pages/GridPage.axaml b/src/OpenIPC.Viewer.App/Views/Pages/GridPage.axaml index ea2e6c8..3540986 100644 --- a/src/OpenIPC.Viewer.App/Views/Pages/GridPage.axaml +++ b/src/OpenIPC.Viewer.App/Views/Pages/GridPage.axaml @@ -27,6 +27,26 @@ ToolTip.Tip="{Binding [Health.Tooltip], Source={x:Static svc:Localizer.Instance}}"> + + + + + + + + + + +