Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/OpenIPC.Viewer.App/Services/Localizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -961,6 +962,7 @@ private static LangCode DetectSystem()

["Grid.EmptySlot"] = "нет камеры",
["Grid.Tooltip.Fullscreen"] = "Сетка на весь экран (F11) — Esc для выхода",
["Grid.Tooltip.Stills"] = "Режим кадров — периодические снимки вместо живого видео",
["Tile.Snapshot"] = "Снимок (HD)",

["Welcome.Language"] = "Язык",
Expand Down
7 changes: 6 additions & 1 deletion src/OpenIPC.Viewer.App/Services/UserSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
87 changes: 87 additions & 0 deletions src/OpenIPC.Viewer.App/ViewModels/CameraTileViewModel.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<CameraTileViewModel> _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.
Expand Down Expand Up @@ -221,6 +238,9 @@ public CameraTileViewModel(
AudioMonitor audio,
IReachabilityProbe reachability,
CameraStatusRegistry statusRegistry,
ISnapshotFrameSource frameSource,
bool stillsMode,
int stillsIntervalSeconds,
ILogger<CameraTileViewModel> logger)
{
Camera = camera;
Expand All @@ -233,6 +253,9 @@ public CameraTileViewModel(
_audio = audio;
_reachability = reachability;
_statusRegistry = statusRegistry;
_frameSource = frameSource;
_stillsMode = stillsMode;
_stillsIntervalSeconds = stillsIntervalSeconds;
_logger = logger;

_coordinator.Invalidated += OnCoordinatorInvalidated;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
56 changes: 54 additions & 2 deletions src/OpenIPC.Viewer.App/ViewModels/GridPageViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand All @@ -114,11 +116,15 @@ public GridPageViewModel(
_audio = audio;
_reachability = reachability;
_statusRegistry = statusRegistry;
_frameSource = frameSource;
_layouts = layouts;
_dialogs = dialogs;
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<GridPageViewModel>();

_stillsMode = _userSettings.Current.GridStillsMode;
_stillsIntervalSeconds = _userSettings.Current.GridStillsIntervalSeconds;

WeakReferenceMessenger.Default.Register<WindowMinimizedMessage>(this);
WeakReferenceMessenger.Default.Register<WindowRestoredMessage>(this);
WeakReferenceMessenger.Default.Register<CloseTileMessage>(this);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -406,15 +458,15 @@ 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<CameraTileViewModel>());
var rebuilt = new CameraTileViewModel(camera, _coordinator, _directory, _userSettings, _snapshots, _analytics, _analyticsBootstrap, _audio, _reachability, _statusRegistry, _frameSource, StillsMode, StillsIntervalSeconds, _loggerFactory.CreateLogger<CameraTileViewModel>());
rebuilt.SetInitialQuality(quality);
Tiles.Insert(idx, rebuilt);
try { await rebuilt.ActivateAsync(ct).ConfigureAwait(true); }
catch (Exception ex) { _logger.LogWarning(ex, "Failed to activate rebuilt tile for {Camera}", camera.Name); }
continue;
}

var tile = new CameraTileViewModel(camera, _coordinator, _directory, _userSettings, _snapshots, _analytics, _analyticsBootstrap, _audio, _reachability, _statusRegistry, _loggerFactory.CreateLogger<CameraTileViewModel>());
var tile = new CameraTileViewModel(camera, _coordinator, _directory, _userSettings, _snapshots, _analytics, _analyticsBootstrap, _audio, _reachability, _statusRegistry, _frameSource, StillsMode, StillsIntervalSeconds, _loggerFactory.CreateLogger<CameraTileViewModel>());
tile.SetInitialQuality(quality);
Tiles.Add(tile);
try { await tile.ActivateAsync(ct).ConfigureAwait(true); }
Expand Down
28 changes: 27 additions & 1 deletion src/OpenIPC.Viewer.App/Views/Pages/GridPage.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,26 @@
ToolTip.Tip="{Binding [Health.Tooltip], Source={x:Static svc:Localizer.Instance}}">
<Path Classes="lucide" Data="{StaticResource IconActivity}" Stroke="{StaticResource TextPrimaryBrush}" Width="14" Height="14" />
</Button>
<!-- Stills mode: periodic snapshots instead of live video (cheap on big walls). -->
<ToggleButton IsChecked="{Binding StillsMode, Mode=TwoWay}"
Padding="10,6" CornerRadius="6" Margin="0,0,8,0"
Foreground="{StaticResource TextPrimaryBrush}"
ToolTip.Tip="{Binding [Grid.Tooltip.Stills], Source={x:Static svc:Localizer.Instance}}">
<Path Classes="lucide" Data="{StaticResource IconCamera}"
Stroke="{StaticResource TextPrimaryBrush}" Width="15" Height="15" />
</ToggleButton>
<ComboBox ItemsSource="{Binding StillsIntervalOptions}"
SelectedItem="{Binding StillsIntervalSeconds, Mode=TwoWay}"
IsVisible="{Binding StillsMode}"
MinWidth="64" Margin="0,0,8,0"
x:CompileBindings="False"
VerticalAlignment="Center">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding StringFormat='{}{0}s'}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<Button Content="1×1" Command="{Binding SetLayoutCommand}" CommandParameter="1"
Padding="10,6" CornerRadius="6"
Background="{StaticResource Bg2Brush}"
Expand Down Expand Up @@ -167,7 +187,13 @@
Tapped="OnTileTapped"
PointerPressed="OnTilePointerPressed"
PointerMoved="OnTilePointerMoved">
<controls:RtspVideoView Session="{Binding Session}" />
<controls:RtspVideoView Session="{Binding Session}"
IsVisible="{Binding !StillsMode}" />

<!-- Stills mode: periodic HTTP snapshot instead of live video. -->
<Image Source="{Binding StillFrame}"
Stretch="Uniform"
IsVisible="{Binding StillsMode}" />

<!-- AI detection boxes (Phase 15.5), above the video. -->
<controls:DetectionOverlay Detections="{Binding Detections}"
Expand Down
3 changes: 3 additions & 0 deletions src/OpenIPC.Viewer.Composition/SharedComposition.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ public static IServiceCollection AddSharedServices(this IServiceCollection servi
services.AddSingleton<IThumbnailGenerator, OpenIPC.Viewer.Video.Imaging.SkiaThumbnailGenerator>();
services.AddSingleton<IImageEditor, OpenIPC.Viewer.Video.Imaging.SkiaImageEditor>();
services.AddSingleton<ISnapshotService, SnapshotService>();
// Cheap HTTP still grab (no decoder) — powers the grid "stills" mode and
// the timelapse archive.
services.AddSingleton<ISnapshotFrameSource, OpenIPC.Viewer.Devices.Snapshots.HttpSnapshotFrameSource>();

// Video
services.AddSingleton<IVideoEngine, OpenIPC.Viewer.Video.FfmpegVideoEngine>();
Expand Down
24 changes: 24 additions & 0 deletions src/OpenIPC.Viewer.Core/Snapshots/ISnapshotFrameSource.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using System.Threading;
using System.Threading.Tasks;
using OpenIPC.Viewer.Core.Entities;

namespace OpenIPC.Viewer.Core.Snapshots;

/// <summary>
/// Grabs a single still frame (JPEG bytes) for a camera as cheaply as possible —
/// an HTTP snapshot endpoint, never a video decoder. Powers the grid "stills"
/// mode (periodic image refresh instead of a live RTSP session) and the private
/// timelapse archive. Returns null when no cheap source is available for the
/// camera (caller shows a placeholder / keeps the last frame).
/// </summary>
public interface ISnapshotFrameSource
{
/// <summary>
/// True when the camera exposes a cheap HTTP still endpoint (OpenIPC/Majestic
/// <c>/image.jpg</c>). Cameras without one keep their live RTSP view in grid
/// stills mode instead of blanking to an empty tile.
/// </summary>
bool Supports(Camera camera);

Task<byte[]?> GrabAsync(Camera camera, CancellationToken ct);
}
Loading
Loading