diff --git a/src/OpenIPC.Viewer.App/Services/Localizer.cs b/src/OpenIPC.Viewer.App/Services/Localizer.cs index 784d769..80fbf65 100644 --- a/src/OpenIPC.Viewer.App/Services/Localizer.cs +++ b/src/OpenIPC.Viewer.App/Services/Localizer.cs @@ -217,6 +217,9 @@ private static LangCode DetectSystem() ["Settings.Appearance"] = "Appearance", ["Settings.Appearance.Language"] = "Language", ["Settings.Appearance.ShowSplash"] = "Show animated splash screen on launch", + ["Settings.Appearance.StartPage"] = "Start page", + ["Settings.Appearance.StartupLayout"] = "Layout at startup", + ["Settings.Appearance.StartupLayout.LastUsed"] = "Last used", ["Settings.Appearance.CloseToTray"] = "Keep running in the tray when the window is closed", ["Settings.Video"] = "Video", ["Settings.Recording"] = "Recording", @@ -708,6 +711,9 @@ private static LangCode DetectSystem() ["Settings.Appearance"] = "Внешний вид", ["Settings.Appearance.Language"] = "Язык", ["Settings.Appearance.ShowSplash"] = "Показывать анимированную заставку при запуске", + ["Settings.Appearance.StartPage"] = "Стартовая страница", + ["Settings.Appearance.StartupLayout"] = "Раскладка при запуске", + ["Settings.Appearance.StartupLayout.LastUsed"] = "Последняя открытая", ["Settings.Appearance.CloseToTray"] = "При закрытии окна оставаться в трее", ["Settings.Video"] = "Видео", ["Settings.Recording"] = "Запись", diff --git a/src/OpenIPC.Viewer.App/Services/UserSettings.cs b/src/OpenIPC.Viewer.App/Services/UserSettings.cs index f0edcad..dc6047f 100644 --- a/src/OpenIPC.Viewer.App/Services/UserSettings.cs +++ b/src/OpenIPC.Viewer.App/Services/UserSettings.cs @@ -98,7 +98,12 @@ public sealed record UserSettings( // Desktop only: refuse to start a second copy of the app — a repeat launch // brings the already-running window to the foreground instead (see // Desktop/SingleInstanceGuard). Off = any number of copies may run. - bool SingleInstance = false) + bool SingleInstance = false, + // Page shown when the app starts: "library" (default) or "live" (#70). + // With "live", StartupLayoutId picks the layout the grid opens on; 0 = the + // one that was active when the app was last closed. + string StartupPage = "library", + int StartupLayoutId = 0) { public static UserSettings Default => new(); } diff --git a/src/OpenIPC.Viewer.App/ViewModels/GridPageViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/GridPageViewModel.cs index c409300..618d116 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/GridPageViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/GridPageViewModel.cs @@ -43,6 +43,10 @@ public sealed partial class GridPageViewModel : ViewModelBase, private IReadOnlyList _allCameras = Array.Empty(); private bool _minimized; private bool _suppressSettingsRefresh; + private bool _startupLayoutApplied; + // Serializes tile-order writes: each is a read-modify-write of LayoutTiles, + // so two quick drags must not land out of order. + private readonly SemaphoreSlim _reorderGate = new(1, 1); private CancellationTokenSource? _graceCts; public string Title => Localizer.Instance["Nav.Live"]; @@ -156,9 +160,30 @@ public async Task LoadAsync(CancellationToken ct) if (_minimized) return; _allCameras = await _directory.ListAsync(ct).ConfigureAwait(true); await LoadLayoutsAsync(ct).ConfigureAwait(true); + await ApplyStartupLayoutOnceAsync().ConfigureAwait(true); await RefreshTilesAsync(ct).ConfigureAwait(true); } + // Settings → "Start with Live" + a chosen layout (#70): the first grid load + // of the session opens that layout instead of the last active one. Once per + // session, so switching tabs afterwards sticks. Persisted as the active + // layout too, so the library's "in grid" checkboxes follow the same tab. + private async Task ApplyStartupLayoutOnceAsync() + { + if (_startupLayoutApplied) return; + _startupLayoutApplied = true; + + var s = _userSettings.Current; + if (s.StartupPage != "live" || s.StartupLayoutId == 0) return; + var target = Layouts.FirstOrDefault(l => l.Id.Value == s.StartupLayoutId); + if (target is null || (ActiveLayout is { } cur && cur.Id == target.Id)) return; + + ActiveLayout = target; + LayoutSize = target.GridSize; + CurrentPage = 0; + await PersistActiveLayoutAsync(target.Id.Value).ConfigureAwait(true); + } + private async Task LoadLayoutsAsync(CancellationToken ct) { var all = await _layouts.GetAllAsync(ct).ConfigureAwait(true); @@ -473,6 +498,17 @@ private async Task RefreshTilesAsync(CancellationToken ct) catch (Exception ex) { _logger.LogWarning(ex, "Failed to activate tile for {Camera}", camera.Name); } } + // Kept tiles hold their old index and new ones were appended, so after a + // layout switch the cameras the two layouts share showed in the previous + // layout's order (#69). Put Tiles back into the layout's stored order. + for (var i = 0; i < visible.Count; i++) + { + var j = -1; + for (var k = i; k < Tiles.Count; k++) + if (Tiles[k].Camera.Id == visible[i].Id) { j = k; break; } + if (j > i) Tiles.Move(j, i); + } + // Slots fills the *visual* grid (always LayoutSize²), padding with // nulls when MaxConcurrentGridSessions is below the layout capacity. var visualCapacity = LayoutSize * LayoutSize; @@ -513,9 +549,8 @@ private StreamQuality DesiredQuality(Camera camera) => // 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 - // for the affected tiles; cameras outside the grid keep their existing - // SortOrder (so library ordering only shifts grid-included rows). + // not draggable and can't be drop targets). Persists the new order into the + // active layout's LayoutTiles positions; other layouts are untouched. public async Task MoveTileAsync(int fromIndex, int toIndex, CancellationToken ct) { if (fromIndex < 0 || fromIndex >= Tiles.Count) return; @@ -532,27 +567,35 @@ public async Task MoveTileAsync(int fromIndex, int toIndex, CancellationToken ct if (ActiveLayout is not { } a) return; + // Snapshot before the first await: a layout or page switch while the + // repository call is pending replaces Tiles, and its cameras must not be + // written into this layout. + var layoutId = a.Id; + var pageIds = Tiles.Select(t => t.Camera.Id).ToList(); + + await _reorderGate.WaitAsync(ct).ConfigureAwait(true); try { - // Tiles holds only the current page's visible prefix; reorder within - // the full member list (offset by the page) so cameras on other pages - // and beyond the session cap keep their place. - var offset = CurrentPage * LayoutSize * LayoutSize; - var from = offset + fromIndex; - var to = offset + toIndex; - var full = (await _layouts.GetTilesAsync(a.Id, ct).ConfigureAwait(true)).ToList(); - if (from < full.Count && to < full.Count) - { - var moved = full[from]; - full.RemoveAt(from); - full.Insert(to, moved); - await _layouts.SetTilesAsync(a.Id, full, ct).ConfigureAwait(true); - } + // Tiles holds only the current page's visible cameras. Write their new + // order back into the positions those same cameras occupy in the full + // member list, so cameras on other pages and beyond the session cap + // keep their place. Matching by camera id (not by page offset + + // index) stays correct when a closed tile has left a gap in Tiles. + var full = (await _layouts.GetTilesAsync(layoutId, ct).ConfigureAwait(true)).ToList(); + var positions = pageIds.Select(id => full.IndexOf(id)).Where(p => p >= 0).OrderBy(p => p).ToList(); + if (positions.Count != pageIds.Count) return; + for (var i = 0; i < positions.Count; i++) + full[positions[i]] = pageIds[i]; + await _layouts.SetTilesAsync(layoutId, full, ct).ConfigureAwait(true); } catch (Exception ex) { _logger.LogWarning(ex, "Persisting layout tile order failed"); } + finally + { + _reorderGate.Release(); + } } // Close button on a tile's error cell — drop it from the grid for this diff --git a/src/OpenIPC.Viewer.App/ViewModels/MainWindowViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/MainWindowViewModel.cs index 21f2405..f10e667 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/MainWindowViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/MainWindowViewModel.cs @@ -77,6 +77,7 @@ public MainWindowViewModel( CameraDirectoryService directory, SingleCameraPageFactory singleCameraFactory, RecordingPlayerPageFactory playerFactory, + UserSettingsService userSettings, ILogger logger) { Live = live; @@ -89,7 +90,8 @@ public MainWindowViewModel( _singleCameraFactory = singleCameraFactory; _playerFactory = playerFactory; _logger = logger; - _currentPage = library; + // Settings → Start page (#70): open straight into the live grid if asked. + _currentPage = userSettings.Current.StartupPage == "live" ? live : library; WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); diff --git a/src/OpenIPC.Viewer.App/ViewModels/SettingsPageViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/SettingsPageViewModel.cs index 1589191..ea80f1c 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/SettingsPageViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/SettingsPageViewModel.cs @@ -8,6 +8,7 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Messaging; +using Microsoft.Extensions.Logging; using OpenIPC.Viewer.App.Services; using OpenIPC.Viewer.Core.Onvif.Discovery; using OpenIPC.Viewer.Core.Platform; @@ -24,6 +25,8 @@ public sealed partial class SettingsPageViewModel : ViewModelBase private readonly OpenIPC.Viewer.Core.Persistence.IConfigBackupService _backup; private readonly ConfigSyncService _configSync; private readonly OpenIPC.Viewer.Core.Notifications.INotificationService _notifications; + private readonly OpenIPC.Viewer.Core.Persistence.ILayoutRepository _layouts; + private readonly ILogger _logger; private bool _suppressSave; public string Title => Localizer.Instance["Settings.Title"]; @@ -48,6 +51,17 @@ public sealed partial class SettingsPageViewModel : ViewModelBase [ObservableProperty] private bool _closeToTray; [ObservableProperty] private bool _singleInstance; + // Start page + the layout the live grid opens on (#70). The layout list is + // filled async from the repository (LoadStartupLayoutsAsync); option 0 is + // "last used". + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsStartupLayoutVisible))] + private StartPageOption? _startPage; + [ObservableProperty] private StartupLayoutOption? _startupLayout; + public IReadOnlyList StartPageOptions { get; } + public System.Collections.ObjectModel.ObservableCollection StartupLayoutOptions { get; } = new(); + public bool IsStartupLayoutVisible => StartPage?.Value == "live"; + // Gates the desktop-only toggles (tray) off the shared settings page. public bool IsDesktopPlatform { get; } = !OperatingSystem.IsAndroid() && !OperatingSystem.IsIOS(); @@ -163,7 +177,9 @@ public SettingsPageViewModel( ISshHostKeyStore hostKeys, OpenIPC.Viewer.Core.Persistence.IConfigBackupService backup, ConfigSyncService configSync, - OpenIPC.Viewer.Core.Notifications.INotificationService notifications) + OpenIPC.Viewer.Core.Notifications.INotificationService notifications, + OpenIPC.Viewer.Core.Persistence.ILayoutRepository layouts, + ILogger logger) { _settings = settings; _fs = fs; @@ -172,6 +188,8 @@ public SettingsPageViewModel( _backup = backup; _configSync = configSync; _notifications = notifications; + _layouts = layouts; + _logger = logger; var options = new List { @@ -188,6 +206,13 @@ public SettingsPageViewModel( m)) .ToList(); + StartPageOptions = new[] + { + new StartPageOption(Localizer.Instance["Nav.Library"], "library"), + new StartPageOption(Localizer.Instance["Nav.Live"], "live"), + }; + StartupLayoutOptions.Add(new StartupLayoutOption(Localizer.Instance["Settings.Appearance.StartupLayout.LastUsed"], 0)); + Load(); } @@ -218,6 +243,8 @@ private void Load() ShowSplash = s.ShowSplash; CloseToTray = s.CloseToTray; SingleInstance = s.SingleInstance; + StartPage = StartPageOptions.FirstOrDefault(o => o.Value == s.StartupPage) ?? StartPageOptions[0]; + StartupLayout = StartupLayoutOptions.FirstOrDefault(o => o.Id == s.StartupLayoutId); SshStrictHostKey = s.SshStrictHostKey; SshDefaultPort = s.SshDefaultPort; SshTerminalFontSize = s.SshTerminalFontSize; @@ -251,6 +278,8 @@ private void Load() partial void OnShowSplashChanged(bool value) => Persist(); partial void OnCloseToTrayChanged(bool value) => Persist(); partial void OnSingleInstanceChanged(bool value) => Persist(); + partial void OnStartPageChanged(StartPageOption? value) => Persist(); + partial void OnStartupLayoutChanged(StartupLayoutOption? value) => Persist(); partial void OnSshStrictHostKeyChanged(bool value) => Persist(); partial void OnSshDefaultPortChanged(int value) => Persist(); partial void OnSshTerminalFontSizeChanged(int value) => Persist(); @@ -283,6 +312,9 @@ private void Persist() ShowSplash = ShowSplash, CloseToTray = CloseToTray, SingleInstance = SingleInstance, + StartupPage = StartPage?.Value ?? "library", + // Null until the layout list has loaded — keep the saved choice. + StartupLayoutId = StartupLayout?.Id ?? _settings.Current.StartupLayoutId, SshStrictHostKey = SshStrictHostKey, SshDefaultPort = SshDefaultPort, SshTerminalFontSize = SshTerminalFontSize, @@ -303,6 +335,33 @@ private void Persist() _ = _settings.UpdateAsync(next, CancellationToken.None); } + // Layouts can be added/renamed on the Live page at any time, so the picker + // is rebuilt each time the settings page is shown. + public async Task LoadStartupLayoutsAsync() + { + IReadOnlyList all; + try { all = await _layouts.GetAllAsync(CancellationToken.None).ConfigureAwait(true); } + catch (Exception ex) + { + // Keep the "last used" fallback so the page still works. + _logger.LogWarning(ex, "Loading layouts for the startup-layout picker failed"); + return; + } + + _suppressSave = true; + try + { + StartupLayoutOptions.Clear(); + StartupLayoutOptions.Add(new StartupLayoutOption(Localizer.Instance["Settings.Appearance.StartupLayout.LastUsed"], 0)); + foreach (var l in all) + StartupLayoutOptions.Add(new StartupLayoutOption(l.Name, l.Id.Value)); + // A deleted layout falls back to "last used" (the grid ignores a stale id too). + var id = _settings.Current.StartupLayoutId; + StartupLayout = StartupLayoutOptions.FirstOrDefault(o => o.Id == id) ?? StartupLayoutOptions[0]; + } + finally { _suppressSave = false; } + } + [RelayCommand] private async Task ResetHostKeysAsync() { @@ -502,3 +561,8 @@ public sealed record NetworkInterfaceOption(string Display, string Value); // Combo item for Settings → Video idle-pause picker. Display is the localized // label ("Off" / "10 min"); Minutes is the persisted threshold (0 = off). public sealed record IdleTimeoutOption(string Display, int Minutes); + +// Combo items for Settings → Appearance start page / startup layout (#70). +// Value is the persisted UserSettings.StartupPage; Id is the LayoutId (0 = last used). +public sealed record StartPageOption(string Display, string Value); +public sealed record StartupLayoutOption(string Display, int Id); diff --git a/src/OpenIPC.Viewer.App/Views/Pages/SettingsPage.axaml b/src/OpenIPC.Viewer.App/Views/Pages/SettingsPage.axaml index fea5c58..dadca3f 100644 --- a/src/OpenIPC.Viewer.App/Views/Pages/SettingsPage.axaml +++ b/src/OpenIPC.Viewer.App/Views/Pages/SettingsPage.axaml @@ -232,6 +232,26 @@ + + + + + + + + + + diff --git a/src/OpenIPC.Viewer.App/Views/Pages/SettingsPage.axaml.cs b/src/OpenIPC.Viewer.App/Views/Pages/SettingsPage.axaml.cs index aff1a1e..91da35c 100644 --- a/src/OpenIPC.Viewer.App/Views/Pages/SettingsPage.axaml.cs +++ b/src/OpenIPC.Viewer.App/Views/Pages/SettingsPage.axaml.cs @@ -21,7 +21,12 @@ public SettingsPage() { InitializeComponent(); DataContextChanged += OnDataContextChanged; - AttachedToVisualTree += (_, _) => ApplyLayout(); + AttachedToVisualTree += (_, _) => + { + ApplyLayout(); + // Layouts may have changed on the Live page since the last visit. + if (_vm is not null) _ = _vm.LoadStartupLayoutsAsync(); + }; SizeChanged += OnSizeChanged; } @@ -35,6 +40,7 @@ private void OnDataContextChanged(object? sender, EventArgs e) // Hydrate the credential-sync passphrase from the secrets store (async, // can't run in the VM's synchronous Load()). _ = _vm.LoadConfigSyncSecretAsync(); + _ = _vm.LoadStartupLayoutsAsync(); ApplyLayout(); } }