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
13 changes: 9 additions & 4 deletions src/OpenIPC.Viewer.App/Converters/StateColorConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}

Expand Down
5 changes: 5 additions & 0 deletions src/OpenIPC.Viewer.App/Messages/OpenCameraMessage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
20 changes: 20 additions & 0 deletions src/OpenIPC.Viewer.App/Services/Localizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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…",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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"] = "Камер пока нет",
Expand Down Expand Up @@ -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"] = "Выбрать…",
Expand Down Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions src/OpenIPC.Viewer.App/Services/UserSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/OpenIPC.Viewer.App/Services/UserSettingsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
2 changes: 2 additions & 0 deletions src/OpenIPC.Viewer.App/Themes/Theme.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -78,5 +78,7 @@
<StreamGeometry x:Key="IconAdvanced">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</StreamGeometry>
<!-- IconAbout — info (i in circle). -->
<StreamGeometry x:Key="IconAbout">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</StreamGeometry>
<!-- IconPlugOff — Lucide "unplug", for the grid error/Offline cell. -->
<StreamGeometry x:Key="IconPlugOff">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</StreamGeometry>

</ResourceDictionary>
138 changes: 132 additions & 6 deletions src/OpenIPC.Viewer.App/ViewModels/CameraTileViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,19 +22,35 @@ public sealed partial class CameraTileViewModel : ViewModelBase, IAsyncDisposabl
private readonly UserSettingsService _userSettings;
private readonly ILogger<CameraTileViewModel> _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;
private bool _disposed;

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
Expand All @@ -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,
Expand All @@ -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);
Expand All @@ -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;

Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading