Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions src/OpenIPC.Viewer.App/ViewModels/GridPageViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<CameraTileViewModel>());
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<CameraTileViewModel>());
Tiles.Add(tile);
try { await tile.ActivateAsync(ct).ConfigureAwait(true); }
Expand All @@ -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
Expand Down
79 changes: 55 additions & 24 deletions src/OpenIPC.Viewer.App/ViewModels/SingleCameraPageViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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;
Expand Down
151 changes: 75 additions & 76 deletions src/OpenIPC.Viewer.App/Views/MainView.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
-->

<Grid>

<!-- Wide / desktop layout -->
<Grid ColumnDefinitions="200,*"
IsVisible="{Binding #Root.IsWideLayout}">

<Border Grid.Column="0"
Background="{StaticResource Bg2Brush}"
BorderBrush="{StaticResource BorderWeakBrush}"
BorderThickness="0,0,1,0">
<DockPanel LastChildFill="True" Margin="12">
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.
-->

<TextBlock DockPanel.Dock="Top"
Text="OpenIPC Viewer"
FontSize="{StaticResource FontSizeMd}"
FontWeight="SemiBold"
Foreground="{StaticResource TextPrimaryBrush}"
Margin="8,8,8,20" />
<Grid ColumnDefinitions="Auto,*">

<StackPanel Spacing="2" VerticalAlignment="Top">
<RadioButton Classes="sidebar" GroupName="DesktopNav"
IsChecked="{Binding IsLiveSelected, Mode=OneWay}"
Command="{Binding NavigateCommand}"
CommandParameter="live">
<StackPanel Orientation="Horizontal" Spacing="12">
<PathIcon Data="{StaticResource IconLive}" Width="16" Height="16" />
<TextBlock Text="{Binding [Nav.Live], Source={x:Static svc:Localizer.Instance}}" VerticalAlignment="Center" />
</StackPanel>
</RadioButton>
<RadioButton Classes="sidebar" GroupName="DesktopNav"
IsChecked="{Binding IsLibrarySelected, Mode=OneWay}"
Command="{Binding NavigateCommand}"
CommandParameter="library">
<StackPanel Orientation="Horizontal" Spacing="12">
<PathIcon Data="{StaticResource IconLibrary}" Width="16" Height="16" />
<TextBlock Text="{Binding [Nav.Library], Source={x:Static svc:Localizer.Instance}}" VerticalAlignment="Center" />
</StackPanel>
</RadioButton>
<RadioButton Classes="sidebar" GroupName="DesktopNav"
IsChecked="{Binding IsRecordingsSelected, Mode=OneWay}"
Command="{Binding NavigateCommand}"
CommandParameter="recordings">
<StackPanel Orientation="Horizontal" Spacing="12">
<PathIcon Data="{StaticResource IconRecordings}" Width="16" Height="16" />
<TextBlock Text="{Binding [Nav.Recordings], Source={x:Static svc:Localizer.Instance}}" VerticalAlignment="Center" />
</StackPanel>
</RadioButton>
<RadioButton Classes="sidebar" GroupName="DesktopNav"
IsChecked="{Binding IsEventsSelected, Mode=OneWay}"
Command="{Binding NavigateCommand}"
CommandParameter="events">
<StackPanel Orientation="Horizontal" Spacing="12">
<PathIcon Data="{StaticResource IconEvents}" Width="16" Height="16" />
<TextBlock Text="{Binding [Nav.Events], Source={x:Static svc:Localizer.Instance}}" VerticalAlignment="Center" />
</StackPanel>
</RadioButton>
<RadioButton Classes="sidebar" GroupName="DesktopNav"
IsChecked="{Binding IsSettingsSelected, Mode=OneWay}"
Command="{Binding NavigateCommand}"
CommandParameter="settings">
<StackPanel Orientation="Horizontal" Spacing="12">
<PathIcon Data="{StaticResource IconSettings}" Width="16" Height="16" />
<TextBlock Text="{Binding [Nav.Settings], Source={x:Static svc:Localizer.Instance}}" VerticalAlignment="Center" />
</StackPanel>
</RadioButton>
</StackPanel>
<!-- Desktop sidebar — fixed 200px wide; collapses to nothing when narrow. -->
<Border Grid.Column="0"
Width="200"
IsVisible="{Binding #Root.IsWideLayout}"
Background="{StaticResource Bg2Brush}"
BorderBrush="{StaticResource BorderWeakBrush}"
BorderThickness="0,0,1,0">
<DockPanel LastChildFill="True" Margin="12">

</DockPanel>
</Border>
<TextBlock DockPanel.Dock="Top"
Text="OpenIPC Viewer"
FontSize="{StaticResource FontSizeMd}"
FontWeight="SemiBold"
Foreground="{StaticResource TextPrimaryBrush}"
Margin="8,8,8,20" />

<ContentControl Grid.Column="1"
Content="{Binding CurrentPage}"
Padding="24" />
<StackPanel Spacing="2" VerticalAlignment="Top">
<RadioButton Classes="sidebar" GroupName="DesktopNav"
IsChecked="{Binding IsLiveSelected, Mode=OneWay}"
Command="{Binding NavigateCommand}"
CommandParameter="live">
<StackPanel Orientation="Horizontal" Spacing="12">
<PathIcon Data="{StaticResource IconLive}" Width="16" Height="16" />
<TextBlock Text="{Binding [Nav.Live], Source={x:Static svc:Localizer.Instance}}" VerticalAlignment="Center" />
</StackPanel>
</RadioButton>
<RadioButton Classes="sidebar" GroupName="DesktopNav"
IsChecked="{Binding IsLibrarySelected, Mode=OneWay}"
Command="{Binding NavigateCommand}"
CommandParameter="library">
<StackPanel Orientation="Horizontal" Spacing="12">
<PathIcon Data="{StaticResource IconLibrary}" Width="16" Height="16" />
<TextBlock Text="{Binding [Nav.Library], Source={x:Static svc:Localizer.Instance}}" VerticalAlignment="Center" />
</StackPanel>
</RadioButton>
<RadioButton Classes="sidebar" GroupName="DesktopNav"
IsChecked="{Binding IsRecordingsSelected, Mode=OneWay}"
Command="{Binding NavigateCommand}"
CommandParameter="recordings">
<StackPanel Orientation="Horizontal" Spacing="12">
<PathIcon Data="{StaticResource IconRecordings}" Width="16" Height="16" />
<TextBlock Text="{Binding [Nav.Recordings], Source={x:Static svc:Localizer.Instance}}" VerticalAlignment="Center" />
</StackPanel>
</RadioButton>
<RadioButton Classes="sidebar" GroupName="DesktopNav"
IsChecked="{Binding IsEventsSelected, Mode=OneWay}"
Command="{Binding NavigateCommand}"
CommandParameter="events">
<StackPanel Orientation="Horizontal" Spacing="12">
<PathIcon Data="{StaticResource IconEvents}" Width="16" Height="16" />
<TextBlock Text="{Binding [Nav.Events], Source={x:Static svc:Localizer.Instance}}" VerticalAlignment="Center" />
</StackPanel>
</RadioButton>
<RadioButton Classes="sidebar" GroupName="DesktopNav"
IsChecked="{Binding IsSettingsSelected, Mode=OneWay}"
Command="{Binding NavigateCommand}"
CommandParameter="settings">
<StackPanel Orientation="Horizontal" Spacing="12">
<PathIcon Data="{StaticResource IconSettings}" Width="16" Height="16" />
<TextBlock Text="{Binding [Nav.Settings], Source={x:Static svc:Localizer.Instance}}" VerticalAlignment="Center" />
</StackPanel>
</RadioButton>
</StackPanel>

</Grid>
</DockPanel>
</Border>

<!-- Narrow / mobile layout -->
<DockPanel IsVisible="{Binding #Root.IsNarrowLayout}"
LastChildFill="True">
<views:BottomNavBar DockPanel.Dock="Bottom" />
<!-- Content host + bottom nav (narrow only). One ContentControl, period. -->
<DockPanel Grid.Column="1" LastChildFill="True">
<views:BottomNavBar DockPanel.Dock="Bottom"
IsVisible="{Binding #Root.IsNarrowLayout}" />
<ContentControl Content="{Binding CurrentPage}"
Padding="12" />
Padding="{Binding #Root.ContentPadding}" />
</DockPanel>

</Grid>
Expand Down
20 changes: 20 additions & 0 deletions src/OpenIPC.Viewer.App/Views/MainView.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,40 @@ public partial class MainView : UserControl
AvaloniaProperty.RegisterDirect<MainView, bool>(
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<MainView, Thickness> ContentPaddingProperty =
AvaloniaProperty.RegisterDirect<MainView, Thickness>(
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
{
get => _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();
Expand Down
Loading