Restructure step 2: Status as one computed level, the Notifier's real policy, system notifications

- Status: computed from connection, presence, hold, relay, player facts;
  no setters (health writes and HealthStatus gone); pill, strip and
  source line read it; transitions go to the Notifier.
- Notifier: noticeFor(event, context) with self presence, alone, window
  activity, playing and prefs; toasts for joins/leaves/returns/swaps/
  Up next adds (with picture)/problems/chat, the rest logged; while
  sitting out only chat, nudges, starting, Up next (+ joins if chosen);
  alone no social notices; replayed lines logged; owns its toast timer.
- System notifications (flutter_local_notifications: D-Bus on Linux,
  WinRT on Windows) when the window isn't focused; a click focuses it.
- Toasts sit above the control bar and move with it.
- Versions: each member's in the members card and avatar tooltip,
  the server's in the card header; the strip no longer shows it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-15 22:11:08 +03:00
1 parent 130c934b9b
commit 629be0d9ed
41 files changed
+2114 -741

No files matched your search

@@ -0,0 +1,100 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import '../desktop_entry.dart' show appId;
import '../ports/system_notifications.dart';
/// [SystemNotifications] on the desktop, through flutter_local_notifications'
/// platform plugins (used directly, not through its facade, so the platform
/// is picked by the OS and not by Flutter's target platform):
/// - Linux: `org.freedesktop.Notifications` over D-Bus (pure Dart), with the
/// app's icon (the bundled PNG, else the launcher entry's icon by name);
/// a click is the notification's default action.
/// - Windows: toast notifications (WinRT), the app's name and icon, a click
/// activates the app while it runs (an unpackaged app has no history).
/// Silent: the app plays its own sound for the same notice. Anything that
/// fails (no notification daemon, no session bus) is logged and dropped.
class DesktopNotifications implements SystemNotifications {
DesktopNotifications({this.appName = 'Starsync'});
final String appName;
void Function(String payload)? _onClick;
Future<bool>? _ready;
LinuxFlutterLocalNotificationsPlugin? _linux;
FlutterLocalNotificationsWindows? _windows;
// Tag -> id, so a notification with the same tag replaces the last one.
final _ids = <String, int>{};
int _next = 1;
@override
set onClick(void Function(String payload)? handler) => _onClick = handler;
void _clicked(NotificationResponse r) => _onClick?.call(r.payload ?? '');
/// The bundled 256 px icon next to the executable (a release or a
/// `flutter run` build), else the icon the launcher entry installed.
static LinuxNotificationIcon _linuxIcon() {
final exe = File(Platform.resolvedExecutable).parent.path;
final png = File('$exe/data/flutter_assets/assets/icon/icon_256.png');
return png.existsSync() ? FilePathLinuxIcon(png.path) : ThemeLinuxIcon(appId);
}
Future<bool> _init() => _ready ??= () async {
try {
if (Platform.isLinux) {
final p = _linux = LinuxFlutterLocalNotificationsPlugin();
return await p.initialize(
settings: LinuxInitializationSettings(defaultActionName: 'Open', defaultIcon: _linuxIcon()),
onDidReceiveNotificationResponse: _clicked,
) ??
false;
}
if (Platform.isWindows) {
final exe = File(Platform.resolvedExecutable).parent.path;
final icon = File('$exe/data/flutter_assets/assets/icon/icon_256.png');
final p = _windows = FlutterLocalNotificationsWindows();
return await p.initialize(
settings: WindowsInitializationSettings(
appName: appName,
appUserModelId: 'Foxyz.Starsync',
guid: '6c0f4b4e-3e8a-4b8e-9d0c-5a7f2b1e9c31',
iconPath: icon.existsSync() ? icon.path : null,
),
onDidReceiveNotificationResponse: _clicked,
);
}
} catch (e) {
debugPrint('system notifications unavailable: $e');
}
return false;
}();
@override
Future<void> show({required String title, required String body, String tag = '', String payload = ''}) async {
if (!await _init()) return;
final id = tag.isEmpty ? _next++ : _ids[tag] ??= _next++;
try {
if (_linux case final p?) {
await p.show(
id: id,
title: title,
body: body,
payload: payload,
notificationDetails: const LinuxNotificationDetails(suppressSound: true),
);
} else if (_windows case final p?) {
await p.show(
id: id,
title: title,
body: body,
payload: payload,
notificationDetails: WindowsNotificationDetails(audio: WindowsNotificationAudio.silent()),
);
}
} catch (e) {
debugPrint('system notification failed: $e');
}
}
}
@@ -1,6 +1,7 @@
import '../../playback/yt_dlp_app.dart';
import '../ports/ports.dart';
import 'desktop_folders.dart';
import 'desktop_notifications.dart';
import 'desktop_shell.dart';
import 'desktop_urls.dart';
@@ -12,6 +13,6 @@ Ports desktopPorts() {
urls: urls,
folders: const DesktopFolders(urls),
links: AppYtDlp.instance,
notifications: const NoSystemNotifications(),
notifications: DesktopNotifications(),
);
}
@@ -97,6 +97,17 @@ class DesktopShell implements ShellPort {
}
}
@override
Future<void> focus() async {
try {
if (await windowManager.isMinimized()) await windowManager.restore();
if (!await windowManager.isVisible()) await windowManager.show();
await windowManager.focus();
} catch (e) {
debugPrint('window focus failed: $e');
}
}
@override
Future<void> requestAttention() => requestWindowAttention();
@@ -1,20 +1,8 @@
import 'package:flutter/widgets.dart';
/// Where the app window stands, for auto sit-out
/// ([ShellPort.watchActivity]).
enum WindowActivity {
/// The window has the keyboard focus.
focused,
import 'window_activity.dart';
/// Shown, but another window has the focus.
unfocused,
/// Minimised to the taskbar.
minimised,
/// Hidden (closing, or a platform that hides instead of minimising).
hidden,
}
export 'window_activity.dart';
/// What the clipboard holds for a paste: its files, else its text.
typedef Paste = ({List<String> files, String? text});
@@ -54,6 +42,10 @@ abstract interface class ShellPort {
Future<void> hide();
Future<void> quit();
/// Brings the window up and gives it the focus (restored when
/// minimised, shown when hidden): a click on a system notification.
Future<void> focus();
/// Points the desktop at the window when it is not focused (taskbar
/// flash, urgency hint); nothing when it is.
Future<void> requestAttention();
@@ -1,14 +1,23 @@
/// Notifications outside the window (the desktop's notification center,
/// Android's shade). The policy that uses them comes with the Notifier.
/// Android's shade). The Notifier decides what goes out here (see
/// `noticeFor`); the port only shows them and reports clicks.
abstract interface class SystemNotifications {
/// Shows [title] / [body]; replaces the one with the same [tag].
Future<void> show({required String title, required String body, String tag = ''});
/// Shows [title] / [body] with the app's name and icon; one with the
/// same [tag] replaces it. [payload] comes back to [onClick].
Future<void> show({required String title, required String body, String tag = '', String payload = ''});
/// Called with a notification's payload when the user clicks it (set
/// once, by the Notifier).
set onClick(void Function(String payload)? handler);
}
/// Shows nothing (desktop today).
/// Shows nothing (tests, platforms without one).
class NoSystemNotifications implements SystemNotifications {
const NoSystemNotifications();
@override
Future<void> show({required String title, required String body, String tag = ''}) async {}
Future<void> show({required String title, required String body, String tag = '', String payload = ''}) async {}
@override
set onClick(void Function(String payload)? handler) {}
}
@@ -0,0 +1,19 @@
/// Where the app window stands ([ShellPort.watchActivity]): auto sit-out
/// and the Notifier (system notifications while it is not focused) read
/// it. Pure Dart.
enum WindowActivity {
/// The window has the keyboard focus.
focused,
/// Shown, but another window has the focus.
unfocused,
/// Minimised to the taskbar.
minimised,
/// Hidden (closing, or a platform that hides instead of minimising).
hidden;
/// Not in front of the user: notices go out as system notifications.
bool get background => this != focused;
}