Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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();
}
62 changes: 46 additions & 16 deletions src/OpenIPC.Viewer.App/ViewModels/GridPageViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ public sealed partial class GridPageViewModel : ViewModelBase,
private IReadOnlyList<Camera> _allCameras = Array.Empty<Camera>();
private bool _minimized;
private bool _suppressSettingsRefresh;
private bool _startupLayoutApplied;
private CancellationTokenSource? _graceCts;

public string Title => Localizer.Instance["Nav.Live"];
Expand Down Expand Up @@ -156,9 +157,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 +495,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 +546,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 @@ -534,20 +566,18 @@ public async Task MoveTileAsync(int fromIndex, int toIndex, CancellationToken ct

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;
// 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(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);
}
var pageIds = Tiles.Select(t => t.Camera.Id).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];
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
Outdated
await _layouts.SetTilesAsync(a.Id, full, ct).ConfigureAwait(true);
}
catch (Exception ex)
{
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
57 changes: 56 additions & 1 deletion src/OpenIPC.Viewer.App/ViewModels/SettingsPageViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ 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 bool _suppressSave;

public string Title => Localizer.Instance["Settings.Title"];
Expand All @@ -48,6 +49,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 +175,8 @@ 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)
{
_settings = settings;
_fs = fs;
Expand All @@ -172,6 +185,7 @@ public SettingsPageViewModel(
_backup = backup;
_configSync = configSync;
_notifications = notifications;
_layouts = layouts;

var options = new List<NetworkInterfaceOption>
{
Expand All @@ -188,6 +202,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 +239,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 +274,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 +308,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 +331,28 @@ 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) { return; }
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
Outdated

_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 +552,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