diff --git a/src/OpenIPC.Viewer.App/ViewModels/GridPageViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/GridPageViewModel.cs index f97b3fb..2aec24a 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/GridPageViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/GridPageViewModel.cs @@ -107,11 +107,32 @@ private async Task RefreshTilesAsync(CancellationToken ct) } } - // Add new ones. + // Reconcile against the freshly-loaded records: keep unchanged tiles, + // rebuild ones whose stream URL was edited, add new ones. Without the + // rebuild branch an edited RTSP URL never reached the grid — the tile + // was matched by Id and kept, so it kept streaming the old URL until an + // app restart. RefreshTilesAsync re-runs on every Live-tab entry (the + // only time an edit can have landed), so the swap happens on next view. foreach (var camera in visible) { - if (Tiles.Any(t => t.Camera.Id == camera.Id)) + var existing = Tiles.FirstOrDefault(t => t.Camera.Id == camera.Id); + if (existing is not null) + { + if (!StreamUriChanged(existing.Camera, camera)) + continue; + + var idx = Tiles.IndexOf(existing); + Tiles.RemoveAt(idx); + try { await existing.DisposeAsync().ConfigureAwait(true); } + catch (Exception ex) { _logger.LogWarning(ex, "Error releasing stale tile for {Camera}", camera.Name); } + + var rebuilt = new CameraTileViewModel(camera, _coordinator, _directory, _userSettings, _loggerFactory.CreateLogger()); + Tiles.Insert(idx, rebuilt); + try { await rebuilt.ActivateAsync(ct).ConfigureAwait(true); } + catch (Exception ex) { _logger.LogWarning(ex, "Failed to activate rebuilt tile for {Camera}", camera.Name); } continue; + } + var tile = new CameraTileViewModel(camera, _coordinator, _directory, _userSettings, _loggerFactory.CreateLogger()); Tiles.Add(tile); try { await tile.ActivateAsync(ct).ConfigureAwait(true); } @@ -126,6 +147,13 @@ private async Task RefreshTilesAsync(CancellationToken ct) Slots.Add(i < Tiles.Count ? Tiles[i] : null); } + // A tile is rebuilt only when the URL it actually streams changes — the + // grid uses the substream, falling back to main (mirrors + // CameraTileViewModel.ActivateAsync). Comparing this (rather than the whole + // record) avoids churning sessions on cosmetic edits like a rename. + private static bool StreamUriChanged(Camera a, Camera b) => + (a.RtspSubUri ?? a.RtspMainUri) != (b.RtspSubUri ?? b.RtspMainUri); + // 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 diff --git a/src/OpenIPC.Viewer.App/ViewModels/SingleCameraPageViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/SingleCameraPageViewModel.cs index 8d3f828..3c536cd 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/SingleCameraPageViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/SingleCameraPageViewModel.cs @@ -39,6 +39,17 @@ public sealed partial class SingleCameraPageViewModel : ViewModelBase, IAsyncDis private IDisposable? _stateSub; private IDisposable? _telemetrySub; + // Re-entrancy/lifecycle gates. MainView hosts CurrentPage in BOTH the wide + // and narrow layouts, so two views bind this single VM and each fires + // Loaded -> ActivateAsync / Unloaded -> DisposeAsync. Without these gates + // the two Activate calls both pass the `Session is null` check before the + // first await assigns it, double-Acquire (leaking a coordinator ref so the + // session is never released) and double-Start (the 2nd throws "Already + // started"). The leaked, stale session is then reused on the next open — + // which is why an edited RTSP URL didn't take effect until an app restart. + private bool _activating; + private bool _disposed; + [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsConnecting))] private IVideoSession? _session; @@ -272,39 +283,51 @@ public async Task NavigateRelativeAsync(int offset, CancellationToken ct) public async Task ActivateAsync(CancellationToken ct) { - if (Session is not null) + // Synchronous gate (no await between check and set) so a second caller + // can't slip past while the first is mid-activation. Cleared in finally + // so legitimate re-activation (ReloadStreamAsync nulls Session first) + // still works. + if (Session is not null || _activating || _disposed) return; - - var creds = await _directory.GetCredentialsAsync(_camera.Id, ct).ConfigureAwait(true); - var options = VideoSessionOptions.Default(_camera.RtspMainUri, creds) - with { Transport = ParseTransport(_userSettings.Current.RtspTransport) }; + _activating = true; try { - var session = _coordinator.Acquire(_camera.Id, _quality, options); - _stateSub = session.StateChanged.Subscribe(s => + var creds = await _directory.GetCredentialsAsync(_camera.Id, ct).ConfigureAwait(true); + var options = VideoSessionOptions.Default(_camera.RtspMainUri, creds) + with { Transport = ParseTransport(_userSettings.Current.RtspTransport) }; + + try { - State = s; - if (s == SessionState.Failed) - ErrorMessage = session.LastError; - }); - _telemetrySub = session.Telemetry.Subscribe(t => Telemetry = t); - Session = session; + var session = _coordinator.Acquire(_camera.Id, _quality, options); + _stateSub = session.StateChanged.Subscribe(s => + { + State = s; + if (s == SessionState.Failed) + ErrorMessage = session.LastError; + }); + _telemetrySub = session.Telemetry.Subscribe(t => Telemetry = t); + Session = session; + + if (session.State == SessionState.Idle) + await session.StartAsync(ct).ConfigureAwait(true); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to start session for camera {CameraId}", _camera.Id); + ErrorMessage = ex.Message; + State = SessionState.Failed; + } + + if (HasPtz) + await InitPtzAsync(creds, ct).ConfigureAwait(true); - if (session.State == SessionState.Idle) - await session.StartAsync(ct).ConfigureAwait(true); + await InitMajesticAsync(creds, ct).ConfigureAwait(true); } - catch (Exception ex) + finally { - _logger.LogError(ex, "Failed to start session for camera {CameraId}", _camera.Id); - ErrorMessage = ex.Message; - State = SessionState.Failed; + _activating = false; } - - if (HasPtz) - await InitPtzAsync(creds, ct).ConfigureAwait(true); - - await InitMajesticAsync(creds, ct).ConfigureAwait(true); } private async Task InitMajesticAsync(CameraCredentials? creds, CancellationToken ct) @@ -680,6 +703,14 @@ private void Back() => public async ValueTask DisposeAsync() { + // Idempotent: the two hosting views (wide + narrow layout) each fire + // Unloaded -> DisposeAsync, and MainWindowViewModel disposes us on + // navigation too. Only the first call releases the session, so the + // coordinator ref-count lands back at zero instead of leaking. + if (_disposed) + return; + _disposed = true; + _stateSub?.Dispose(); _telemetrySub?.Dispose(); _recordings.StateChanged -= OnRecordingsStateChanged; diff --git a/src/OpenIPC.Viewer.App/Views/MainView.axaml b/src/OpenIPC.Viewer.App/Views/MainView.axaml index 6e54aca..4a37b33 100644 --- a/src/OpenIPC.Viewer.App/Views/MainView.axaml +++ b/src/OpenIPC.Viewer.App/Views/MainView.axaml @@ -16,90 +16,89 @@ wide → desktop sidebar on the left, content fills the rest narrow → bottom-nav under the content (mobile / pinned-narrow window) IsWideLayout is updated by code-behind on every SizeChanged. - --> - - - - - - - + CurrentPage is hosted in a SINGLE ContentControl shared by both layouts. + Hosting it twice (one ContentControl per layout) made the same page VM bind + to two views at once — both fired Loaded and double-activated the live + session (double Acquire + "Already started"). Only the chrome toggles on the + breakpoint now: the sidebar collapses to zero width and the bottom nav hides. + --> - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + - - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + - - - + + + + Padding="{Binding #Root.ContentPadding}" /> diff --git a/src/OpenIPC.Viewer.App/Views/MainView.axaml.cs b/src/OpenIPC.Viewer.App/Views/MainView.axaml.cs index a9b4937..3d22f12 100644 --- a/src/OpenIPC.Viewer.App/Views/MainView.axaml.cs +++ b/src/OpenIPC.Viewer.App/Views/MainView.axaml.cs @@ -18,7 +18,18 @@ public partial class MainView : UserControl AvaloniaProperty.RegisterDirect( nameof(IsNarrowLayout), o => o.IsNarrowLayout); + // Content inset differs by layout (desktop has more breathing room). Exposed + // as a property because the single shared ContentControl can no longer pick + // its Padding from two separate layout literals. + public static readonly DirectProperty ContentPaddingProperty = + AvaloniaProperty.RegisterDirect( + nameof(ContentPadding), o => o.ContentPadding); + + private static readonly Thickness WidePadding = new(24); + private static readonly Thickness NarrowPadding = new(12); + private bool _isWideLayout = true; + private Thickness _contentPadding = WidePadding; public bool IsWideLayout { @@ -26,12 +37,21 @@ public bool IsWideLayout private set { if (SetAndRaise(IsWideLayoutProperty, ref _isWideLayout, value)) + { RaisePropertyChanged(IsNarrowLayoutProperty, !value, value == false); + ContentPadding = value ? WidePadding : NarrowPadding; + } } } public bool IsNarrowLayout => !_isWideLayout; + public Thickness ContentPadding + { + get => _contentPadding; + private set => SetAndRaise(ContentPaddingProperty, ref _contentPadding, value); + } + public MainView() { InitializeComponent();