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
30 changes: 30 additions & 0 deletions src/OpenIPC.Viewer.App/App.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -19,6 +20,10 @@ public sealed class App : Application
// AvaloniaMainActivity<App> 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);
Expand Down Expand Up @@ -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<ILoggerFactory>()?.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
Expand Down
16 changes: 16 additions & 0 deletions src/OpenIPC.Viewer.App/Services/Localizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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}",
Expand Down Expand Up @@ -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"] = "Не запущен",
Expand Down Expand Up @@ -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"] = "Поиск",
Expand Down Expand Up @@ -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}",
Expand Down
188 changes: 188 additions & 0 deletions src/OpenIPC.Viewer.App/Services/TrayIconService.cs
Original file line number Diff line number Diff line change
@@ -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<string, Bitmap?> _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<MainWindowViewModel>();
if (vm is null) return;
vm.NavigateCommand.Execute("library");
vm.Library.AddCameraCommand.Execute(null);
}

private void OpenSettings(bool about)
{
RestoreMainWindow();
var vm = _services.GetService<MainWindowViewModel>();
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;
}
}
5 changes: 4 additions & 1 deletion src/OpenIPC.Viewer.App/Services/UserSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
8 changes: 7 additions & 1 deletion src/OpenIPC.Viewer.App/Services/UserSettingsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
4 changes: 4 additions & 0 deletions src/OpenIPC.Viewer.App/Themes/Theme.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -98,5 +98,9 @@
<StreamGeometry x:Key="IconVolumeMuted">M11,5 L6,9 H2 V15 H6 L11,19 Z M23,9 L17,15 M17,9 L23,15</StreamGeometry>
<!-- IconMic — Lucide "mic", push-to-talk (Phase 17.6). -->
<StreamGeometry x:Key="IconMic">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</StreamGeometry>
<!-- IconCog — Lucide "settings" (gear), tray menu. -->
<StreamGeometry x:Key="IconCog">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</StreamGeometry>
<!-- IconPower — Lucide "power", tray menu exit. -->
<StreamGeometry x:Key="IconPower">M12,2 V12 M18.36,6.64 A9,9 0 1 1 5.63,6.64</StreamGeometry>

</ResourceDictionary>
13 changes: 13 additions & 0 deletions src/OpenIPC.Viewer.App/ViewModels/Dialogs/CameraEditorViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
Loading
Loading