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
6 changes: 6 additions & 0 deletions src/OpenIPC.Viewer.App/Services/Localizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"] = "Запись",
Expand Down
7 changes: 6 additions & 1 deletion src/OpenIPC.Viewer.App/Services/UserSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
77 changes: 60 additions & 17 deletions src/OpenIPC.Viewer.App/ViewModels/GridPageViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ public sealed partial class GridPageViewModel : ViewModelBase,
private IReadOnlyList<Camera> _allCameras = Array.Empty<Camera>();
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"];
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand Down
4 changes: 3 additions & 1 deletion src/OpenIPC.Viewer.App/ViewModels/MainWindowViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ public MainWindowViewModel(
CameraDirectoryService directory,
SingleCameraPageFactory singleCameraFactory,
RecordingPlayerPageFactory playerFactory,
UserSettingsService userSettings,
ILogger<MainWindowViewModel> logger)
{
Live = live;
Expand All @@ -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<OpenCameraMessage>(this);
WeakReferenceMessenger.Default.Register<GoBackToLibraryMessage>(this);
Expand Down
66 changes: 65 additions & 1 deletion src/OpenIPC.Viewer.App/ViewModels/SettingsPageViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<SettingsPageViewModel> _logger;
private bool _suppressSave;

public string Title => Localizer.Instance["Settings.Title"];
Expand All @@ -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<StartPageOption> StartPageOptions { get; }
public System.Collections.ObjectModel.ObservableCollection<StartupLayoutOption> 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();
Expand Down Expand Up @@ -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<SettingsPageViewModel> logger)
{
_settings = settings;
_fs = fs;
Expand All @@ -172,6 +188,8 @@ public SettingsPageViewModel(
_backup = backup;
_configSync = configSync;
_notifications = notifications;
_layouts = layouts;
_logger = logger;

var options = new List<NetworkInterfaceOption>
{
Expand All @@ -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();
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand All @@ -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<OpenIPC.Viewer.Core.Entities.GridLayout> 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()
{
Expand Down Expand Up @@ -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);
20 changes: 20 additions & 0 deletions src/OpenIPC.Viewer.App/Views/Pages/SettingsPage.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,26 @@
</ComboBox>
</Grid>

<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Classes="field-label"
Text="{Binding [Settings.Appearance.StartPage], Source={x:Static svc:Localizer.Instance}}" />
<ComboBox Grid.Column="1"
ItemsSource="{Binding StartPageOptions}"
SelectedItem="{Binding StartPage, Mode=TwoWay}"
DisplayMemberBinding="{Binding Display}"
MinWidth="140" />
</Grid>

<Grid ColumnDefinitions="*,Auto" IsVisible="{Binding IsStartupLayoutVisible}">
<TextBlock Grid.Column="0" Classes="field-label"
Text="{Binding [Settings.Appearance.StartupLayout], Source={x:Static svc:Localizer.Instance}}" />
<ComboBox Grid.Column="1"
ItemsSource="{Binding StartupLayoutOptions}"
SelectedItem="{Binding StartupLayout, Mode=TwoWay}"
DisplayMemberBinding="{Binding Display}"
MinWidth="140" />
</Grid>

<CheckBox IsChecked="{Binding ShowSplash}"
Content="{Binding [Settings.Appearance.ShowSplash], Source={x:Static svc:Localizer.Instance}}"
Foreground="{StaticResource TextPrimaryBrush}" />
Expand Down
8 changes: 7 additions & 1 deletion src/OpenIPC.Viewer.App/Views/Pages/SettingsPage.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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();
}
}
Expand Down
Loading