diff --git a/src/OpenIPC.Viewer.App/Converters/StateColorConverter.cs b/src/OpenIPC.Viewer.App/Converters/StateColorConverter.cs index f9a2632..35a011a 100644 --- a/src/OpenIPC.Viewer.App/Converters/StateColorConverter.cs +++ b/src/OpenIPC.Viewer.App/Converters/StateColorConverter.cs @@ -10,18 +10,23 @@ public sealed class StateColorConverter : IValueConverter { public static readonly StateColorConverter Instance = new(); - private static readonly IBrush LiveBrush = new SolidColorBrush(Color.Parse("#ef4444")); + // Grid badge palette (mockup screen 1): LIVE green, Connecting/Reconnecting + // amber, Offline/Failed red, idle grey. Distinct from SingleCameraPage where + // LIVE is danger-red — there the badge doubles as a recording cue. + private static readonly IBrush LiveBrush = new SolidColorBrush(Color.Parse("#22c55e")); private static readonly IBrush WarnBrush = new SolidColorBrush(Color.Parse("#f59e0b")); - private static readonly IBrush OfflineBrush = new SolidColorBrush(Color.Parse("#5e6878")); + private static readonly IBrush FailedBrush = new SolidColorBrush(Color.Parse("#ef4444")); + private static readonly IBrush IdleBrush = new SolidColorBrush(Color.Parse("#5e6878")); public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) { - if (value is not SessionState state) return OfflineBrush; + if (value is not SessionState state) return IdleBrush; return state switch { SessionState.Playing => LiveBrush, SessionState.Connecting or SessionState.Reconnecting => WarnBrush, - _ => OfflineBrush, + SessionState.Failed => FailedBrush, + _ => IdleBrush, }; } diff --git a/src/OpenIPC.Viewer.App/Messages/OpenCameraMessage.cs b/src/OpenIPC.Viewer.App/Messages/OpenCameraMessage.cs index e53464d..1a4bd44 100644 --- a/src/OpenIPC.Viewer.App/Messages/OpenCameraMessage.cs +++ b/src/OpenIPC.Viewer.App/Messages/OpenCameraMessage.cs @@ -9,3 +9,8 @@ public sealed record GoBackToLibraryMessage; public sealed record WindowMinimizedMessage; public sealed record WindowRestoredMessage; + +// Raised by a grid tile's Close button (error cell). The grid drops the tile +// for this session; it comes back on the next Live-tab refresh since the +// camera's IncludedInGrid flag is untouched. +public sealed record CloseTileMessage(CameraId CameraId); diff --git a/src/OpenIPC.Viewer.App/Services/Localizer.cs b/src/OpenIPC.Viewer.App/Services/Localizer.cs index 4b9d9d9..af8a74f 100644 --- a/src/OpenIPC.Viewer.App/Services/Localizer.cs +++ b/src/OpenIPC.Viewer.App/Services/Localizer.cs @@ -78,9 +78,12 @@ private static LangCode DetectSystem() ["Common.AddCamera"] = "+ Add camera", ["Common.Loading"] = "Loading…", ["Common.Retry"] = "Retry", + ["Common.Close"] = "Close", ["Stream.Connecting"] = "Connecting…", + ["Stream.Reconnecting"] = "Reconnecting…", ["Stream.Disconnected"] = "Disconnected", + ["Stream.Unavailable"] = "Stream unavailable", ["Library.Title"] = "Cameras", ["Library.EmptyTitle"] = "No cameras yet", @@ -136,8 +139,11 @@ private static LangCode DetectSystem() ["Settings.About"] = "About", ["Settings.Video.TelemetryOverlay"] = "Show telemetry overlay on live view", + ["Settings.Video.AutoSdHd"] = "Auto SD/HD (sub in grid, main when full-screen)", ["Settings.Video.MaxGridSessions"] = "Max concurrent grid sessions", ["Settings.Video.RtspTransport"] = "Default RTSP transport", + ["Settings.Video.NetworkInterface"] = "Network interface (discovery)", + ["Settings.Video.NetworkInterface.Auto"] = "Auto", ["Settings.Recording.Directory"] = "Recordings directory", ["Settings.Recording.PickFolder"] = "Pick folder…", @@ -168,6 +174,10 @@ private static LangCode DetectSystem() ["CameraEditor.Label.Username"] = "Username", ["CameraEditor.Label.Password"] = "Password", ["CameraEditor.Label.Group"] = "Group", + ["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.Placeholder.Name"] = "Front door", ["CameraEditor.Placeholder.Host"] = "192.168.1.10", ["CameraEditor.Placeholder.OnvifPort"] = "8899", @@ -278,9 +288,12 @@ private static LangCode DetectSystem() ["Common.AddCamera"] = "+ Добавить камеру", ["Common.Loading"] = "Загрузка…", ["Common.Retry"] = "Повторить", + ["Common.Close"] = "Закрыть", ["Stream.Connecting"] = "Подключение…", + ["Stream.Reconnecting"] = "Переподключение…", ["Stream.Disconnected"] = "Нет соединения", + ["Stream.Unavailable"] = "Поток недоступен", ["Library.Title"] = "Камеры", ["Library.EmptyTitle"] = "Камер пока нет", @@ -336,8 +349,11 @@ private static LangCode DetectSystem() ["Settings.About"] = "О приложении", ["Settings.Video.TelemetryOverlay"] = "Показывать телеметрию на видео", + ["Settings.Video.AutoSdHd"] = "Авто SD/HD (sub в гриде, main на весь экран)", ["Settings.Video.MaxGridSessions"] = "Максимум потоков в гриде", ["Settings.Video.RtspTransport"] = "RTSP-транспорт по умолчанию", + ["Settings.Video.NetworkInterface"] = "Сетевой интерфейс (поиск)", + ["Settings.Video.NetworkInterface.Auto"] = "Авто", ["Settings.Recording.Directory"] = "Папка записей", ["Settings.Recording.PickFolder"] = "Выбрать…", @@ -368,6 +384,10 @@ private static LangCode DetectSystem() ["CameraEditor.Label.Username"] = "Логин", ["CameraEditor.Label.Password"] = "Пароль", ["CameraEditor.Label.Group"] = "Группа", + ["CameraEditor.Label.StreamQuality"] = "Качество в гриде", + ["CameraEditor.Quality.Auto"] = "Авто (SD/HD)", + ["CameraEditor.Quality.AlwaysHd"] = "Всегда HD (main)", + ["CameraEditor.Quality.AlwaysSd"] = "Всегда SD (sub)", ["CameraEditor.Placeholder.Name"] = "Входная", ["CameraEditor.Placeholder.Host"] = "192.168.1.10", ["CameraEditor.Placeholder.OnvifPort"] = "8899", diff --git a/src/OpenIPC.Viewer.App/Services/UserSettings.cs b/src/OpenIPC.Viewer.App/Services/UserSettings.cs index aa62fe5..17c4e95 100644 --- a/src/OpenIPC.Viewer.App/Services/UserSettings.cs +++ b/src/OpenIPC.Viewer.App/Services/UserSettings.cs @@ -11,6 +11,13 @@ public sealed record UserSettings( bool AutoScanLanOnStartup = false, int MaxConcurrentGridSessions = 9, string RtspTransport = "tcp", + // Auto SD/HD (Phase 12.2): substream in the multi-camera grid, mainstream + // when a single tile fills the view (1×1 layout / single-camera page). + // Off → always substream in the grid. + bool AutoSdHd = true, + // Local IPv4 to bind WS-Discovery to (Phase 12.6). "" = auto-pick the best + // LAN interface (ignore VPN/virtual adapters). + string PreferredNetworkInterface = "", string RecordingsDirOverride = "", // "system" follows CurrentUICulture; "en"/"ru" force a specific locale. string Language = "system", diff --git a/src/OpenIPC.Viewer.App/Services/UserSettingsService.cs b/src/OpenIPC.Viewer.App/Services/UserSettingsService.cs index 8b083a9..b766724 100644 --- a/src/OpenIPC.Viewer.App/Services/UserSettingsService.cs +++ b/src/OpenIPC.Viewer.App/Services/UserSettingsService.cs @@ -22,6 +22,8 @@ public sealed class UserSettingsService : IUserSettingsAccessor public string? RecordingsDirectoryOverride => string.IsNullOrWhiteSpace(Current.RecordingsDirOverride) ? null : Current.RecordingsDirOverride; public int MaxConcurrentGridSessions => Current.MaxConcurrentGridSessions; + public string? PreferredNetworkInterface => + string.IsNullOrWhiteSpace(Current.PreferredNetworkInterface) ? null : Current.PreferredNetworkInterface; private static readonly JsonSerializerOptions JsonOpts = new() { diff --git a/src/OpenIPC.Viewer.App/Themes/Theme.axaml b/src/OpenIPC.Viewer.App/Themes/Theme.axaml index 29e967e..1c42da2 100644 --- a/src/OpenIPC.Viewer.App/Themes/Theme.axaml +++ b/src/OpenIPC.Viewer.App/Themes/Theme.axaml @@ -78,5 +78,7 @@ M10,5 H3 M12,19 H3 M14,3 V7 M16,17 V21 M21,12 H12 M21,19 H16 M21,5 H14 M8,10 V14 M8,12 H3 M2,12 A10,10 0 1 0 22,12 A10,10 0 1 0 2,12 Z M12,16 v-4 M12,8 h0.01 + + M19,5 L22,2 M2,22 L5,19 M6.3,20.3 a2.4,2.4 0 0 0 3.4,0 L12,18 L6,12 L3.7,14.3 a2.4,2.4 0 0 0 0,3.4 Z M7.5,13.5 L10,11 M10.5,16.5 L13,14 M12,6 L18,12 L20.3,9.7 a2.4,2.4 0 0 0 0,-3.4 L17.7,3.7 a2.4,2.4 0 0 0 -3.4,0 Z diff --git a/src/OpenIPC.Viewer.App/ViewModels/CameraTileViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/CameraTileViewModel.cs index 38b82e3..d3813b8 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/CameraTileViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/CameraTileViewModel.cs @@ -22,7 +22,11 @@ public sealed partial class CameraTileViewModel : ViewModelBase, IAsyncDisposabl private readonly UserSettingsService _userSettings; private readonly ILogger _logger; - private readonly StreamQuality _quality = StreamQuality.Sub; + // Auto SD/HD (Phase 12.2): substream in the grid, mainstream when a single + // tile fills the view. Mutated by SetQualityAsync (a session swap), set + // pre-activation by SetInitialQuality so a tile opening straight into 1×1 + // doesn't briefly start on the substream. + private StreamQuality _quality = StreamQuality.Sub; private IDisposable? _stateSub; private IDisposable? _telemetrySub; private bool _started; @@ -30,11 +34,23 @@ public sealed partial class CameraTileViewModel : ViewModelBase, IAsyncDisposabl public Camera Camera { get; } - [ObservableProperty] private IVideoSession? _session; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsConnecting))] + private IVideoSession? _session; [ObservableProperty] [NotifyPropertyChangedFor(nameof(StateLabel))] + [NotifyPropertyChangedFor(nameof(IsConnecting))] + [NotifyPropertyChangedFor(nameof(IsFailed))] + [NotifyPropertyChangedFor(nameof(ErrorDetail))] + [NotifyPropertyChangedFor(nameof(ConnectingLabel))] private SessionState _state = SessionState.Idle; - [ObservableProperty] private SessionTelemetry? _telemetry; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(StatsLabel))] + [NotifyPropertyChangedFor(nameof(HasStats))] + private SessionTelemetry? _telemetry; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ErrorDetail))] + private string? _errorMessage; public string Name => Camera.Name; public string StateLabel => State switch @@ -47,6 +63,51 @@ public sealed partial class CameraTileViewModel : ViewModelBase, IAsyncDisposabl _ => "IDLE", }; + // Mid-connect dim overlay (spinner). Gated on Session so the pre-activate + // window doesn't flash a spinner out of nowhere. + public bool IsConnecting => + Session is not null && State is SessionState.Connecting or SessionState.Reconnecting; + + // Drives the interactive error cell (icon + reason + Retry/Close). + public bool IsFailed => State == SessionState.Failed; + + // Text under the connecting spinner — distinguishes a fresh connect from a + // reconnect attempt. + public string ConnectingLabel => State == SessionState.Reconnecting + ? Localizer.Instance["Stream.Reconnecting"] + : Localizer.Instance["Stream.Connecting"]; + + // Reason line for the error cell: server-supplied error (or a generic + // fallback) followed by the camera host, e.g. "Stream timeout · 10.0.0.42". + public string ErrorDetail + { + get + { + var reason = string.IsNullOrWhiteSpace(ErrorMessage) + ? Localizer.Instance["Stream.Unavailable"] + : ErrorMessage; + return $"{reason} · {Camera.Host}"; + } + } + + public bool HasStats => Telemetry is not null; + + // Bottom-right stats badge: codec • resolution • fps • bitrate. + public string? StatsLabel + { + get + { + var t = Telemetry; + if (t is null) return null; + var codec = string.IsNullOrEmpty(t.Codec) ? "—" : t.Codec; + return $"{codec} • {t.Width}×{t.Height} • {t.Fps:F0} fps • {FormatBitrate(t.BitrateKbps)}"; + } + } + + private static string FormatBitrate(double kbps) => kbps >= 1000 + ? $"{kbps / 1000:F1} Mb/s" + : $"{kbps:F0} kb/s"; + public CameraTileViewModel( Camera camera, LiveStreamCoordinator coordinator, @@ -63,13 +124,43 @@ public CameraTileViewModel( _coordinator.Invalidated += OnCoordinatorInvalidated; } + // Set the stream quality before the first ActivateAsync. No-op once started + // — use SetQualityAsync to switch a live tile. + public void SetInitialQuality(StreamQuality quality) + { + if (_started) return; + _quality = quality; + } + + // Auto SD/HD swap on a live tile: tear the current-quality session down and + // re-acquire on the other stream. Mirrors RetryAsync. The coordinator keys + // sessions by (camera, quality), so we must release with the OLD quality + // before flipping the field. + public async Task SetQualityAsync(StreamQuality quality, CancellationToken ct) + { + if (_disposed || quality == _quality) return; + _stateSub?.Dispose(); + _telemetrySub?.Dispose(); + if (Session is not null) + { + Session = null; + await _coordinator.ReleaseAsync(Camera.Id, _quality).ConfigureAwait(true); + } + _quality = quality; + State = SessionState.Idle; + _started = false; + await ActivateAsync(ct).ConfigureAwait(true); + } + public async Task ActivateAsync(CancellationToken ct) { if (_started) return; _started = true; - var streamUri = Camera.RtspSubUri ?? Camera.RtspMainUri; - if (Camera.RtspSubUri is null) + var streamUri = _quality == StreamQuality.Main + ? Camera.RtspMainUri + : (Camera.RtspSubUri ?? Camera.RtspMainUri); + if (_quality == StreamQuality.Sub && Camera.RtspSubUri is null) _logger.LogWarning("Camera {Name} has no substream URL, using mainstream in grid", Camera.Name); var creds = await _directory.GetCredentialsAsync(Camera.Id, ct).ConfigureAwait(true); @@ -82,7 +173,12 @@ public async Task ActivateAsync(CancellationToken ct) try { var session = _coordinator.Acquire(Camera.Id, _quality, options); - _stateSub = session.StateChanged.Subscribe(s => State = s); + _stateSub = session.StateChanged.Subscribe(s => + { + State = s; + if (s == SessionState.Failed) + ErrorMessage = session.LastError; + }); _telemetrySub = session.Telemetry.Subscribe(t => Telemetry = t); Session = session; @@ -131,6 +227,36 @@ private void OnCoordinatorInvalidated(object? sender, EventArgs e) private void OpenSingle() => WeakReferenceMessenger.Default.Send(new OpenCameraMessage(Camera.Id)); + // Retry button on the error cell. Mirrors OnCoordinatorInvalidated: drop the + // dead session, reset state, and re-run the full Activate path. + [RelayCommand] + private async Task RetryAsync() + { + if (_disposed) return; + ErrorMessage = null; + _stateSub?.Dispose(); + _telemetrySub?.Dispose(); + if (Session is not null) + { + Session = null; + await _coordinator.ReleaseAsync(Camera.Id, _quality).ConfigureAwait(true); + } + State = SessionState.Idle; + _started = false; + await ActivateAsync(CancellationToken.None).ConfigureAwait(true); + } + + // Close button on the error cell — drops this tile from the grid for the + // session (GridPageViewModel handles the message). + [RelayCommand] + private void Close() => + WeakReferenceMessenger.Default.Send(new CloseTileMessage(Camera.Id)); + + // Smart Pause (Phase 12.1): suspend/resume decode without dropping the + // session, so the last frame stays frozen for an instant resume. + public void Pause() => Session?.PauseDecode(); + public void Resume() => Session?.Resume(); + public async ValueTask DisposeAsync() { _disposed = true; diff --git a/src/OpenIPC.Viewer.App/ViewModels/Dialogs/CameraEditorViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/Dialogs/CameraEditorViewModel.cs index 394d983..64971cb 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/Dialogs/CameraEditorViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/Dialogs/CameraEditorViewModel.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Linq; using System.Reactive.Linq; using System.Reactive.Threading.Tasks; using System.Threading; @@ -35,6 +37,16 @@ public sealed partial class CameraEditorViewModel : ViewModelBase [ObservableProperty] private string _password = ""; [ObservableProperty] private CameraGroup? _selectedGroup; + // Per-camera SD/HD override (Phase 12.2). + public IReadOnlyList StreamQualityOptions { get; } = new[] + { + new StreamQualityOption(Localizer.Instance["CameraEditor.Quality.Auto"], StreamQualityOverride.Auto), + new StreamQualityOption(Localizer.Instance["CameraEditor.Quality.AlwaysHd"], StreamQualityOverride.AlwaysHd), + new StreamQualityOption(Localizer.Instance["CameraEditor.Quality.AlwaysSd"], StreamQualityOverride.AlwaysSd), + }; + + [ObservableProperty] private StreamQualityOption? _selectedStreamQuality; + // Includes a leading null entry so the user can pick "no group". public ObservableCollection AvailableGroups { get; } = new(); @@ -55,6 +67,7 @@ public CameraEditorViewModel(IVideoEngine engine, CameraDirectoryService directo _directory = directory; _userSettings = userSettings; _logger = logger; + SelectedStreamQuality = StreamQualityOptions[0]; // Auto } public CameraEditorViewModel(Camera existing, CameraCredentials? credentials, IVideoEngine engine, CameraDirectoryService directory, UserSettingsService userSettings, ILogger logger) @@ -70,6 +83,8 @@ public CameraEditorViewModel(Camera existing, CameraCredentials? credentials, IV Username = credentials?.Username ?? ""; Password = credentials?.Password ?? ""; _pendingGroupId = existing.GroupId; + SelectedStreamQuality = StreamQualityOptions.FirstOrDefault(o => o.Value == existing.StreamQualityOverride) + ?? StreamQualityOptions[0]; } public async Task LoadGroupsAsync(CancellationToken ct) @@ -156,6 +171,8 @@ public bool TryBuildRequest(out NewCameraRequest? newRequest, out UpdateCameraRe ? null : new CameraCredentials(Username, Password); + var quality = SelectedStreamQuality?.Value ?? StreamQualityOverride.Auto; + if (EditingId is null) { newRequest = new NewCameraRequest( @@ -166,7 +183,8 @@ public bool TryBuildRequest(out NewCameraRequest? newRequest, out UpdateCameraRe RtspMainUri: rtspMain, RtspSubUri: rtspSub, Credentials: credentials, - GroupId: SelectedGroup?.Id); + GroupId: SelectedGroup?.Id, + StreamQualityOverride: quality); } else { @@ -178,7 +196,8 @@ public bool TryBuildRequest(out NewCameraRequest? newRequest, out UpdateCameraRe RtspMainUri: rtspMain, RtspSubUri: rtspSub, Credentials: credentials, - GroupId: SelectedGroup?.Id); + GroupId: SelectedGroup?.Id, + StreamQualityOverride: quality); } return ok; @@ -250,3 +269,6 @@ private bool TryValidate(out bool ok, out Uri rtspMain, out Uri? rtspSub, out in } public sealed record CameraEditorResult(NewCameraRequest? NewRequest, UpdateCameraRequest? UpdateRequest); + +// Combo item for the per-camera SD/HD override picker (Phase 12.2). +public sealed record StreamQualityOption(string Display, StreamQualityOverride Value); diff --git a/src/OpenIPC.Viewer.App/ViewModels/GridPageViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/GridPageViewModel.cs index 2aec24a..145e198 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/GridPageViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/GridPageViewModel.cs @@ -19,6 +19,7 @@ namespace OpenIPC.Viewer.App.ViewModels; public sealed partial class GridPageViewModel : ViewModelBase, IRecipient, IRecipient, + IRecipient, IAsyncDisposable { private readonly CameraDirectoryService _directory; @@ -29,6 +30,7 @@ public sealed partial class GridPageViewModel : ViewModelBase, private IReadOnlyList _allCameras = Array.Empty(); private bool _minimized; + private CancellationTokenSource? _graceCts; public string Title => Localizer.Instance["Nav.Live"]; @@ -57,6 +59,7 @@ public GridPageViewModel( WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); + WeakReferenceMessenger.Default.Register(this); // Re-render when the user changes the max-sessions cap so currently- // dropped cameras come back (or excess ones go away) without a relaunch. @@ -115,11 +118,18 @@ private async Task RefreshTilesAsync(CancellationToken ct) // only time an edit can have landed), so the swap happens on next view. foreach (var camera in visible) { + var quality = DesiredQuality(camera); var existing = Tiles.FirstOrDefault(t => t.Camera.Id == camera.Id); if (existing is not null) { if (!StreamUriChanged(existing.Camera, camera)) + { + // Kept tile — re-evaluate SD/HD against the (possibly new) + // layout. No-op when quality is unchanged. + try { await existing.SetQualityAsync(quality, ct).ConfigureAwait(true); } + catch (Exception ex) { _logger.LogWarning(ex, "Failed to switch quality for {Camera}", camera.Name); } continue; + } var idx = Tiles.IndexOf(existing); Tiles.RemoveAt(idx); @@ -127,6 +137,7 @@ private async Task RefreshTilesAsync(CancellationToken ct) catch (Exception ex) { _logger.LogWarning(ex, "Error releasing stale tile for {Camera}", camera.Name); } var rebuilt = new CameraTileViewModel(camera, _coordinator, _directory, _userSettings, _loggerFactory.CreateLogger()); + rebuilt.SetInitialQuality(quality); Tiles.Insert(idx, rebuilt); try { await rebuilt.ActivateAsync(ct).ConfigureAwait(true); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to activate rebuilt tile for {Camera}", camera.Name); } @@ -134,6 +145,7 @@ private async Task RefreshTilesAsync(CancellationToken ct) } var tile = new CameraTileViewModel(camera, _coordinator, _directory, _userSettings, _loggerFactory.CreateLogger()); + tile.SetInitialQuality(quality); Tiles.Add(tile); try { await tile.ActivateAsync(ct).ConfigureAwait(true); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to activate tile for {Camera}", camera.Name); } @@ -154,6 +166,12 @@ private async Task RefreshTilesAsync(CancellationToken ct) private static bool StreamUriChanged(Camera a, Camera b) => (a.RtspSubUri ?? a.RtspMainUri) != (b.RtspSubUri ?? b.RtspMainUri); + // Auto SD/HD policy (Phase 12.2): mainstream only when a single tile fills + // the view (1×1 layout) and the user hasn't disabled the feature; otherwise + // the substream keeps the multi-camera grid light. + private StreamQuality DesiredQuality(Camera camera) => + StreamQualityPolicy.Resolve(camera.StreamQualityOverride, _userSettings.Current.AutoSdHd, LayoutSize); + // Drag-reorder hook called from GridPage code-behind. Both indices are in // the *Tiles* collection (live cameras only — empty Slots placeholders are // not draggable and can't be drop targets). Persists SortOrder = newIndex @@ -196,16 +214,70 @@ public async Task MoveTileAsync(int fromIndex, int toIndex, CancellationToken ct } } - public async void Receive(WindowMinimizedMessage message) + // Close button on a tile's error cell — drop it from the grid for this + // session and leave an empty slot in its place. The camera stays + // IncludedInGrid, so re-entering Live re-adds it via RefreshTilesAsync. + public async void Receive(CloseTileMessage message) + { + var tile = Tiles.FirstOrDefault(t => t.Camera.Id == message.CameraId); + if (tile is null) return; + Tiles.Remove(tile); + RebuildSlots(); + try { await tile.DisposeAsync().ConfigureAwait(true); } + catch (Exception ex) { _logger.LogWarning(ex, "Error releasing closed tile"); } + } + + // Re-pad Slots to the visual grid size (LayoutSize²), filling trailing + // gaps with null placeholders. + private void RebuildSlots() + { + var visualCapacity = LayoutSize * LayoutSize; + Slots.Clear(); + for (var i = 0; i < visualCapacity; i++) + Slots.Add(i < Tiles.Count ? Tiles[i] : null); + } + + // Smart Pause (Phase 12.1): on minimize, pause decode immediately (CPU + // drops, last frame stays frozen for an instant resume) and start a grace + // timer. Only if the window is still hidden after the grace period do we + // fully release the sessions to free RTSP connections. + private static readonly TimeSpan PauseGrace = TimeSpan.FromSeconds(10); + + public void Receive(WindowMinimizedMessage message) { _minimized = true; - await ReleaseAllAsync().ConfigureAwait(true); + foreach (var tile in Tiles) + tile.Pause(); + + _graceCts?.Cancel(); + _graceCts = new CancellationTokenSource(); + var token = _graceCts.Token; + _ = Task.Run(async () => + { + try { await Task.Delay(PauseGrace, token).ConfigureAwait(true); } + catch (OperationCanceledException) { return; } + await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(async () => + { + if (_minimized) await ReleaseAllAsync().ConfigureAwait(true); + }); + }); } public async void Receive(WindowRestoredMessage message) { _minimized = false; - await LoadAsync(CancellationToken.None).ConfigureAwait(true); + _graceCts?.Cancel(); + if (Tiles.Count > 0) + { + // Still paused (within grace) — resume in place, frozen frame intact. + foreach (var tile in Tiles) + tile.Resume(); + } + else + { + // Grace elapsed and sessions were released — rebuild the grid. + await LoadAsync(CancellationToken.None).ConfigureAwait(true); + } } private async Task ReleaseAllAsync() @@ -223,6 +295,7 @@ private async Task ReleaseAllAsync() public async ValueTask DisposeAsync() { WeakReferenceMessenger.Default.UnregisterAll(this); + _graceCts?.Cancel(); await ReleaseAllAsync().ConfigureAwait(false); } } diff --git a/src/OpenIPC.Viewer.App/ViewModels/SettingsPageViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/SettingsPageViewModel.cs index 7bf1e5e..71814e0 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/SettingsPageViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/SettingsPageViewModel.cs @@ -2,9 +2,12 @@ using System.Reflection; using System.Threading; using System.Threading.Tasks; +using System.Collections.Generic; +using System.Linq; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using OpenIPC.Viewer.App.Services; +using OpenIPC.Viewer.Core.Onvif.Discovery; using OpenIPC.Viewer.Core.Platform; namespace OpenIPC.Viewer.App.ViewModels; @@ -24,6 +27,9 @@ public sealed partial class SettingsPageViewModel : ViewModelBase [ObservableProperty] private bool _autoScanLanOnStartup; [ObservableProperty] private int _maxConcurrentGridSessions; [ObservableProperty] private string _rtspTransport = "tcp"; + [ObservableProperty] private bool _autoSdHd = true; + + [ObservableProperty] private NetworkInterfaceOption? _selectedNetworkInterface; [ObservableProperty] private string _language = "system"; [ObservableProperty] @@ -87,15 +93,31 @@ partial void OnIsWideChanged(bool value) public string[] TransportOptions { get; } = new[] { "tcp", "udp" }; public string[] LanguageOptions { get; } = new[] { "system", "en", "ru" }; + // Auto + each usable LAN adapter (Phase 12.6). Display shown in the combo, + // Value persisted ("" = auto). + public IReadOnlyList NetworkInterfaceOptions { get; } + public string AppDataDirectory => _fs.AppDataDir.FullName; public string Version => Assembly.GetEntryAssembly()?.GetName().Version?.ToString() ?? "0.1.0"; public string RepositoryUrl => "https://github.com/keyldev/openipc-viewer"; - public SettingsPageViewModel(UserSettingsService settings, IFileSystem fs, IDialogService dialogs) + public SettingsPageViewModel( + UserSettingsService settings, + IFileSystem fs, + IDialogService dialogs, + INetworkInterfaceProvider nics) { _settings = settings; _fs = fs; _dialogs = dialogs; + + var options = new List + { + new(Localizer.Instance["Settings.Video.NetworkInterface.Auto"], ""), + }; + options.AddRange(nics.GetCandidates().Select(c => new NetworkInterfaceOption(c.DisplayName, c.Address))); + NetworkInterfaceOptions = options; + Load(); } @@ -114,6 +136,10 @@ private void Load() AutoScanLanOnStartup = s.AutoScanLanOnStartup; MaxConcurrentGridSessions = s.MaxConcurrentGridSessions; RtspTransport = s.RtspTransport; + AutoSdHd = s.AutoSdHd; + SelectedNetworkInterface = + NetworkInterfaceOptions.FirstOrDefault(o => o.Value == s.PreferredNetworkInterface) + ?? NetworkInterfaceOptions[0]; RecordingsDirOverride = s.RecordingsDirOverride; Language = s.Language; } @@ -126,6 +152,8 @@ private void Load() partial void OnAutoScanLanOnStartupChanged(bool value) => Persist(); partial void OnMaxConcurrentGridSessionsChanged(int value) => Persist(); partial void OnRtspTransportChanged(string value) => Persist(); + partial void OnAutoSdHdChanged(bool value) => Persist(); + partial void OnSelectedNetworkInterfaceChanged(NetworkInterfaceOption? value) => Persist(); partial void OnRecordingsDirOverrideChanged(string value) => Persist(); partial void OnLanguageChanged(string value) => Persist(); @@ -140,6 +168,8 @@ private void Persist() AutoScanLanOnStartup = AutoScanLanOnStartup, MaxConcurrentGridSessions = MaxConcurrentGridSessions, RtspTransport = RtspTransport, + AutoSdHd = AutoSdHd, + PreferredNetworkInterface = SelectedNetworkInterface?.Value ?? "", RecordingsDirOverride = RecordingsDirOverride, Language = Language, }; @@ -181,3 +211,8 @@ private static void OpenInShell(string target) catch (Exception) { /* best effort — Android sandbox blocks shell-open, desktop works */ } } } + +// Combo item for the Settings → Video network-interface picker (Phase 12.6). +// Display is the human label ("Auto" / "Ethernet (192.168.1.5)"); Value is the +// persisted IPv4 ("" = auto). +public sealed record NetworkInterfaceOption(string Display, string Value); diff --git a/src/OpenIPC.Viewer.App/Views/Dialogs/CameraEditorContent.axaml b/src/OpenIPC.Viewer.App/Views/Dialogs/CameraEditorContent.axaml index b54ed55..de62aab 100644 --- a/src/OpenIPC.Viewer.App/Views/Dialogs/CameraEditorContent.axaml +++ b/src/OpenIPC.Viewer.App/Views/Dialogs/CameraEditorContent.axaml @@ -107,6 +107,14 @@ + + + + + - + - - - + IsVisible="{Binding HasStats}"> + + + + + + + + + + + + + + + + + + +