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}">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/OpenIPC.Viewer.App/Views/Pages/SettingsPage.axaml b/src/OpenIPC.Viewer.App/Views/Pages/SettingsPage.axaml
index fd6a7ed..a1977a9 100644
--- a/src/OpenIPC.Viewer.App/Views/Pages/SettingsPage.axaml
+++ b/src/OpenIPC.Viewer.App/Views/Pages/SettingsPage.axaml
@@ -232,6 +232,10 @@
Content="{Binding [Settings.Video.TelemetryOverlay], Source={x:Static svc:Localizer.Instance}}"
Foreground="{StaticResource TextPrimaryBrush}" />
+
+
@@ -249,6 +253,16 @@
SelectedItem="{Binding RtspTransport, Mode=TwoWay}"
MinWidth="80" />
+
+
+
+
+
diff --git a/src/OpenIPC.Viewer.Composition/SharedComposition.cs b/src/OpenIPC.Viewer.Composition/SharedComposition.cs
index 37c7045..594f1c2 100644
--- a/src/OpenIPC.Viewer.Composition/SharedComposition.cs
+++ b/src/OpenIPC.Viewer.Composition/SharedComposition.cs
@@ -51,6 +51,8 @@ public static IServiceCollection AddSharedServices(this IServiceCollection servi
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
// Majestic HTTP
services.AddSingleton();
diff --git a/src/OpenIPC.Viewer.Core/Entities/Camera.cs b/src/OpenIPC.Viewer.Core/Entities/Camera.cs
index 0e151e6..12b486a 100644
--- a/src/OpenIPC.Viewer.Core/Entities/Camera.cs
+++ b/src/OpenIPC.Viewer.Core/Entities/Camera.cs
@@ -1,4 +1,5 @@
using System;
+using OpenIPC.Viewer.Core.Video;
namespace OpenIPC.Viewer.Core.Entities;
@@ -22,4 +23,7 @@ public sealed record Camera(
bool IsMajestic,
int SortOrder,
DateTime CreatedAt,
- DateTime UpdatedAt);
+ DateTime UpdatedAt,
+ // Per-camera SD/HD override (Phase 12.2). Trailing param with a default so
+ // existing constructor calls stay valid.
+ StreamQualityOverride StreamQualityOverride = StreamQualityOverride.Auto);
diff --git a/src/OpenIPC.Viewer.Core/Entities/NewCameraRequest.cs b/src/OpenIPC.Viewer.Core/Entities/NewCameraRequest.cs
index 9d8fe54..68b7403 100644
--- a/src/OpenIPC.Viewer.Core/Entities/NewCameraRequest.cs
+++ b/src/OpenIPC.Viewer.Core/Entities/NewCameraRequest.cs
@@ -1,4 +1,5 @@
using System;
+using OpenIPC.Viewer.Core.Video;
namespace OpenIPC.Viewer.Core.Entities;
@@ -10,4 +11,5 @@ public sealed record NewCameraRequest(
Uri RtspMainUri,
Uri? RtspSubUri,
CameraCredentials? Credentials,
- GroupId? GroupId = null);
+ GroupId? GroupId = null,
+ StreamQualityOverride StreamQualityOverride = StreamQualityOverride.Auto);
diff --git a/src/OpenIPC.Viewer.Core/Entities/UpdateCameraRequest.cs b/src/OpenIPC.Viewer.Core/Entities/UpdateCameraRequest.cs
index 3cbce8d..71f3747 100644
--- a/src/OpenIPC.Viewer.Core/Entities/UpdateCameraRequest.cs
+++ b/src/OpenIPC.Viewer.Core/Entities/UpdateCameraRequest.cs
@@ -1,4 +1,5 @@
using System;
+using OpenIPC.Viewer.Core.Video;
namespace OpenIPC.Viewer.Core.Entities;
@@ -10,4 +11,5 @@ public sealed record UpdateCameraRequest(
Uri RtspMainUri,
Uri? RtspSubUri,
CameraCredentials? Credentials,
- GroupId? GroupId = null);
+ GroupId? GroupId = null,
+ StreamQualityOverride StreamQualityOverride = StreamQualityOverride.Auto);
diff --git a/src/OpenIPC.Viewer.Core/Onvif/Discovery/INetworkInterfaceProvider.cs b/src/OpenIPC.Viewer.Core/Onvif/Discovery/INetworkInterfaceProvider.cs
new file mode 100644
index 0000000..034e543
--- /dev/null
+++ b/src/OpenIPC.Viewer.Core/Onvif/Discovery/INetworkInterfaceProvider.cs
@@ -0,0 +1,16 @@
+using System.Collections.Generic;
+
+namespace OpenIPC.Viewer.Core.Onvif.Discovery;
+
+// Enumerates usable LAN network interfaces for binding WS-Discovery and other
+// listeners (Phase 12.6). Implementations filter out tunnel/loopback/down
+// adapters so discovery doesn't leave via a VPN on multi-adapter machines.
+// Candidates are ordered best-first (gateway-bearing LAN adapters first).
+public interface INetworkInterfaceProvider
+{
+ IReadOnlyList GetCandidates();
+}
+
+// DisplayName is shown in Settings; Address is the local IPv4 used both as the
+// persisted setting value and the socket bind target.
+public sealed record NetworkInterfaceInfo(string DisplayName, string Address);
diff --git a/src/OpenIPC.Viewer.Core/Onvif/Discovery/NetworkInterfaceSelector.cs b/src/OpenIPC.Viewer.Core/Onvif/Discovery/NetworkInterfaceSelector.cs
new file mode 100644
index 0000000..e09b6d6
--- /dev/null
+++ b/src/OpenIPC.Viewer.Core/Onvif/Discovery/NetworkInterfaceSelector.cs
@@ -0,0 +1,73 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace OpenIPC.Viewer.Core.Onvif.Discovery;
+
+// Platform-agnostic adapter snapshot — the impl in Devices builds these from
+// System.Net.NetworkInformation; tests build them by hand. Keeps the selection
+// rules (filter + ordering + bind pick) pure and unit-testable without a real
+// network stack.
+public readonly record struct NicDescriptor(
+ string Name,
+ bool IsUp,
+ bool IsLoopback,
+ bool IsTunnel,
+ bool HasGateway,
+ IReadOnlyList IPv4Addresses);
+
+// Pure WS-Discovery interface selection (Phase 12.6). Drops down/loopback/tunnel
+// adapters and orders the survivors best-first: a gateway-bearing private-LAN
+// adapter wins, a VPN/virtual adapter sinks. ResolveBindAddress turns a user
+// preference (or "auto") into a concrete local IPv4 to bind the probe socket.
+public static class NetworkInterfaceSelector
+{
+ public static IReadOnlyList SelectCandidates(IEnumerable adapters)
+ {
+ var result = new List<(NetworkInterfaceInfo Info, bool HasGateway, bool Private)>();
+ foreach (var a in adapters)
+ {
+ if (!a.IsUp || a.IsLoopback || a.IsTunnel) continue;
+ if (a.IPv4Addresses is null) continue;
+ foreach (var ip in a.IPv4Addresses)
+ {
+ if (string.IsNullOrWhiteSpace(ip)) continue;
+ result.Add((new NetworkInterfaceInfo($"{a.Name} ({ip})", ip), a.HasGateway, IsPrivate(ip)));
+ }
+ }
+
+ // Gateway-bearing private LAN adapters first; then private; then the rest.
+ return result
+ .OrderByDescending(x => x.HasGateway && x.Private)
+ .ThenByDescending(x => x.Private)
+ .ThenByDescending(x => x.HasGateway)
+ .Select(x => x.Info)
+ .ToList();
+ }
+
+ public static string? ResolveBindAddress(IReadOnlyList candidates, string? preferred)
+ {
+ if (!string.IsNullOrWhiteSpace(preferred) &&
+ candidates.Any(c => string.Equals(c.Address, preferred, StringComparison.OrdinalIgnoreCase)))
+ return preferred;
+
+ // Auto (or a stale preference no longer present) → best candidate, or
+ // null to mean "bind to any" when nothing usable was found.
+ return candidates.Count > 0 ? candidates[0].Address : null;
+ }
+
+ private static bool IsPrivate(string ip)
+ {
+ var parts = ip.Split('.');
+ if (parts.Length != 4 || !int.TryParse(parts[0], out var a) || !int.TryParse(parts[1], out var b))
+ return false;
+ return a switch
+ {
+ 10 => true,
+ 192 => b == 168,
+ 172 => b >= 16 && b <= 31,
+ 169 => b != 254, // exclude link-local autoconfig (169.254/16)
+ _ => false,
+ };
+ }
+}
diff --git a/src/OpenIPC.Viewer.Core/Services/CameraDirectoryService.cs b/src/OpenIPC.Viewer.Core/Services/CameraDirectoryService.cs
index 107005b..2e84ad4 100644
--- a/src/OpenIPC.Viewer.Core/Services/CameraDirectoryService.cs
+++ b/src/OpenIPC.Viewer.Core/Services/CameraDirectoryService.cs
@@ -70,7 +70,8 @@ public async Task AddAsync(NewCameraRequest req, CancellationToken ct)
IsMajestic: false,
SortOrder: 0,
CreatedAt: now,
- UpdatedAt: now);
+ UpdatedAt: now,
+ StreamQualityOverride: req.StreamQualityOverride);
return await _cameras.AddAsync(camera, ct).ConfigureAwait(false);
}
@@ -95,6 +96,7 @@ public async Task UpdateAsync(CameraId id, UpdateCameraRequest req, Cancellation
UsernameRef = usernameRef,
PasswordRef = passwordRef,
GroupId = req.GroupId,
+ StreamQualityOverride = req.StreamQualityOverride,
UpdatedAt = DateTime.UtcNow,
};
diff --git a/src/OpenIPC.Viewer.Core/Settings/IUserSettingsAccessor.cs b/src/OpenIPC.Viewer.Core/Settings/IUserSettingsAccessor.cs
index ed5a4f9..2cb3f9d 100644
--- a/src/OpenIPC.Viewer.Core/Settings/IUserSettingsAccessor.cs
+++ b/src/OpenIPC.Viewer.Core/Settings/IUserSettingsAccessor.cs
@@ -11,4 +11,8 @@ public interface IUserSettingsAccessor
string? RecordingsDirectoryOverride { get; }
int MaxConcurrentGridSessions { get; }
+
+ // Local IPv4 to bind discovery/listeners to (Phase 12.6). Empty / null =
+ // auto-pick the best LAN interface (ignore VPN/virtual adapters).
+ string? PreferredNetworkInterface { get; }
}
diff --git a/src/OpenIPC.Viewer.Core/Video/IVideoSession.cs b/src/OpenIPC.Viewer.Core/Video/IVideoSession.cs
index 62ebc13..869b248 100644
--- a/src/OpenIPC.Viewer.Core/Video/IVideoSession.cs
+++ b/src/OpenIPC.Viewer.Core/Video/IVideoSession.cs
@@ -15,4 +15,10 @@ public interface IVideoSession : IAsyncDisposable
Task StartAsync(CancellationToken ct);
Task SnapshotAsync(SnapshotFormat format, CancellationToken ct);
+
+ // Smart Pause (Phase 12.1): stop decoding without tearing the session down,
+ // so a hidden/minimized tile stops burning CPU while keeping its last frame
+ // for an instant resume. Both are idempotent no-ops if not started.
+ void PauseDecode();
+ void Resume();
}
diff --git a/src/OpenIPC.Viewer.Core/Video/ReconnectBackoff.cs b/src/OpenIPC.Viewer.Core/Video/ReconnectBackoff.cs
new file mode 100644
index 0000000..c23f550
--- /dev/null
+++ b/src/OpenIPC.Viewer.Core/Video/ReconnectBackoff.cs
@@ -0,0 +1,18 @@
+using System;
+
+namespace OpenIPC.Viewer.Core.Video;
+
+// Exponential reconnect backoff for AutoReconnectingVideoSession (Phase 12.3).
+// attempt is 1-based; the delay doubles each attempt and caps at MaxSeconds:
+// 1→1s, 2→2s, 3→4s, 4→8s, 5→16s, 6+→30s. A successful frame resets attempt.
+public static class ReconnectBackoff
+{
+ public const double MaxSeconds = 30;
+
+ public static TimeSpan Delay(int attempt)
+ {
+ if (attempt < 1) attempt = 1;
+ var seconds = Math.Min(Math.Pow(2, attempt - 1), MaxSeconds);
+ return TimeSpan.FromSeconds(seconds);
+ }
+}
diff --git a/src/OpenIPC.Viewer.Core/Video/SessionTelemetry.cs b/src/OpenIPC.Viewer.Core/Video/SessionTelemetry.cs
index fef3844..3967d57 100644
--- a/src/OpenIPC.Viewer.Core/Video/SessionTelemetry.cs
+++ b/src/OpenIPC.Viewer.Core/Video/SessionTelemetry.cs
@@ -10,4 +10,5 @@ public sealed record SessionTelemetry(
string? Codec,
int Width,
int Height,
- DateTime CapturedAt);
+ DateTime CapturedAt,
+ double BitrateKbps = 0);
diff --git a/src/OpenIPC.Viewer.Core/Video/StreamQualityOverride.cs b/src/OpenIPC.Viewer.Core/Video/StreamQualityOverride.cs
new file mode 100644
index 0000000..d1aaaf8
--- /dev/null
+++ b/src/OpenIPC.Viewer.Core/Video/StreamQualityOverride.cs
@@ -0,0 +1,12 @@
+namespace OpenIPC.Viewer.Core.Video;
+
+// Per-camera SD/HD policy override (Phase 12.2). Auto defers to the global
+// Auto SD/HD setting + layout; the explicit values pin a camera regardless.
+// Stored as an INTEGER column, so the numeric values are part of the schema —
+// append new members, don't renumber.
+public enum StreamQualityOverride
+{
+ Auto = 0,
+ AlwaysHd = 1,
+ AlwaysSd = 2,
+}
diff --git a/src/OpenIPC.Viewer.Core/Video/StreamQualityPolicy.cs b/src/OpenIPC.Viewer.Core/Video/StreamQualityPolicy.cs
new file mode 100644
index 0000000..567d9c8
--- /dev/null
+++ b/src/OpenIPC.Viewer.Core/Video/StreamQualityPolicy.cs
@@ -0,0 +1,20 @@
+namespace OpenIPC.Viewer.Core.Video;
+
+// Auto SD/HD policy (Phase 12.2): the multi-camera grid runs the substream to
+// stay light; a single tile filling the view (1×1 layout) gets the mainstream.
+// Disabling auto pins the grid to the substream everywhere.
+public static class StreamQualityPolicy
+{
+ public static StreamQuality ForGrid(bool autoSdHd, int layoutSize) =>
+ autoSdHd && layoutSize == 1 ? StreamQuality.Main : StreamQuality.Sub;
+
+ // A per-camera override pins quality regardless of layout/global setting;
+ // Auto falls back to ForGrid.
+ public static StreamQuality Resolve(StreamQualityOverride cameraOverride, bool autoSdHd, int layoutSize) =>
+ cameraOverride switch
+ {
+ StreamQualityOverride.AlwaysHd => StreamQuality.Main,
+ StreamQualityOverride.AlwaysSd => StreamQuality.Sub,
+ _ => ForGrid(autoSdHd, layoutSize),
+ };
+}
diff --git a/src/OpenIPC.Viewer.Devices/Onvif/Discovery/PlatformSafeUdpClient.cs b/src/OpenIPC.Viewer.Devices/Onvif/Discovery/PlatformSafeUdpClient.cs
index ef4f819..79dc258 100644
--- a/src/OpenIPC.Viewer.Devices/Onvif/Discovery/PlatformSafeUdpClient.cs
+++ b/src/OpenIPC.Viewer.Devices/Onvif/Discovery/PlatformSafeUdpClient.cs
@@ -20,11 +20,17 @@ internal sealed class PlatformSafeUdpClient : IUdpClient
{
private readonly UdpClient _client;
- public PlatformSafeUdpClient()
+ // bindAddress pins the probe to a specific local IPv4 (Phase 12.6) so the
+ // multicast doesn't leave via a VPN/virtual adapter on multi-NIC machines.
+ // Null / unparseable → IPAddress.Any (let the OS route, prior behavior).
+ public PlatformSafeUdpClient(string? bindAddress = null)
{
+ var local = !string.IsNullOrWhiteSpace(bindAddress) && IPAddress.TryParse(bindAddress, out var parsed)
+ ? parsed
+ : IPAddress.Any;
// Port 0 = let the OS pick a free ephemeral port. The stock wrapper
// hard-bound port 80, which is both privileged and collision-prone.
- _client = new UdpClient(new IPEndPoint(IPAddress.Any, 0)) { EnableBroadcast = true };
+ _client = new UdpClient(new IPEndPoint(local, 0)) { EnableBroadcast = true };
}
public short Ttl
diff --git a/src/OpenIPC.Viewer.Devices/Onvif/Discovery/SystemNetworkInterfaceProvider.cs b/src/OpenIPC.Viewer.Devices/Onvif/Discovery/SystemNetworkInterfaceProvider.cs
new file mode 100644
index 0000000..3cc38a9
--- /dev/null
+++ b/src/OpenIPC.Viewer.Devices/Onvif/Discovery/SystemNetworkInterfaceProvider.cs
@@ -0,0 +1,49 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net.NetworkInformation;
+using System.Net.Sockets;
+using OpenIPC.Viewer.Core.Onvif.Discovery;
+
+namespace OpenIPC.Viewer.Devices.Onvif.Discovery;
+
+// Builds NicDescriptors from System.Net.NetworkInformation and defers the
+// filter/order/select rules to the pure NetworkInterfaceSelector (Phase 12.6).
+// Enumeration is best-effort: a platform that restricts it (some mobile
+// sandboxes) yields an empty list → discovery falls back to binding "any".
+public sealed class SystemNetworkInterfaceProvider : INetworkInterfaceProvider
+{
+ public IReadOnlyList GetCandidates()
+ {
+ NetworkInterface[] nics;
+ try { nics = NetworkInterface.GetAllNetworkInterfaces(); }
+ catch { return Array.Empty(); }
+
+ var adapters = new List(nics.Length);
+ foreach (var nic in nics)
+ {
+ IPInterfaceProperties props;
+ try { props = nic.GetIPProperties(); }
+ catch { continue; }
+
+ var ipv4 = props.UnicastAddresses
+ .Where(u => u.Address.AddressFamily == AddressFamily.InterNetwork)
+ .Select(u => u.Address.ToString())
+ .ToList();
+ if (ipv4.Count == 0) continue;
+
+ var hasGateway = props.GatewayAddresses
+ .Any(g => g.Address.AddressFamily == AddressFamily.InterNetwork);
+
+ adapters.Add(new NicDescriptor(
+ Name: nic.Name,
+ IsUp: nic.OperationalStatus == OperationalStatus.Up,
+ IsLoopback: nic.NetworkInterfaceType == NetworkInterfaceType.Loopback,
+ IsTunnel: nic.NetworkInterfaceType == NetworkInterfaceType.Tunnel,
+ HasGateway: hasGateway,
+ IPv4Addresses: ipv4));
+ }
+
+ return NetworkInterfaceSelector.SelectCandidates(adapters);
+ }
+}
diff --git a/src/OpenIPC.Viewer.Devices/Onvif/Discovery/WsDiscoveryService.cs b/src/OpenIPC.Viewer.Devices/Onvif/Discovery/WsDiscoveryService.cs
index d533eb0..01b77f5 100644
--- a/src/OpenIPC.Viewer.Devices/Onvif/Discovery/WsDiscoveryService.cs
+++ b/src/OpenIPC.Viewer.Devices/Onvif/Discovery/WsDiscoveryService.cs
@@ -8,6 +8,7 @@
using Onvif.Core.Discovery;
using Onvif.Core.Discovery.Models;
using OpenIPC.Viewer.Core.Onvif.Discovery;
+using OpenIPC.Viewer.Core.Settings;
namespace OpenIPC.Viewer.Devices.Onvif.Discovery;
@@ -19,10 +20,17 @@ namespace OpenIPC.Viewer.Devices.Onvif.Discovery;
public sealed class WsDiscoveryService : IDiscoveryService
{
private readonly ILogger _logger;
+ private readonly INetworkInterfaceProvider _nics;
+ private readonly IUserSettingsAccessor _settings;
- public WsDiscoveryService(ILogger logger)
+ public WsDiscoveryService(
+ ILogger logger,
+ INetworkInterfaceProvider nics,
+ IUserSettingsAccessor settings)
{
_logger = logger;
+ _nics = nics;
+ _settings = settings;
}
public async IAsyncEnumerable ScanAsync(
@@ -31,11 +39,15 @@ public async IAsyncEnumerable ScanAsync(
{
var seconds = Math.Max(1, (int)Math.Ceiling(timeout.TotalSeconds));
var ws = new WSDiscovery();
+ // Bind to the chosen LAN interface so the probe doesn't leave via a VPN
+ // on multi-adapter machines (Phase 12.6). Auto/stale → best candidate.
+ var bind = NetworkInterfaceSelector.ResolveBindAddress(
+ _nics.GetCandidates(), _settings.PreferredNetworkInterface);
// Our own IUdpClient — the stock UdpClientWrapper crashes on Android/iOS
// (GetActiveTcpListeners is PlatformNotSupported). See PlatformSafeUdpClient.
- var client = new PlatformSafeUdpClient();
+ var client = new PlatformSafeUdpClient(bind);
- _logger.LogDebug("WS-Discovery scan starting (timeout={Seconds}s)", seconds);
+ _logger.LogDebug("WS-Discovery scan starting (timeout={Seconds}s, bind={Bind})", seconds, bind ?? "any");
IEnumerable devices;
try
{
diff --git a/src/OpenIPC.Viewer.Infrastructure/Persistence/Migrations/007_stream_quality_override.sql b/src/OpenIPC.Viewer.Infrastructure/Persistence/Migrations/007_stream_quality_override.sql
new file mode 100644
index 0000000..efe8a6e
--- /dev/null
+++ b/src/OpenIPC.Viewer.Infrastructure/Persistence/Migrations/007_stream_quality_override.sql
@@ -0,0 +1,4 @@
+-- Phase 12.2 — per-camera SD/HD override. 0 = Auto (follow global Auto SD/HD +
+-- layout), 1 = always mainstream, 2 = always substream. Existing rows default
+-- to Auto, preserving current behavior.
+ALTER TABLE Cameras ADD COLUMN StreamQualityOverride INTEGER NOT NULL DEFAULT 0;
diff --git a/src/OpenIPC.Viewer.Infrastructure/Persistence/SqliteCameraRepository.cs b/src/OpenIPC.Viewer.Infrastructure/Persistence/SqliteCameraRepository.cs
index ce64d2e..d4993c2 100644
--- a/src/OpenIPC.Viewer.Infrastructure/Persistence/SqliteCameraRepository.cs
+++ b/src/OpenIPC.Viewer.Infrastructure/Persistence/SqliteCameraRepository.cs
@@ -7,6 +7,7 @@
using Dapper;
using OpenIPC.Viewer.Core.Entities;
using OpenIPC.Viewer.Core.Persistence;
+using OpenIPC.Viewer.Core.Video;
namespace OpenIPC.Viewer.Infrastructure.Persistence;
@@ -45,12 +46,14 @@ INSERT INTO Cameras (
Id, GroupId, Name, Host, OnvifPort, HttpPort,
RtspMainUri, RtspSubUri, UsernameRef, PasswordRef,
OnvifEnabled, OnvifProfileToken, ChipModel, FirmwareVersion,
- IncludedInGrid, HasPtz, IsMajestic, SortOrder, CreatedAt, UpdatedAt)
+ IncludedInGrid, HasPtz, IsMajestic, SortOrder, CreatedAt, UpdatedAt,
+ StreamQualityOverride)
VALUES (
@Id, @GroupId, @Name, @Host, @OnvifPort, @HttpPort,
@RtspMainUri, @RtspSubUri, @UsernameRef, @PasswordRef,
@OnvifEnabled, @OnvifProfileToken, @ChipModel, @FirmwareVersion,
- @IncludedInGrid, @HasPtz, @IsMajestic, @SortOrder, @CreatedAt, @UpdatedAt);
+ @IncludedInGrid, @HasPtz, @IsMajestic, @SortOrder, @CreatedAt, @UpdatedAt,
+ @StreamQualityOverride);
""",
ToRow(camera), transaction: tx).ConfigureAwait(false);
await tx.CommitAsync(ct).ConfigureAwait(false);
@@ -81,7 +84,8 @@ UPDATE Cameras SET
HasPtz = @HasPtz,
IsMajestic = @IsMajestic,
SortOrder = @SortOrder,
- UpdatedAt = @UpdatedAt
+ UpdatedAt = @UpdatedAt,
+ StreamQualityOverride = @StreamQualityOverride
WHERE Id = @Id;
""",
ToRow(camera), transaction: tx).ConfigureAwait(false);
@@ -137,7 +141,8 @@ await conn.ExecuteAsync(
IsMajestic: row.IsMajestic != 0,
SortOrder: row.SortOrder,
CreatedAt: DateTime.Parse(row.CreatedAt, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind),
- UpdatedAt: DateTime.Parse(row.UpdatedAt, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind));
+ UpdatedAt: DateTime.Parse(row.UpdatedAt, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind),
+ StreamQualityOverride: (StreamQualityOverride)row.StreamQualityOverride);
private static object ToRow(Camera c) => new
{
@@ -161,6 +166,7 @@ await conn.ExecuteAsync(
c.SortOrder,
CreatedAt = c.CreatedAt.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture),
UpdatedAt = c.UpdatedAt.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture),
+ StreamQualityOverride = (int)c.StreamQualityOverride,
};
private sealed class CameraRow
@@ -185,5 +191,6 @@ private sealed class CameraRow
public int SortOrder { get; init; }
public string CreatedAt { get; init; } = default!;
public string UpdatedAt { get; init; } = default!;
+ public int StreamQualityOverride { get; init; }
}
}
diff --git a/src/OpenIPC.Viewer.Video/Pipeline/AutoReconnectingVideoSession.cs b/src/OpenIPC.Viewer.Video/Pipeline/AutoReconnectingVideoSession.cs
index 8f3225f..41d0588 100644
--- a/src/OpenIPC.Viewer.Video/Pipeline/AutoReconnectingVideoSession.cs
+++ b/src/OpenIPC.Viewer.Video/Pipeline/AutoReconnectingVideoSession.cs
@@ -1,4 +1,5 @@
using System;
+using System.Reactive.Disposables;
using System.Reactive.Subjects;
using System.Threading;
using System.Threading.Tasks;
@@ -7,20 +8,21 @@
namespace OpenIPC.Viewer.Video.Pipeline;
-// Wraps an inner-session factory and transparently re-creates the session
-// on Failed, with backoff 1→2→5→10→30s (then 30s capped). Auth errors
-// (401, Unauthorized, EACCES) abort permanently — we never retry against
-// a wrong password (would lock the camera out / DDoS it).
+// Wraps an inner-session factory and transparently re-creates the session on
+// Failed, with exponential backoff 1→2→4→8→16→30s (then 30s capped). A
+// successful frame resets the backoff to zero. After ColdFailures consecutive
+// attempts with no frame the wrapper surfaces Offline (the interactive error
+// cell) while still probing at the 30s cadence. A watchdog forces a reconnect
+// when a "Playing" stream stops delivering frames — FFmpeg can sit on a dead
+// RTSP socket without erroring. Auth errors (401, Unauthorized, EACCES) abort
+// permanently — we never retry against a wrong password (would lock the camera
+// out / DDoS it). Phase 12.3.
internal sealed class AutoReconnectingVideoSession : IVideoSession
{
- private static readonly TimeSpan[] Backoff =
- {
- TimeSpan.FromSeconds(1),
- TimeSpan.FromSeconds(2),
- TimeSpan.FromSeconds(5),
- TimeSpan.FromSeconds(10),
- TimeSpan.FromSeconds(30),
- };
+ // No decoded frame for this long while Playing → treat the stream as hung.
+ private const long FrameTimeoutTicks = 5 * TimeSpan.TicksPerSecond;
+ // Consecutive failed attempts (no successful frame) before going Offline.
+ private const int ColdFailures = 5;
private readonly Func _innerFactory;
private readonly ILogger _logger;
@@ -33,9 +35,19 @@ internal sealed class AutoReconnectingVideoSession : IVideoSession
private SessionState _state = SessionState.Idle;
private string? _lastError;
+ // Watchdog shared state. _lastActivityTicks is the UTC tick of the last
+ // frame (or the moment Playing was reached); _watching gates the watchdog
+ // so it only fires while the inner session believes it is Playing.
+ private long _lastActivityTicks;
+ private volatile bool _watching;
+ // Transition-only logging — avoids a log line per retry attempt.
+ private SessionState _lastLoggedState = SessionState.Idle;
+
private IVideoSession? _activeInner;
private CancellationTokenSource? _cts;
private Task? _loop;
+ // Sticky across reconnects: a session created mid-pause starts paused too.
+ private volatile bool _pauseRequested;
public AutoReconnectingVideoSession(Func innerFactory, ILogger logger)
{
@@ -65,6 +77,18 @@ public Task SnapshotAsync(SnapshotFormat format, CancellationToken ct)
return inner.SnapshotAsync(format, ct);
}
+ public void PauseDecode()
+ {
+ _pauseRequested = true;
+ _activeInner?.PauseDecode();
+ }
+
+ public void Resume()
+ {
+ _pauseRequested = false;
+ _activeInner?.Resume();
+ }
+
public async ValueTask DisposeAsync()
{
_cts?.Cancel();
@@ -88,48 +112,85 @@ private async Task LoopAsync(CancellationToken ct)
{
var inner = _innerFactory();
_activeInner = inner;
-
- using var framesSub = inner.Frames.Subscribe(_frames.OnNext);
- using var telemetrySub = inner.Telemetry.Subscribe(_telemetry.OnNext);
+ var sawFrame = false;
var failed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
- // failed.Task only completes when the inner session reports Failed/Idle.
- // On Dispose the token is cancelled while the inner is still Playing, so
- // without this the await below would hang forever and Dispose would never
- // return (freezing the layout-switch command). Cancelling the TCS lets the
- // OperationCanceledException catch unwind the loop promptly.
+ // failed.Task only completes when the inner session reports Failed/Idle
+ // (or the watchdog forces it). On Dispose the token is cancelled while
+ // the inner is still Playing, so without this the await below would hang
+ // forever and Dispose would never return (freezing the layout-switch
+ // command). Cancelling the TCS lets the OperationCanceledException catch
+ // unwind the loop promptly.
using var ctReg = ct.Register(() => failed.TrySetCanceled());
+
+ using var framesSub = inner.Frames.Subscribe(f =>
+ {
+ Volatile.Write(ref _lastActivityTicks, DateTime.UtcNow.Ticks);
+ // A real decoded frame means the connection is healthy — reset the
+ // backoff so a later blip starts again from 1s, not the capped 30s.
+ if (!sawFrame) { sawFrame = true; attempt = 0; }
+ _frames.OnNext(f);
+ });
+ using var telemetrySub = inner.Telemetry.Subscribe(_telemetry.OnNext);
using var stateSub = inner.StateChanged.Subscribe(s =>
{
+ if (s == SessionState.Playing)
+ {
+ Volatile.Write(ref _lastActivityTicks, DateTime.UtcNow.Ticks);
+ _watching = true;
+ LogTransition(SessionState.Playing, null);
+ }
+ else
+ {
+ _watching = false;
+ }
SetState(s);
- if (s is SessionState.Failed or SessionState.Idle && attempt > 0)
- failed.TrySetResult(inner.LastError);
- else if (s == SessionState.Failed)
+ if (s is SessionState.Failed or SessionState.Idle)
failed.TrySetResult(inner.LastError);
});
try
{
await inner.StartAsync(ct).ConfigureAwait(false);
- var error = await failed.Task.ConfigureAwait(false);
+ if (_pauseRequested) inner.PauseDecode();
- if (IsAuthFailure(error))
+ using (StartWatchdog(failed, ct))
{
- _logger.LogWarning("Auth failure for video session ({Error}); will not retry.", error);
- SetState(SessionState.Failed, error);
- return;
- }
+ var error = await failed.Task.ConfigureAwait(false);
+ _watching = false;
- attempt++;
- var delay = Backoff[Math.Min(attempt - 1, Backoff.Length - 1)];
- SetState(SessionState.Reconnecting, error);
- _logger.LogInformation("Reconnect attempt {Attempt} in {Delay}s after {Error}", attempt, delay.TotalSeconds, error);
+ if (IsAuthFailure(error))
+ {
+ SetState(SessionState.Failed, error);
+ LogTransition(SessionState.Failed, error);
+ return;
+ }
- await inner.DisposeAsync().ConfigureAwait(false);
- _activeInner = null;
+ attempt++;
+ var cold = attempt >= ColdFailures;
+ // Cold mode probes at the capped cadence; otherwise climb the
+ // exponential ramp 1→2→4→8→16→30s.
+ var delay = cold
+ ? TimeSpan.FromSeconds(ReconnectBackoff.MaxSeconds)
+ : ReconnectBackoff.Delay(attempt);
- try { await Task.Delay(delay, ct).ConfigureAwait(false); }
- catch (OperationCanceledException) { return; }
+ // Offline (Failed) surfaces the interactive error cell after
+ // ColdFailures dead attempts; below that it's a transient
+ // Reconnecting badge.
+ var next = cold ? SessionState.Failed : SessionState.Reconnecting;
+ SetState(next, error);
+ LogTransition(next, error);
+
+ // Stop listening before disposing: the inner decode thread
+ // emits a terminal Idle as it unwinds, which would otherwise
+ // clobber the Reconnecting/Offline badge we just set.
+ stateSub.Dispose();
+ await inner.DisposeAsync().ConfigureAwait(false);
+ _activeInner = null;
+
+ try { await Task.Delay(delay, ct).ConfigureAwait(false); }
+ catch (OperationCanceledException) { return; }
+ }
}
catch (OperationCanceledException)
{
@@ -144,6 +205,58 @@ private async Task LoopAsync(CancellationToken ct)
}
}
+ // Background frame watchdog. While the inner session reports Playing, a gap
+ // of FrameTimeoutTicks with no decoded frame means the stream is hung — push
+ // the same failure path as an explicit disconnect. Disposing the returned
+ // handle cancels the loop.
+ private IDisposable StartWatchdog(TaskCompletionSource failed, CancellationToken ct)
+ {
+ var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ while (!cts.IsCancellationRequested)
+ {
+ await Task.Delay(1000, cts.Token).ConfigureAwait(false);
+ if (!_watching) continue;
+ var idle = DateTime.UtcNow.Ticks - Volatile.Read(ref _lastActivityTicks);
+ if (idle > FrameTimeoutTicks)
+ {
+ failed.TrySetResult("Stream stalled (no frames for 5s)");
+ return;
+ }
+ }
+ }
+ catch (Exception) { /* cancelled on dispose */ }
+ }, cts.Token);
+
+ return Disposable.Create(() =>
+ {
+ cts.Cancel();
+ cts.Dispose();
+ });
+ }
+
+ // One log line per state transition, not per retry attempt (Phase 12.3).
+ private void LogTransition(SessionState state, string? error)
+ {
+ if (_lastLoggedState == state) return;
+ _lastLoggedState = state;
+ switch (state)
+ {
+ case SessionState.Playing:
+ _logger.LogInformation("Video session live");
+ break;
+ case SessionState.Reconnecting:
+ _logger.LogInformation("Video session reconnecting: {Error}", error);
+ break;
+ case SessionState.Failed:
+ _logger.LogWarning("Video session offline: {Error}", error);
+ break;
+ }
+ }
+
private static bool IsAuthFailure(string? error)
{
if (string.IsNullOrEmpty(error)) return false;
diff --git a/src/OpenIPC.Viewer.Video/Pipeline/FfmpegRuntime.cs b/src/OpenIPC.Viewer.Video/Pipeline/FfmpegRuntime.cs
index cf99323..f040128 100644
--- a/src/OpenIPC.Viewer.Video/Pipeline/FfmpegRuntime.cs
+++ b/src/OpenIPC.Viewer.Video/Pipeline/FfmpegRuntime.cs
@@ -8,71 +8,86 @@ namespace OpenIPC.Viewer.Video.Pipeline;
internal static class FfmpegRuntime
{
- private static int _initialized;
+ // Serializes the one-time native init. The old design used an Interlocked
+ // CompareExchange flag, but that let the *second* caller return "ready"
+ // the instant the first caller flipped the flag — before its (slow)
+ // DynamicallyLoadedBindings.Initialize() had populated the function
+ // vectors. On a multi-camera grid every tile's reconnect loop calls in
+ // concurrently, so the racers spawned decode threads that hit a still-null
+ // delegate and died with a bare NullReferenceException inside av_dict_set.
+ // A lock makes concurrent callers block until init genuinely completes.
+ private static readonly object _gate = new();
+ private static volatile bool _ready;
private static Exception? _initFailure;
public static void EnsureInitialized()
{
- if (Interlocked.CompareExchange(ref _initialized, 1, 0) != 0)
+ if (_ready)
{
- // Replay a failed first init instead of returning success — otherwise
- // every later session walks straight into uninitialized bindings and
- // dies mid-stream with a bare "Specified method is not supported"
- // (what the field logs showed: one descriptive crash, then an endless
- // reconnect loop of cryptic av_dict_set failures).
- if (_initFailure is not null)
- throw _initFailure;
+ if (_initFailure is not null) throw _initFailure;
return;
}
- if (OperatingSystem.IsAndroid())
+ lock (_gate)
{
- // FFmpeg.AutoGen 7.1.1's FunctionResolverFactory.Create() throws
- // PlatformNotSupportedException on Android — RuntimeInformation
- // .IsOSPlatform(Linux) returns false (Android is its own platform
- // in .NET 6+). Initialize() uses FunctionResolver if set, otherwise
- // calls the broken factory. Setting it here bypasses the throw.
- DynamicallyLoadedBindings.FunctionResolver = new AndroidFunctionResolver();
- }
+ if (_ready)
+ {
+ if (_initFailure is not null) throw _initFailure;
+ return;
+ }
- var nativeDir = ResolveNativeDir();
+ if (OperatingSystem.IsAndroid())
+ {
+ // FFmpeg.AutoGen 7.1.1's FunctionResolverFactory.Create() throws
+ // PlatformNotSupportedException on Android — RuntimeInformation
+ // .IsOSPlatform(Linux) returns false (Android is its own platform
+ // in .NET 6+). Initialize() uses FunctionResolver if set, otherwise
+ // calls the broken factory. Setting it here bypasses the throw.
+ DynamicallyLoadedBindings.FunctionResolver = new AndroidFunctionResolver();
+ }
- try
- {
- // Replaces the old ffmpeg.RootPath setter. The Abstractions.ffmpeg
- // facade no longer owns the loader path; LibrariesPath on
- // DynamicallyLoadedBindings is the single source of truth.
- DynamicallyLoadedBindings.LibrariesPath = nativeDir;
+ var nativeDir = ResolveNativeDir();
- // Abstractions.ffmpeg cctor doesn't auto-invoke Initialize the way
- // the old monolithic FFmpeg.AutoGen package did — we have to call
- // it explicitly to populate the vectors before touching av_*.
- DynamicallyLoadedBindings.Initialize();
+ try
+ {
+ // Replaces the old ffmpeg.RootPath setter. The Abstractions.ffmpeg
+ // facade no longer owns the loader path; LibrariesPath on
+ // DynamicallyLoadedBindings is the single source of truth.
+ DynamicallyLoadedBindings.LibrariesPath = nativeDir;
- // Touch one function so the P/Invoke loader resolves the native
- // libs early — anything missing surfaces here instead of mid-stream.
- _ = ffmpeg.av_version_info();
- ffmpeg.avformat_network_init();
- }
- catch (Exception ex) when (IsNativeLoadFailure(ex))
- {
- // Most-likely cause on Android: the APK shipped without FFmpeg
- // shared libs because tools/build-ffmpeg-android.sh wasn't run for
- // the device's ABI (arm64-v8a / x86_64), or the .so are present but
- // depend on something missing (libc++_shared, libmediandk, etc.).
- // Re-throw with the full inner chain so the underlying loader
- // message is preserved.
- var (rid, _) = RuntimeIds.Current();
- var probe = string.IsNullOrEmpty(nativeDir) ? "(loader path)" : nativeDir;
- _initFailure = new FfmpegNativeLibsMissingException(
- $"FFmpeg native libraries failed to load for runtime '{rid ?? "unknown"}'. " +
- $"Probed: {probe}. " +
- $"Android: ensure runtimes/android-{{arm64,x64}}/native/*.so are populated " +
- $"(run tools/build-ffmpeg-android.sh via WSL or pull .so from a CI APK artifact). " +
- $"Desktop: keep the runtimes/ folder from the release archive next to the exe, " +
- $"or tools/fetch-ffmpeg.ps1 (Windows) / apt/brew. " +
- $"Underlying: {DescribeChain(ex)}", ex);
- throw _initFailure;
+ // Abstractions.ffmpeg cctor doesn't auto-invoke Initialize the way
+ // the old monolithic FFmpeg.AutoGen package did — we have to call
+ // it explicitly to populate the vectors before touching av_*.
+ DynamicallyLoadedBindings.Initialize();
+
+ // Touch one function so the P/Invoke loader resolves the native
+ // libs early — anything missing surfaces here instead of mid-stream.
+ _ = ffmpeg.av_version_info();
+ ffmpeg.avformat_network_init();
+ }
+ catch (Exception ex) when (IsNativeLoadFailure(ex))
+ {
+ // Most-likely cause on Android: the APK shipped without FFmpeg
+ // shared libs because tools/build-ffmpeg-android.sh wasn't run for
+ // the device's ABI (arm64-v8a / x86_64), or the .so are present but
+ // depend on something missing (libc++_shared, libmediandk, etc.).
+ // Cache the failure and mark ready so later callers replay it
+ // instead of walking into uninitialized bindings.
+ var (rid, _) = RuntimeIds.Current();
+ var probe = string.IsNullOrEmpty(nativeDir) ? "(loader path)" : nativeDir;
+ _initFailure = new FfmpegNativeLibsMissingException(
+ $"FFmpeg native libraries failed to load for runtime '{rid ?? "unknown"}'. " +
+ $"Probed: {probe}. " +
+ $"Android: ensure runtimes/android-{{arm64,x64}}/native/*.so are populated " +
+ $"(run tools/build-ffmpeg-android.sh via WSL or pull .so from a CI APK artifact). " +
+ $"Desktop: keep the runtimes/ folder from the release archive next to the exe, " +
+ $"or tools/fetch-ffmpeg.ps1 (Windows) / apt/brew. " +
+ $"Underlying: {DescribeChain(ex)}", ex);
+ _ready = true;
+ throw _initFailure;
+ }
+
+ _ready = true;
}
}
diff --git a/src/OpenIPC.Viewer.Video/Pipeline/FfmpegVideoSession.cs b/src/OpenIPC.Viewer.Video/Pipeline/FfmpegVideoSession.cs
index cd301e3..ee59bb6 100644
--- a/src/OpenIPC.Viewer.Video/Pipeline/FfmpegVideoSession.cs
+++ b/src/OpenIPC.Viewer.Video/Pipeline/FfmpegVideoSession.cs
@@ -30,9 +30,16 @@ internal sealed class FfmpegVideoSession : IVideoSession
private SessionState _state = SessionState.Idle;
private string? _lastError;
+ // Smart Pause gate (Phase 12.1). Signaled = decoding; reset = the Run loop
+ // parks before av_read_frame so a hidden tile burns no CPU. The native
+ // context stays alive for an instant resume.
+ private readonly ManualResetEventSlim _decodeGate = new(true);
+ private volatile bool _paused;
+
private int _framesDecoded;
private DateTime _lastFpsTick;
private int _framesSinceFpsTick;
+ private long _bytesSinceFpsTick;
private string? _codecName;
private int _width;
private int _height;
@@ -111,9 +118,28 @@ public Task SnapshotAsync(SnapshotFormat format, CancellationToken ct)
return Task.FromResult(data.ToArray());
}
+ public void PauseDecode()
+ {
+ if (_thread is null || _paused) return;
+ _paused = true;
+ _decodeGate.Reset();
+ SetState(SessionState.Paused);
+ }
+
+ public void Resume()
+ {
+ if (_thread is null || !_paused) return;
+ _paused = false;
+ _decodeGate.Set();
+ SetState(SessionState.Playing);
+ }
+
public async ValueTask DisposeAsync()
{
_cts?.Cancel();
+ // Unblock the decode gate so a paused thread observes cancellation and
+ // exits instead of parking forever.
+ _decodeGate.Set();
if (_thread is { IsAlive: true })
{
await Task.Run(() => _thread.Join(TimeSpan.FromSeconds(2))).ConfigureAwait(false);
@@ -122,6 +148,7 @@ public async ValueTask DisposeAsync()
_stateChanged.OnCompleted();
_telemetry.OnCompleted();
_cts?.Dispose();
+ _decodeGate.Dispose();
}
private unsafe void Run()
@@ -204,6 +231,15 @@ private unsafe void Run()
var ct = _cts!.Token;
while (!ct.IsCancellationRequested)
{
+ // Smart Pause: park here (no av_read_frame, no decode) while the
+ // tile is hidden. Cancellation during pause throws and unwinds to
+ // the clean Idle exit below.
+ if (_paused)
+ {
+ try { _decodeGate.Wait(ct); }
+ catch (OperationCanceledException) { break; }
+ }
+
ret = ffmpeg.av_read_frame(fmtCtx, packet);
if (ret < 0)
{
@@ -222,6 +258,9 @@ private unsafe void Run()
continue;
}
+ // Demux-level byte count → video bitrate in MaybePublishTelemetry.
+ Interlocked.Add(ref _bytesSinceFpsTick, packet->size);
+
ret = ffmpeg.avcodec_send_packet(codecCtx, packet);
ffmpeg.av_packet_unref(packet);
if (ret < 0 && ret != ffmpeg.AVERROR(ffmpeg.EAGAIN))
@@ -417,6 +456,8 @@ private void MaybePublishTelemetry()
var sinceLast = Interlocked.Exchange(ref _framesSinceFpsTick, 0);
var fps = sinceLast / elapsed.TotalSeconds;
+ var bytes = Interlocked.Exchange(ref _bytesSinceFpsTick, 0);
+ var bitrateKbps = bytes * 8.0 / 1000.0 / elapsed.TotalSeconds;
_lastFpsTick = now;
_telemetry.OnNext(new SessionTelemetry(
@@ -427,7 +468,8 @@ private void MaybePublishTelemetry()
Codec: _codecName,
Width: _width,
Height: _height,
- CapturedAt: now));
+ CapturedAt: now,
+ BitrateKbps: bitrateKbps));
}
private void SetState(SessionState newState, string? error = null)
diff --git a/tests/OpenIPC.Viewer.Core.Tests/Video/Phase12PolicyTests.cs b/tests/OpenIPC.Viewer.Core.Tests/Video/Phase12PolicyTests.cs
new file mode 100644
index 0000000..d9409b2
--- /dev/null
+++ b/tests/OpenIPC.Viewer.Core.Tests/Video/Phase12PolicyTests.cs
@@ -0,0 +1,120 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using OpenIPC.Viewer.Core.Onvif.Discovery;
+using OpenIPC.Viewer.Core.Video;
+using Xunit;
+
+namespace OpenIPC.Viewer.Core.Tests.Video;
+
+// Pure Phase 12 policy helpers — backoff ramp (12.3), SD/HD selection (12.2),
+// and WS-Discovery interface filtering (12.6).
+public sealed class Phase12PolicyTests
+{
+ [Theory]
+ [InlineData(1, 1)]
+ [InlineData(2, 2)]
+ [InlineData(3, 4)]
+ [InlineData(4, 8)]
+ [InlineData(5, 16)]
+ [InlineData(6, 30)] // 32 capped to 30
+ [InlineData(7, 30)]
+ [InlineData(20, 30)]
+ public void Backoff_FollowsExponentialRampCappedAt30(int attempt, double expectedSeconds)
+ {
+ Assert.Equal(expectedSeconds, ReconnectBackoff.Delay(attempt).TotalSeconds);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-5)]
+ public void Backoff_ClampsNonPositiveAttemptToFirstStep(int attempt)
+ {
+ Assert.Equal(1, ReconnectBackoff.Delay(attempt).TotalSeconds);
+ }
+
+ [Theory]
+ [InlineData(true, 1, StreamQuality.Main)] // single tile fills the view → HD
+ [InlineData(true, 2, StreamQuality.Sub)]
+ [InlineData(true, 3, StreamQuality.Sub)]
+ [InlineData(false, 1, StreamQuality.Sub)] // auto off → always SD
+ [InlineData(false, 2, StreamQuality.Sub)]
+ public void GridQuality_HdOnlyForSingleTileWithAutoOn(bool autoSdHd, int layout, StreamQuality expected)
+ {
+ Assert.Equal(expected, StreamQualityPolicy.ForGrid(autoSdHd, layout));
+ }
+
+ [Theory]
+ // Per-camera override wins regardless of layout / global toggle…
+ [InlineData(StreamQualityOverride.AlwaysHd, false, 3, StreamQuality.Main)]
+ [InlineData(StreamQualityOverride.AlwaysSd, true, 1, StreamQuality.Sub)]
+ // …Auto defers to the global grid policy.
+ [InlineData(StreamQualityOverride.Auto, true, 1, StreamQuality.Main)]
+ [InlineData(StreamQualityOverride.Auto, true, 2, StreamQuality.Sub)]
+ [InlineData(StreamQualityOverride.Auto, false, 1, StreamQuality.Sub)]
+ public void Resolve_OverrideBeatsGlobalPolicy(
+ StreamQualityOverride cameraOverride, bool autoSdHd, int layout, StreamQuality expected)
+ {
+ Assert.Equal(expected, StreamQualityPolicy.Resolve(cameraOverride, autoSdHd, layout));
+ }
+
+ [Fact]
+ public void Nics_DropsDownLoopbackAndTunnelAdapters()
+ {
+ var candidates = NetworkInterfaceSelector.SelectCandidates(new[]
+ {
+ Nic("eth0", up: true, loopback: false, tunnel: false, gateway: true, "192.168.1.5"),
+ Nic("vpn", up: true, loopback: false, tunnel: true, gateway: false, "10.8.0.2"),
+ Nic("lo", up: true, loopback: true, tunnel: false, gateway: false, "127.0.0.1"),
+ Nic("down", up: false, loopback: false, tunnel: false, gateway: true, "192.168.1.9"),
+ });
+
+ Assert.Equal(new[] { "192.168.1.5" }, candidates.Select(c => c.Address).ToArray());
+ }
+
+ [Fact]
+ public void Nics_OrdersGatewayPrivateLanFirst()
+ {
+ var candidates = NetworkInterfaceSelector.SelectCandidates(new[]
+ {
+ Nic("virt", up: true, loopback: false, tunnel: false, gateway: false, "172.20.0.1"), // private, no gw
+ Nic("eth0", up: true, loopback: false, tunnel: false, gateway: true, "192.168.1.5"), // private + gw
+ });
+
+ Assert.Equal("192.168.1.5", candidates[0].Address);
+ Assert.Equal("172.20.0.1", candidates[1].Address);
+ }
+
+ [Fact]
+ public void ResolveBindAddress_PrefersExplicitChoiceWhenPresent()
+ {
+ var candidates = NetworkInterfaceSelector.SelectCandidates(new[]
+ {
+ Nic("eth0", up: true, loopback: false, tunnel: false, gateway: true, "192.168.1.5"),
+ Nic("eth1", up: true, loopback: false, tunnel: false, gateway: false, "192.168.1.6"),
+ });
+
+ Assert.Equal("192.168.1.6", NetworkInterfaceSelector.ResolveBindAddress(candidates, "192.168.1.6"));
+ }
+
+ [Fact]
+ public void ResolveBindAddress_FallsBackToBestCandidateForAutoOrStale()
+ {
+ var candidates = NetworkInterfaceSelector.SelectCandidates(new[]
+ {
+ Nic("eth0", up: true, loopback: false, tunnel: false, gateway: true, "192.168.1.5"),
+ });
+
+ Assert.Equal("192.168.1.5", NetworkInterfaceSelector.ResolveBindAddress(candidates, "")); // auto
+ Assert.Equal("192.168.1.5", NetworkInterfaceSelector.ResolveBindAddress(candidates, "9.9.9.9")); // stale
+ }
+
+ [Fact]
+ public void ResolveBindAddress_NullWhenNoCandidates()
+ {
+ Assert.Null(NetworkInterfaceSelector.ResolveBindAddress(Array.Empty(), "192.168.1.5"));
+ }
+
+ private static NicDescriptor Nic(string name, bool up, bool loopback, bool tunnel, bool gateway, params string[] ipv4) =>
+ new(name, up, loopback, tunnel, gateway, ipv4);
+}