diff --git a/src/OpenIPC.Viewer.App/App.axaml.cs b/src/OpenIPC.Viewer.App/App.axaml.cs index 44ab1d0..7ef0524 100644 --- a/src/OpenIPC.Viewer.App/App.axaml.cs +++ b/src/OpenIPC.Viewer.App/App.axaml.cs @@ -5,6 +5,7 @@ using Avalonia.Markup.Xaml; using Avalonia.Threading; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using OpenIPC.Viewer.App.Services; using OpenIPC.Viewer.App.ViewModels; using OpenIPC.Viewer.App.Views; @@ -19,6 +20,10 @@ public sealed class App : Application // AvaloniaMainActivity requires on Android. public static IServiceProvider? Services { get; set; } + // Desktop tray icon (null on mobile and until the main window first opens). + // MainWindow consults ExitRequested to tell a tray "Exit" from a close. + public static TrayIconService? Tray { get; private set; } + // How long the mobile splash overlay stays before fading. The Android boot // is fast, so without a minimum beat the splash would just flicker. private static readonly TimeSpan MobileSplashMinDuration = TimeSpan.FromSeconds(1.6); @@ -83,6 +88,31 @@ private static void ShowMainWindow(IClassicDesktopStyleApplicationLifetime deskt desktop.MainWindow = main; main.Show(); toClose?.Close(); + ShowTrayIcon(desktop); + } + + // Best-effort: some Linux desktops have no tray support, and a missing tray + // must never take the app down with it. + private static void ShowTrayIcon(IClassicDesktopStyleApplicationLifetime desktop) + { + if (Tray is not null) + return; + try + { + Tray = new TrayIconService(desktop, Services!); + Tray.Show(); + desktop.Exit += (_, _) => + { + Tray?.Dispose(); + Tray = null; + }; + } + catch (Exception ex) + { + Services!.GetService()?.CreateLogger(nameof(App)) + .LogWarning(ex, "Tray icon unavailable on this platform"); + Tray = null; + } } // Android/iOS: migrations already ran in the platform host, so the splash is diff --git a/src/OpenIPC.Viewer.App/Services/Localizer.cs b/src/OpenIPC.Viewer.App/Services/Localizer.cs index 2836175..e4d8d5b 100644 --- a/src/OpenIPC.Viewer.App/Services/Localizer.cs +++ b/src/OpenIPC.Viewer.App/Services/Localizer.cs @@ -76,6 +76,11 @@ private static LangCode DetectSystem() ["Startup.Failed"] = "Initialization failed", ["Startup.Retry"] = "Retry", ["Startup.Exit"] = "Exit", + ["Tray.Open"] = "Open viewer", + ["Tray.AddCamera"] = "Add camera…", + ["Tray.Settings"] = "Settings", + ["Tray.About"] = "About", + ["Tray.Exit"] = "Exit", ["Analytics.Overview"] = "Engine", ["Analytics.Status"] = "Status", ["Analytics.Status.NotStarted"] = "Not started", @@ -212,6 +217,7 @@ private static LangCode DetectSystem() ["Settings.Appearance"] = "Appearance", ["Settings.Appearance.Language"] = "Language", ["Settings.Appearance.ShowSplash"] = "Show animated splash screen on launch", + ["Settings.Appearance.CloseToTray"] = "Keep running in the tray when the window is closed", ["Settings.Video"] = "Video", ["Settings.Recording"] = "Recording", ["Settings.Discovery"] = "Discovery", @@ -334,6 +340,8 @@ private static LangCode DetectSystem() ["CameraEditor.Placeholder.SshPort"] = "22", ["CameraEditor.NoGroup"] = "(no group)", ["CameraEditor.Button.AutoDeriveRtsp"] = "Auto from host", + ["CameraEditor.Button.AutoDeriveXmRtsp"] = "XM preset", + ["CameraEditor.Tooltip.AutoDeriveXmRtsp"] = "Fill in the RTSP URLs used by XM/Xiongmai (NETSurveillance) cameras", ["CameraEditor.Button.TestConnection"] = "Test connection", ["CameraEditor.Status.Connecting"] = "Connecting…", ["CameraEditor.Status.OkFormat"] = "OK — {0}x{1}", @@ -539,6 +547,11 @@ private static LangCode DetectSystem() ["Startup.Failed"] = "Ошибка инициализации", ["Startup.Retry"] = "Повторить", ["Startup.Exit"] = "Выход", + ["Tray.Open"] = "Открыть", + ["Tray.AddCamera"] = "Добавить камеру…", + ["Tray.Settings"] = "Настройки", + ["Tray.About"] = "О программе", + ["Tray.Exit"] = "Выход", ["Analytics.Overview"] = "Движок", ["Analytics.Status"] = "Статус", ["Analytics.Status.NotStarted"] = "Не запущен", @@ -675,6 +688,7 @@ private static LangCode DetectSystem() ["Settings.Appearance"] = "Внешний вид", ["Settings.Appearance.Language"] = "Язык", ["Settings.Appearance.ShowSplash"] = "Показывать анимированную заставку при запуске", + ["Settings.Appearance.CloseToTray"] = "При закрытии окна оставаться в трее", ["Settings.Video"] = "Видео", ["Settings.Recording"] = "Запись", ["Settings.Discovery"] = "Поиск", @@ -797,6 +811,8 @@ private static LangCode DetectSystem() ["CameraEditor.Placeholder.SshPort"] = "22", ["CameraEditor.NoGroup"] = "(без группы)", ["CameraEditor.Button.AutoDeriveRtsp"] = "Подставить из хоста", + ["CameraEditor.Button.AutoDeriveXmRtsp"] = "Пресет XM", + ["CameraEditor.Tooltip.AutoDeriveXmRtsp"] = "Подставить RTSP-адреса камер XM/Xiongmai (NETSurveillance)", ["CameraEditor.Button.TestConnection"] = "Проверить", ["CameraEditor.Status.Connecting"] = "Подключение…", ["CameraEditor.Status.OkFormat"] = "OK — {0}x{1}", diff --git a/src/OpenIPC.Viewer.App/Services/TrayIconService.cs b/src/OpenIPC.Viewer.App/Services/TrayIconService.cs new file mode 100644 index 0000000..b54aac1 --- /dev/null +++ b/src/OpenIPC.Viewer.App/Services/TrayIconService.cs @@ -0,0 +1,188 @@ +using System; +using System.Collections.Generic; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.Platform; +using Microsoft.Extensions.DependencyInjection; +using OpenIPC.Viewer.App.ViewModels; + +namespace OpenIPC.Viewer.App.Services; + +// Desktop tray icon. Created by App once the main window is up and lives until +// process exit. Owns the close-to-tray flow: with the setting on, MainWindow +// cancels its close and hides itself, and this menu is the way back (or out). +public sealed class TrayIconService : IDisposable +{ + private readonly IClassicDesktopStyleApplicationLifetime _desktop; + private readonly IServiceProvider _services; + private TrayIcon? _tray; + + // Set by the tray "Exit" item so MainWindow.OnClosing lets the close + // proceed even when close-to-tray is enabled. + public bool ExitRequested { get; private set; } + + public TrayIconService(IClassicDesktopStyleApplicationLifetime desktop, IServiceProvider services) + { + _desktop = desktop; + _services = services; + } + + public void Show() + { + if (_tray is not null) + return; + + _tray = new TrayIcon + { + Icon = new WindowIcon(AssetLoader.Open( + new Uri("avares://OpenIPC.Viewer.App/Assets/openipc-logo.png"))), + ToolTipText = "OpenIPC Viewer", + Menu = BuildMenu(), + }; + _tray.Clicked += OnTrayClicked; + TrayIcon.SetIcons(Application.Current!, new TrayIcons { _tray }); + + // Menu item text follows the in-app language switch. + Localizer.Instance.PropertyChanged += OnLanguageChanged; + } + + private void OnTrayClicked(object? sender, EventArgs e) => RestoreMainWindow(); + + private void OnLanguageChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if (_tray is not null) + _tray.Menu = BuildMenu(); + } + + private NativeMenu BuildMenu() + { + var menu = new NativeMenu(); + menu.Add(Item("Tray.Open", "IconVideo", RestoreMainWindow)); + menu.Add(Item("Tray.AddCamera", "IconCamera", AddCamera)); + menu.Add(new NativeMenuItemSeparator()); + // About lives on the settings page, so both items land there — About + // just deep-links to its section. + menu.Add(Item("Tray.Settings", "IconCog", () => OpenSettings(about: false))); + menu.Add(Item("Tray.About", "IconAbout", () => OpenSettings(about: true))); + menu.Add(new NativeMenuItemSeparator()); + menu.Add(Item("Tray.Exit", "IconPower", ExitApplication)); + return menu; + } + + private NativeMenuItem Item(string key, string iconKey, Action action) + { + var item = new NativeMenuItem(Localizer.Instance[key]) + { + Icon = GetMenuIcon(iconKey), + }; + item.Click += (_, _) => action(); + return item; + } + + // Rendered lucide bitmaps, one per geometry key. Cached because the menu is + // rebuilt on every language switch and the icons never change. + private readonly Dictionary _menuIcons = new(); + + // Renders one of the Theme.axaml lucide stroke geometries into a small + // bitmap for NativeMenuItem.Icon (there is no vector slot on native menu + // items). Mirrors the Path.lucide style. The bitmap MUST be 16×16 at 96 + // DPI: the menu presents it at pixel size, so a hi-DPI (2x) bitmap comes + // out double-sized and gets cropped to its top-left corner in the ~16px + // icon slot. Best-effort: a missing resource or render failure just + // leaves the item without an icon. + private Bitmap? GetMenuIcon(string resourceKey) + { + if (_menuIcons.TryGetValue(resourceKey, out var cached)) + return cached; + + Bitmap? bitmap = null; + try + { + if (Application.Current?.TryGetResource(resourceKey, null, out var res) == true && + res is Geometry geometry) + { + var accent = Application.Current.TryGetResource("AccentBrush", null, out var b) && b is IBrush brush + ? brush + : Brushes.Gray; + + var path = new Avalonia.Controls.Shapes.Path + { + Data = geometry, + Stroke = accent, + StrokeThickness = 1.5, + StrokeLineCap = PenLineCap.Round, + StrokeJoin = PenLineJoin.Round, + Fill = Brushes.Transparent, + Stretch = Stretch.Uniform, + Width = 14, + Height = 14, + HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center, + VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center, + }; + var host = new Border { Width = 16, Height = 16, Child = path }; + host.Measure(new Size(16, 16)); + host.Arrange(new Rect(0, 0, 16, 16)); + + var rtb = new RenderTargetBitmap(new PixelSize(16, 16), new Vector(96, 96)); + rtb.Render(host); + bitmap = rtb; + } + } + catch + { + bitmap = null; + } + + _menuIcons[resourceKey] = bitmap; + return bitmap; + } + + private void RestoreMainWindow() + { + if (_desktop.MainWindow is Views.MainWindow window) + window.RestoreFromTray(); + } + + private void AddCamera() + { + RestoreMainWindow(); + var vm = _services.GetService(); + if (vm is null) return; + vm.NavigateCommand.Execute("library"); + vm.Library.AddCameraCommand.Execute(null); + } + + private void OpenSettings(bool about) + { + RestoreMainWindow(); + var vm = _services.GetService(); + if (vm is null) return; + vm.NavigateCommand.Execute("settings"); + if (about) + vm.Settings.SelectAboutSection(); + } + + private void ExitApplication() + { + ExitRequested = true; + _desktop.Shutdown(); + } + + public void Dispose() + { + Localizer.Instance.PropertyChanged -= OnLanguageChanged; + foreach (var icon in _menuIcons.Values) + icon?.Dispose(); + _menuIcons.Clear(); + if (_tray is null) + return; + // Explicit disposal removes the icon immediately — relying on process + // death leaves a ghost icon in the Windows tray until hovered. + _tray.Clicked -= OnTrayClicked; + _tray.Dispose(); + _tray = null; + } +} diff --git a/src/OpenIPC.Viewer.App/Services/UserSettings.cs b/src/OpenIPC.Viewer.App/Services/UserSettings.cs index ee029c3..9320bf7 100644 --- a/src/OpenIPC.Viewer.App/Services/UserSettings.cs +++ b/src/OpenIPC.Viewer.App/Services/UserSettings.cs @@ -91,7 +91,10 @@ public sealed record UserSettings( // session in every grid tile — far cheaper on CPU/bandwidth for big walls. // Interval is seconds between grabs. A per-camera override lands later. bool GridStillsMode = false, - int GridStillsIntervalSeconds = 10) + int GridStillsIntervalSeconds = 10, + // Desktop only: closing the main window hides it to the tray icon instead + // of quitting (live streams are released while hidden). Off = close quits. + bool CloseToTray = false) { public static UserSettings Default => new(); } diff --git a/src/OpenIPC.Viewer.App/Services/UserSettingsService.cs b/src/OpenIPC.Viewer.App/Services/UserSettingsService.cs index d2cd8ce..cb58e8d 100644 --- a/src/OpenIPC.Viewer.App/Services/UserSettingsService.cs +++ b/src/OpenIPC.Viewer.App/Services/UserSettingsService.cs @@ -95,7 +95,13 @@ private void TryLoad() private async Task SaveAsync(CancellationToken ct) { var tmp = _path + ".tmp"; - await using (var stream = File.Create(tmp)) + var stream = File.Create(tmp); + // A bare `await using` has no ConfigureAwait — when this method is + // entered on the UI thread (free-gate UpdateAsync runs synchronously up + // to here), the DisposeAsync continuation would be posted back to the + // blocked dispatcher and deadlock a caller that synchronously waits on + // the task (MainWindow.OnClosing did exactly that). + await using (stream.ConfigureAwait(false)) await JsonSerializer.SerializeAsync(stream, Current, JsonOpts, ct).ConfigureAwait(false); File.Move(tmp, _path, overwrite: true); } diff --git a/src/OpenIPC.Viewer.App/Themes/Theme.axaml b/src/OpenIPC.Viewer.App/Themes/Theme.axaml index c57fb9e..96eb7e7 100644 --- a/src/OpenIPC.Viewer.App/Themes/Theme.axaml +++ b/src/OpenIPC.Viewer.App/Themes/Theme.axaml @@ -98,5 +98,9 @@ M11,5 L6,9 H2 V15 H6 L11,19 Z M23,9 L17,15 M17,9 L23,15 M12,2 A3,3 0 0 0 9,5 V11 A3,3 0 0 0 15,11 V5 A3,3 0 0 0 12,2 Z M19,10 V11 A7,7 0 0 1 5,11 V10 M12,18 V22 M8,22 H16 + + M12.22,2 h-0.44 a2,2 0 0 0 -2,2 v0.18 a2,2 0 0 1 -1,1.73 l-0.43,0.25 a2,2 0 0 1 -2,0 l-0.15,-0.08 a2,2 0 0 0 -2.73,0.73 l-0.22,0.38 a2,2 0 0 0 0.73,2.73 l0.15,0.1 a2,2 0 0 1 1,1.72 v0.51 a2,2 0 0 1 -1,1.74 l-0.15,0.09 a2,2 0 0 0 -0.73,2.73 l0.22,0.38 a2,2 0 0 0 2.73,0.73 l0.15,-0.08 a2,2 0 0 1 2,0 l0.43,0.25 a2,2 0 0 1 1,1.73 V20 a2,2 0 0 0 2,2 h0.44 a2,2 0 0 0 2,-2 v-0.18 a2,2 0 0 1 1,-1.73 l0.43,-0.25 a2,2 0 0 1 2,0 l0.15,0.08 a2,2 0 0 0 2.73,-0.73 l0.22,-0.39 a2,2 0 0 0 -0.73,-2.73 l-0.15,-0.08 a2,2 0 0 1 -1,-1.74 v-0.5 a2,2 0 0 1 1,-1.74 l0.15,-0.09 a2,2 0 0 0 0.73,-2.73 l-0.22,-0.38 a2,2 0 0 0 -2.73,-0.73 l-0.15,0.08 a2,2 0 0 1 -2,0 l-0.43,-0.25 a2,2 0 0 1 -1,-1.73 V4 a2,2 0 0 0 -2,-2 Z M9,12 A3,3 0 1 0 15,12 A3,3 0 1 0 9,12 Z + + M12,2 V12 M18.36,6.64 A9,9 0 1 1 5.63,6.64 diff --git a/src/OpenIPC.Viewer.App/ViewModels/Dialogs/CameraEditorViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/Dialogs/CameraEditorViewModel.cs index 5403ce2..8dde719 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/Dialogs/CameraEditorViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/Dialogs/CameraEditorViewModel.cs @@ -159,6 +159,19 @@ private void AutoDeriveRtsp() RtspMainText = $"rtsp://{Host.Trim()}/"; } + // XM/Xiongmai (NETSurveillance) firmware embeds the login in the RTSP path + // and serves main/sub as stream=0/1. Uses the credential fields when set, + // falling back to the stock "admin" + empty password those cameras ship with. + [RelayCommand] + private void AutoDeriveXmRtsp() + { + if (string.IsNullOrWhiteSpace(Host)) return; + var host = Host.Trim(); + var user = string.IsNullOrWhiteSpace(Username) ? "admin" : Username.Trim(); + RtspMainText = $"rtsp://{host}:554/user={user}&password={Password}&channel=1&stream=0.sdp?real_stream"; + RtspSubText = $"rtsp://{host}:554/user={user}&password={Password}&channel=1&stream=1.sdp?real_stream"; + } + [RelayCommand(CanExecute = nameof(CanTestConnection))] private async Task TestConnectionAsync() { diff --git a/src/OpenIPC.Viewer.App/ViewModels/SettingsPageViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/SettingsPageViewModel.cs index 97a87d5..2e2ec98 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/SettingsPageViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/SettingsPageViewModel.cs @@ -45,6 +45,11 @@ public sealed partial class SettingsPageViewModel : ViewModelBase [ObservableProperty] private NetworkInterfaceOption? _selectedNetworkInterface; [ObservableProperty] private string _language = "system"; [ObservableProperty] private bool _showSplash = true; + [ObservableProperty] private bool _closeToTray; + + // Gates the desktop-only toggles (tray) off the shared settings page. + public bool IsDesktopPlatform { get; } = + !OperatingSystem.IsAndroid() && !OperatingSystem.IsIOS(); // SSH section (Phase 13). [ObservableProperty] private bool _sshStrictHostKey = true; @@ -116,6 +121,10 @@ partial void OnIsWideChanged(bool value) [RelayCommand] private void BackToList() => SelectedSectionIndex = -1; + // Deep-link target for the tray "About" item — keeps the section index in + // one place (it shifts between editions with different section sets). + public void SelectAboutSection() => SelectedSectionIndex = 6; + public bool IsRecordingsDirOverridden => !string.IsNullOrWhiteSpace(RecordingsDirOverride); // What RecordingService will actually use — override if set, otherwise @@ -206,6 +215,7 @@ private void Load() RecordingsDirOverride = s.RecordingsDirOverride; Language = s.Language; ShowSplash = s.ShowSplash; + CloseToTray = s.CloseToTray; SshStrictHostKey = s.SshStrictHostKey; SshDefaultPort = s.SshDefaultPort; SshTerminalFontSize = s.SshTerminalFontSize; @@ -237,6 +247,7 @@ private void Load() partial void OnRecordingsDirOverrideChanged(string value) => Persist(); partial void OnLanguageChanged(string value) => Persist(); partial void OnShowSplashChanged(bool value) => Persist(); + partial void OnCloseToTrayChanged(bool value) => Persist(); partial void OnSshStrictHostKeyChanged(bool value) => Persist(); partial void OnSshDefaultPortChanged(int value) => Persist(); partial void OnSshTerminalFontSizeChanged(int value) => Persist(); @@ -267,6 +278,7 @@ private void Persist() RecordingsDirOverride = RecordingsDirOverride, Language = Language, ShowSplash = ShowSplash, + CloseToTray = CloseToTray, SshStrictHostKey = SshStrictHostKey, SshDefaultPort = SshDefaultPort, SshTerminalFontSize = SshTerminalFontSize, diff --git a/src/OpenIPC.Viewer.App/Views/Dialogs/CameraEditorContent.axaml b/src/OpenIPC.Viewer.App/Views/Dialogs/CameraEditorContent.axaml index 45abd41..f6c02c0 100644 --- a/src/OpenIPC.Viewer.App/Views/Dialogs/CameraEditorContent.axaml +++ b/src/OpenIPC.Viewer.App/Views/Dialogs/CameraEditorContent.axaml @@ -43,7 +43,7 @@ - +