From bfd50ece3cc70ef46ebb95ed2c08c7a8a26157e3 Mon Sep 17 00:00:00 2001 From: keyldev Date: Sun, 27 Sep 2026 15:18:07 +0300 Subject: [PATCH 01/15] feat(grid): regroup the live toolbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Live | Stills segmented switch replaces the camera toggle - Grid size is one dropdown on the tabs row (per-layout), showing the current shape - Layout actions move into the active tab's menu (… / right-click); + Layout closes the tab row - Center wide Lucide glyphs (camera, volume) on tile and camera-page buttons --- .../Converters/LayoutTabConverters.cs | 60 +++- src/OpenIPC.Viewer.App/Services/Localizer.cs | 14 + src/OpenIPC.Viewer.App/Styles/Buttons.axaml | 60 ++++ src/OpenIPC.Viewer.App/Themes/Theme.axaml | 15 + .../ViewModels/GridPageViewModel.cs | 15 +- .../Views/Pages/GridPage.axaml | 314 +++++++++++------- .../Views/Pages/GridPage.axaml.cs | 47 +++ .../Views/Pages/SingleCameraPage.axaml | 4 +- 8 files changed, 405 insertions(+), 124 deletions(-) diff --git a/src/OpenIPC.Viewer.App/Converters/LayoutTabConverters.cs b/src/OpenIPC.Viewer.App/Converters/LayoutTabConverters.cs index 55694ee..1c4985a 100644 --- a/src/OpenIPC.Viewer.App/Converters/LayoutTabConverters.cs +++ b/src/OpenIPC.Viewer.App/Converters/LayoutTabConverters.cs @@ -16,11 +16,63 @@ 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(); +} + +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/Services/Localizer.cs b/src/OpenIPC.Viewer.App/Services/Localizer.cs index 787533e..bb29b56 100644 --- a/src/OpenIPC.Viewer.App/Services/Localizer.cs +++ b/src/OpenIPC.Viewer.App/Services/Localizer.cs @@ -110,6 +110,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", @@ -529,6 +531,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", @@ -606,6 +613,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"] = "В библиотеке пока нет камер", @@ -1025,6 +1034,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"] = "Язык", diff --git a/src/OpenIPC.Viewer.App/Styles/Buttons.axaml b/src/OpenIPC.Viewer.App/Styles/Buttons.axaml index 04f5bed..c5a87f4 100644 --- a/src/OpenIPC.Viewer.App/Styles/Buttons.axaml +++ b/src/OpenIPC.Viewer.App/Styles/Buttons.axaml @@ -21,4 +21,64 @@ + + + + + + + + + + + + + diff --git a/src/OpenIPC.Viewer.App/Themes/Theme.axaml b/src/OpenIPC.Viewer.App/Themes/Theme.axaml index ff777bf..098f343 100644 --- a/src/OpenIPC.Viewer.App/Themes/Theme.axaml +++ b/src/OpenIPC.Viewer.App/Themes/Theme.axaml @@ -104,4 +104,19 @@ 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 + 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/Views/Pages/GridPage.axaml b/src/OpenIPC.Viewer.App/Views/Pages/GridPage.axaml index 3540986..86ac5f6 100644 --- a/src/OpenIPC.Viewer.App/Views/Pages/GridPage.axaml +++ b/src/OpenIPC.Viewer.App/Views/Pages/GridPage.axaml @@ -5,12 +5,21 @@ xmlns:conv="using:OpenIPC.Viewer.App.Converters" xmlns:svc="using:OpenIPC.Viewer.App.Services" xmlns:input="using:Avalonia.Input" + xmlns:b="using:OpenIPC.Viewer.App.Behaviors" x:Class="OpenIPC.Viewer.App.Views.Pages.GridPage" x:DataType="vm:GridPageViewModel"> - + + + + - + + + - - - - - - + + + + + + + + + + + + + + + + + + + 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" /> + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - - - - - - - - - - - - - - - - - - - - - + + - (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) From b49be8a1d612015b5ce8b57f9eccd77aed59baa9 Mon Sep 17 00:00:00 2001 From: keyldev Date: Sun, 27 Sep 2026 16:20:56 +0300 Subject: [PATCH 05/15] feat(recordings): day-grouped list with filters, pager and a clearer calendar - Camera / period / motion-only filters, day headers with totals, 20 rows per page - Dense rows: play, camera, time range, duration, size, motion / in-progress badges, menu (show in folder, copy file, delete) - Calendar fills recording days by count, dots event-only days, follows the camera/motion filter, localized weekdays - Empty month offers a jump to the nearest month with recordings --- src/OpenIPC.Viewer.App/Services/Localizer.cs | 42 +++ src/OpenIPC.Viewer.App/Styles/Buttons.axaml | 10 +- .../ViewModels/ArchiveCalendarViewModel.cs | 113 +++++- .../ViewModels/RecordingsPageViewModel.cs | 306 +++++++++++++-- .../Views/Pages/ArchiveCalendarView.axaml | 48 ++- .../Views/Pages/CameraLibraryPage.axaml | 2 +- .../Views/Pages/RecordingsPage.axaml | 350 +++++++++++++----- .../Views/Pages/RecordingsPage.axaml.cs | 72 ++++ 8 files changed, 780 insertions(+), 163 deletions(-) diff --git a/src/OpenIPC.Viewer.App/Services/Localizer.cs b/src/OpenIPC.Viewer.App/Services/Localizer.cs index e60c71f..972a012 100644 --- a/src/OpenIPC.Viewer.App/Services/Localizer.cs +++ b/src/OpenIPC.Viewer.App/Services/Localizer.cs @@ -556,6 +556,27 @@ 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.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.MarkIn"] = "Set In", ["Recordings.MarkOut"] = "Set Out", ["Recordings.Precise"] = "Precise", @@ -1070,6 +1091,27 @@ private static LangCode DetectSystem() ["Recordings.Empty"] = "Записей пока нет. Нажмите ● REC на странице камеры, чтобы начать.", ["Recordings.Live"] = "идёт", ["Recordings.LoadError"] = "Не удалось загрузить записи", + ["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.MarkIn"] = "Начало", ["Recordings.MarkOut"] = "Конец", ["Recordings.Precise"] = "Точно", diff --git a/src/OpenIPC.Viewer.App/Styles/Buttons.axaml b/src/OpenIPC.Viewer.App/Styles/Buttons.axaml index d92e16b..a2262cf 100644 --- a/src/OpenIPC.Viewer.App/Styles/Buttons.axaml +++ b/src/OpenIPC.Viewer.App/Styles/Buttons.axaml @@ -63,8 +63,8 @@ - - - - - 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/RecordingsPageViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/RecordingsPageViewModel.cs index af2ae5d..116252f 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,144 @@ 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 ---------------------------------------------------------------- + public ObservableCollection CameraOptions { get; } = new(); + + [ObservableProperty] private RecordingCameraOption? _selectedCamera; + [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 OnSelectedCameraChanged(RecordingCameraOption? value) => OnCameraOrMotionChanged(); + 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 cameraId = SelectedCamera?.Id; + var motionOnly = MotionOnly; + _ = Calendar.SetRecordingFilterAsync(cameraId is null && !motionOnly + ? null + : r => (cameraId is null || r.CameraId == cameraId.Value) && (!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 +198,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, @@ -87,22 +227,60 @@ public RecordingsPageViewModel( Calendar.DaySelected += OnDaySelected; } - 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, + }; + var cameraId = SelectedCamera?.Id; - private void ApplyFilter() - { - Items.Clear(); - foreach (var row in _allRows) + var filtered = _allRows.Where(r => + (cameraId is null || r.Recording.CameraId == cameraId.Value) + && (!MotionOnly || r.HasMotion) + && (since is null || r.StartedAtLocal >= since.Value) + && (_dayFilter is null || r.StartedAtLocal.Date == _dayFilter.Value.Date)) + .ToList(); + + 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] @@ -128,12 +306,13 @@ public async Task LoadAsync(CancellationToken ct) 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(); + RebuildCameraOptions(); + ApplyFilter(resetPage: false); IsLoaded = true; await Calendar.LoadAsync(ct).ConfigureAwait(true); @@ -149,6 +328,24 @@ public async Task LoadAsync(CancellationToken ct) } } + // Only cameras that actually have recordings; keeps the selection by id. + private void RebuildCameraOptions() + { + var keep = SelectedCamera?.Id; + var options = _allRows + .GroupBy(r => r.Recording.CameraId) + .Select(g => new RecordingCameraOption(g.Key, g.First().CameraName)) + .OrderBy(o => o.Name, StringComparer.CurrentCultureIgnoreCase) + .ToList(); + + CameraOptions.Clear(); + CameraOptions.Add(new RecordingCameraOption(null, Localizer.Instance["Snapshots.AllCameras"])); + foreach (var o in options) CameraOptions.Add(o); + + var match = CameraOptions.FirstOrDefault(o => o.Id == keep) ?? CameraOptions[0]; + if (!Equals(SelectedCamera, match)) SelectedCamera = match; + } + [RelayCommand] private Task ReloadAsync() => LoadAsync(CancellationToken.None); @@ -159,6 +356,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 +392,8 @@ 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)); + RebuildCameraOptions(); + ApplyFilter(resetPage: false); } catch (Exception ex) { @@ -187,6 +402,29 @@ private async Task DeleteAsync(RecordingRowViewModel? row) } } +// Entry of the camera filter; Id null = all cameras. +public sealed record RecordingCameraOption(CameraId? Id, string Name); + +// "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 +433,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 +453,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/Pages/ArchiveCalendarView.axaml b/src/OpenIPC.Viewer.App/Views/Pages/ArchiveCalendarView.axaml index 9992e4a..fc42db3 100644 --- a/src/OpenIPC.Viewer.App/Views/Pages/ArchiveCalendarView.axaml +++ b/src/OpenIPC.Viewer.App/Views/Pages/ArchiveCalendarView.axaml @@ -29,6 +29,16 @@ + + + - + crushed into ~80px next to a fixed 240px calendar. Size also drops. --> + + + + + + + - - + + - - + + + + + + + + + + + + - + - + - - + + + - + + + + + + + + + - + + - @@ -172,7 +321,7 @@ - @@ -182,8 +331,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}"); + } + } } From 27bd5dd8a00bbe49910938d78842f2e9a87bd698 Mon Sep 17 00:00:00 2001 From: keyldev Date: Sun, 27 Sep 2026 16:41:01 +0300 Subject: [PATCH 06/15] feat(events): filters, day groups, burst episodes and jump to recording - Camera / type / period filters (default 7 days), day headers, 20 rows per page - Bursts of one camera and kind within a minute fold into one episode with merged class counts - Rows show what was detected (localized COCO classes), play the covering recording from the event moment, open the camera - Simulate motion moves into the header overflow; chip style no longer picks up Fluent's checked fill --- .../Messages/OpenCameraMessage.cs | 5 +- src/OpenIPC.Viewer.App/Services/Localizer.cs | 40 +- src/OpenIPC.Viewer.App/Styles/Buttons.axaml | 20 + src/OpenIPC.Viewer.App/Themes/Theme.axaml | 2 + .../ViewModels/EventsPageViewModel.cs | 415 +++++++++++++++--- .../ViewModels/MainWindowViewModel.cs | 1 + .../RecordingPlayerPageViewModel.cs | 5 + .../Views/Pages/EventsPage.axaml | 306 ++++++++++--- .../Views/Pages/EventsPage.axaml.cs | 17 + 9 files changed, 684 insertions(+), 127 deletions(-) diff --git a/src/OpenIPC.Viewer.App/Messages/OpenCameraMessage.cs b/src/OpenIPC.Viewer.App/Messages/OpenCameraMessage.cs index 4ca613b..ca7503e 100644 --- a/src/OpenIPC.Viewer.App/Messages/OpenCameraMessage.cs +++ b/src/OpenIPC.Viewer.App/Messages/OpenCameraMessage.cs @@ -9,8 +9,9 @@ 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. +public sealed record OpenRecordingMessage(Recording Recording, string CameraName, TimeSpan? StartAt = null); public sealed record GoBackToRecordingsMessage; diff --git a/src/OpenIPC.Viewer.App/Services/Localizer.cs b/src/OpenIPC.Viewer.App/Services/Localizer.cs index 972a012..4a3905d 100644 --- a/src/OpenIPC.Viewer.App/Services/Localizer.cs +++ b/src/OpenIPC.Viewer.App/Services/Localizer.cs @@ -584,7 +584,17 @@ private static LangCode DetectSystem() ["Recordings.ExportTitle"] = "Export clip", ["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.Tooltip.Play"] = "Play the recording from this moment", + ["Events.Tooltip.OpenCamera"] = "Open camera", + ["Events.Tooltip.More"] = "More", ["Events.AllCameras"] = "All cameras", ["Events.Open"] = "open", @@ -1119,7 +1129,33 @@ private static LangCode DetectSystem() ["Recordings.ExportTitle"] = "Экспорт клипа", ["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.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/Styles/Buttons.axaml b/src/OpenIPC.Viewer.App/Styles/Buttons.axaml index a2262cf..ad20374 100644 --- a/src/OpenIPC.Viewer.App/Styles/Buttons.axaml +++ b/src/OpenIPC.Viewer.App/Styles/Buttons.axaml @@ -96,6 +96,26 @@ + + + + + + diff --git a/src/OpenIPC.Viewer.App/Themes/Theme.axaml b/src/OpenIPC.Viewer.App/Themes/Theme.axaml index dea7876..acfc3b5 100644 --- a/src/OpenIPC.Viewer.App/Themes/Theme.axaml +++ b/src/OpenIPC.Viewer.App/Themes/Theme.axaml @@ -127,4 +127,6 @@ 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 diff --git a/src/OpenIPC.Viewer.App/ViewModels/EventsPageViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/EventsPageViewModel.cs index b57eaa1..86e7a1b 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,39 +44,137 @@ public sealed partial class EventsPageViewModel : ViewModelBase, IDisposable private readonly Dictionary _cameraNames = new(); private readonly IDisposable _liveSub; + // Newest first; the kind filter and episode folding slice it into PageItems. + private readonly List _events = new(); + private Dictionary> _recordings = new(); + public string Title => Localizer.Instance["Nav.Events"]; - public ObservableCollection Items { get; } = new(); public ObservableCollection CameraOptions { get; } = new(); [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; + // --- Filters ---------------------------------------------------------------- [ObservableProperty] private CameraOption? _selectedCamera; + [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; + + // Camera and period change what is loaded; kind and grouping only re-slice. + partial void OnSelectedCameraChanged(CameraOption? value) => _ = ReloadAsync(CancellationToken.None); + 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; + + [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)); } @@ -68,6 +182,7 @@ public async Task LoadAsync(CancellationToken ct) { var cams = await _cameras.ListAsync(ct).ConfigureAwait(true); _cameraNames.Clear(); + var keep = SelectedCamera?.Id; CameraOptions.Clear(); CameraOptions.Add(new CameraOption(null, Localizer.Instance["Events.AllCameras"])); foreach (var c in cams) @@ -75,13 +190,24 @@ public async Task LoadAsync(CancellationToken ct) _cameraNames[c.Id] = c.Name; CameraOptions.Add(new CameraOption(c.Id, c.Name)); } - SelectedCamera ??= CameraOptions[0]; - - await ReloadAsync(ct).ConfigureAwait(true); + // Restoring the selection re-triggers a reload; otherwise load here. + var match = CameraOptions.FirstOrDefault(o => o.Id == keep) ?? CameraOptions[0]; + if (!Equals(SelectedCamera, match)) SelectedCamera = match; + else 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) { @@ -91,14 +217,24 @@ private async Task ReloadAsync(CancellationToken ct) var events = await _repo.ListAsync( cameraId: SelectedCamera?.Id, kind: null, - since: null, - limit: PageLimit, + since: PeriodStartUtc(), + limit: LoadLimit, ct).ConfigureAwait(true); + _events.Clear(); + _events.AddRange(events.OrderByDescending(e => e.OccurredAt)); - Items.Clear(); - foreach (var e in events) Items.Add(BuildRow(e)); + // 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()); + + ApplyFilter(resetPage: true); IsLoaded = true; - OnPropertyChanged(nameof(IsEmpty)); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to load events"); } finally { @@ -106,13 +242,126 @@ 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 filtered = _events.Where(MatchesKind).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. + // exercises the full ingestion -> repo -> UI path against the first + // available camera so the plumbing is testable. var target = SelectedCamera?.Id ?? _cameraNames.Keys.FirstOrDefault(); if (target == default) @@ -125,33 +374,22 @@ private void SimulateMotion() private void OnLiveEvent(CameraEvent ev) { - // Filter: respect the current camera filter for live updates too. + // Respect the camera and period filters for live updates too. if (SelectedCamera?.Id is { } selected && ev.CameraId != selected) return; + 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(); @@ -172,24 +410,97 @@ public sealed record CameraOption(CameraId? Id, string Name) public override string ToString() => Name; } -public sealed class EventRowViewModel +// "5 July · 87" — interleaved into PageItems. +public sealed class EventDayHeader { - public OpenIPC.Viewer.Core.Events.EventId EventId { get; } - public string CameraName { get; } - public string KindLabel { get; } - public string Source { get; } - public DateTime OccurredAtLocal { get; } - public string Duration { get; } + 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 EventRowViewModel(CameraEvent ev, string cameraName) + public string Title { get; } + public string Count { get; } +} + +// One row: a single event, or a burst of same-camera same-kind events. +public sealed class EventEpisodeRow +{ + public EventEpisodeRow(IReadOnlyList newestFirst, string cameraName, Recording? recording, TimeSpan? recordingOffset) { - EventId = ev.Id; + var latest = newestFirst[0]; + var earliest = newestFirst[^1]; + CameraId = latest.CameraId; 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"]; + 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); + Tooltip = string.Join(" · ", new[] { latest.Source, CountLabel }.Where(s => !string.IsNullOrEmpty(s))); + } + + public CameraId CameraId { get; } + public string CameraName { get; } + public EventKind Kind { get; } + public int Count { get; } + public DateTime LatestLocal { get; } + public string TimeLabel { get; } + public string Description { get; } + 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 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) + { + if (string.IsNullOrWhiteSpace(e.Summary)) continue; + foreach (var part in e.Summary.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var bits = part.Split('×', StringSplitOptions.TrimEntries); + var label = bits[0]; + var count = bits.Length > 1 && int.TryParse(bits[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var c) ? c : 1; + if (label.Length == 0) continue; + 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 => $"{ClassName(kv.Key)} ×{kv.Value}")); + } + + // Localizer returns the key itself when a translation is missing — fall + // back to the model's own class label then. + private static string ClassName(string label) + { + var key = "Coco." + label.ToLowerInvariant(); + var text = Localizer.Instance[key]; + return text == key ? label : text; } } diff --git a/src/OpenIPC.Viewer.App/ViewModels/MainWindowViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/MainWindowViewModel.cs index f10e667..45bcb16 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/MainWindowViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/MainWindowViewModel.cs @@ -221,6 +221,7 @@ public void Receive(OpenRecordingMessage message) { _ = DisposeActivePlayerAsync(); _activePlayer = _playerFactory.Create(message.Recording, message.CameraName); + _activePlayer.StartAt = message.StartAt; CurrentPage = _activePlayer; } catch (Exception ex) diff --git a/src/OpenIPC.Viewer.App/ViewModels/RecordingPlayerPageViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/RecordingPlayerPageViewModel.cs index a633ee7..3fc3a3d 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/RecordingPlayerPageViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/RecordingPlayerPageViewModel.cs @@ -73,6 +73,9 @@ public RecordingPlayerPageViewModel( Segments = new[] { new TimelineSegment(TimelineStart, TimelineEnd) }; } + // Initial seek applied once playback starts (e.g. opened from an event). + public TimeSpan? StartAt { get; set; } + public string Title => Path.GetFileName(_recording.FilePath); public string CameraName { get; } @@ -213,6 +216,8 @@ public async Task ActivateAsync(CancellationToken ct) _playback = session; VideoSession = session; await session.StartAsync(ct).ConfigureAwait(true); + if (StartAt is { } start && start > TimeSpan.FromSeconds(1)) + await SeekToAsync(start).ConfigureAwait(true); } catch (Exception ex) { diff --git a/src/OpenIPC.Viewer.App/Views/Pages/EventsPage.axaml b/src/OpenIPC.Viewer.App/Views/Pages/EventsPage.axaml index 336135c..9431c29 100644 --- a/src/OpenIPC.Viewer.App/Views/Pages/EventsPage.axaml +++ b/src/OpenIPC.Viewer.App/Views/Pages/EventsPage.axaml @@ -2,101 +2,265 @@ 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" x:Class="OpenIPC.Viewer.App.Views.Pages.EventsPage" x:DataType="vm:EventsPageViewModel"> - + + + + + - - + + + + - + Foreground="{StaticResource TextPrimaryBrush}" + VerticalAlignment="Center" /> + + + + + + - + + + + + + + + + + + + + + + - + - - - - - - - - - - - - - - - - - - + + - + + - Date: Sun, 27 Sep 2026 16:43:26 +0300 Subject: [PATCH 07/15] fix(events): center the tall motion glyph in its circle --- src/OpenIPC.Viewer.App/Views/Pages/EventsPage.axaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/OpenIPC.Viewer.App/Views/Pages/EventsPage.axaml b/src/OpenIPC.Viewer.App/Views/Pages/EventsPage.axaml index 9431c29..fc66b27 100644 --- a/src/OpenIPC.Viewer.App/Views/Pages/EventsPage.axaml +++ b/src/OpenIPC.Viewer.App/Views/Pages/EventsPage.axaml @@ -39,7 +39,7 @@ - + @@ -59,7 +59,7 @@ - + @@ -157,7 +157,7 @@ + Stroke="{StaticResource WarningBrush}" Height="14" HorizontalAlignment="Center" VerticalAlignment="Center" /> From f0ba288e8fba74db90a63ce79cd231287097f199 Mon Sep 17 00:00:00 2001 From: keyldev Date: Sun, 27 Sep 2026 16:56:47 +0300 Subject: [PATCH 08/15] feat(events): searchable multi-camera picker with event counts - Camera dropdown becomes a picker: search by name/IP/group, tick several cameras, per-camera counts with a bar - Defaults to cameras that have events in the period/type; "All cameras" lists every one - Selected cameras show as removable chips; clicking a camera name in a row filters to it --- src/OpenIPC.Viewer.App/Services/Localizer.cs | 16 ++ .../ViewModels/EventsPageViewModel.cs | 187 ++++++++++++++++-- .../Views/Pages/EventsPage.axaml | 126 +++++++++++- 3 files changed, 301 insertions(+), 28 deletions(-) diff --git a/src/OpenIPC.Viewer.App/Services/Localizer.cs b/src/OpenIPC.Viewer.App/Services/Localizer.cs index 4a3905d..4d9687f 100644 --- a/src/OpenIPC.Viewer.App/Services/Localizer.cs +++ b/src/OpenIPC.Viewer.App/Services/Localizer.cs @@ -592,6 +592,14 @@ private static LangCode DetectSystem() ["Events.Kind.Detection"] = "AI detection", ["Events.GroupBursts"] = "Group bursts", ["Events.ShowAllTime"] = "Show all time", + ["Events.Cameras.All"] = "Cameras: all", + ["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", @@ -1137,6 +1145,14 @@ private static LangCode DetectSystem() ["Events.Kind.Detection"] = "Детекция ИИ", ["Events.GroupBursts"] = "Группировать серии", ["Events.ShowAllTime"] = "Показать за всё время", + ["Events.Cameras.All"] = "Камеры: все", + ["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"] = "Ещё", diff --git a/src/OpenIPC.Viewer.App/ViewModels/EventsPageViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/EventsPageViewModel.cs index 86e7a1b..9b3357c 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/EventsPageViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/EventsPageViewModel.cs @@ -50,8 +50,6 @@ public sealed partial class EventsPageViewModel : ViewModelBase, IDisposable public string Title => Localizer.Instance["Nav.Events"]; - public ObservableCollection CameraOptions { get; } = new(); - [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsEmpty))] [NotifyPropertyChangedFor(nameof(HasNoMatches))] @@ -67,8 +65,90 @@ public sealed partial class EventsPageViewModel : ViewModelBase, IDisposable public bool HasNoMatches => IsLoaded && !IsLoading && _events.Count > 0 && PageItems.Count == 0; public bool HasRows => PageItems.Count > 0; + // --- Camera filter (multi-select picker) ------------------------------------- + // Every camera, with its event count under the current period + kind. The + // picker shows a searchable slice of these; ticked ones filter the list. + private readonly List _pickItems = new(); + + public ObservableCollection PickerItems { get; } = new(); + public ObservableCollection SelectedCameras { get; } = new(); + + [ObservableProperty] private string _pickerSearch = ""; + + // Picker scope: only cameras with events (default) or every camera. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsPickerWithEvents))] + private bool _pickerShowAll; + + public bool IsPickerWithEvents { get => !PickerShowAll; set => PickerShowAll = !value; } + + // "4 of 83 with events" + [ObservableProperty] private string _pickerFooter = ""; + + // "Cameras: all" / the single camera's name / "Cameras: 2" + [ObservableProperty] private string _cameraButtonLabel = ""; + + public bool HasCameraSelection => SelectedCameras.Count > 0; + + partial void OnPickerSearchChanged(string value) => RebuildPicker(); + partial void OnPickerShowAllChanged(bool value) => RebuildPicker(); + + private void OnPickToggled(CameraPickItem item) + { + if (item.IsSelected && !SelectedCameras.Contains(item)) SelectedCameras.Add(item); + else if (!item.IsSelected) SelectedCameras.Remove(item); + UpdateCameraButtonLabel(); + ApplyFilter(resetPage: true); + } + + private void UpdateCameraButtonLabel() + { + CameraButtonLabel = 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(HasCameraSelection)); + } + + [RelayCommand] + private void RemoveCamera(CameraPickItem? item) + { + if (item is not null) item.IsSelected = false; // → OnPickToggled + } + + [RelayCommand] + private void ClearCameras() + { + foreach (var item in SelectedCameras.ToList()) item.IsSelected = false; + } + + // Clicking a camera name in a row narrows the list to it. + [RelayCommand] + private void AddCameraFilter(EventEpisodeRow? row) + { + if (row is null) return; + var item = _pickItems.FirstOrDefault(i => i.Id == row.CameraId); + if (item is not null && !item.IsSelected) item.IsSelected = true; + } + + // Visible picker rows: search over name / host / group; cameras with events + // first by count, unless "all cameras" is on (then alphabetical). + private void RebuildPicker() + { + var q = PickerSearch?.Trim() ?? ""; + IEnumerable items = _pickItems.Where(i => i.Matches(q)); + items = PickerShowAll + ? items.OrderByDescending(i => i.Count).ThenBy(i => i.Name, StringComparer.CurrentCultureIgnoreCase) + : items.Where(i => i.Count > 0 || i.IsSelected).OrderByDescending(i => i.Count).ThenBy(i => i.Name, StringComparer.CurrentCultureIgnoreCase); + PickerItems.Clear(); + foreach (var i in items) PickerItems.Add(i); + PickerFooter = string.Format(CultureInfo.CurrentCulture, Localizer.Instance["Events.Cameras.FooterFormat"], + _pickItems.Count(i => i.Count > 0), _pickItems.Count); + } + // --- Filters ---------------------------------------------------------------- - [ObservableProperty] private CameraOption? _selectedCamera; [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsKindAll))] @@ -95,8 +175,7 @@ public sealed partial class EventsPageViewModel : ViewModelBase, IDisposable // Fold bursts (e.g. a car parked under an AI camera) into one row. [ObservableProperty] private bool _groupBursts = true; - // Camera and period change what is loaded; kind and grouping only re-slice. - partial void OnSelectedCameraChanged(CameraOption? value) => _ = ReloadAsync(CancellationToken.None); + // 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); @@ -181,19 +260,22 @@ public EventsPageViewModel( 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(); - var keep = SelectedCamera?.Id; - CameraOptions.Clear(); - CameraOptions.Add(new CameraOption(null, Localizer.Instance["Events.AllCameras"])); + var keep = SelectedCameras.Select(i => i.Id).ToHashSet(); + SelectedCameras.Clear(); + _pickItems.Clear(); foreach (var c in cams) { _cameraNames[c.Id] = c.Name; - CameraOptions.Add(new CameraOption(c.Id, c.Name)); + var group = c.GroupId is { } gid && groups.TryGetValue(gid, out var g) ? g : null; + var item = new CameraPickItem(c.Id, c.Name, c.Host, group, OnPickToggled); + item.SetSelectedSilently(keep.Contains(c.Id)); + if (item.IsSelected) SelectedCameras.Add(item); + _pickItems.Add(item); } - // Restoring the selection re-triggers a reload; otherwise load here. - var match = CameraOptions.FirstOrDefault(o => o.Id == keep) ?? CameraOptions[0]; - if (!Equals(SelectedCamera, match)) SelectedCamera = match; - else await ReloadAsync(ct).ConfigureAwait(true); + UpdateCameraButtonLabel(); + await ReloadAsync(ct).ConfigureAwait(true); } private DateTime? PeriodStartUtc() @@ -215,7 +297,7 @@ private async Task ReloadAsync(CancellationToken ct) try { var events = await _repo.ListAsync( - cameraId: SelectedCamera?.Id, + cameraId: null, kind: null, since: PeriodStartUtc(), limit: LoadLimit, @@ -251,7 +333,25 @@ private async Task ReloadAsync(CancellationToken ct) private void ApplyFilter(bool resetPage) { - var filtered = _events.Where(MatchesKind).ToList(); + var ofKind = _events.Where(MatchesKind).ToList(); + + // Picker counts ignore the camera selection itself, so unticked cameras + // still show how busy they are. + var counts = ofKind.GroupBy(e => e.CameraId).ToDictionary(g => g.Key, g => g.Count()); + var max = counts.Count == 0 ? 0 : counts.Values.Max(); + var countsChanged = false; + foreach (var item in _pickItems) + { + var c = counts.TryGetValue(item.Id, out var n) ? n : 0; + if (c != item.Count) countsChanged = true; + item.SetCount(c, max); + } + // Ticking a camera doesn't move counts — leave the open picker alone + // then, so the row under the pointer isn't rebuilt mid-click. + if (countsChanged || PickerItems.Count == 0) RebuildPicker(); + + var selected = SelectedCameras.Select(i => i.Id).ToHashSet(); + var filtered = selected.Count == 0 ? ofKind : ofKind.Where(e => selected.Contains(e.CameraId)).ToList(); var episodes = GroupBursts ? FoldEpisodes(filtered) : filtered.Select(e => new List { e }).ToList(); Summary = string.Format(CultureInfo.CurrentCulture, Localizer.Instance["Events.SummaryFormat"], @@ -362,7 +462,7 @@ private void SimulateMotion() // (Majestic endpoint TBD; ONVIF PullPoint not in Onvif.Core). This // exercises the full ingestion -> repo -> UI path against the first // available camera so the plumbing is testable. - var target = SelectedCamera?.Id + var target = SelectedCameras.Select(i => (CameraId?)i.Id).FirstOrDefault() ?? _cameraNames.Keys.FirstOrDefault(); if (target == default) { @@ -374,9 +474,7 @@ private void SimulateMotion() private void OnLiveEvent(CameraEvent ev) { - // Respect the camera and period filters for live updates too. - if (SelectedCamera?.Id is { } selected && ev.CameraId != selected) - return; + // Respect the period for live updates too (cameras filter in memory). if (PeriodStartUtc() is { } since && ev.OccurredAt < since) return; @@ -405,9 +503,56 @@ public void OnCompleted() { } } } -public sealed record CameraOption(CameraId? Id, string Name) +// One camera in the events picker: its event count under the current period +// and kind (with a proportional bar), and whether it's part of the filter. +public sealed partial class CameraPickItem : ObservableObject { - public override string ToString() => Name; + 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); } // "5 July · 87" — interleaved into PageItems. diff --git a/src/OpenIPC.Viewer.App/Views/Pages/EventsPage.axaml b/src/OpenIPC.Viewer.App/Views/Pages/EventsPage.axaml index fc66b27..2051ed2 100644 --- a/src/OpenIPC.Viewer.App/Views/Pages/EventsPage.axaml +++ b/src/OpenIPC.Viewer.App/Views/Pages/EventsPage.axaml @@ -13,6 +13,20 @@ + + + + + + + + + + + + - - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - + + + + + + + + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/OpenIPC.Viewer.Composition/SharedComposition.cs b/src/OpenIPC.Viewer.Composition/SharedComposition.cs index 683e77d..6517408 100644 --- a/src/OpenIPC.Viewer.Composition/SharedComposition.cs +++ b/src/OpenIPC.Viewer.Composition/SharedComposition.cs @@ -193,6 +193,7 @@ public static IServiceCollection AddSharedServices(this IServiceCollection servi services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); From a6ebf8cda0f74d6afe10ad691e6c8e1619795fb1 Mon Sep 17 00:00:00 2001 From: keyldev Date: Sun, 27 Sep 2026 17:59:43 +0300 Subject: [PATCH 11/15] feat(player): redesign the recording player MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Header shows camera + date/time/length/size; previous/next recording and a ⋯ menu (folder, copy, delete) - Transport: ±10 s, wall-clock time, 0.5×–8× speed (IPlaybackSession.Rate), Space/←/→/Home/End hotkeys - Clip mode replaces the export bar; save the current frame into snapshots - Event sidebar hidden when empty, localized filter, class-icon rows; auto-continue into an adjacent recording --- .../Messages/OpenCameraMessage.cs | 9 +- src/OpenIPC.Viewer.App/Services/Localizer.cs | 52 +- .../Services/RecordingPlayerPageFactory.cs | 9 +- src/OpenIPC.Viewer.App/Themes/Theme.axaml | 7 + .../ViewModels/MainWindowViewModel.cs | 3 + .../RecordingPlayerPageViewModel.cs | 513 +++++++++++++--- .../Views/Pages/RecordingPlayerPage.axaml | 557 ++++++++++++------ .../Views/Pages/RecordingPlayerPage.axaml.cs | 52 ++ .../Views/Pages/RecordingsPage.axaml | 1 + .../Snapshots/ISnapshotService.cs | 7 + .../Snapshots/SnapshotService.cs | 12 +- .../Snapshots/SnapshotSource.cs | 3 + .../Video/IPlaybackSession.cs | 4 + .../Pipeline/FfmpegPlaybackSession.cs | 30 +- 14 files changed, 975 insertions(+), 284 deletions(-) diff --git a/src/OpenIPC.Viewer.App/Messages/OpenCameraMessage.cs b/src/OpenIPC.Viewer.App/Messages/OpenCameraMessage.cs index 9fa5880..6d1f5d6 100644 --- a/src/OpenIPC.Viewer.App/Messages/OpenCameraMessage.cs +++ b/src/OpenIPC.Viewer.App/Messages/OpenCameraMessage.cs @@ -10,10 +10,13 @@ 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. StartAt (offset into the -// file) lets the Events page open a recording at the moment of an event. -public sealed record OpenRecordingMessage(Recording Recording, string CameraName, TimeSpan? StartAt = null); +// 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); // AI page → Events filtered to AI detections. public sealed record ShowDetectionEventsMessage; diff --git a/src/OpenIPC.Viewer.App/Services/Localizer.cs b/src/OpenIPC.Viewer.App/Services/Localizer.cs index 5bb4937..914aa93 100644 --- a/src/OpenIPC.Viewer.App/Services/Localizer.cs +++ b/src/OpenIPC.Viewer.App/Services/Localizer.cs @@ -591,11 +591,29 @@ private static LangCode DetectSystem() ["Calendar.MonthSummaryFormat"] = "Recordings this month: {0}", ["Calendar.DayTooltipFormat"] = "Recordings: {0} · Events: {1}", ["Calendar.NearestFormat"] = "Nearest recordings: {0} →", - ["Recordings.MarkIn"] = "Set In", - ["Recordings.MarkOut"] = "Set Out", - ["Recordings.Precise"] = "Precise", - ["Recordings.Export"] = "Export", ["Recordings.ExportTitle"] = "Export clip", + ["Player.Back"] = "Back to recordings", + ["Player.Previous"] = "Previous", + ["Player.Next"] = "Next", + ["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.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 for this period. They appear when AI detection or a motion source fires on a camera.", @@ -1160,11 +1178,29 @@ private static LangCode DetectSystem() ["Calendar.MonthSummaryFormat"] = "Записей в месяце: {0}", ["Calendar.DayTooltipFormat"] = "Записей: {0} · Событий: {1}", ["Calendar.NearestFormat"] = "Ближайшие записи: {0} →", - ["Recordings.MarkIn"] = "Начало", - ["Recordings.MarkOut"] = "Конец", - ["Recordings.Precise"] = "Точно", - ["Recordings.Export"] = "Экспорт", ["Recordings.ExportTitle"] = "Экспорт клипа", + ["Player.Back"] = "К списку записей", + ["Player.Previous"] = "Предыдущая", + ["Player.Next"] = "Следующая", + ["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.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"] = "За этот период событий нет. Они появляются, когда на камере срабатывает детекция ИИ или источник движения.", diff --git a/src/OpenIPC.Viewer.App/Services/RecordingPlayerPageFactory.cs b/src/OpenIPC.Viewer.App/Services/RecordingPlayerPageFactory.cs index c970c9c..33aca82 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,6 +13,8 @@ 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 IDialogService _dialogs; private readonly OpenIPC.Viewer.Core.Platform.IShareService _share; @@ -21,6 +24,8 @@ public RecordingPlayerPageFactory( IPlaybackEngine engine, IMediaProbe probe, IEventRepository events, + IRecordingRepository recordings, + ISnapshotService snapshots, IClipExporter exporter, IDialogService dialogs, OpenIPC.Viewer.Core.Platform.IShareService share, @@ -29,6 +34,8 @@ public RecordingPlayerPageFactory( _engine = engine; _probe = probe; _events = events; + _recordings = recordings; + _snapshots = snapshots; _exporter = exporter; _dialogs = dialogs; _share = share; @@ -36,6 +43,6 @@ public RecordingPlayerPageFactory( } 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, _dialogs, _share, _loggerFactory.CreateLogger()); } diff --git a/src/OpenIPC.Viewer.App/Themes/Theme.axaml b/src/OpenIPC.Viewer.App/Themes/Theme.axaml index d82de2f..9360620 100644 --- a/src/OpenIPC.Viewer.App/Themes/Theme.axaml +++ b/src/OpenIPC.Viewer.App/Themes/Theme.axaml @@ -129,6 +129,13 @@ 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 + 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 diff --git a/src/OpenIPC.Viewer.App/ViewModels/MainWindowViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/MainWindowViewModel.cs index ddd841e..6f0e571 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/MainWindowViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/MainWindowViewModel.cs @@ -230,6 +230,7 @@ public void Receive(OpenRecordingMessage message) _ = DisposeActivePlayerAsync(); _activePlayer = _playerFactory.Create(message.Recording, message.CameraName); _activePlayer.StartAt = message.StartAt; + _activePlayer.Rate = message.Rate; CurrentPage = _activePlayer; } catch (Exception ex) @@ -242,6 +243,8 @@ public async void Receive(GoBackToRecordingsMessage message) { await DisposeActivePlayerAsync().ConfigureAwait(true); 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 3fc3a3d..b075662 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,14 +26,23 @@ 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 IDialogService _dialogs; private readonly OpenIPC.Viewer.Core.Platform.IShareService _share; @@ -40,6 +51,11 @@ 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 _userPaused; + private bool _continued; private bool _activating; private bool _disposed; @@ -49,6 +65,8 @@ public RecordingPlayerPageViewModel( IPlaybackEngine engine, IMediaProbe probe, IEventRepository events, + IRecordingRepository recordings, + ISnapshotService snapshots, IClipExporter exporter, IDialogService dialogs, OpenIPC.Viewer.Core.Platform.IShareService share, @@ -59,6 +77,8 @@ public RecordingPlayerPageViewModel( _engine = engine; _probe = probe; _events = events; + _recordings = recordings; + _snapshots = snapshots; _exporter = exporter; _dialogs = dialogs; _share = share; @@ -71,13 +91,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)); } // Initial seek applied once playback starts (e.g. opened from an event). public TimeSpan? StartAt { get; set; } - public string Title => Path.GetFileName(_recording.FilePath); 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))] @@ -85,44 +130,81 @@ 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; + // Transient confirmation over the video ("Frame saved"); clears itself. + [ObservableProperty] private string? _notice; + [ObservableProperty] - [NotifyPropertyChangedFor(nameof(PositionLabel))] + [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 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))] @@ -142,39 +224,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) { @@ -197,13 +271,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(() => { @@ -231,21 +310,241 @@ 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(); + } } [RelayCommand] - private Task RestartAsync() => SeekToAsync(TimeSpan.Zero); + private Task SkipBack() => SeekRelativeAsync(-SkipStep); - // Invoked by the timeline on click/marker-tap with an absolute UTC time. + [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 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.Previous", _previous); + NextTip = _next is null ? null : NeighbourTip("Player.Next", _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() { @@ -258,11 +557,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))] @@ -281,13 +582,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"). @@ -297,7 +598,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 { @@ -307,32 +608,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) { @@ -343,7 +619,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; @@ -355,13 +632,17 @@ 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(FilterAllLabel)); + OnPropertyChanged(nameof(FilterMotionLabel)); + OnPropertyChanged(nameof(FilterDetectionLabel)); + OnPropertyChanged(nameof(EventsTitle)); } catch (Exception ex) { @@ -383,15 +664,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)); } @@ -400,6 +681,7 @@ public async ValueTask DisposeAsync() { if (_disposed) return; _disposed = true; + _noticeCts?.Cancel(); _stateSub?.Dispose(); _positionSub?.Dispose(); var session = _playback; @@ -412,8 +694,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/Views/Pages/RecordingPlayerPage.axaml b/src/OpenIPC.Viewer.App/Views/Pages/RecordingPlayerPage.axaml index afbd1c5..710a189 100644 --- a/src/OpenIPC.Viewer.App/Views/Pages/RecordingPlayerPage.axaml +++ b/src/OpenIPC.Viewer.App/Views/Pages/RecordingPlayerPage.axaml @@ -2,157 +2,325 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:vm="using:OpenIPC.Viewer.App.ViewModels" xmlns:controls="using:OpenIPC.Viewer.App.Controls" + xmlns:conv="using:OpenIPC.Viewer.App.Converters" xmlns:svc="using:OpenIPC.Viewer.App.Services" xmlns:b="using:OpenIPC.Viewer.App.Behaviors" x:Class="OpenIPC.Viewer.App.Views.Pages.RecordingPlayerPage" x:DataType="vm:RecordingPlayerPageViewModel"> - + - + + + + + + + + - + + + - + - - - - - + - + + + + + + - + - - - - + + + + - + - - - + + + + + + - - - - + + + + - - - - - - - + + + + + + + + + + + + - + - - - - @@ -162,11 +330,11 @@ - - + + + cursor, click/marker-tap to seek, drag to select a clip (16.4). --> - - - - - - - - - + + + + + + - - - @@ -235,8 +260,9 @@ - + + - + @@ -364,6 +391,12 @@ HorizontalAlignment="Center" VerticalAlignment="Center" Margin="0,1,0,0" /> + + + + + + @@ -419,7 +480,7 @@ - Date: Sun, 27 Sep 2026 19:12:32 +0300 Subject: [PATCH 14/15] feat(cameras): redesign the add/edit camera dialog - Address + login first; Connect detects OpenIPC/ONVIF, fills streams, ports and name, and shows a live frame - Failure card says what went wrong (unreachable, credentials, no video) - Advanced settings in collapsible rows with one-line summaries; per-field validation - Pasted rtsp:// links are split into fields; new group inline; dark header replaces the title bar --- .../Services/CameraConnectService.cs | 295 +++++++ .../Services/CameraEditService.cs | 1 + .../Services/CameraEditorFactory.cs | 15 +- .../Services/CameraEditorResultExtensions.cs | 20 + src/OpenIPC.Viewer.App/Services/Localizer.cs | 190 +++-- src/OpenIPC.Viewer.App/Themes/Theme.axaml | 4 + .../ViewModels/CameraLibraryPageViewModel.cs | 20 +- .../Dialogs/CameraEditorViewModel.cs | 750 ++++++++++++------ .../Views/Dialogs/CameraEditorContent.axaml | 645 ++++++++++----- .../Dialogs/CameraEditorContent.axaml.cs | 59 +- .../Views/Dialogs/CameraEditorWindow.axaml | 5 +- .../Views/Dialogs/CameraEditorWindow.axaml.cs | 9 +- .../SharedComposition.cs | 1 + 13 files changed, 1520 insertions(+), 494 deletions(-) create mode 100644 src/OpenIPC.Viewer.App/Services/CameraConnectService.cs create mode 100644 src/OpenIPC.Viewer.App/Services/CameraEditorResultExtensions.cs 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 index da5d8f7..3b2343d 100644 --- a/src/OpenIPC.Viewer.App/Services/CameraEditService.cs +++ b/src/OpenIPC.Viewer.App/Services/CameraEditService.cs @@ -41,6 +41,7 @@ public async Task EditAsync(Camera camera) 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) 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/Localizer.cs b/src/OpenIPC.Viewer.App/Services/Localizer.cs index 402581f..fb9acb4 100644 --- a/src/OpenIPC.Viewer.App/Services/Localizer.cs +++ b/src/OpenIPC.Viewer.App/Services/Localizer.cs @@ -338,54 +338,83 @@ 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.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}", @@ -931,50 +960,79 @@ 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.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.", diff --git a/src/OpenIPC.Viewer.App/Themes/Theme.axaml b/src/OpenIPC.Viewer.App/Themes/Theme.axaml index 6dbea09..3f2b2f4 100644 --- a/src/OpenIPC.Viewer.App/Themes/Theme.axaml +++ b/src/OpenIPC.Viewer.App/Themes/Theme.axaml @@ -135,6 +135,10 @@ 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 diff --git a/src/OpenIPC.Viewer.App/ViewModels/CameraLibraryPageViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/CameraLibraryPageViewModel.cs index 3bbde26..6a3aad9 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/CameraLibraryPageViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/CameraLibraryPageViewModel.cs @@ -514,13 +514,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) @@ -570,8 +581,10 @@ private async Task DiscoverCameraAsync() // 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) + // A Connect inside the editor supersedes the discovery probe. + if (result.Onvif is null && found.Probe is { } probe) await _directory.SaveOnvifMetadataAsync(id, probe, CancellationToken.None).ConfigureAwait(true); + await _directory.SaveDetectedAsync(id, result, CancellationToken.None).ConfigureAwait(true); await LoadAsync(CancellationToken.None).ConfigureAwait(true); knownHosts.Add(req.Host); } @@ -644,7 +657,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) diff --git a/src/OpenIPC.Viewer.App/ViewModels/Dialogs/CameraEditorViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/Dialogs/CameraEditorViewModel.cs index 62567e7..1a6eec7 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/Dialogs/CameraEditorViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/Dialogs/CameraEditorViewModel.cs @@ -1,48 +1,111 @@ 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; + + 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 +115,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 +135,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 +254,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 +290,337 @@ 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 ────────────────────────────────────────────────────────── - if (!TryValidate(out _, out var rtspMain, out _, out _, out _)) + private bool CanConnect() => _connect is not null && ConnectState != ConnectState.Connecting; + + [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 => (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; } + } + + 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.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(); - var rtspMainSource = string.IsNullOrWhiteSpace(RtspMainText) - ? $"rtsp://{Host.Trim()}/" - : RtspMainText.Trim(); + // ── Save ───────────────────────────────────────────────────────────── - if (!Uri.TryCreate(rtspMainSource, UriKind.Absolute, out rtspMain!)) + public bool TryBuildRequest(out NewCameraRequest? newRequest, out UpdateCameraRequest? updateRequest) + { + newRequest = null; + updateRequest = null; + _saveAttempted = true; + ErrorMessage = null; + + 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 +633,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 +704,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/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. --> + + + + + + + + + + - - - - - - - - + + + + + + + + + + - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - - - - + - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + +