diff --git a/src/OpenIPC.Viewer.App/Controls/CameraPickerView.axaml b/src/OpenIPC.Viewer.App/Controls/CameraPickerView.axaml new file mode 100644 index 0000000..1ddc161 --- /dev/null +++ b/src/OpenIPC.Viewer.App/Controls/CameraPickerView.axaml @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/OpenIPC.Viewer.App/Controls/CameraPickerView.axaml.cs b/src/OpenIPC.Viewer.App/Controls/CameraPickerView.axaml.cs new file mode 100644 index 0000000..6efa45e --- /dev/null +++ b/src/OpenIPC.Viewer.App/Controls/CameraPickerView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace OpenIPC.Viewer.App.Controls; + +public sealed partial class CameraPickerView : UserControl +{ + public CameraPickerView() + { + InitializeComponent(); + } +} diff --git a/src/OpenIPC.Viewer.App/Converters/LayoutTabConverters.cs b/src/OpenIPC.Viewer.App/Converters/LayoutTabConverters.cs index 55694ee..c3d55d4 100644 --- a/src/OpenIPC.Viewer.App/Converters/LayoutTabConverters.cs +++ b/src/OpenIPC.Viewer.App/Converters/LayoutTabConverters.cs @@ -16,11 +16,80 @@ public sealed class LayoutTabBrushConverter : IMultiValueConverter public static readonly LayoutTabBrushConverter Instance = new(); public object? Convert(IList values, Type targetType, object? parameter, CultureInfo culture) + => ThemeResource.Brush(LayoutTabIsActiveConverter.IsActive(values) ? "AccentBrush" : "Bg2Brush"); +} + +// Same inputs as LayoutTabBrushConverter; true only for the active tab (drives +// the tab's "⋯" actions button). +public sealed class LayoutTabIsActiveConverter : IMultiValueConverter +{ + public static readonly LayoutTabIsActiveConverter Instance = new(); + + public object? Convert(IList values, Type targetType, object? parameter, CultureInfo culture) + => IsActive(values); + + internal static bool IsActive(IList values) + => values.Count > 1 && values[0] is GridLayout self + && values[1] is GridLayout act + && self.Id == act.Id; +} + +// Grid-size menu: value is the layout's current size, parameter the menu item's +// size ("1".."5"). The current size's glyph is drawn in the accent colour. +public sealed class GridSizeBrushConverter : IValueConverter +{ + public static readonly GridSizeBrushConverter Instance = new(); + + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + var current = value is int n && parameter is string p + && int.TryParse(p, NumberStyles.Integer, CultureInfo.InvariantCulture, out var size) + && n == size; + return ThemeResource.Brush(current ? "AccentBrush" : "TextSecondaryBrush"); + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException(); +} + +// Grid size (1..5) → the matching IconGrid{n} geometry, so the size button +// shows the shape of the current grid. +public sealed class GridSizeIconConverter : IValueConverter +{ + public static readonly GridSizeIconConverter Instance = new(); + + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + var n = value is int i ? Math.Clamp(i, 1, 5) : 2; + object? res = null; + return Application.Current?.Resources.TryGetResource($"IconGrid{n}", null, out res) == true ? res : null; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException(); +} + +// Resource key (e.g. "IconCar") → the theme resource, so view models can pick +// an icon without referencing Avalonia types. +public sealed class ResourceKeyConverter : IValueConverter +{ + public static readonly ResourceKeyConverter Instance = new(); + + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is not string key) return null; + object? res = null; + return Application.Current?.Resources.TryGetResource(key, null, out res) == true ? res : null; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException(); +} + +internal static class ThemeResource +{ + public static IBrush Brush(string key) { - var active = values.Count > 0 && values[0] is GridLayout self - && values[1] is GridLayout act - && self.Id == act.Id; - var key = active ? "AccentBrush" : "Bg2Brush"; object? res = null; if (Application.Current?.Resources.TryGetResource(key, null, out res) == true && res is IBrush brush) return brush; diff --git a/src/OpenIPC.Viewer.App/Messages/OpenCameraMessage.cs b/src/OpenIPC.Viewer.App/Messages/OpenCameraMessage.cs index 4ca613b..b3e768f 100644 --- a/src/OpenIPC.Viewer.App/Messages/OpenCameraMessage.cs +++ b/src/OpenIPC.Viewer.App/Messages/OpenCameraMessage.cs @@ -9,10 +9,21 @@ public sealed record GoBackToLibraryMessage; // Phase 16: open the recordings player on a recorded segment, and return to // the recordings list. CameraName travels with the message so the player can -// label itself without another directory lookup. -public sealed record OpenRecordingMessage(Recording Recording, string CameraName); +// label itself without another directory lookup. StartAt (offset into the +// file) lets the Events page open a recording at the moment of an event; +// Rate carries the player's speed over to the previous/next recording. +public sealed record OpenRecordingMessage(Recording Recording, string CameraName, TimeSpan? StartAt = null, double Rate = 1.0); -public sealed record GoBackToRecordingsMessage; +// Reload: the player changed the archive (deleted its recording), so the list +// must be re-read instead of shown from cache. +public sealed record GoBackToRecordingsMessage(bool Reload = false); + +// Recording player asks for (or leaves) chrome-free fullscreen; the main window +// owns the state so it survives previous/next swapping the player VM. +public sealed record SetPlayerFullscreenMessage(bool On); + +// AI page → Events filtered to AI detections. +public sealed record ShowDetectionEventsMessage; public sealed record WindowMinimizedMessage; diff --git a/src/OpenIPC.Viewer.App/Services/CameraConnectService.cs b/src/OpenIPC.Viewer.App/Services/CameraConnectService.cs new file mode 100644 index 0000000..ab41f1e --- /dev/null +++ b/src/OpenIPC.Viewer.App/Services/CameraConnectService.cs @@ -0,0 +1,295 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Reactive.Linq; +using System.Reactive.Threading.Tasks; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using OpenIPC.Viewer.Core.Entities; +using OpenIPC.Viewer.Core.Majestic; +using OpenIPC.Viewer.Core.Onvif; +using OpenIPC.Viewer.Core.Services; +using OpenIPC.Viewer.Core.Video; + +namespace OpenIPC.Viewer.App.Services; + +public enum CameraConnectFailure { None, Unreachable, Unauthorized, NoVideo, Timeout } + +// What the camera editor sends to "Connect". RtspMain/RtspSub are only the +// user's own URIs — null means "work it out". +public sealed record CameraConnectRequest( + string Host, + int HttpPort, + int? OnvifPort, + Uri? RtspMain, + Uri? RtspSub, + CameraCredentials? Credentials); + +public sealed record CameraConnectResult( + CameraConnectFailure Failure, + TimeSpan Elapsed, + bool IsMajestic, + MajesticInfo? Majestic, + OnvifProbeResult? Onvif, + int? OnvifPort, + Uri? RtspMain, + Uri? RtspSub, + int MainWidth, + int MainHeight, + string? MainCodec, + int SubWidth, + int SubHeight, + bool HasAudio, + byte[]? SnapshotJpeg, + string? ErrorDetail) +{ + public bool Ok => Failure == CameraConnectFailure.None; + public bool HasPtz => Onvif?.HasPtz == true; +} + +// "Connect" in the camera editor: works out what sits at an address and how to +// stream it. Identifies the device (OpenIPC/Majestic web API, ONVIF), picks the +// RTSP URIs, then proves them by decoding a frame — the same pipeline the live +// view uses — and grabs a still for the result card. +public sealed class CameraConnectService +{ + private static readonly TimeSpan ReachTimeout = TimeSpan.FromSeconds(2); + private static readonly TimeSpan IdentifyTimeout = TimeSpan.FromSeconds(6); + private static readonly TimeSpan MainStreamTimeout = TimeSpan.FromSeconds(8); + private static readonly TimeSpan SubStreamTimeout = TimeSpan.FromSeconds(6); + + // Where ONVIF device services commonly live when the user didn't say. + private static readonly int[] OnvifPortGuesses = { 80, 8899, 8000, 2020, 8080 }; + + private readonly IVideoEngine _engine; + private readonly OnvifProbeService _onvif; + private readonly IOnvifFingerprint _onvifFingerprint; + private readonly IMajesticClient _majestic; + private readonly IReachabilityProbe _reach; + private readonly UserSettingsService _settings; + private readonly ILogger _logger; + + public CameraConnectService( + IVideoEngine engine, + OnvifProbeService onvif, + IOnvifFingerprint onvifFingerprint, + IMajesticClient majestic, + IReachabilityProbe reach, + UserSettingsService settings, + ILogger logger) + { + _engine = engine; + _onvif = onvif; + _onvifFingerprint = onvifFingerprint; + _majestic = majestic; + _reach = reach; + _settings = settings; + _logger = logger; + } + + // OpenIPC firmware serves these two paths on the default RTSP port. + public static Uri MajesticMain(string host) => new($"rtsp://{host}:554/stream0"); + public static Uri MajesticSub(string host) => new($"rtsp://{host}:554/stream1"); + + public async Task ConnectAsync(CameraConnectRequest req, CancellationToken ct) + { + var sw = Stopwatch.StartNew(); + var host = req.Host; + var rtspPort = req.RtspMain is { } um && um.Port > 0 ? um.Port : 554; + + // 1. Anyone home? Fails fast instead of waiting out every timeout. + var reachRtsp = _reach.IsReachableAsync(host, rtspPort, ReachTimeout, ct); + var reachHttp = _reach.IsReachableAsync(host, req.HttpPort, ReachTimeout, ct); + var reachable = (await Task.WhenAll(reachRtsp, reachHttp).ConfigureAwait(true)).Any(r => r); + if (!reachable) + return Fail(CameraConnectFailure.Unreachable, sw, null); + + // 2. Identify, both ways at once. + var majTask = IdentifyMajesticAsync(host, req.HttpPort, req.Credentials, ct); + var onvifTask = IdentifyOnvifAsync(host, req.HttpPort, req.OnvifPort, req.Credentials, ct); + await Task.WhenAll(majTask, onvifTask).ConfigureAwait(true); + var maj = majTask.Result; + var onv = onvifTask.Result; + + // 3. Streams: the user's own URI wins; then OpenIPC's fixed paths, then + // what ONVIF reported, then the bare host. + var mainGuessed = req.RtspMain is null; + var main = req.RtspMain + ?? (maj.IsMajestic ? MajesticMain(host) : onv.Probe?.RtspMainUri ?? new Uri($"rtsp://{host}/")); + var subGuessed = req.RtspSub is null; + var sub = req.RtspSub ?? (maj.IsMajestic ? MajesticSub(host) : null); + + var mainTask = TryStreamAsync(main, req.Credentials, MainStreamTimeout, snapshot: true, ct); + var subTask = sub is null + ? Task.FromResult(StreamProbe.None) + : TryStreamAsync(sub, req.Credentials, SubStreamTimeout, snapshot: false, ct); + await Task.WhenAll(mainTask, subTask).ConfigureAwait(true); + var m = mainTask.Result; + var s = subTask.Result; + + // A stock ONVIF URI can fail where the bare host works (and vice versa + // for a guess) — one fallback before giving up. + if (!m.Ok && mainGuessed && onv.Probe is null && !maj.IsMajestic && !m.Unauthorized) + { + var alt = new Uri($"rtsp://{host}:554/stream0"); + var retry = await TryStreamAsync(alt, req.Credentials, MainStreamTimeout, snapshot: true, ct).ConfigureAwait(true); + if (retry.Ok) { m = retry; main = alt; } + } + + if (!s.Ok && subGuessed) sub = null; // don't keep a sub path we invented and couldn't open + + var hasAudio = onv.Probe?.HasAudioIn == true || maj.AudioEnabled == true; + if (!m.Ok) + { + var failure = m.Unauthorized || maj.Unauthorized || onv.Unauthorized + ? CameraConnectFailure.Unauthorized + : m.TimedOut ? CameraConnectFailure.Timeout : CameraConnectFailure.NoVideo; + return new CameraConnectResult(failure, sw.Elapsed, maj.IsMajestic, maj.Info, onv.Probe, onv.Port, + main, sub, 0, 0, null, 0, 0, hasAudio, null, m.Error); + } + + return new CameraConnectResult(CameraConnectFailure.None, sw.Elapsed, maj.IsMajestic, maj.Info, onv.Probe, onv.Port, + main, sub, m.Width, m.Height, m.Codec, s.Width, s.Height, hasAudio, m.Jpeg, null); + } + + private static CameraConnectResult Fail(CameraConnectFailure f, Stopwatch sw, string? detail) => + new(f, sw.Elapsed, false, null, null, null, null, null, 0, 0, null, 0, 0, false, null, detail); + + private sealed record MajesticId(bool IsMajestic, MajesticInfo? Info, bool? AudioEnabled, bool Unauthorized); + + private async Task IdentifyMajesticAsync(string host, int port, CameraCredentials? creds, CancellationToken ct) + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(IdentifyTimeout); + try + { + var anon = new MajesticEndpoint(host, port, null); + if (!await _majestic.PingAsync(anon, cts.Token).ConfigureAwait(false)) + return new MajesticId(false, null, null, false); + + var endpoint = new MajesticEndpoint(host, port, creds); + MajesticInfo? info = null; + bool? audio = null; + var unauthorized = false; + try { info = await _majestic.GetInfoAsync(endpoint, cts.Token).ConfigureAwait(false); } + catch (Exception ex) when (ex is not OperationCanceledException) { unauthorized |= IsAuthError(ex); } + try { audio = (await _majestic.GetConfigAsync(endpoint, cts.Token).ConfigureAwait(false)).AudioEnabled; } + catch (Exception ex) when (ex is not OperationCanceledException) { unauthorized |= IsAuthError(ex); } + return new MajesticId(true, info, audio, unauthorized); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Majestic identify failed for {Host}", host); + return new MajesticId(false, null, null, false); + } + } + + private sealed record OnvifId(OnvifProbeResult? Probe, int? Port, bool Unauthorized); + + private async Task IdentifyOnvifAsync(string host, int httpPort, int? onvifPort, CameraCredentials? creds, CancellationToken ct) + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(IdentifyTimeout); + try + { + var ports = (onvifPort is { } p ? (IEnumerable)new[] { p } : new[] { httpPort }.Concat(OnvifPortGuesses)).Distinct().ToArray(); + // Cheap anonymous SOAP ping on every candidate, then the real + // (authenticated) probe on the first port that answers. + var hits = await Task.WhenAll(ports.Select(async port => + (Port: port, Uri: await _onvifFingerprint.ProbeAsync(host, port, cts.Token).ConfigureAwait(false)))) + .ConfigureAwait(false); + var hit = hits.FirstOrDefault(h => h.Uri is not null); + if (hit.Uri is null) + return new OnvifId(null, null, false); + + try + { + var probe = await _onvif.ProbeAsync(new OnvifEndpoint(hit.Uri, creds), cts.Token).ConfigureAwait(false); + return new OnvifId(probe, hit.Port, false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // The WCF probe doesn't run on every platform (Android) — the + // port is still worth keeping. + _logger.LogDebug(ex, "ONVIF probe failed for {Host}:{Port}", host, hit.Port); + return new OnvifId(null, hit.Port, IsAuthError(ex)); + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "ONVIF identify failed for {Host}", host); + return new OnvifId(null, onvifPort, false); + } + } + + private sealed record StreamProbe(bool Ok, int Width, int Height, string? Codec, byte[]? Jpeg, bool Unauthorized, bool TimedOut, string? Error) + { + public static readonly StreamProbe None = new(false, 0, 0, null, null, false, false, null); + } + + private async Task TryStreamAsync(Uri uri, CameraCredentials? creds, TimeSpan timeout, bool snapshot, CancellationToken ct) + { + // Same transport the live view will use — a UDP-only setup used to pass + // playback but fail the test (which was hardwired to TCP). + var transport = string.Equals(_settings.Current.RtspTransport, "udp", StringComparison.OrdinalIgnoreCase) + ? RtspTransport.Udp + : RtspTransport.Tcp; + var options = VideoSessionOptions.Default(uri, creds) with { Transport = transport, AutoReconnect = false }; + var session = _engine.CreateSession(options); + string? codec = null; + using var telemetry = session.Telemetry.Subscribe(t => codec ??= t.Codec); + try + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(timeout); + // A failed open surfaces as a Failed state, not an exception — race it. + var failed = session.StateChanged.Where(s => s == SessionState.Failed).Take(1).DefaultIfEmpty(SessionState.Idle).ToTask(cts.Token); + await session.StartAsync(cts.Token).ConfigureAwait(false); + var frameTask = session.Frames.Take(1).ToTask(cts.Token); + var first = await Task.WhenAny(frameTask, failed).ConfigureAwait(false); + if (first == failed) + { + await failed.ConfigureAwait(false); + var err = session.LastError; + return new StreamProbe(false, 0, 0, null, null, IsAuthMessage(err), false, err); + } + var frame = await frameTask.ConfigureAwait(false); + byte[]? jpeg = null; + if (snapshot) + { + // Telemetry (with the codec name) is published about once a + // second, so it lags the first frame; give it a moment. + for (var i = 0; i < 15 && Volatile.Read(ref codec) is null; i++) + await Task.Delay(100, cts.Token).ConfigureAwait(false); + + try { jpeg = await session.SnapshotAsync(SnapshotFormat.Jpeg, cts.Token).ConfigureAwait(false); } + catch (Exception ex) when (ex is not OperationCanceledException) { _logger.LogDebug(ex, "Connect snapshot failed"); } + } + return new StreamProbe(true, frame.Width, frame.Height, codec, jpeg is { Length: > 0 } ? jpeg : null, false, false, null); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + return new StreamProbe(false, 0, 0, null, null, IsAuthMessage(session.LastError), true, session.LastError); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogDebug(ex, "Stream test failed for {Uri}", uri.GetLeftPart(UriPartial.Authority)); + return new StreamProbe(false, 0, 0, null, null, IsAuthMessage(ex.Message), false, ex.Message); + } + finally + { + await session.DisposeAsync().ConfigureAwait(false); + } + } + + private static bool IsAuthError(Exception ex) => IsAuthMessage(ex.Message) || (ex.InnerException is { } inner && IsAuthError(inner)); + + private static bool IsAuthMessage(string? message) => + message is not null + && (message.Contains("401", StringComparison.Ordinal) + || message.Contains("Unauthorized", StringComparison.OrdinalIgnoreCase) + || message.Contains("NotAuthorized", StringComparison.OrdinalIgnoreCase) + || message.Contains("not authorized", StringComparison.OrdinalIgnoreCase)); +} diff --git a/src/OpenIPC.Viewer.App/Services/CameraEditService.cs b/src/OpenIPC.Viewer.App/Services/CameraEditService.cs new file mode 100644 index 0000000..3b2343d --- /dev/null +++ b/src/OpenIPC.Viewer.App/Services/CameraEditService.cs @@ -0,0 +1,53 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using OpenIPC.Viewer.Core.Entities; +using OpenIPC.Viewer.Core.Services; + +namespace OpenIPC.Viewer.App.Services; + +// Opens the camera editor for an existing camera and saves the result. Shared by +// the Cameras page and the AI page ("Configure" on a detection camera). +public sealed class CameraEditService +{ + private readonly CameraEditorFactory _editorFactory; + private readonly IDialogService _dialogs; + private readonly CameraDirectoryService _directory; + private readonly ILogger _logger; + + public CameraEditService( + CameraEditorFactory editorFactory, + IDialogService dialogs, + CameraDirectoryService directory, + ILogger logger) + { + _editorFactory = editorFactory; + _dialogs = dialogs; + _directory = directory; + _logger = logger; + } + + // True when the camera was changed and saved. + public async Task EditAsync(Camera camera) + { + var creds = await _directory.GetCredentialsAsync(camera.Id, CancellationToken.None).ConfigureAwait(true); + var sshCreds = await _directory.GetSshCredentialsAsync(camera.Id, CancellationToken.None).ConfigureAwait(true); + var editor = _editorFactory.CreateForEdit(camera, creds, sshCreds); + var result = await _dialogs.ShowCameraEditorAsync(editor).ConfigureAwait(true); + if (result?.UpdateRequest is not { } req) + return false; + + try + { + await _directory.UpdateAsync(camera.Id, req, CancellationToken.None).ConfigureAwait(true); + await _directory.SaveDetectedAsync(camera.Id, result, CancellationToken.None).ConfigureAwait(true); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to update camera {Id}", camera.Id); + return false; + } + } +} diff --git a/src/OpenIPC.Viewer.App/Services/CameraEditorFactory.cs b/src/OpenIPC.Viewer.App/Services/CameraEditorFactory.cs index 0a939d4..469382b 100644 --- a/src/OpenIPC.Viewer.App/Services/CameraEditorFactory.cs +++ b/src/OpenIPC.Viewer.App/Services/CameraEditorFactory.cs @@ -2,28 +2,27 @@ using OpenIPC.Viewer.App.ViewModels.Dialogs; using OpenIPC.Viewer.Core.Entities; using OpenIPC.Viewer.Core.Services; -using OpenIPC.Viewer.Core.Video; namespace OpenIPC.Viewer.App.Services; public sealed class CameraEditorFactory { - private readonly IVideoEngine _engine; + private readonly CameraConnectService _connect; private readonly CameraDirectoryService _directory; - private readonly UserSettingsService _userSettings; + private readonly IDialogService _dialogs; private readonly ILoggerFactory _loggerFactory; - public CameraEditorFactory(IVideoEngine engine, CameraDirectoryService directory, UserSettingsService userSettings, ILoggerFactory loggerFactory) + public CameraEditorFactory(CameraConnectService connect, CameraDirectoryService directory, IDialogService dialogs, ILoggerFactory loggerFactory) { - _engine = engine; + _connect = connect; _directory = directory; - _userSettings = userSettings; + _dialogs = dialogs; _loggerFactory = loggerFactory; } public CameraEditorViewModel CreateForNew() => - new(_engine, _directory, _userSettings, _loggerFactory.CreateLogger()); + new(_connect, _directory, _dialogs, _loggerFactory.CreateLogger()); public CameraEditorViewModel CreateForEdit(Camera existing, CameraCredentials? credentials, CameraCredentials? sshCredentials) => - new(existing, credentials, sshCredentials, _engine, _directory, _userSettings, _loggerFactory.CreateLogger()); + new(existing, credentials, sshCredentials, _connect, _directory, _dialogs, _loggerFactory.CreateLogger()); } diff --git a/src/OpenIPC.Viewer.App/Services/CameraEditorResultExtensions.cs b/src/OpenIPC.Viewer.App/Services/CameraEditorResultExtensions.cs new file mode 100644 index 0000000..01cac3c --- /dev/null +++ b/src/OpenIPC.Viewer.App/Services/CameraEditorResultExtensions.cs @@ -0,0 +1,20 @@ +using System.Threading; +using System.Threading.Tasks; +using OpenIPC.Viewer.App.ViewModels.Dialogs; +using OpenIPC.Viewer.Core.Entities; +using OpenIPC.Viewer.Core.Services; + +namespace OpenIPC.Viewer.App.Services; + +public static class CameraEditorResultExtensions +{ + // Stores what the editor's Connect learned (PTZ/ONVIF profile, OpenIPC + // web API) so the camera page knows about it without probing again. + public static async Task SaveDetectedAsync(this CameraDirectoryService directory, CameraId id, CameraEditorResult result, CancellationToken ct) + { + if (result.Onvif is { } onvif) + await directory.SaveOnvifMetadataAsync(id, onvif, ct).ConfigureAwait(false); + if (result.IsMajestic is { } majestic) + await directory.SetIsMajesticAsync(id, majestic, ct).ConfigureAwait(false); + } +} diff --git a/src/OpenIPC.Viewer.App/Services/DetectionClasses.cs b/src/OpenIPC.Viewer.App/Services/DetectionClasses.cs new file mode 100644 index 0000000..bab45a4 --- /dev/null +++ b/src/OpenIPC.Viewer.App/Services/DetectionClasses.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace OpenIPC.Viewer.App.Services; + +// Presentation helpers for AI detection class labels (COCO names as the model +// emits them, e.g. "car"): localized name, icon, and parsing of an event's +// "person ×2, car ×1" summary. +public static class DetectionClasses +{ + // Localizer returns the key itself when a translation is missing — fall + // back to the model's own label then. + public static string Localize(string label) + { + var key = "Coco." + label.ToLowerInvariant(); + var text = Localizer.Instance[key]; + return text == key ? label : text; + } + + // Theme.axaml geometry key for the class; similar classes share one. + public static string IconKey(string label) => label.ToLowerInvariant() switch + { + "person" => "IconPerson", + "car" => "IconCar", + "truck" or "bus" or "train" => "IconTruck", + "bicycle" or "motorcycle" => "IconBike", + _ => "IconScanSearch", + }; + + // "person ×2, car ×1" → { person: 2, car: 1 }. + public static Dictionary Parse(string? summary) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (string.IsNullOrWhiteSpace(summary)) return result; + foreach (var part in summary.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var bits = part.Split('×', StringSplitOptions.TrimEntries); + var label = bits[0]; + if (label.Length == 0) continue; + var count = bits.Length > 1 && int.TryParse(bits[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var c) ? c : 1; + result[label] = result.TryGetValue(label, out var cur) ? Math.Max(cur, count) : count; + } + return result; + } +} + +// A class with a count, rendered as an icon chip ("🚗 машина 84"). +public sealed record DetectionClassChip(string Label, int Count, string IconKey) +{ + public string Name => DetectionClasses.Localize(Label); + public bool HasCount => Count > 0; + public string CountLabel => Count > 0 ? $"×{Count}" : ""; +} diff --git a/src/OpenIPC.Viewer.App/Services/DiscoveryDialogFactory.cs b/src/OpenIPC.Viewer.App/Services/DiscoveryDialogFactory.cs index 7de685c..4623bfa 100644 --- a/src/OpenIPC.Viewer.App/Services/DiscoveryDialogFactory.cs +++ b/src/OpenIPC.Viewer.App/Services/DiscoveryDialogFactory.cs @@ -3,14 +3,12 @@ using OpenIPC.Viewer.App.ViewModels.Dialogs; using OpenIPC.Viewer.Core.Discovery; using OpenIPC.Viewer.Core.Majestic; -using OpenIPC.Viewer.Core.Onvif; namespace OpenIPC.Viewer.App.Services; public sealed class DiscoveryDialogFactory { private readonly IDiscoveryAggregator _aggregator; - private readonly OnvifProbeService _probe; private readonly IMajesticClient _majestic; private readonly IScanTargetProvider _scanTargets; private readonly DiscoverySessionCache _cache; @@ -18,23 +16,25 @@ public sealed class DiscoveryDialogFactory public DiscoveryDialogFactory( IDiscoveryAggregator aggregator, - OnvifProbeService probe, IMajesticClient majestic, IScanTargetProvider scanTargets, DiscoverySessionCache cache, ILoggerFactory loggerFactory) { _aggregator = aggregator; - _probe = probe; _majestic = majestic; _scanTargets = scanTargets; _cache = cache; _loggerFactory = loggerFactory; } + // Session memory (results, deep-scan choices, last login) shared by every + // dialog opened this run. + public DiscoverySessionCache Session => _cache; + // knownHosts: hosts of cameras already in the library, so rows can carry // an "already added" badge during the multi-add loop. public DiscoveryDialogViewModel Create(IReadOnlySet knownHosts) => - new(_aggregator, _probe, _majestic, _scanTargets, _cache, knownHosts, + new(_aggregator, _majestic, _scanTargets, _cache, knownHosts, _loggerFactory.CreateLogger()); } diff --git a/src/OpenIPC.Viewer.App/Services/Localizer.cs b/src/OpenIPC.Viewer.App/Services/Localizer.cs index 787533e..bf9c1ae 100644 --- a/src/OpenIPC.Viewer.App/Services/Localizer.cs +++ b/src/OpenIPC.Viewer.App/Services/Localizer.cs @@ -94,9 +94,23 @@ private static LangCode DetectSystem() ["Analytics.Dropped"] = "Frames dropped", ["Analytics.Queue"] = "Queue:", ["Analytics.Latency"] = "Latency:", - ["Analytics.Cameras"] = "Cameras with detection", - ["Analytics.RecentDetections"] = "Recent detections", + ["Analytics.Cameras"] = "Detection on cameras", + ["Analytics.RecentDetections"] = "Last 24 hours", ["Analytics.AllClasses"] = "all classes", + ["Analytics.SettingsFormat"] = "{0} fps · ≥{1:0.00}", + ["Analytics.Detections24hFormat"] = "{0} in 24 h", + ["Analytics.EnableOnCamera"] = "Enable on a camera", + ["Analytics.EnableSearch"] = "Name or IP", + ["Analytics.NoCameras"] = "Detection isn't on for any camera yet. It runs on this computer, separately for each camera — pick one to start.", + ["Analytics.NotStartedHint"] = "The engine starts by itself when a camera with detection is open in Live or on its page. The model downloads on first start.", + ["Analytics.Configure"] = "Configure", + ["Analytics.AllDetections"] = "All detections in Events →", + ["Analytics.NoDetections24h"] = "No detections in the last 24 hours", + ["Analytics.Tile.Cameras"] = "Cameras running", + ["Analytics.Tile.Processed"] = "Frames processed", + ["Analytics.Tile.Dropped"] = "Dropped", + ["Analytics.Tile.Latency"] = "Latency", + ["Analytics.Tile.Queue"] = "Queue", ["Common.Cancel"] = "Cancel", ["Common.Delete"] = "Delete", @@ -110,6 +124,8 @@ private static LangCode DetectSystem() ["Layouts.Tooltip.Rename"] = "Rename layout", ["Layouts.Tooltip.Delete"] = "Delete layout", ["Layouts.Tooltip.Cameras"] = "Add / remove cameras", + ["Layouts.Tooltip.Menu"] = "Layout actions", + ["Layouts.Add"] = "Layout", ["LayoutCameras.WindowTitle"] = "Layout cameras", ["LayoutCameras.TitleFormat"] = "Cameras in “{0}”", ["LayoutCameras.Empty"] = "No cameras in your library yet", @@ -156,6 +172,17 @@ private static LangCode DetectSystem() ["Library.RowDelete"] = "Delete", ["Library.RowShowInGrid"] = "Show in grid", ["Library.RowDevice"] = "Device", + ["Library.RowLayouts"] = "Layouts", + ["Library.SearchPlaceholder"] = "Name or IP", + ["Library.FindOnNetwork"] = "Find on network", + ["Library.NoMatches"] = "No cameras match these filters", + ["Library.Filter.All"] = "All", + ["Library.Filter.Online"] = "Online", + ["Library.Filter.Offline"] = "Offline", + ["Library.Filter.Attention"] = "Attention", + ["Library.Tooltip.More"] = "More actions", + ["Library.Tooltip.ManageGroups"] = "Manage groups", + ["Library.Tooltip.Refresh"] = "Reload the list and re-check status", ["Firmware.Title"] = "Device maintenance", ["Firmware.NoCredentials"] = "No SSH credentials for this camera — add them in Edit to manage the device.", ["Firmware.Time"] = "Time", @@ -311,54 +338,86 @@ private static LangCode DetectSystem() ["Snapshot.SaveAs"] = "Save as…", ["CameraEditor.Title.Add"] = "Add camera", - ["CameraEditor.Title.Edit"] = "Edit camera", - ["CameraEditor.Label.Name"] = "Name", - ["CameraEditor.Label.Host"] = "Host", - ["CameraEditor.Label.HttpPort"] = "HTTP port", - ["CameraEditor.Label.OnvifPort"] = "ONVIF port (optional)", - ["CameraEditor.Label.RtspMain"] = "RTSP main URI", - ["CameraEditor.Label.RtspSub"] = "RTSP sub URI (optional)", + ["CameraEditor.Title.EditFormat"] = "Camera · {0}", + ["CameraEditor.Section.Connection"] = "CONNECTION", + ["CameraEditor.Section.Name"] = "NAME AND GROUP", + ["CameraEditor.Section.Advanced"] = "ADVANCED", + ["CameraEditor.Label.Address"] = "Address", + ["CameraEditor.Placeholder.Address"] = "192.168.1.10 or rtsp://…", + ["CameraEditor.Hint.Address"] = "IP, ip:port or a whole rtsp:// link — it gets split up for you", ["CameraEditor.Label.Username"] = "Username", ["CameraEditor.Label.Password"] = "Password", + ["CameraEditor.Placeholder.Username"] = "admin", + ["CameraEditor.Button.Connect"] = "Connect", + ["CameraEditor.Hint.Connect"] = "detects the type, streams and ports", + ["CameraEditor.Status.Connecting"] = "Connecting…", + ["CameraEditor.Status.ConnectedFormat"] = "connected in {0:0.0} s", + ["CameraEditor.Status.Failed"] = "couldn't connect", + ["CameraEditor.Result.FirmwareFormat"] = "firmware {0}", + ["CameraEditor.Result.Generic"] = "RTSP camera", + ["CameraEditor.Pill.SubFormat"] = "{0}×{1} sub", + ["CameraEditor.Pill.Audio"] = "audio", + ["CameraEditor.Fail.Unreachable"] = "The camera doesn't respond", + ["CameraEditor.Fail.UnreachableHint"] = "Check the address and that the camera is on this network.", + ["CameraEditor.Fail.Auth"] = "Wrong username or password", + ["CameraEditor.Fail.AuthHint"] = "The camera rejected the credentials.", + ["CameraEditor.Fail.AuthHintOpenIpc"] = "The camera rejected the credentials. OpenIPC ships with root / 12345.", + ["CameraEditor.Fail.AuthNeeded"] = "The camera needs a username and password", + ["CameraEditor.Fail.AuthNeededHint"] = "Enter them above and press Connect.", + ["CameraEditor.Fail.AuthNeededHintOpenIpc"] = "Enter them above and press Connect. OpenIPC ships with root / 12345.", + ["CameraEditor.Fail.NoVideo"] = "The camera answers, but the video stream didn't open", + ["CameraEditor.Fail.Timeout"] = "The camera took too long to send video", + ["CameraEditor.Fail.NoVideoHint"] = "Check the stream path under Advanced → Streams, or pick your vendor's template.", + ["CameraEditor.Label.Name"] = "Name", ["CameraEditor.Label.Group"] = "Group", - ["CameraEditor.Label.SshSection"] = "SSH (optional)", + ["CameraEditor.NoGroup"] = "(no group)", + ["CameraEditor.NewGroup"] = "+ New group", + ["CameraEditor.NewGroup.Title"] = "New group", + ["CameraEditor.Adv.Streams"] = "Streams", + ["CameraEditor.Adv.Ports"] = "Ports", + ["CameraEditor.Adv.Ssh"] = "SSH access", + ["CameraEditor.Adv.Quality"] = "Grid quality", + ["CameraEditor.Adv.Ai"] = "AI detection", + ["CameraEditor.Summary.Auto"] = "auto", + ["CameraEditor.Summary.MainOnly"] = "main", + ["CameraEditor.Summary.MainSub"] = "main + sub", + ["CameraEditor.Summary.SameLogin"] = "same as main", + ["CameraEditor.Summary.Off"] = "off", + ["CameraEditor.Summary.On"] = "on", + ["CameraEditor.Label.RtspMain"] = "Main stream", + ["CameraEditor.Label.RtspSub"] = "Sub stream (optional)", + ["CameraEditor.Placeholder.RtspMain"] = "rtsp://… — filled in on Connect", + ["CameraEditor.Placeholder.RtspSub"] = "rtsp://…", + ["CameraEditor.Button.RtspTemplates"] = "Vendor templates ▾", + ["CameraEditor.Tooltip.RtspTemplates"] = "Fill in the RTSP URLs a specific camera vendor uses", + ["CameraEditor.Label.HttpPort"] = "HTTP", + ["CameraEditor.Label.OnvifPort"] = "ONVIF", + ["CameraEditor.Label.SshPort"] = "SSH", + ["CameraEditor.Placeholder.PortAuto"] = "auto", + ["CameraEditor.Hint.Ssh"] = "Leave empty to use the main username and password.", ["CameraEditor.Label.StreamQuality"] = "Grid stream quality", ["CameraEditor.Quality.Auto"] = "Auto (SD/HD)", ["CameraEditor.Quality.AlwaysHd"] = "Always HD (main)", ["CameraEditor.Quality.AlwaysSd"] = "Always SD (sub)", - ["CameraEditor.Analytics.Section"] = "AI analytics", ["CameraEditor.Analytics.Enable"] = "Enable object detection", ["CameraEditor.Analytics.Classes"] = "Classes", ["CameraEditor.Analytics.Threshold"] = "Confidence threshold", ["CameraEditor.Analytics.Fps"] = "Analysis FPS", ["CameraEditor.Analytics.AutoRecord"] = "Auto-record on detection", ["CameraEditor.Analytics.PostEvent"] = "Keep recording after (s)", - ["CameraEditor.Placeholder.Name"] = "Front door", - ["CameraEditor.Placeholder.Host"] = "192.168.1.10", - ["CameraEditor.Placeholder.OnvifPort"] = "8899", - ["CameraEditor.Placeholder.RtspMain"] = "rtsp://192.168.1.10/", - ["CameraEditor.Placeholder.RtspSub"] = "rtsp://192.168.1.10/stream1", - ["CameraEditor.Placeholder.Username"] = "admin", - ["CameraEditor.Placeholder.SshUser"] = "SSH user (reuse main if blank)", - ["CameraEditor.Placeholder.SshPassword"] = "SSH password (reuse main if blank)", - ["CameraEditor.Placeholder.SshPort"] = "22", - ["CameraEditor.NoGroup"] = "(no group)", - ["CameraEditor.Button.AutoDeriveRtsp"] = "Auto from host", - ["CameraEditor.Button.RtspTemplates"] = "Templates ▾", - ["CameraEditor.Tooltip.RtspTemplates"] = "Fill in the RTSP URLs used by a specific camera vendor", - ["CameraEditor.Button.TestConnection"] = "Test connection", - ["CameraEditor.Status.Connecting"] = "Connecting…", - ["CameraEditor.Status.OkFormat"] = "OK — {0}x{1}", - ["CameraEditor.Status.Timeout"] = "Timeout (8s)", - ["CameraEditor.Status.FailedFormat"] = "Failed: {0}", - ["CameraEditor.Error.NameRequired"] = "Name is required.", - ["CameraEditor.Error.HostRequired"] = "Host is required.", - ["CameraEditor.Error.RtspMainInvalid"] = "RTSP main URI is not a valid absolute URI.", - ["CameraEditor.Error.RtspSubInvalid"] = "RTSP sub URI is not a valid absolute URI.", + ["CameraEditor.Button.Add"] = "Add", + ["CameraEditor.Hint.EnterAdd"] = "Enter — add", + ["CameraEditor.Hint.EnterSave"] = "Enter — save", + ["CameraEditor.Hint.NeedAddress"] = "Enter the camera address", + ["CameraEditor.Alt.Discover"] = "Find on network", + ["CameraEditor.Alt.Qr"] = "QR code", + ["CameraEditor.Error.HostRequired"] = "Enter the camera address.", + ["CameraEditor.Error.RtspMainInvalid"] = "Enter a full rtsp:// address.", + ["CameraEditor.Error.RtspSubInvalid"] = "Enter a full rtsp:// address.", ["CameraEditor.Error.OnvifPortInvalid"] = "ONVIF port must be between 1 and 65535.", ["CameraEditor.Error.HttpPortInvalid"] = "HTTP port must be between 1 and 65535.", ["CameraEditor.Error.SshPortInvalid"] = "SSH port must be between 1 and 65535.", - ["CameraEditor.Error.SshCredsIncomplete"] = "Enter both SSH username and password, or leave both blank.", + ["CameraEditor.Error.SshCredsIncomplete"] = "Enter both SSH username and password, or leave both empty.", ["Terminal.Connecting"] = "Connecting…", ["Terminal.NoCreds"] = "No SSH credentials set for this camera.", ["Terminal.FailedFormat"] = "SSH error: {0}", @@ -384,36 +443,43 @@ private static LangCode DetectSystem() ["FileManager.DeleteMessage"] = "Delete \"{0}\" from the camera?", ["FileManager.Hint"] = "Tap a folder to open, a file to download.", - ["Discovery.Title"] = "Discover cameras", - ["Discovery.Header"] = "Discover ONVIF cameras", - ["Discovery.Button.Scan"] = "Scan", - ["Discovery.Button.AddSelected"] = "Add selected", - ["Discovery.Status.Initial"] = "Click Scan to find cameras on the LAN (ONVIF + mDNS). Deep scan also sweeps the ticked subnets — including ones only reachable through a VPN.", - ["Discovery.Status.Scanning"] = "Scanning…", - ["Discovery.Status.ScanningRangeFormat"] = "Scanning {0} ({1} addresses)…", - ["Discovery.Status.NoResponse"] = "No cameras responded. Check multicast / firewall.", - ["Discovery.Status.FoundFormat"] = "Found {0} camera(s).", - ["Discovery.Status.Cancelled"] = "Scan cancelled.", + ["Discovery.Title"] = "Find cameras on the network", + ["Discovery.Manual"] = "Enter an address", + ["Discovery.Retry"] = "Scan again", + ["Discovery.Stop"] = "Stop", + ["Discovery.Add"] = "Add", + ["Discovery.InLibrary"] = "in library", + ["Discovery.Section.InLibrary"] = "ALREADY IN LIBRARY", + ["Discovery.Status.Quick"] = "Looking around the network: ONVIF and mDNS…", + ["Discovery.Status.DeepFormat"] = "Deep scan: {0}% · found {1}", + ["Discovery.Status.FoundFormat"] = "Found {0}", + ["Discovery.Status.InLibraryFormat"] = "{0} already in library", + ["Discovery.Status.NothingQuick"] = "The quick scan found nothing", + ["Discovery.Status.NothingDeep"] = "The deep scan found nothing", + ["Discovery.Status.Stopped"] = "Scan stopped", ["Discovery.Status.ScanFailedFormat"] = "Scan failed: {0}", - ["Discovery.Status.ProbingFormat"] = "Probing {0}…", - ["Discovery.Status.ProbeOkFormat"] = "OK — {0} {1}", - ["Discovery.Status.ProbeFailedFormat"] = "Probe failed: {0}", - ["Discovery.Status.ManualAdd"] = "No ONVIF — review the stream URL in the editor.", - ["Discovery.DeepScan"] = "Deep scan (slower)", - ["Discovery.IpRange.Placeholder"] = "another range, if you need one — 192.168.5.0/24, 10.0.0.10-60", - ["Discovery.Target.DetailFormat"] = "{0} · {1} · {2} addresses", - ["Discovery.Target.Local"] = "this machine's network", - ["Discovery.Target.Routed"] = "routed — VPN or another VLAN", - ["Discovery.Sweep.SummaryFormat"] = "Deep scan will knock on {0} address(es).", - ["Discovery.Sweep.SummaryLocal"] = "Deep scan will sweep this machine's own subnet.", + ["Discovery.Kind.OpenIpc"] = "OpenIPC camera", + ["Discovery.Kind.Onvif"] = "ONVIF camera", + ["Discovery.Kind.Unknown"] = "Unknown device", + ["Discovery.Kind.RtspOnly"] = "answers RTSP", + ["Discovery.Kind.HttpOnly"] = "answers HTTP", + ["Discovery.Empty.Title"] = "No cameras answered", + ["Discovery.Empty.QuickBody"] = "The quick scan only hears ONVIF and mDNS. Many OpenIPC cameras only turn up in a deep scan, which knocks on every address of your network.", + ["Discovery.Empty.DeepBody"] = "Make sure the camera is powered and on this network, or add it by its address.", + ["Discovery.Deep.Title"] = "Deep scan", + ["Discovery.Deep.Hint"] = "knocks on every address, slower", + ["Discovery.Deep.Start"] = "Scan deeper", + ["Discovery.Deep.NothingTicked"] = "Tick a subnet or type a range.", + ["Discovery.IpRange.Placeholder"] = "another range: 192.168.5.0/24, 10.0.0.10-60", + ["Discovery.Target.DetailFormat"] = "{0} · {1}", + ["Discovery.Target.Local"] = "this network", + ["Discovery.Target.Routed"] = "through a VPN or route", + ["Discovery.Sweep.One"] = "{0} address", + ["Discovery.Sweep.Few"] = "{0} addresses", + ["Discovery.Sweep.Many"] = "{0} addresses", + ["Discovery.Sweep.SummaryLocal"] = "this network", ["Discovery.IpRange.Invalid"] = "Not a valid IP range. Use 192.168.1.0/24, 192.168.1.10-200, or a single address.", - ["Discovery.IpRange.TooLargeFormat"] = "Too many addresses selected (max {0}). Untick a subnet, or narrow the typed range.", - ["Discovery.UnknownModel"] = "(unknown model)", - ["Discovery.AlreadyAdded"] = "added", - ["Discovery.ReuseCreds"] = "Use these credentials for all cameras", - ["Discovery.Confidence.Low"] = "low", - ["Discovery.Confidence.Medium"] = "medium", - ["Discovery.Confidence.High"] = "high", + ["Discovery.IpRange.TooLargeFormat"] = "Too many addresses (max {0}). Untick a subnet or narrow the typed range.", ["RawConfigEditor.Title"] = "Edit raw Majestic config", ["RawConfigEditor.InvalidJsonFormat"] = "Invalid JSON: {0}", @@ -529,6 +595,11 @@ 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", + ["Grid.Tooltip.Live"] = "Live video from every camera", + ["Grid.Tooltip.StillsInterval"] = "How often snapshots refresh", + ["Grid.Tooltip.GridSize"] = "Grid size for this layout", + ["Grid.Mode.Live"] = "Live", + ["Grid.Mode.Stills"] = "Stills", ["Tile.Snapshot"] = "Snapshot (HD)", ["Welcome.Language"] = "Language", @@ -538,14 +609,79 @@ private static LangCode DetectSystem() ["Recordings.Empty"] = "No recordings yet. Hit ● REC on a camera page to start.", ["Recordings.Live"] = "live", ["Recordings.LoadError"] = "Could not load recordings", - ["Recordings.MarkIn"] = "Set In", - ["Recordings.MarkOut"] = "Set Out", - ["Recordings.Precise"] = "Precise", - ["Recordings.Export"] = "Export", + ["Recordings.SummaryFormat"] = "Recordings: {0} · {1}", + ["Recordings.NoMatches"] = "No recordings match these filters", + ["Recordings.MotionOnly"] = "Motion only", + ["Recordings.Motion"] = "motion", + ["Recordings.InProgress"] = "recording", + ["Recordings.Today"] = "Today", + ["Recordings.Yesterday"] = "Yesterday", + ["Recordings.Play"] = "Play", + ["Recordings.ShowInFolder"] = "Show in folder", + ["Recordings.CopyFile"] = "Copy file", + ["Recordings.Tooltip.More"] = "More actions", + ["Calendar.Mon"] = "Mon", + ["Calendar.Tue"] = "Tue", + ["Calendar.Wed"] = "Wed", + ["Calendar.Thu"] = "Thu", + ["Calendar.Fri"] = "Fri", + ["Calendar.Sat"] = "Sat", + ["Calendar.Sun"] = "Sun", + ["Calendar.MonthSummaryFormat"] = "Recordings this month: {0}", + ["Calendar.DayTooltipFormat"] = "Recordings: {0} · Events: {1}", + ["Calendar.NearestFormat"] = "Nearest recordings: {0} →", ["Recordings.ExportTitle"] = "Export clip", + ["Player.Back"] = "Back to recordings", + ["Player.Previous"] = "Previous", + ["Player.PreviousTip"] = "Previous recording (PgUp)", + ["Player.Next"] = "Next", + ["Player.NextTip"] = "Next recording (PgDn)", + ["Player.EventsFormat"] = "Events · {0}", + ["Player.Filter.All"] = "All", + ["Player.Filter.Motion"] = "Motion", + ["Player.Filter.Detection"] = "AI", + ["Player.SaveFrame"] = "Save this frame to snapshots", + ["Player.FrameSaved"] = "Frame saved to snapshots", + ["Player.SkipBack"] = "Back 10 s (← 5 s)", + ["Player.SkipForward"] = "Forward 10 s (→ 5 s)", + ["Player.PlayPause"] = "Play / pause (Space)", + ["Player.Speed"] = "Playback speed", + ["Player.StepBack"] = "Previous frame (,)", + ["Player.StepForward"] = "Next frame (.)", + ["Player.Mute"] = "Sound on / off (M), at 1× only", + ["Player.Fullscreen"] = "Fullscreen (F, Esc to exit)", + ["Player.Clip"] = "Clip", + ["Player.Clip.Tooltip"] = "Cut a clip from this recording", + ["Player.Clip.SetStart"] = "Start here", + ["Player.Clip.SetEnd"] = "End here", + ["Player.Clip.Hint"] = "Drag on the timeline to select", + ["Player.Clip.SecondsFormat"] = "{0} s", + ["Player.Clip.Precise"] = "Precise cut (slower)", + ["Player.Clip.Save"] = "Save clip", + ["Player.Clip.SavedFormat"] = "Clip saved: {0}", ["Events.Button.SimulateMotion"] = "Simulate motion", - ["Events.Empty"] = "No events yet. Hit Simulate motion to push one through the pipeline.", + ["Events.Empty"] = "No events for this period. They appear when AI detection or a motion source fires on a camera.", + ["Events.NoMatches"] = "No events of this type for the period", + ["Events.SummaryFormat"] = "Events: {0} · episodes: {1}", + ["Events.Kind.All"] = "All types", + ["Events.Kind.Motion"] = "Motion", + ["Events.Kind.Detection"] = "AI detection", + ["Events.GroupBursts"] = "Group bursts", + ["Events.ShowAllTime"] = "Show all time", + ["Events.Cameras.All"] = "Cameras: all", + ["Recordings.Cameras.WithRecordings"] = "With recordings", + ["Recordings.Cameras.FooterFormat"] = "{0} of {1} with recordings", + ["Events.Cameras.CountFormat"] = "Cameras: {0}", + ["Events.Cameras.FooterFormat"] = "{0} of {1} with events", + ["Events.Cameras.Search"] = "Name, IP or group", + ["Events.Cameras.WithEvents"] = "With events", + ["Events.Cameras.Everything"] = "All cameras", + ["Events.Cameras.Reset"] = "Reset", + ["Events.Tooltip.FilterCamera"] = "Show only this camera", + ["Events.Tooltip.Play"] = "Play the recording from this moment", + ["Events.Tooltip.OpenCamera"] = "Open camera", + ["Events.Tooltip.More"] = "More", ["Events.AllCameras"] = "All cameras", ["Events.Open"] = "open", @@ -590,9 +726,23 @@ private static LangCode DetectSystem() ["Analytics.Dropped"] = "Кадров отброшено", ["Analytics.Queue"] = "Очередь:", ["Analytics.Latency"] = "Задержка:", - ["Analytics.Cameras"] = "Камеры с детекцией", - ["Analytics.RecentDetections"] = "Недавние детекции", + ["Analytics.Cameras"] = "Детекция на камерах", + ["Analytics.RecentDetections"] = "За 24 часа", ["Analytics.AllClasses"] = "все классы", + ["Analytics.SettingsFormat"] = "{0} к/с · ≥{1:0.00}", + ["Analytics.Detections24hFormat"] = "{0} за 24 ч", + ["Analytics.EnableOnCamera"] = "Включить на камере", + ["Analytics.EnableSearch"] = "Имя или IP", + ["Analytics.NoCameras"] = "Детекция пока не включена ни на одной камере. Она работает на этом компьютере, отдельно для каждой камеры — выберите камеру, чтобы начать.", + ["Analytics.NotStartedHint"] = "Движок запускается сам, когда в Эфире или на странице камеры открыта камера с детекцией. Модель скачивается при первом запуске.", + ["Analytics.Configure"] = "Настроить", + ["Analytics.AllDetections"] = "Все детекции в Событиях →", + ["Analytics.NoDetections24h"] = "За последние 24 часа детекций не было", + ["Analytics.Tile.Cameras"] = "Камер в работе", + ["Analytics.Tile.Processed"] = "Обработано кадров", + ["Analytics.Tile.Dropped"] = "Отброшено", + ["Analytics.Tile.Latency"] = "Задержка", + ["Analytics.Tile.Queue"] = "Очередь", ["Common.Cancel"] = "Отмена", ["Common.Delete"] = "Удалить", @@ -606,6 +756,8 @@ private static LangCode DetectSystem() ["Layouts.Tooltip.Rename"] = "Переименовать раскладку", ["Layouts.Tooltip.Delete"] = "Удалить раскладку", ["Layouts.Tooltip.Cameras"] = "Добавить / убрать камеры", + ["Layouts.Tooltip.Menu"] = "Действия с раскладкой", + ["Layouts.Add"] = "Раскладка", ["LayoutCameras.WindowTitle"] = "Камеры раскладки", ["LayoutCameras.TitleFormat"] = "Камеры в «{0}»", ["LayoutCameras.Empty"] = "В библиотеке пока нет камер", @@ -652,6 +804,17 @@ private static LangCode DetectSystem() ["Library.RowDelete"] = "Удалить", ["Library.RowShowInGrid"] = "В гриде", ["Library.RowDevice"] = "Устройство", + ["Library.RowLayouts"] = "Раскладки", + ["Library.SearchPlaceholder"] = "Имя или IP", + ["Library.FindOnNetwork"] = "Найти в сети", + ["Library.NoMatches"] = "Под эти фильтры камер нет", + ["Library.Filter.All"] = "Все", + ["Library.Filter.Online"] = "В сети", + ["Library.Filter.Offline"] = "Не в сети", + ["Library.Filter.Attention"] = "Внимание", + ["Library.Tooltip.More"] = "Ещё действия", + ["Library.Tooltip.ManageGroups"] = "Управлять группами", + ["Library.Tooltip.Refresh"] = "Обновить список и статусы", ["Firmware.Title"] = "Обслуживание устройства", ["Firmware.NoCredentials"] = "У камеры нет SSH-доступа — добавьте логин/пароль в «Изменить», чтобы управлять устройством.", ["Firmware.Time"] = "Время", @@ -807,50 +970,82 @@ private static LangCode DetectSystem() ["Snapshot.SaveAs"] = "Сохранить как…", ["CameraEditor.Title.Add"] = "Добавить камеру", - ["CameraEditor.Title.Edit"] = "Редактировать камеру", - ["CameraEditor.Label.Name"] = "Имя", - ["CameraEditor.Label.Host"] = "Хост", - ["CameraEditor.Label.HttpPort"] = "HTTP-порт", - ["CameraEditor.Label.OnvifPort"] = "ONVIF-порт (необязательно)", - ["CameraEditor.Label.RtspMain"] = "RTSP main URI", - ["CameraEditor.Label.RtspSub"] = "RTSP sub URI (необязательно)", + ["CameraEditor.Title.EditFormat"] = "Камера · {0}", + ["CameraEditor.Section.Connection"] = "ПОДКЛЮЧЕНИЕ", + ["CameraEditor.Section.Name"] = "НАЗВАНИЕ И ГРУППА", + ["CameraEditor.Section.Advanced"] = "ДОПОЛНИТЕЛЬНО", + ["CameraEditor.Label.Address"] = "Адрес", + ["CameraEditor.Placeholder.Address"] = "192.168.1.10 или rtsp://…", + ["CameraEditor.Hint.Address"] = "IP, ip:порт или целая rtsp-ссылка — разберём сами", ["CameraEditor.Label.Username"] = "Логин", ["CameraEditor.Label.Password"] = "Пароль", + ["CameraEditor.Placeholder.Username"] = "admin", + ["CameraEditor.Button.Connect"] = "Подключиться", + ["CameraEditor.Hint.Connect"] = "определит тип, потоки и порты", + ["CameraEditor.Status.Connecting"] = "Подключение…", + ["CameraEditor.Status.ConnectedFormat"] = "подключено за {0:0.0} с", + ["CameraEditor.Status.Failed"] = "не удалось подключиться", + ["CameraEditor.Result.FirmwareFormat"] = "прошивка {0}", + ["CameraEditor.Result.Generic"] = "RTSP-камера", + ["CameraEditor.Pill.SubFormat"] = "{0}×{1} доп.", + ["CameraEditor.Pill.Audio"] = "звук", + ["CameraEditor.Fail.Unreachable"] = "Камера не отвечает", + ["CameraEditor.Fail.UnreachableHint"] = "Проверьте адрес и что камера в этой же сети.", + ["CameraEditor.Fail.Auth"] = "Неверный логин или пароль", + ["CameraEditor.Fail.AuthHint"] = "Камера отклонила учётные данные.", + ["CameraEditor.Fail.AuthHintOpenIpc"] = "Камера отклонила учётные данные. У OpenIPC по умолчанию root / 12345.", + ["CameraEditor.Fail.AuthNeeded"] = "Камера требует логин и пароль", + ["CameraEditor.Fail.AuthNeededHint"] = "Введите их выше и нажмите «Подключиться».", + ["CameraEditor.Fail.AuthNeededHintOpenIpc"] = "Введите их выше и нажмите «Подключиться». У OpenIPC по умолчанию root / 12345.", + ["CameraEditor.Fail.NoVideo"] = "Камера отвечает, но видеопоток не открылся", + ["CameraEditor.Fail.Timeout"] = "Камера слишком долго не отдаёт видео", + ["CameraEditor.Fail.NoVideoHint"] = "Проверьте путь потока в «Дополнительно → Потоки» или выберите шаблон производителя.", + ["CameraEditor.Label.Name"] = "Название", ["CameraEditor.Label.Group"] = "Группа", - ["CameraEditor.Label.SshSection"] = "SSH (необязательно)", - ["CameraEditor.Label.StreamQuality"] = "Качество в гриде", + ["CameraEditor.NoGroup"] = "(без группы)", + ["CameraEditor.NewGroup"] = "+ Новая группа", + ["CameraEditor.NewGroup.Title"] = "Новая группа", + ["CameraEditor.Adv.Streams"] = "Потоки", + ["CameraEditor.Adv.Ports"] = "Порты", + ["CameraEditor.Adv.Ssh"] = "Доступ по SSH", + ["CameraEditor.Adv.Quality"] = "Качество в сетке", + ["CameraEditor.Adv.Ai"] = "ИИ-детекция", + ["CameraEditor.Summary.Auto"] = "авто", + ["CameraEditor.Summary.MainOnly"] = "основной", + ["CameraEditor.Summary.MainSub"] = "основной + доп.", + ["CameraEditor.Summary.SameLogin"] = "как основной", + ["CameraEditor.Summary.Off"] = "выкл", + ["CameraEditor.Summary.On"] = "вкл", + ["CameraEditor.Label.RtspMain"] = "Основной поток", + ["CameraEditor.Label.RtspSub"] = "Дополнительный поток (необязательно)", + ["CameraEditor.Placeholder.RtspMain"] = "rtsp://… — заполнится при подключении", + ["CameraEditor.Placeholder.RtspSub"] = "rtsp://…", + ["CameraEditor.Button.RtspTemplates"] = "Шаблоны производителей ▾", + ["CameraEditor.Tooltip.RtspTemplates"] = "Подставить RTSP-адреса под конкретного производителя камеры", + ["CameraEditor.Label.HttpPort"] = "HTTP", + ["CameraEditor.Label.OnvifPort"] = "ONVIF", + ["CameraEditor.Label.SshPort"] = "SSH", + ["CameraEditor.Placeholder.PortAuto"] = "авто", + ["CameraEditor.Hint.Ssh"] = "Пусто — используется основной логин и пароль.", + ["CameraEditor.Label.StreamQuality"] = "Качество потока в сетке", ["CameraEditor.Quality.Auto"] = "Авто (SD/HD)", - ["CameraEditor.Quality.AlwaysHd"] = "Всегда HD (main)", - ["CameraEditor.Quality.AlwaysSd"] = "Всегда SD (sub)", - ["CameraEditor.Analytics.Section"] = "ИИ-аналитика", + ["CameraEditor.Quality.AlwaysHd"] = "Всегда HD (основной)", + ["CameraEditor.Quality.AlwaysSd"] = "Всегда SD (доп.)", ["CameraEditor.Analytics.Enable"] = "Включить детекцию объектов", ["CameraEditor.Analytics.Classes"] = "Классы", ["CameraEditor.Analytics.Threshold"] = "Порог уверенности", ["CameraEditor.Analytics.Fps"] = "Кадров/с анализа", ["CameraEditor.Analytics.AutoRecord"] = "Автозапись по детекции", ["CameraEditor.Analytics.PostEvent"] = "Дозапись после (с)", - ["CameraEditor.Placeholder.Name"] = "Входная", - ["CameraEditor.Placeholder.Host"] = "192.168.1.10", - ["CameraEditor.Placeholder.OnvifPort"] = "8899", - ["CameraEditor.Placeholder.RtspMain"] = "rtsp://192.168.1.10/", - ["CameraEditor.Placeholder.RtspSub"] = "rtsp://192.168.1.10/stream1", - ["CameraEditor.Placeholder.Username"] = "admin", - ["CameraEditor.Placeholder.SshUser"] = "SSH-логин (пусто — как основной)", - ["CameraEditor.Placeholder.SshPassword"] = "SSH-пароль (пусто — как основной)", - ["CameraEditor.Placeholder.SshPort"] = "22", - ["CameraEditor.NoGroup"] = "(без группы)", - ["CameraEditor.Button.AutoDeriveRtsp"] = "Подставить из хоста", - ["CameraEditor.Button.RtspTemplates"] = "Шаблоны ▾", - ["CameraEditor.Tooltip.RtspTemplates"] = "Подставить RTSP-адреса под конкретного производителя камеры", - ["CameraEditor.Button.TestConnection"] = "Проверить", - ["CameraEditor.Status.Connecting"] = "Подключение…", - ["CameraEditor.Status.OkFormat"] = "OK — {0}x{1}", - ["CameraEditor.Status.Timeout"] = "Таймаут (8 с)", - ["CameraEditor.Status.FailedFormat"] = "Ошибка: {0}", - ["CameraEditor.Error.NameRequired"] = "Имя обязательно.", - ["CameraEditor.Error.HostRequired"] = "Хост обязателен.", - ["CameraEditor.Error.RtspMainInvalid"] = "RTSP main URI должен быть абсолютным URI.", - ["CameraEditor.Error.RtspSubInvalid"] = "RTSP sub URI должен быть абсолютным URI.", + ["CameraEditor.Button.Add"] = "Добавить", + ["CameraEditor.Hint.EnterAdd"] = "Enter — добавить", + ["CameraEditor.Hint.EnterSave"] = "Enter — сохранить", + ["CameraEditor.Hint.NeedAddress"] = "Введите адрес камеры", + ["CameraEditor.Alt.Discover"] = "Найти в сети", + ["CameraEditor.Alt.Qr"] = "QR-код", + ["CameraEditor.Error.HostRequired"] = "Введите адрес камеры.", + ["CameraEditor.Error.RtspMainInvalid"] = "Нужен полный адрес вида rtsp://…", + ["CameraEditor.Error.RtspSubInvalid"] = "Нужен полный адрес вида rtsp://…", ["CameraEditor.Error.OnvifPortInvalid"] = "ONVIF-порт должен быть от 1 до 65535.", ["CameraEditor.Error.HttpPortInvalid"] = "HTTP-порт должен быть от 1 до 65535.", ["CameraEditor.Error.SshPortInvalid"] = "SSH-порт должен быть от 1 до 65535.", @@ -880,36 +1075,43 @@ private static LangCode DetectSystem() ["FileManager.DeleteMessage"] = "Удалить «{0}» с камеры?", ["FileManager.Hint"] = "Папка — открыть, файл — скачать.", - ["Discovery.Title"] = "Поиск камер", - ["Discovery.Header"] = "Поиск ONVIF-камер", - ["Discovery.Button.Scan"] = "Сканировать", - ["Discovery.Button.AddSelected"] = "Добавить выбранные", - ["Discovery.Status.Initial"] = "Нажмите «Сканировать» для поиска камер в сети (ONVIF + mDNS). «Глубокое сканирование» дополнительно обойдёт отмеченные подсети — включая доступные только через VPN.", - ["Discovery.Status.Scanning"] = "Сканирование…", - ["Discovery.Status.ScanningRangeFormat"] = "Сканирование {0} ({1} адр.)…", - ["Discovery.Status.NoResponse"] = "Камеры не ответили. Проверьте multicast / фаервол.", - ["Discovery.Status.FoundFormat"] = "Найдено камер: {0}.", - ["Discovery.Status.Cancelled"] = "Сканирование отменено.", - ["Discovery.Status.ScanFailedFormat"] = "Сканирование не удалось: {0}", - ["Discovery.Status.ProbingFormat"] = "Опрос {0}…", - ["Discovery.Status.ProbeOkFormat"] = "OK — {0} {1}", - ["Discovery.Status.ProbeFailedFormat"] = "Опрос не удался: {0}", - ["Discovery.Status.ManualAdd"] = "Без ONVIF — проверьте URL потока в редакторе.", - ["Discovery.DeepScan"] = "Глубокое сканирование (медленнее)", - ["Discovery.IpRange.Placeholder"] = "ещё диапазон, если нужен — 192.168.5.0/24, 10.0.0.10-60", - ["Discovery.Target.DetailFormat"] = "{0} · {1} · адресов: {2}", - ["Discovery.Target.Local"] = "сеть этого компьютера", - ["Discovery.Target.Routed"] = "через маршрут — VPN или другой VLAN", - ["Discovery.Sweep.SummaryFormat"] = "Глубокое сканирование обойдёт адресов: {0}.", - ["Discovery.Sweep.SummaryLocal"] = "Глубокое сканирование обойдёт подсеть этого компьютера.", + ["Discovery.Title"] = "Поиск камер в сети", + ["Discovery.Manual"] = "Ввести адрес вручную", + ["Discovery.Retry"] = "Повторить", + ["Discovery.Stop"] = "Остановить", + ["Discovery.Add"] = "Добавить", + ["Discovery.InLibrary"] = "в библиотеке", + ["Discovery.Section.InLibrary"] = "УЖЕ В БИБЛИОТЕКЕ", + ["Discovery.Status.Quick"] = "Ищем в сети: ONVIF и mDNS…", + ["Discovery.Status.DeepFormat"] = "Глубокий поиск: {0}% · найдено {1}", + ["Discovery.Status.FoundFormat"] = "Найдено {0}", + ["Discovery.Status.InLibraryFormat"] = "{0} уже в библиотеке", + ["Discovery.Status.NothingQuick"] = "Быстрый поиск ничего не нашёл", + ["Discovery.Status.NothingDeep"] = "Глубокий поиск ничего не нашёл", + ["Discovery.Status.Stopped"] = "Поиск остановлен", + ["Discovery.Status.ScanFailedFormat"] = "Поиск не удался: {0}", + ["Discovery.Kind.OpenIpc"] = "OpenIPC-камера", + ["Discovery.Kind.Onvif"] = "ONVIF-камера", + ["Discovery.Kind.Unknown"] = "Неизвестное устройство", + ["Discovery.Kind.RtspOnly"] = "отвечает на RTSP", + ["Discovery.Kind.HttpOnly"] = "отвечает на HTTP", + ["Discovery.Empty.Title"] = "Камеры не откликнулись", + ["Discovery.Empty.QuickBody"] = "Быстрый поиск слышит только ONVIF и mDNS. Многие OpenIPC-камеры находятся глубоким поиском — он обходит адреса вашей сети.", + ["Discovery.Empty.DeepBody"] = "Проверьте, что камера включена и в этой же сети, или добавьте её по адресу.", + ["Discovery.Deep.Title"] = "Глубокий поиск", + ["Discovery.Deep.Hint"] = "обход адресов сети, медленнее", + ["Discovery.Deep.Start"] = "Искать глубже", + ["Discovery.Deep.NothingTicked"] = "Отметьте подсеть или введите диапазон.", + ["Discovery.IpRange.Placeholder"] = "ещё диапазон: 192.168.5.0/24, 10.0.0.10-60", + ["Discovery.Target.DetailFormat"] = "{0} · {1}", + ["Discovery.Target.Local"] = "эта сеть", + ["Discovery.Target.Routed"] = "через VPN или маршрут", + ["Discovery.Sweep.One"] = "{0} адрес", + ["Discovery.Sweep.Few"] = "{0} адреса", + ["Discovery.Sweep.Many"] = "{0} адресов", + ["Discovery.Sweep.SummaryLocal"] = "эта сеть", ["Discovery.IpRange.Invalid"] = "Неверный диапазон IP. Например: 192.168.1.0/24, 192.168.1.10-200 или один адрес.", - ["Discovery.IpRange.TooLargeFormat"] = "Слишком много адресов (макс. {0}). Снимите отметку с подсети или сузьте введённый диапазон.", - ["Discovery.UnknownModel"] = "(неизвестная модель)", - ["Discovery.AlreadyAdded"] = "добавлена", - ["Discovery.ReuseCreds"] = "Одни учётные данные для всех камер", - ["Discovery.Confidence.Low"] = "низкая", - ["Discovery.Confidence.Medium"] = "средняя", - ["Discovery.Confidence.High"] = "высокая", + ["Discovery.IpRange.TooLargeFormat"] = "Слишком много адресов (макс. {0}). Снимите отметку с подсети или сузьте диапазон.", ["RawConfigEditor.Title"] = "Правка raw-конфига Majestic", ["RawConfigEditor.InvalidJsonFormat"] = "Невалидный JSON: {0}", @@ -1025,6 +1227,11 @@ private static LangCode DetectSystem() ["Grid.EmptySlot"] = "нет камеры", ["Grid.Tooltip.Fullscreen"] = "Сетка на весь экран (F11) — Esc для выхода", ["Grid.Tooltip.Stills"] = "Режим кадров — периодические снимки вместо живого видео", + ["Grid.Tooltip.Live"] = "Живое видео со всех камер", + ["Grid.Tooltip.StillsInterval"] = "Как часто обновлять кадры", + ["Grid.Tooltip.GridSize"] = "Размер сетки этой раскладки", + ["Grid.Mode.Live"] = "Видео", + ["Grid.Mode.Stills"] = "Кадры", ["Tile.Snapshot"] = "Снимок (HD)", ["Welcome.Language"] = "Язык", @@ -1034,14 +1241,95 @@ private static LangCode DetectSystem() ["Recordings.Empty"] = "Записей пока нет. Нажмите ● REC на странице камеры, чтобы начать.", ["Recordings.Live"] = "идёт", ["Recordings.LoadError"] = "Не удалось загрузить записи", - ["Recordings.MarkIn"] = "Начало", - ["Recordings.MarkOut"] = "Конец", - ["Recordings.Precise"] = "Точно", - ["Recordings.Export"] = "Экспорт", + ["Recordings.SummaryFormat"] = "Записей: {0} · {1}", + ["Recordings.NoMatches"] = "Под эти фильтры записей нет", + ["Recordings.MotionOnly"] = "Только с движением", + ["Recordings.Motion"] = "движение", + ["Recordings.InProgress"] = "идёт запись", + ["Recordings.Today"] = "Сегодня", + ["Recordings.Yesterday"] = "Вчера", + ["Recordings.Play"] = "Воспроизвести", + ["Recordings.ShowInFolder"] = "Показать в папке", + ["Recordings.CopyFile"] = "Копировать файл", + ["Recordings.Tooltip.More"] = "Ещё действия", + ["Calendar.Mon"] = "Пн", + ["Calendar.Tue"] = "Вт", + ["Calendar.Wed"] = "Ср", + ["Calendar.Thu"] = "Чт", + ["Calendar.Fri"] = "Пт", + ["Calendar.Sat"] = "Сб", + ["Calendar.Sun"] = "Вс", + ["Calendar.MonthSummaryFormat"] = "Записей в месяце: {0}", + ["Calendar.DayTooltipFormat"] = "Записей: {0} · Событий: {1}", + ["Calendar.NearestFormat"] = "Ближайшие записи: {0} →", ["Recordings.ExportTitle"] = "Экспорт клипа", + ["Player.Back"] = "К списку записей", + ["Player.Previous"] = "Предыдущая", + ["Player.PreviousTip"] = "Предыдущая запись (PgUp)", + ["Player.Next"] = "Следующая", + ["Player.NextTip"] = "Следующая запись (PgDn)", + ["Player.EventsFormat"] = "События · {0}", + ["Player.Filter.All"] = "Все", + ["Player.Filter.Motion"] = "Движение", + ["Player.Filter.Detection"] = "ИИ", + ["Player.SaveFrame"] = "Сохранить кадр в снимки", + ["Player.FrameSaved"] = "Кадр сохранён в снимки", + ["Player.SkipBack"] = "Назад 10 с (← 5 с)", + ["Player.SkipForward"] = "Вперёд 10 с (→ 5 с)", + ["Player.PlayPause"] = "Воспроизведение / пауза (пробел)", + ["Player.Speed"] = "Скорость воспроизведения", + ["Player.StepBack"] = "Предыдущий кадр (,)", + ["Player.StepForward"] = "Следующий кадр (.)", + ["Player.Mute"] = "Звук вкл/выкл (M), только на 1×", + ["Player.Fullscreen"] = "Во весь экран (F, выход — Esc)", + ["Player.Clip"] = "Клип", + ["Player.Clip.Tooltip"] = "Вырезать клип из записи", + ["Player.Clip.SetStart"] = "Начало здесь", + ["Player.Clip.SetEnd"] = "Конец здесь", + ["Player.Clip.Hint"] = "Выделите участок на шкале", + ["Player.Clip.SecondsFormat"] = "{0} с", + ["Player.Clip.Precise"] = "Точная обрезка (медленнее)", + ["Player.Clip.Save"] = "Сохранить клип", + ["Player.Clip.SavedFormat"] = "Клип сохранён: {0}", ["Events.Button.SimulateMotion"] = "Симулировать движение", - ["Events.Empty"] = "Событий пока нет. Нажмите «Симулировать движение», чтобы прогнать одно через конвейер.", + ["Events.Empty"] = "За этот период событий нет. Они появляются, когда на камере срабатывает детекция ИИ или источник движения.", + ["Events.NoMatches"] = "Событий этого типа за период нет", + ["Events.SummaryFormat"] = "Событий: {0} · эпизодов: {1}", + ["Events.Kind.All"] = "Все типы", + ["Events.Kind.Motion"] = "Движение", + ["Events.Kind.Detection"] = "Детекция ИИ", + ["Events.GroupBursts"] = "Группировать серии", + ["Events.ShowAllTime"] = "Показать за всё время", + ["Events.Cameras.All"] = "Камеры: все", + ["Recordings.Cameras.WithRecordings"] = "С записями", + ["Recordings.Cameras.FooterFormat"] = "{0} из {1} с записями", + ["Events.Cameras.CountFormat"] = "Камеры: {0}", + ["Events.Cameras.FooterFormat"] = "{0} из {1} с событиями", + ["Events.Cameras.Search"] = "Имя, IP или группа", + ["Events.Cameras.WithEvents"] = "С событиями", + ["Events.Cameras.Everything"] = "Все камеры", + ["Events.Cameras.Reset"] = "Сбросить", + ["Events.Tooltip.FilterCamera"] = "Показать только эту камеру", + ["Events.Tooltip.Play"] = "Открыть запись с этого момента", + ["Events.Tooltip.OpenCamera"] = "Открыть камеру", + ["Events.Tooltip.More"] = "Ещё", + ["Coco.person"] = "человек", + ["Coco.car"] = "машина", + ["Coco.truck"] = "грузовик", + ["Coco.bus"] = "автобус", + ["Coco.motorcycle"] = "мотоцикл", + ["Coco.bicycle"] = "велосипед", + ["Coco.train"] = "поезд", + ["Coco.boat"] = "лодка", + ["Coco.dog"] = "собака", + ["Coco.cat"] = "кошка", + ["Coco.bird"] = "птица", + ["Coco.horse"] = "лошадь", + ["Coco.backpack"] = "рюкзак", + ["Coco.handbag"] = "сумка", + ["Coco.suitcase"] = "чемодан", + ["Coco.umbrella"] = "зонт", ["Events.AllCameras"] = "Все камеры", ["Events.Open"] = "открыто", diff --git a/src/OpenIPC.Viewer.App/Services/RecordingPlayerPageFactory.cs b/src/OpenIPC.Viewer.App/Services/RecordingPlayerPageFactory.cs index c970c9c..b7779ec 100644 --- a/src/OpenIPC.Viewer.App/Services/RecordingPlayerPageFactory.cs +++ b/src/OpenIPC.Viewer.App/Services/RecordingPlayerPageFactory.cs @@ -3,6 +3,7 @@ using OpenIPC.Viewer.Core.Archive; using OpenIPC.Viewer.Core.Events; using OpenIPC.Viewer.Core.Recording; +using OpenIPC.Viewer.Core.Snapshots; using OpenIPC.Viewer.Core.Video; namespace OpenIPC.Viewer.App.Services; @@ -12,7 +13,11 @@ public sealed class RecordingPlayerPageFactory private readonly IPlaybackEngine _engine; private readonly IMediaProbe _probe; private readonly IEventRepository _events; + private readonly IRecordingRepository _recordings; + private readonly ISnapshotService _snapshots; private readonly IClipExporter _exporter; + private readonly AudioMonitor _audio; + private readonly UserSettingsService _userSettings; private readonly IDialogService _dialogs; private readonly OpenIPC.Viewer.Core.Platform.IShareService _share; private readonly ILoggerFactory _loggerFactory; @@ -21,7 +26,11 @@ public RecordingPlayerPageFactory( IPlaybackEngine engine, IMediaProbe probe, IEventRepository events, + IRecordingRepository recordings, + ISnapshotService snapshots, IClipExporter exporter, + AudioMonitor audio, + UserSettingsService userSettings, IDialogService dialogs, OpenIPC.Viewer.Core.Platform.IShareService share, ILoggerFactory loggerFactory) @@ -29,13 +38,17 @@ public RecordingPlayerPageFactory( _engine = engine; _probe = probe; _events = events; + _recordings = recordings; + _snapshots = snapshots; _exporter = exporter; + _audio = audio; + _userSettings = userSettings; _dialogs = dialogs; _share = share; _loggerFactory = loggerFactory; } public RecordingPlayerPageViewModel Create(Recording recording, string cameraName) => - new(recording, cameraName, _engine, _probe, _events, _exporter, _dialogs, _share, + new(recording, cameraName, _engine, _probe, _events, _recordings, _snapshots, _exporter, _audio, _userSettings, _dialogs, _share, _loggerFactory.CreateLogger()); } diff --git a/src/OpenIPC.Viewer.App/Styles/Buttons.axaml b/src/OpenIPC.Viewer.App/Styles/Buttons.axaml index 04f5bed..ad20374 100644 --- a/src/OpenIPC.Viewer.App/Styles/Buttons.axaml +++ b/src/OpenIPC.Viewer.App/Styles/Buttons.axaml @@ -21,4 +21,134 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/OpenIPC.Viewer.App/Themes/Theme.axaml b/src/OpenIPC.Viewer.App/Themes/Theme.axaml index ff777bf..3f2b2f4 100644 --- a/src/OpenIPC.Viewer.App/Themes/Theme.axaml +++ b/src/OpenIPC.Viewer.App/Themes/Theme.axaml @@ -104,4 +104,49 @@ M12,2 V12 M18.36,6.64 A9,9 0 1 1 5.63,6.64 + + M5,12 H19 M12,5 V19 + M11,12 A1,1 0 1 0 13,12 A1,1 0 1 0 11,12 Z M18,12 A1,1 0 1 0 20,12 A1,1 0 1 0 18,12 Z M4,12 A1,1 0 1 0 6,12 A1,1 0 1 0 4,12 Z + M10,11 V17 M14,11 V17 M19,6 V20 a2,2 0 0 1 -2,2 H7 a2,2 0 0 1 -2,-2 V6 M3,6 H21 M8,6 V4 a2,2 0 0 1 2,-2 h4 a2,2 0 0 1 2,2 v2 + M6,9 L12,15 L18,9 + M16,13 L21.223,16.482 a0.5,0.5 0 0 0 0.777,-0.416 V7.87 a0.5,0.5 0 0 0 -0.752,-0.432 L16,10.5 M4,6 H14 A2,2 0 0 1 16,8 V16 A2,2 0 0 1 14,18 H4 A2,2 0 0 1 2,16 V8 A2,2 0 0 1 4,6 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 + 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 M3,12 H21 M12,3 V21 + 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 M3,9 H21 M3,15 H21 M9,3 V21 M15,3 V21 + 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 M3,7.5 H21 M3,12 H21 M3,16.5 H21 M7.5,3 V21 M12,3 V21 M16.5,3 V21 + 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 M3,6.6 H21 M3,10.2 H21 M3,13.8 H21 M3,17.4 H21 M6.6,3 V21 M10.2,3 V21 M13.8,3 V21 M17.4,3 V21 + + + M3,12 A9,9 0 1 0 21,12 A9,9 0 1 0 3,12 Z M3.6,9 H20.4 M3.6,15 H20.4 M11.5,3 A17,17 0 0 0 11.5,21 M12.5,3 A17,17 0 0 1 12.5,21 + M8,9 L11,12 L8,15 M13,15 H16 M5,4 H19 A2,2 0 0 1 21,6 V18 A2,2 0 0 1 19,20 H5 A2,2 0 0 1 3,18 V6 A2,2 0 0 1 5,4 Z + M6,4 H18 A2,2 0 0 1 20,6 V18 A2,2 0 0 1 18,20 H6 A2,2 0 0 1 4,18 V6 A2,2 0 0 1 6,4 Z M10,9 H14 A1,1 0 0 1 15,10 V14 A1,1 0 0 1 14,15 H10 A1,1 0 0 1 9,14 V10 A1,1 0 0 1 10,9 Z M15,2 V4 M15,20 V22 M2,15 H4 M2,9 H4 M20,15 H22 M20,9 H22 M9,2 V4 M9,20 V22 + M20,20 a2,2 0 0 0 2,-2 V8 a2,2 0 0 0 -2,-2 h-7.9 a2,2 0 0 1 -1.69,-0.9 L9.6,3.9 A2,2 0 0 0 7.93,3 H4 a2,2 0 0 0 -2,2 v13 a2,2 0 0 0 2,2 Z + M12.586,2.586 A2,2 0 0 0 11.172,2 H4 a2,2 0 0 0 -2,2 v7.172 a2,2 0 0 0 0.586,1.414 l8.704,8.704 a2.426,2.426 0 0 0 3.42,0 l6.58,-6.58 a2.426,2.426 0 0 0 0,-3.42 Z M7,7.5 A0.5,0.5 0 1 0 8,7.5 A0.5,0.5 0 1 0 7,7.5 Z + M21,12 a9,9 0 1 1 -9,-9 c2.52,0 4.93,1 6.74,2.74 L21,8 M21,3 v5 h-5 + + M13,4 A1,1 0 1 0 15,4 A1,1 0 1 0 13,4 Z M7,21 L10,17 M16,21 L14,17 L11,14 L12,8 M6,12 L8,9 L12,8 L15,11 L18,12 + + M3,12 a9,9 0 1 0 9,-9 a9.75,9.75 0 0 0 -6.74,2.74 L3,8 M3,3 v5 h5 + M21,12 a9,9 0 1 1 -9,-9 c2.52,0 4.93,1 6.74,2.74 L21,8 M21,3 v5 h-5 + M19,20 L9,12 L19,4 Z M5,19 V5 + M5,4 L15,12 L5,20 Z M19,5 V19 + + M2.062,12.348 a1,1 0 0 1 0,-0.696 a10.75,10.75 0 0 1 19.876,0 a1,1 0 0 1 0,0.696 a10.75,10.75 0 0 1 -19.876,0 M15,12 A3,3 0 1 1 9,12 A3,3 0 1 1 15,12 Z + M10.733,5.076 a10.744,10.744 0 0 1 11.205,6.575 a1,1 0 0 1 0,0.696 a10.747,10.747 0 0 1 -1.444,2.49 M14.084,14.158 a3,3 0 0 1 -4.242,-4.242 M17.479,17.499 a10.75,10.75 0 0 1 -15.417,-5.151 a1,1 0 0 1 0,-0.696 a10.75,10.75 0 0 1 4.446,-5.143 M2,2 L22,22 + M12,22 V17 M9,8 V2 M15,8 V2 M18,8 V13 a4,4 0 0 1 -4,4 H10 a4,4 0 0 1 -4,-4 V8 Z + + M17,18 L11,12 L17,6 M7,6 V18 + M7,18 L13,12 L7,6 M17,6 V18 + M8,3 v3 a2,2 0 0 1 -2,2 H3 M21,8 h-3 a2,2 0 0 1 -2,-2 V3 M3,16 h3 a2,2 0 0 1 2,2 v3 M16,21 v-3 a2,2 0 0 1 2,-2 h3 + M9,6 A3,3 0 1 1 3,6 A3,3 0 1 1 9,6 Z M8.12,8.12 L12,12 M20,4 L8.12,15.88 M9,18 A3,3 0 1 1 3,18 A3,3 0 1 1 9,18 Z M14.8,14.8 L20,20 + + M19,21 v-2 a4,4 0 0 0 -4,-4 H9 a4,4 0 0 0 -4,4 v2 M8,7 A4,4 0 1 0 16,7 A4,4 0 1 0 8,7 Z + M19,17 h2 c0.6,0 1,-0.4 1,-1 v-3 c0,-0.9 -0.7,-1.7 -1.5,-1.9 C18.7,10.6 16,10 16,10 c0,0 -1.3,-1.4 -2.2,-2.3 c-0.5,-0.4 -1.1,-0.7 -1.8,-0.7 H5 c-0.6,0 -1.1,0.4 -1.4,0.9 l-1.4,2.9 A3.7,3.7 0 0 0 2,12 v4 c0,0.6 0.4,1 1,1 h2 M5,17 A2,2 0 1 0 9,17 A2,2 0 1 0 5,17 Z M9,17 H15 M15,17 A2,2 0 1 0 19,17 A2,2 0 1 0 15,17 Z + M14,18 V6 a2,2 0 0 0 -2,-2 H4 a2,2 0 0 0 -2,2 v11 a1,1 0 0 0 1,1 h2 M15,18 H9 M19,18 h2 a1,1 0 0 0 1,-1 v-3.65 a1,1 0 0 0 -0.22,-0.624 l-3.48,-4.35 A1,1 0 0 0 17.52,8 H14 M15,18 A2,2 0 1 0 19,18 A2,2 0 1 0 15,18 Z M5,18 A2,2 0 1 0 9,18 A2,2 0 1 0 5,18 Z + M2,17.5 A3.5,3.5 0 1 0 9,17.5 A3.5,3.5 0 1 0 2,17.5 Z M15,17.5 A3.5,3.5 0 1 0 22,17.5 A3.5,3.5 0 1 0 15,17.5 Z M14,5 A1,1 0 1 0 16,5 A1,1 0 1 0 14,5 Z M12,17.5 V14 L9,11 L13,8 L15,11 H17 diff --git a/src/OpenIPC.Viewer.App/ViewModels/AnalyticsPageViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/AnalyticsPageViewModel.cs index ec415d7..2405656 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/AnalyticsPageViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/AnalyticsPageViewModel.cs @@ -1,54 +1,90 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using CommunityToolkit.Mvvm.Messaging; using Microsoft.Extensions.Logging; +using OpenIPC.Viewer.App.Messages; using OpenIPC.Viewer.App.Services; using OpenIPC.Viewer.Core.Analytics; +using OpenIPC.Viewer.Core.Entities; using OpenIPC.Viewer.Core.Events; using OpenIPC.Viewer.Core.Services; namespace OpenIPC.Viewer.App.ViewModels; -// AI control center (Phase 15.7): engine status + diagnostics, the cameras with -// analytics on, and recent detection events. The diagnostics counters poll on a -// 1 Hz timer that the view starts/stops with its visual lifetime. +// AI control center (Phase 15.7): engine status + diagnostics, per-camera +// detection (on/off, settings summary, 24 h activity) and a 24 h class summary. +// Diagnostics poll on a 1 Hz timer the view starts/stops with its lifetime. public sealed partial class AnalyticsPageViewModel : ViewModelBase { + private static readonly TimeSpan ActivityWindow = TimeSpan.FromHours(24); + private readonly IAnalyticsEngine _engine; private readonly CameraDirectoryService _directory; private readonly IEventRepository _events; + private readonly CameraEditService _editService; private readonly ILogger _logger; private readonly DispatcherTimer _timer; + private IReadOnlyList _allCameras = Array.Empty(); + public string Title => Localizer.Instance["Nav.Analytics"]; - [ObservableProperty] private bool _engineReady; - [ObservableProperty] private string _engineStatus = ""; - [ObservableProperty] private string _activeProvider = "—"; - [ObservableProperty] private long _framesProcessed; - [ObservableProperty] private long _framesDropped; - [ObservableProperty] private int _queueDepth; - [ObservableProperty] private double _averageLatencyMs; - [ObservableProperty] private int _activeCameras; + // --- Engine ----------------------------------------------------------------- + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsReady))] + [NotifyPropertyChangedFor(nameof(IsBusy))] + [NotifyPropertyChangedFor(nameof(IsFailed))] + [NotifyPropertyChangedFor(nameof(IsNotStarted))] + [NotifyPropertyChangedFor(nameof(ShowTiles))] + private AnalyticsEngineStatus _status; + + public bool IsReady => Status == AnalyticsEngineStatus.Ready; + public bool IsBusy => Status is AnalyticsEngineStatus.Preparing or AnalyticsEngineStatus.Loading; + public bool IsFailed => Status == AnalyticsEngineStatus.Failed; + public bool IsNotStarted => Status == AnalyticsEngineStatus.NotStarted; + public bool ShowTiles => Status is AnalyticsEngineStatus.Ready or AnalyticsEngineStatus.Loading; + + // "Ready · CPU" + [ObservableProperty] private string _statusBadge = ""; + + [ObservableProperty] private string _activeCameras = "0"; + [ObservableProperty] private string _framesProcessed = "0"; + [ObservableProperty] private string _droppedShare = "0 %"; + [ObservableProperty] private string _latency = "—"; + [ObservableProperty] private string _queueDepth = "0"; + + // --- Cameras ---------------------------------------------------------------- + public ObservableCollection Cameras { get; } = new(); + public bool HasCameras => Cameras.Count > 0; + + // "Enable on a camera…" picker: cameras with detection off. + public ObservableCollection EnableCandidates { get; } = new(); + [ObservableProperty] private string _enableSearch = ""; + partial void OnEnableSearchChanged(string value) => RebuildCandidates(); - public ObservableCollection Cameras { get; } = new(); - public ObservableCollection RecentDetections { get; } = new(); + // --- Last 24 h ---------------------------------------------------------------- + public ObservableCollection ClassSummary { get; } = new(); + public bool HasClassSummary => ClassSummary.Count > 0; public AnalyticsPageViewModel( IAnalyticsEngine engine, CameraDirectoryService directory, IEventRepository events, + CameraEditService editService, ILogger logger) { _engine = engine; _directory = directory; _events = events; + _editService = editService; _logger = logger; _timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) }; _timer.Tick += (_, _) => RefreshDiagnostics(); @@ -72,25 +108,30 @@ private async Task ReloadAsync(CancellationToken ct) RefreshDiagnostics(); try { - var cameras = await _directory.ListAsync(ct).ConfigureAwait(true); - var names = cameras.ToDictionary(c => c.Id, c => c.Name); + _allCameras = await _directory.ListAsync(ct).ConfigureAwait(true); + + var since = DateTime.UtcNow - ActivityWindow; + var detections = await _events.ListAsync(null, EventKind.Detection, since, 10000, ct).ConfigureAwait(true); + var perCamera = detections.GroupBy(e => e.CameraId).ToDictionary(g => g.Key, g => g.Count()); Cameras.Clear(); - foreach (var c in cameras.Where(c => c.AnalyticsOrDefault.Enabled)) + foreach (var c in _allCameras.Where(c => c.AnalyticsOrDefault.Enabled) + .OrderBy(c => c.Name, StringComparer.CurrentCultureIgnoreCase)) { - var s = c.AnalyticsOrDefault; - Cameras.Add(new AnalyticsCameraRow( - c.Name, ClassesSummary(s.ClassIds), s.AnalyticsFps, s.ConfidenceThreshold, s.AutoRecord)); + Cameras.Add(new AiCameraRow(c, perCamera.TryGetValue(c.Id, out var n) ? n : 0, OnRowToggled)); } + OnPropertyChanged(nameof(HasCameras)); + RebuildCandidates(); - var events = await _events.ListAsync(null, EventKind.Detection, null, 50, ct).ConfigureAwait(true); - RecentDetections.Clear(); - foreach (var ev in events) - { - var name = names.TryGetValue(ev.CameraId, out var n) ? n : ev.CameraId.ToString(); - RecentDetections.Add(new DetectionEventRow( - name, ev.OccurredAt.ToLocalTime(), ev.Summary ?? "")); - } + // Per class: how many detections it appeared in over the window. + var perClass = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var ev in detections) + foreach (var label in DetectionClasses.Parse(ev.Summary).Keys) + perClass[label] = perClass.TryGetValue(label, out var cur) ? cur + 1 : 1; + ClassSummary.Clear(); + foreach (var (label, count) in perClass.OrderByDescending(kv => kv.Value).Take(8)) + ClassSummary.Add(new DetectionClassChip(label, count, DetectionClasses.IconKey(label))); + OnPropertyChanged(nameof(HasClassSummary)); } catch (Exception ex) { @@ -98,19 +139,96 @@ private async Task ReloadAsync(CancellationToken ct) } } + private void RebuildCandidates() + { + var q = EnableSearch?.Trim() ?? ""; + var enabled = Cameras.Where(r => r.IsEnabled).Select(r => r.Camera.Id).ToHashSet(); + EnableCandidates.Clear(); + foreach (var c in _allCameras + .Where(c => !enabled.Contains(c.Id)) + .Where(c => q.Length == 0 + || c.Name.Contains(q, StringComparison.OrdinalIgnoreCase) + || c.Host.Contains(q, StringComparison.OrdinalIgnoreCase)) + .OrderBy(c => c.Name, StringComparer.CurrentCultureIgnoreCase) + .Take(50)) + { + EnableCandidates.Add(new AiEnableCandidate(c.Id, c.Name, c.Host)); + } + } + + // Row switch: flip Enabled but keep the rest of the camera's settings, so + // turning detection back on restores classes / fps / threshold. + private async void OnRowToggled(AiCameraRow row) + { + try + { + var settings = row.Camera.AnalyticsOrDefault with { Enabled = row.IsEnabled }; + await _directory.SetAnalyticsAsync(row.Camera.Id, settings, CancellationToken.None).ConfigureAwait(true); + RebuildCandidates(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Toggling detection for {Camera} failed", row.Camera.Name); + } + } + + [RelayCommand] + private async Task EnableCameraAsync(AiEnableCandidate? candidate) + { + if (candidate is null) return; + var camera = _allCameras.FirstOrDefault(c => c.Id == candidate.Id); + if (camera is null) return; + try + { + var settings = camera.AnalyticsOrDefault with { Enabled = true }; + await _directory.SetAnalyticsAsync(camera.Id, settings, CancellationToken.None).ConfigureAwait(true); + await ReloadAsync(CancellationToken.None).ConfigureAwait(true); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Enabling detection for {Camera} failed", camera.Name); + } + } + + // Detection classes, fps, threshold and auto-record live in the camera + // editor; open it on that camera. + [RelayCommand] + private async Task ConfigureCameraAsync(AiCameraRow? row) + { + if (row is null) return; + if (await _editService.EditAsync(row.Camera).ConfigureAwait(true)) + await ReloadAsync(CancellationToken.None).ConfigureAwait(true); + } + + [RelayCommand] + private void ShowAllDetections() => WeakReferenceMessenger.Default.Send(new ShowDetectionEventsMessage()); + private void RefreshDiagnostics() { var d = _engine.Diagnostics; - EngineReady = _engine.IsReady; - EngineStatus = StatusLabel(_engine.Status); - ActiveProvider = _engine.ActiveProvider.ToString(); - FramesProcessed = d.FramesProcessed; - FramesDropped = d.FramesDropped; - QueueDepth = d.QueueDepth; - AverageLatencyMs = d.AverageLatencyMs; - ActiveCameras = d.ActiveCameras; + Status = _engine.Status; + var provider = ProviderLabel(_engine.ActiveProvider); + StatusBadge = IsReady ? $"{StatusLabel(Status)} · {provider}" : StatusLabel(Status); + ActiveCameras = d.ActiveCameras.ToString(CultureInfo.CurrentCulture); + FramesProcessed = d.FramesProcessed.ToString("N0", CultureInfo.CurrentCulture); + var total = d.FramesProcessed + d.FramesDropped; + DroppedShare = total == 0 ? "0 %" : $"{100.0 * d.FramesDropped / total:0.#} %"; + Latency = d.AverageLatencyMs > 0 ? $"{d.AverageLatencyMs:F0} ms" : "—"; + QueueDepth = d.QueueDepth.ToString(CultureInfo.CurrentCulture); } + private static string ProviderLabel(ExecutionProvider p) => p switch + { + ExecutionProvider.Cpu => "CPU", + ExecutionProvider.DirectMl => "DirectML", + ExecutionProvider.Cuda => "CUDA", + ExecutionProvider.OpenVino => "OpenVINO", + ExecutionProvider.CoreMl => "Core ML", + ExecutionProvider.NnApi => "NNAPI", + ExecutionProvider.Xnnpack => "XNNPACK", + _ => p.ToString(), + }; + private static string StatusLabel(AnalyticsEngineStatus status) => status switch { AnalyticsEngineStatus.Preparing => Localizer.Instance["Analytics.Status.Preparing"], @@ -119,16 +237,42 @@ private void RefreshDiagnostics() AnalyticsEngineStatus.Failed => Localizer.Instance["Analytics.Status.Failed"], _ => Localizer.Instance["Analytics.Status.NotStarted"], }; +} - private static string ClassesSummary(IReadOnlyCollection? ids) +// A camera with detection configured: switch, classes, settings, 24 h count. +public sealed partial class AiCameraRow : ObservableObject +{ + private readonly Action _onToggled; + + public AiCameraRow(Camera camera, int detections24h, Action onToggled) { - if (ids is not { Count: > 0 }) return Localizer.Instance["Analytics.AllClasses"]; - return string.Join(", ", ids.Select(id => - id >= 0 && id < CocoClasses.Names.Count ? CocoClasses.Names[id] : $"class{id}")); + Camera = camera; + _onToggled = onToggled; + _isEnabled = camera.AnalyticsOrDefault.Enabled; + var s = camera.AnalyticsOrDefault; + Classes = s.ClassIds is { Count: > 0 } ids + ? ids.Where(id => id >= 0 && id < CocoClasses.Names.Count) + .Select(id => CocoClasses.Names[id]) + .Select(label => new DetectionClassChip(label, 0, DetectionClasses.IconKey(label))) + .ToList() + : Array.Empty(); + SettingsLabel = string.Format(CultureInfo.CurrentCulture, Localizer.Instance["Analytics.SettingsFormat"], + s.AnalyticsFps, s.ConfidenceThreshold); + AutoRecord = s.AutoRecord; + Detections24h = string.Format(CultureInfo.CurrentCulture, Localizer.Instance["Analytics.Detections24hFormat"], detections24h); } -} -public sealed record AnalyticsCameraRow( - string Name, string Classes, int Fps, float Threshold, bool AutoRecord); + public Camera Camera { get; } + public string Name => Camera.Name; + public IReadOnlyList Classes { get; } + public bool AllClasses => Classes.Count == 0; + public string SettingsLabel { get; } + public bool AutoRecord { get; } + public string Detections24h { get; } + + [ObservableProperty] private bool _isEnabled; + + partial void OnIsEnabledChanged(bool value) => _onToggled(this); +} -public sealed record DetectionEventRow(string CameraName, DateTime OccurredAt, string Summary); +public sealed record AiEnableCandidate(CameraId Id, string Name, string Host); diff --git a/src/OpenIPC.Viewer.App/ViewModels/ArchiveCalendarViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/ArchiveCalendarViewModel.cs index 558851b..4dd444c 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/ArchiveCalendarViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/ArchiveCalendarViewModel.cs @@ -8,15 +8,17 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Microsoft.Extensions.Logging; +using OpenIPC.Viewer.App.Services; using OpenIPC.Viewer.Core.Archive; using OpenIPC.Viewer.Core.Events; using OpenIPC.Viewer.Core.Recording; namespace OpenIPC.Viewer.App.ViewModels; -// Archive month calendar (Phase 16.3). Highlights days with recordings/events -// (intensity ∝ volume) and raises DaySelected so the recordings list filters to -// the chosen local day. Aggregates are recomputed per visible month. +// Archive month calendar (Phase 16.3). Days with recordings get an accent fill +// (intensity ∝ recording count); event-only days just get a dot, since picking +// them would filter the recordings list to nothing. Raises DaySelected so the +// list filters to the chosen local day. Aggregates are recomputed per month. public sealed partial class ArchiveCalendarViewModel : ViewModelBase { private readonly IRecordingRepository _recordings; @@ -27,7 +29,11 @@ public sealed partial class ArchiveCalendarViewModel : ViewModelBase // reuses it; LoadAsync (page re-entry) clears it so new recordings show. private readonly Dictionary<(int Year, int Month), MonthActivity> _cache = new(); - private sealed record MonthActivity(IReadOnlyDictionary Activity, int MaxTotal); + private sealed record MonthActivity(IReadOnlyDictionary Activity, int MaxRecordings); + + // The recordings page narrows the calendar to its camera / motion filter, + // so only days that would actually list something light up. + private Func? _recordingFilter; // Fired with the selected local date, or null for "all days". public event Action? DaySelected; @@ -58,7 +64,48 @@ public ArchiveCalendarViewModel( public ObservableCollection Days { get; } = new(); public string MonthLabel => - new DateTime(Year, Month, 1).ToString("MMMM yyyy", CultureInfo.CurrentCulture); + UiCulture.TextInfo.ToTitleCase(new DateTime(Year, Month, 1).ToString("MMMM yyyy", UiCulture)); + + // "Recordings this month: 12" — tells at a glance whether a month has any. + [ObservableProperty] private string _monthSummary = ""; + + // First-of-month dates that hold at least one (filtered) recording. Lets an + // empty month offer a jump to the nearest one instead of blind paging. + private List _recordingMonths = new(); + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasNearestMonth))] + private DateTime? _nearestMonth; + + public bool HasNearestMonth => NearestMonth is not null; + + public string NearestMonthLabel => NearestMonth is { } m + ? string.Format(CultureInfo.CurrentCulture, Localizer.Instance["Calendar.NearestFormat"], + UiCulture.TextInfo.ToTitleCase(m.ToString("MMMM yyyy", UiCulture))) + : ""; + + partial void OnNearestMonthChanged(DateTime? value) => OnPropertyChanged(nameof(NearestMonthLabel)); + + [RelayCommand] + private Task JumpToNearestMonthAsync() + { + if (NearestMonth is not { } m) return Task.CompletedTask; + Year = m.Year; + Month = m.Month; + return ShowMonthAsync(CancellationToken.None); + } + + // Follows the app language setting rather than the OS culture. + private static CultureInfo UiCulture => Localizer.Instance.Active == LangCode.Russian + ? CultureInfo.GetCultureInfo("ru-RU") + : CultureInfo.GetCultureInfo("en-US"); + + public async Task SetRecordingFilterAsync(Func? filter) + { + _recordingFilter = filter; + _cache.Clear(); + await ShowMonthAsync(CancellationToken.None).ConfigureAwait(true); + } // Page-entry refresh: drop cached months so freshly-recorded days appear. public Task LoadAsync(CancellationToken ct) @@ -82,18 +129,36 @@ private async Task ShowMonthAsync(CancellationToken ct) .ListAsync(cameraId: null, kind: null, since: monthStartUtc, limit: 20000, ct) .ConfigureAwait(true); + var filter = _recordingFilter; + var matching = recordings.Where(r => filter is null || filter(r)).ToList(); + _recordingMonths = matching + .Select(r => TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(r.StartedAt, DateTimeKind.Utc), tz)) + .Select(local => new DateTime(local.Year, local.Month, 1)) + .Distinct() + .OrderBy(d => d) + .ToList(); var activity = CalendarActivity.ForMonth( Year, Month, - recordings.Select(r => r.StartedAt), + matching.Select(r => r.StartedAt), events.Select(e => e.OccurredAt), tz); - var maxTotal = activity.Count == 0 ? 0 : activity.Values.Max(d => d.Total); - month = new MonthActivity(activity, maxTotal); + var maxRecordings = activity.Count == 0 ? 0 : activity.Values.Max(d => d.RecordingCount); + month = new MonthActivity(activity, maxRecordings); _cache[(Year, Month)] = month; } - BuildGrid(month.Activity, month.MaxTotal); + BuildGrid(month.Activity, month.MaxRecordings); + var monthCount = month.Activity.Values.Sum(d => d.RecordingCount); + MonthSummary = string.Format(CultureInfo.CurrentCulture, Localizer.Instance["Calendar.MonthSummaryFormat"], monthCount); + + // Empty month: point at the closest month that has recordings, + // preferring the past (that's where an archive usually is). + var shown = new DateTime(Year, Month, 1); + NearestMonth = monthCount > 0 + ? null + : _recordingMonths.Where(m => m < shown).Select(m => (DateTime?)m).LastOrDefault() + ?? _recordingMonths.Where(m => m > shown).Select(m => (DateTime?)m).FirstOrDefault(); } catch (Exception ex) { @@ -101,7 +166,7 @@ private async Task ShowMonthAsync(CancellationToken ct) } } - private void BuildGrid(IReadOnlyDictionary activity, int maxTotal) + private void BuildGrid(IReadOnlyDictionary activity, int maxRecordings) { Days.Clear(); var first = new DateTime(Year, Month, 1); @@ -117,8 +182,9 @@ private void BuildGrid(IReadOnlyDictionary activity, int var cell = new CalendarDayCell( date, inMonth: date.Month == Month && date.Year == Year, - total: day.Total, - intensity: CalendarActivity.Intensity(day, maxTotal), + recordingCount: day.RecordingCount, + eventCount: day.EventCount, + intensity: maxRecordings <= 0 ? 0 : Math.Clamp(day.RecordingCount / (double)maxRecordings, 0.0, 1.0), isToday: date == today) { IsSelected = SelectedDate is { } sel && sel.Date == date, @@ -151,7 +217,7 @@ private void Shift(int months) [RelayCommand] private void SelectDay(CalendarDayCell? cell) { - if (cell is null || !cell.HasActivity) return; + if (cell is null || !cell.HasRecordings) return; // Toggle off if the same day is tapped again. if (SelectedDate is { } cur && cur.Date == cell.Date.Date) { @@ -176,26 +242,33 @@ private void ShowAll() public sealed partial class CalendarDayCell : ObservableObject { - public CalendarDayCell(DateTime date, bool inMonth, int total, double intensity, bool isToday) + public CalendarDayCell(DateTime date, bool inMonth, int recordingCount, int eventCount, double intensity, bool isToday) { Date = date; InMonth = inMonth; - Total = total; + RecordingCount = recordingCount; + EventCount = eventCount; Intensity = intensity; IsToday = isToday; } public DateTime Date { get; } public bool InMonth { get; } - public int Total { get; } + public int RecordingCount { get; } + public int EventCount { get; } public double Intensity { get; } public bool IsToday { get; } - public bool HasActivity => Total > 0; + public bool HasRecordings => RecordingCount > 0; + public bool HasEventsOnly => RecordingCount == 0 && EventCount > 0; public string DayNumber => Date.Day.ToString(CultureInfo.CurrentCulture); - // Accent opacity for the cell: faint days still read as active (floor 0.25); - // empty days stay transparent. - public double Shade => HasActivity ? Math.Max(0.25, Intensity) : 0.0; + // Accent opacity for a recording day: even a single recording reads clearly + // (floor 0.45); days without recordings stay transparent. + public double Shade => HasRecordings ? 0.45 + 0.55 * Intensity : 0.0; + + public string? Tooltip => RecordingCount == 0 && EventCount == 0 + ? null + : string.Format(CultureInfo.CurrentCulture, Localizer.Instance["Calendar.DayTooltipFormat"], RecordingCount, EventCount); [ObservableProperty] private bool _isSelected; } diff --git a/src/OpenIPC.Viewer.App/ViewModels/CameraLibraryPageViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/CameraLibraryPageViewModel.cs index 6ff371a..18b9f5b 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/CameraLibraryPageViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/CameraLibraryPageViewModel.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Linq; using System.Threading; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; @@ -9,8 +11,10 @@ using OpenIPC.Viewer.App.Messages; using OpenIPC.Viewer.App.Services; using OpenIPC.Viewer.App.ViewModels.Dialogs; +using OpenIPC.Viewer.Core.Discovery; using OpenIPC.Viewer.Core.Entities; using OpenIPC.Viewer.Core.Onvif.Discovery; +using OpenIPC.Viewer.Core.Persistence; using OpenIPC.Viewer.Core.Services; using OpenIPC.Viewer.Core.Status; @@ -50,15 +54,115 @@ public sealed partial class CameraLibraryPageViewModel : ViewModelBase, IRecipie private readonly CameraStatusRegistry _statusRegistry; private readonly ManageGroupsDialogFactory _manageGroupsFactory; private bool _autoScanRanThisSession; - private System.Collections.Generic.IReadOnlyList _allCameras = System.Array.Empty(); - // CameraIds in the active layout (Phase 19.1) — drives the "in grid" checkbox. - private System.Collections.Generic.HashSet _gridMembership = new(); + private readonly ILayoutRepository _layouts; + private readonly CameraEditService _editService; + private IReadOnlyList _allCameras = Array.Empty(); + private IReadOnlyList _allLayouts = Array.Empty(); + private Dictionary _groupNames = new(); public ObservableCollection AvailableGroups { get; } = new(); + // Every layout, for the row menu's "Layouts ▸" submenu. + public IReadOnlyList AllLayouts => _allLayouts; + + // --- Filters ------------------------------------------------------------ + // Rows are built once per load; group / search / status only re-slice them + // into PageRows, so typing in the search box doesn't re-probe every camera. [ObservableProperty] private CameraGroup? _selectedGroupFilter; + [ObservableProperty] private string _searchText = ""; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsFilterAll))] + [NotifyPropertyChangedFor(nameof(IsFilterOnline))] + [NotifyPropertyChangedFor(nameof(IsFilterOffline))] + [NotifyPropertyChangedFor(nameof(IsFilterAttention))] + private LibraryStatusFilter _statusFilter; + + // One flag per status chip (RadioButtons bind TwoWay to these). + public bool IsFilterAll { get => StatusFilter == LibraryStatusFilter.All; set { if (value) StatusFilter = LibraryStatusFilter.All; } } + public bool IsFilterOnline { get => StatusFilter == LibraryStatusFilter.Online; set { if (value) StatusFilter = LibraryStatusFilter.Online; } } + public bool IsFilterOffline { get => StatusFilter == LibraryStatusFilter.Offline; set { if (value) StatusFilter = LibraryStatusFilter.Offline; } } + public bool IsFilterAttention { get => StatusFilter == LibraryStatusFilter.Attention; set { if (value) StatusFilter = LibraryStatusFilter.Attention; } } + + // Chip counters — over the rows that pass the group + search filters. + [ObservableProperty] private int _totalCount; + [ObservableProperty] private int _onlineCount; + [ObservableProperty] private int _offlineCount; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasAttention))] + private int _attentionCount; + public bool HasAttention => AttentionCount > 0 || StatusFilter == LibraryStatusFilter.Attention; + + // Cameras exist but the filters hide all of them. + [ObservableProperty] private bool _hasNoMatches; + + // A new filter starts from the first page; a probe landing only re-clamps. + partial void OnSelectedGroupFilterChanged(CameraGroup? value) => ApplyFilters(resetPage: true); + partial void OnSearchTextChanged(string value) => ApplyFilters(resetPage: true); + partial void OnStatusFilterChanged(LibraryStatusFilter value) + { + OnPropertyChanged(nameof(HasAttention)); + ApplyFilters(resetPage: true); + } + + // --- Pagination ----------------------------------------------------------- + // Only the current page's rows are materialised (PageRows). Reachability + // probes still cover every camera — a TCP connect is cheap and the status + // chips need the full counts — but anything heavier per row (preview + // stills) should run for PageRows only. + public const int PageSize = 20; + + public ObservableCollection PageRows { get; } = new(); + + // 1-based page numbers for the pager buttons. + public ObservableCollection Pages { get; } = new(); - partial void OnSelectedGroupFilterChanged(CameraGroup? value) => RefilterCameras(); + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CurrentPageDisplay))] + [NotifyPropertyChangedFor(nameof(CanPrevPage))] + [NotifyPropertyChangedFor(nameof(CanNextPage))] + private int _currentPage; // 0-based + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasMultiplePages))] + [NotifyPropertyChangedFor(nameof(CanNextPage))] + private int _pageCount = 1; + + public int CurrentPageDisplay => CurrentPage + 1; + public bool HasMultiplePages => PageCount > 1; + public bool CanPrevPage => CurrentPage > 0; + public bool CanNextPage => CurrentPage + 1 < PageCount; + + [RelayCommand] + private void PrevPage() + { + if (!CanPrevPage) return; + CurrentPage--; + ApplyFilters(resetPage: false); + } + + [RelayCommand] + private void NextPage() + { + if (!CanNextPage) return; + CurrentPage++; + ApplyFilters(resetPage: false); + } + + // CommandParameter is the boxed 1-based page number from the Pages binding + // (object? sidesteps the RelayCommand XAML render crash). + [RelayCommand] + private void GoToPage(object? page) + { + if (page is null) return; + int oneBased; + try { oneBased = Convert.ToInt32(page, System.Globalization.CultureInfo.InvariantCulture); } + catch (Exception) { return; } + var target = oneBased - 1; + if (target < 0 || target >= PageCount || target == CurrentPage) return; + CurrentPage = target; + ApplyFilters(resetPage: false); + } public CameraLibraryPageViewModel( CameraDirectoryService directory, @@ -73,6 +177,8 @@ public CameraLibraryPageViewModel( IDiscoveryService discovery, IReachabilityProbe reachability, CameraStatusRegistry statusRegistry, + ILayoutRepository layouts, + CameraEditService editService, ILogger logger) { _directory = directory; @@ -87,6 +193,8 @@ public CameraLibraryPageViewModel( _discovery = discovery; _reachability = reachability; _statusRegistry = statusRegistry; + _layouts = layouts; + _editService = editService; _logger = logger; WeakReferenceMessenger.Default.Register(this); // Toggling "risky device tools" in Settings shows/hides the Files button @@ -133,12 +241,10 @@ public async Task LoadAsync(CancellationToken ct) try { _allCameras = await _directory.ListAsync(ct).ConfigureAwait(true); - // "In grid" now means membership in the active layout (Phase 19.1), - // so the checkbox reflects the tab the grid is currently showing. - var members = await _directory.GetActiveLayoutCameraIdsAsync(ct).ConfigureAwait(true); - _gridMembership = new System.Collections.Generic.HashSet(members); await ReloadGroupsAsync(ct).ConfigureAwait(true); - RefilterCameras(); + RebuildRows(); + await LoadLayoutMembershipAsync(ct).ConfigureAwait(true); + ApplyFilters(); IsLoaded = true; } finally @@ -228,6 +334,7 @@ await _userSettings.UpdateAsync(_userSettings.Current with { WelcomeShown = true private async Task ReloadGroupsAsync(CancellationToken ct) { var groups = await _directory.ListGroupsAsync(ct).ConfigureAwait(true); + _groupNames = groups.ToDictionary(g => g.Id, g => g.Name); // Preserve the current selection's Id across reloads (record identity // changes when we re-query the DB). var prevId = SelectedGroupFilter?.Id; @@ -243,20 +350,19 @@ private async Task ReloadGroupsAsync(CancellationToken ct) SelectedGroupFilter = null; } - private void RefilterCameras() + private void RebuildRows() { - var filtered = SelectedGroupFilter is null - ? _allCameras - : (System.Collections.Generic.IReadOnlyList) - System.Linq.Enumerable.ToList( - System.Linq.Enumerable.Where(_allCameras, c => c.GroupId.Equals(SelectedGroupFilter.Id))); - + foreach (var old in Cameras) old.PropertyChanged -= OnRowPropertyChanged; Cameras.Clear(); - foreach (var camera in filtered) + foreach (var camera in _allCameras) { - var row = new CameraRowViewModel(camera, _directory, _reachability, _statusRegistry, _logger, _gridMembership.Contains(camera.Id)); + var row = new CameraRowViewModel(camera, _directory, _reachability, _statusRegistry, _logger) + { + GroupName = camera.GroupId is { } gid && _groupNames.TryGetValue(gid, out var name) ? name : null, + }; // Seed from whatever the registry already knows (e.g. a live grid session). row.ApplyStatus(_statusRegistry.Get(camera.Id).Status); + row.PropertyChanged += OnRowPropertyChanged; Cameras.Add(row); } @@ -265,6 +371,111 @@ private void RefilterCameras() _ = ProbeReachabilityAsync(); } + // A probe / registry verdict landed — counters and the status filter move. + private void OnRowPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(CameraRowViewModel.Status)) + ApplyFilters(); + } + + private void ApplyFilters(bool resetPage = false) + { + var query = SearchText?.Trim() ?? ""; + var group = SelectedGroupFilter; + int total = 0, online = 0, offline = 0, attention = 0; + var shown = new List(); + foreach (var row in Cameras) + { + var matches = (group is null || row.Camera.GroupId.Equals(group.Id)) && row.Matches(query); + if (matches) + { + total++; + switch (row.Status) + { + case CameraStatus.Online: online++; break; + case CameraStatus.Offline: offline++; break; + case CameraStatus.Attention: attention++; break; + } + } + var passesStatus = StatusFilter switch + { + LibraryStatusFilter.Online => row.Status == CameraStatus.Online, + LibraryStatusFilter.Offline => row.Status == CameraStatus.Offline, + LibraryStatusFilter.Attention => row.Status == CameraStatus.Attention, + _ => true, + }; + if (matches && passesStatus) shown.Add(row); + } + TotalCount = total; + OnlineCount = online; + OfflineCount = offline; + AttentionCount = attention; + HasNoMatches = Cameras.Count > 0 && shown.Count == 0; + + var pageCount = Math.Max(1, (shown.Count + PageSize - 1) / PageSize); + if (pageCount != PageCount || Pages.Count != pageCount) + { + PageCount = pageCount; + Pages.Clear(); + for (var i = 1; i <= pageCount; i++) Pages.Add(i); + } + CurrentPage = resetPage ? 0 : Math.Min(CurrentPage, pageCount - 1); + + // Swap the visible rows only when the page's set actually changed — a + // probe verdict under the "All" filter must not re-template the list. + var page = shown.Skip(CurrentPage * PageSize).Take(PageSize).ToList(); + if (!page.SequenceEqual(PageRows)) + { + PageRows.Clear(); + foreach (var row in page) PageRows.Add(row); + } + } + + // --- Layout membership (replaces the old "in grid" checkbox) ----------- + // A camera can sit in any number of layouts; the row shows them and its + // menu toggles each one. + private async Task LoadLayoutMembershipAsync(CancellationToken ct) + { + _allLayouts = await _layouts.GetAllAsync(ct).ConfigureAwait(true); + var byCamera = new Dictionary>(); + foreach (var layout in _allLayouts) + { + foreach (var id in await _layouts.GetTilesAsync(layout.Id, ct).ConfigureAwait(true)) + { + if (!byCamera.TryGetValue(id, out var list)) byCamera[id] = list = new List(); + list.Add(layout); + } + } + foreach (var row in Cameras) + row.SetLayouts(byCamera.TryGetValue(row.Camera.Id, out var m) ? m : new List()); + } + + /// + /// Re-reads layout membership for the rows on screen — layouts may have + /// changed on the Live page since the library was loaded. + /// + public async Task RefreshLayoutMembershipAsync() + { + try { await LoadLayoutMembershipAsync(CancellationToken.None).ConfigureAwait(true); } + catch (Exception ex) { _logger.LogWarning(ex, "Layout membership refresh failed"); } + } + + public async Task ToggleLayoutMembershipAsync(CameraRowViewModel row, GridLayout layout) + { + try + { + if (row.IsInLayout(layout.Id)) + await _layouts.RemoveTileAsync(layout.Id, row.Camera.Id, CancellationToken.None).ConfigureAwait(true); + else + await _layouts.AddTileAsync(layout.Id, row.Camera.Id, CancellationToken.None).ConfigureAwait(true); + await LoadLayoutMembershipAsync(CancellationToken.None).ConfigureAwait(true); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Toggling layout {Layout} for {CameraId} failed", layout.Name, row.Camera.Id); + } + } + /// /// Re-runs reachability probes for the rows already on screen. Called by /// the view on every Loaded — the full LoadAsync only runs once (IsLoaded @@ -304,13 +515,24 @@ private void OpenCamera(CameraRowViewModel? row) private async Task AddCameraAsync() { var editor = _editorFactory.CreateForNew(); + editor.ShowAlternatives = true; var result = await _dialogs.ShowCameraEditorAsync(editor).ConfigureAwait(true); + switch (result?.Redirect) + { + case CameraEditorRedirect.Discover: + await DiscoverCameraAsync().ConfigureAwait(true); + return; + case CameraEditorRedirect.ScanQr: + await ScanQrAsync().ConfigureAwait(true); + return; + } if (result?.NewRequest is not { } req) return; try { - await _directory.AddAsync(req, CancellationToken.None).ConfigureAwait(true); + var id = await _directory.AddAsync(req, CancellationToken.None).ConfigureAwait(true); + await _directory.SaveDetectedAsync(id, result, CancellationToken.None).ConfigureAwait(true); await LoadAsync(CancellationToken.None).ConfigureAwait(true); } catch (Exception ex) @@ -326,8 +548,8 @@ private async Task DiscoverCameraAsync() // the discovery dialog reopens with the SAME scan results and creds // (DiscoverySessionCache), so several cameras go in from one scan. // Only cancelling the discovery dialog itself exits. - var knownHosts = new System.Collections.Generic.HashSet( - System.Linq.Enumerable.Select(_allCameras, c => c.Host), + var knownHosts = new HashSet( + _allCameras.Select(c => c.Host), StringComparer.OrdinalIgnoreCase); while (true) @@ -339,29 +561,44 @@ private async Task DiscoverCameraAsync() if (found is null) return; - // Pre-fill the editor from the probe result so the user sees / can tweak - // everything before saving (RTSP URI especially — phase-04 risks §"ONVIF - // returns wrong RTSP URI behind NAT" applies). + if (found.ManualEntry || found.Device is not { } device) + { + await AddCameraAsync().ConfigureAwait(true); + return; + } + + // The editor's Connect does the identifying (OpenIPC / ONVIF), picks + // the streams and checks them; hand it the address, what discovery + // already knows, and the login last used this session. var editor = _editorFactory.CreateForNew(); - editor.Name = found.Device.Model ?? found.Device.Name ?? found.Device.Host; - editor.Host = found.Device.Host; - editor.OnvifPortText = (found.Device.OnvifServiceUri?.Port ?? 80).ToString(System.Globalization.CultureInfo.InvariantCulture); - editor.RtspMainText = found.RtspMainUri.ToString(); + editor.Host = device.Host; + // The web UI answered on 8080 only: that's the camera's HTTP port. + if (device.Ports.Contains(8080) && !device.Ports.Contains(80)) + editor.HttpPort = 8080; + if (device.OnvifServiceUri is { } onvif) + editor.OnvifPortText = onvif.Port.ToString(System.Globalization.CultureInfo.InvariantCulture); + if ((device.Model ?? device.Name) is { } label) + editor.SuggestName($"{label} — {device.Host}"); + // A bare RTSP responder on the alternate port: point the stream there. + if (!device.Protocols.HasFlag(DiscoveryProtocol.Majestic) && !device.Protocols.HasFlag(DiscoveryProtocol.Onvif) + && device.Ports.Contains(8554) && !device.Ports.Contains(554)) + editor.RtspMainText = $"rtsp://{device.Host}:8554/"; editor.Username = found.Credentials?.Username ?? ""; editor.Password = found.Credentials?.Password ?? ""; + editor.AutoConnect = true; var result = await _dialogs.ShowCameraEditorAsync(editor).ConfigureAwait(true); + // Whatever login was typed is the best guess for the next camera. + _discoveryFactory.Session.Username = editor.Username; + _discoveryFactory.Session.Password = editor.Password; if (result?.NewRequest is not { } req) continue; // editor cancelled — back to the scan list try { var id = await _directory.AddAsync(req, CancellationToken.None).ConfigureAwait(true); - // Persist HasPtz / ProfileToken / manufacturer info from the probe so - // SingleCameraPage knows whether to show the PTZ joystick (Phase 4c). - // Non-ONVIF devices (sweep/mDNS) have no probe — nothing to persist. - if (found.Probe is { } probe) - await _directory.SaveOnvifMetadataAsync(id, probe, CancellationToken.None).ConfigureAwait(true); + // PTZ / ONVIF profile / OpenIPC flag learned by the editor's Connect. + await _directory.SaveDetectedAsync(id, result, CancellationToken.None).ConfigureAwait(true); await LoadAsync(CancellationToken.None).ConfigureAwait(true); knownHosts.Add(req.Host); } @@ -434,7 +671,8 @@ await _dialogs.ConfirmAsync( try { - await _directory.AddAsync(req, CancellationToken.None).ConfigureAwait(true); + var id = await _directory.AddAsync(req, CancellationToken.None).ConfigureAwait(true); + await _directory.SaveDetectedAsync(id, result, CancellationToken.None).ConfigureAwait(true); await LoadAsync(CancellationToken.None).ConfigureAwait(true); } catch (Exception ex) @@ -493,22 +731,8 @@ private async Task EditCameraAsync(CameraRowViewModel? row) if (row is null) return; - var creds = await _directory.GetCredentialsAsync(row.Camera.Id, CancellationToken.None).ConfigureAwait(true); - var sshCreds = await _directory.GetSshCredentialsAsync(row.Camera.Id, CancellationToken.None).ConfigureAwait(true); - var editor = _editorFactory.CreateForEdit(row.Camera, creds, sshCreds); - var result = await _dialogs.ShowCameraEditorAsync(editor).ConfigureAwait(true); - if (result?.UpdateRequest is not { } req) - return; - - try - { - await _directory.UpdateAsync(row.Camera.Id, req, CancellationToken.None).ConfigureAwait(true); + if (await _editService.EditAsync(row.Camera).ConfigureAwait(true)) await LoadAsync(CancellationToken.None).ConfigureAwait(true); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to update camera {Id}", row.Camera.Id); - } } [RelayCommand] @@ -556,7 +780,26 @@ public sealed partial class CameraRowViewModel : ViewModelBase ? Camera.Host : $"{Camera.Host}:{Camera.HttpPort}"; - [ObservableProperty] private bool _isIncludedInGrid; + public string? GroupName { get; init; } + public bool HasGroup => !string.IsNullOrEmpty(GroupName); + + // Layouts this camera is a tile of, as a "Default, Yard" label. + private HashSet _layoutIds = new(); + [ObservableProperty] private string _layoutsLabel = "—"; + + public bool IsInLayout(LayoutId id) => _layoutIds.Contains(id); + + public void SetLayouts(IReadOnlyCollection layouts) + { + _layoutIds = layouts.Select(l => l.Id).ToHashSet(); + LayoutsLabel = layouts.Count == 0 ? "—" : string.Join(", ", layouts.Select(l => l.Name)); + } + + internal bool Matches(string query) => + query.Length == 0 + || Name.Contains(query, StringComparison.OrdinalIgnoreCase) + || HostAndPort.Contains(query, StringComparison.OrdinalIgnoreCase) + || (GroupName?.Contains(query, StringComparison.OrdinalIgnoreCase) ?? false); // Displayed status, pushed from the shared CameraStatusRegistry via the page's // Changed handler. The registry merges this row's own probe with any live grid @@ -581,16 +824,13 @@ public CameraRowViewModel(Camera camera) : this(camera, null, null, null, null) public CameraRowViewModel(Camera camera, CameraDirectoryService? directory, ILogger? logger) : this(camera, directory, null, null, logger) { } - public CameraRowViewModel(Camera camera, CameraDirectoryService? directory, IReachabilityProbe? reachability, CameraStatusRegistry? statusRegistry, ILogger? logger, bool? includedInGrid = null) + public CameraRowViewModel(Camera camera, CameraDirectoryService? directory, IReachabilityProbe? reachability, CameraStatusRegistry? statusRegistry, ILogger? logger) { Camera = camera; _directory = directory; _reachability = reachability; _statusRegistry = statusRegistry; _logger = logger; - // Field (not property) so seeding the checkbox doesn't trigger a persist. - // Defaults to the active-layout membership (Phase 19.1), else the flag. - _isIncludedInGrid = includedInGrid ?? camera.IncludedInGrid; } /// @@ -615,22 +855,12 @@ public async Task RefreshReachabilityAsync(CancellationToken ct) else _statusRegistry.ReportReachability(Camera.Id, reachable); } +} - partial void OnIsIncludedInGridChanged(bool value) - { - if (_directory is null) return; - _ = PersistGridFlagAsync(value); - } - - private async Task PersistGridFlagAsync(bool value) - { - try - { - await _directory!.SetIncludedInGridAsync(Camera.Id, value, CancellationToken.None).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger?.LogWarning(ex, "Failed to persist IncludedInGrid for {CameraId}", Camera.Id); - } - } +public enum LibraryStatusFilter +{ + All, + Online, + Offline, + Attention, } diff --git a/src/OpenIPC.Viewer.App/ViewModels/CameraPickerViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/CameraPickerViewModel.cs new file mode 100644 index 0000000..147f3b9 --- /dev/null +++ b/src/OpenIPC.Viewer.App/ViewModels/CameraPickerViewModel.cs @@ -0,0 +1,200 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Globalization; +using System.Linq; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using OpenIPC.Viewer.App.Services; +using OpenIPC.Viewer.Core.Entities; + +namespace OpenIPC.Viewer.App.ViewModels; + +// A camera as the picker lists it. +public sealed record CameraPickSource(CameraId Id, string Name, string Host, string? Group); + +// Searchable multi-select camera filter shared by the Events and Recordings +// pages (rendered by Controls/CameraPickerView). The page feeds it every camera +// plus a per-camera count of the items it lists (events, recordings); by default +// the picker only offers cameras with a non-zero count, busiest first. +public sealed partial class CameraPickerViewModel : ObservableObject +{ + private readonly string _footerFormatKey; + private readonly List _items = new(); + + // withItemsKey: label of the "only cameras that have something" scope + // ("With events"); footerFormatKey: "{0} of {1} with events". + public CameraPickerViewModel(string withItemsKey, string footerFormatKey) + { + WithItemsLabel = Localizer.Instance[withItemsKey]; + _footerFormatKey = footerFormatKey; + UpdateButtonLabel(); + } + + public string WithItemsLabel { get; } + + public ObservableCollection PickerItems { get; } = new(); + public ObservableCollection SelectedCameras { get; } = new(); + + // Raised after the ticked set changes. + public event Action? SelectionChanged; + + [ObservableProperty] private string _search = ""; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsWithItems))] + private bool _showAll; + + public bool IsWithItems { get => !ShowAll; set => ShowAll = !value; } + + [ObservableProperty] private string _footer = ""; + + // "Cameras: all" / the single camera's name / "Cameras: 2" + [ObservableProperty] private string _buttonLabel = ""; + + public bool HasSelection => SelectedCameras.Count > 0; + + partial void OnSearchChanged(string value) => Rebuild(); + partial void OnShowAllChanged(bool value) => Rebuild(); + + // True when nothing is ticked (no filter) or the camera is ticked. + public bool Matches(CameraId id) => SelectedCameras.Count == 0 || SelectedCameras.Any(i => i.Id == id); + + // Replaces the camera list, keeping ticks on cameras that still exist. + public void SetCameras(IEnumerable cameras) + { + var keep = SelectedCameras.Select(i => i.Id).ToHashSet(); + SelectedCameras.Clear(); + _items.Clear(); + foreach (var c in cameras) + { + var item = new CameraPickItem(c.Id, c.Name, c.Host, c.Group, OnToggled); + item.SetSelectedSilently(keep.Contains(c.Id)); + if (item.IsSelected) SelectedCameras.Add(item); + _items.Add(item); + } + UpdateButtonLabel(); + Rebuild(); + } + + // Per-camera counts (the page computes them ignoring the camera filter, so + // unticked cameras still show how busy they are). + public void SetCounts(IReadOnlyDictionary counts) + { + var max = counts.Count == 0 ? 0 : counts.Values.Max(); + var changed = false; + foreach (var item in _items) + { + var c = counts.TryGetValue(item.Id, out var n) ? n : 0; + if (c != item.Count) changed = true; + item.SetCount(c, max); + } + // Ticking a camera doesn't move counts — leave an open picker alone + // then, so the row under the pointer isn't rebuilt mid-click. + if (changed || PickerItems.Count == 0) Rebuild(); + } + + // Ticks one camera (e.g. clicking its name in a list row). + public void Select(CameraId id) + { + var item = _items.FirstOrDefault(i => i.Id == id); + if (item is not null && !item.IsSelected) item.IsSelected = true; + } + + [RelayCommand] + private void Remove(CameraPickItem? item) + { + if (item is not null) item.IsSelected = false; // → OnToggled + } + + [RelayCommand] + private void Clear() + { + foreach (var item in SelectedCameras.ToList()) item.IsSelected = false; + } + + private void OnToggled(CameraPickItem item) + { + if (item.IsSelected && !SelectedCameras.Contains(item)) SelectedCameras.Add(item); + else if (!item.IsSelected) SelectedCameras.Remove(item); + UpdateButtonLabel(); + SelectionChanged?.Invoke(); + } + + private void UpdateButtonLabel() + { + ButtonLabel = SelectedCameras.Count switch + { + 0 => Localizer.Instance["Events.Cameras.All"], + 1 => SelectedCameras[0].Name, + var n => string.Format(CultureInfo.CurrentCulture, Localizer.Instance["Events.Cameras.CountFormat"], n), + }; + OnPropertyChanged(nameof(HasSelection)); + } + + // Visible rows: search over name / host / group; busiest first. The default + // scope hides cameras with nothing to show (ticked ones always stay). + private void Rebuild() + { + var q = Search?.Trim() ?? ""; + var items = _items.Where(i => i.Matches(q)); + if (!ShowAll) items = items.Where(i => i.Count > 0 || i.IsSelected); + PickerItems.Clear(); + foreach (var i in items.OrderByDescending(i => i.Count).ThenBy(i => i.Name, StringComparer.CurrentCultureIgnoreCase)) + PickerItems.Add(i); + Footer = string.Format(CultureInfo.CurrentCulture, Localizer.Instance[_footerFormatKey], + _items.Count(i => i.Count > 0), _items.Count); + } +} + +// One camera in the picker: its count (with a proportional bar) and whether +// it's part of the filter. +public sealed partial class CameraPickItem : ObservableObject +{ + private const double BarMax = 60; + + private readonly Action _onToggled; + private bool _silent; + + public CameraPickItem(CameraId id, string name, string host, string? group, Action onToggled) + { + Id = id; + Name = name; + Host = host; + Group = group; + _onToggled = onToggled; + } + + public CameraId Id { get; } + public string Name { get; } + public string Host { get; } + public string? Group { get; } + + [ObservableProperty] private bool _isSelected; + [ObservableProperty] private int _count; + [ObservableProperty] private double _barWidth; + + partial void OnIsSelectedChanged(bool value) + { + if (!_silent) _onToggled(this); + } + + public void SetSelectedSilently(bool value) + { + _silent = true; + try { IsSelected = value; } + finally { _silent = false; } + } + + public void SetCount(int count, int max) + { + Count = count; + BarWidth = count == 0 || max == 0 ? 0 : Math.Max(3, BarMax * count / max); + } + + internal bool Matches(string query) => + query.Length == 0 + || Name.Contains(query, StringComparison.OrdinalIgnoreCase) + || Host.Contains(query, StringComparison.OrdinalIgnoreCase) + || (Group?.Contains(query, StringComparison.OrdinalIgnoreCase) ?? false); +} diff --git a/src/OpenIPC.Viewer.App/ViewModels/Dialogs/CameraEditorViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/Dialogs/CameraEditorViewModel.cs index 62567e7..d320e52 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/Dialogs/CameraEditorViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/Dialogs/CameraEditorViewModel.cs @@ -1,48 +1,121 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Globalization; +using System.IO; using System.Linq; -using System.Reactive.Linq; -using System.Reactive.Threading.Tasks; using System.Threading; using System.Threading.Tasks; +using Avalonia.Media.Imaging; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Microsoft.Extensions.Logging; using OpenIPC.Viewer.App.Services; using OpenIPC.Viewer.Core.Analytics; using OpenIPC.Viewer.Core.Entities; +using OpenIPC.Viewer.Core.Onvif; using OpenIPC.Viewer.Core.Services; using OpenIPC.Viewer.Core.Video; namespace OpenIPC.Viewer.App.ViewModels.Dialogs; +public enum ConnectState { Idle, Connecting, Connected, Failed } + +// Add / edit camera. The form leads with what's needed to reach the camera +// (address + login); "Connect" works out the rest (device type, RTSP URIs, +// ports, a name) and proves it with a live frame. Everything else sits in +// collapsed "Advanced" sections with a one-line summary each. public sealed partial class CameraEditorViewModel : ViewModelBase { - private readonly IVideoEngine? _engine; + private const int DefaultHttpPort = 80; + + private readonly CameraConnectService? _connect; private readonly CameraDirectoryService? _directory; - private readonly UserSettingsService? _userSettings; + private readonly IDialogService? _dialogs; private readonly ILogger? _logger; private GroupId? _pendingGroupId; + private CancellationTokenSource? _connectCts; + private string? _autoName; + private bool _saveAttempted; public CameraId? EditingId { get; } - public string Title => Localizer.Instance[EditingId is null ? "CameraEditor.Title.Add" : "CameraEditor.Title.Edit"]; - - [ObservableProperty] private string _name = ""; - [ObservableProperty] private string _host = ""; - [ObservableProperty] private int _httpPort = 80; - [ObservableProperty] private string _onvifPortText = ""; - [ObservableProperty] private string _rtspMainText = ""; - [ObservableProperty] private string _rtspSubText = ""; + public bool IsNew => EditingId is null; + + // Library "Add camera" sets this so the header offers the other ways in. + [ObservableProperty] private bool _showAlternatives; + + // Opened from discovery: run Connect as soon as the dialog shows. + public bool AutoConnect { get; set; } + + // A name to start with that Connect may still replace with a better one. + public void SuggestName(string name) + { + Name = name; + _autoName = name; + } + + public string Title => IsNew + ? Localizer.Instance["CameraEditor.Title.Add"] + : string.Format(CultureInfo.CurrentCulture, Localizer.Instance["CameraEditor.Title.EditFormat"], + string.IsNullOrWhiteSpace(Name) ? Host : Name); + + public string SaveLabel => Localizer.Instance[IsNew ? "CameraEditor.Button.Add" : "Common.Save"]; + public string FooterHint => CanSave + ? Localizer.Instance[IsNew ? "CameraEditor.Hint.EnterAdd" : "CameraEditor.Hint.EnterSave"] + : Localizer.Instance["CameraEditor.Hint.NeedAddress"]; + + // ── Connection ──────────────────────────────────────────────────────── + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowNameSection), nameof(Title), nameof(CanSave), nameof(FooterHint))] + private string _host = ""; + [ObservableProperty] private string _username = ""; [ObservableProperty] private string _password = ""; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(Title))] + private string _name = ""; + [ObservableProperty] private CameraGroup? _selectedGroup; - // SSH device suite (Phase 13). Blank SSH fields = reuse the main login; - // blank port = default 22. - [ObservableProperty] private string _sshUsername = ""; + public bool ShowNameSection => !IsNew || !string.IsNullOrWhiteSpace(Host); + + // ── Advanced: streams / ports / SSH / quality / AI ─────────────────── + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(StreamsSummary))] + private string _rtspMainText = ""; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(StreamsSummary))] + private string _rtspSubText = ""; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(PortsSummary))] + private string _httpPortText = ""; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(PortsSummary))] + private string _onvifPortText = ""; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(PortsSummary))] + private string _sshPortText = ""; + + // SSH device suite (Phase 13). Blank = reuse the main login. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(SshSummary))] + private string _sshUsername = ""; + [ObservableProperty] private string _sshPassword = ""; - [ObservableProperty] private string _sshPortText = ""; + + // Kept for callers that pre-fill a port (QR payloads). + public int HttpPort + { + get => int.TryParse(HttpPortText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var p) ? p : DefaultHttpPort; + set => HttpPortText = value == DefaultHttpPort ? "" : value.ToString(CultureInfo.InvariantCulture); + } // Per-camera SD/HD override (Phase 12.2). public IReadOnlyList StreamQualityOptions { get; } = new[] @@ -52,10 +125,15 @@ public sealed partial class CameraEditorViewModel : ViewModelBase new StreamQualityOption(Localizer.Instance["CameraEditor.Quality.AlwaysSd"], StreamQualityOverride.AlwaysSd), }; - [ObservableProperty] private StreamQualityOption? _selectedStreamQuality; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(QualitySummary))] + private StreamQualityOption? _selectedStreamQuality; // AI analytics (Phase 15.4). Threshold 0..1; fps clamped 1..10 by the engine. - [ObservableProperty] private bool _aiEnabled; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(AiSummary))] + private bool _aiEnabled; + [ObservableProperty] private double _aiThreshold = 0.5; [ObservableProperty] private int _aiFps = 3; [ObservableProperty] private bool _aiAutoRecord; @@ -67,56 +145,107 @@ public sealed partial class CameraEditorViewModel : ViewModelBase // Includes a leading null entry so the user can pick "no group". public ObservableCollection AvailableGroups { get; } = new(); + [ObservableProperty] private bool _isStreamsOpen; + [ObservableProperty] private bool _isPortsOpen; + [ObservableProperty] private bool _isSshOpen; + [ObservableProperty] private bool _isQualityOpen; + [ObservableProperty] private bool _isAiOpen; + + public string StreamsSummary => + string.IsNullOrWhiteSpace(RtspMainText) + ? Localizer.Instance["CameraEditor.Summary.Auto"] + : string.IsNullOrWhiteSpace(RtspSubText) + ? Localizer.Instance["CameraEditor.Summary.MainOnly"] + : Localizer.Instance["CameraEditor.Summary.MainSub"]; + + public string PortsSummary => + $"HTTP {PortOr(HttpPortText, "80")} · ONVIF {PortOr(OnvifPortText, "—")} · SSH {PortOr(SshPortText, "22")}"; + + public string SshSummary => string.IsNullOrWhiteSpace(SshUsername) + ? Localizer.Instance["CameraEditor.Summary.SameLogin"] + : SshUsername.Trim(); + + public string QualitySummary => SelectedStreamQuality?.Display ?? ""; + + public string AiSummary + { + get + { + if (!AiEnabled) return Localizer.Instance["CameraEditor.Summary.Off"]; + var names = AnalyticsClasses.Where(c => c.IsSelected).Select(c => c.DisplayName).ToList(); + return names.Count == 0 ? Localizer.Instance["CameraEditor.Summary.On"] : string.Join(", ", names); + } + } + + private static string PortOr(string text, string fallback) => + string.IsNullOrWhiteSpace(text) ? fallback : text.Trim(); + + // ── Validation (per field, live once there's something to judge) ───── + + [ObservableProperty] private string? _hostError; + [ObservableProperty] private string? _rtspMainError; + [ObservableProperty] private string? _rtspSubError; + [ObservableProperty] private string? _portsError; + [ObservableProperty] private string? _sshError; + + // Save/storage failure that isn't about a single field. [ObservableProperty] private string? _errorMessage; - [ObservableProperty] private string? _testStatus; - [ObservableProperty] - [NotifyPropertyChangedFor(nameof(CanTestConnection))] - private bool _testInProgress; + public bool CanSave => !string.IsNullOrWhiteSpace(Host); + + // ── Connect result ──────────────────────────────────────────────────── - public bool CanTestConnection => _engine is not null && !TestInProgress; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsConnecting), nameof(IsConnected), nameof(IsConnectFailed), nameof(IsConnectIdle))] + [NotifyCanExecuteChangedFor(nameof(ConnectCommand))] + private ConnectState _connectState = ConnectState.Idle; + + public bool IsConnecting => ConnectState == ConnectState.Connecting; + public bool IsConnected => ConnectState == ConnectState.Connected; + public bool IsConnectFailed => ConnectState == ConnectState.Failed; + public bool IsConnectIdle => ConnectState == ConnectState.Idle; + + [ObservableProperty] private string? _connectStatus; + [ObservableProperty] private string? _resultTitle; + [ObservableProperty] private string? _resultDetails; + [ObservableProperty] private Bitmap? _resultThumbnail; + [ObservableProperty] private string? _failTitle; + [ObservableProperty] private string? _failHint; + public ObservableCollection ResultPills { get; } = new(); + + // What "Connect" learned about the device, handed back with the result so + // the caller can store PTZ/ONVIF/Majestic metadata. + public OnvifProbeResult? DetectedOnvif { get; private set; } + public bool? DetectedMajestic { get; private set; } public CameraEditorViewModel() { } - public CameraEditorViewModel(IVideoEngine engine, CameraDirectoryService directory, UserSettingsService userSettings, ILogger logger) + public CameraEditorViewModel(CameraConnectService connect, CameraDirectoryService directory, IDialogService dialogs, ILogger logger) { - _engine = engine; + _connect = connect; _directory = directory; - _userSettings = userSettings; + _dialogs = dialogs; _logger = logger; SelectedStreamQuality = StreamQualityOptions[0]; // Auto PopulateAnalyticsClasses(); } - // Surveillance-relevant COCO classes. person is pre-selected so a fresh - // enable does something sensible; the user can broaden/narrow from here. - private static readonly (int Id, bool Default)[] CuratedClasses = - { - (0, true), (1, false), (2, false), (3, false), (5, false), (7, false), (15, false), (16, false), - }; - - private void PopulateAnalyticsClasses() - { - if (AnalyticsClasses.Count > 0) return; - foreach (var (id, def) in CuratedClasses) - AnalyticsClasses.Add(new DetectionClassOption(id, CocoClasses.Names[id]) { IsSelected = def }); - } - - public CameraEditorViewModel(Camera existing, CameraCredentials? credentials, CameraCredentials? sshCredentials, IVideoEngine engine, CameraDirectoryService directory, UserSettingsService userSettings, ILogger logger) - : this(engine, directory, userSettings, logger) + public CameraEditorViewModel(Camera existing, CameraCredentials? credentials, CameraCredentials? sshCredentials, + CameraConnectService connect, CameraDirectoryService directory, IDialogService dialogs, ILogger logger) + : this(connect, directory, dialogs, logger) { EditingId = existing.Id; Name = existing.Name; Host = existing.Host; HttpPort = existing.HttpPort; - OnvifPortText = existing.OnvifPort?.ToString() ?? ""; + OnvifPortText = existing.OnvifPort?.ToString(CultureInfo.InvariantCulture) ?? ""; RtspMainText = existing.RtspMainUri.ToString(); RtspSubText = existing.RtspSubUri?.ToString() ?? ""; Username = credentials?.Username ?? ""; Password = credentials?.Password ?? ""; SshUsername = sshCredentials?.Username ?? ""; SshPassword = sshCredentials?.Password ?? ""; - SshPortText = existing.SshPort?.ToString() ?? ""; + SshPortText = existing.SshPort?.ToString(CultureInfo.InvariantCulture) ?? ""; _pendingGroupId = existing.GroupId; SelectedStreamQuality = StreamQualityOptions.FirstOrDefault(o => o.Value == existing.StreamQualityOverride) ?? StreamQualityOptions[0]; @@ -135,6 +264,24 @@ public CameraEditorViewModel(Camera existing, CameraCredentials? credentials, Ca } } + // Surveillance-relevant COCO classes. person is pre-selected so a fresh + // enable does something sensible; the user can broaden/narrow from here. + private static readonly (int Id, bool Default)[] CuratedClasses = + { + (0, true), (1, false), (2, false), (3, false), (5, false), (7, false), (15, false), (16, false), + }; + + private void PopulateAnalyticsClasses() + { + if (AnalyticsClasses.Count > 0) return; + foreach (var (id, def) in CuratedClasses) + { + var opt = new DetectionClassOption(id, CocoClasses.Names[id]) { IsSelected = def }; + opt.PropertyChanged += (_, _) => OnPropertyChanged(nameof(AiSummary)); + AnalyticsClasses.Add(opt); + } + } + public async Task LoadGroupsAsync(CancellationToken ct) { if (_directory is null) return; @@ -153,252 +300,340 @@ public async Task LoadGroupsAsync(CancellationToken ct) } [RelayCommand] - private void AutoDeriveRtsp() + private async Task NewGroupAsync() { - if (!string.IsNullOrWhiteSpace(Host)) - RtspMainText = $"rtsp://{Host.Trim()}/"; + if (_dialogs is null || _directory is null) return; + var name = await _dialogs.PromptAsync( + Localizer.Instance["CameraEditor.NewGroup.Title"], "", + Localizer.Instance["Common.Create"], Localizer.Instance["Common.Cancel"]).ConfigureAwait(true); + if (string.IsNullOrWhiteSpace(name)) return; + try + { + var id = await _directory.AddGroupAsync(name.Trim(), CancellationToken.None).ConfigureAwait(true); + _pendingGroupId = id; + await LoadGroupsAsync(CancellationToken.None).ConfigureAwait(true); + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "Creating group {Name} failed", name); + } } - // Vendor RTSP URI templates (camera-editor "Templates" flyout). Fills both - // main/sub from the host field. Credentials ride the RTSP auth handshake - // (the Username/Password fields), so they are NOT embedded in the URI — - // except XM/Xiongmai (NETSurveillance), whose firmware wants the login in - // the path itself; there the fields are used, falling back to the stock - // "admin" + empty password those cameras ship with. - [RelayCommand] - private void ApplyRtspTemplate(string vendor) + // ── Address parsing ────────────────────────────────────────────────── + + // Accepts a bare host, host:port, or a whole URL (rtsp://user:pass@host/path, + // http://host:8080) and spreads it over the fields. Called when the address + // box loses focus, on paste, and before Connect/Save — never per keystroke, + // so the text doesn't rearrange itself under the caret. + public void NormalizeAddress() { - if (string.IsNullOrWhiteSpace(Host)) return; - var host = Host.Trim(); - var user = string.IsNullOrWhiteSpace(Username) ? "admin" : Username.Trim(); - (RtspMainText, RtspSubText) = vendor switch + var raw = Host.Trim(); + if (raw.Length == 0) { if (Host.Length > 0) Host = ""; return; } + + if (raw.Contains("://", StringComparison.Ordinal)) { - "openipc" => ( - $"rtsp://{host}:554/stream0", - $"rtsp://{host}:554/stream1"), - "xm" => ( - $"rtsp://{host}:554/user={user}&password={Password}&channel=1&stream=0.sdp?real_stream", - $"rtsp://{host}:554/user={user}&password={Password}&channel=1&stream=1.sdp?real_stream"), - "xm-sofia" => ( - $"rtsp://{host}:554/user={user}&password={SofiaHash(Password)}&channel=1&stream=0.sdp?real_stream", - $"rtsp://{host}:554/user={user}&password={SofiaHash(Password)}&channel=1&stream=1.sdp?real_stream"), - "hikvision" => ( - $"rtsp://{host}:554/Streaming/Channels/101", - $"rtsp://{host}:554/Streaming/Channels/102"), - "dahua" => ( - $"rtsp://{host}:554/cam/realmonitor?channel=1&subtype=0", - $"rtsp://{host}:554/cam/realmonitor?channel=1&subtype=1"), - "reolink" => ( - $"rtsp://{host}:554/h264Preview_01_main", - $"rtsp://{host}:554/h264Preview_01_sub"), - "tplink" => ( - $"rtsp://{host}:554/stream1", - $"rtsp://{host}:554/stream2"), - "uniview" => ( - $"rtsp://{host}:554/media/video1", - $"rtsp://{host}:554/media/video2"), - _ => (RtspMainText, RtspSubText), - }; - } + if (!Uri.TryCreate(raw, UriKind.Absolute, out var uri) || string.IsNullOrEmpty(uri.Host)) return; + if (!string.IsNullOrEmpty(uri.UserInfo)) + { + var parts = uri.UserInfo.Split(':', 2); + Username = Uri.UnescapeDataString(parts[0]); + if (parts.Length > 1) Password = Uri.UnescapeDataString(parts[1]); + } + if (uri.Scheme is "rtsp" or "rtsps") + { + // Credentials ride the RTSP handshake, not the URI. + var clean = new UriBuilder(uri) { UserName = "", Password = "" }.Uri; + RtspMainText = clean.ToString(); + } + else if (uri.Scheme is "http" or "https" && !uri.IsDefaultPort) + { + HttpPort = uri.Port; + } + Host = uri.Host; + return; + } - // XM/Xiongmai "Sofia" password digest (NETSurveillance/DVRIP): pairs of MD5 - // bytes summed mod 62, mapped to [0-9A-Za-z], 8 chars. Newer XM firmwares - // reject the plaintext password in the RTSP URL and want this instead - // (empty password hashes to the well-known "tlJwpbo6"). MD5 here is a - // protocol requirement, not our choice of cryptography. - private static string SofiaHash(string password) - { - var md5 = System.Security.Cryptography.MD5.HashData(System.Text.Encoding.UTF8.GetBytes(password)); - var chars = new char[8]; - for (var i = 0; i < 8; i++) + // host:port (not IPv6). RTSP ports mean the stream, anything else HTTP. + var colon = raw.LastIndexOf(':'); + if (colon > 0 && raw.IndexOf(':') == colon + && int.TryParse(raw[(colon + 1)..], NumberStyles.Integer, CultureInfo.InvariantCulture, out var port) + && port is > 0 and < 65536) { - var n = (md5[2 * i] + md5[2 * i + 1]) % 62; - chars[i] = (char)(n < 10 ? '0' + n : n < 36 ? 'A' + n - 10 : 'a' + n - 36); + var host = raw[..colon]; + if (port is 554 or 8554) + RtspMainText = $"rtsp://{host}:{port}/"; + else + HttpPort = port; + Host = host; + return; } - return new string(chars); + + if (raw != Host) Host = raw; } - [RelayCommand(CanExecute = nameof(CanTestConnection))] - private async Task TestConnectionAsync() - { - if (_engine is null) return; + // ── Connect ────────────────────────────────────────────────────────── + + private bool CanConnect() => _connect is not null && ConnectState != ConnectState.Connecting; - if (!TryValidate(out _, out var rtspMain, out _, out _, out _)) + [RelayCommand(CanExecute = nameof(CanConnect))] + private async Task ConnectAsync() + { + if (_connect is null) return; + NormalizeAddress(); + if (string.IsNullOrWhiteSpace(Host)) { - TestStatus = ErrorMessage; + HostError = Localizer.Instance["CameraEditor.Error.HostRequired"]; return; } + if (!ValidateFields()) return; - TestInProgress = true; - TestStatus = Localizer.Instance["CameraEditor.Status.Connecting"]; - TestConnectionCommand.NotifyCanExecuteChanged(); + _connectCts?.Cancel(); + var cts = _connectCts = new CancellationTokenSource(); + ConnectState = ConnectState.Connecting; + ConnectStatus = Localizer.Instance["CameraEditor.Status.Connecting"]; - var creds = string.IsNullOrEmpty(Username) && string.IsNullOrEmpty(Password) - ? null - : new CameraCredentials(Username, Password); - // Same transport the live view will use — a UDP-only setup used to pass - // playback but fail the test (which was hardwired to the default TCP). - var options = VideoSessionOptions.Default(rtspMain, creds) - with { Transport = ParseTransport(_userSettings?.Current.RtspTransport) }; - var session = _engine.CreateSession(options); + var host = Host.Trim(); + var mainText = RtspMainText.Trim(); + // A bare rtsp://host/ is our own guess, not a choice — let Connect improve it. + var userMain = mainText.Length == 0 || IsTrivialRtsp(mainText, host) ? null : new Uri(mainText); + var userSub = string.IsNullOrWhiteSpace(RtspSubText) ? null : new Uri(RtspSubText.Trim()); + var request = new CameraConnectRequest(host, HttpPort, ParsePort(OnvifPortText), userMain, userSub, Credentials()); + CameraConnectResult result; try { - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(8)); - await session.StartAsync(cts.Token).ConfigureAwait(true); - var frame = await session.Frames.Take(1).ToTask(cts.Token).ConfigureAwait(true); - TestStatus = string.Format(Localizer.Instance["CameraEditor.Status.OkFormat"], frame.Width, frame.Height); + result = await _connect.ConnectAsync(request, cts.Token).ConfigureAwait(true); } catch (OperationCanceledException) { - TestStatus = Localizer.Instance["CameraEditor.Status.Timeout"]; + return; } catch (Exception ex) { - _logger?.LogWarning(ex, "Test connection failed"); - TestStatus = string.Format(Localizer.Instance["CameraEditor.Status.FailedFormat"], ex.Message); - } - finally - { - await session.DisposeAsync().ConfigureAwait(true); - TestInProgress = false; - TestConnectionCommand.NotifyCanExecuteChanged(); + _logger?.LogWarning(ex, "Connect failed for {Host}", host); + ShowFailure(CameraConnectFailure.NoVideo, false); + return; } + if (cts.IsCancellationRequested) return; + Apply(result, host); } - public bool TryBuildRequest(out NewCameraRequest? newRequest, out UpdateCameraRequest? updateRequest) + private void Apply(CameraConnectResult r, string host) { - newRequest = null; - updateRequest = null; + DetectedOnvif = r.Onvif; + DetectedMajestic = r.IsMajestic; + if (r.OnvifPort is { } op && string.IsNullOrWhiteSpace(OnvifPortText)) + OnvifPortText = op.ToString(CultureInfo.InvariantCulture); - if (!TryValidate(out var ok, out var rtspMain, out var rtspSub, out var onvifPort, out var sshPort)) - return false; + if (!r.Ok) + { + // Keep what was learned about the streams even when they didn't open. + if (r.RtspMain is { } m && (string.IsNullOrWhiteSpace(RtspMainText) || IsTrivialRtsp(RtspMainText, host))) + RtspMainText = m.ToString(); + ShowFailure(r.Failure, r.IsMajestic); + return; + } - var credentials = string.IsNullOrEmpty(Username) && string.IsNullOrEmpty(Password) - ? null - : new CameraCredentials(Username, Password); + RtspMainText = r.RtspMain!.ToString(); + if (r.RtspSub is { } sub) RtspSubText = sub.ToString(); - // Built only when both SSH fields are set; the both-or-neither rule is - // enforced in TryValidate so we never store a half credential. - var sshCredentials = string.IsNullOrEmpty(SshUsername) && string.IsNullOrEmpty(SshPassword) - ? null - : new CameraCredentials(SshUsername, SshPassword); + var (title, details, suggested) = Describe(r, host); + if (string.IsNullOrWhiteSpace(Name) || Name == _autoName) + { + Name = suggested; + _autoName = suggested; + } - var quality = SelectedStreamQuality?.Value ?? StreamQualityOverride.Auto; - var analytics = BuildAnalyticsSettings(); + ResultTitle = title; + ResultDetails = details; + ResultPills.Clear(); + ResultPills.Add(r.MainCodec is null ? $"{r.MainWidth}×{r.MainHeight}" : $"{r.MainWidth}×{r.MainHeight} {PrettyCodec(r.MainCodec)}"); + if (r.SubWidth > 0) ResultPills.Add(string.Format(CultureInfo.CurrentCulture, Localizer.Instance["CameraEditor.Pill.SubFormat"], r.SubWidth, r.SubHeight)); + if (r.HasPtz) ResultPills.Add("PTZ"); + if (r.HasAudio) ResultPills.Add(Localizer.Instance["CameraEditor.Pill.Audio"]); + ResultThumbnail = Decode(r.SnapshotJpeg); + ConnectStatus = string.Format(CultureInfo.CurrentCulture, Localizer.Instance["CameraEditor.Status.ConnectedFormat"], + r.Elapsed.TotalSeconds); + ConnectState = ConnectState.Connected; + } - if (EditingId is null) + private void ShowFailure(CameraConnectFailure failure, bool isMajestic) + { + (FailTitle, FailHint) = failure switch { - newRequest = new NewCameraRequest( - Name: Name.Trim(), - Host: Host.Trim(), - HttpPort: HttpPort, - OnvifPort: onvifPort, - RtspMainUri: rtspMain, - RtspSubUri: rtspSub, - Credentials: credentials, - GroupId: SelectedGroup?.Id, - StreamQualityOverride: quality, - SshCredentials: sshCredentials, - SshPort: sshPort, - Analytics: analytics); + CameraConnectFailure.Unreachable => (Localizer.Instance["CameraEditor.Fail.Unreachable"], Localizer.Instance["CameraEditor.Fail.UnreachableHint"]), + CameraConnectFailure.Unauthorized when Credentials() is null => (Localizer.Instance["CameraEditor.Fail.AuthNeeded"], + Localizer.Instance[isMajestic ? "CameraEditor.Fail.AuthNeededHintOpenIpc" : "CameraEditor.Fail.AuthNeededHint"]), + CameraConnectFailure.Unauthorized => (Localizer.Instance["CameraEditor.Fail.Auth"], + Localizer.Instance[isMajestic ? "CameraEditor.Fail.AuthHintOpenIpc" : "CameraEditor.Fail.AuthHint"]), + CameraConnectFailure.Timeout => (Localizer.Instance["CameraEditor.Fail.Timeout"], Localizer.Instance["CameraEditor.Fail.NoVideoHint"]), + _ => (Localizer.Instance["CameraEditor.Fail.NoVideo"], Localizer.Instance["CameraEditor.Fail.NoVideoHint"]), + }; + ConnectStatus = Localizer.Instance["CameraEditor.Status.Failed"]; + ConnectState = ConnectState.Failed; + if (failure is CameraConnectFailure.NoVideo or CameraConnectFailure.Timeout) IsStreamsOpen = true; + } + + private (string Title, string Details, string SuggestedName) Describe(CameraConnectResult r, string host) + { + if (r.IsMajestic) + { + var chip = r.Majestic?.ChipModel?.ToUpperInvariant(); + var parts = new List(); + if (!string.IsNullOrWhiteSpace(chip)) parts.Add(chip!); + if (!string.IsNullOrWhiteSpace(r.Majestic?.FirmwareVersion)) + parts.Add(string.Format(CultureInfo.CurrentCulture, Localizer.Instance["CameraEditor.Result.FirmwareFormat"], r.Majestic!.FirmwareVersion)); + return ("OpenIPC · Majestic", string.Join(" · ", parts), $"{chip ?? "OpenIPC"} — {host}"); } - else + if (r.Onvif is { } o) { - updateRequest = new UpdateCameraRequest( - Name: Name.Trim(), - Host: Host.Trim(), - HttpPort: HttpPort, - OnvifPort: onvifPort, - RtspMainUri: rtspMain, - RtspSubUri: rtspSub, - Credentials: credentials, - GroupId: SelectedGroup?.Id, - StreamQualityOverride: quality, - SshCredentials: sshCredentials, - SshPort: sshPort, - Analytics: analytics); + var model = string.Join(" ", new[] { o.Manufacturer, o.Model }.Where(s => !string.IsNullOrWhiteSpace(s))); + var details = string.IsNullOrWhiteSpace(o.FirmwareVersion) + ? "ONVIF" + : "ONVIF · " + string.Format(CultureInfo.CurrentCulture, Localizer.Instance["CameraEditor.Result.FirmwareFormat"], o.FirmwareVersion); + return (model.Length > 0 ? model : "ONVIF", details, $"{(string.IsNullOrWhiteSpace(o.Model) ? model : o.Model)} — {host}".TrimStart(' ', '—')); } - - return ok; + return (Localizer.Instance["CameraEditor.Result.Generic"], "RTSP", host); } - private bool TryValidate(out bool ok, out Uri rtspMain, out Uri? rtspSub, out int? onvifPort, out int? sshPort) + private static string PrettyCodec(string codec) => codec.ToLowerInvariant() switch { - ok = false; - rtspMain = default!; - rtspSub = null; - onvifPort = null; - sshPort = null; - ErrorMessage = null; + var c when c.StartsWith("hevc", StringComparison.Ordinal) || c.StartsWith("h265", StringComparison.Ordinal) => "H.265", + var c when c.StartsWith("h264", StringComparison.Ordinal) => "H.264", + var c => c.Split(' ')[0].ToUpperInvariant(), + }; - if (string.IsNullOrWhiteSpace(Name)) + private static Bitmap? Decode(byte[]? jpeg) + { + if (jpeg is null) return null; + try { - ErrorMessage = Localizer.Instance["CameraEditor.Error.NameRequired"]; - return false; + using var ms = new MemoryStream(jpeg); + return Bitmap.DecodeToWidth(ms, 320); } - if (string.IsNullOrWhiteSpace(Host)) + catch (Exception) { - ErrorMessage = Localizer.Instance["CameraEditor.Error.HostRequired"]; - return false; + return null; } + } - var rtspMainSource = string.IsNullOrWhiteSpace(RtspMainText) - ? $"rtsp://{Host.Trim()}/" - : RtspMainText.Trim(); + private static bool IsTrivialRtsp(string text, string host) => + Uri.TryCreate(text.Trim(), UriKind.Absolute, out var u) + && u.Scheme == "rtsp" && string.Equals(u.Host, host, StringComparison.OrdinalIgnoreCase) + && u.Port is -1 or 554 + && (u.AbsolutePath is "" or "/") && string.IsNullOrEmpty(u.Query); + + // Editing the address or login invalidates a previous Connect result. + partial void OnHostChanged(string value) { HostError = null; ResetConnect(); } + partial void OnUsernameChanged(string value) => ResetConnect(); + partial void OnPasswordChanged(string value) => ResetConnect(); + + partial void OnRtspMainTextChanged(string value) { if (_saveAttempted || RtspMainError is not null) ValidateFields(); } + partial void OnRtspSubTextChanged(string value) { if (_saveAttempted || RtspSubError is not null) ValidateFields(); } + partial void OnHttpPortTextChanged(string value) { if (_saveAttempted || PortsError is not null) ValidateFields(); } + partial void OnOnvifPortTextChanged(string value) { if (_saveAttempted || PortsError is not null) ValidateFields(); } + partial void OnSshPortTextChanged(string value) { if (_saveAttempted || PortsError is not null) ValidateFields(); } + partial void OnSshUsernameChanged(string value) { if (SshError is not null) ValidateFields(); } + partial void OnSshPasswordChanged(string value) { if (SshError is not null) ValidateFields(); } + + private void ResetConnect() + { + if (ConnectState == ConnectState.Idle) return; + _connectCts?.Cancel(); + ConnectState = ConnectState.Idle; + ConnectStatus = null; + } + + public void CancelPending() => _connectCts?.Cancel(); + + // ── Save ───────────────────────────────────────────────────────────── + + public bool TryBuildRequest(out NewCameraRequest? newRequest, out UpdateCameraRequest? updateRequest) + { + newRequest = null; + updateRequest = null; + _saveAttempted = true; + ErrorMessage = null; - if (!Uri.TryCreate(rtspMainSource, UriKind.Absolute, out rtspMain!)) + NormalizeAddress(); + if (string.IsNullOrWhiteSpace(Host)) { - ErrorMessage = Localizer.Instance["CameraEditor.Error.RtspMainInvalid"]; + HostError = Localizer.Instance["CameraEditor.Error.HostRequired"]; return false; } + if (!ValidateFields()) return false; - if (!string.IsNullOrWhiteSpace(RtspSubText)) - { - if (!Uri.TryCreate(RtspSubText.Trim(), UriKind.Absolute, out rtspSub)) - { - ErrorMessage = Localizer.Instance["CameraEditor.Error.RtspSubInvalid"]; - return false; - } - } + var host = Host.Trim(); + var rtspMain = string.IsNullOrWhiteSpace(RtspMainText) + ? new Uri($"rtsp://{host}/") + : new Uri(RtspMainText.Trim()); + var rtspSub = string.IsNullOrWhiteSpace(RtspSubText) ? null : new Uri(RtspSubText.Trim()); + var name = string.IsNullOrWhiteSpace(Name) ? host : Name.Trim(); + + // Built only when both SSH fields are set; ValidateFields enforces + // both-or-neither so we never store a half credential. + var sshCredentials = string.IsNullOrEmpty(SshUsername) && string.IsNullOrEmpty(SshPassword) + ? null + : new CameraCredentials(SshUsername, SshPassword); - if (!string.IsNullOrWhiteSpace(OnvifPortText)) - { - if (!int.TryParse(OnvifPortText.Trim(), out var port) || port < 1 || port > 65535) - { - ErrorMessage = Localizer.Instance["CameraEditor.Error.OnvifPortInvalid"]; - return false; - } - onvifPort = port; - } + var quality = SelectedStreamQuality?.Value ?? StreamQualityOverride.Auto; + var analytics = BuildAnalyticsSettings(); - if (HttpPort < 1 || HttpPort > 65535) + if (IsNew) { - ErrorMessage = Localizer.Instance["CameraEditor.Error.HttpPortInvalid"]; - return false; + newRequest = new NewCameraRequest( + Name: name, Host: host, HttpPort: HttpPort, OnvifPort: ParsePort(OnvifPortText), + RtspMainUri: rtspMain, RtspSubUri: rtspSub, Credentials: Credentials(), + GroupId: SelectedGroup?.Id, StreamQualityOverride: quality, + SshCredentials: sshCredentials, SshPort: ParsePort(SshPortText), Analytics: analytics); } - - if (!string.IsNullOrWhiteSpace(SshPortText)) + else { - if (!int.TryParse(SshPortText.Trim(), out var sp) || sp < 1 || sp > 65535) - { - ErrorMessage = Localizer.Instance["CameraEditor.Error.SshPortInvalid"]; - return false; - } - sshPort = sp; + updateRequest = new UpdateCameraRequest( + Name: name, Host: host, HttpPort: HttpPort, OnvifPort: ParsePort(OnvifPortText), + RtspMainUri: rtspMain, RtspSubUri: rtspSub, Credentials: Credentials(), + GroupId: SelectedGroup?.Id, StreamQualityOverride: quality, + SshCredentials: sshCredentials, SshPort: ParsePort(SshPortText), Analytics: analytics); } + return true; + } + + // Validates everything but the address; errors land on their own field + // and the section holding one is opened so it's never hidden. + private bool ValidateFields() + { + RtspMainError = !string.IsNullOrWhiteSpace(RtspMainText) && !IsRtspUri(RtspMainText) + ? Localizer.Instance["CameraEditor.Error.RtspMainInvalid"] : null; + RtspSubError = !string.IsNullOrWhiteSpace(RtspSubText) && !IsRtspUri(RtspSubText) + ? Localizer.Instance["CameraEditor.Error.RtspSubInvalid"] : null; + + PortsError = !IsPortOrBlank(HttpPortText) ? Localizer.Instance["CameraEditor.Error.HttpPortInvalid"] + : !IsPortOrBlank(OnvifPortText) ? Localizer.Instance["CameraEditor.Error.OnvifPortInvalid"] + : !IsPortOrBlank(SshPortText) ? Localizer.Instance["CameraEditor.Error.SshPortInvalid"] + : null; // SSH login is password-based here — both fields, or neither. - if (string.IsNullOrEmpty(SshUsername) != string.IsNullOrEmpty(SshPassword)) - { - ErrorMessage = Localizer.Instance["CameraEditor.Error.SshCredsIncomplete"]; - return false; - } + SshError = string.IsNullOrEmpty(SshUsername) != string.IsNullOrEmpty(SshPassword) + ? Localizer.Instance["CameraEditor.Error.SshCredsIncomplete"] : null; - ok = true; - return true; + if (RtspMainError is not null || RtspSubError is not null) IsStreamsOpen = true; + if (PortsError is not null) IsPortsOpen = true; + if (SshError is not null) IsSshOpen = true; + return RtspMainError is null && RtspSubError is null && PortsError is null && SshError is null; } + private static bool IsRtspUri(string text) => + Uri.TryCreate(text.Trim(), UriKind.Absolute, out var u) && u.Scheme is "rtsp" or "rtsps" && !string.IsNullOrEmpty(u.Host); + + private static bool IsPortOrBlank(string text) => + string.IsNullOrWhiteSpace(text) || ParsePort(text) is not null; + + private static int? ParsePort(string text) => + int.TryParse(text.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var p) && p is > 0 and < 65536 ? p : null; + + private CameraCredentials? Credentials() => + string.IsNullOrEmpty(Username) && string.IsNullOrEmpty(Password) ? null : new CameraCredentials(Username, Password); + private AnalyticsSettings BuildAnalyticsSettings() { var classIds = AnalyticsClasses.Where(c => c.IsSelected).Select(c => c.ClassId).ToArray(); @@ -411,11 +646,64 @@ private AnalyticsSettings BuildAnalyticsSettings() PostEventSeconds: AiPostEventSeconds); } - private static RtspTransport ParseTransport(string? s) => s?.ToLowerInvariant() switch + // ── Vendor RTSP templates (Streams section) ────────────────────────── + + // Fills both main/sub from the host field. Credentials ride the RTSP auth + // handshake (the login fields), so they are NOT embedded in the URI — + // except XM/Xiongmai (NETSurveillance), whose firmware wants the login in + // the path itself; there the fields are used, falling back to the stock + // "admin" + empty password those cameras ship with. + [RelayCommand] + private void ApplyRtspTemplate(string vendor) { - "udp" => RtspTransport.Udp, - _ => RtspTransport.Tcp, - }; + NormalizeAddress(); + if (string.IsNullOrWhiteSpace(Host)) return; + var host = Host.Trim(); + var user = string.IsNullOrWhiteSpace(Username) ? "admin" : Username.Trim(); + (RtspMainText, RtspSubText) = vendor switch + { + "openipc" => (CameraConnectService.MajesticMain(host).ToString(), CameraConnectService.MajesticSub(host).ToString()), + "xm" => ( + $"rtsp://{host}:554/user={user}&password={Password}&channel=1&stream=0.sdp?real_stream", + $"rtsp://{host}:554/user={user}&password={Password}&channel=1&stream=1.sdp?real_stream"), + "xm-sofia" => ( + $"rtsp://{host}:554/user={user}&password={SofiaHash(Password)}&channel=1&stream=0.sdp?real_stream", + $"rtsp://{host}:554/user={user}&password={SofiaHash(Password)}&channel=1&stream=1.sdp?real_stream"), + "hikvision" => ( + $"rtsp://{host}:554/Streaming/Channels/101", + $"rtsp://{host}:554/Streaming/Channels/102"), + "dahua" => ( + $"rtsp://{host}:554/cam/realmonitor?channel=1&subtype=0", + $"rtsp://{host}:554/cam/realmonitor?channel=1&subtype=1"), + "reolink" => ( + $"rtsp://{host}:554/h264Preview_01_main", + $"rtsp://{host}:554/h264Preview_01_sub"), + "tplink" => ( + $"rtsp://{host}:554/stream1", + $"rtsp://{host}:554/stream2"), + "uniview" => ( + $"rtsp://{host}:554/media/video1", + $"rtsp://{host}:554/media/video2"), + _ => (RtspMainText, RtspSubText), + }; + } + + // XM/Xiongmai "Sofia" password digest (NETSurveillance/DVRIP): pairs of MD5 + // bytes summed mod 62, mapped to [0-9A-Za-z], 8 chars. Newer XM firmwares + // reject the plaintext password in the RTSP URL and want this instead + // (empty password hashes to the well-known "tlJwpbo6"). MD5 here is a + // protocol requirement, not our choice of cryptography. + private static string SofiaHash(string password) + { + var md5 = System.Security.Cryptography.MD5.HashData(System.Text.Encoding.UTF8.GetBytes(password)); + var chars = new char[8]; + for (var i = 0; i < 8; i++) + { + var n = (md5[2 * i] + md5[2 * i + 1]) % 62; + chars[i] = (char)(n < 10 ? '0' + n : n < 36 ? 'A' + n - 10 : 'a' + n - 36); + } + return new string(chars); + } } // Toggleable detection class in the editor (Phase 15.4). @@ -429,11 +717,22 @@ public DetectionClassOption(int classId, string name) public int ClassId { get; } public string Name { get; } + public string DisplayName => DetectionClasses.Localize(Name); [ObservableProperty] private bool _isSelected; } -public sealed record CameraEditorResult(NewCameraRequest? NewRequest, UpdateCameraRequest? UpdateRequest); +// Onvif/IsMajestic: what Connect detected, for the caller to persist (null = +// not probed this time, keep what's stored). +public sealed record CameraEditorResult( + NewCameraRequest? NewRequest, + UpdateCameraRequest? UpdateRequest, + OnvifProbeResult? Onvif = null, + bool? IsMajestic = null, + CameraEditorRedirect Redirect = CameraEditorRedirect.None); + +// The add dialog's header links: leave the form for discovery or a QR code. +public enum CameraEditorRedirect { None, Discover, ScanQr } // Combo item for the per-camera SD/HD override picker (Phase 12.2). public sealed record StreamQualityOption(string Display, StreamQualityOverride Value); diff --git a/src/OpenIPC.Viewer.App/ViewModels/Dialogs/DiscoveryDialogViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/Dialogs/DiscoveryDialogViewModel.cs index 9fe8898..1d7289f 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/Dialogs/DiscoveryDialogViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/Dialogs/DiscoveryDialogViewModel.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -10,19 +11,20 @@ using OpenIPC.Viewer.App.Services; using OpenIPC.Viewer.Core.Discovery; using OpenIPC.Viewer.Core.Entities; -using OpenIPC.Viewer.Core.Onvif; namespace OpenIPC.Viewer.App.ViewModels.Dialogs; -// Two-step: (1) scan via the aggregator (ONVIF + later sweep/mDNS) -> merged -// devices, upserted by host as signals arrive; (2) user picks one, types creds, -// we ONVIF-probe (capabilities + profiles + stream URI) to produce the result -// the Library hands to the CameraEditor. Probe fails fast inside the dialog -// instead of pre-filling the editor with bad data. +public enum DiscoveryPhase { Idle, Quick, Deep } + +// Finds cameras on the network. Opening the dialog runs a quick passive scan +// (ONVIF + mDNS); an opt-in deep scan walks the addresses of the ticked +// subnets. Each found device is one row with its own "Add", which hands the +// device to the camera editor — identification, credentials and the stream +// check happen there (its Connect). Results survive closing the dialog so +// several cameras go in from one scan. public sealed partial class DiscoveryDialogViewModel : ViewModelBase { private readonly IDiscoveryAggregator _aggregator; - private readonly OnvifProbeService _probe; private readonly OpenIPC.Viewer.Core.Majestic.IMajesticClient _majestic; private readonly DiscoverySessionCache _cache; private readonly IReadOnlySet _knownHosts; @@ -31,81 +33,98 @@ public sealed partial class DiscoveryDialogViewModel : ViewModelBase private CancellationTokenSource? _scanCts; // Cancels in-flight Majestic fingerprints when the dialog goes away. private readonly CancellationTokenSource _lifetimeCts = new(); - // What the sweep will actually walk: the ticked subnets folded together with - // anything typed. Null means "no sweep", which is what an untouched dialog - // wants — passive sources only. + // What the deep scan will walk: the ticked subnets folded together with + // anything typed. Null = nothing chosen (the sweep then works out the + // local subnet by itself, when no subnets are offered at all). private IpRange? _effectiveRange; private readonly Dictionary _rowsByHost = new(StringComparer.OrdinalIgnoreCase); // Hosts we already fingerprinted (or are fingerprinting) — one ping per host. private readonly HashSet _fingerprinted = new(StringComparer.OrdinalIgnoreCase); private readonly SemaphoreSlim _fingerprintGate = new(6); + private DiscoveryPhase _lastScan = DiscoveryPhase.Idle; + private bool _stopped; + private string? _failure; - public ObservableCollection Cameras { get; } = new(); + // New finds first; cameras already in the library collect underneath. + public ObservableCollection NewCameras { get; } = new(); + public ObservableCollection AddedCameras { get; } = new(); - // Subnets the OS says are reachable, ticked by default — the point is that - // Deep scan needs no typing to reach a camera on another VLAN or behind a - // VPN. Empty on a platform with no route reader and no usable interfaces, - // in which case the dialog shows only the manual box. + // Subnets offered for the deep scan. Local ones arrive ticked, routed ones + // (behind a VPN / on another VLAN) don't — sweeping the far side of a + // tunnel shouldn't happen without a deliberate tick. public ObservableCollection ScanTargets { get; } = new(); - public bool HasScanTargets => ScanTargets.Count > 0; - [ObservableProperty] private string _statusText = Localizer.Instance["Discovery.Status.Initial"]; - [ObservableProperty] - [NotifyPropertyChangedFor(nameof(IsRowSelected))] - [NotifyPropertyChangedFor(nameof(CanAdd))] - private DiscoveredDeviceRowVm? _selected; + [NotifyPropertyChangedFor(nameof(IsScanning), nameof(IsDeepScanning), nameof(IsIdle), nameof(StatusText), + nameof(ShowEmpty), nameof(ShowEmptyDeepHint), nameof(ShowEmptyManualHint))] + [NotifyCanExecuteChangedFor(nameof(RescanCommand), nameof(DeepScanCommand))] + private DiscoveryPhase _phase = DiscoveryPhase.Idle; - public bool IsRowSelected => Selected is not null; + public bool IsScanning => Phase != DiscoveryPhase.Idle; + public bool IsDeepScanning => Phase == DiscoveryPhase.Deep; + public bool IsIdle => Phase == DiscoveryPhase.Idle; - [ObservableProperty] private string _username = ""; - [ObservableProperty] private string _password = ""; - - // "Use these credentials for all cameras" (multi-add). Off → the next - // dialog instance starts with blank login fields. - [ObservableProperty] private bool _reuseCredentials = true; - - // 0..1 scan progress (mean across sources). Drives the progress bar; hidden - // when not scanning. - [ObservableProperty] private double _scanProgress; + // 0..1, mean across sources. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(StatusText))] + private double _scanProgress; - // Opt-in active /24 sweep — finds OpenIPC cameras that answer neither ONVIF - // nor mDNS, at the cost of knocking on every host. Off by default. - [ObservableProperty] private bool _deepScan; + // The deep-scan section is expanded. + [ObservableProperty] private bool _isDeepOpen; - // Extra hand-typed range on top of the ticked subnets — for a subnet with no - // route of its own, or a single address. Typing one turns the sweep on by - // itself, so it still works with Deep scan off. + // Extra hand-typed range on top of the ticked subnets. [ObservableProperty] private string _ipRangeText = ""; - // Null while everything ticked and typed adds up to something sweepable; a - // localized complaint otherwise. Disables Scan, so neither a typo nor an - // over-wide selection can quietly degrade into the wrong sweep. + // Null while the ticked + typed targets add up to something sweepable. [ObservableProperty] - [NotifyCanExecuteChangedFor(nameof(ScanCommand))] + [NotifyCanExecuteChangedFor(nameof(DeepScanCommand))] private string? _ipRangeError; - // "will knock on N addresses" — shown before the user commits, because the - // ticked subnets were chosen for them and the total should not surprise. - [ObservableProperty] private string _sweepSummary = ""; - + // "508 addresses" / "this computer's subnet". [ObservableProperty] - [NotifyCanExecuteChangedFor(nameof(ScanCommand))] - [NotifyPropertyChangedFor(nameof(CanAdd))] - private bool _scanInProgress; + [NotifyPropertyChangedFor(nameof(DeepButtonLabel))] + private string _sweepSummary = ""; - [ObservableProperty] - [NotifyCanExecuteChangedFor(nameof(ScanCommand))] - [NotifyPropertyChangedFor(nameof(CanAdd))] - private bool _addInProgress; + public string DeepButtonLabel => string.IsNullOrEmpty(SweepSummary) + ? Localizer.Instance["Discovery.Deep.Start"] + : $"{Localizer.Instance["Discovery.Deep.Start"]} · {SweepSummary}"; - public bool CanAdd => Selected is not null && !ScanInProgress && !AddInProgress; + public bool HasNew => NewCameras.Count > 0; + public bool HasAdded => AddedCameras.Count > 0; + // Not after a manual Stop: nobody failed to answer, the user cut it short. + public bool ShowEmpty => IsIdle && _lastScan != DiscoveryPhase.Idle && !_stopped && !HasNew && !HasAdded; + public bool ShowEmptyDeepHint => ShowEmpty && _lastScan == DiscoveryPhase.Quick; + public bool ShowEmptyManualHint => ShowEmpty && _lastScan == DiscoveryPhase.Deep; + + public string StatusText + { + get + { + var total = NewCameras.Count + AddedCameras.Count; + switch (Phase) + { + case DiscoveryPhase.Quick: + return Localizer.Instance["Discovery.Status.Quick"]; + case DiscoveryPhase.Deep: + return string.Format(CultureInfo.CurrentCulture, Localizer.Instance["Discovery.Status.DeepFormat"], + (int)Math.Round(ScanProgress * 100), total); + } + if (_failure is not null) return _failure; + if (_lastScan == DiscoveryPhase.Idle) return ""; + if (total == 0) + return Localizer.Instance[_stopped ? "Discovery.Status.Stopped" + : _lastScan == DiscoveryPhase.Deep ? "Discovery.Status.NothingDeep" : "Discovery.Status.NothingQuick"]; + var found = string.Format(CultureInfo.CurrentCulture, Localizer.Instance["Discovery.Status.FoundFormat"], total); + return AddedCameras.Count == 0 + ? found + : found + " · " + string.Format(CultureInfo.CurrentCulture, Localizer.Instance["Discovery.Status.InLibraryFormat"], AddedCameras.Count); + } + } public DiscoveryDialogViewModel( IDiscoveryAggregator aggregator, - OnvifProbeService probe, OpenIPC.Viewer.Core.Majestic.IMajesticClient majestic, IScanTargetProvider scanTargets, DiscoverySessionCache cache, @@ -113,30 +132,15 @@ public DiscoveryDialogViewModel( ILogger logger) { _aggregator = aggregator; - _probe = probe; _majestic = majestic; _cache = cache; _knownHosts = knownHosts; _logger = logger; - // Rehydrate the previous scan so the user can add several cameras - // one-by-one without rescanning between dialog opens. Credentials only - // carry over while "use for all cameras" is on — a mixed-credential - // park starts each camera with blank fields. - _reuseCredentials = cache.ReuseCredentials; - if (_reuseCredentials) - { - _username = cache.Username; - _password = cache.Password; - } - _deepScan = cache.DeepScan; + _isDeepOpen = cache.DeepScan; _ipRangeText = cache.IpRangeText; - // Local subnets ticked, routed ones (behind a VPN / another VLAN) not: - // sweeping the LAN this machine is on is the feature, but sweeping the - // far side of a tunnel someone happens to be on shouldn't happen without - // a deliberate tick. A choice the user already made this session wins - // over the default. + // A choice the user already made this session wins over the default. foreach (var target in SafeTargets(scanTargets)) { var selected = cache.TargetSelections.TryGetValue(target.Cidr, out var choice) @@ -146,24 +150,20 @@ public DiscoveryDialogViewModel( } // Field init skips the generated On*Changed hooks, so the rehydrated - // state has to be folded in by hand or the first Scan would ignore it. + // state has to be folded in by hand. RecomputeRange(); foreach (var device in cache.Snapshot()) Upsert(device); - if (Cameras.Count > 0) - StatusText = string.Format(Localizer.Instance["Discovery.Status.FoundFormat"], Cameras.Count); + if (NewCameras.Count + AddedCameras.Count > 0) + _lastScan = DiscoveryPhase.Quick; } - partial void OnUsernameChanged(string value) => _cache.Username = value; - partial void OnPasswordChanged(string value) => _cache.Password = value; - partial void OnDeepScanChanged(bool value) - { - _cache.DeepScan = value; - // Deep scan is the gate, the tick list is the destination — flipping the - // gate changes what gets swept, so the total has to be recomputed. - RecomputeRange(); - } - partial void OnReuseCredentialsChanged(bool value) => _cache.ReuseCredentials = value; + // Called when the dialog opens: a fresh session scans straight away; a + // reopened one (multi-add) shows what it already found. + public Task StartAsync() => + _lastScan == DiscoveryPhase.Idle && !IsScanning ? ScanAsync(deep: false) : Task.CompletedTask; + + partial void OnIsDeepOpenChanged(bool value) => _cache.DeepScan = value; partial void OnIpRangeTextChanged(string value) { @@ -178,8 +178,7 @@ private void OnTargetToggled(ScanTargetRowVm row) } // Folds the ticked subnets and the typed range into the single range the - // sweep walks, and reports what that adds up to. Runs per keystroke and per - // tick, so it stays pure arithmetic and never touches the network. + // sweep walks. Runs per keystroke and per tick — pure arithmetic. private void RecomputeRange() { IpRange? typed = null; @@ -193,21 +192,14 @@ private void RecomputeRange() var parts = new List(); if (typed is not null) parts.Add(typed); - // Deep scan decides WHETHER to sweep, the tick list decides WHERE. An - // untouched dialog therefore stays passive-only, exactly as before. - if (DeepScan) - parts.AddRange(ScanTargets.Where(t => t.IsSelected).Select(t => t.Target.Range)); + parts.AddRange(ScanTargets.Where(t => t.IsSelected).Select(t => t.Target.Range)); if (parts.Count == 0) { _effectiveRange = null; - // With no targets to offer, Deep scan still sweeps the subnet the - // source works out for itself — say so, rather than leaving the line - // blank and letting a 254-host sweep come as a surprise. - SweepSummary = DeepScan && ScanTargets.Count == 0 - ? Localizer.Instance["Discovery.Sweep.SummaryLocal"] - : ""; - IpRangeError = null; + // No subnets offered at all: the sweep works out the local /24 itself. + SweepSummary = ScanTargets.Count == 0 ? Localizer.Instance["Discovery.Sweep.SummaryLocal"] : ""; + IpRangeError = ScanTargets.Count == 0 ? null : Localizer.Instance["Discovery.Deep.NothingTicked"]; return; } @@ -218,21 +210,39 @@ private void RecomputeRange() } _effectiveRange = combined; - SweepSummary = string.Format(Localizer.Instance["Discovery.Sweep.SummaryFormat"], combined.Count); + SweepSummary = AddressCount(combined.Count); IpRangeError = null; } + // "1 address" / "5 addresses"; Russian has three forms (1 / 2–4 / 5+). + private static string AddressCount(int n) + { + var key = "Discovery.Sweep.Many"; + if (Localizer.Instance.Active == LangCode.Russian) + { + var mod100 = n % 100; + var mod10 = n % 10; + if (mod10 == 1 && mod100 != 11) key = "Discovery.Sweep.One"; + else if (mod10 is >= 2 and <= 4 && mod100 is < 12 or > 14) key = "Discovery.Sweep.Few"; + } + else if (n == 1) + { + key = "Discovery.Sweep.One"; + } + return string.Format(CultureInfo.CurrentCulture, Localizer.Instance[key], n); + } + private void Reject(IpRangeParseError error) { _effectiveRange = null; SweepSummary = ""; IpRangeError = error == IpRangeParseError.TooLarge - ? string.Format(Localizer.Instance["Discovery.IpRange.TooLargeFormat"], IpRange.MaxHosts) + ? string.Format(CultureInfo.CurrentCulture, Localizer.Instance["Discovery.IpRange.TooLargeFormat"], IpRange.MaxHosts) : Localizer.Instance["Discovery.IpRange.Invalid"]; } - // A provider that trips over an exotic adapter must not stop the dialog from - // opening — the manual box still works with no targets at all. + // A provider that trips over an exotic adapter must not stop the dialog + // from opening — the manual range still works with no targets at all. private IReadOnlyList SafeTargets(IScanTargetProvider provider) { try @@ -246,56 +256,68 @@ private IReadOnlyList SafeTargets(IScanTargetProvider provider) } } - [RelayCommand(CanExecute = nameof(CanScan))] - private async Task ScanAsync() + private bool CanRescan() => !IsScanning; + private bool CanDeepScan() => !IsScanning && IpRangeError is null; + + [RelayCommand(CanExecute = nameof(CanRescan))] + private Task RescanAsync() => ScanAsync(deep: false); + + [RelayCommand(CanExecute = nameof(CanDeepScan))] + private Task DeepScanAsync() + { + IsDeepOpen = true; + return ScanAsync(deep: true); + } + + [RelayCommand] + private void Stop() => _scanCts?.Cancel(); + + [RelayCommand] + private void ToggleDeep() => IsDeepOpen = !IsDeepOpen; + + // Quick: passive sources only, starts the list over. Deep: adds the + // address sweep and keeps what's already listed. + private async Task ScanAsync(bool deep) { - Cameras.Clear(); - _rowsByHost.Clear(); - _fingerprinted.Clear(); - _cache.Clear(); - Selected = null; + if (!deep) + { + NewCameras.Clear(); + AddedCameras.Clear(); + _rowsByHost.Clear(); + _fingerprinted.Clear(); + _cache.Clear(); + ListChanged(); + } + _stopped = false; + _failure = null; ScanProgress = 0; - StatusText = _effectiveRange is { } sweep - ? string.Format(Localizer.Instance["Discovery.Status.ScanningRangeFormat"], sweep, sweep.Count) - : Localizer.Instance["Discovery.Status.Scanning"]; - ScanInProgress = true; _scanCts?.Cancel(); _scanCts = new CancellationTokenSource(); var ct = _scanCts.Token; + Phase = deep ? DiscoveryPhase.Deep : DiscoveryPhase.Quick; try { - // Unticking every target means "don't sweep", and has to be passed - // as such: the sweep source enables on DeepScan alone and would fall - // back to deriving the local /24, running a 254-host sweep the - // dialog just told the user it would not run. When no targets were - // offered at all there is nothing to untick, so Deep scan keeps its - // original meaning of "sweep whatever subnet you can work out". - var doSweep = DeepScan && (_effectiveRange is not null || ScanTargets.Count == 0); - var options = new DiscoveryOptions(TimeSpan.FromSeconds(6), doSweep, _effectiveRange); + var options = new DiscoveryOptions(TimeSpan.FromSeconds(6), deep, deep ? _effectiveRange : null); var progress = new Progress(p => ScanProgress = p); - await foreach (var device in _aggregator.ScanAsync(options, progress, ct).ConfigureAwait(true)) Upsert(device); - - StatusText = Cameras.Count == 0 - ? Localizer.Instance["Discovery.Status.NoResponse"] - : string.Format(Localizer.Instance["Discovery.Status.FoundFormat"], Cameras.Count); } catch (OperationCanceledException) { - StatusText = Localizer.Instance["Discovery.Status.Cancelled"]; + _stopped = true; } catch (Exception ex) { _logger.LogWarning(ex, "Discovery scan failed"); - StatusText = string.Format(Localizer.Instance["Discovery.Status.ScanFailedFormat"], ex.Message); + _failure = string.Format(CultureInfo.CurrentCulture, Localizer.Instance["Discovery.Status.ScanFailedFormat"], ex.Message); } finally { - ScanInProgress = false; + _lastScan = deep ? DiscoveryPhase.Deep : DiscoveryPhase.Quick; ScanProgress = 0; + Phase = DiscoveryPhase.Idle; } } @@ -305,7 +327,7 @@ private void Upsert(DiscoveredDevice device) { if (_rowsByHost.TryGetValue(device.Host, out var row)) { - row.Device = device; + row.Device = row.Device.MergeWith(device); } else { @@ -314,17 +336,28 @@ private void Upsert(DiscoveredDevice device) IsAlreadyAdded = _knownHosts.Contains(device.Host), }; _rowsByHost[device.Host] = row; - Cameras.Add(row); + (row.IsAlreadyAdded ? AddedCameras : NewCameras).Add(row); + ListChanged(); } _cache.Put(row.Device); ScheduleFingerprint(row.Device); } + private void ListChanged() + { + OnPropertyChanged(nameof(HasNew)); + OnPropertyChanged(nameof(HasAdded)); + OnPropertyChanged(nameof(StatusText)); + OnPropertyChanged(nameof(ShowEmpty)); + OnPropertyChanged(nameof(ShowEmptyDeepHint)); + OnPropertyChanged(nameof(ShowEmptyManualHint)); + } + // Newer OpenIPC firmwares always run the Majestic web UI, so an HTTP ping // identifies them even when they answer neither ONVIF nor mDNS with a // model. One bounded background ping per host; on a hit the row upgrades - // in place (Majestic protocol + "OpenIPC" label + High confidence). + // in place (Majestic protocol + "OpenIPC" label). private void ScheduleFingerprint(DiscoveredDevice device) { if (device.Protocols.HasFlag(DiscoveryProtocol.Majestic) && device.Model is not null) @@ -368,69 +401,14 @@ await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => }, ct); } - private bool CanScan() => !ScanInProgress && !AddInProgress && IpRangeError is null; - - public async Task AddSelectedAsync() + // The row's "Add": hand the device (and the login last used this session) + // to the camera editor. + public DiscoveryDialogResult BuildResult(DiscoveredDeviceRowVm row) { - var row = Selected; - if (row is null) return null; - - var creds = string.IsNullOrEmpty(Username) && string.IsNullOrEmpty(Password) + var creds = string.IsNullOrEmpty(_cache.Username) && string.IsNullOrEmpty(_cache.Password) ? null - : new CameraCredentials(Username, Password); - - AddInProgress = true; - try - { - // ONVIF device → probe for the real stream URI. Non-ONVIF (sweep/mDNS) - // → skip the probe and pre-fill a guessed RTSP URL from the open ports; - // the user reviews / tests it in the editor before saving. - var onvifUri = row.Device.OnvifServiceUri; - if (onvifUri is null) - { - StatusText = Localizer.Instance["Discovery.Status.ManualAdd"]; - return new DiscoveryDialogResult(row.Device, GuessRtspUri(row.Device), null, creds); - } - - StatusText = string.Format(Localizer.Instance["Discovery.Status.ProbingFormat"], row.HostPort); - var endpoint = new OnvifEndpoint(onvifUri, creds); - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - var probeResult = await _probe.ProbeAsync(endpoint, cts.Token).ConfigureAwait(true); - - StatusText = string.Format(Localizer.Instance["Discovery.Status.ProbeOkFormat"], probeResult.Manufacturer ?? "?", probeResult.Model ?? "").TrimEnd(); - return new DiscoveryDialogResult(row.Device, probeResult.RtspMainUri, probeResult, creds); - } - catch (OperationCanceledException) - { - StatusText = Localizer.Instance["Discovery.Status.Cancelled"]; - return null; - } - catch (Exception ex) - { - // The ONVIF SOAP probe can't run on every platform — the WCF - // XmlSerializer stack fails to build on Android (XmlType reflection - // error over the generated contract types). Rather than dead-end the - // user, degrade to the same guessed-RTSP add we use for non-ONVIF - // finds: the camera is added and refined/tested in the editor. - _logger.LogWarning(ex, "ONVIF probe failed for {Host}; falling back to guessed RTSP", row.HostPort); - StatusText = Localizer.Instance["Discovery.Status.ManualAdd"]; - return new DiscoveryDialogResult(row.Device, GuessRtspUri(row.Device), null, creds); - } - finally - { - AddInProgress = false; - } - } - - // OpenIPC/Majestic RTSP convention (matches CameraEditor): rtsp://host/ for - // the default 554, an explicit port otherwise. The user can refine it. - private static Uri GuessRtspUri(DiscoveredDevice device) - { - var port = device.Ports.Contains(8554) && !device.Ports.Contains(554) ? 8554 : 554; - return port == 554 - ? new Uri($"rtsp://{device.Host}/") - : new Uri($"rtsp://{device.Host}:{port}/"); + : new CameraCredentials(_cache.Username, _cache.Password); + return new DiscoveryDialogResult(row.Device, creds); } public void Cancel() @@ -440,9 +418,9 @@ public void Cancel() } } -// One auto-detected subnet in the tick list. The callback (rather than the VM -// watching the collection) keeps the "remember what I unticked" bookkeeping in -// one place and avoids a CollectionChanged subscription per row. +// One auto-detected subnet in the deep-scan tick list. The callback (rather +// than the VM watching the collection) keeps the "remember what I unticked" +// bookkeeping in one place. public sealed partial class ScanTargetRowVm : ViewModelBase { private readonly Action _onToggled; @@ -462,73 +440,87 @@ public ScanTargetRowVm(ScanTarget target, bool isSelected, Action Target.Cidr; - // e.g. "Ethernet 2 · this machine's network · 254 addresses" — the origin is - // the part worth reading: a routed subnet is one passive discovery can never - // see, which is exactly why it is offered here. + // "Ethernet 2 · this network" / "through a VPN or route". public string Detail => string.Format( + CultureInfo.CurrentCulture, Localizer.Instance["Discovery.Target.DetailFormat"], Target.InterfaceName, Localizer.Instance[Target.Origin == ScanTargetOrigin.LocalSubnet ? "Discovery.Target.Local" - : "Discovery.Target.Routed"], - Target.Range.Count); + : "Discovery.Target.Routed"]); } +public enum DiscoveredKind { OpenIpc, Onvif, Rtsp, Other } + public sealed partial class DiscoveredDeviceRowVm : ViewModelBase { [ObservableProperty] - [NotifyPropertyChangedFor(nameof(DisplayName))] - [NotifyPropertyChangedFor(nameof(Subtitle))] - [NotifyPropertyChangedFor(nameof(HostPort))] - [NotifyPropertyChangedFor(nameof(ProtocolsText))] - [NotifyPropertyChangedFor(nameof(ConfidenceText))] + [NotifyPropertyChangedFor(nameof(Kind), nameof(IsOpenIpc), nameof(IsOnvif), nameof(IsGeneric), + nameof(Title), nameof(Subtitle), nameof(HostPort), nameof(Tags))] private DiscoveredDevice _device; - // A camera with this host already exists in the library — shown as a badge - // so the multi-add flow makes it obvious what's left to add. + // A camera with this host already exists in the library. [ObservableProperty] private bool _isAlreadyAdded; public DiscoveredDeviceRowVm(DiscoveredDevice device) => _device = device; public string Host => Device.Host; - public string DisplayName => Device.Name ?? Device.Model ?? Device.Host; - public string Subtitle => Device.Model ?? Localizer.Instance["Discovery.UnknownModel"]; + + public DiscoveredKind Kind => + Device.Protocols.HasFlag(DiscoveryProtocol.Majestic) ? DiscoveredKind.OpenIpc + : Device.Protocols.HasFlag(DiscoveryProtocol.Onvif) ? DiscoveredKind.Onvif + : Device.Protocols.HasFlag(DiscoveryProtocol.Rtsp) ? DiscoveredKind.Rtsp + : DiscoveredKind.Other; + + public bool IsOpenIpc => Kind == DiscoveredKind.OpenIpc; + public bool IsOnvif => Kind == DiscoveredKind.Onvif; + public bool IsGeneric => Kind is DiscoveredKind.Rtsp or DiscoveredKind.Other; + + public string Title => Device.Model ?? Device.Name ?? Localizer.Instance[Kind switch + { + DiscoveredKind.OpenIpc => "Discovery.Kind.OpenIpc", + DiscoveredKind.Onvif => "Discovery.Kind.Onvif", + _ => "Discovery.Kind.Unknown", + }]; + + // What kind of thing it is, in words (the model is already the title). + public string Subtitle => Kind switch + { + DiscoveredKind.OpenIpc => "OpenIPC · Majestic", + DiscoveredKind.Onvif => Device.Name is { } n && n != Device.Model ? $"ONVIF · {n}" : Localizer.Instance["Discovery.Kind.Onvif"], + DiscoveredKind.Rtsp => Localizer.Instance["Discovery.Kind.RtspOnly"], + _ => Localizer.Instance["Discovery.Kind.HttpOnly"], + }; public string HostPort { get { - var port = Device.OnvifServiceUri?.Port ?? (Device.Ports.Count > 0 ? Device.Ports.First() : 0); + var port = Device.OnvifServiceUri?.Port ?? 0; return port is 0 or 80 ? Device.Host : $"{Device.Host}:{port}"; } } - // e.g. "ONVIF · RTSP" — how the device was detected. - public string ProtocolsText => string.Join(" · ", DescribeProtocols(Device.Protocols)); - - public string ConfidenceText => Localizer.Instance[Device.Confidence switch - { - DiscoveryConfidence.High => "Discovery.Confidence.High", - DiscoveryConfidence.Medium => "Discovery.Confidence.Medium", - _ => "Discovery.Confidence.Low", - }]; - - private static IEnumerable DescribeProtocols(DiscoveryProtocol p) + // How the device was found, as small tags. + public IReadOnlyList Tags { - if (p.HasFlag(DiscoveryProtocol.Onvif)) yield return "ONVIF"; - if (p.HasFlag(DiscoveryProtocol.Mdns)) yield return "mDNS"; - if (p.HasFlag(DiscoveryProtocol.Majestic)) yield return "Majestic"; - if (p.HasFlag(DiscoveryProtocol.Rtsp)) yield return "RTSP"; - if (p.HasFlag(DiscoveryProtocol.Http)) yield return "HTTP"; + get + { + var p = Device.Protocols; + var tags = new List(); + if (p.HasFlag(DiscoveryProtocol.Majestic)) tags.Add("OpenIPC"); + if (p.HasFlag(DiscoveryProtocol.Onvif)) tags.Add("ONVIF"); + if (p.HasFlag(DiscoveryProtocol.Mdns)) tags.Add("mDNS"); + if (p.HasFlag(DiscoveryProtocol.Rtsp)) tags.Add("RTSP"); + return tags; + } } } -// The dialog's output: the picked device, the RTSP URL to pre-fill (from the -// ONVIF probe when available, otherwise a sensible guess for non-ONVIF devices), -// the ONVIF probe result (null for non-ONVIF — no PTZ/profile metadata), and any -// credentials the user typed. +// The dialog's output: the picked device and the login last used this session +// (the editor pre-fills and connects with it) — or ManualEntry when the user +// chose to type an address instead. public sealed record DiscoveryDialogResult( - DiscoveredDevice Device, - Uri RtspMainUri, - OnvifProbeResult? Probe, - CameraCredentials? Credentials); + DiscoveredDevice? Device, + CameraCredentials? Credentials, + bool ManualEntry = false); diff --git a/src/OpenIPC.Viewer.App/ViewModels/EventsPageViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/EventsPageViewModel.cs index b57eaa1..f5cac29 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/EventsPageViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/EventsPageViewModel.cs @@ -1,25 +1,41 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using CommunityToolkit.Mvvm.Messaging; using Microsoft.Extensions.Logging; +using OpenIPC.Viewer.App.Messages; using OpenIPC.Viewer.App.Services; using OpenIPC.Viewer.Core.Entities; using OpenIPC.Viewer.Core.Events; +using OpenIPC.Viewer.Core.Recording; using OpenIPC.Viewer.Core.Services; namespace OpenIPC.Viewer.App.ViewModels; +public enum EventKindFilter { All, Motion, Detection } + +public enum EventPeriod { Today, Days7, Days30, All } + public sealed partial class EventsPageViewModel : ViewModelBase, IDisposable { - private const int PageLimit = 200; + // Upper bound on events pulled for one filter set; the list pages over it. + private const int LoadLimit = 10000; + + // Events of one camera and kind closer than this fold into one episode. + private static readonly TimeSpan EpisodeGap = TimeSpan.FromSeconds(60); + + // An event opens its recording slightly early, to show what led up to it. + private static readonly TimeSpan PlayLeadIn = TimeSpan.FromSeconds(3); private readonly IEventRepository _repo; + private readonly IRecordingRepository _recordingRepo; private readonly EventIngestionService _ingestion; private readonly ManualMotionEventSource _manualSource; private readonly CameraDirectoryService _cameras; @@ -28,60 +44,170 @@ public sealed partial class EventsPageViewModel : ViewModelBase, IDisposable private readonly Dictionary _cameraNames = new(); private readonly IDisposable _liveSub; - public string Title => Localizer.Instance["Nav.Events"]; + // Newest first; the kind filter and episode folding slice it into PageItems. + private readonly List _events = new(); + private Dictionary> _recordings = new(); - public ObservableCollection Items { get; } = new(); - public ObservableCollection CameraOptions { get; } = new(); + public string Title => Localizer.Instance["Nav.Events"]; [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsEmpty))] + [NotifyPropertyChangedFor(nameof(HasNoMatches))] private bool _isLoaded; [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsEmpty))] + [NotifyPropertyChangedFor(nameof(HasNoMatches))] private bool _isLoading; - public bool IsEmpty => IsLoaded && !IsLoading && Items.Count == 0; + // No events for the camera/period at all vs. hidden by the kind filter. + public bool IsEmpty => IsLoaded && !IsLoading && _events.Count == 0; + public bool HasNoMatches => IsLoaded && !IsLoading && _events.Count > 0 && PageItems.Count == 0; + public bool HasRows => PageItems.Count > 0; + + // Searchable multi-select camera filter (shared with Recordings). + public CameraPickerViewModel Cameras { get; } = new("Events.Cameras.WithEvents", "Events.Cameras.FooterFormat"); + + // Clicking a camera name in a row narrows the list to it. + [RelayCommand] + private void AddCameraFilter(EventEpisodeRow? row) + { + if (row is not null) Cameras.Select(row.CameraId); + } + + // --- Filters ---------------------------------------------------------------- + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsKindAll))] + [NotifyPropertyChangedFor(nameof(IsKindMotion))] + [NotifyPropertyChangedFor(nameof(IsKindDetection))] + private EventKindFilter _kindFilter; + + public bool IsKindAll { get => KindFilter == EventKindFilter.All; set { if (value) KindFilter = EventKindFilter.All; } } + public bool IsKindMotion { get => KindFilter == EventKindFilter.Motion; set { if (value) KindFilter = EventKindFilter.Motion; } } + public bool IsKindDetection { get => KindFilter == EventKindFilter.Detection; set { if (value) KindFilter = EventKindFilter.Detection; } } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsPeriodToday))] + [NotifyPropertyChangedFor(nameof(IsPeriod7))] + [NotifyPropertyChangedFor(nameof(IsPeriod30))] + [NotifyPropertyChangedFor(nameof(IsPeriodAll))] + private EventPeriod _period = EventPeriod.Days7; + + public bool IsPeriodToday { get => Period == EventPeriod.Today; set { if (value) Period = EventPeriod.Today; } } + public bool IsPeriod7 { get => Period == EventPeriod.Days7; set { if (value) Period = EventPeriod.Days7; } } + public bool IsPeriod30 { get => Period == EventPeriod.Days30; set { if (value) Period = EventPeriod.Days30; } } + public bool IsPeriodAll { get => Period == EventPeriod.All; set { if (value) Period = EventPeriod.All; } } + + // Fold bursts (e.g. a car parked under an AI camera) into one row. + [ObservableProperty] private bool _groupBursts = true; + + // Period changes what is loaded; cameras, kind and grouping only re-slice. + partial void OnPeriodChanged(EventPeriod value) => _ = ReloadAsync(CancellationToken.None); + partial void OnKindFilterChanged(EventKindFilter value) => ApplyFilter(resetPage: true); + partial void OnGroupBurstsChanged(bool value) => ApplyFilter(resetPage: true); + + // "Events: 87 · episodes: 9" + [ObservableProperty] private string _summary = ""; + + // --- Pagination --------------------------------------------------------------- + public const int PageSize = 20; + + public ObservableCollection PageItems { get; } = new(); + public ObservableCollection Pages { get; } = new(); + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CurrentPageDisplay))] + [NotifyPropertyChangedFor(nameof(CanPrevPage))] + [NotifyPropertyChangedFor(nameof(CanNextPage))] + private int _currentPage; // 0-based + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasMultiplePages))] + [NotifyPropertyChangedFor(nameof(CanNextPage))] + private int _pageCount = 1; + + public int CurrentPageDisplay => CurrentPage + 1; + public bool HasMultiplePages => PageCount > 1; + public bool CanPrevPage => CurrentPage > 0; + public bool CanNextPage => CurrentPage + 1 < PageCount; - [ObservableProperty] private CameraOption? _selectedCamera; + [RelayCommand] + private void PrevPage() + { + if (!CanPrevPage) return; + CurrentPage--; + ApplyFilter(resetPage: false); + } + + [RelayCommand] + private void NextPage() + { + if (!CanNextPage) return; + CurrentPage++; + ApplyFilter(resetPage: false); + } + + // CommandParameter is the boxed 1-based page number from the Pages binding + // (object? sidesteps the RelayCommand XAML render crash). + [RelayCommand] + private void GoToPage(object? page) + { + if (page is null) return; + int oneBased; + try { oneBased = Convert.ToInt32(page, CultureInfo.InvariantCulture); } + catch (Exception) { return; } + var target = oneBased - 1; + if (target < 0 || target >= PageCount || target == CurrentPage) return; + CurrentPage = target; + ApplyFilter(resetPage: false); + } public EventsPageViewModel( IEventRepository repo, + IRecordingRepository recordingRepo, EventIngestionService ingestion, ManualMotionEventSource manualSource, CameraDirectoryService cameras, ILogger logger) { _repo = repo; + _recordingRepo = recordingRepo; _ingestion = ingestion; _manualSource = manualSource; _cameras = cameras; _logger = logger; - // Live updates: new events stream into the top of the list as the - // ingestion service emits them. Both new (open) and finalized events - // come through Events; we de-dup by Id in MergeOrInsert. + // Live updates: new events stream in as the ingestion service emits + // them. Both new (open) and finalized events come through Events; we + // de-dup by Id in OnLiveEvent. _liveSub = _ingestion.Events.Subscribe(new EventObserver(this)); + Cameras.SelectionChanged += () => ApplyFilter(resetPage: true); } public async Task LoadAsync(CancellationToken ct) { var cams = await _cameras.ListAsync(ct).ConfigureAwait(true); + var groups = (await _cameras.ListGroupsAsync(ct).ConfigureAwait(true)).ToDictionary(g => g.Id, g => g.Name); _cameraNames.Clear(); - CameraOptions.Clear(); - CameraOptions.Add(new CameraOption(null, Localizer.Instance["Events.AllCameras"])); - foreach (var c in cams) - { - _cameraNames[c.Id] = c.Name; - CameraOptions.Add(new CameraOption(c.Id, c.Name)); - } - SelectedCamera ??= CameraOptions[0]; - + foreach (var c in cams) _cameraNames[c.Id] = c.Name; + Cameras.SetCameras(cams.Select(c => new CameraPickSource( + c.Id, c.Name, c.Host, c.GroupId is { } gid && groups.TryGetValue(gid, out var g) ? g : null))); await ReloadAsync(ct).ConfigureAwait(true); } - partial void OnSelectedCameraChanged(CameraOption? value) => - _ = ReloadAsync(CancellationToken.None); + private DateTime? PeriodStartUtc() + { + var today = DateTime.Now.Date; + DateTime? local = Period switch + { + EventPeriod.Today => today, + EventPeriod.Days7 => today.AddDays(-6), + EventPeriod.Days30 => today.AddDays(-29), + _ => null, + }; + return local?.ToUniversalTime(); + } private async Task ReloadAsync(CancellationToken ct) { @@ -89,16 +215,26 @@ private async Task ReloadAsync(CancellationToken ct) try { var events = await _repo.ListAsync( - cameraId: SelectedCamera?.Id, + cameraId: null, kind: null, - since: null, - limit: PageLimit, + since: PeriodStartUtc(), + limit: LoadLimit, ct).ConfigureAwait(true); + _events.Clear(); + _events.AddRange(events.OrderByDescending(e => e.OccurredAt)); + + // Recordings back the "▶ play from here" action. + var recordings = await _recordingRepo.ListAsync(cameraId: null, ct).ConfigureAwait(true); + _recordings = recordings + .GroupBy(r => r.CameraId) + .ToDictionary(g => g.Key, g => g.ToList()); - Items.Clear(); - foreach (var e in events) Items.Add(BuildRow(e)); + ApplyFilter(resetPage: true); IsLoaded = true; - OnPropertyChanged(nameof(IsEmpty)); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to load events"); } finally { @@ -106,14 +242,132 @@ private async Task ReloadAsync(CancellationToken ct) } } + private bool MatchesKind(CameraEvent e) => KindFilter switch + { + EventKindFilter.Motion => e.Kind == EventKind.Motion, + EventKindFilter.Detection => e.Kind == EventKind.Detection, + _ => true, + }; + + private void ApplyFilter(bool resetPage) + { + var ofKind = _events.Where(MatchesKind).ToList(); + + // Picker counts ignore the camera selection itself, so unticked cameras + // still show how busy they are. + Cameras.SetCounts(ofKind.GroupBy(e => e.CameraId).ToDictionary(g => g.Key, g => g.Count())); + var filtered = ofKind.Where(e => Cameras.Matches(e.CameraId)).ToList(); + var episodes = GroupBursts ? FoldEpisodes(filtered) : filtered.Select(e => new List { e }).ToList(); + + Summary = string.Format(CultureInfo.CurrentCulture, Localizer.Instance["Events.SummaryFormat"], + filtered.Count, episodes.Count); + + var pageCount = Math.Max(1, (episodes.Count + PageSize - 1) / PageSize); + if (pageCount != PageCount || Pages.Count != pageCount) + { + PageCount = pageCount; + Pages.Clear(); + for (var i = 1; i <= pageCount; i++) Pages.Add(i); + } + CurrentPage = resetPage ? 0 : Math.Min(CurrentPage, pageCount - 1); + + // Per-day event counts over the whole filtered set. + var dayTotals = filtered + .GroupBy(e => e.OccurredAt.ToLocalTime().Date) + .ToDictionary(g => g.Key, g => g.Count()); + + PageItems.Clear(); + DateTime? lastDay = null; + foreach (var episode in episodes.Skip(CurrentPage * PageSize).Take(PageSize)) + { + var row = BuildRow(episode); + var day = row.LatestLocal.Date; + if (lastDay != day) + { + PageItems.Add(new EventDayHeader(day, dayTotals.TryGetValue(day, out var n) ? n : 0)); + lastDay = day; + } + PageItems.Add(row); + } + + OnPropertyChanged(nameof(IsEmpty)); + OnPropertyChanged(nameof(HasNoMatches)); + OnPropertyChanged(nameof(HasRows)); + } + + // Walks newest → oldest; an event joins its camera+kind's open episode when + // it is within EpisodeGap of that episode's oldest event. Episodes come out + // ordered by their latest event, newest first. Each is newest-first too. + private static List> FoldEpisodes(IReadOnlyList newestFirst) + { + var result = new List>(); + var open = new Dictionary<(CameraId, EventKind), List>(); + foreach (var e in newestFirst) + { + var key = (e.CameraId, e.Kind); + if (open.TryGetValue(key, out var episode) && episode[^1].OccurredAt - e.OccurredAt <= EpisodeGap) + { + episode.Add(e); + continue; + } + episode = new List { e }; + open[key] = episode; + result.Add(episode); + } + return result; + } + + private EventEpisodeRow BuildRow(IReadOnlyList episode) + { + var first = episode[^1]; + var name = _cameraNames.TryGetValue(first.CameraId, out var n) ? n : Localizer.Instance["Common.Unknown"]; + var recording = FindRecording(first.CameraId, first.OccurredAt); + TimeSpan? offset = null; + if (recording is not null) + { + var o = first.OccurredAt - recording.StartedAt - PlayLeadIn; + offset = o > TimeSpan.Zero ? o : TimeSpan.Zero; + } + return new EventEpisodeRow(episode, name, recording, offset); + } + + // The camera's recording whose span covers the moment, if any. + private Recording? FindRecording(CameraId camera, DateTime atUtc) + { + if (!_recordings.TryGetValue(camera, out var list)) return null; + foreach (var r in list) + { + var end = r.EndedAt ?? DateTime.UtcNow; + if (r.StartedAt <= atUtc && atUtc <= end) return r; + } + return null; + } + + [RelayCommand] + private void PlayFromEvent(EventEpisodeRow? row) + { + if (row?.Recording is not { } recording) return; + WeakReferenceMessenger.Default.Send(new OpenRecordingMessage(recording, row.CameraName, row.RecordingOffset)); + } + + [RelayCommand] + private void OpenCamera(EventEpisodeRow? row) + { + if (row is null) return; + WeakReferenceMessenger.Default.Send(new OpenCameraMessage(row.CameraId)); + } + + [RelayCommand] + private void ShowAllTime() => Period = EventPeriod.All; + [RelayCommand] private void SimulateMotion() { // Phase 7 §7.1 punt: real per-protocol motion sources aren't built yet // (Majestic endpoint TBD; ONVIF PullPoint not in Onvif.Core). This - // button exercises the full ingestion -> repo -> UI path against the - // first available camera so the plumbing is testable. - var target = SelectedCamera?.Id + // exercises the full ingestion -> repo -> UI path against the first + // available camera so the plumbing is testable. + var target = Cameras.SelectedCameras.Select(i => (CameraId?)i.Id).FirstOrDefault() ?? _cameraNames.Keys.FirstOrDefault(); if (target == default) { @@ -125,33 +379,20 @@ private void SimulateMotion() private void OnLiveEvent(CameraEvent ev) { - // Filter: respect the current camera filter for live updates too. - if (SelectedCamera?.Id is { } selected && ev.CameraId != selected) + // Respect the period for live updates too (cameras filter in memory). + if (PeriodStartUtc() is { } since && ev.OccurredAt < since) return; Dispatcher.UIThread.Post(() => { - var idx = -1; - for (var i = 0; i < Items.Count; i++) - { - if (Items[i].EventId == ev.Id) { idx = i; break; } - } - var row = BuildRow(ev); - if (idx >= 0) Items[idx] = row; - else Items.Insert(0, row); - - if (Items.Count > PageLimit) - Items.RemoveAt(Items.Count - 1); - OnPropertyChanged(nameof(IsEmpty)); + var idx = _events.FindIndex(e => e.Id == ev.Id); + if (idx >= 0) _events[idx] = ev; + else _events.Insert(0, ev); + if (_events.Count > LoadLimit) _events.RemoveAt(_events.Count - 1); + ApplyFilter(resetPage: false); }); } - private EventRowViewModel BuildRow(CameraEvent e) - { - var name = _cameraNames.TryGetValue(e.CameraId, out var n) ? n : Localizer.Instance["Common.Unknown"]; - return new EventRowViewModel(e, name); - } - public void Dispose() { _liveSub.Dispose(); @@ -167,29 +408,96 @@ public void OnCompleted() { } } } -public sealed record CameraOption(CameraId? Id, string Name) +// "5 July · 87" — interleaved into PageItems. +public sealed class EventDayHeader { - public override string ToString() => Name; + public EventDayHeader(DateTime day, int count) + { + var culture = Localizer.Instance.Active == LangCode.Russian + ? CultureInfo.GetCultureInfo("ru-RU") + : CultureInfo.GetCultureInfo("en-US"); + var today = DateTime.Now.Date; + var date = day.ToString(day.Year == today.Year ? "d MMMM" : "d MMMM yyyy", culture); + Title = day == today ? $"{Localizer.Instance["Recordings.Today"]}, {date}" + : day == today.AddDays(-1) ? $"{Localizer.Instance["Recordings.Yesterday"]}, {date}" + : date; + Count = count.ToString(CultureInfo.CurrentCulture); + } + + public string Title { get; } + public string Count { get; } } -public sealed class EventRowViewModel +// One row: a single event, or a burst of same-camera same-kind events. +public sealed class EventEpisodeRow { - public OpenIPC.Viewer.Core.Events.EventId EventId { get; } + public EventEpisodeRow(IReadOnlyList newestFirst, string cameraName, Recording? recording, TimeSpan? recordingOffset) + { + var latest = newestFirst[0]; + var earliest = newestFirst[^1]; + CameraId = latest.CameraId; + CameraName = cameraName; + Kind = latest.Kind; + Count = newestFirst.Count; + Recording = recording; + RecordingOffset = recordingOffset; + LatestLocal = latest.OccurredAt.ToLocalTime(); + var earliestLocal = earliest.OccurredAt.ToLocalTime(); + TimeLabel = Count == 1 || LatestLocal - earliestLocal < TimeSpan.FromMinutes(1) + ? $"{LatestLocal:HH:mm:ss}" + : $"{earliestLocal:HH:mm} – {LatestLocal:HH:mm}"; + Description = Describe(newestFirst); + Classes = newestFirst[0].Kind == EventKind.Detection ? MaxClasses(newestFirst) : Array.Empty(); + Tooltip = string.Join(" · ", new[] { latest.Source, CountLabel }.Where(s => !string.IsNullOrEmpty(s))); + } + + public CameraId CameraId { get; } public string CameraName { get; } - public string KindLabel { get; } - public string Source { get; } - public DateTime OccurredAtLocal { get; } - public string Duration { get; } + public EventKind Kind { get; } + public int Count { get; } + public DateTime LatestLocal { get; } + public string TimeLabel { get; } + public string Description { get; } - public EventRowViewModel(CameraEvent ev, string cameraName) + // Detection: one icon chip per class ("🚗 машина ×4"); empty for motion, + // which shows Description instead. + public IReadOnlyList Classes { get; } + public bool HasClasses => Classes.Count > 0; + public string Tooltip { get; } + public Recording? Recording { get; } + public TimeSpan? RecordingOffset { get; } + + public bool IsDetection => Kind == EventKind.Detection; + public bool IsMotion => Kind != EventKind.Detection; + public bool HasRecording => Recording is not null; + public bool IsBurst => Count > 1; + public string CountLabel => Count > 1 ? $"×{Count}" : ""; + + // Detection: per-class maximum across the burst ("car ×5, person ×1"), + // with class names localized where we have them. Motion: its summary or + // a plain "motion". + private static IReadOnlyList MaxClasses(IReadOnlyList events) { - EventId = ev.Id; - CameraName = cameraName; - KindLabel = ev.Kind.ToString(); - Source = ev.Source ?? "?"; - OccurredAtLocal = ev.OccurredAt.ToLocalTime(); - Duration = ev.EndedAt is { } end - ? $"{(int)(end - ev.OccurredAt).TotalSeconds}s" - : Localizer.Instance["Events.Open"]; + var max = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var e in events) + foreach (var (label, count) in DetectionClasses.Parse(e.Summary)) + max[label] = max.TryGetValue(label, out var cur) ? Math.Max(cur, count) : count; + return max.OrderByDescending(kv => kv.Value) + .Select(kv => new DetectionClassChip(kv.Key, kv.Value, DetectionClasses.IconKey(kv.Key))) + .ToList(); + } + + private static string Describe(IReadOnlyList events) + { + if (events[0].Kind != EventKind.Detection) + return events.Select(e => e.Summary).FirstOrDefault(s => !string.IsNullOrWhiteSpace(s)) + ?? Localizer.Instance["Recordings.Motion"]; + + var max = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var e in events) + foreach (var (label, count) in DetectionClasses.Parse(e.Summary)) + max[label] = max.TryGetValue(label, out var cur) ? Math.Max(cur, count) : count; + if (max.Count == 0) return Localizer.Instance["Events.Kind.Detection"]; + return string.Join(", ", max.OrderByDescending(kv => kv.Value).Select(kv => $"{DetectionClasses.Localize(kv.Key)} ×{kv.Value}")); } } diff --git a/src/OpenIPC.Viewer.App/ViewModels/GridPageViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/GridPageViewModel.cs index 618d116..7be103c 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/GridPageViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/GridPageViewModel.cs @@ -68,10 +68,12 @@ public sealed partial class GridPageViewModel : ViewModelBase, [ObservableProperty] [NotifyPropertyChangedFor(nameof(Columns))] [NotifyPropertyChangedFor(nameof(Rows))] + [NotifyPropertyChangedFor(nameof(LayoutSizeLabel))] private int _layoutSize = 2; public int Columns => LayoutSize; public int Rows => LayoutSize; + public string LayoutSizeLabel => $"{LayoutSize}×{LayoutSize}"; // Pagination within a layout. The visual grid (LayoutSize² cells) is one // page; a layout can hold more cameras than fit, so the member list is @@ -349,7 +351,18 @@ private void ReplaceLayoutInList(GridLayout updated) // --- Stills mode (grid-wide) -------------------------------------------- // Every tile shows a periodic HTTP snapshot instead of a live RTSP session. - [ObservableProperty] private bool _stillsMode; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(LiveMode))] + private bool _stillsMode; + + // Inverse of StillsMode for the "Live | Stills" segmented switch — each + // segment binds TwoWay to its own flag. + public bool LiveMode + { + get => !StillsMode; + set => StillsMode = !value; + } + [ObservableProperty] private int _stillsIntervalSeconds; public int[] StillsIntervalOptions { get; } = { 2, 5, 10, 30, 60 }; diff --git a/src/OpenIPC.Viewer.App/ViewModels/MainWindowViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/MainWindowViewModel.cs index f10e667..8fea254 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/MainWindowViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/MainWindowViewModel.cs @@ -15,8 +15,10 @@ public sealed partial class MainWindowViewModel : ViewModelBase, IRecipient, IRecipient, IRecipient, + IRecipient, IRecipient, - IRecipient + IRecipient, + IRecipient { private readonly CameraDirectoryService _directory; private readonly SingleCameraPageFactory _singleCameraFactory; @@ -60,6 +62,9 @@ public sealed partial class MainWindowViewModel : ViewModelBase, [ObservableProperty] private bool _isFullscreen; + // Recording player fullscreen, requested by the player's own button / F. + private bool _playerFullscreen; + public bool IsLiveSelected => CurrentPage is GridPageViewModel; public bool IsLibrarySelected => CurrentPage is CameraLibraryPageViewModel or SingleCameraPageViewModel; public bool IsRecordingsSelected => CurrentPage is RecordingsPageViewModel or RecordingPlayerPageViewModel; @@ -96,8 +101,10 @@ public MainWindowViewModel( WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); + WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); + WeakReferenceMessenger.Default.Register(this); // While a mobile overlay dialog is open the bottom nav must not switch // pages under it — the dim layer doesn't reliably swallow those taps. @@ -120,13 +127,23 @@ public void SetViewportOrientation(bool isMobileLandscape) private void UpdateFullscreen() { + var onPlayer = CurrentPage is RecordingPlayerPageViewModel; IsFullscreen = KioskMode - || (_isMobileLandscape && CurrentPage is SingleCameraPageViewModel); - // Camera-to-camera swipe replaces the page VM while IsFullscreen stays - // true, so the flag is pushed to the current page explicitly instead - // of relying on the property-changed callback. + || (_isMobileLandscape && (CurrentPage is SingleCameraPageViewModel || onPlayer)) + || (_playerFullscreen && onPlayer); + // Camera-to-camera swipe (and player previous/next) replaces the page + // VM while IsFullscreen stays true, so the flag is pushed to the current + // page explicitly instead of relying on the property-changed callback. if (CurrentPage is SingleCameraPageViewModel camera) camera.IsFullscreen = IsFullscreen; + if (CurrentPage is RecordingPlayerPageViewModel player) + player.IsFullscreen = IsFullscreen; + } + + public void Receive(SetPlayerFullscreenMessage message) + { + _playerFullscreen = message.On; + UpdateFullscreen(); } // Toggle the desktop kiosk. Entering tears down any single-camera/player @@ -144,14 +161,27 @@ public void Receive(ToggleKioskMessage message) UpdateFullscreen(); } + // F11: on the recording player it toggles the player's own fullscreen + // (kiosk would close the player and drop to the grid). [RelayCommand] - private void ToggleKiosk() => Receive(new ToggleKioskMessage()); + private void ToggleKiosk() + { + if (CurrentPage is RecordingPlayerPageViewModel) + Receive(new SetPlayerFullscreenMessage(!_playerFullscreen)); + else + Receive(new ToggleKioskMessage()); + } // Esc only exits (never enters) — a no-op elsewhere, so it won't swallow // Escape from dialogs that aren't in kiosk. [RelayCommand] private void ExitKiosk() { + if (_playerFullscreen && CurrentPage is RecordingPlayerPageViewModel) + { + Receive(new SetPlayerFullscreenMessage(false)); + return; + } if (!KioskMode) return; KioskMode = false; UpdateFullscreen(); @@ -215,12 +245,23 @@ public async void Receive(GoBackToLibraryMessage message) _singleCameraOrigin = null; } + public void Receive(ShowDetectionEventsMessage message) + { + Events.KindFilter = EventKindFilter.Detection; + Navigate("events"); + } + public void Receive(OpenRecordingMessage message) { try { + // Fullscreen carries over previous/next, not a fresh open. + if (CurrentPage is not RecordingPlayerPageViewModel) + _playerFullscreen = false; _ = DisposeActivePlayerAsync(); _activePlayer = _playerFactory.Create(message.Recording, message.CameraName); + _activePlayer.StartAt = message.StartAt; + _activePlayer.Rate = message.Rate; CurrentPage = _activePlayer; } catch (Exception ex) @@ -232,7 +273,10 @@ public void Receive(OpenRecordingMessage message) public async void Receive(GoBackToRecordingsMessage message) { await DisposeActivePlayerAsync().ConfigureAwait(true); + _playerFullscreen = false; CurrentPage = Recordings; + if (message.Reload) + await Recordings.LoadAsync(CancellationToken.None).ConfigureAwait(true); } private async Task DisposeActiveSingleCameraAsync() diff --git a/src/OpenIPC.Viewer.App/ViewModels/RecordingPlayerPageViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/RecordingPlayerPageViewModel.cs index a633ee7..d90ae6f 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/RecordingPlayerPageViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/RecordingPlayerPageViewModel.cs @@ -3,6 +3,7 @@ using System.Collections.ObjectModel; using System.Globalization; using System.IO; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Avalonia.Threading; @@ -15,6 +16,7 @@ using OpenIPC.Viewer.Core.Archive; using OpenIPC.Viewer.Core.Events; using OpenIPC.Viewer.Core.Recording; +using OpenIPC.Viewer.Core.Snapshots; using OpenIPC.Viewer.Core.Timeline; using OpenIPC.Viewer.Core.Video; @@ -24,15 +26,26 @@ public enum PlayerEventFilter { All, Motion, Detection } // Phase 16 — playback of a recorded segment. Hosts an IPlaybackSession (file // decode, transport, seek) and exposes it as IVideoSession so the existing -// RtspVideoView renders frames unchanged. The timeline/calendar/export layers -// (Slices C–F) grow on top of this page. +// RtspVideoView renders frames unchanged. Around it: the camera/time header +// with neighbour recordings, the event list, speed, clip export and frame grab. public sealed partial class RecordingPlayerPageViewModel : ViewModelBase, IAsyncDisposable { + // Skip buttons jump 10 s, arrow keys 5 s. + private static readonly TimeSpan SkipStep = TimeSpan.FromSeconds(10); + // A next recording starting within this gap of our end plays on by itself. + private static readonly TimeSpan AutoContinueGap = TimeSpan.FromSeconds(5); + private static readonly TimeSpan DefaultClipLength = TimeSpan.FromSeconds(10); + private static readonly double[] RateSteps = { 0.5, 1, 2, 4, 8 }; + private readonly Recording _recording; private readonly IPlaybackEngine _engine; private readonly IMediaProbe _probe; private readonly IEventRepository _events; + private readonly IRecordingRepository _recordings; + private readonly ISnapshotService _snapshots; private readonly IClipExporter _exporter; + private readonly AudioMonitor _audio; + private readonly UserSettingsService _userSettings; private readonly IDialogService _dialogs; private readonly OpenIPC.Viewer.Core.Platform.IShareService _share; private readonly ILogger _logger; @@ -40,6 +53,12 @@ public sealed partial class RecordingPlayerPageViewModel : ViewModelBase, IAsync private IPlaybackSession? _playback; private IDisposable? _stateSub; private IDisposable? _positionSub; + private Recording? _previous; + private Recording? _next; + private CancellationTokenSource? _noticeCts; + private bool _audioAttached; + private bool _userPaused; + private bool _continued; private bool _activating; private bool _disposed; @@ -49,7 +68,11 @@ public RecordingPlayerPageViewModel( IPlaybackEngine engine, IMediaProbe probe, IEventRepository events, + IRecordingRepository recordings, + ISnapshotService snapshots, IClipExporter exporter, + AudioMonitor audio, + UserSettingsService userSettings, IDialogService dialogs, OpenIPC.Viewer.Core.Platform.IShareService share, ILogger logger) @@ -59,7 +82,11 @@ public RecordingPlayerPageViewModel( _engine = engine; _probe = probe; _events = events; + _recordings = recordings; + _snapshots = snapshots; _exporter = exporter; + _audio = audio; + _userSettings = userSettings; _dialogs = dialogs; _share = share; _logger = logger; @@ -71,10 +98,38 @@ public RecordingPlayerPageViewModel( ? e : recording.StartedAt.AddMinutes(1); Segments = new[] { new TimelineSegment(TimelineStart, TimelineEnd) }; + + foreach (var r in RateSteps) + RateOptions.Add(new PlaybackRateOption(r, r == 1)); } - public string Title => Path.GetFileName(_recording.FilePath); + // Initial seek applied once playback starts (e.g. opened from an event). + public TimeSpan? StartAt { get; set; } + public string CameraName { get; } + public string FileName => Path.GetFileName(_recording.FilePath); + + // Opening the OS file manager only makes sense on desktop heads. + public bool CanShowInFolder => !OverlayDialogPresenter.IsMobile; + + // "20 June 2026 · 23:12:57 – 23:13:01 · 0:04 · 574 KB". + public string Subtitle + { + get + { + var start = TimelineStart.ToLocalTime(); + var end = TimelineEnd.ToLocalTime(); + var parts = new List + { + start.ToString("d MMMM yyyy", UiCulture), + $"{start:HH:mm:ss} – {end:HH:mm:ss}", + FormatShort(TimelineEnd - TimelineStart), + }; + if (_recording.SizeBytes > 0) + parts.Add(RecordingRowViewModel.FormatSize(_recording.SizeBytes)); + return string.Join(" · ", parts); + } + } [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsConnecting))] @@ -82,44 +137,95 @@ public RecordingPlayerPageViewModel( [NotifyPropertyChangedFor(nameof(IsPlaying))] private SessionState _state = SessionState.Idle; - [ObservableProperty] private IVideoSession? _videoSession; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsConnecting))] + [NotifyCanExecuteChangedFor(nameof(SaveFrameCommand))] + private IVideoSession? _videoSession; + [ObservableProperty] private string? _errorMessage; + // Chrome-free fullscreen; owned by MainWindowViewModel (see + // SetPlayerFullscreenMessage), pushed back here. [ObservableProperty] - [NotifyPropertyChangedFor(nameof(PositionLabel))] + [NotifyPropertyChangedFor(nameof(ShowEventSidebar))] + private bool _isFullscreen; + + // Sound: the file must carry a decodable audio track and a sink must exist. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanListen))] + private bool _hasAudio; + + public bool CanListen => HasAudio && _audio.IsAvailable; + + // Transient confirmation over the video ("Frame saved"); clears itself. + [ObservableProperty] private string? _notice; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ProgressLabel))] + [NotifyPropertyChangedFor(nameof(WallClockLabel))] private TimeSpan _position; [ObservableProperty] - [NotifyPropertyChangedFor(nameof(DurationLabel))] - [NotifyPropertyChangedFor(nameof(DurationSeconds))] + [NotifyPropertyChangedFor(nameof(ProgressLabel))] private TimeSpan _duration; // Timeline (16.4): absolute-time track range, segments, event markers, and // the playhead as an absolute time derived from Position. - [ObservableProperty] private DateTime _timelineStart; - [ObservableProperty] private DateTime _timelineEnd; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(Subtitle))] + [NotifyPropertyChangedFor(nameof(WallClockLabel))] + private DateTime _timelineStart; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(Subtitle))] + private DateTime _timelineEnd; + [ObservableProperty] private IReadOnlyList? _segments; [ObservableProperty] private IReadOnlyList? _markers; [ObservableProperty] private DateTime? _playheadTime; - // Day event list (16.6): the same motion/detection events as the timeline, - // in a clickable side list with a type filter. - private readonly List _allEventMarkers = new(); - public ObservableCollection EventList { get; } = new(); + // Neighbour recordings of the same camera. + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(PreviousCommand))] + private string? _previousTip; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(NextCommand))] + private string? _nextTip; + + // Speed (0.5×–8×), applied to the session and carried to neighbours. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(RateLabel))] + private double _rate = 1; + + public ObservableCollection RateOptions { get; } = new(); + public string RateLabel => FormatRate(Rate); + + // Event list (16.6): motion/detection inside this recording, clickable to + // seek, filtered by type. Hidden entirely when the recording has none. + private readonly List _allEvents = new(); + public ObservableCollection EventList { get; } = new(); [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsFilterAll))] [NotifyPropertyChangedFor(nameof(IsFilterMotion))] [NotifyPropertyChangedFor(nameof(IsFilterDetection))] - [NotifyPropertyChangedFor(nameof(HasEvents))] private PlayerEventFilter _eventFilter = PlayerEventFilter.All; public bool IsFilterAll => EventFilter == PlayerEventFilter.All; public bool IsFilterMotion => EventFilter == PlayerEventFilter.Motion; public bool IsFilterDetection => EventFilter == PlayerEventFilter.Detection; + public bool HasAnyEvents => _allEvents.Count > 0; + public bool ShowEventSidebar => HasAnyEvents && !IsFullscreen; public bool HasEvents => EventList.Count > 0; + public string FilterAllLabel => $"{Localizer.Instance["Player.Filter.All"]} {_allEvents.Count}"; + public string FilterMotionLabel => $"{Localizer.Instance["Player.Filter.Motion"]} {_allEvents.Count(e => e.IsMotion)}"; + public string FilterDetectionLabel => $"{Localizer.Instance["Player.Filter.Detection"]} {_allEvents.Count(e => !e.IsMotion)}"; + public string EventsTitle => string.Format(CultureInfo.CurrentCulture, Localizer.Instance["Player.EventsFormat"], _allEvents.Count); + + // Clip mode (16.5): in/out marks (absolute UTC) on the timeline + export. + [ObservableProperty] private bool _isClipMode; - // Clip export (16.5): in/out marks (absolute UTC) on the timeline + state. [ObservableProperty] [NotifyPropertyChangedFor(nameof(HasSelection))] [NotifyPropertyChangedFor(nameof(SelectionLabel))] @@ -139,39 +245,31 @@ public RecordingPlayerPageViewModel( private bool _isExporting; [ObservableProperty] private double _exportFraction; - [ObservableProperty] private string? _exportStatus; public bool HasSelection => SelectionStart is { } s && SelectionEnd is { } e && Math.Abs((e - s).TotalSeconds) >= 0.5; - public string SelectionLabel => - HasSelection - ? $"{Format(Min(SelectionStart!.Value, SelectionEnd!.Value) - TimelineStart)} – {Format(Max(SelectionStart!.Value, SelectionEnd!.Value) - TimelineStart)}" - : "—"; + // "23:13:00 – 23:13:05 · 5 s". + public string SelectionLabel + { + get + { + if (!HasSelection) return Localizer.Instance["Player.Clip.Hint"]; + var s = Min(SelectionStart!.Value, SelectionEnd!.Value); + var e = Max(SelectionStart!.Value, SelectionEnd!.Value); + return $"{s.ToLocalTime():HH:mm:ss} – {e.ToLocalTime():HH:mm:ss} · " + + string.Format(CultureInfo.CurrentCulture, Localizer.Instance["Player.Clip.SecondsFormat"], + Math.Round((e - s).TotalSeconds)); + } + } public bool IsConnecting => VideoSession is not null && State == SessionState.Connecting; public bool IsFailed => State == SessionState.Failed; public bool IsPlaying => State == SessionState.Playing; - public string PositionLabel => Format(Position); - public string DurationLabel => Format(Duration); - public double DurationSeconds => Duration.TotalSeconds; - - // Two-way bound to the seek slider's Value. Incoming playback updates raise - // PositionSeconds (via OnPositionChanged) so the thumb tracks; a drag from - // the UI lands here and, if it diverges from the playhead by more than a - // second, is treated as a seek. The threshold absorbs the rounding churn of - // per-frame position pushes so they don't loop back as phantom seeks. - public double PositionSeconds - { - get => Position.TotalSeconds; - set - { - if (Math.Abs(value - Position.TotalSeconds) < 1.0) - return; - _ = SeekToAsync(TimeSpan.FromSeconds(value)); - } - } + // Big wall-clock time of the current frame + "00:01 / 00:04" under it. + public string WallClockLabel => (TimelineStart + Position).ToLocalTime().ToString("HH:mm:ss", CultureInfo.InvariantCulture); + public string ProgressLabel => $"{Format(Position)} / {Format(Duration)}"; public async Task ActivateAsync(CancellationToken ct) { @@ -187,6 +285,7 @@ public async Task ActivateAsync(CancellationToken ct) var info = await _probe.ProbeAsync(_recording.FilePath, ct).ConfigureAwait(true); if (info.Duration > TimeSpan.Zero) Duration = info.Duration; + HasAudio = info.HasAudio; } catch (Exception ex) { @@ -194,13 +293,18 @@ public async Task ActivateAsync(CancellationToken ct) } await LoadMarkersAsync(ct).ConfigureAwait(true); + await LoadNeighboursAsync(ct).ConfigureAwait(true); + if (_disposed) return; var session = _engine.OpenFile(PlaybackOptions.Default(_recording.FilePath)); + session.Rate = Rate; _stateSub = session.StateChanged.Subscribe(s => Dispatcher.UIThread.Post(() => { State = s; if (s == SessionState.Failed) ErrorMessage = session.LastError; + if (s == SessionState.Paused) + TryAutoContinue(); })); _positionSub = session.PositionChanged.Subscribe(p => Dispatcher.UIThread.Post(() => { @@ -212,7 +316,21 @@ public async Task ActivateAsync(CancellationToken ct) })); _playback = session; VideoSession = session; + if (CanListen) + { + // Same persisted mute/volume as the live page; the player takes + // over as the one audio source while it's open. + _audio.Muted = _userSettings.Current.AudioMuted; + _audio.Volume = (float)_userSettings.Current.AudioVolume; + _audio.Changed += OnAudioChanged; + session.SetAudioEnabled(true); + _audio.Detach(); + _audio.Attach(session, _recording.CameraId); + _audioAttached = true; + } await session.StartAsync(ct).ConfigureAwait(true); + if (StartAt is { } start && start > TimeSpan.FromSeconds(1)) + await SeekToAsync(start).ConfigureAwait(true); } catch (Exception ex) { @@ -226,21 +344,300 @@ public async Task ActivateAsync(CancellationToken ct) } } + // ── Transport ───────────────────────────────────────────────────────── + [RelayCommand] private void PlayPause() { if (_playback is null) return; - if (_playback.IsPaused) _playback.Play(); - else _playback.Pause(); + if (_playback.IsPaused) + { + _userPaused = false; + // Play at the end of the file starts over, like any player. + if (Duration > TimeSpan.Zero && Position >= Duration - TimeSpan.FromSeconds(0.5)) + _ = SeekToAsync(TimeSpan.Zero); + _playback.Play(); + } + else + { + _userPaused = true; + _playback.Pause(); + } + } + + // Single-frame step (pauses first). Backward costs a GOP decode. + [RelayCommand] + private void StepBack() => StepFrame(false); + + [RelayCommand] + private void StepForward() => StepFrame(true); + + public void StepFrame(bool forward) + { + if (_playback is null) return; + _userPaused = true; + _playback.StepFrame(forward); + } + + [RelayCommand] + private void ToggleFullscreen() => + WeakReferenceMessenger.Default.Send(new SetPlayerFullscreenMessage(!IsFullscreen)); + + // ── Sound ───────────────────────────────────────────────────────────── + + public bool IsMuted + { + get => _audio.Muted; + set + { + if (_audio.Muted == value) return; + _audio.Muted = value; // raises Changed → OnAudioChanged re-raises + persists + } + } + + public double Volume + { + get => _audio.Volume; + set + { + if (Math.Abs(_audio.Volume - value) < 0.0001) return; + _audio.Volume = (float)value; + } + } + + [RelayCommand] + public void ToggleMute() + { + if (CanListen) IsMuted = !IsMuted; + } + + private void OnAudioChanged(object? sender, EventArgs e) + { + Dispatcher.UIThread.Post(() => + { + OnPropertyChanged(nameof(IsMuted)); + OnPropertyChanged(nameof(Volume)); + }); + var cur = _userSettings.Current; + if (cur.AudioMuted == _audio.Muted && Math.Abs(cur.AudioVolume - _audio.Volume) < 0.0001) + return; + _ = _userSettings.UpdateAsync(cur with { AudioMuted = _audio.Muted, AudioVolume = _audio.Volume }); } [RelayCommand] - private Task RestartAsync() => SeekToAsync(TimeSpan.Zero); + private Task SkipBack() => SeekRelativeAsync(-SkipStep); + + [RelayCommand] + private Task SkipForward() => SeekRelativeAsync(SkipStep); + + public Task SeekRelativeAsync(TimeSpan delta) + { + var target = Position + delta; + if (target < TimeSpan.Zero) target = TimeSpan.Zero; + if (Duration > TimeSpan.Zero && target > Duration) target = Duration; + return SeekToAsync(target); + } + + public Task SeekToStartAsync() => SeekToAsync(TimeSpan.Zero); + + public Task SeekToEndAsync() => + Duration > TimeSpan.Zero ? SeekToAsync(Duration) : Task.CompletedTask; - // Invoked by the timeline on click/marker-tap with an absolute UTC time. + // Invoked by the timeline and the event list with an absolute UTC time. [RelayCommand] private Task SeekToTime(DateTime target) => SeekToAsync(target - TimelineStart); + [RelayCommand] + private void SetRate(string? value) + { + if (double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var r)) + Rate = r; + } + + partial void OnRateChanged(double value) + { + foreach (var o in RateOptions) o.IsActive = o.Value == value; + if (_playback is not null) _playback.Rate = value; + } + + private async Task SeekToAsync(TimeSpan position) + { + if (_playback is null) return; + Position = position; // optimistic — keeps the playhead responsive mid-drag + try { await _playback.SeekAsync(position, CancellationToken.None).ConfigureAwait(true); } + catch (Exception ex) { _logger.LogWarning(ex, "Seek to {Pos} failed", position); } + } + + partial void OnPositionChanged(TimeSpan value) => PlayheadTime = TimelineStart + value; + + partial void OnDurationChanged(TimeSpan value) + { + if (value <= TimeSpan.Zero) return; + TimelineEnd = TimelineStart + value; + Segments = new[] { new TimelineSegment(TimelineStart, TimelineEnd) }; + } + + // ── Neighbours ──────────────────────────────────────────────────────── + + private async Task LoadNeighboursAsync(CancellationToken ct) + { + try + { + var all = (await _recordings.ListAsync(_recording.CameraId, ct).ConfigureAwait(true)) + .OrderBy(r => r.StartedAt) + .ToList(); + var i = all.FindIndex(r => r.Id == _recording.Id); + if (i < 0) return; + _previous = i > 0 ? all[i - 1] : null; + _next = i < all.Count - 1 ? all[i + 1] : null; + PreviousTip = _previous is null ? null : NeighbourTip("Player.PreviousTip", _previous); + NextTip = _next is null ? null : NeighbourTip("Player.NextTip", _next); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to load neighbour recordings for {Path}", _recording.FilePath); + } + } + + private static string NeighbourTip(string key, Recording r) + { + var start = r.StartedAt.ToLocalTime(); + var when = start.Date == DateTime.Now.Date + ? start.ToString("HH:mm:ss", CultureInfo.InvariantCulture) + : start.ToString("d MMM, HH:mm:ss", UiCulture); + return $"{Localizer.Instance[key]} · {when}"; + } + + [RelayCommand(CanExecute = nameof(HasPrevious))] + private void Previous() => OpenNeighbour(_previous); + + [RelayCommand(CanExecute = nameof(HasNext))] + private void Next() => OpenNeighbour(_next); + + private bool HasPrevious() => PreviousTip is not null; + private bool HasNext() => NextTip is not null; + + private void OpenNeighbour(Recording? r) + { + if (r is null) return; + WeakReferenceMessenger.Default.Send(new OpenRecordingMessage(r, CameraName, Rate: Rate)); + } + + // The file ended by itself (not a user pause) and the next recording picks + // up right where this one stops — continuous recording split into files. + private void TryAutoContinue() + { + if (_continued || _userPaused || _next is null || Duration <= TimeSpan.Zero) return; + if (Position < Duration - TimeSpan.FromSeconds(0.5)) return; + if (_next.StartedAt - (TimelineStart + Duration) > AutoContinueGap) return; + _continued = true; + OpenNeighbour(_next); + } + + // ── Frame, file actions ─────────────────────────────────────────────── + + [RelayCommand(CanExecute = nameof(CanSaveFrame))] + private async Task SaveFrameAsync() + { + if (_playback is null) return; + try + { + var jpeg = await _playback.SnapshotAsync(SnapshotFormat.Jpeg, CancellationToken.None).ConfigureAwait(true); + if (jpeg.Length == 0) return; + await _snapshots.SaveFrameAsync(_recording.CameraId, jpeg, TimelineStart + Position, CancellationToken.None) + .ConfigureAwait(true); + ShowNotice(Localizer.Instance["Player.FrameSaved"]); + } + catch (Exception ex) + { + _logger.LogError(ex, "Saving a frame from {Path} failed", _recording.FilePath); + ShowNotice(ex.Message); + } + } + + private bool CanSaveFrame() => VideoSession is not null; + + [RelayCommand] + private async Task ShowInFolderAsync() + { + var dir = Path.GetDirectoryName(_recording.FilePath); + if (string.IsNullOrEmpty(dir) || !Directory.Exists(dir)) return; + if (!await _dialogs.OpenUrlAsync(new Uri(dir + Path.DirectorySeparatorChar).AbsoluteUri).ConfigureAwait(true)) + _logger.LogWarning("Could not open folder {Dir}", dir); + } + + [RelayCommand] + private async Task CopyFileAsync() + { + try { await _dialogs.CopyFileToClipboardAsync(_recording.FilePath).ConfigureAwait(true); } + catch (Exception ex) { _logger.LogWarning(ex, "Copy to clipboard failed for {Path}", _recording.FilePath); } + } + + [RelayCommand] + private async Task DeleteAsync() + { + var confirmed = await _dialogs.ConfirmAsync( + title: Localizer.Instance["Recordings.Dialog.DeleteTitle"], + message: string.Format(Localizer.Instance["Recordings.Dialog.DeleteMessageFormat"], FileName), + confirmLabel: Localizer.Instance["Common.Delete"], + cancelLabel: Localizer.Instance["Common.Cancel"]).ConfigureAwait(true); + if (!confirmed) return; + + // The decoder holds the file open; release it before deleting. + await DisposeAsync().ConfigureAwait(true); + try + { + try { if (File.Exists(_recording.FilePath)) File.Delete(_recording.FilePath); } + catch (IOException ex) { _logger.LogWarning(ex, "File still locked (recording in progress?)"); } + await _recordings.RemoveAsync(_recording.Id, CancellationToken.None).ConfigureAwait(true); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to delete recording {Id}", _recording.Id); + } + WeakReferenceMessenger.Default.Send(new GoBackToRecordingsMessage(Reload: true)); + } + + [RelayCommand] + private void Back() => WeakReferenceMessenger.Default.Send(new GoBackToRecordingsMessage()); + + private async void ShowNotice(string text) + { + _noticeCts?.Cancel(); + var cts = _noticeCts = new CancellationTokenSource(); + Notice = text; + try { await Task.Delay(TimeSpan.FromSeconds(3), cts.Token).ConfigureAwait(true); } + catch (OperationCanceledException) { return; } + Notice = null; + } + + // ── Clip mode ───────────────────────────────────────────────────────── + + // Entering clip mode pre-selects the next 10 s so there's something to + // adjust; leaving it drops the selection. + partial void OnIsClipModeChanged(bool value) + { + if (!value) + { + SelectionStart = null; + SelectionEnd = null; + return; + } + if (HasSelection || PlayheadTime is not { } t) return; + var start = t; + var end = start + DefaultClipLength; + if (end > TimelineEnd) + { + end = TimelineEnd; + start = end - DefaultClipLength < TimelineStart ? TimelineStart : end - DefaultClipLength; + } + SelectionStart = start; + SelectionEnd = end; + } + + [RelayCommand] + private void CancelClip() => IsClipMode = false; + [RelayCommand] private void SetIn() { @@ -253,11 +650,13 @@ private void SetOut() if (PlayheadTime is { } t) SelectionEnd = t; } - [RelayCommand] - private void ClearSelection() + // A drag on the timeline selects a range — that means clip mode. + partial void OnSelectionStartChanged(DateTime? value) => EnterClipModeOnSelection(); + partial void OnSelectionEndChanged(DateTime? value) => EnterClipModeOnSelection(); + + private void EnterClipModeOnSelection() { - SelectionStart = null; - SelectionEnd = null; + if (HasSelection && !IsClipMode) IsClipMode = true; } [RelayCommand(CanExecute = nameof(CanExport))] @@ -276,13 +675,13 @@ private async Task ExportAsync() IsExporting = true; ExportFraction = 0; - ExportStatus = null; try { var request = new ClipExportRequest(_recording.FilePath, dest!, startOff, endOff, PreciseExport); var progress = new Progress(p => ExportFraction = p); await _exporter.ExportAsync(request, progress, CancellationToken.None).ConfigureAwait(true); - ExportStatus = Path.GetFileName(dest); + CancelClip(); + ShowNotice(string.Format(CultureInfo.CurrentCulture, Localizer.Instance["Player.Clip.SavedFormat"], Path.GetFileName(dest))); // On mobile the picked file is invisible to the user, so hand the // clip to the native share sheet (Phase 16.5 "в галерею/share"). @@ -292,7 +691,7 @@ private async Task ExportAsync() catch (Exception ex) { _logger.LogError(ex, "Clip export failed for {Path}", _recording.FilePath); - ExportStatus = ex.Message; + ShowNotice(ex.Message); } finally { @@ -302,32 +701,7 @@ private async Task ExportAsync() private bool CanExport() => HasSelection && !IsExporting; - private static DateTime Min(DateTime a, DateTime b) => a <= b ? a : b; - private static DateTime Max(DateTime a, DateTime b) => a >= b ? a : b; - - private async Task SeekToAsync(TimeSpan position) - { - if (_playback is null) return; - Position = position; // optimistic — keeps the slider responsive mid-drag - try { await _playback.SeekAsync(position, CancellationToken.None).ConfigureAwait(true); } - catch (Exception ex) { _logger.LogWarning(ex, "Seek to {Pos} failed", position); } - } - - [RelayCommand] - private void Back() => WeakReferenceMessenger.Default.Send(new GoBackToRecordingsMessage()); - - partial void OnPositionChanged(TimeSpan value) - { - OnPropertyChanged(nameof(PositionSeconds)); - PlayheadTime = TimelineStart + value; - } - - partial void OnDurationChanged(TimeSpan value) - { - if (value <= TimeSpan.Zero) return; - TimelineEnd = TimelineStart + value; - Segments = new[] { new TimelineSegment(TimelineStart, TimelineEnd) }; - } + // ── Events ──────────────────────────────────────────────────────────── private async Task LoadMarkersAsync(CancellationToken ct) { @@ -338,7 +712,8 @@ private async Task LoadMarkersAsync(CancellationToken ct) .ConfigureAwait(true); var markers = new List(); - foreach (var ev in list) + _allEvents.Clear(); + foreach (var ev in list.OrderBy(e => e.OccurredAt)) { if (ev.OccurredAt < TimelineStart || ev.OccurredAt > TimelineEnd) continue; @@ -350,13 +725,18 @@ private async Task LoadMarkersAsync(CancellationToken ct) }; if (kind == TimelineMarkerKind.Other) continue; // only motion/detection belong on the archive track - var label = $"{ev.OccurredAt.ToLocalTime():HH:mm:ss} · {ev.Summary ?? kind.ToString()}"; - markers.Add(new TimelineMarker(ev.OccurredAt, kind, label)); + var row = new PlayerEventRow(ev.OccurredAt, ev.OccurredAt - TimelineStart, kind == TimelineMarkerKind.Motion, ev.Summary); + _allEvents.Add(row); + markers.Add(new TimelineMarker(ev.OccurredAt, kind, $"{row.TimeLabel} · {row.Title}")); } Markers = markers; - _allEventMarkers.Clear(); - _allEventMarkers.AddRange(markers); ApplyEventFilter(); + OnPropertyChanged(nameof(HasAnyEvents)); + OnPropertyChanged(nameof(ShowEventSidebar)); + OnPropertyChanged(nameof(FilterAllLabel)); + OnPropertyChanged(nameof(FilterMotionLabel)); + OnPropertyChanged(nameof(FilterDetectionLabel)); + OnPropertyChanged(nameof(EventsTitle)); } catch (Exception ex) { @@ -378,15 +758,15 @@ private void SetEventFilter(string? filter) => private void ApplyEventFilter() { EventList.Clear(); - foreach (var m in _allEventMarkers) + foreach (var e in _allEvents) { var include = EventFilter switch { - PlayerEventFilter.Motion => m.Kind == TimelineMarkerKind.Motion, - PlayerEventFilter.Detection => m.Kind == TimelineMarkerKind.Detection, + PlayerEventFilter.Motion => e.IsMotion, + PlayerEventFilter.Detection => !e.IsMotion, _ => true, }; - if (include) EventList.Add(m); + if (include) EventList.Add(e); } OnPropertyChanged(nameof(HasEvents)); } @@ -395,6 +775,9 @@ public async ValueTask DisposeAsync() { if (_disposed) return; _disposed = true; + _noticeCts?.Cancel(); + _audio.Changed -= OnAudioChanged; + if (_audioAttached) _audio.Detach(_recording.CameraId); _stateSub?.Dispose(); _positionSub?.Dispose(); var session = _playback; @@ -407,8 +790,67 @@ public async ValueTask DisposeAsync() } } - private static string Format(TimeSpan t) => + private static CultureInfo UiCulture => Localizer.Instance.Active == LangCode.Russian + ? CultureInfo.GetCultureInfo("ru-RU") + : CultureInfo.GetCultureInfo("en-US"); + + private static DateTime Min(DateTime a, DateTime b) => a <= b ? a : b; + private static DateTime Max(DateTime a, DateTime b) => a >= b ? a : b; + + internal static string FormatRate(double r) => + r.ToString("0.##", CultureInfo.InvariantCulture) + "×"; + + // "0:04", "1:02:10". + private static string FormatShort(TimeSpan t) => t.TotalHours >= 1 + ? $"{(int)t.TotalHours}:{t.Minutes:D2}:{t.Seconds:D2}" + : $"{t.Minutes}:{t.Seconds:D2}"; + + internal static string Format(TimeSpan t) => t.TotalHours >= 1 ? string.Format(CultureInfo.InvariantCulture, "{0:D2}:{1:D2}:{2:D2}", (int)t.TotalHours, t.Minutes, t.Seconds) : string.Format(CultureInfo.InvariantCulture, "{0:D2}:{1:D2}", t.Minutes, t.Seconds); } + +// One speed step in the transport's segment. +public sealed partial class PlaybackRateOption : ObservableObject +{ + public PlaybackRateOption(double value, bool isActive) + { + Value = value; + _isActive = isActive; + } + + public double Value { get; } + public string Label => RecordingPlayerPageViewModel.FormatRate(Value); + public string Parameter => Value.ToString(CultureInfo.InvariantCulture); + + [ObservableProperty] private bool _isActive; +} + +// A motion/detection event inside the playing recording. +public sealed class PlayerEventRow +{ + public PlayerEventRow(DateTime time, TimeSpan offset, bool isMotion, string? summary) + { + Time = time; + IsMotion = isMotion; + TimeLabel = time.ToLocalTime().ToString("HH:mm:ss", CultureInfo.InvariantCulture); + OffsetLabel = RecordingPlayerPageViewModel.Format(offset < TimeSpan.Zero ? TimeSpan.Zero : offset); + Chips = isMotion + ? Array.Empty() + : DetectionClasses.Parse(summary) + .OrderByDescending(kv => kv.Value) + .Select(kv => new DetectionClassChip(kv.Key, kv.Value > 1 ? kv.Value : 0, DetectionClasses.IconKey(kv.Key))) + .ToList(); + Title = isMotion ? Localizer.Instance["Events.Kind.Motion"] : Localizer.Instance["Events.Kind.Detection"]; + } + + public DateTime Time { get; } + public bool IsMotion { get; } + public string TimeLabel { get; } + public string OffsetLabel { get; } + public string Title { get; } + public IReadOnlyList Chips { get; } + public bool HasChips => Chips.Count > 0; + public string IconKey => IsMotion ? "IconWalk" : "IconScanSearch"; +} diff --git a/src/OpenIPC.Viewer.App/ViewModels/RecordingsPageViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/RecordingsPageViewModel.cs index af2ae5d..652e40f 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/RecordingsPageViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/RecordingsPageViewModel.cs @@ -1,7 +1,9 @@ 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; @@ -18,6 +20,8 @@ namespace OpenIPC.Viewer.App.ViewModels; public enum MediaTab { Recordings, Snapshots } +public enum RecordingPeriod { All, Today, Days7, Days30 } + public sealed partial class RecordingsPageViewModel : ViewModelBase { private readonly IRecordingRepository _repo; @@ -25,8 +29,10 @@ public sealed partial class RecordingsPageViewModel : ViewModelBase private readonly IDialogService _dialogs; private readonly ILogger _logger; + // Newest first; the filters below slice this into PageItems. private readonly List _allRows = new(); private DateTime? _dayFilter; + private bool _syncingDayAndPeriod; public string Title => Localizer.Instance["Nav.Recordings"]; @@ -47,17 +53,143 @@ public sealed partial class RecordingsPageViewModel : ViewModelBase 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(); + // Settable so the "Recordings | Snapshots" segment binds TwoWay. + public bool IsRecordingsTabActive + { + get => SelectedTab == MediaTab.Recordings; + set { if (value) SelectRecordings(); } + } + + public bool IsSnapshotsTabActive + { + get => SelectedTab == MediaTab.Snapshots; + set { if (value) _ = SelectSnapshotsAsync(); } + } + + // --- Filters ---------------------------------------------------------------- + // Searchable multi-select camera filter (shared with Events). + public CameraPickerViewModel Cameras { get; } = new("Recordings.Cameras.WithRecordings", "Recordings.Cameras.FooterFormat"); + + [ObservableProperty] private bool _motionOnly; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsPeriodAll))] + [NotifyPropertyChangedFor(nameof(IsPeriodToday))] + [NotifyPropertyChangedFor(nameof(IsPeriod7))] + [NotifyPropertyChangedFor(nameof(IsPeriod30))] + private RecordingPeriod _period; + + public bool IsPeriodAll { get => Period == RecordingPeriod.All; set { if (value) Period = RecordingPeriod.All; } } + public bool IsPeriodToday { get => Period == RecordingPeriod.Today; set { if (value) Period = RecordingPeriod.Today; } } + public bool IsPeriod7 { get => Period == RecordingPeriod.Days7; set { if (value) Period = RecordingPeriod.Days7; } } + public bool IsPeriod30 { get => Period == RecordingPeriod.Days30; set { if (value) Period = RecordingPeriod.Days30; } } + + partial void OnMotionOnlyChanged(bool value) => OnCameraOrMotionChanged(); + + // Camera / motion also narrow the calendar highlight, so a lit day always + // lists something. + private void OnCameraOrMotionChanged() + { + ApplyFilter(resetPage: true); + var cameras = Cameras.SelectedCameras.Select(i => i.Id).ToHashSet(); + var motionOnly = MotionOnly; + _ = Calendar.SetRecordingFilterAsync(cameras.Count == 0 && !motionOnly + ? null + : r => (cameras.Count == 0 || cameras.Contains(r.CameraId)) && (!motionOnly || r.HasMotion)); + } + + // A period and a calendar day don't combine: picking one clears the other. + partial void OnPeriodChanged(RecordingPeriod value) + { + if (!_syncingDayAndPeriod && value != RecordingPeriod.All && _dayFilter is not null) + { + _syncingDayAndPeriod = true; + try { Calendar.ShowAllCommand.Execute(null); } + finally { _syncingDayAndPeriod = false; } + _dayFilter = null; + } + ApplyFilter(resetPage: true); + } + + private void OnDaySelected(DateTime? day) + { + _dayFilter = day; + if (!_syncingDayAndPeriod && day is not null && Period != RecordingPeriod.All) + { + _syncingDayAndPeriod = true; + try { Period = RecordingPeriod.All; } + finally { _syncingDayAndPeriod = false; } + } + ApplyFilter(resetPage: true); + } + + // "Recordings: 12 · 1.4 GB" over the filtered set. + [ObservableProperty] private string _summary = ""; + + // --- Pagination --------------------------------------------------------------- + // PageSize recordings per page; day headers are interleaved on top of that. + public const int PageSize = 20; + + public ObservableCollection PageItems { get; } = new(); + public ObservableCollection Pages { get; } = new(); + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CurrentPageDisplay))] + [NotifyPropertyChangedFor(nameof(CanPrevPage))] + [NotifyPropertyChangedFor(nameof(CanNextPage))] + private int _currentPage; // 0-based + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasMultiplePages))] + [NotifyPropertyChangedFor(nameof(CanNextPage))] + private int _pageCount = 1; + + public int CurrentPageDisplay => CurrentPage + 1; + public bool HasMultiplePages => PageCount > 1; + public bool CanPrevPage => CurrentPage > 0; + public bool CanNextPage => CurrentPage + 1 < PageCount; + + [RelayCommand] + private void PrevPage() + { + if (!CanPrevPage) return; + CurrentPage--; + ApplyFilter(resetPage: false); + } + + [RelayCommand] + private void NextPage() + { + if (!CanNextPage) return; + CurrentPage++; + ApplyFilter(resetPage: false); + } + + // CommandParameter is the boxed 1-based page number from the Pages binding + // (object? sidesteps the RelayCommand XAML render crash). + [RelayCommand] + private void GoToPage(object? page) + { + if (page is null) return; + int oneBased; + try { oneBased = Convert.ToInt32(page, CultureInfo.InvariantCulture); } + catch (Exception) { return; } + var target = oneBased - 1; + if (target < 0 || target >= PageCount || target == CurrentPage) return; + CurrentPage = target; + ApplyFilter(resetPage: false); + } + // --- Load state ------------------------------------------------------------- [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsEmpty))] + [NotifyPropertyChangedFor(nameof(HasNoMatches))] private bool _isLoaded; [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsEmpty))] + [NotifyPropertyChangedFor(nameof(HasNoMatches))] private bool _isLoading; // Set when ListAsync throws — the page shows a localized error overlay @@ -65,11 +197,18 @@ public sealed partial class RecordingsPageViewModel : ViewModelBase [ObservableProperty] [NotifyPropertyChangedFor(nameof(HasLoadError))] [NotifyPropertyChangedFor(nameof(IsEmpty))] + [NotifyPropertyChangedFor(nameof(HasNoMatches))] private string? _loadError; - public bool IsEmpty => IsLoaded && !IsLoading && LoadError is null && Items.Count == 0; + // No recordings at all vs. recordings hidden by the filters. + public bool IsEmpty => IsLoaded && !IsLoading && LoadError is null && _allRows.Count == 0; + public bool HasNoMatches => IsLoaded && !IsLoading && LoadError is null && _allRows.Count > 0 && PageItems.Count == 0; + public bool HasRows => PageItems.Count > 0; public bool HasLoadError => LoadError is not null; + // Opening the OS file manager only makes sense on desktop heads. + public bool CanShowInFolder => !OverlayDialogPresenter.IsMobile; + public RecordingsPageViewModel( IRecordingRepository repo, CameraDirectoryService cameras, @@ -85,24 +224,63 @@ public RecordingsPageViewModel( Calendar = calendar; _logger = logger; Calendar.DaySelected += OnDaySelected; + Cameras.SelectionChanged += OnCameraOrMotionChanged; } - private void OnDaySelected(DateTime? day) + private void ApplyFilter(bool resetPage = false) { - _dayFilter = day; - ApplyFilter(); - } + var today = DateTime.Now.Date; + DateTime? since = Period switch + { + RecordingPeriod.Today => today, + RecordingPeriod.Days7 => today.AddDays(-6), + RecordingPeriod.Days30 => today.AddDays(-29), + _ => null, + }; + // Picker counts use every filter except the camera one itself. + var inScope = _allRows.Where(r => + (!MotionOnly || r.HasMotion) + && (since is null || r.StartedAtLocal >= since.Value) + && (_dayFilter is null || r.StartedAtLocal.Date == _dayFilter.Value.Date)) + .ToList(); + Cameras.SetCounts(inScope.GroupBy(r => r.Recording.CameraId).ToDictionary(g => g.Key, g => g.Count())); + var filtered = inScope.Where(r => Cameras.Matches(r.Recording.CameraId)).ToList(); - private void ApplyFilter() - { - Items.Clear(); - foreach (var row in _allRows) + Summary = string.Format(CultureInfo.CurrentCulture, Localizer.Instance["Recordings.SummaryFormat"], + filtered.Count, RecordingRowViewModel.FormatSize(filtered.Sum(r => Math.Max(0, r.Recording.SizeBytes)))); + + var pageCount = Math.Max(1, (filtered.Count + PageSize - 1) / PageSize); + if (pageCount != PageCount || Pages.Count != pageCount) + { + PageCount = pageCount; + Pages.Clear(); + for (var i = 1; i <= pageCount; i++) Pages.Add(i); + } + CurrentPage = resetPage ? 0 : Math.Min(CurrentPage, pageCount - 1); + + // Per-day totals over the whole filtered set, so a header reads the + // same whichever page the day starts on. + var dayTotals = filtered + .GroupBy(r => r.StartedAtLocal.Date) + .ToDictionary(g => g.Key, g => (Count: g.Count(), Bytes: g.Sum(r => Math.Max(0, r.Recording.SizeBytes)))); + + PageItems.Clear(); + DateTime? lastDay = null; + foreach (var row in filtered.Skip(CurrentPage * PageSize).Take(PageSize)) { - if (_dayFilter is { } d && row.StartedAtLocal.Date != d.Date) - continue; - Items.Add(row); + var day = row.StartedAtLocal.Date; + if (lastDay != day) + { + var totals = dayTotals[day]; + PageItems.Add(new RecordingDayHeader(day, totals.Count, totals.Bytes)); + lastDay = day; + } + PageItems.Add(row); } + OnPropertyChanged(nameof(IsEmpty)); + OnPropertyChanged(nameof(HasNoMatches)); + OnPropertyChanged(nameof(HasRows)); } [RelayCommand] @@ -124,16 +302,19 @@ public async Task LoadAsync(CancellationToken ct) { var recordings = await _repo.ListAsync(cameraId: null, ct).ConfigureAwait(true); var cams = await _cameras.ListAsync(ct).ConfigureAwait(true); + var groups = (await _cameras.ListGroupsAsync(ct).ConfigureAwait(true)).ToDictionary(g => g.Id, g => g.Name); + Cameras.SetCameras(cams.Select(c => new CameraPickSource( + c.Id, c.Name, c.Host, c.GroupId is { } gid && groups.TryGetValue(gid, out var g) ? g : null))); var nameById = new Dictionary(); foreach (var c in cams) nameById[c.Id] = c.Name; _allRows.Clear(); - foreach (var r in recordings) + foreach (var r in recordings.OrderByDescending(r => r.StartedAt)) { var name = nameById.TryGetValue(r.CameraId, out var n) ? n : Localizer.Instance["Common.Unknown"]; _allRows.Add(new RecordingRowViewModel(r, name)); } - ApplyFilter(); + ApplyFilter(resetPage: false); IsLoaded = true; await Calendar.LoadAsync(ct).ConfigureAwait(true); @@ -159,6 +340,24 @@ private void Play(RecordingRowViewModel? row) WeakReferenceMessenger.Default.Send(new OpenRecordingMessage(row.Recording, row.CameraName)); } + [RelayCommand] + private async Task ShowInFolderAsync(RecordingRowViewModel? row) + { + if (row is null) return; + var dir = Path.GetDirectoryName(row.FilePath); + if (string.IsNullOrEmpty(dir) || !Directory.Exists(dir)) return; + if (!await _dialogs.OpenUrlAsync(new Uri(dir + Path.DirectorySeparatorChar).AbsoluteUri).ConfigureAwait(true)) + _logger.LogWarning("Could not open folder {Dir}", dir); + } + + [RelayCommand] + private async Task CopyFileAsync(RecordingRowViewModel? row) + { + if (row is null) return; + try { await _dialogs.CopyFileToClipboardAsync(row.FilePath).ConfigureAwait(true); } + catch (Exception ex) { _logger.LogWarning(ex, "Copy to clipboard failed for {Path}", row.FilePath); } + } + [RelayCommand] private async Task DeleteAsync(RecordingRowViewModel? row) { @@ -177,8 +376,7 @@ private async Task DeleteAsync(RecordingRowViewModel? row) await _repo.RemoveAsync(row.Recording.Id, CancellationToken.None).ConfigureAwait(true); _allRows.Remove(row); - Items.Remove(row); - OnPropertyChanged(nameof(IsEmpty)); + ApplyFilter(resetPage: false); } catch (Exception ex) { @@ -187,6 +385,26 @@ private async Task DeleteAsync(RecordingRowViewModel? row) } } +// "Today, 27 September · 3 · 142 MB" — interleaved into PageItems. +public sealed class RecordingDayHeader +{ + public RecordingDayHeader(DateTime day, int count, long bytes) + { + var culture = Localizer.Instance.Active == LangCode.Russian + ? CultureInfo.GetCultureInfo("ru-RU") + : CultureInfo.GetCultureInfo("en-US"); + var today = DateTime.Now.Date; + var date = day.ToString(day.Year == today.Year ? "d MMMM" : "d MMMM yyyy", culture); + Title = day == today ? $"{Localizer.Instance["Recordings.Today"]}, {date}" + : day == today.AddDays(-1) ? $"{Localizer.Instance["Recordings.Yesterday"]}, {date}" + : date; + Totals = $"{count} · {RecordingRowViewModel.FormatSize(bytes)}"; + } + + public string Title { get; } + public string Totals { get; } +} + public sealed class RecordingRowViewModel { public Recording Recording { get; } @@ -195,14 +413,19 @@ public sealed class RecordingRowViewModel public string FilePath => Recording.FilePath; public string FileName => Path.GetFileName(Recording.FilePath); public DateTime StartedAtLocal => Recording.StartedAt.ToLocalTime(); + public bool IsLive => Recording.EndedAt is null; + public bool HasMotion => Recording.HasMotion; + + // "14:52 – 14:58", or "15:02 —" while still recording. + public string TimeRange => Recording.EndedAt is { } end + ? $"{StartedAtLocal:HH:mm} – {end.ToLocalTime():HH:mm}" + : $"{StartedAtLocal:HH:mm} —"; + public string Duration => Recording.EndedAt is { } end ? FormatDuration(end - Recording.StartedAt) - : Localizer.Instance["Recordings.Live"]; - public string SizeLabel => Recording.SizeBytes <= 0 - ? "—" - : Recording.SizeBytes > 1024 * 1024 - ? $"{Recording.SizeBytes / (1024.0 * 1024):F1} MB" - : $"{Recording.SizeBytes / 1024.0:F0} KB"; + : "—"; + + public string SizeLabel => Recording.SizeBytes <= 0 ? "—" : FormatSize(Recording.SizeBytes); public RecordingRowViewModel(Recording recording, string cameraName) { @@ -210,6 +433,15 @@ public RecordingRowViewModel(Recording recording, string cameraName) CameraName = cameraName; } - private static string FormatDuration(TimeSpan t) => - $"{(int)t.TotalHours:D2}:{t.Minutes:D2}:{t.Seconds:D2}"; + // "6:29", "1:02:10". + private static string FormatDuration(TimeSpan t) => t.TotalHours >= 1 + ? $"{(int)t.TotalHours}:{t.Minutes:D2}:{t.Seconds:D2}" + : $"{t.Minutes}:{t.Seconds:D2}"; + + public static string FormatSize(long bytes) => bytes switch + { + >= 1024L * 1024 * 1024 => $"{bytes / (1024.0 * 1024 * 1024):F1} GB", + >= 1024 * 1024 => $"{bytes / (1024.0 * 1024):F0} MB", + _ => $"{bytes / 1024.0:F0} KB", + }; } diff --git a/src/OpenIPC.Viewer.App/Views/Dialogs/CameraEditorContent.axaml b/src/OpenIPC.Viewer.App/Views/Dialogs/CameraEditorContent.axaml index 25d9348..cb0a5d6 100644 --- a/src/OpenIPC.Viewer.App/Views/Dialogs/CameraEditorContent.axaml +++ b/src/OpenIPC.Viewer.App/Views/Dialogs/CameraEditorContent.axaml @@ -4,92 +4,159 @@ xmlns:svc="using:OpenIPC.Viewer.App.Services" x:Class="OpenIPC.Viewer.App.Views.Dialogs.CameraEditorContent" x:DataType="vm:CameraEditorViewModel" - MaxWidth="520" MaxHeight="700" MinWidth="300" + MaxWidth="540" MinWidth="300" Background="{StaticResource Bg1Brush}" FontFamily="{StaticResource FontSans}" FontSize="{StaticResource FontSizeBase}" Foreground="{StaticResource TextPrimaryBrush}"> - + + + + + - + + + + - - + + - - - - + - - - - - - - - - - + + + + + - - - - + + - - - - - - - + - - - + + + + + + + + + + + + + + + - - - + + + + + + + + - + unreliable, so credentials copied from a password manager had + no way into these fields. --> + + + + + + + + + + - - - - - - - - + + + + + + + + + + - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - - - - + - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - + + + + + + + + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - - - - - - - - - - - - - - - - - - - - - + + - (includeSelf: false)?.ContextFlyout?.ShowAt(button); + } + + private void OnRowMenuOpening(object? sender, EventArgs e) + { + if (sender is not MenuFlyout flyout || DataContext is not CameraLibraryPageViewModel vm) return; + _menuRow = flyout.Target?.DataContext as CameraRowViewModel; + if (_menuRow is not { } row) return; + + foreach (var item in flyout.Items.OfType()) + { + if (Equals(item.Tag, "files")) + item.IsVisible = vm.IsFileManagerEnabled; + else if (Equals(item.Tag, "layouts")) + FillLayoutsSubmenu(item, vm, row); + } + } + + private static void FillLayoutsSubmenu(MenuItem parent, CameraLibraryPageViewModel vm, CameraRowViewModel row) + { + parent.Items.Clear(); + foreach (var layout in vm.AllLayouts) + { + var item = new MenuItem + { + Header = layout.Name, + ToggleType = MenuItemToggleType.CheckBox, + IsChecked = row.IsInLayout(layout.Id), + }; + item.Click += async (_, _) => await vm.ToggleLayoutMembershipAsync(row, layout); + parent.Items.Add(item); + } + parent.IsEnabled = parent.Items.Count > 0; + } + + private async void OnRowEditClick(object? sender, RoutedEventArgs e) + => await RunOnMenuRowAsync((vm, row) => vm.EditCameraCommand.ExecuteAsync(row)); + + private async void OnRowDeviceClick(object? sender, RoutedEventArgs e) + => await RunOnMenuRowAsync((vm, row) => vm.OpenFirmwareCommand.ExecuteAsync(row)); + + private async void OnRowFilesClick(object? sender, RoutedEventArgs e) + => await RunOnMenuRowAsync((vm, row) => vm.OpenFileManagerCommand.ExecuteAsync(row)); + + private async void OnRowDeleteClick(object? sender, RoutedEventArgs e) + => await RunOnMenuRowAsync((vm, row) => vm.DeleteCameraCommand.ExecuteAsync(row)); + + private async Task RunOnMenuRowAsync(Func action) + { + try + { + if (DataContext is not CameraLibraryPageViewModel vm || _menuRow is not { } row) return; + _menuRow = null; + await action(vm, row); + } + catch (Exception ex) + { + Trace.WriteLine($"[CameraLibraryPage] row menu action failed: {ex}"); + } } private void OnCameraCardTapped(object? sender, TappedEventArgs e) diff --git a/src/OpenIPC.Viewer.App/Views/Pages/EventsPage.axaml b/src/OpenIPC.Viewer.App/Views/Pages/EventsPage.axaml index 336135c..b64f2d2 100644 --- a/src/OpenIPC.Viewer.App/Views/Pages/EventsPage.axaml +++ b/src/OpenIPC.Viewer.App/Views/Pages/EventsPage.axaml @@ -2,101 +2,308 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:vm="using:OpenIPC.Viewer.App.ViewModels" xmlns:svc="using:OpenIPC.Viewer.App.Services" + xmlns:conv="using:OpenIPC.Viewer.App.Converters" + xmlns:controls="using:OpenIPC.Viewer.App.Controls" x:Class="OpenIPC.Viewer.App.Views.Pages.EventsPage" x:DataType="vm:EventsPageViewModel"> - + + + + + + + + - - + + + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - - - - - - - - - - - - - - - - - + + - + + - - + + + + - + + + - - - - - - + + + + + + + + + + + + + + + + + + + VerticalAlignment="Center" + ToolTip.Tip="{Binding [Grid.Tooltip.StillsInterval], Source={x:Static svc:Localizer.Instance}}"> - + + + + + + + - + - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - + + @@ -239,7 +319,7 @@ Width="28" Height="28" Padding="0" ToolTip.Tip="{Binding [Tile.Snapshot], Source={x:Static svc:Localizer.Instance}}"> + Stroke="White" Width="16" VerticalAlignment="Center" HorizontalAlignment="Center" /> - - + + + + + + + + + + + + + - + + - + - + - - - - - + - + + + + + + - + - - - - + + + + - + - - - + + + + + + + - - - - + + + + - - - - - - - + + + + + + + + + + + + - - - - + + + cursor, click/marker-tap to seek, drag to select a clip (16.4). --> - - - - + + + + + + + + - + + + + + - - + + + + + + - - - + + + + + + + + + + + + - + - + - - + + + - + + + + + + + + + - + + - @@ -172,7 +315,7 @@ - @@ -182,8 +325,7 @@ HorizontalAlignment="Center" /> - - + diff --git a/src/OpenIPC.Viewer.App/Views/Pages/RecordingsPage.axaml.cs b/src/OpenIPC.Viewer.App/Views/Pages/RecordingsPage.axaml.cs index aa5dc87..4ad4e14 100644 --- a/src/OpenIPC.Viewer.App/Views/Pages/RecordingsPage.axaml.cs +++ b/src/OpenIPC.Viewer.App/Views/Pages/RecordingsPage.axaml.cs @@ -1,18 +1,28 @@ using System; +using System.ComponentModel; using System.Diagnostics; +using System.Linq; using System.Threading; +using System.Threading.Tasks; +using Avalonia; using Avalonia.Controls; +using Avalonia.Input; using Avalonia.Interactivity; +using Avalonia.VisualTree; using OpenIPC.Viewer.App.ViewModels; namespace OpenIPC.Viewer.App.Views.Pages; public sealed partial class RecordingsPage : UserControl { + private RecordingsPageViewModel? _vm; + private RecordingRowViewModel? _menuRow; + public RecordingsPage() { InitializeComponent(); Loaded += OnLoaded; + DataContextChanged += OnDataContextChanged; } private async void OnLoaded(object? sender, RoutedEventArgs e) @@ -27,4 +37,66 @@ private async void OnLoaded(object? sender, RoutedEventArgs e) Trace.WriteLine($"[RecordingsPage] OnLoaded failed: {ex}"); } } + + // A new page of rows starts at the top of the list. + private void OnDataContextChanged(object? sender, EventArgs e) + { + if (_vm is not null) _vm.PropertyChanged -= OnVmPropertyChanged; + _vm = DataContext as RecordingsPageViewModel; + if (_vm is not null) _vm.PropertyChanged += OnVmPropertyChanged; + } + + private void OnVmPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(RecordingsPageViewModel.CurrentPage)) + ListScroll.Offset = default; + } + + // Clicking a row plays it; clicks on its buttons are theirs. + private void OnRowTapped(object? sender, TappedEventArgs e) + { + if (sender is not Control { DataContext: RecordingRowViewModel row } || _vm is null) return; + for (var v = e.Source as Visual; v is not null && v != sender; v = v.GetVisualParent()) + if (v is Button) return; + _vm.PlayCommand.Execute(row); + } + + // --- Row menu ("⋯", or right-click a row) ----------------------------- + private void OnRowMoreClick(object? sender, RoutedEventArgs e) + { + if (sender is not Control button) return; + button.FindAncestorOfType(includeSelf: false)?.ContextFlyout?.ShowAt(button); + } + + private void OnRowMenuOpening(object? sender, EventArgs e) + { + if (sender is not MenuFlyout flyout) return; + _menuRow = flyout.Target?.DataContext as RecordingRowViewModel; + foreach (var item in flyout.Items.OfType()) + if (Equals(item.Tag, "folder")) + item.IsVisible = _vm?.CanShowInFolder == true; + } + + private async void OnShowInFolderClick(object? sender, RoutedEventArgs e) + => await RunOnMenuRowAsync((vm, row) => vm.ShowInFolderCommand.ExecuteAsync(row)); + + private async void OnCopyFileClick(object? sender, RoutedEventArgs e) + => await RunOnMenuRowAsync((vm, row) => vm.CopyFileCommand.ExecuteAsync(row)); + + private async void OnDeleteClick(object? sender, RoutedEventArgs e) + => await RunOnMenuRowAsync((vm, row) => vm.DeleteCommand.ExecuteAsync(row)); + + private async Task RunOnMenuRowAsync(Func action) + { + try + { + if (_vm is not { } vm || _menuRow is not { } row) return; + _menuRow = null; + await action(vm, row); + } + catch (Exception ex) + { + Trace.WriteLine($"[RecordingsPage] row menu action failed: {ex}"); + } + } } diff --git a/src/OpenIPC.Viewer.App/Views/Pages/SingleCameraPage.axaml b/src/OpenIPC.Viewer.App/Views/Pages/SingleCameraPage.axaml index db5946e..8b72283 100644 --- a/src/OpenIPC.Viewer.App/Views/Pages/SingleCameraPage.axaml +++ b/src/OpenIPC.Viewer.App/Views/Pages/SingleCameraPage.axaml @@ -738,10 +738,10 @@ ToolTip.Tip="{Binding [CameraPage.Tooltip.Mute], Source={x:Static svc:Localizer.Instance}}"> diff --git a/src/OpenIPC.Viewer.Composition/SharedComposition.cs b/src/OpenIPC.Viewer.Composition/SharedComposition.cs index 683e77d..80921fb 100644 --- a/src/OpenIPC.Viewer.Composition/SharedComposition.cs +++ b/src/OpenIPC.Viewer.Composition/SharedComposition.cs @@ -192,7 +192,9 @@ public static IServiceCollection AddSharedServices(this IServiceCollection servi services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/OpenIPC.Viewer.Core/Snapshots/ISnapshotService.cs b/src/OpenIPC.Viewer.Core/Snapshots/ISnapshotService.cs index 7d8d7d3..d3776a2 100644 --- a/src/OpenIPC.Viewer.Core/Snapshots/ISnapshotService.cs +++ b/src/OpenIPC.Viewer.Core/Snapshots/ISnapshotService.cs @@ -1,3 +1,4 @@ +using System; using System.Threading; using System.Threading.Tasks; using OpenIPC.Viewer.Core.Entities; @@ -30,5 +31,11 @@ Task CaptureAsync( /// the result as a new *_edited.jpg copy (the original is untouched), /// generating a thumbnail and a DB row. Returns the new snapshot. /// + /// + /// Saves an already-encoded JPEG frame (e.g. from the archive player) as a + /// snapshot of taken at . + /// + Task SaveFrameAsync(CameraId cameraId, byte[] jpeg, DateTime takenAtUtc, CancellationToken ct); + Task SaveEditAsync(Snapshot source, SnapshotEdit edit, CancellationToken ct); } diff --git a/src/OpenIPC.Viewer.Core/Snapshots/SnapshotService.cs b/src/OpenIPC.Viewer.Core/Snapshots/SnapshotService.cs index 01ab39b..8d75075 100644 --- a/src/OpenIPC.Viewer.Core/Snapshots/SnapshotService.cs +++ b/src/OpenIPC.Viewer.Core/Snapshots/SnapshotService.cs @@ -85,9 +85,12 @@ 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); + return await SaveAsync(camera.Id, jpeg, source, SnapshotKind.Manual, DateTime.UtcNow, ct).ConfigureAwait(false); } + public Task SaveFrameAsync(CameraId cameraId, byte[] jpeg, DateTime takenAtUtc, CancellationToken ct) => + SaveAsync(cameraId, jpeg, SnapshotSource.Recording, SnapshotKind.Manual, takenAtUtc, ct); + private async Task<(byte[] Jpeg, SnapshotSource Source)> GrabAsync( Camera camera, IVideoSession? liveSession, StreamQuality? liveQuality, CancellationToken ct) { @@ -187,12 +190,11 @@ public async Task CaptureAsync( } private async Task SaveAsync( - Camera camera, byte[] jpeg, SnapshotSource source, SnapshotKind kind, CancellationToken ct) + CameraId cameraId, byte[] jpeg, SnapshotSource source, SnapshotKind kind, DateTime takenAt, CancellationToken ct) { var id = SnapshotId.New(); - var takenAt = DateTime.UtcNow; - var dir = Path.Combine(_fs.SnapshotsDir.FullName, camera.Id.ToString()); + var dir = Path.Combine(_fs.SnapshotsDir.FullName, cameraId.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)); @@ -215,7 +217,7 @@ private async Task SaveAsync( savedThumb = null; } - var snapshot = new Snapshot(id, camera.Id, takenAt, path, savedThumb, size.Width, size.Height, source, kind); + var snapshot = new Snapshot(id, cameraId, takenAt, path, savedThumb, size.Width, size.Height, source, kind); await _repo.AddAsync(snapshot, ct).ConfigureAwait(false); return snapshot; } diff --git a/src/OpenIPC.Viewer.Core/Snapshots/SnapshotSource.cs b/src/OpenIPC.Viewer.Core/Snapshots/SnapshotSource.cs index 866b82a..a2e27fc 100644 --- a/src/OpenIPC.Viewer.Core/Snapshots/SnapshotSource.cs +++ b/src/OpenIPC.Viewer.Core/Snapshots/SnapshotSource.cs @@ -20,4 +20,7 @@ public enum SnapshotSource /// Produced by the in-app editor (a saved copy). Edited = 4, + + /// A frame grabbed from a recording in the archive player. + Recording = 5, } diff --git a/src/OpenIPC.Viewer.Core/Video/IMediaProbe.cs b/src/OpenIPC.Viewer.Core/Video/IMediaProbe.cs index e49bddd..122e76c 100644 --- a/src/OpenIPC.Viewer.Core/Video/IMediaProbe.cs +++ b/src/OpenIPC.Viewer.Core/Video/IMediaProbe.cs @@ -16,4 +16,5 @@ public readonly record struct MediaInfo( TimeSpan Duration, string? Codec, int Width, - int Height); + int Height, + bool HasAudio = false); diff --git a/src/OpenIPC.Viewer.Core/Video/IPlaybackSession.cs b/src/OpenIPC.Viewer.Core/Video/IPlaybackSession.cs index de031c8..49d2bf9 100644 --- a/src/OpenIPC.Viewer.Core/Video/IPlaybackSession.cs +++ b/src/OpenIPC.Viewer.Core/Video/IPlaybackSession.cs @@ -22,6 +22,10 @@ public interface IPlaybackSession : IVideoSession bool IsPaused { get; } + // Playback speed multiplier (1 = real time). Presentation re-anchors on + // change so the playhead doesn't jump. + double Rate { get; set; } + IObservable PositionChanged { get; } void Play(); @@ -30,4 +34,9 @@ public interface IPlaybackSession : IVideoSession // Seek to the nearest keyframe at or before the requested position, then // decode forward to it. Idempotent while seeking is in flight (latest wins). Task SeekAsync(TimeSpan position, CancellationToken ct); + + // Pause and move exactly one frame. Forward decodes the next frame; backward + // re-seeks to the previous one (keyframe + decode forward), so it costs up + // to a GOP of decoding. + void StepFrame(bool forward); } diff --git a/src/OpenIPC.Viewer.Infrastructure/Net/TcpReachabilityProbe.cs b/src/OpenIPC.Viewer.Infrastructure/Net/TcpReachabilityProbe.cs index cf32297..b413ced 100644 --- a/src/OpenIPC.Viewer.Infrastructure/Net/TcpReachabilityProbe.cs +++ b/src/OpenIPC.Viewer.Infrastructure/Net/TcpReachabilityProbe.cs @@ -1,4 +1,5 @@ using System; +using System.Net; using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; @@ -18,13 +19,31 @@ public async Task IsReachableAsync(string host, int port, TimeSpan timeout if (string.IsNullOrWhiteSpace(host) || port < 1 || port > 65535) return false; - using var client = new TcpClient(); using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct); linked.CancelAfter(timeout); try { - await client.ConnectAsync(host, port, linked.Token).ConfigureAwait(false); - return client.Connected; + // Connect with a socket of each address's own family. A bare + // TcpClient() is a dual-mode IPv6 socket, and on hosts with IPv6 + // disabled (or a VPN that drops it) connecting that to an IPv4 + // camera fails with WSAEADDRNOTAVAIL — every camera read offline. + var addresses = IPAddress.TryParse(host, out var literal) + ? new[] { literal } + : await Dns.GetHostAddressesAsync(host, linked.Token).ConfigureAwait(false); + foreach (var address in addresses) + { + using var client = new TcpClient(address.AddressFamily); + try + { + await client.ConnectAsync(address, port, linked.Token).ConfigureAwait(false); + if (client.Connected) return true; + } + catch (SocketException) + { + // Try the next address (e.g. AAAA then A). + } + } + return false; } catch (OperationCanceledException) when (!ct.IsCancellationRequested) { @@ -32,7 +51,6 @@ public async Task IsReachableAsync(string host, int port, TimeSpan timeout } catch (Exception) { - // SocketException (refused / unreachable), DNS resolution failure, etc. return false; } } diff --git a/src/OpenIPC.Viewer.Infrastructure/Persistence/SqliteConfigBackupService.cs b/src/OpenIPC.Viewer.Infrastructure/Persistence/SqliteConfigBackupService.cs index 5cc40f5..0e49a9d 100644 --- a/src/OpenIPC.Viewer.Infrastructure/Persistence/SqliteConfigBackupService.cs +++ b/src/OpenIPC.Viewer.Infrastructure/Persistence/SqliteConfigBackupService.cs @@ -12,7 +12,7 @@ namespace OpenIPC.Viewer.Infrastructure.Persistence; // JSON config export/import (Phase 19.2). Cameras round-trip by GUID (upsert); -// layouts are appended with their tile membership. Passwords are excluded by +// layouts upsert by name with their tile membership. Passwords are excluded by // default — they live in ISecretsStore — but the network-sync flow (Phase 20) // can opt in to carry them ENCRYPTED with a fleet passphrase (see CredentialCipher). public sealed class SqliteConfigBackupService : IConfigBackupService @@ -145,16 +145,33 @@ public async Task ImportAsync(string json, CancellationToke // Only keep tiles for cameras that now exist (avoid orphan rows). var present = (await _cameras.GetAllAsync(ct).ConfigureAwait(false)).Select(c => c.Id).ToHashSet(); + // A layout whose name already exists is updated in place, so re-importing + // a backup (or importing onto a fresh install with its own "Default") + // doesn't duplicate tabs. + var byName = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var existing in await _layouts.GetAllAsync(ct).ConfigureAwait(false)) + byName.TryAdd(existing.Name, existing); foreach (var l in root.Layouts) { - var newId = await _layouts.AddAsync(l.Name, l.GridSize, l.SortOrder, ct).ConfigureAwait(false); + LayoutId layoutId; + if (byName.TryGetValue(l.Name, out var match)) + { + layoutId = match.Id; + if (match.GridSize != l.GridSize) + await _layouts.SetGridSizeAsync(layoutId, l.GridSize, ct).ConfigureAwait(false); + } + else + { + layoutId = await _layouts.AddAsync(l.Name, l.GridSize, l.SortOrder, ct).ConfigureAwait(false); + byName[l.Name] = new GridLayout(layoutId, l.Name, l.GridSize, l.SortOrder); + } var tileIds = l.Tiles .Select(s => Guid.TryParse(s, out var g) ? new CameraId(g) : (CameraId?)null) .Where(c => c is { } cid && present.Contains(cid)) .Select(c => c!.Value) .ToList(); if (tileIds.Count > 0) - await _layouts.SetTilesAsync(newId, tileIds, ct).ConfigureAwait(false); + await _layouts.SetTilesAsync(layoutId, tileIds, ct).ConfigureAwait(false); } return new ConfigImportPreview(added, updated, root.Layouts.Count); diff --git a/src/OpenIPC.Viewer.Video/Pipeline/FfmpegAudioDecoder.cs b/src/OpenIPC.Viewer.Video/Pipeline/FfmpegAudioDecoder.cs new file mode 100644 index 0000000..be5212f --- /dev/null +++ b/src/OpenIPC.Viewer.Video/Pipeline/FfmpegAudioDecoder.cs @@ -0,0 +1,203 @@ +using System; +using System.Runtime.InteropServices; +using System.Threading; +using FFmpeg.AutoGen.Abstractions; +using Microsoft.Extensions.Logging; +using OpenIPC.Viewer.Core.Video; + +namespace OpenIPC.Viewer.Video.Pipeline; + +// Decodes a container's first audio stream to the one PCM format every sink +// consumes (Phase 17.1): signed-16 interleaved, stereo, 48 kHz. swresample +// does the heavy lifting; the native sink (WASAPI etc.) only converts to the +// device mix format. Shared by the live RTSP session and the file player. +// Not thread-safe — owned and driven by a single decode thread. +internal sealed unsafe class FfmpegAudioDecoder : IDisposable +{ + public const int OutSampleRate = 48000; + public const int OutChannels = 2; + + private readonly ILogger _logger; + private AVCodecContext* _ctx; + private SwrContext* _swr; + private AVFrame* _frame; + + private FfmpegAudioDecoder(int streamIndex, double timeBase, AVCodecContext* ctx, SwrContext* swr, ILogger logger) + { + StreamIndex = streamIndex; + TimeBase = timeBase; + _ctx = ctx; + _swr = swr; + _frame = ffmpeg.av_frame_alloc(); + _logger = logger; + } + + public int StreamIndex { get; } + + // Seconds per PTS unit of the audio stream. + public double TimeBase { get; } + + // Finds the first audio stream and opens its decoder + resampler. Returns + // null if there is no audio or setup fails (callers treat both as "no audio"). + public static FfmpegAudioDecoder? TryOpen(AVFormatContext* fmtCtx, ILogger logger) + { + var idx = -1; + for (var i = 0; i < (int)fmtCtx->nb_streams; i++) + { + if (fmtCtx->streams[i]->codecpar->codec_type == AVMediaType.AVMEDIA_TYPE_AUDIO) + { + idx = i; + break; + } + } + if (idx < 0) return null; + + var stream = fmtCtx->streams[idx]; + var codecpar = stream->codecpar; + var codec = ffmpeg.avcodec_find_decoder(codecpar->codec_id); + if (codec == null) + { + logger.LogWarning("No audio decoder for codec id {Id}", codecpar->codec_id); + return null; + } + + var ctx = ffmpeg.avcodec_alloc_context3(codec); + var ret = ffmpeg.avcodec_parameters_to_context(ctx, codecpar); + if (ret < 0) + { + logger.LogWarning("audio avcodec_parameters_to_context failed: {Err}", FfmpegError.Describe(ret)); + ffmpeg.avcodec_free_context(&ctx); + return null; + } + + ret = ffmpeg.avcodec_open2(ctx, codec, null); + if (ret < 0) + { + logger.LogWarning("audio avcodec_open2 failed: {Err}", FfmpegError.Describe(ret)); + ffmpeg.avcodec_free_context(&ctx); + return null; + } + + // Input layout: trust the decoder's if it knows it, else assume mono. + AVChannelLayout defaultIn = default; + AVChannelLayout* inLayout; + if (ctx->ch_layout.nb_channels > 0) + { + inLayout = &ctx->ch_layout; + } + else + { + ffmpeg.av_channel_layout_default(&defaultIn, 1); + inLayout = &defaultIn; + } + + AVChannelLayout outLayout = default; + ffmpeg.av_channel_layout_default(&outLayout, OutChannels); + + SwrContext* swr = null; + ret = ffmpeg.swr_alloc_set_opts2( + &swr, + &outLayout, AVSampleFormat.AV_SAMPLE_FMT_S16, OutSampleRate, + inLayout, ctx->sample_fmt, ctx->sample_rate, + 0, null); + if (ret < 0 || swr == null) + { + logger.LogWarning("swr_alloc_set_opts2 failed: {Err}", FfmpegError.Describe(ret)); + ffmpeg.av_channel_layout_uninit(&outLayout); + ffmpeg.avcodec_free_context(&ctx); + return null; + } + + ret = ffmpeg.swr_init(swr); + ffmpeg.av_channel_layout_uninit(&outLayout); + if (ret < 0) + { + logger.LogWarning("swr_init failed: {Err}", FfmpegError.Describe(ret)); + ffmpeg.swr_free(&swr); + ffmpeg.avcodec_free_context(&ctx); + return null; + } + + var baseName = Marshal.PtrToStringAnsi((IntPtr)codec->name); + logger.LogInformation("Audio decode active: {Codec} {Rate}Hz {Ch}ch → 48kHz stereo S16", + baseName, ctx->sample_rate, ctx->ch_layout.nb_channels); + + return new FfmpegAudioDecoder(idx, ffmpeg.av_q2d(stream->time_base), ctx, swr, logger); + } + + // Decodes one packet and hands every resulting PCM chunk to emit. + public void Decode(AVPacket* packet, Action emit, CancellationToken ct) + { + if (_ctx == null) return; + + var ret = ffmpeg.avcodec_send_packet(_ctx, packet); + if (ret < 0 && ret != ffmpeg.AVERROR(ffmpeg.EAGAIN)) + { + _logger.LogDebug("audio send_packet failed: {Err}", FfmpegError.Describe(ret)); + return; + } + + while (!ct.IsCancellationRequested) + { + ret = ffmpeg.avcodec_receive_frame(_ctx, _frame); + if (ret == ffmpeg.AVERROR(ffmpeg.EAGAIN) || ret == ffmpeg.AVERROR_EOF) + break; + if (ret < 0) + { + _logger.LogDebug("audio receive_frame failed: {Err}", FfmpegError.Describe(ret)); + break; + } + + var pcm = Resample(_frame, _ctx->sample_rate); + ffmpeg.av_frame_unref(_frame); + if (pcm is null) continue; + try + { + emit(new AudioFrame(pcm, OutSampleRate, OutChannels, 0)); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Subscriber threw in audio OnNext"); + } + } + } + + // Drop buffered decoder state after a seek so stale audio isn't played. + public void Flush() + { + if (_ctx != null) ffmpeg.avcodec_flush_buffers(_ctx); + } + + private byte[]? Resample(AVFrame* frame, int inRate) + { + if (inRate <= 0) return null; + + // Worst-case output sample count: queued resampler delay + this frame, + // rescaled to the output rate. + var delay = ffmpeg.swr_get_delay(_swr, inRate); + var maxOut = (int)ffmpeg.av_rescale_rnd(delay + frame->nb_samples, OutSampleRate, inRate, AVRounding.AV_ROUND_UP); + if (maxOut <= 0) return null; + + var pcm = new byte[maxOut * OutChannels * 2]; // S16 = 2 bytes/sample + int produced; + fixed (byte* dst = pcm) + { + var outPlane = dst; + produced = ffmpeg.swr_convert(_swr, &outPlane, maxOut, frame->extended_data, frame->nb_samples); + } + if (produced <= 0) return null; + + var bytes = produced * OutChannels * 2; + if (bytes == pcm.Length) return pcm; + var payload = new byte[bytes]; + Buffer.BlockCopy(pcm, 0, payload, 0, bytes); + return payload; + } + + public void Dispose() + { + if (_swr != null) { var p = _swr; ffmpeg.swr_free(&p); _swr = null; } + if (_frame != null) { var p = _frame; ffmpeg.av_frame_free(&p); _frame = null; } + if (_ctx != null) { var p = _ctx; ffmpeg.avcodec_free_context(&p); _ctx = null; } + } +} diff --git a/src/OpenIPC.Viewer.Video/Pipeline/FfmpegMediaProbe.cs b/src/OpenIPC.Viewer.Video/Pipeline/FfmpegMediaProbe.cs index 07cd1a8..99acd84 100644 --- a/src/OpenIPC.Viewer.Video/Pipeline/FfmpegMediaProbe.cs +++ b/src/OpenIPC.Viewer.Video/Pipeline/FfmpegMediaProbe.cs @@ -42,6 +42,14 @@ private unsafe MediaInfo Probe(string filePath) string? codec = null; int width = 0, height = 0; + var hasAudio = false; + for (var i = 0; i < (int)fmtCtx->nb_streams; i++) + { + var ap = fmtCtx->streams[i]->codecpar; + // Only audio this build can actually decode counts. + if (ap->codec_type == AVMediaType.AVMEDIA_TYPE_AUDIO && ffmpeg.avcodec_find_decoder(ap->codec_id) != null) + hasAudio = true; + } for (var i = 0; i < (int)fmtCtx->nb_streams; i++) { var par = fmtCtx->streams[i]->codecpar; @@ -63,7 +71,7 @@ private unsafe MediaInfo Probe(string filePath) break; } - return new MediaInfo(duration, codec, width, height); + return new MediaInfo(duration, codec, width, height, hasAudio); } finally { diff --git a/src/OpenIPC.Viewer.Video/Pipeline/FfmpegPlaybackSession.cs b/src/OpenIPC.Viewer.Video/Pipeline/FfmpegPlaybackSession.cs index c170179..054d014 100644 --- a/src/OpenIPC.Viewer.Video/Pipeline/FfmpegPlaybackSession.cs +++ b/src/OpenIPC.Viewer.Video/Pipeline/FfmpegPlaybackSession.cs @@ -14,8 +14,9 @@ namespace OpenIPC.Viewer.Video.Pipeline; // Plays a recorded file (Phase 16). Mirrors FfmpegVideoSession's decode/emit // path but for local files: presentation is paced off the file's own PTS so it -// plays at real-time speed, and it adds transport (play/pause), a probed -// Duration, an observable Position, and keyframe Seek. +// plays at real-time (or Rate x) speed, and it adds transport (play/pause), +// a probed Duration, an observable Position, frame-accurate Seek, single-frame +// stepping and audio (decoded only at 1x while playing; no time-stretching). // // Software decode only — a single local playback stream doesn't need the HW // path the live grid relies on, and software decode keeps seeking simple @@ -31,6 +32,7 @@ internal sealed class FfmpegPlaybackSession : IPlaybackSession private readonly Subject _stateChanged = new(); private readonly Subject _telemetry = new(); private readonly Subject _positionChanged = new(); + private readonly Subject _audioFrames = new(); private readonly object _stateLock = new(); private readonly object _snapshotLock = new(); @@ -50,6 +52,19 @@ internal sealed class FfmpegPlaybackSession : IPlaybackSession // drains it before each packet read; SeekAsync just overwrites it. private long _pendingSeekTicks = NoSeek; + // Playback speed, and a flag asking the loop to re-anchor its clock so a + // rate change continues from the current frame instead of jumping. + private double _rate = 1.0; + private volatile bool _rebaseClock; + + // Audio is decoded only when a listener asked for it (SetAudioEnabled). + private volatile bool _audioEnabled; + + // Forward single-frame step request (1 = pending); consumed by the frame + // it presents. Frame duration backs the backward step's seek target. + private int _stepRequested; + private long _frameDurationTicks = TimeSpan.FromSeconds(1 / 25.0).Ticks; + private long _durationTicks; private long _positionTicks; @@ -73,10 +88,8 @@ public FfmpegPlaybackSession(PlaybackOptions options, ILogger Frames => _frames; - // File playback has no live-audio monitoring path (Phase 17 is RTSP only); - // expose an empty stream so the IVideoSession contract is satisfied. - public IObservable AudioFrames { get; } = System.Reactive.Linq.Observable.Empty(); - public void SetAudioEnabled(bool enabled) { /* file playback has no live audio */ } + public IObservable AudioFrames => _audioFrames; + public void SetAudioEnabled(bool enabled) => _audioEnabled = enabled; public IObservable StateChanged => _stateChanged; public IObservable Telemetry => _telemetry; public IObservable PositionChanged => _positionChanged; @@ -85,6 +98,18 @@ public void SetAudioEnabled(bool enabled) { /* file playback has no live audio * public TimeSpan Position => TimeSpan.FromTicks(Interlocked.Read(ref _positionTicks)); public bool IsPaused => _paused; + public double Rate + { + get => Volatile.Read(ref _rate); + set + { + var clamped = Math.Clamp(value, 0.25, 16.0); + if (Volatile.Read(ref _rate) == clamped) return; + Volatile.Write(ref _rate, clamped); + _rebaseClock = true; + } + } + public Task StartAsync(CancellationToken ct) { if (_thread is not null) @@ -139,6 +164,23 @@ public Task SeekAsync(TimeSpan position, CancellationToken ct) return Task.CompletedTask; } + public void StepFrame(bool forward) + { + if (_thread is null) return; + if (!_paused) Pause(); + if (forward) + { + Interlocked.Exchange(ref _stepRequested, 1); + _playGate.Set(); + return; + } + // Aim between the previous frame and the one before it, so PTS jitter + // cannot land back on the current frame. + var back = TimeSpan.FromTicks(Interlocked.Read(ref _frameDurationTicks) * 3 / 2); + var target = Position - back; + _ = SeekAsync(target < TimeSpan.Zero ? TimeSpan.Zero : target, CancellationToken.None); + } + public Task SnapshotAsync(SnapshotFormat format, CancellationToken ct) { byte[]? bgra; @@ -172,6 +214,7 @@ public async ValueTask DisposeAsync() _stateChanged.OnCompleted(); _telemetry.OnCompleted(); _positionChanged.OnCompleted(); + _audioFrames.OnCompleted(); _cts?.Dispose(); _playGate.Dispose(); } @@ -183,6 +226,7 @@ private unsafe void Run() AVFrame* frame = null; AVPacket* packet = null; SwsContext* sws = null; + FfmpegAudioDecoder? audio = null; var videoStreamIndex = -1; var swsSrcPixFmt = AVPixelFormat.AV_PIX_FMT_NONE; double timeBase = 0; @@ -217,6 +261,9 @@ private unsafe void Run() var stream = fmtCtx->streams[videoStreamIndex]; timeBase = ffmpeg.av_q2d(stream->time_base); SetDuration(fmtCtx, stream); + var frameRate = ffmpeg.av_guess_frame_rate(fmtCtx, stream, null); + if (frameRate.num > 0 && frameRate.den > 0) + Interlocked.Exchange(ref _frameDurationTicks, TimeSpan.FromSeconds(frameRate.den / (double)frameRate.num).Ticks); var codecpar = stream->codecpar; var codec = ffmpeg.avcodec_find_decoder(codecpar->codec_id); @@ -236,10 +283,15 @@ private unsafe void Run() packet = ffmpeg.av_packet_alloc(); frame = ffmpeg.av_frame_alloc(); + audio = FfmpegAudioDecoder.TryOpen(fmtCtx, _logger); SetState(SessionState.Playing); var ct = _cts!.Token; + // Decode-and-drop target after a seek, in video PTS units. Lives + // across packets: landing on the exact frame may take a whole GOP. + long seekTargetPts = ffmpeg.AV_NOPTS_VALUE; + while (!ct.IsCancellationRequested) { // Park while paused (still serves seeks: SeekAsync sets the gate). @@ -252,7 +304,6 @@ private unsafe void Run() } var seek = Interlocked.Exchange(ref _pendingSeekTicks, NoSeek); - long seekTargetPts = ffmpeg.AV_NOPTS_VALUE; if (seek != NoSeek) { var target = TimeSpan.FromTicks(seek); @@ -261,6 +312,7 @@ private unsafe void Run() if (ret < 0) _logger.LogWarning("avformat_seek_file failed: {Err}", FfmpegError.Describe(ret)); ffmpeg.avcodec_flush_buffers(codecCtx); + audio?.Flush(); haveClock = false; seekTargetPts = ts; // decode-and-drop until we reach the requested point // After a seek we present one frame even if paused, so the UI @@ -275,6 +327,7 @@ private unsafe void Run() // Hold at end-of-file: freeze on the last frame and park // until a seek (or dispose) arrives. SetPosition(Duration); + seekTargetPts = ffmpeg.AV_NOPTS_VALUE; _paused = true; _playGate.Reset(); SetState(SessionState.Paused); @@ -284,6 +337,16 @@ private unsafe void Run() break; } + if (audio is not null && packet->stream_index == audio.StreamIndex) + { + // Sound only in plain 1x playback: not while paused/stepping, + // catching up to a seek target, or at another speed. + if (_audioEnabled && !_paused && Rate == 1.0 && seekTargetPts == ffmpeg.AV_NOPTS_VALUE) + audio.Decode(packet, _audioFrames.OnNext, ct); + ffmpeg.av_packet_unref(packet); + continue; + } + if (packet->stream_index != videoStreamIndex) { ffmpeg.av_packet_unref(packet); @@ -345,8 +408,10 @@ private unsafe void Run() SetPosition(TimeSpan.FromSeconds(mediaSec)); ffmpeg.av_frame_unref(frame); - // A seek while paused presents exactly one frame, then re-parks. - if (justSeeked && _paused) + // A seek or a step while paused presents exactly one frame, + // then re-parks. + var stepped = Interlocked.Exchange(ref _stepRequested, 0) == 1; + if ((justSeeked || stepped) && _paused) { _playGate.Reset(); break; @@ -363,6 +428,7 @@ private unsafe void Run() finally { if (sws != null) ffmpeg.sws_freeContext(sws); + audio?.Dispose(); if (frame != null) { var p = frame; ffmpeg.av_frame_free(&p); } if (packet != null) { var p = packet; ffmpeg.av_packet_free(&p); } if (codecCtx != null) { var p = codecCtx; ffmpeg.avcodec_free_context(&p); } @@ -386,6 +452,12 @@ private unsafe void SetDuration(AVFormatContext* fmtCtx, AVStream* stream) private void PaceTo(Stopwatch clock, ref bool haveClock, ref double wallOriginSec, ref double mediaOriginSec, double mediaSec, CancellationToken ct) { + if (_rebaseClock) + { + _rebaseClock = false; + haveClock = false; + } + if (!haveClock) { wallOriginSec = clock.Elapsed.TotalSeconds; @@ -394,13 +466,14 @@ private void PaceTo(Stopwatch clock, ref bool haveClock, ref double wallOriginSe return; } - var targetWall = wallOriginSec + (mediaSec - mediaOriginSec); + var targetWall = wallOriginSec + (mediaSec - mediaOriginSec) / Rate; while (!ct.IsCancellationRequested) { var remaining = targetWall - clock.Elapsed.TotalSeconds; if (remaining <= 0.001) break; - // Bail out early if a seek arrives so scrubbing stays responsive. - if (Interlocked.Read(ref _pendingSeekTicks) != NoSeek) break; + // Bail out early if a seek or a speed change arrives so scrubbing + // and speed switching stay responsive. + if (Interlocked.Read(ref _pendingSeekTicks) != NoSeek || _rebaseClock) break; var ms = (int)Math.Min(remaining * 1000, 50); if (ms > 0) Thread.Sleep(ms); } diff --git a/src/OpenIPC.Viewer.Video/Pipeline/FfmpegVideoSession.cs b/src/OpenIPC.Viewer.Video/Pipeline/FfmpegVideoSession.cs index ede7a87..60544cf 100644 --- a/src/OpenIPC.Viewer.Video/Pipeline/FfmpegVideoSession.cs +++ b/src/OpenIPC.Viewer.Video/Pipeline/FfmpegVideoSession.cs @@ -23,12 +23,6 @@ internal sealed class FfmpegVideoSession : IVideoSession private readonly Subject _stateChanged = new(); private readonly Subject _telemetry = new(); - // Audio output is normalized to this format for every camera (Phase 17.1): - // signed-16 interleaved, stereo, 48 kHz. swresample does the heavy lifting; - // the native sink (WASAPI etc.) only converts to the device mix format. - private const int AudioOutSampleRate = 48000; - private const int AudioOutChannels = 2; - private readonly object _stateLock = new(); private readonly object _snapshotLock = new(); @@ -181,11 +175,8 @@ private unsafe void Run() SwsContext* sws = null; AVDictionary* opts = null; AVBufferRef* hwDeviceCtx = null; - AVCodecContext* audioCtx = null; - AVFrame* audioFrame = null; - SwrContext* swr = null; + FfmpegAudioDecoder? audio = null; var videoStreamIndex = -1; - var audioStreamIndex = -1; var audioProbedNoTrack = false; var swsSrcPixFmt = AVPixelFormat.AV_PIX_FMT_NONE; var hwActive = false; @@ -268,21 +259,19 @@ private unsafe void Run() // Lazily spin the audio decoder up/down to track SetAudioEnabled. // Done on the decode thread only, so audioCtx/swr are never raced. // A camera with no audio track is probed once and left alone. - if (_audioEnabled && audioStreamIndex < 0 && !audioProbedNoTrack) + if (_audioEnabled && audio is null && !audioProbedNoTrack) { - audioStreamIndex = SetupAudio(fmtCtx, &audioCtx, &swr, &audioFrame); - if (audioStreamIndex < 0) + audio = FfmpegAudioDecoder.TryOpen(fmtCtx, _logger); + if (audio is null) { audioProbedNoTrack = true; _logger.LogInformation("No audio track for {Host}", _options.RtspUri.Host); } } - else if (!_audioEnabled && audioStreamIndex >= 0) + else if (!_audioEnabled && audio is not null) { - if (swr != null) { var p = swr; ffmpeg.swr_free(&p); swr = null; } - if (audioFrame != null) { var p = audioFrame; ffmpeg.av_frame_free(&p); audioFrame = null; } - if (audioCtx != null) { var p = audioCtx; ffmpeg.avcodec_free_context(&p); audioCtx = null; } - audioStreamIndex = -1; + audio.Dispose(); + audio = null; audioProbedNoTrack = false; // a later re-enable should retry setup } @@ -298,9 +287,9 @@ private unsafe void Run() break; } - if (packet->stream_index == audioStreamIndex) + if (audio is not null && packet->stream_index == audio.StreamIndex) { - DecodeAudio(audioCtx, swr, audioFrame, packet, ct); + audio.Decode(packet, _audioFrames.OnNext, ct); ffmpeg.av_packet_unref(packet); continue; } @@ -377,13 +366,11 @@ private unsafe void Run() finally { if (sws != null) ffmpeg.sws_freeContext(sws); - if (swr != null) { var p = swr; ffmpeg.swr_free(&p); } + audio?.Dispose(); if (frame != null) { var p = frame; ffmpeg.av_frame_free(&p); } if (swFrame != null) { var p = swFrame; ffmpeg.av_frame_free(&p); } - if (audioFrame != null) { var p = audioFrame; ffmpeg.av_frame_free(&p); } if (packet != null) { var p = packet; ffmpeg.av_packet_free(&p); } if (codecCtx != null) { var p = codecCtx; ffmpeg.avcodec_free_context(&p); } - if (audioCtx != null) { var p = audioCtx; ffmpeg.avcodec_free_context(&p); } if (hwDeviceCtx != null) { var p = hwDeviceCtx; ffmpeg.av_buffer_unref(&p); } if (fmtCtx != null) ffmpeg.avformat_close_input(&fmtCtx); if (opts != null) ffmpeg.av_dict_free(&opts); @@ -489,162 +476,6 @@ private unsafe void EmitFrame(SwsContext* sws, AVFrame* frame) } } - // Finds the first audio stream, opens its decoder and a swresample context - // that normalizes to S16/48 kHz/stereo. Returns the stream index, or -1 if - // there is no audio or setup fails (caller treats both as "no audio"). - private unsafe int SetupAudio(AVFormatContext* fmtCtx, AVCodecContext** outCtx, SwrContext** outSwr, AVFrame** outFrame) - { - var idx = -1; - for (var i = 0; i < (int)fmtCtx->nb_streams; i++) - { - if (fmtCtx->streams[i]->codecpar->codec_type == AVMediaType.AVMEDIA_TYPE_AUDIO) - { - idx = i; - break; - } - } - if (idx < 0) return -1; - - var codecpar = fmtCtx->streams[idx]->codecpar; - var codec = ffmpeg.avcodec_find_decoder(codecpar->codec_id); - if (codec == null) - { - _logger.LogWarning("No audio decoder for codec id {Id}", codecpar->codec_id); - return -1; - } - - var ctx = ffmpeg.avcodec_alloc_context3(codec); - var ret = ffmpeg.avcodec_parameters_to_context(ctx, codecpar); - if (ret < 0) - { - _logger.LogWarning("audio avcodec_parameters_to_context failed: {Err}", FfmpegError.Describe(ret)); - ffmpeg.avcodec_free_context(&ctx); - return -1; - } - - ret = ffmpeg.avcodec_open2(ctx, codec, null); - if (ret < 0) - { - _logger.LogWarning("audio avcodec_open2 failed: {Err}", FfmpegError.Describe(ret)); - ffmpeg.avcodec_free_context(&ctx); - return -1; - } - - // Input layout: trust the decoder's if it knows it, else assume mono. - AVChannelLayout defaultIn = default; - AVChannelLayout* inLayout; - if (ctx->ch_layout.nb_channels > 0) - { - inLayout = &ctx->ch_layout; - } - else - { - ffmpeg.av_channel_layout_default(&defaultIn, 1); - inLayout = &defaultIn; - } - - AVChannelLayout outLayout = default; - ffmpeg.av_channel_layout_default(&outLayout, AudioOutChannels); - - SwrContext* swr = null; - ret = ffmpeg.swr_alloc_set_opts2( - &swr, - &outLayout, AVSampleFormat.AV_SAMPLE_FMT_S16, AudioOutSampleRate, - inLayout, ctx->sample_fmt, ctx->sample_rate, - 0, null); - if (ret < 0 || swr == null) - { - _logger.LogWarning("swr_alloc_set_opts2 failed: {Err}", FfmpegError.Describe(ret)); - ffmpeg.av_channel_layout_uninit(&outLayout); - ffmpeg.avcodec_free_context(&ctx); - return -1; - } - - ret = ffmpeg.swr_init(swr); - ffmpeg.av_channel_layout_uninit(&outLayout); - if (ret < 0) - { - _logger.LogWarning("swr_init failed: {Err}", FfmpegError.Describe(ret)); - ffmpeg.swr_free(&swr); - ffmpeg.avcodec_free_context(&ctx); - return -1; - } - - var baseName = Marshal.PtrToStringAnsi((IntPtr)codec->name); - _logger.LogInformation("Audio decode active: {Codec} {Rate}Hz {Ch}ch → 48kHz stereo S16", - baseName, ctx->sample_rate, ctx->ch_layout.nb_channels); - - *outCtx = ctx; - *outSwr = swr; - *outFrame = ffmpeg.av_frame_alloc(); - return idx; - } - - private unsafe void DecodeAudio(AVCodecContext* ctx, SwrContext* swr, AVFrame* frame, AVPacket* packet, CancellationToken ct) - { - if (ctx == null || swr == null || frame == null) return; - - var ret = ffmpeg.avcodec_send_packet(ctx, packet); - if (ret < 0 && ret != ffmpeg.AVERROR(ffmpeg.EAGAIN)) - { - _logger.LogDebug("audio send_packet failed: {Err}", FfmpegError.Describe(ret)); - return; - } - - while (!ct.IsCancellationRequested) - { - ret = ffmpeg.avcodec_receive_frame(ctx, frame); - if (ret == ffmpeg.AVERROR(ffmpeg.EAGAIN) || ret == ffmpeg.AVERROR_EOF) - break; - if (ret < 0) - { - _logger.LogDebug("audio receive_frame failed: {Err}", FfmpegError.Describe(ret)); - break; - } - - EmitAudioFrame(swr, frame, ctx->sample_rate); - ffmpeg.av_frame_unref(frame); - } - } - - private unsafe void EmitAudioFrame(SwrContext* swr, AVFrame* frame, int inRate) - { - if (inRate <= 0) return; - - // Worst-case output sample count: queued resampler delay + this frame, - // rescaled to the output rate. - var delay = ffmpeg.swr_get_delay(swr, inRate); - var maxOut = (int)ffmpeg.av_rescale_rnd(delay + frame->nb_samples, AudioOutSampleRate, inRate, AVRounding.AV_ROUND_UP); - if (maxOut <= 0) return; - - var pcm = new byte[maxOut * AudioOutChannels * 2]; // S16 = 2 bytes/sample - int produced; - fixed (byte* dst = pcm) - { - var outPlane = dst; - produced = ffmpeg.swr_convert(swr, &outPlane, maxOut, frame->extended_data, frame->nb_samples); - } - if (produced <= 0) return; - - var bytes = produced * AudioOutChannels * 2; - var payload = pcm; - if (bytes != pcm.Length) - { - payload = new byte[bytes]; - Buffer.BlockCopy(pcm, 0, payload, 0, bytes); - } - - var af = new AudioFrame(payload, AudioOutSampleRate, AudioOutChannels, 0); - try - { - _audioFrames.OnNext(af); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Subscriber threw in audio OnNext"); - } - } - private void UpdateSnapshotBuffer(byte[] sourceBgra, int stride) { var size = stride * _height; diff --git a/tests/OpenIPC.Viewer.Infrastructure.Tests/SqliteConfigBackupServiceTests.cs b/tests/OpenIPC.Viewer.Infrastructure.Tests/SqliteConfigBackupServiceTests.cs index 0c252c6..a999771 100644 --- a/tests/OpenIPC.Viewer.Infrastructure.Tests/SqliteConfigBackupServiceTests.cs +++ b/tests/OpenIPC.Viewer.Infrastructure.Tests/SqliteConfigBackupServiceTests.cs @@ -68,6 +68,29 @@ public async Task Export_Import_RoundTripsCamerasAndLayoutTiles() Assert.Equal(new[] { b, a }, tiles); } + [Fact] + public async Task Import_Twice_DoesNotDuplicateLayouts() + { + var src = await NewDbAsync(); + var a = await src.cams.AddAsync(Cam("A"), CancellationToken.None); + var def = (await src.layouts.GetAllAsync(CancellationToken.None))[0]; + await src.layouts.SetTilesAsync(def.Id, new[] { a }, CancellationToken.None); + await src.layouts.AddAsync("Yard", 3, 1, CancellationToken.None); + var json = await src.backup.ExportAsync(null, CancellationToken.None); + + // A fresh install already has its own "Default"; importing twice must + // merge into it and into the first imported "Yard". + var dst = await NewDbAsync(); + await dst.backup.ImportAsync(json, CancellationToken.None); + await dst.backup.ImportAsync(json, CancellationToken.None); + + var layouts = await dst.layouts.GetAllAsync(CancellationToken.None); + Assert.Equal(new[] { "Default", "Yard" }, layouts.Select(l => l.Name).OrderBy(n => n)); + var merged = layouts.Single(l => l.Name == "Default"); + Assert.Equal(new[] { a }, await dst.layouts.GetTilesAsync(merged.Id, CancellationToken.None)); + Assert.Equal(3, layouts.Single(l => l.Name == "Yard").GridSize); + } + [Fact] public async Task Preview_CountsUpdatesForExistingCameras() {