From 10f4be11f2b016cc72bd388824517d1196a9a8aa Mon Sep 17 00:00:00 2001 From: keyldev Date: Sun, 7 Jun 2026 00:11:23 +0300 Subject: [PATCH] =?UTF-8?q?fix(live):=20sync=20edited=20RTSP=20URL=20witho?= =?UTF-8?q?ut=20an=20app=20restart=20MainView=20hosted=20CurrentPage=20in?= =?UTF-8?q?=20two=20ContentControls=20(one=20per=20layout),=20so=20the=20s?= =?UTF-8?q?ame=20page=20VM=20bound=20to=20two=20views=20=E2=80=94=20both?= =?UTF-8?q?=20fired=20Loaded=20and=20double-activated=20the=20session=20(d?= =?UTF-8?q?ouble=20Acquire=20+=20"Already=20started"),=20leaking=20a=20coo?= =?UTF-8?q?rdinator=20ref=20so=20the=20stale=20session=20was=20reused=20on?= =?UTF-8?q?=20next=20open.=20-=20MainView:=20host=20CurrentPage=20in=20a?= =?UTF-8?q?=20single=20shared=20ContentControl;=20only=20the=20chrome=20(s?= =?UTF-8?q?idebar=20/=20bottom=20nav)=20toggles=20on=20the=20breakpoint.?= =?UTF-8?q?=20ContentPadding=20moved=20to=20a=20property=20since=20one=20h?= =?UTF-8?q?ost=20can't=20switch=20padding=20via=20two=20layouts=20-=20Sing?= =?UTF-8?q?leCameraPageViewModel:=20make=20Activate/Dispose=20idempotent?= =?UTF-8?q?=20(re-entrancy=20gate=20+=20dispose=20guard)=20so=20a=20sessio?= =?UTF-8?q?n=20is=20acquired=20and=20released=20exactly=20once=20-=20GridP?= =?UTF-8?q?ageViewModel:=20rebuild=20a=20tile=20when=20its=20effective=20s?= =?UTF-8?q?tream=20URL=20changes=20instead=20of=20keeping=20the=20stale=20?= =?UTF-8?q?tile=20by=20Id=20(refreshes=20on=20next=20Live-tab=20entry)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ViewModels/GridPageViewModel.cs | 32 +++- .../ViewModels/SingleCameraPageViewModel.cs | 79 ++++++--- src/OpenIPC.Viewer.App/Views/MainView.axaml | 151 +++++++++--------- .../Views/MainView.axaml.cs | 20 +++ 4 files changed, 180 insertions(+), 102 deletions(-) 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();