diff --git a/README.md b/README.md index 7628872..fd9242c 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ few hours, and when one exists a band across the top of the window says so (never over a playing video). "Update and restart" downloads, verifies, applies in place and restarts; "Later" hides it until the next check; the install folder must be writable by you. The version you run is -under the Settings tab. Settings has Check now and an auto-check toggle. From a terminal: +at the bottom of every Settings page (and in the Settings tab's tooltip). Settings has Check now and an auto-check toggle. From a terminal: `./starsync-server --upgrade` (no display needed, so it works on a server box too), `./starsync --upgrade`, or the shipped `update.sh` / `update.bat` (which run the first). Each updates the whole folder, app and @@ -84,7 +84,8 @@ with a ring for their state (green ready, amber buffering, grey not ready, a little faded joining, dimmed reconnecting, dimmed with a moon sitting out; a crown on whoever hosts, a tower on whoever passes the video on). Click the avatars for the -member list (force ready, Nudge, Sit out, "Copy invite address"; when you host it lists this computer's +member list (each member's app version, amber when it differs from yours, +and the server's at the top; force ready, Nudge, Sit out, "Copy invite address"; when you host it lists this computer's network addresses with the port, and friends outside your network need that port forwarded or a server they can all reach, like a VPS). Beside them the server and "ready n/m" (who is sitting out or reconnecting does not count), the chat button with unread @@ -107,10 +108,23 @@ loading…") or what went wrong, with Retry or Cancel when it helps. Chat and ac Tab), ready to type; it shows messages (select text to copy it, or right-click for Copy message and, with a link in it, Copy link / Open in browser), and its "Activity" toggle adds -joins, ready, seeks and the like between them. Joins, leaves, chat and -other news also pop up bottom left with the person's avatar; click a message to open the chat -(Settings → Notifications → Chat messages: All by default, or Only when -paused, or Off; messages always reach the chat, with their sound). Unread chat shows as a count on the chat +joins, ready, seeks and the like between them. Joins, leaves, someone +losing connection and coming back, video changes, additions to Up next +(the title, its picture and who added it), chat and problems also pop up +bottom left with the person's avatar, above the player's controls (never +on them); ready, seeks and pauses stay in the activity. Click a message +to open the chat (Settings → Notifications & sounds → Chat messages: All +by default, or Only when paused, or Off; messages always reach the chat, +with their sound). Watching alone, nothing pops up or sounds about the +party (there is nobody); the app's own notices still show. + +**While sitting out or in the background.** Sitting out, only chat, +nudges, "starting now" (the video starts for the others) and Up next +reach you; joins and leaves too if you turn on Settings → Notifications & +sounds → "Joins and leaves too". With the window in the background or +minimised, the same ones also come as desktop notifications (Linux's +notification center, Windows' toasts); clicking one brings the window +up, and a chat one opens the chat. Unread chat shows as a count on the chat buttons and, in fullscreen with the controls hidden and the video paused, as a small chip top right. @@ -315,10 +329,11 @@ of help: Profile; Party & network (the Host card's default port and password, recent servers with remove); Player (below); Jellyfin & integrations (linked Jellyfin servers, below, the AnimeFlow connection: "connected as " with what you may do, or why not, and playing the -next episode automatically); Notifications & sounds (what pops up; the sound +next episode automatically); Notifications & sounds (what pops up, chat +pop-ups, what reaches you while sitting out or in the background; the sound volume and sound set, and every sound with a preview, its own set and a switch); Updates; Keyboard shortcuts (read-only); About -(the version; resting the mouse on the Settings tab shows it too; and the -credits, e.g. that the Minimal player design is inspired by uosc). +(the version, also at the bottom of every Settings page and in the +Settings tab's tooltip; and the credits, e.g. that the Minimal player design is inspired by uosc). Player settings live in the player too: ⚙ → Player settings, at the right of its controls, and Settings → Player has the same ones (plus the @@ -502,9 +517,11 @@ top: who passes it on, or that the server fetches it, everyone plays the link, or everyone streams it from Jellyfin, with how many watch through the server and, for a relay, how fast it arrives against what the video needs (the bar's mark; past it is keeping up). It turns amber when the -source is too slow or reconnecting, red when it left (the others play -out what the server already has), and whoever passes it on is told once -when their upload is too slow. Each member's line says how they get it +source is too slow or its connection dropped ("Bob's connection dropped — +playing what the server has (2 min)"), red when it left (the others play +out what the server already has; the player's pill says the same and a +pop-up tells you once), and whoever passes it on is told once when their +upload is too slow. Each member's line says how they get it ("via server" with their speed, or "direct") after a dot for their buffer: green enough to play on, amber low, red empty. A red dot on one member while the source keeps up is their own connection. diff --git a/apps/desktop/lib/main.dart b/apps/desktop/lib/main.dart index 7f71125..7cf66be 100644 --- a/apps/desktop/lib/main.dart +++ b/apps/desktop/lib/main.dart @@ -52,8 +52,12 @@ Future main(List args) async { final ports = SessionState.instance.ports; await ports.shell.start( title: dirs.profile == null ? appName : '$appName [${dirs.profile}]', onCloseRequest: shutDownApp); - // Focus, minimise and restore feed auto sit-out. - ports.shell.watchActivity(SessionState.instance.coordinator.windowActivity); + // Focus, minimise and restore feed auto sit-out and the Notifier (in + // the background, notices go out as system notifications). + ports.shell.watchActivity((a) { + SessionState.instance.coordinator.windowActivity(a); + SessionState.instance.notifier.window = a; + }); try { await SessionState.instance.loadForm(); } catch (e, st) { diff --git a/apps/desktop/lib/src/platform/desktop/desktop_notifications.dart b/apps/desktop/lib/src/platform/desktop/desktop_notifications.dart new file mode 100644 index 0000000..3135efa --- /dev/null +++ b/apps/desktop/lib/src/platform/desktop/desktop_notifications.dart @@ -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? _ready; + LinuxFlutterLocalNotificationsPlugin? _linux; + FlutterLocalNotificationsWindows? _windows; + // Tag -> id, so a notification with the same tag replaces the last one. + final _ids = {}; + 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 _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 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'); + } + } +} diff --git a/apps/desktop/lib/src/platform/desktop/desktop_ports.dart b/apps/desktop/lib/src/platform/desktop/desktop_ports.dart index 94d1141..cee9712 100644 --- a/apps/desktop/lib/src/platform/desktop/desktop_ports.dart +++ b/apps/desktop/lib/src/platform/desktop/desktop_ports.dart @@ -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(), ); } diff --git a/apps/desktop/lib/src/platform/desktop/desktop_shell.dart b/apps/desktop/lib/src/platform/desktop/desktop_shell.dart index 8906413..d68f81d 100644 --- a/apps/desktop/lib/src/platform/desktop/desktop_shell.dart +++ b/apps/desktop/lib/src/platform/desktop/desktop_shell.dart @@ -97,6 +97,17 @@ class DesktopShell implements ShellPort { } } + @override + Future 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 requestAttention() => requestWindowAttention(); diff --git a/apps/desktop/lib/src/platform/ports/shell_port.dart b/apps/desktop/lib/src/platform/ports/shell_port.dart index bf71ed9..8716b48 100644 --- a/apps/desktop/lib/src/platform/ports/shell_port.dart +++ b/apps/desktop/lib/src/platform/ports/shell_port.dart @@ -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 files, String? text}); @@ -54,6 +42,10 @@ abstract interface class ShellPort { Future hide(); Future quit(); + /// Brings the window up and gives it the focus (restored when + /// minimised, shown when hidden): a click on a system notification. + Future focus(); + /// Points the desktop at the window when it is not focused (taskbar /// flash, urgency hint); nothing when it is. Future requestAttention(); diff --git a/apps/desktop/lib/src/platform/ports/system_notifications.dart b/apps/desktop/lib/src/platform/ports/system_notifications.dart index 155cf2b..a0bd9ef 100644 --- a/apps/desktop/lib/src/platform/ports/system_notifications.dart +++ b/apps/desktop/lib/src/platform/ports/system_notifications.dart @@ -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 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 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 show({required String title, required String body, String tag = ''}) async {} + Future show({required String title, required String body, String tag = '', String payload = ''}) async {} + + @override + set onClick(void Function(String payload)? handler) {} } diff --git a/apps/desktop/lib/src/platform/ports/window_activity.dart b/apps/desktop/lib/src/platform/ports/window_activity.dart new file mode 100644 index 0000000..87470b2 --- /dev/null +++ b/apps/desktop/lib/src/platform/ports/window_activity.dart @@ -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; +} diff --git a/apps/desktop/lib/src/screens/home/health_probe.dart b/apps/desktop/lib/src/screens/home/health_probe.dart index cea4fc6..2a953e9 100644 --- a/apps/desktop/lib/src/screens/home/health_probe.dart +++ b/apps/desktop/lib/src/screens/home/health_probe.dart @@ -16,12 +16,15 @@ class ServerHealth { final String name; final int? proto; final int viewers; + /// Its Starsync version (`version`), '' from a server too old to say. + final String version; const ServerHealth.offline() : online = false, name = '', proto = null, - viewers = 0; - const ServerHealth.online({required this.name, this.proto, this.viewers = 0}) + viewers = 0, + version = ''; + const ServerHealth.online({required this.name, this.proto, this.viewers = 0, this.version = ''}) : online = true; /// True when the server speaks another protocol than this build. @@ -33,11 +36,12 @@ class ServerHealth { try { final j = jsonDecode(body); if (j is! Map || j['ok'] != true) return const ServerHealth.offline(); - final name = j['name'], proto = j['proto'], viewers = j['viewers']; + final name = j['name'], proto = j['proto'], viewers = j['viewers'], version = j['version']; return ServerHealth.online( name: name is String ? name.trim() : '', proto: proto is num ? proto.toInt() : null, viewers: viewers is num ? viewers.toInt() : 0, + version: version is String ? version.trim() : '', ); } catch (_) { return const ServerHealth.offline(); diff --git a/apps/desktop/lib/src/session/app_events.dart b/apps/desktop/lib/src/session/app_events.dart index b60b7b6..b6757f9 100644 --- a/apps/desktop/lib/src/session/app_events.dart +++ b/apps/desktop/lib/src/session/app_events.dart @@ -15,65 +15,71 @@ sealed class AppEvent { /// in the activity log. sealed class FeedEvent extends AppEvent { final FeedMessage feed; - const FeedEvent(this.feed); + /// This member is the actor (its own chat, ready, queue add...). + final bool own; + const FeedEvent(this.feed, {this.own = false}); String get actor => feed.actor; /// [feed] as its typed event. [selfId]: this member's id (people are /// matched by `actorId` / `targetId`, never by name); [readyCount]: - /// members ready in the latest state (the pitch of a `ready` sound). - factory FeedEvent.of(FeedMessage f, {String selfId = '', int readyCount = 0}) { + /// members ready in the latest state (the pitch of a `ready` sound); + /// [queueThumb]: the picture hash of the Up next item a `queue-add` + /// added (from the state that came with it), '' when none. + factory FeedEvent.of(FeedMessage f, {String selfId = '', int readyCount = 0, String queueThumb = ''}) { bool isSelf(String id) => selfId.isNotEmpty && id == selfId; + final own = isSelf(f.actorId); return switch (f.action) { - 'join' => MemberJoined(f), - 'leave' => MemberLeft(f), - 'gone' => MemberGone(f), - 'return' => MemberReturned(f), + 'join' => MemberJoined(f, own: own), + 'leave' => MemberLeft(f, own: own), + 'gone' => MemberGone(f, own: own), + 'return' => MemberReturned(f, own: own), 'away' => MemberSatOut(f, + own: own, atYou: isSelf(f.targetId) && !isSelf(f.actorId), autoSelf: isSelf(f.actorId) && f.targetId.isEmpty && f.reason.isNotEmpty), - 'in' => MemberBack(f), - 'ready' => ReadyChanged(f, ready: true, readyIndex: readyCount), - 'unready' => ReadyChanged(f, ready: false), - 'force-ready' => ForcedReady(f, ready: true), - 'force-unready' => ForcedReady(f, ready: false), - 'force-play' => ForcedTransport(f, play: true), - 'force-hold' => ForcedTransport(f, play: false), - 'swap' => MediaSwapped(f), - 'seek' => Seeked(f), - 'pause' => Paused(f), - 'resume' => Resumed(f), - 'finished' => Finished(f), - 'chat' => ChatPosted(f, own: isSelf(f.actorId)), - 'rename' => MemberRenamed(f), - 'avatar' => AvatarChanged(f), - 'queue-add' => QueueAdded(f), - 'queue-remove' => QueueRemoved(f), - 'queue-skip' => QueueSkipped(f), - 'fetch-fail' => FetchFailed(f), - _ => OtherFeed(f), + 'in' => MemberBack(f, own: own), + 'ready' => ReadyChanged(f, own: own, ready: true, readyIndex: readyCount), + 'unready' => ReadyChanged(f, own: own, ready: false), + 'force-ready' => ForcedReady(f, own: own, ready: true), + 'force-unready' => ForcedReady(f, own: own, ready: false), + 'force-play' => ForcedTransport(f, own: own, play: true), + 'force-hold' => ForcedTransport(f, own: own, play: false), + 'swap' => MediaSwapped(f, own: own), + 'seek' => Seeked(f, own: own), + 'pause' => Paused(f, own: own), + 'resume' => Resumed(f, own: own), + 'finished' => Finished(f, own: own), + 'chat' => ChatPosted(f, own: own), + 'rename' => MemberRenamed(f, own: own), + 'avatar' => AvatarChanged(f, own: own), + 'queue-add' => QueueAdded(f, own: own, thumb: queueThumb), + 'queue-remove' => QueueRemoved(f, own: own), + 'queue-skip' => QueueSkipped(f, own: own), + 'fetch-fail' => FetchFailed(f, own: own), + _ => OtherFeed(f, own: own), }; } } class MemberJoined extends FeedEvent { - const MemberJoined(super.feed); + const MemberJoined(super.feed, {super.own}); } /// Left for good (`reason` `bye`: the app closed or Leave; `grace`: gone /// too long). class MemberLeft extends FeedEvent { - const MemberLeft(super.feed); + const MemberLeft(super.feed, {super.own}); } /// Lost signal: gone for a while, kept in its grace period. class MemberGone extends FeedEvent { - const MemberGone(super.feed); + const MemberGone(super.feed, {super.own}); } /// Back within its grace after a [MemberGone]. class MemberReturned extends FeedEvent { - const MemberReturned(super.feed); + const MemberReturned(super.feed, {super.own}); } /// Someone sat out (`away`: the actor itself, or `target` sat out by the @@ -83,87 +89,89 @@ class MemberReturned extends FeedEvent { class MemberSatOut extends FeedEvent { final bool atYou; final bool autoSelf; - const MemberSatOut(super.feed, {this.atYou = false, this.autoSelf = false}); + const MemberSatOut(super.feed, {super.own, this.atYou = false, this.autoSelf = false}); } /// Back from sitting out (`in`). class MemberBack extends FeedEvent { - const MemberBack(super.feed); + const MemberBack(super.feed, {super.own}); } class ReadyChanged extends FeedEvent { final bool ready; /// Members ready when it came (pitch of the ready sound). final int readyIndex; - const ReadyChanged(super.feed, {required this.ready, this.readyIndex = 0}); + const ReadyChanged(super.feed, {super.own, required this.ready, this.readyIndex = 0}); } class ForcedReady extends FeedEvent { final bool ready; - const ForcedReady(super.feed, {required this.ready}); + const ForcedReady(super.feed, {super.own, required this.ready}); } /// Force play / hold. class ForcedTransport extends FeedEvent { final bool play; - const ForcedTransport(super.feed, {required this.play}); + const ForcedTransport(super.feed, {super.own, required this.play}); } class MediaSwapped extends FeedEvent { - const MediaSwapped(super.feed); + const MediaSwapped(super.feed, {super.own}); } class Seeked extends FeedEvent { - const Seeked(super.feed); + const Seeked(super.feed, {super.own}); } class Paused extends FeedEvent { - const Paused(super.feed); + const Paused(super.feed, {super.own}); } class Resumed extends FeedEvent { - const Resumed(super.feed); + const Resumed(super.feed, {super.own}); } class Finished extends FeedEvent { - const Finished(super.feed); + const Finished(super.feed, {super.own}); } /// A chat message (`reason` carries the text); [own]: this member sent it. class ChatPosted extends FeedEvent { - final bool own; - const ChatPosted(super.feed, {this.own = false}); + const ChatPosted(super.feed, {super.own}); } class MemberRenamed extends FeedEvent { - const MemberRenamed(super.feed); + const MemberRenamed(super.feed, {super.own}); } class AvatarChanged extends FeedEvent { - const AvatarChanged(super.feed); + const AvatarChanged(super.feed, {super.own}); } +/// Someone added an item to Up next (`target` its title); [thumb]: the +/// item's picture hash ('' when none). class QueueAdded extends FeedEvent { - const QueueAdded(super.feed); + final String thumb; + const QueueAdded(super.feed, {super.own, this.thumb = ''}); } class QueueRemoved extends FeedEvent { - const QueueRemoved(super.feed); + const QueueRemoved(super.feed, {super.own}); } /// Up next skipped an item (`target` its title, `reason` why). class QueueSkipped extends FeedEvent { - const QueueSkipped(super.feed); + const QueueSkipped(super.feed, {super.own}); } /// The server could not fetch the current media. class FetchFailed extends FeedEvent { - const FetchFailed(super.feed); + const FetchFailed(super.feed, {super.own}); } /// An action this app does not know (a newer server). class OtherFeed extends FeedEvent { - const OtherFeed(super.feed); + const OtherFeed(super.feed, {super.own}); } // -------------------------------------------------------------- session @@ -209,6 +217,38 @@ class ConnectionBack extends AppEvent { const ConnectionBack(); } +/// The server refused something this app sent (`err`: Up next is full, a +/// forbidden action...). +class ServerRefused extends AppEvent { + final String text; + const ServerRefused(this.text); +} + +/// The member passing the current video on has been gone a few seconds +/// ([left]: it left the server); viewers play what the server has. +class RelaySourceDropped extends AppEvent { + final String name; + final bool left; + const RelaySourceDropped(this.name, {this.left = false}); +} + +/// The current media started playing for the first time ("starting +/// now"; [title]: what plays, '' when unknown). +class PlaybackStarted extends AppEvent { + final String title; + const PlaybackStarted(this.title); +} + +/// A reconnect found the server restarted (another `welcome.instance`). +class ServerRestarted extends AppEvent { + const ServerRestarted(); +} + +/// Another window of this install took the member over (`replaced`). +class Replaced extends AppEvent { + const Replaced(); +} + /// Jellyfin progress reports fail (once per media). class JellyfinProgressFailed extends AppEvent { final bool signInExpired; diff --git a/apps/desktop/lib/src/session/delivery.dart b/apps/desktop/lib/src/session/delivery.dart index a849f46..34a81f1 100644 --- a/apps/desktop/lib/src/session/delivery.dart +++ b/apps/desktop/lib/src/session/delivery.dart @@ -6,6 +6,12 @@ import 'party.dart'; // Who fetches the current media and how it spreads, from the state alone: // the members card's source line and each member's route and buffer. +/// `media.relay` of a state, or null when the media is not relayed. +MediaRelay? stateMediaRelay(StateBroadcast state) { + final r = state.media?['relay']; + return r is Map ? MediaRelay.fromJson(Map.from(r)) : null; +} + /// "4.1 MB/s", "850 kB/s". String formatRate(int bytesPerSec) { if (bytesPerSec >= 1000000) { @@ -50,6 +56,9 @@ enum SourceState { slow, /// Its channel is down (or the server has not read the link yet). waiting, + /// The source member's connection dropped (`gone`, within its grace): + /// viewers play what the server has. + gone, /// It left the server. left, } @@ -114,6 +123,9 @@ class Delivery { final String sourceName; final bool sourceIsSelf; final SourceState state; + /// [SourceState.gone]: since when; and the time the line is read at. + final DateTime? goneSince; + final DateTime? at; /// `media.relay.rate` / `need` (0 unknown). final int rate, need; /// Members (not sitting out, not the source) reading from the server, @@ -125,6 +137,8 @@ class Delivery { this.sourceName = '', this.sourceIsSelf = false, this.state = SourceState.ok, + this.goneSince, + this.at, this.rate = 0, this.need = 0, this.viaServer = 0, @@ -155,7 +169,10 @@ class Delivery { }; case DeliveryKind.memberRelay: return switch (state) { - SourceState.left => '$sourceName left: playing what the server has', + SourceState.left => '$sourceName left — playing what the server has', + SourceState.gone => sourceIsSelf + ? 'Reconnecting you to the server…' + : "$sourceName's connection dropped — playing what the server has${_for()}", SourceState.waiting => sourceIsSelf ? 'Reconnecting you to the server…' : 'Waiting for $who to reconnect', SourceState.slow => sourceIsSelf ? 'Your upload is slower than the video' : "$who's upload is slower than the video", SourceState.ok => sourceIsSelf ? 'You pass it on through the server' : '$who passes it on through the server', @@ -163,6 +180,12 @@ class Delivery { } } + String _for() { + final since = goneSince, now = at; + if (since == null || now == null || now.difference(since).inMinutes < 1) return ''; + return ' (${sinceText(since, now)})'; + } + /// "3 through the server · 4.1 MB/s, needs 2.3 MB/s"; empty when /// there is nothing to add. String get detail { @@ -170,7 +193,7 @@ class Delivery { if (direct > 0 && (kind == DeliveryKind.jellyfin || kind == DeliveryKind.memberRelay)) '$direct with their own account', if (viaServer > 0 && kind != DeliveryKind.link) '$viaServer through the server', - if (rate > 0 && state != SourceState.left) need > 0 ? '${formatRate(rate)}, needs ${formatRate(need)}' : formatRate(rate), + if (rate > 0 && state != SourceState.left && state != SourceState.gone) need > 0 ? '${formatRate(rate)}, needs ${formatRate(need)}' : formatRate(rate), ]; return parts.join(' · '); } @@ -193,8 +216,9 @@ class Delivery { } /// The current media's [Delivery]; null with nothing up or nobody else in -/// the server. [names] remembers a source that left. -Delivery? deliveryOf(StateBroadcast r, {required String selfId, MemberNames? names}) { +/// the server. [names] remembers a source that left; [now] reads how long +/// a gone source has been gone. +Delivery? deliveryOf(StateBroadcast r, {required String selfId, MemberNames? names, DateTime? now}) { final media = r.media; if (media == null || r.members.length < 2) return null; final rel = media['relay']; @@ -237,6 +261,8 @@ Delivery? deliveryOf(StateBroadcast r, {required String selfId, MemberNames? nam ? SourceState.ok : kind == DeliveryKind.memberRelay && source == null ? SourceState.left + : kind == DeliveryKind.memberRelay && (relay.sourceGone || source?.presence == Presence.gone) + ? SourceState.gone : !relay.live ? SourceState.waiting : relay.slow @@ -248,6 +274,8 @@ Delivery? deliveryOf(StateBroadcast r, {required String selfId, MemberNames? nam sourceName: source?.name ?? names?[sourceId] ?? 'The member passing it on', sourceIsSelf: sourceId.isNotEmpty && sourceId == selfId, state: state, + goneSince: state == SourceState.gone ? relay?.sourceGoneSince ?? source?.goneSince : null, + at: now ?? DateTime.now().toUtc(), rate: relay?.rate ?? 0, need: relay?.need ?? 0, viaServer: via, diff --git a/apps/desktop/lib/src/session/health.dart b/apps/desktop/lib/src/session/health.dart deleted file mode 100644 index 91988c0..0000000 --- a/apps/desktop/lib/src/session/health.dart +++ /dev/null @@ -1,7 +0,0 @@ -enum HealthSeverity { ok, info, warn, error } - -class HealthStatus { - final HealthSeverity severity; - final String message; - const HealthStatus(this.severity, this.message); -} diff --git a/apps/desktop/lib/src/session/notice_prefs.dart b/apps/desktop/lib/src/session/notice_prefs.dart new file mode 100644 index 0000000..67b9f32 --- /dev/null +++ b/apps/desktop/lib/src/session/notice_prefs.dart @@ -0,0 +1,29 @@ +// The user's notification choices (Settings → Notifications & sounds), as +// the Notifier's policy reads them. Pure Dart; [AppSettings] stores them. + +/// When chat messages pop up as toasts (they always reach the chat). +enum ChatPopups { + all('All'), + paused('Only when paused'), + off('Off'); + + final String label; + const ChatPopups(this.label); + + /// Whether a chat message pops up now ([playing]: the video plays). + bool shows({required bool playing}) => switch (this) { + ChatPopups.all => true, + ChatPopups.paused => !playing, + ChatPopups.off => false, + }; +} + +/// What the user picked: chat pop-ups, and whether joins and leaves also +/// reach them while they sit out or the window is in the background +/// ([people]; off by default: then only chat, nudges, "starting now" and +/// Up next do). +class NotifyPrefs { + final ChatPopups chat; + final bool people; + const NotifyPrefs({this.chat = ChatPopups.all, this.people = false}); +} diff --git a/apps/desktop/lib/src/session/notifier.dart b/apps/desktop/lib/src/session/notifier.dart index 206bb09..71d9e30 100644 --- a/apps/desktop/lib/src/session/notifier.dart +++ b/apps/desktop/lib/src/session/notifier.dart @@ -1,12 +1,16 @@ import 'dart:async'; import 'package:signals/signals_core.dart'; -import 'package:sync_relay_core/core.dart' show FeedMessage; +import 'package:sync_relay_core/core.dart' show FeedMessage, Presence; +import '../platform/ports/system_notifications.dart'; +import '../platform/ports/window_activity.dart'; import '../sfx/sfx.dart'; import 'app_events.dart'; +import 'notice_prefs.dart'; export 'app_events.dart'; +export 'notice_prefs.dart'; class ToastItem { final int id; @@ -17,67 +21,243 @@ class ToastItem { final String? actor; /// [actor]'s member id (feed and chat toasts); '' when unknown. final String actorId; - const ToastItem({required this.id, required this.text, required this.at, this.actor, this.actorId = ''}); + /// A chat message: a click opens the chat. + final bool chat; + /// A picture hash to show (an Up next item's), '' when none. + final String thumb; + const ToastItem( + {required this.id, + required this.text, + required this.at, + this.actor, + this.actorId = '', + this.chat = false, + this.thumb = ''}); +} + +/// What a notice is about; the policy filters by it ([noticeFor]). +enum NoticeCategory { + /// Chat messages. + chat, + + /// Someone poked this member. + nudge, + + /// The media starts playing for the first time ("starting now"). + start, + + /// Up next and the video changing (adds, skips, swaps). + upNext, + + /// Joins, leaves, lost signal and returns. + people, + + /// The rest of what the party does (ready, seeks, pauses, someone sat + /// out, renames): the activity log and a few sounds. + party, + + /// Something keeps the watch from going smoothly (someone cannot play + /// it, the source dropped, the server could not fetch it). + problem, + + /// About this member itself (sat out, auto sit-out, the relay it + /// serves, its connection, another window took over). + self, + + /// The app's own notices (copied, shaders, update, errors): never + /// filtered. + app; + + /// What reaches you while you sit out or the window is in the + /// background (D1): chat, nudges, "starting now" and Up next, plus joins + /// and leaves when [NotifyPrefs.people] is on. + bool reachesAway(NotifyPrefs prefs) => switch (this) { + chat || nudge || start || upNext || self || app => true, + people => prefs.people, + party || problem => false, + }; + + /// Social: nothing of it plays or pops up while you watch alone (D9). + bool get social => this == chat || this == start || this == people || this == party; + + /// May go out as a system notification while the window is in the + /// background. + bool get system => this == chat || this == nudge || this == start || this == upNext || this == people; +} + +/// The session as the policy sees it. +typedef NoticeSession = ({Presence? self, bool alone, bool playing}); + +/// Where the user stands when a notice comes: their presence ([self], +/// null outside a session), whether anyone else is there ([alone]), the +/// window ([window]), whether the video plays ([playing]) and their +/// choices ([prefs]). A replayed feed line says so itself. +class NoticeContext { + final Presence? self; + final bool alone; + final WindowActivity window; + final bool playing; + final NotifyPrefs prefs; + const NoticeContext({ + this.self, + this.alone = false, + this.window = WindowActivity.focused, + this.playing = false, + this.prefs = const NotifyPrefs(), + }); + + bool get away => self == Presence.away; +} + +/// A notification outside the window ([SystemNotifications]). +class SystemNote { + final String title; + final String body; + final String tag; + /// [payloadChat]: a click opens the chat. + final String payload; + const SystemNote({required this.title, required this.body, this.tag = '', this.payload = ''}); + + static const payloadChat = 'chat'; } /// What one [AppEvent] does: a toast ([toast], about [actor]), a sound, -/// the window asking for attention, an activity-log line. +/// the window asking for attention, a system notification, an +/// activity-log line. class Notice { + final NoticeCategory category; final String? toast; final String? actor; + /// The toast is a chat message (a click opens the chat). + final bool chat; + /// A picture hash for the toast (an Up next item's). + final String thumb; final SfxCue? sound; final bool attention; + final SystemNote? system; /// The feed line it goes into the activity log as. final FeedMessage? log; - const Notice({this.toast, this.actor, this.sound, this.attention = false, this.log}); + const Notice({ + this.category = NoticeCategory.app, + this.toast, + this.actor, + this.chat = false, + this.thumb = '', + this.sound, + this.attention = false, + this.system, + this.log, + }); static const none = Notice(); + + Notice _with({bool toast = true, bool sound = true, SystemNote? system}) => Notice( + category: category, + toast: toast ? this.toast : null, + actor: toast ? actor : null, + chat: toast && chat, + thumb: toast ? thumb : '', + sound: sound ? this.sound : null, + attention: attention, + system: system, + log: log, + ); } -/// The policy: what each event does. -Notice noticeFor(AppEvent e) => switch (e) { - // Lines a reconnect replayed (`welcome.feed`): the log only. - FeedEvent(:final feed) when feed.replayed => Notice(log: feed), - // The feed: everything is logged; joins, leaves, swaps and chat - // pop up; the social actions have a sound. +String _title(String t) => t.isEmpty ? 'the video' : t; + +/// The event alone: its category, toast, sound and log line. +Notice _plain(AppEvent e) => switch (e) { + // The feed: everything is logged. MemberJoined(:final feed) => Notice( - log: feed, toast: '${feed.actor} joined', actor: feed.actor, sound: const SfxCue(SfxKind.join)), + category: NoticeCategory.people, + log: feed, + toast: '${feed.actor} joined', + actor: feed.actor, + sound: const SfxCue(SfxKind.join)), MemberLeft(:final feed) => Notice( - log: feed, toast: '${feed.actor} left', actor: feed.actor, sound: const SfxCue(SfxKind.leave)), + category: NoticeCategory.people, + log: feed, + toast: '${feed.actor} left', + actor: feed.actor, + sound: const SfxCue(SfxKind.leave)), + // After the server's delayed `gone` line (a quick reconnect says + // nothing), and back after it. + MemberGone(:final feed) => Notice( + category: NoticeCategory.people, log: feed, toast: '${feed.actor} lost connection', actor: feed.actor), + MemberReturned(:final feed) => + Notice(category: NoticeCategory.people, log: feed, toast: '${feed.actor} is back', actor: feed.actor), MediaSwapped(:final feed) => Notice( - log: feed, toast: '${feed.actor} changed media', actor: feed.actor, sound: const SfxCue(SfxKind.swap)), - ChatPosted(:final feed, :final own) => Notice( + category: NoticeCategory.upNext, + log: feed, + toast: '${feed.actor} changed media', + actor: feed.actor, + sound: const SfxCue(SfxKind.swap)), + QueueAdded(:final feed, :final thumb) => Notice( + category: NoticeCategory.upNext, + log: feed, + toast: '${feed.actor} added ${_title(feed.target ?? '')} to Up next', + actor: feed.actor, + thumb: thumb), + QueueSkipped(:final feed) => Notice( + category: NoticeCategory.upNext, + log: feed, + toast: 'Skipped ${feed.target ?? 'an item'}${feed.reason.isEmpty ? '' : ': ${feed.reason}'}'), + QueueRemoved(:final feed) => Notice(category: NoticeCategory.upNext, log: feed), + ChatPosted(:final feed) => Notice( + category: NoticeCategory.chat, log: feed, toast: '${feed.actor}: ${feed.reason}', actor: feed.actor, - // Your own message needs no sound. - sound: own ? null : const SfxCue(SfxKind.chat)), + chat: true, + sound: const SfxCue(SfxKind.chat)), MemberSatOut(:final feed, atYou: true) => Notice( - log: feed, toast: '${feed.actor} sat you out', actor: feed.actor, sound: const SfxCue(SfxKind.kick)), + category: NoticeCategory.self, + log: feed, + toast: '${feed.actor} sat you out', + actor: feed.actor, + sound: const SfxCue(SfxKind.kick)), MemberSatOut(:final feed, autoSelf: true) => Notice( + category: NoticeCategory.self, log: feed, toast: 'You sit out (${feed.reason == 'minimised' ? 'minimised' : 'idle'}); you are back as soon as you return', sound: const SfxCue(SfxKind.warn)), ReadyChanged(:final feed, ready: true, :final readyIndex) => - Notice(log: feed, sound: SfxCue(SfxKind.ready, readyIndex: readyIndex)), - ReadyChanged(:final feed, ready: false) => Notice(log: feed, sound: const SfxCue(SfxKind.unready)), - ForcedReady(:final feed, ready: true) => Notice(log: feed, sound: const SfxCue(SfxKind.forceReady)), - QueueSkipped(:final feed) => Notice( - log: feed, toast: 'Skipped ${feed.target ?? 'an item'}${feed.reason.isEmpty ? '' : ': ${feed.reason}'}'), - FeedEvent(:final feed) => Notice(log: feed), + Notice(category: NoticeCategory.party, log: feed, sound: SfxCue(SfxKind.ready, readyIndex: readyIndex)), + ReadyChanged(:final feed, ready: false) => + Notice(category: NoticeCategory.party, log: feed, sound: const SfxCue(SfxKind.unready)), + ForcedReady(:final feed, ready: true) => + Notice(category: NoticeCategory.party, log: feed, sound: const SfxCue(SfxKind.forceReady)), + FetchFailed(:final feed) => Notice(category: NoticeCategory.problem, log: feed), + FeedEvent(:final feed) => Notice(category: NoticeCategory.party, log: feed), // The session. Nudged(:final name) => Notice( + category: NoticeCategory.nudge, toast: '${name.isEmpty ? 'Someone' : name} nudged you', actor: name.isEmpty ? null : name, sound: const SfxCue(SfxKind.nudge), attention: true), - MemberCantPlay(:final name, :final error) => Notice(toast: "$name can't play this: $error", actor: name), - SlowSource(:final text) => Notice(toast: text), - RelayStarted() => - const Notice(toast: 'Passing the video on through the server. Keep the app open while it plays.'), - RelayFailed(:final error) => Notice(toast: 'Cannot pass the video on: $error'), - ConnectionLost() => const Notice(sound: SfxCue(SfxKind.lost)), - ConnectionBack() => const Notice(sound: SfxCue(SfxKind.back)), + PlaybackStarted(:final title) => + Notice(category: NoticeCategory.start, toast: title.isEmpty ? 'Starting now' : 'Starting now: $title'), + MemberCantPlay(:final name, :final error) => + Notice(category: NoticeCategory.problem, toast: "$name can't play this: $error", actor: name), + RelaySourceDropped(:final name, :final left) => Notice( + category: NoticeCategory.problem, + toast: left + ? '$name left: playing what the server has' + : "$name's connection dropped: playing what the server has", + actor: name), + SlowSource(:final text) => Notice(category: NoticeCategory.self, toast: text), + RelayStarted() => const Notice( + category: NoticeCategory.self, + toast: 'Passing the video on through the server. Keep the app open while it plays.'), + RelayFailed(:final error) => Notice(category: NoticeCategory.self, toast: 'Cannot pass the video on: $error'), + ConnectionLost() => const Notice(category: NoticeCategory.self, sound: SfxCue(SfxKind.lost)), + ConnectionBack() => const Notice(category: NoticeCategory.self, sound: SfxCue(SfxKind.back)), + ServerRestarted() => + const Notice(category: NoticeCategory.self, toast: 'The server restarted; you are back in'), + Replaced() => const Notice(category: NoticeCategory.self, toast: 'Starsync was opened in another window'), + ServerRefused(:final text) => Notice(category: NoticeCategory.self, toast: text), JellyfinProgressFailed(:final signInExpired) => Notice(toast: 'Jellyfin progress not saved: ${signInExpired ? 'sign-in expired' : 'server unreachable'}'), // The app. @@ -89,29 +269,115 @@ Notice noticeFor(AppEvent e) => switch (e) { Notice(toast: inSession ? 'Marked in the session log' : "Marked in this app's log"), Copied(:final text) => Notice(toast: text), CopyFailed() => const Notice(toast: "Couldn't copy"), - // The update band shows it. + // The update band shows it (D8: a clear prompt, never over playback). UpdateAvailable() => Notice.none, }; +/// The policy: what [e] does for a user in [c]. +/// - Lines a reconnect replayed (`welcome.feed`): the activity log only. +/// - Your own joins, leaves, swaps and Up next adds: logged, no pop-up or +/// sound (the app already said so); your own chat pops up, silently. +/// - Alone (D9): no social sounds or pop-ups (nobody is there); the app's +/// notices, problems and your own still show. +/// - Sitting out (D1): only chat, nudges, "starting now", Up next, your +/// own and the app's, plus joins and leaves when chosen +/// ([NotifyPrefs.people]); the rest is logged only. +/// - "Starting now" pops up only while you sit out or the window is in +/// the background (otherwise you see it start). +/// - Chat pop-ups follow [NotifyPrefs.chat]; the chat sound has its own +/// switch (Settings → Sounds). +/// - The window in the background: what reaches you there also goes out +/// as a system notification (chat, nudges, "starting now", Up next; +/// joins and leaves when chosen). +Notice noticeFor(AppEvent e, [NoticeContext c = const NoticeContext()]) { + if (e is FeedEvent && e.feed.replayed) return Notice(category: _plain(e).category, log: e.feed); + final n = _plain(e); + final cat = n.category; + final own = e is FeedEvent && e.own; + var toast = n.toast != null; + var sound = n.sound != null; + if (own && (cat == NoticeCategory.people || cat == NoticeCategory.upNext)) { + toast = false; + sound = false; + } + if (own && cat == NoticeCategory.chat) sound = false; + if (c.alone && cat.social) { + toast = false; + sound = false; + } + if (c.away && !cat.reachesAway(c.prefs)) { + toast = false; + sound = false; + } + if (cat == NoticeCategory.start && !c.away && !c.window.background) toast = false; + if (cat == NoticeCategory.chat && !c.prefs.chat.shows(playing: c.playing)) toast = false; + SystemNote? system; + if (toast && !own && c.window.background && cat.system && cat.reachesAway(c.prefs)) { + final actor = n.actor; + final text = n.toast!; + final body = actor != null && text.startsWith(actor) ? text.substring(actor.length).replaceFirst(RegExp(r'^:? '), '') : text; + system = SystemNote( + title: actor ?? 'Starsync', + body: actor == null ? text : body, + tag: cat == NoticeCategory.chat ? '' : cat.name, + payload: cat == NoticeCategory.chat ? SystemNote.payloadChat : '', + ); + } + return n._with(toast: toast, sound: sound, system: system); +} + /// The one way news reaches the user: [notify] turns an [AppEvent] into -/// toasts ([toasts]), sounds, an attention request and the activity log -/// ([feedLog]) per [noticeFor]. +/// toasts ([toasts], each dropped after [toastLife] by the Notifier's +/// own timer), sounds, an attention request, a system notification and +/// the activity log ([feedLog]) per [noticeFor], in the context the +/// session ([session]), the window ([window]) and the user's choices +/// ([prefs]) give. class Notifier { final SfxService sfx; final Future Function() attention; - Notifier({this.sfx = const NoopSfxService(), Future Function()? attention}) - : attention = attention ?? _noAttention; + final SystemNotifications system; + /// Brings the window up (a click on a system notification). + final Future Function() focusWindow; + final Duration toastLife; + /// The session's part of the context; set by the coordinator. + NoticeSession Function() session = () => (self: null, alone: false, playing: false); + NotifyPrefs Function() prefs; + /// The window's activity ([ShellPort.watchActivity], wired in `main`). + WindowActivity window = WindowActivity.focused; - static Future _noAttention() async {} + Notifier({ + this.sfx = const NoopSfxService(), + Future Function()? attention, + this.system = const NoSystemNotifications(), + Future Function()? focusWindow, + this.toastLife = const Duration(seconds: 5), + NotifyPrefs Function()? prefs, + }) : attention = attention ?? _nothing, + focusWindow = focusWindow ?? _nothing, + prefs = prefs ?? _defaultPrefs { + system.onClick = _clicked; + } - /// Toasts on screen (the shell shows them; [expire] drops old ones). + static Future _nothing() async {} + static NotifyPrefs _defaultPrefs() => const NotifyPrefs(); + + /// Toasts on screen (the shell shows them). final toasts = signal>([]); /// The activity log: the last 100 feed lines. final feedLog = signal>([]); + /// Bumped when a click on a chat notification asks for the chat (the + /// shell opens it). + final chatRequests = signal(0); int _toastId = 0; + final _timers = {}; + + NoticeContext get context { + final s = session(); + return NoticeContext(self: s.self, alone: s.alone, window: window, playing: s.playing, prefs: prefs()); + } void notify(AppEvent e) { - final n = noticeFor(e); + final n = noticeFor(e, context); final log = n.log; if (log != null) { final all = [...feedLog.peek(), log]; @@ -119,29 +385,47 @@ class Notifier { } final text = n.toast; if (text != null) { - _toastId++; + final id = ++_toastId; toasts.value = [ ...toasts.peek(), ToastItem( - id: _toastId, + id: id, text: text, at: DateTime.now().toUtc(), actor: n.actor, - actorId: n.actor == null ? '' : n.log?.actorId ?? ''), + actorId: n.actor == null ? '' : n.log?.actorId ?? '', + chat: n.chat, + thumb: n.thumb), ]; + _timers[id] = Timer(toastLife, () => dismiss(id)); } final sound = n.sound; if (sound != null) unawaited(sfx.play(sound)); if (n.attention) unawaited(attention()); + final note = n.system; + if (note != null) { + unawaited(system.show(title: note.title, body: note.body, tag: note.tag, payload: note.payload)); + } } - /// Drops toasts 4 s old or older. - void expire(DateTime now) { - toasts.value = toasts.peek().where((t) => now.difference(t.at).inSeconds < 4).toList(); + void _clicked(String payload) { + unawaited(focusWindow()); + if (payload == SystemNote.payloadChat) chatRequests.value++; + } + + /// Takes toast [id] off the screen (its time is up, or it was clicked). + void dismiss(int id) { + _timers.remove(id)?.cancel(); + final now = toasts.peek(); + if (now.any((t) => t.id == id)) toasts.value = [for (final t in now) if (t.id != id) t]; } /// A new session: no toasts, an empty log. void clear() { + for (final t in _timers.values) { + t.cancel(); + } + _timers.clear(); feedLog.value = []; toasts.value = []; } diff --git a/apps/desktop/lib/src/session/session_coordinator.dart b/apps/desktop/lib/src/session/session_coordinator.dart index 72e3c81..deb468b 100644 --- a/apps/desktop/lib/src/session/session_coordinator.dart +++ b/apps/desktop/lib/src/session/session_coordinator.dart @@ -28,16 +28,18 @@ import '../platform/ports/shell_port.dart' show WindowActivity; import 'auto_sit_out.dart'; import 'connection.dart'; import 'delivery.dart'; -import 'health.dart'; import 'media_art.dart'; import 'notifier.dart'; import 'now_playing.dart'; import 'now_playing_info.dart'; import 'party.dart'; +import 'status.dart'; import 'switch_trace.dart'; export 'connection.dart' show ConnectCancelled, ConnectParams; export 'notifier.dart' show ToastItem; +export 'delivery.dart' show stateMediaRelay; +export 'status.dart'; final _log = createLogger('session'); @@ -71,12 +73,6 @@ bool unreadyForNewMedia( required bool ready}) => enabled && lastMediaId >= 0 && mediaId != lastMediaId && ready; -/// `media.relay` of a state, or null when the media is not relayed. -MediaRelay? stateMediaRelay(StateBroadcast state) { - final r = state.media?['relay']; - return r is Map ? MediaRelay.fromJson(Map.from(r)) : null; -} - /// What this member serves when its own swap is relayed: set at swap time, /// claimed by the state that names this member as the source. sealed class _RelayOrigin { @@ -103,19 +99,6 @@ final class _JellyfinOrigin extends _RelayOrigin { const _JellyfinOrigin(this.link); } -/// The health line a connection change writes; null writes none (closed: -/// whoever closed says why). -HealthStatus? connectionHealth(ConnState s) => switch (s) { - ConnConnecting() => const HealthStatus(HealthSeverity.info, 'connecting…'), - ConnOnline() => const HealthStatus(HealthSeverity.ok, 'connected'), - ConnUnstable() => const HealthStatus(HealthSeverity.warn, 'connection unstable…'), - ConnLost() => const HealthStatus(HealthSeverity.warn, 'connection lost — retrying…'), - ConnReconnecting() => const HealthStatus(HealthSeverity.info, 'reconnecting…'), - ConnRefused(:final message) => HealthStatus(HealthSeverity.error, message), - ConnReplaced(:final message) => HealthStatus(HealthSeverity.error, message), - ConnClosed() => null, - }; - String? stateMediaUrl(StateBroadcast state) { final media = state.media; if (media == null) return null; @@ -171,8 +154,6 @@ class SessionCoordinator { final SwitchTrace switchTrace = SwitchTrace(); final StateDiff diff = StateDiff(); - final health = - signal(const HealthStatus(HealthSeverity.info, 'idle')); /// The server's latest state. A drop keeps it (with the player, the /// media and a relay this member serves): [stateStale] says it is not /// current; only leaving clears it. @@ -191,7 +172,38 @@ class SessionCoordinator { late final Computed reconnecting = computed(() => connection.state.value.retrying); /// The link is up but went quiet ([ConnUnstable]). late final Computed unstable = computed(() => connection.state.value is ConnUnstable); - final starving = signal(null); + /// The player itself failed (a "Reload video" that failed); '' when + /// fine. A fact for [status]. + final playerFault = signal(''); + /// The mediaId the server said it could not fetch (`fetch-fail`); -1. + final fetchFailedFor = signal(-1); + /// Reconnects that found the server restarted (another + /// `welcome.instance`). + final restarts = signal(0); + // The status's clock, moved on by the tick when the minute changes (a + // "(2 min)" in it). + final _clock = signal(DateTime.now().toUtc()); + + /// The one computed status (session/status.dart): the player's pill, + /// the strip's line, the members card's source line. Nothing sets it; + /// [StatusWatch] turns its transitions into notices. + late final Computed status = computed(() { + final plan = jellyfinPlan.value; + return statusOf(StatusFacts( + conn: connection.state.value, + state: serverState.value, + selfId: connection.memberId, + playerError: playerError.value, + playerFault: playerFault.value, + ownPlan: plan is OwnPlay, + fetchFailedFor: fetchFailedFor.value, + names: memberNames, + restarts: restarts.value, + now: _clock.value, + )); + }); + final StatusWatch _statusWatch = StatusWatch(); + void Function()? _statusEffect; /// Why this player cannot open the current media, in plain words /// ([playErrorText]); empty while it can. It rides in `report.error`, so /// the server stops waiting for this member and the others see it. @@ -255,7 +267,6 @@ class SessionCoordinator { StreamSubscription? _feedSub; StreamSubscription? _nudgeSub; StreamSubscription? _errSub; - void Function()? _connectionHealth; /// A solo session (set by SessionState): no auto sit-out, there is /// nobody to hold up. @@ -372,6 +383,27 @@ class SessionCoordinator { connection ..onWelcome = _onWelcome ..caps = () => caps; + // The Notifier's view of the session (Status → Notifier, never back). + notifier.session = () { + final r = serverState.peek(); + return ( + self: selfMember.peek()?.presence, + alone: r != null && aloneIn(r.members, connection.memberId), + playing: r?.transport == 'playing', + ); + }; + _statusEffect = effect(() => _stepStatus(status.value)); + } + + /// Status transitions worth a notice (see [StatusWatch]). + void _stepStatus(Status s) { + final events = _statusWatch.step(s, DateTime.now().toUtc()); + if (events.isEmpty) return; + untracked(() { + for (final e in events) { + notify(e); + } + }); } // yt-dlp is there to resolve links (checked at each connect). @@ -406,8 +438,6 @@ class SessionCoordinator { _playerReady = true; } catch (e, st) { debugPrint('player init failed: $e\n$st'); - health.value = - HealthStatus(HealthSeverity.error, 'player failed to start: $e'); rethrow; } } @@ -465,6 +495,7 @@ class SessionCoordinator { if (connection.params == null || _reloadingVideo) return; _reloadingVideo = true; _playerReady = false; + playerFault.value = ''; try { await playback.recreate(); final url = _openUrl; @@ -474,8 +505,7 @@ class SessionCoordinator { if (url != null) await _openMedia(url, headers: headers); } catch (e, st) { debugPrint('video reload failed: $e\n$st'); - health.value = - HealthStatus(HealthSeverity.error, 'video reload failed: $e'); + playerFault.value = 'video reload failed: $e'; } finally { _playerReady = playback.alive.peek(); _reloadingVideo = false; @@ -504,6 +534,10 @@ class SessionCoordinator { settledMediaId.value = -1; _openUrl = null; playerError.value = ''; + playerFault.value = ''; + fetchFailedFor.value = -1; + restarts.value = 0; + _statusWatch.reset(); _memberErrors = const {}; _lastReady = false; _restoringReady = false; @@ -535,12 +569,7 @@ class SessionCoordinator { _startTicker(); try { final url = welcome.mediaUrl; - if (url != null && url.isNotEmpty) { - await _openMedia(url); - } else { - health.value = const HealthStatus( - HealthSeverity.info, 'connected — waiting for host media…'); - } + if (url != null && url.isNotEmpty) await _openMedia(url); final media = params.media ?? (params.linkUrl.isEmpty ? null : await mediaForLink(jellyfin, params.linkUrl)); if (!connection.isCurrent(epoch)) throw const ConnectCancelled(); @@ -549,8 +578,6 @@ class SessionCoordinator { if (!connection.isCurrent(epoch)) throw const ConnectCancelled(); debugPrint('connect failed: $e'); connection.scheduleReconnect(); - health.value = - HealthStatus(HealthSeverity.error, connectErrorText(e)); rethrow; } } @@ -560,13 +587,8 @@ class SessionCoordinator { _stateSub ??= connection.states.listen(_onState); _feedSub ??= connection.feeds.listen(_onFeed); _nudgeSub ??= connection.nudges.listen(_onNudge); - _errSub ??= connection.errors.listen((msg) { - health.value = HealthStatus(HealthSeverity.warn, msg); - }); - _connectionHealth ??= connection.state.subscribe((s) { - final h = connectionHealth(s); - if (h != null) health.value = h; - }); + // The server refused something this app sent: a toast. + _errSub ??= connection.errors.listen((msg) => notify(ServerRefused(_sentenceCase(msg)))); } /// Opens [url] (with [headers] on every request); false on failure. A @@ -593,8 +615,6 @@ class SessionCoordinator { keepPlayer: keepPlayer, onFreshPlayer: () => switchTrace.mark(mediaId, SwitchTrace.player)); switchTrace.mark(mediaId, promoted ? SwitchTrace.preloaded : SwitchTrace.opened); - health.value = - const HealthStatus(HealthSeverity.ok, 'connected'); ok = true; } catch (e) { debugPrint('open media failed: $e'); @@ -688,8 +708,6 @@ class SessionCoordinator { return playback.preload(url, headers: headers, ytdlPath: path, fresh: fresh); } - bool _relayWaitShown = false; - /// The server-fetched media this viewer opens once it is live. int _awaitLiveFor = -1; @@ -700,33 +718,6 @@ class SessionCoordinator { bool _waitForSource(StateBroadcast r, MediaRelay relay) => !relay.live && !relay.sourceGone && (relay.fetch || r.members.any((m) => m.id == relay.source)); - /// Relayed through a member whose channel is down: say who everyone is - /// waiting for (the buffer still plays what it has). - void _relayStatus(StateBroadcast r) { - final relay = stateMediaRelay(r); - final waiting = relay != null && - relay.source.isNotEmpty && - relay.source != connection.memberId && - !relay.live && - _openUrl != null && - jellyfinPlan.peek() is! OwnPlay; - if (waiting) { - final who = r.members.where((m) => m.id == relay.source).firstOrNull?.name; - final gone = memberNames[relay.source] ?? 'the member who put this up'; - health.value = HealthStatus( - HealthSeverity.warn, - who == null - ? '$gone left: playing what the server has' - : relay.sourceGone - ? '$who lost signal: playing what the server has' - : 'waiting for $who to pass the video on'); - _relayWaitShown = true; - } else if (_relayWaitShown) { - _relayWaitShown = false; - health.value = const HealthStatus(HealthSeverity.ok, 'connected'); - } - } - /// This viewer's `/r/` URL; with tracks, a pieces track's playlist, or /// an mpv EDL of every track when there are several (a page's separate /// video and audio). A relay started in Up next ([queueId]) keeps its @@ -948,8 +939,6 @@ class SessionCoordinator { _awaitLiveFor = r.mediaId; final who = r.members.where((m) => m.id == relay.source).firstOrNull?.name; _log.info('media ${r.mediaId}: waiting for ${relay.fetch ? 'the server' : who} before opening the relay'); - health.value = HealthStatus(HealthSeverity.info, - relay.fetch ? 'the server is getting the video…' : 'waiting for $who to pass the video on…'); } else { await _openMedia(_relayMediaUrlFor(name, r.mediaId, relay.tracks, relay.queueId)); } @@ -976,10 +965,8 @@ class SessionCoordinator { _relayMediaUrlFor((r.media?['name'] as String?) ?? '', r.mediaId, relay.tracks, relay.queueId)); } _jellyfinTransport(r); - _relayStatus(r); if (!_playerReady || _openUrl == null) { _wasPlayAllowed = false; - _updateStarving(r, 0); return; } // Server clock: serverTime in the state is the server's. @@ -1014,7 +1001,6 @@ class SessionCoordinator { _seekRetries = 0; } await playback.apply(actions); - _updateStarving(r, playback.bufferedSecsAhead); } /// Mirrors the server's ready flag for self into [_lastReady] (force @@ -1051,7 +1037,6 @@ class SessionCoordinator { Future _unloadForOut() async { _wasPlayAllowed = false; _awaitLiveFor = -1; - starving.value = null; if (_mediaId == -1 && _openUrl == null) return; _mediaId = -1; _playerMediaId = -1; @@ -1085,61 +1070,30 @@ class SessionCoordinator { } catch (_) {} } - void _updateStarving(StateBroadcast r, double bufferedAhead) { - if (r.transport == 'playing') { - starving.value = null; - return; - } - if (r.transport == 'finished') { - starving.value = 'finished — seek back or change media'; - return; - } - for (final m in r.members) { - if (m.inSet && m.buffered < r.bufferNeed) { - starving.value = - 'waiting for ${m.name} — ${m.buffered.toStringAsFixed(1)} s buffered'; - return; - } - } - final self = selfMember.peek(); - if (self != null && self.ready) { - // Who the ready counter counts (not away, not gone, can play it). - final present = r.members.where(countsForReady).toList(); - final waiters = present - .where((m) => m.id != self.id && !(m.inSet && m.ready)) - .map((m) => m.name) - .toList(); - if (waiters.isNotEmpty) { - final readyCount = present.where((m) => m.ready).length; - starving.value = - 'waiting for ${waiters.join(', ')} ($readyCount/${present.length} ready)'; - return; - } - } - if (self != null && !self.ready) { - starving.value = 'press Space when ready'; - return; - } - if (r.members.any((m) => m.inSet)) { - starving.value = 'held — waiting for readiness'; - return; - } - starving.value = 'held — press Space when ready'; - } - void _onFeed(FeedMessage f) { + final r = serverState.peek(); final event = FeedEvent.of(f, selfId: connection.memberId, - readyCount: f.action == 'ready' - ? serverState.peek()?.members.where((m) => m.ready && countsForReady(m)).length ?? 0 - : 0); + readyCount: f.action == 'ready' ? r?.members.where((m) => m.ready && countsForReady(m)).length ?? 0 : 0, + // The state goes out before its feed: the item is in it. + queueThumb: f.action == 'queue-add' ? _addedThumb(r, f) : ''); + // The server gave up fetching what everyone waits for (a fact for + // [status]). + if (event is FetchFailed && r != null && !f.replayed) fetchFailedFor.value = r.mediaId; notify(event); - // The server gave up fetching what everyone waits for. - if (event is FetchFailed && _awaitLiveFor != -1 && _awaitLiveFor == serverState.peek()?.mediaId) { - health.value = const HealthStatus(HealthSeverity.error, "the server couldn't fetch this video"); - } } + /// The picture of the Up next item [f] (`queue-add`) added: the newest + /// item of that title by that member. + static String _addedThumb(StateBroadcast? r, FeedMessage f) { + for (final q in (r?.queue ?? const []).reversed) { + if (q.title == f.target && (f.actorId.isEmpty || q.by == f.actorId)) return q.thumb; + } + return ''; + } + + static String _sentenceCase(String s) => s.isEmpty ? s : s[0].toUpperCase() + s.substring(1); + /// Someone poked this member: sound (even when its switch is off; the /// volume still applies), a toast, and the window asks for attention. void _onNudge(NudgeNotice n) => notify(Nudged(n.name)); @@ -1176,7 +1130,10 @@ class SessionCoordinator { Future _tick() async { final now = DateTime.now().toUtc(); - notifier.expire(now); + // The status's clock moves by the minute; its transitions (a source + // gone long enough) are checked every second. + if (now.difference(_clock.peek()).inMinutes >= 1 || now.minute != _clock.peek().minute) _clock.value = now; + _stepStatus(status.peek()); final r = serverState.peek(); final online = connection.state.peek().online; if (online && connection.token.isNotEmpty && r != null && _playerReady) { @@ -2038,6 +1995,7 @@ class SessionCoordinator { if (!rejoin.reconnect) return; _log.info('reconnected: ${rejoin.resumed ? 'resumed' : 'a new member'}' '${rejoin.sameServer ? '' : ' (the server restarted)'}'); + if (!rejoin.sameServer) restarts.value++; if (rejoin.resumed) return; final before = serverState.peek(); final raw = welcome.state; @@ -2107,16 +2065,9 @@ class SessionCoordinator { /// "Stop reconnecting". void cancelReconnect() => connection.cancelReconnect(); - Future cancelConnect() async { - await connection.cancelConnect(); - health.value = const HealthStatus(HealthSeverity.info, 'cancelled'); - } + Future cancelConnect() => connection.cancelConnect(); - void retry() { - if (!connection.retry()) return; - health.value = - const HealthStatus(HealthSeverity.info, 'retrying…'); - } + void retry() => connection.retry(); /// App close, before [leave]: the players go first (no mpv may outlive /// the engine). The Jellyfin stop report [leave] sends keeps the @@ -2158,11 +2109,7 @@ class SessionCoordinator { } catch (_) {} // The last lines go before the token does, then the bye. await connection.close(bye: bye); - if (!silent) { - serverState.value = null; - starving.value = null; - health.value = const HealthStatus(HealthSeverity.info, 'idle'); - } + if (!silent) serverState.value = null; } Future dispose() async { @@ -2175,7 +2122,7 @@ class SessionCoordinator { await _feedSub?.cancel(); await _nudgeSub?.cancel(); await _errSub?.cancel(); - _connectionHealth?.call(); + _statusEffect?.call(); _bufferSub?.call(); _aniList?.close(); await connection.dispose(); diff --git a/apps/desktop/lib/src/session/status.dart b/apps/desktop/lib/src/session/status.dart new file mode 100644 index 0000000..3411f7a --- /dev/null +++ b/apps/desktop/lib/src/session/status.dart @@ -0,0 +1,330 @@ +import 'package:sync_relay_core/core.dart'; + +import 'app_events.dart'; +import 'connection.dart'; +import 'delivery.dart'; +import 'now_playing_info.dart'; +import 'party.dart'; + +// The session's one computed status: what the player's status pill, the +// party strip's server line and the members card's source line say, from +// facts only (the connection, the server's state with its hold reason and +// relay, this player's open/error facts). Nothing writes a status; the +// coordinator computes it ([statusOf]) and [StatusWatch] turns the +// transitions worth a notice into [AppEvent]s for the Notifier (Status → +// Notifier, never back). + +/// How bad it is: [ok] and [info] never colour anything, [warn] amber, +/// [error] red. +enum StatusLevel { ok, info, warn, error } + +/// What the player's status pill says, most pressing first. +enum StatusKind { reconnecting, problem, connecting, fetching, finished, loading, you, cannotPlay, waiting } + +/// The pill's button: stop reconnecting, retry (reconnect), reload the +/// video (your player could not open it), or mark yourself ready (the +/// whole pill). +enum StatusAction { cancel, retry, reload, ready } + +/// One status line: [text], a muted [detail], its [level], the members it +/// is about ([who]; [self] for you) and a loading [progress] (0..1). +class StatusLine { + final StatusKind kind; + final String text; + final String? detail; + final StatusLevel level; + final List who; + final bool self; + final double? progress; + final StatusAction? action; + /// The mini player's shorter line. + final String short; + const StatusLine( + this.kind, + this.text, { + this.detail, + this.level = StatusLevel.info, + this.who = const [], + this.self = false, + this.progress, + this.action, + String? short, + }) : short = short ?? text; + + /// Coloured: a problem. + bool get problem => level == StatusLevel.warn || level == StatusLevel.error; +} + +/// The facts a status is computed from. +class StatusFacts { + final ConnState conn; + /// The server's latest state (kept through a drop). + final StateBroadcast? state; + /// This member's id ('' before the welcome). + final String selfId; + /// Why this player cannot open the current media ([playErrorText]). + final String playerError; + /// The player itself failed (a reload that failed); '' when fine. + final String playerFault; + /// This viewer plays the current Jellyfin item with its own account: + /// the relay's troubles are not its own. + final bool ownPlan; + /// The mediaId the server said it could not fetch (`fetch-fail`), -1. + final int fetchFailedFor; + /// Names of members that left (a relay source that left). + final MemberNames? names; + /// A reconnect found the server restarted this many times. + final int restarts; + final DateTime now; + const StatusFacts({ + this.conn = const ConnClosed(), + this.state, + this.selfId = '', + this.playerError = '', + this.playerFault = '', + this.ownPlan = false, + this.fetchFailedFor = -1, + this.names, + this.restarts = 0, + required this.now, + }); +} + +/// The relay source's trouble, as the transitions watch it. +class SourceTrouble { + final int mediaId; + final String sourceId; + final String name; + /// Gone since (the source member is `gone`); null when it left. + final DateTime? since; + bool get left => since == null; + const SourceTrouble({required this.mediaId, required this.sourceId, required this.name, this.since}); +} + +class Status { + /// The player's pill; null when there is nothing to say (playing, + /// connected, everyone ready). + final StatusLine? line; + /// The party strip's line under the server name: "connecting…", + /// "reconnecting…", "ready 2/3". + final String party; + /// Where the current media comes from (the members card's source line); + /// null with nothing up or nobody else there. + final Delivery? delivery; + /// The relay source this viewer depends on is gone or left. + final SourceTrouble? source; + final ConnState conn; + final StateBroadcast? state; + final int restarts; + const Status({ + this.line, + this.party = '', + this.delivery, + this.source, + this.conn = const ConnClosed(), + this.state, + this.restarts = 0, + }); +} + +/// "Bob", "Bob and Mika", "Bob, Mika and Cleo", "Bob, Mika and 3 others". +String nameList(List names) { + if (names.length <= 1) return names.join(); + if (names.length <= 3) return '${names.sublist(0, names.length - 1).join(', ')} and ${names.last}'; + return '${names[0]}, ${names[1]} and ${names.length - 2} others'; +} + +String _sentence(String s) => s.isEmpty ? s : s[0].toUpperCase() + s.substring(1); + +/// "(2 min)" from a minute on, nothing before. +String _forHow(DateTime since, DateTime now) => + now.difference(since).inMinutes >= 1 ? ' (${sinceText(since, now)})' : ''; + +/// "Bob's connection dropped — playing what the server has (2 min)", +/// "Bob left — playing what the server has". +String sourceTroubleText(SourceTrouble t, DateTime now) => t.left + ? '${t.name} left — playing what the server has' + : "${t.name}'s connection dropped — playing what the server has${_forHow(t.since!, now)}"; + +/// The relayed media's source this viewer reads from is gone or left. +SourceTrouble? sourceTroubleOf(StateBroadcast r, String selfId, MemberNames? names) { + final relay = stateMediaRelay(r); + if (relay == null || relay.fetch || relay.source.isEmpty || relay.source == selfId) return null; + final m = r.members.where((m) => m.id == relay.source).firstOrNull; + final name = m?.name ?? names?[relay.source] ?? 'The member passing it on'; + if (m == null) return SourceTrouble(mediaId: r.mediaId, sourceId: relay.source, name: name); + if (!relay.sourceGone && m.presence != Presence.gone) return null; + return SourceTrouble( + mediaId: r.mediaId, + sourceId: relay.source, + name: name, + since: relay.sourceGoneSince ?? m.goneSince ?? r.serverTime); +} + +/// The status from [f]. +Status statusOf(StatusFacts f) { + final r = f.state; + final stale = r != null && (f.conn.retrying || f.conn is ConnReplaced || f.conn is ConnRefused); + final counted = (r?.members ?? const []).where(countsForReady).toList(); + final party = r == null + ? 'connecting…' + : stale + ? 'reconnecting…' + : 'ready ${counted.where((m) => m.ready).length}/${counted.length}'; + final self = r == null || f.selfId.isEmpty ? null : r.members.where((m) => m.id == f.selfId).firstOrNull; + final source = r == null || f.ownPlan ? null : sourceTroubleOf(r, f.selfId, f.names); + return Status( + line: _line(f, r, self, source), + party: party, + delivery: r == null ? null : deliveryOf(r, selfId: f.selfId, names: f.names, now: f.now), + source: source, + conn: f.conn, + state: r, + restarts: f.restarts, + ); +} + +StatusLine? _line(StatusFacts f, StateBroadcast? r, MemberView? self, SourceTrouble? source) { + switch (f.conn) { + case ConnLost() || ConnReconnecting(): + return const StatusLine(StatusKind.reconnecting, 'Reconnecting…', + level: StatusLevel.warn, action: StatusAction.cancel); + case ConnUnstable(): + return const StatusLine(StatusKind.reconnecting, 'Connection unstable…', level: StatusLevel.warn); + case ConnReplaced(:final message) || ConnRefused(:final message): + return StatusLine(StatusKind.problem, _sentence(message), level: StatusLevel.error, action: StatusAction.retry); + case ConnConnecting(): + return const StatusLine(StatusKind.connecting, 'Connecting…'); + case ConnOnline() || ConnClosed(): + break; + } + final away = self?.presence == Presence.away; + if (r == null || r.media == null) return null; + // Sitting out: the join-back card says it; nobody waits for you. + if (away) return null; + if (f.playerFault.isNotEmpty) { + return StatusLine(StatusKind.problem, _sentence(f.playerFault), + level: StatusLevel.error, action: StatusAction.reload, short: "Can't play this"); + } + if (f.playerError.isNotEmpty) { + return StatusLine(StatusKind.problem, "Can't play this: ${f.playerError}", + level: StatusLevel.error, action: StatusAction.reload, short: "Can't play this"); + } + // The relay: what everyone reads comes through the server. + final relay = f.ownPlan ? null : stateMediaRelay(r); + if (relay != null && relay.source != f.selfId) { + if (relay.fetch && !relay.live) { + if (f.fetchFailedFor == r.mediaId) { + return const StatusLine(StatusKind.problem, "The server couldn't fetch this video", level: StatusLevel.error); + } + return const StatusLine(StatusKind.fetching, 'The server is getting the video…'); + } + if (source != null) { + return StatusLine(StatusKind.problem, sourceTroubleText(source, f.now), + level: StatusLevel.warn, + short: source.left ? '${source.name} left' : "${source.name}'s connection dropped"); + } + if (!relay.fetch && !relay.live) { + final who = r.members.where((m) => m.id == relay.source).firstOrNull?.name ?? 'the member who put it up'; + return StatusLine(StatusKind.problem, 'Waiting for $who to pass the video on', level: StatusLevel.warn); + } + } + if (r.transport == 'playing') return null; + if (r.transport == 'finished') { + return const StatusLine(StatusKind.finished, 'Finished · seek back or pick the next video', short: 'Finished'); + } + // Members whose player cannot open it: the server does not wait for + // them; say why instead of "waiting for". + final failed = [ + for (final m in r.members) + if (m.error.isNotEmpty && isPresent(m) && (self == null || m.id != self.id)) m, + ]; + // As the server holds playback: someone in the ready set short of the + // minimum buffer. + final need = r.bufferNeed; + for (final m in r.members) { + if (m.inSet && m.buffered < need) { + final you = self != null && m.id == self.id; + final p = (m.buffered / need).clamp(0.0, 1.0); + return StatusLine(StatusKind.loading, you ? 'Loading the video…' : '${m.name} is loading…', + who: [m], self: you, progress: p, detail: '${(p * 100).round()}%'); + } + } + if (failed.isNotEmpty) { + final first = failed.first; + final who = nameList([for (final m in failed) m.name]); + final unready = self != null && !self.ready; + return StatusLine(StatusKind.cannotPlay, "$who can't play this: ${first.error}", + who: failed, + short: "$who can't play this", + detail: unready ? "Press Space when you're ready" : null, + action: unready ? StatusAction.ready : null); + } + if (self != null && !self.ready) { + return StatusLine(StatusKind.you, "Press Space when you're ready", + who: [self], self: true, action: StatusAction.ready, detail: 'or click here', short: 'Press Space when ready'); + } + // Members sitting out or gone are never waited for nor counted. + final active = [for (final m in r.members) if (countsForReady(m)) m]; + final others = [ + for (final m in active) + if ((self == null || m.id != self.id) && !(m.inSet && m.ready)) m, + ]; + if (others.isEmpty) return null; + final ready = active.where((m) => m.ready).length; + return StatusLine(StatusKind.waiting, 'Waiting for ${nameList([for (final m in others) m.name])}', + who: others, detail: '$ready/${active.length} ready'); +} + +/// How long the relay's source must be gone before it is worth a notice +/// (a quick reconnect says nothing). +const sourceDropNotice = Duration(seconds: 5); + +/// Turns status transitions worth a notice into events: the source this +/// viewer reads from gone for [sourceDropNotice] (or left), another window +/// took over, the server restarted, the media starting for the first time. +/// Idempotent: [step] may run on every status change and every second. +class StatusWatch { + final _dropped = {}; + final _seenHeld = {}; + final _started = {}; + bool _replaced = false; + int _restarts = 0; + bool _first = true; + + List step(Status s, DateTime now) { + final out = []; + final src = s.source; + if (src != null) { + final key = '${src.mediaId}/${src.sourceId}/${src.left ? 'left' : src.since!.toIso8601String()}'; + final due = src.left || now.difference(src.since!) >= sourceDropNotice; + // Once per drop, and once when it left. + if (due && _dropped.add(key)) out.add(RelaySourceDropped(src.name, left: src.left)); + } + final replaced = s.conn is ConnReplaced; + if (replaced && !_replaced) out.add(const Replaced()); + _replaced = replaced; + if (!_first && s.restarts > _restarts) out.add(const ServerRestarted()); + _restarts = s.restarts; + _first = false; + final r = s.state; + if (r != null && r.media != null) { + if (r.transport == 'held') _seenHeld.add(r.mediaId); + if (r.transport == 'playing' && _seenHeld.contains(r.mediaId) && _started.add(r.mediaId)) { + final now = nowPlayingOf(r.media); + out.add(PlaybackStarted([?now?.title, ?now?.episode].join(' · '))); + } + } + return out; + } + + /// A new session. + void reset() { + _dropped.clear(); + _seenHeld.clear(); + _started.clear(); + _replaced = false; + _restarts = 0; + _first = true; + } +} diff --git a/apps/desktop/lib/src/shell/app_shell.dart b/apps/desktop/lib/src/shell/app_shell.dart index 8e814a4..79d05c1 100644 --- a/apps/desktop/lib/src/shell/app_shell.dart +++ b/apps/desktop/lib/src/shell/app_shell.dart @@ -6,6 +6,7 @@ import 'package:flutter/services.dart'; import 'package:signals/signals_flutter.dart'; import '../controls/frame.dart' show DoubleClickListener; +import '../controls/session_controls.dart' show controlsBottomHeight; import '../files/folder_store.dart'; import '../files/ui/files_page.dart' show showFilesFolder; import '../jellyfin/accounts.dart'; @@ -98,6 +99,9 @@ class AppShellState extends State with SingleTickerProviderStateMixin final _chat = TextEditingController(); final _chatFocus = FocusNode(); final _playerKey = GlobalKey(); + /// Whether the full player's controls are up (the player's frame + /// reports it): the toasts sit above them. + final _controlsShown = ValueNotifier(true); Alignment _corner = Alignment.bottomRight; Offset? _drag; // mini top-left while dragging @@ -187,6 +191,17 @@ class AppShellState extends State with SingleTickerProviderStateMixin _autoFull = false; }); })); + // A click on a chat system notification: the window is up, open the + // chat. + var chatAsks = coord.notifier.chatRequests.peek(); + _stops.add(effect(() { + final asks = coord.notifier.chatRequests.value; + if (asks == chatAsks) return; + chatAsks = asks; + _later(() { + if (!_chatOpen && _online.peek()) _toggleChat(); + }); + })); // Chat that arrives while the panel is closed. _stops.add(effect(() { final log = coord.feedLog.value; @@ -250,6 +265,7 @@ class AppShellState extends State with SingleTickerProviderStateMixin _picker.dispose(); _chat.dispose(); _chatFocus.dispose(); + _controlsShown.dispose(); if (_fullscreen) _setWindowFullscreen(false); super.dispose(); } @@ -771,16 +787,44 @@ class AppShellState extends State with SingleTickerProviderStateMixin ), ), if (online && _shown != PlayerMode.none) _playerLayer(area), - Positioned(left: 16, bottom: _fullscreen ? 90 : 16, child: PartyToasts( - onOpenChat: () { - if (!_chatOpen) _toggleChat(); - }, - )), + _toasts(full && online), ], ); }); } + /// The toasts, bottom left; over the full player above its control + /// bar (the bar's height in the skin and size in use), moving up while + /// the controls show and down while they are hidden, so they never + /// cover the controls (both skins). + Widget _toasts(bool overPlayer) { + final toasts = PartyToasts( + onOpenChat: () { + if (!_chatOpen) _toggleChat(); + }, + ); + return ValueListenableBuilder( + valueListenable: _controlsShown, + builder: (context, shown, child) => SignalBuilder(builder: (context) { + final s = AppSettings.instance; + final bar = controlsBottomHeight(s.controlsSkin.value, s.controlsSize.value); + return AnimatedPositioned( + key: const ValueKey('toasts'), + duration: easeTime, + curve: ease, + left: toastInset, + bottom: overPlayer && shown ? bar + toastInset : toastInset, + child: child!, + ); + }), + child: toasts, + ); + } + + /// The toasts' distance from the body's left and bottom edges (and from + /// the player's control bar). + static const toastInset = 16.0; + Offset _cornerPos(Size area, Alignment a, Size size) => Offset( a.x < 0 ? _miniMargin : (area.width - size.width - _miniMargin).clamp(0, double.infinity), a.y < 0 ? _miniMargin : (area.height - size.height - _miniMargin).clamp(0, double.infinity), @@ -852,6 +896,7 @@ class AppShellState extends State with SingleTickerProviderStateMixin onLeave: _confirmLeave, videoBuilder: widget.videoBuilder, windowControls: widget.windowControls, + controlsShown: _controlsShown, ), ), ), diff --git a/apps/desktop/lib/src/shell/avatar.dart b/apps/desktop/lib/src/shell/avatar.dart index 82a9554..952912a 100644 --- a/apps/desktop/lib/src/shell/avatar.dart +++ b/apps/desktop/lib/src/shell/avatar.dart @@ -30,6 +30,8 @@ class AvatarInfo { /// How it gets the media and its buffer ([MemberDelivery.detail]), for /// the hover; empty when unknown. final String delivery; + /// Their Starsync version (`members[].app`), for the hover; '' unknown. + final String app; const AvatarInfo({ this.id = '', required this.name, @@ -44,6 +46,7 @@ class AvatarInfo { this.self = false, this.error = '', this.delivery = '', + this.app = '', }); /// Sitting out. @@ -136,6 +139,7 @@ AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator self: self, error: m.error, delivery: delivery == null ? '' : memberDeliveryOf(m, r!, delivery).detail, + app: m.app, ); } @@ -332,6 +336,7 @@ class _AvatarStackState extends State { ? visible[i].name : "${visible[i].name} can't play this: ${visible[i].error}", if (visible[i].delivery.isNotEmpty) visible[i].delivery, + if (visible[i].app.isNotEmpty) 'Starsync ${visible[i].app}', ].join('\n'), child: StatusAvatar(info: visible[i], size: size), ), diff --git a/apps/desktop/lib/src/shell/delivery_line.dart b/apps/desktop/lib/src/shell/delivery_line.dart index 5efc3ce..99383aa 100644 --- a/apps/desktop/lib/src/shell/delivery_line.dart +++ b/apps/desktop/lib/src/shell/delivery_line.dart @@ -8,13 +8,14 @@ import 'motion.dart'; Color _tone(SourceState s, ColorScheme scheme) => switch (s) { SourceState.ok => scheme.primary, - SourceState.slow || SourceState.waiting => Colors.amber, + SourceState.slow || SourceState.waiting || SourceState.gone => Colors.amber, SourceState.left => Colors.redAccent.shade200, }; IconData _icon(Delivery d) => switch (d.state) { SourceState.left => Icons.link_off_rounded, SourceState.waiting => Icons.hourglass_top_rounded, + SourceState.gone => Icons.wifi_off_rounded, _ => switch (d.kind) { DeliveryKind.memberRelay => Icons.cell_tower, DeliveryKind.serverFetch => Icons.cloud_download_outlined, @@ -28,8 +29,8 @@ IconData _icon(Delivery d) => switch (d.state) { /// Where the current media comes from and how it reaches the others: /// "Foxyz passes it on through the server", "3 through the server · /// 4.1 MB/s, needs 2.3 MB/s" and a bar of the source's speed against the -/// video's (past the mark: keeping up). Amber when slow or waiting, red -/// when the source left. +/// video's (past the mark: keeping up). Amber when slow, waiting or the +/// source's connection dropped, red when the source left. class SourceLine extends StatelessWidget { final Delivery delivery; const SourceLine({super.key, required this.delivery}); @@ -40,7 +41,7 @@ class SourceLine extends StatelessWidget { final scheme = Theme.of(context).colorScheme; final tone = _tone(d.state, scheme); final detail = d.detail; - final headroom = d.state == SourceState.left ? null : d.headroom; + final headroom = d.state == SourceState.left || d.state == SourceState.gone ? null : d.headroom; return Tooltip( message: d.explain, waitDuration: const Duration(milliseconds: 400), diff --git a/apps/desktop/lib/src/shell/library_tab.dart b/apps/desktop/lib/src/shell/library_tab.dart index 3339d44..2c2e973 100644 --- a/apps/desktop/lib/src/shell/library_tab.dart +++ b/apps/desktop/lib/src/shell/library_tab.dart @@ -13,6 +13,7 @@ import '../jellyfin/home_page.dart' show libraryIcon, isVideoLibrary; import '../jellyfin/picker_source.dart'; import '../jellyfin/session_media.dart'; import '../screens/home/media_picker.dart'; +import '../session/app_events.dart' show ActionFailed; import '../state/session_state.dart'; import 'library_link.dart'; import 'motion.dart'; @@ -196,21 +197,17 @@ class _LibraryTabState extends State { return pick is JellyfinMedia ? add(pick, next: next) : pick as String; } - /// A card's menu: errors as a snack bar. + /// A card's menu: errors as a toast (the Notifier's). Future _cardQueue(JellyfinSelection sel, {bool next = false}) async { final error = await _queueJellyfin(sel, next: next); - if (error != null && mounted) { - ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(error))); - } + if (error != null) SessionState.instance.notifier.notify(ActionFailed(error)); } - /// Double-click on a card: put it up, errors as a snack bar. + /// Double-click on a card: put it up, errors as a toast. Future _doublePutUp(JellyfinSelection sel) async { _c.select(sel); final error = await _putUpJellyfin(sel); - if (error != null && mounted) { - ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(error))); - } + if (error != null) SessionState.instance.notifier.notify(ActionFailed(error)); } // -------------------------------------------------------------- build diff --git a/apps/desktop/lib/src/shell/party_strip.dart b/apps/desktop/lib/src/shell/party_strip.dart index f591e7c..14bc3a7 100644 --- a/apps/desktop/lib/src/shell/party_strip.dart +++ b/apps/desktop/lib/src/shell/party_strip.dart @@ -121,10 +121,9 @@ class PartyStrip extends StatelessWidget { label: t.label, selected: t == tab, compact: compact, - // The version: always in sight under Settings, - // the full one a rest of the mouse away. + // The version a rest of the mouse away (also at + // the bottom of Settings and in About). tooltip: t == ShellTab.settings ? '$appName ${displayVersion()}' : null, - caption: t == ShellTab.settings ? shortVersion() : null, onTap: () => onTab(t), ), ], @@ -281,13 +280,9 @@ class _ServerLabel extends StatelessWidget { @override Widget build(BuildContext context) => SignalBuilder( builder: (context) { - final coord = SessionState.instance.coordinator; - final r = coord.serverState.value; - // The ready counter counts who the presence policy counts (not away, - // not gone, able to play it). - final counted = (r?.members ?? const []).where(countsForReady).toList(); - final ready = counted.where((m) => m.ready).length; - final stale = coord.stateStale.value; + // "connecting…", "reconnecting…", "ready 2/3" (the ready counter + // counts who the presence policy counts), from the one status. + final line = SessionState.instance.coordinator.status.value.party; return Column( key: const ValueKey('server-label'), mainAxisSize: MainAxisSize.min, @@ -298,11 +293,7 @@ class _ServerLabel extends StatelessWidget { style: const TextStyle(fontWeight: FontWeight.w600, height: 1.1), ), Text( - r == null - ? 'connecting…' - : stale - ? 'reconnecting…' - : 'ready $ready/${counted.length}', + line, style: TextStyle( color: scheme.onSurfaceVariant, fontSize: 12, @@ -373,8 +364,6 @@ class StripTab extends StatelessWidget { final VoidCallback onTap; /// Shown after resting on the tab. final String? tooltip; - /// Small text under the tab (the version under Settings). - final String? caption; const StripTab({ super.key, required this.label, @@ -382,7 +371,6 @@ class StripTab extends StatelessWidget { required this.onTap, this.compact = false, this.tooltip, - this.caption, }); @override @@ -399,7 +387,6 @@ class StripTab extends StatelessWidget { child: SizedBox( height: partyStripHeight, child: Stack( - clipBehavior: Clip.none, alignment: Alignment.center, children: [ Column( @@ -426,27 +413,6 @@ class StripTab extends StatelessWidget { ), ], ), - if (caption case final c?) - Positioned( - left: 0, - right: 0, - bottom: 2, - height: 13, - child: OverflowBox( - maxWidth: 160, - child: Text( - c, - key: const ValueKey('strip-version'), - maxLines: 1, - softWrap: false, - style: TextStyle( - fontSize: 10, - height: 1.2, - color: scheme.onSurfaceVariant.withValues(alpha: 0.7), - ), - ), - ), - ), ], ), ), @@ -522,18 +488,31 @@ class MembersCard extends StatelessWidget { final address = hosting ? null : '${session.lastForm.host}:${session.lastForm.port}'; - final delivery = r == null - ? null - : deliveryOf(r, selfId: coord.connection.memberId, names: coord.memberNames); + final delivery = coord.status.value.delivery; + final server = session.serverVersion.value; return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), - child: Text( - ['In the server', ?address].join(' · '), - style: const TextStyle(fontWeight: FontWeight.w600), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + ['In the server', ?address].join(' · '), + style: const TextStyle(fontWeight: FontWeight.w600), + ), + if (server.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 2), + child: VersionText( + key: const ValueKey('server-version'), + label: 'Server', + version: server, + ), + ), + ], ), ), if (delivery != null) SourceLine(delivery: delivery), @@ -618,6 +597,7 @@ class _MemberTile extends StatelessWidget { crossAxisAlignment: WrapCrossAlignment.center, children: [ Text(info.self ? '${m.name} (you)' : m.name), + if (m.app.isNotEmpty) VersionText(key: ValueKey('version-${m.id}'), version: m.app), if (info.host) const Pill('host', Colors.amber), if (info.relaying) const Pill('passing the video on', Colors.lightBlueAccent), @@ -651,6 +631,35 @@ class _MemberTile extends StatelessWidget { } } +/// A Starsync version, quiet ("1.3.0"), amber with a hint when it is not +/// the one this app runs; [label] before it ("Server 1.3.0"). +class VersionText extends StatelessWidget { + final String version; + final String? label; + const VersionText({super.key, required this.version, this.label}); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final differs = versionDiffers(version); + final text = Text( + [?label, version].join(' '), + style: TextStyle( + fontSize: 11, + color: differs ? Colors.amber.shade300 : scheme.onSurfaceVariant.withValues(alpha: 0.8), + fontWeight: differs ? FontWeight.w600 : FontWeight.w400, + ), + ); + if (!differs) return text; + return Tooltip(message: 'You run ${shortVersion()}', child: text); + } +} + +/// [version] is not the one this app runs (a dev run matches anything: +/// it has no version of its own). +bool versionDiffers(String version, {String own = appVersion}) => + version.isNotEmpty && own != '0.0.0-dev' && version != '0.0.0-dev' && version != own; + /// Hosting: the addresses friends on this network can join, the first /// one behind "Copy invite address", and what friends elsewhere need. class _HostInvite extends StatefulWidget { diff --git a/apps/desktop/lib/src/shell/player_status.dart b/apps/desktop/lib/src/shell/player_status.dart index d66e442..f38365d 100644 --- a/apps/desktop/lib/src/shell/player_status.dart +++ b/apps/desktop/lib/src/shell/player_status.dart @@ -1,154 +1,22 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:sync_relay_core/core.dart'; -import '../session/health.dart'; -import '../session/party.dart'; import '../session/session_coordinator.dart'; import '../state/session_state.dart'; import '../widgets/app_menu.dart' show AppGlass; import 'avatar.dart'; import 'motion.dart'; -/// What the player's status pill says, most pressing first. -enum StatusKind { reconnecting, problem, connecting, finished, loading, you, cannotPlay, waiting } +export '../session/status.dart' show StatusAction, StatusKind, StatusLevel, StatusLine, nameList; -/// The pill's button: stop reconnecting, retry (reconnect), reload the -/// video (your player could not open it), or mark yourself ready (the -/// whole pill). -enum StatusAction { cancel, retry, reload, ready } - -/// One status line: [text], a muted [detail], a coloured [tone] for -/// problems (warn, error), the members it is about ([who]; [self] for you) -/// and a loading [progress] (0..1). -class StatusView { - final StatusKind kind; - final String text; - final String? detail; - final HealthSeverity? tone; - final List who; - final bool self; - final double? progress; - final StatusAction? action; - /// The mini player's shorter line. - final String short; - const StatusView( - this.kind, - this.text, { - this.detail, - this.tone, - this.who = const [], - this.self = false, - this.progress, - this.action, - String? short, - }) : short = short ?? text; -} - -/// "Bob", "Bob and Mika", "Bob, Mika and Cleo", "Bob, Mika and 3 others". -String nameList(List names) { - if (names.length <= 1) return names.join(); - if (names.length <= 3) return '${names.sublist(0, names.length - 1).join(', ')} and ${names.last}'; - return '${names[0]}, ${names[1]} and ${names.length - 2} others'; -} - -String _sentence(String s) => s.isEmpty ? s : s[0].toUpperCase() + s.substring(1); - -/// The player's status from the session's signals; null when there is -/// nothing to say (playing, connected, everyone ready). [slowConnect]: -/// connecting has taken over 2 s. [playerError]: why your player cannot -/// open the media (the coordinator's `playerError`). -StatusView? playerStatusFor({ - required HealthStatus health, - bool reconnecting = false, - bool unstable = false, - StateBroadcast? state, - MemberView? self, - bool slowConnect = false, - String playerError = '', -}) { - if (reconnecting) { - return const StatusView(StatusKind.reconnecting, 'Reconnecting…', - tone: HealthSeverity.warn, action: StatusAction.cancel); - } - if (unstable) { - return const StatusView(StatusKind.reconnecting, 'Connection unstable…', tone: HealthSeverity.warn); - } - final away = self?.presence == Presence.away; - if (playerError.isNotEmpty && state?.media != null && !away) { - return StatusView(StatusKind.problem, "Can't play this: $playerError", - tone: HealthSeverity.error, action: StatusAction.reload, short: "Can't play this"); - } - if (health.severity == HealthSeverity.warn || health.severity == HealthSeverity.error) { - return StatusView(StatusKind.problem, _sentence(health.message), - tone: health.severity, action: StatusAction.retry); - } - if (slowConnect && health.severity == HealthSeverity.info && health.message.startsWith('connecting')) { - return const StatusView(StatusKind.connecting, 'Connecting…'); - } - final r = state; - if (r == null || r.media == null || r.transport == 'playing') return null; - // Sitting out: the join-back card says it; nobody waits for you. - if (away) return null; - if (r.transport == 'finished') { - return const StatusView(StatusKind.finished, 'Finished · seek back or pick the next video', short: 'Finished'); - } - // Members whose player cannot open it: the server does not wait for - // them; say why instead of "waiting for". - final failed = [ - for (final m in r.members) - if (m.error.isNotEmpty && isPresent(m) && (self == null || m.id != self.id)) m, - ]; - // As the server holds playback: someone in the ready set short of the - // minimum buffer. - final need = r.bufferNeed; - for (final m in r.members) { - if (m.inSet && m.buffered < need) { - final you = self != null && m.id == self.id; - final p = (m.buffered / need).clamp(0.0, 1.0); - return StatusView(StatusKind.loading, you ? 'Loading the video…' : '${m.name} is loading…', - who: [m], self: you, progress: p, detail: '${(p * 100).round()}%'); - } - } - if (failed.isNotEmpty) { - final f = failed.first; - final who = nameList([for (final m in failed) m.name]); - final unready = self != null && !self.ready; - return StatusView(StatusKind.cannotPlay, "$who can't play this: ${f.error}", - who: failed, - short: "$who can't play this", - detail: unready ? "Press Space when you're ready" : null, - action: unready ? StatusAction.ready : null); - } - if (self != null && !self.ready) { - return StatusView(StatusKind.you, "Press Space when you're ready", - who: [self], self: true, action: StatusAction.ready, detail: 'or click here', short: 'Press Space when ready'); - } - // Members sitting out or gone are never waited for nor counted. - final active = [for (final m in r.members) if (countsForReady(m)) m]; - final others = [ - for (final m in active) - if ((self == null || m.id != self.id) && !(m.inSet && m.ready)) m, - ]; - if (others.isEmpty) return null; - final ready = active.where((m) => m.ready).length; - return StatusView(StatusKind.waiting, 'Waiting for ${nameList([for (final m in others) m.name])}', - who: others, detail: '$ready/${active.length} ready'); -} - -/// [playerStatusFor] from the session (tracked: call inside a builder). -StatusView? playerStatusNow({bool slowConnect = false}) { - final c = SessionState.instance.coordinator; - return playerStatusFor( - health: c.health.value, - reconnecting: c.reconnecting.value, - unstable: c.unstable.value, - state: c.serverState.value, - self: c.selfMember.value, - slowConnect: slowConnect, - playerError: c.playerError.value, - ); +/// The pill's line from the session's [SessionCoordinator.status] +/// (tracked: call inside a builder); "Connecting…" only once it has taken +/// [PlayerStatus.connectGrace] ([slowConnect]). +StatusLine? playerStatusNow({bool slowConnect = false}) { + final line = SessionState.instance.coordinator.status.value.line; + if (line?.kind == StatusKind.connecting && !slowConnect) return null; + return line; } /// The player's one status pill (bottom centre): frosted dark glass with @@ -167,7 +35,7 @@ class PlayerStatus extends StatefulWidget { class _PlayerStatusState extends State { Timer? _connectTimer; bool _slow = false; - StatusView? _last; + StatusLine? _last; @override void dispose() { @@ -176,8 +44,8 @@ class _PlayerStatusState extends State { } // "Connecting…" only once it takes a while. - void _trackConnecting(HealthStatus h) { - final connecting = h.severity == HealthSeverity.info && h.message.startsWith('connecting'); + void _trackConnecting(StatusLine? line) { + final connecting = line?.kind == StatusKind.connecting; if (!connecting) { _connectTimer?.cancel(); _connectTimer = null; @@ -206,7 +74,7 @@ class _PlayerStatusState extends State { Widget build(BuildContext context) { return PartyWatch(builder: (context) { final coord = SessionState.instance.coordinator; - _trackConnecting(coord.health.value); + _trackConnecting(coord.status.value.line); final view = playerStatusNow(slowConnect: _slow); if (view != null) _last = view; final shown = _last; @@ -262,7 +130,7 @@ class PlayerGlass extends StatelessWidget { } class _StatusLine extends StatelessWidget { - final StatusView view; + final StatusLine view; final SessionCoordinator coord; final ValueChanged onAction; const _StatusLine({super.key, required this.view, required this.coord, required this.onAction}); @@ -273,12 +141,13 @@ class _StatusLine extends StatelessWidget { final v = view; switch (v.kind) { case StatusKind.problem: - final error = v.tone == HealthSeverity.error; + final error = v.level == StatusLevel.error; return Icon(error ? Icons.error_outline_rounded : Icons.warning_amber_rounded, size: 20, color: error ? Colors.redAccent.shade100 : Colors.amber); case StatusKind.reconnecting: return const _Spinner(color: Colors.amber); case StatusKind.connecting: + case StatusKind.fetching: return const _Spinner(color: Colors.white70); case StatusKind.finished: return null; diff --git a/apps/desktop/lib/src/shell/session_player.dart b/apps/desktop/lib/src/shell/session_player.dart index 4af56b1..0873496 100644 --- a/apps/desktop/lib/src/shell/session_player.dart +++ b/apps/desktop/lib/src/shell/session_player.dart @@ -63,6 +63,9 @@ class SessionPlayer extends StatefulWidget { /// Window buttons in the fullscreen top bar (off in tests: they need /// window_manager). final bool windowControls; + /// Follows whether the full player's controls are up (the shell lifts + /// its toasts above them); the player keeps its own when null. + final ValueNotifier? controlsShown; const SessionPlayer({ super.key, required this.mode, @@ -79,6 +82,7 @@ class SessionPlayer extends StatefulWidget { this.unread = 0, this.videoBuilder, this.windowControls = true, + this.controlsShown, }); @override @@ -96,7 +100,8 @@ class _SessionPlayerState extends State { int _shaderNoticeSeen = 0; bool _miniHover = false; // Whether the controls are up (their frame reports it). - final _controlsShown = ValueNotifier(true); + final _ownShown = ValueNotifier(true); + ValueNotifier get _controlsShown => widget.controlsShown ?? _ownShown; // The mouse on a floating element, or the Up next popover open, keeps // the controls up. final _hold = ControlsHold(); @@ -155,7 +160,7 @@ class _SessionPlayerState extends State { _stopShaderWatch(); _stopPopoverWatch(); _noFocus.dispose(); - _controlsShown.dispose(); + _ownShown.dispose(); _hold.dispose(); _model.dispose(); super.dispose(); diff --git a/apps/desktop/lib/src/shell/settings_tab.dart b/apps/desktop/lib/src/shell/settings_tab.dart index 7e213d0..416957b 100644 --- a/apps/desktop/lib/src/shell/settings_tab.dart +++ b/apps/desktop/lib/src/shell/settings_tab.dart @@ -13,6 +13,7 @@ import '../jellyfin/accounts_panel.dart'; import '../schedule/animeflow.dart'; import '../screens/home/about_page.dart'; import '../screens/home/settings_page.dart'; +import '../screens/home/ui.dart' show appName, displayVersion; import '../session/auto_sit_out.dart' show AutoSitOutTimes; import '../sfx/sfx.dart'; import '../sfx/sound_bank.dart'; @@ -228,6 +229,19 @@ class SettingsTab extends StatelessWidget { Text(section.label, style: Theme.of(context).textTheme.headlineSmall), const SizedBox(height: 14), ..._page(context), + // The version, quietly (also the Settings tab's + // tooltip and About). + Padding( + padding: const EdgeInsets.only(top: 8), + child: SelectableText( + '$appName ${displayVersion()}', + key: const ValueKey('settings-version'), + style: TextStyle( + fontSize: 11, + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.7), + ), + ), + ), ], ), ), @@ -730,8 +744,9 @@ class _AnimeFlowCardState extends State { } } -/// What pops up (chat pop-ups: [AppSettings.chatPopups]) and what makes -/// a sound. +/// What pops up (chat pop-ups: [AppSettings.chatPopups]), what reaches +/// you while you sit out or the window is in the background +/// ([AppSettings.notifyPeople]), and what makes a sound. class NotificationsInfo extends StatelessWidget { const NotificationsInfo({super.key}); @@ -753,8 +768,9 @@ class NotificationsInfo extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - line('Joins, leaves and video changes', - 'Pop up bottom left for 5 s, with the person\'s picture'), + line('Joins, leaves, lost connections and video changes', + "Pop up bottom left for 5 s with the person's picture, above the player's controls"), + line('Added to Up next', 'With the title, its picture and who added it'), Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: Row( @@ -781,7 +797,30 @@ class NotificationsInfo extends StatelessWidget { ), line('Sat out or nudged by someone', 'Only on your end, with their picture'), line('Ready, seeks and pauses', 'Only in Chat & activity (Tab), never as a pop-up'), - line('App notices', 'Shader changes, auto sit-out setting you away'), + line('Watching alone', 'No pop-ups or sounds about the party: there is nobody else'), + line('App notices', 'Shader changes, copies, errors, auto sit-out setting you away'), + ], + ), + ), + SettingsPanel( + title: 'Sitting out and in the background', + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + line('What still reaches you', + 'While you sit out, only chat, nudges, "starting now" and Up next pop up. With the window ' + 'in the background or minimised they also go out as system notifications; a click brings ' + 'the window up (a chat one opens the chat)'), + SignalBuilder( + builder: (context) => SwitchListTile( + key: const ValueKey('notify-people'), + contentPadding: EdgeInsets.zero, + title: const Text('Joins and leaves too'), + subtitle: Text('Who joins, leaves, loses connection or comes back', style: muted), + value: AppSettings.instance.notifyPeople.value, + onChanged: AppSettings.instance.setNotifyPeople, + ), + ), ], ), ), diff --git a/apps/desktop/lib/src/shell/toasts.dart b/apps/desktop/lib/src/shell/toasts.dart index 907322d..9fce177 100644 --- a/apps/desktop/lib/src/shell/toasts.dart +++ b/apps/desktop/lib/src/shell/toasts.dart @@ -5,29 +5,29 @@ import 'package:signals/signals_flutter.dart'; import '../session/session_coordinator.dart' show ToastItem; import '../state/session_state.dart'; -import '../state/settings.dart'; import 'avatar.dart'; import 'motion.dart'; -/// One toast on screen: who (null = the app itself), what, and whether it -/// is on its way out. +/// One toast on screen: who (null = the app itself), what, a picture +/// (an Up next item's), and whether it is on its way out. class ShellToast { final int id; final AvatarInfo? who; final IconData? icon; final String text; final bool chat; + final String thumb; bool leaving = false; - ShellToast({required this.id, required this.text, this.who, this.icon, this.chat = false}); + ShellToast({required this.id, required this.text, this.who, this.icon, this.chat = false, this.thumb = ''}); } -/// Splits a coordinator toast into who and what: feed and chat toasts -/// name their [ToastItem.actor] and start with it ("Bob joined", "Bob: -/// hi"); the rest are the app's. +/// Splits a Notifier toast into who and what: feed and chat toasts name +/// their [ToastItem.actor] and start with it ("Bob joined", "Bob: hi"); +/// the rest are the app's. ShellToast shellToastFor(ToastItem t, AvatarInfo Function(String name) avatar) { final n = t.actor; - if (n == null || n.isEmpty) return ShellToast(id: t.id, text: t.text, icon: Icons.info_outline); - if (t.text.startsWith('$n: ')) { + if (n == null || n.isEmpty) return ShellToast(id: t.id, text: t.text, icon: Icons.info_outline, thumb: t.thumb); + if (t.chat && t.text.startsWith('$n: ')) { return ShellToast(id: t.id, who: avatar(n), text: t.text.substring(n.length + 2), chat: true); } final rest = t.text.startsWith('$n ') ? t.text.substring(n.length + 1) : t.text; @@ -35,24 +35,30 @@ ShellToast shellToastFor(ToastItem t, AvatarInfo Function(String name) avatar) { ? Icons.login : rest.startsWith('left') ? Icons.logout - : rest.startsWith('sat you out') - ? Icons.bedtime - : rest.startsWith('nudged') - ? Icons.notifications_active_outlined - : rest.startsWith('changed') - ? Icons.swap_horiz - : Icons.info_outline; - return ShellToast(id: t.id, who: avatar(n), text: rest, icon: icon); + : rest.startsWith('lost connection') + ? Icons.wifi_off_rounded + : rest.startsWith('is back') + ? Icons.wifi_rounded + : rest.startsWith('sat you out') + ? Icons.bedtime + : rest.startsWith('nudged') + ? Icons.notifications_active_outlined + : rest.startsWith('changed') + ? Icons.swap_horiz + : rest.startsWith('added') + ? Icons.playlist_add_rounded + : Icons.info_outline; + return ShellToast(id: t.id, who: avatar(n), text: rest, icon: icon, thumb: t.thumb); } -/// The coordinator's toasts as cards with avatars, bottom-left: each -/// slides in, stays [life], then slides out and collapses its gap. A click -/// on a chat toast opens the chat ([onOpenChat]); the rest let clicks -/// through. Chat toasts follow [AppSettings.chatPopups]. +/// The Notifier's toasts as cards with avatars, bottom-left: each slides +/// in, stays while the Notifier keeps it (it drops it after its time), +/// then slides out and collapses its gap. A click on a chat toast opens +/// the chat ([onOpenChat]); the rest let clicks through. What pops up at +/// all (chat pop-ups, sitting out, alone) is the Notifier's policy. class PartyToasts extends StatefulWidget { - final Duration life; final VoidCallback? onOpenChat; - const PartyToasts({super.key, this.life = const Duration(seconds: 5), this.onOpenChat}); + const PartyToasts({super.key, this.onOpenChat}); @override State createState() => _PartyToastsState(); @@ -72,33 +78,40 @@ class _PartyToastsState extends State { _seen = {for (final t in coord.toasts.peek()) t.id}; _stop = effect(() { final items = coord.toasts.value; + final ids = {for (final t in items) t.id}; final fresh = [for (final t in items) if (!_seen.contains(t.id)) t]; - _seen = {for (final t in items) t.id}; - if (fresh.isEmpty) return; + final gone = _seen.difference(ids); + _seen = ids; + if (fresh.isEmpty && gone.isEmpty) return; // Only new toasts re-run this, not the party they mention. untracked(() { - final playing = coord.serverState.value?.transport == 'playing'; - final popups = AppSettings.instance.chatPopups.value; for (final t in fresh) { - final toast = shellToastFor(t, (n) => avatarForName(n, coord, t.actorId)); - // Chat per Settings → Notifications (it is in the chat either way). - if (toast.chat && !popups.shows(playing: playing)) continue; - _add(toast); + _add(shellToastFor(t, (n) => avatarForName(n, coord, t.actorId))); } + if (gone.isNotEmpty) _leave(gone); }); }); } void _add(ShellToast t) { // Not from inside the effect's build-time notification. + scheduleMicrotask(() { + if (mounted) setState(() => _shown.add(t)); + }); + } + + void _leave(Set ids) { scheduleMicrotask(() { if (!mounted) return; - setState(() => _shown.add(t)); - _timers.add(Timer(widget.life, () { - if (mounted) setState(() => t.leaving = true); - })); - _timers.add(Timer(widget.life + easeTime, () { - if (mounted) setState(() => _shown.remove(t)); + final leaving = [for (final t in _shown) if (ids.contains(t.id) && !t.leaving) t]; + if (leaving.isEmpty) return; + setState(() { + for (final t in leaving) { + t.leaving = true; + } + }); + _timers.add(Timer(easeTime, () { + if (mounted) setState(() => _shown.removeWhere(leaving.contains)); })); }); } @@ -114,7 +127,7 @@ class _PartyToastsState extends State { void _open(ShellToast t) { widget.onOpenChat?.call(); - setState(() => t.leaving = true); + SessionState.instance.notifier.dismiss(t.id); } @override @@ -206,6 +219,8 @@ class ToastCard extends StatelessWidget { children: [ if (who != null) StatusAvatar(info: who, size: 38, badges: false) + else if (toast.thumb.isNotEmpty) + _Thumb(hash: toast.thumb) else CircleAvatar( radius: 19, @@ -237,6 +252,8 @@ class ToastCard extends StatelessWidget { ], Text( toast.text, + maxLines: 3, + overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: toast.chat ? 15 : 14, color: toast.chat || who == null ? scheme.onSurface : scheme.onSurfaceVariant, @@ -245,6 +262,10 @@ class ToastCard extends StatelessWidget { ], ), ), + if (who != null && toast.thumb.isNotEmpty) ...[ + const SizedBox(width: 12), + _Thumb(hash: toast.thumb), + ], ], ), ), @@ -252,3 +273,31 @@ class ToastCard extends StatelessWidget { ); } } + +/// An Up next item's picture (16:9, from the queue's picture cache); +/// a blank until it is fetched. +class _Thumb extends StatelessWidget { + final String hash; + const _Thumb({required this.hash}); + + @override + Widget build(BuildContext context) { + final thumbs = SessionState.instance.coordinator.queue.thumbs; + return ClipRRect( + key: const ValueKey('toast-thumb'), + borderRadius: BorderRadius.circular(6), + child: SizedBox( + width: 68, + height: 38, + child: ListenableBuilder( + listenable: thumbs, + builder: (context, _) { + final bytes = thumbs.bytesFor(hash); + if (bytes == null) return ColoredBox(color: Theme.of(context).colorScheme.surfaceContainerHighest); + return Image.memory(bytes, fit: BoxFit.cover, gaplessPlayback: true); + }, + ), + ), + ); + } +} diff --git a/apps/desktop/lib/src/state/session_state.dart b/apps/desktop/lib/src/state/session_state.dart index 2e21e46..651e0f6 100644 --- a/apps/desktop/lib/src/state/session_state.dart +++ b/apps/desktop/lib/src/state/session_state.dart @@ -16,7 +16,6 @@ import '../platform/ports/ports.dart'; import '../sfx/sfx.dart'; import '../sfx/sound_bank.dart'; import '../sfx/synth_sfx.dart'; -import '../session/health.dart'; import '../session/notifier.dart'; import '../session/connection.dart' show Connection; import '../session/identity.dart' show ClientIdentity; @@ -163,7 +162,6 @@ class SessionState { final route = signal(AppRoute.home); final recents = signal>(const []); - final status = signal(const HealthStatus(HealthSeverity.info, 'idle')); /// Owns the app's audio device ([SynthSfx.dispose] on close). final SynthSfx sfx = SynthSfx( volume: () => AppSettings.instance.soundVolume.peek(), @@ -173,8 +171,15 @@ class SessionState { ); /// The platform (window, links, folders, yt-dlp, notifications). final Ports ports = desktopPorts(); - /// Every toast, sound and activity-log line goes through it. - late final Notifier notifier = Notifier(sfx: sfx, attention: ports.shell.requestAttention); + /// Every toast, sound, system notification and activity-log line goes + /// through it. + late final Notifier notifier = Notifier( + sfx: sfx, + attention: ports.shell.requestAttention, + system: ports.notifications, + focusWindow: ports.shell.focus, + prefs: () => AppSettings.instance.notifyPrefs, + ); /// The link to the server; lost/back go to the notifier. /// The link to the server, with this install's stored identity /// (`hello.client` / `seq`); lost/back go to the notifier. @@ -192,6 +197,9 @@ class SessionState { /// The joined server's own name (`/health`), '' until known or when it /// only has the default name. final serverLabel = signal(''); + /// The server's Starsync version: a hosted (or solo) server is this + /// app's own; a joined one's comes from its `/health`; '' until known. + final serverVersion = signal(''); /// A solo session is becoming a hosted one: the player has nothing open /// until the media is up again on the new server. final switching = signal(false); @@ -367,8 +375,6 @@ class SessionState { final op = ++_op; if (form != null) _rememberForm(form); try { - status.value = - const HealthStatus(HealthSeverity.info, 'starting server…'); await stopServer(); if (op != _op) throw const ConnectCancelled(); final hostKey = newHostKey(); @@ -403,8 +409,6 @@ class SessionState { ); } catch (e, st) { debugPrint('server start failed: $e\n$st'); - status.value = HealthStatus( - HealthSeverity.error, 'cannot start server on port ${params.port}: $e'); rethrow; } if (op != _op) { @@ -414,8 +418,7 @@ class SessionState { _server = server; coordinator.hostFile = (path) async => identical(_server, server) ? server.setFile(path) : null; - status.value = HealthStatus( - HealthSeverity.ok, soloSession ? 'watching solo' : 'hosting on port ${server.port}'); + serverVersion.value = appVersion; try { await coordinator.connect(ConnectParams( host: '127.0.0.1', @@ -452,7 +455,6 @@ class SessionState { _setupCrash = cause; return; } - coordinator.health.value = HealthStatus(HealthSeverity.error, message); serverCrash.value = message; unawaited(leave().catchError((Object e) => debugPrint('leave after crash failed: $e'))); } @@ -465,9 +467,12 @@ class SessionState { if (op != _op) throw const ConnectCancelled(); route.value = AppRoute.session; serverLabel.value = ''; + serverVersion.value = ''; unawaited(probeHealth(params.host, '${params.port}', defaultHealthGet).then((h) { + if (op != _op || !h.online) return; // 'server' is the dedicated server's default --name: not a name. - if (op == _op && h.online && h.name.isNotEmpty && h.name != 'server') serverLabel.value = h.name; + if (h.name.isNotEmpty && h.name != 'server') serverLabel.value = h.name; + serverVersion.value = h.version; }).catchError((Object _) {})); } finally { await _saveFormQuietly(); @@ -524,7 +529,7 @@ class SessionState { solo.value = false; coordinator.solo = false; serverLabel.value = ''; - status.value = const HealthStatus(HealthSeverity.info, 'idle'); + serverVersion.value = ''; route.value = AppRoute.home; } } diff --git a/apps/desktop/lib/src/state/settings.dart b/apps/desktop/lib/src/state/settings.dart index d6ca023..f91e2f8 100644 --- a/apps/desktop/lib/src/state/settings.dart +++ b/apps/desktop/lib/src/state/settings.dart @@ -6,8 +6,11 @@ import 'package:signals/signals_flutter.dart'; import '../controls/model/controls_prefs.dart'; import '../shaders/shader_presets.dart'; +import '../session/notice_prefs.dart'; import 'settings_schema.dart'; +export '../session/notice_prefs.dart' show ChatPopups, NotifyPrefs; + // App preferences (Settings page), one Hive box. Signals start at their // defaults and take the stored values once [AppSettings.ensureLoaded] // finishes; setters write through. @@ -39,6 +42,7 @@ const _keySoundPresetFor = 'soundPresetFor'; const _keyUnreadyOnSwap = 'unreadyOnSwap'; const keyAutoSitOut = 'autoSitOut'; const _keyChatPopups = 'chatPopups'; +const _keyNotifyPeople = 'notifyPeople'; const _keyShareDiagnostics = 'shareDiagnostics'; enum HwdecMode { auto, off, custom } @@ -49,23 +53,6 @@ enum HwdecMode { auto, off, custom } /// surface there ("Failed to create EGL surface"). Elsewhere 'auto'. String get autoHwdec => Platform.isWindows ? 'auto-copy' : 'auto'; -/// When chat messages pop up as toasts (they always reach the chat). -enum ChatPopups { - all('All'), - paused('Only when paused'), - off('Off'); - - final String label; - const ChatPopups(this.label); - - /// Whether a chat message pops up now ([playing]: the video plays). - bool shows({required bool playing}) => switch (this) { - ChatPopups.all => true, - ChatPopups.paused => !playing, - ChatPopups.off => false, - }; -} - class AppSettings { static final AppSettings instance = AppSettings(); @@ -100,6 +87,12 @@ class AppSettings { final shareDiagnostics = signal(true); /// Chat message toasts (default: all). Applies live. final chatPopups = signal(ChatPopups.all); + /// Joins and leaves also notify while sitting out or with the window in + /// the background (default off). Applies live. + final notifyPeople = signal(false); + + /// The notification choices as the Notifier reads them. + NotifyPrefs get notifyPrefs => NotifyPrefs(chat: chatPopups.peek(), people: notifyPeople.peek()); /// App sounds (`sfx/`): 0..1, 0 = silent. final soundVolume = signal(1.0); /// `SfxKind` names switched off one by one. @@ -174,6 +167,8 @@ class AppSettings { if (share is bool) shareDiagnostics.value = share; final popups = box.get(_keyChatPopups); chatPopups.value = ChatPopups.values.where((m) => m.name == popups).firstOrNull ?? ChatPopups.all; + final people = box.get(_keyNotifyPeople); + if (people is bool) notifyPeople.value = people; final vol = box.get(_keySoundVolume); if (vol is num) soundVolume.value = vol.toDouble().clamp(0, 1); final off = box.get(_keySoundsOff); @@ -278,6 +273,11 @@ class AppSettings { _put(_keyChatPopups, mode.name); } + void setNotifyPeople(bool on) { + notifyPeople.value = on; + _put(_keyNotifyPeople, on); + } + void setSoundVolume(double v) { soundVolume.value = v.clamp(0, 1); _put(_keySoundVolume, soundVolume.value); diff --git a/apps/desktop/pubspec.lock b/apps/desktop/pubspec.lock index f01fe2f..e0ef223 100644 --- a/apps/desktop/pubspec.lock +++ b/apps/desktop/pubspec.lock @@ -230,6 +230,46 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: "9ee28742343dd13df758bb4185e8ded37ac2d1aeca6648c3ba7862c99797517e" + url: "https://pub.dev" + source: hosted + version: "22.3.2" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: "3b89a4bf095fa5f8a99beeabf982c5bd506c3ad25e7b9e61da945e994dc5b615" + url: "https://pub.dev" + source: hosted + version: "8.0.2" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "43c3761d916c9bd3d5c7ebbc44d82f4990329840c0c5d62ad5260cc1b5d399bd" + url: "https://pub.dev" + source: hosted + version: "12.2.0" + flutter_local_notifications_web: + dependency: transitive + description: + name: flutter_local_notifications_web + sha256: "516afaf97a2d1e67a036c6617321b00d205d72f7a67b6eccf936cd565f985878" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + flutter_local_notifications_windows: + dependency: transitive + description: + name: flutter_local_notifications_windows + sha256: "6f43bdd03b171b7a90f22647506fea33e2bb12294b7c7c7a3d690e960a382945" + url: "https://pub.dev" + source: hosted + version: "3.1.1" flutter_localizations: dependency: transitive description: flutter @@ -858,6 +898,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.12" + timezone: + dependency: transitive + description: + name: timezone + sha256: "9f4e5a1a0e4fef7dd3424842c0575db01bfb7a11730e8d55753fcd5c5c98767b" + url: "https://pub.dev" + source: hosted + version: "0.11.2" typed_data: dependency: transitive description: diff --git a/apps/desktop/pubspec.yaml b/apps/desktop/pubspec.yaml index 4772565..f2f2e74 100644 --- a/apps/desktop/pubspec.yaml +++ b/apps/desktop/pubspec.yaml @@ -39,6 +39,9 @@ dependencies: # App sounds (sfx/, platform/audio/): SoLoud + miniaudio, built from # source by its build hook (needs only a C++ toolchain). flutter_soloud: ^5.2.1 + # System notifications (platform/desktop/desktop_notifications.dart): + # Linux over D-Bus in pure Dart, Windows toasts through WinRT. + flutter_local_notifications: ^22.3.0 # The sounds are WAVs made in memory: no Ogg/Opus/FLAC codecs, so the # build hook downloads and bundles no Xiph libraries. hooks: diff --git a/apps/desktop/test/coord_presence_test.dart b/apps/desktop/test/coord_presence_test.dart index ad9f7ba..1de7131 100644 --- a/apps/desktop/test/coord_presence_test.dart +++ b/apps/desktop/test/coord_presence_test.dart @@ -1,6 +1,3 @@ -// restructure: rewrite in step 2 (Status + Notifier): "feed and chat toasts -// name their actor exactly". -// // The session through drops, takeovers and goodbyes: real coordinators // (real mpv), a real server, and FlakyProxy between a member and it. import 'dart:async'; @@ -242,7 +239,7 @@ void main() { final second = await member(server.boundPort, 'me', identity: install); expect(second.connection.memberId, id, reason: 'the newer connection took the member over'); await _poll(() => first.connection.state.peek() is ConnReplaced, 'the first one replaced'); - expect(first.health.peek().message, replacedText); + expect(first.status.peek().line?.text, replacedText); // It does not fight back. await Future.delayed(const Duration(seconds: 4)); expect(first.connection.state.peek(), isA()); @@ -412,6 +409,7 @@ void main() { final toasts = coord.toasts.peek(); expect(toasts.firstWhere((t) => t.text == 'Bob Jr joined').actor, 'Bob Jr'); final chat = toasts.firstWhere((t) => t.text == 'Bob Jr: hi there'); + expect(chat.chat, isTrue); expect(chat.actor, 'Bob Jr'); expect(chat.actorId, friend.memberId, reason: 'matched by member id'); // Others' chat makes a sound, your own does not. diff --git a/apps/desktop/test/delivery_test.dart b/apps/desktop/test/delivery_test.dart index 960228c..4428b13 100644 --- a/apps/desktop/test/delivery_test.dart +++ b/apps/desktop/test/delivery_test.dart @@ -1,5 +1,6 @@ -// restructure: rewrite in step 2 (Status + Notifier): delivery lines become -// part of the one computed status (and the source-gone half of P2). +// Who fetches the current media and how it spreads (the members card's +// source line, part of the one status), and each member's route and +// buffer. import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:sync_relay_core/core.dart'; @@ -63,10 +64,24 @@ void main() { final r = _state(_relayed(const MediaRelay(source: 'm1', rate: 4100000, need: 2300000)), [bob, ann]); final d = deliveryOf(r, selfId: 'm2', names: names)!; expect(d.state, SourceState.left); - expect(d.title, 'Foxyz left: playing what the server has'); + expect(d.title, 'Foxyz left — playing what the server has'); expect(d.detail, '2 through the server'); }); + test("the source's connection dropped: what the server has, for how long, no rates", () { + final since = DateTime.utc(2026, 1, 1, 12); + final gone = MemberView(id: 'm1', name: 'Foxyz', ready: true, inSet: false, buffered: 30, presence: Presence.gone); + final r = _state( + _relayed(MediaRelay(source: 'm1', live: true, rate: 4100000, need: 2300000, sourceGone: true, sourceGoneSince: since)), + [gone, bob, ann]); + final d = deliveryOf(r, selfId: 'm2', now: since.add(const Duration(seconds: 30)))!; + expect(d.state, SourceState.gone); + expect(d.title, "Foxyz's connection dropped — playing what the server has"); + expect(d.detail, '2 through the server'); + expect(deliveryOf(r, selfId: 'm2', now: since.add(const Duration(minutes: 3)))!.title, + "Foxyz's connection dropped — playing what the server has (3 min)"); + }); + test('channel down: waiting', () { final r = _state(_relayed(const MediaRelay(source: 'm1')), [foxyz, bob]); expect(deliveryOf(r, selfId: 'm2')!.title, 'Waiting for Foxyz to reconnect'); diff --git a/apps/desktop/test/end_of_file_e2e_test.dart b/apps/desktop/test/end_of_file_e2e_test.dart index ee0a820..301e5de 100644 --- a/apps/desktop/test/end_of_file_e2e_test.dart +++ b/apps/desktop/test/end_of_file_e2e_test.dart @@ -58,7 +58,7 @@ void main() { expect(c.serverState.peek()!.position, closeTo(13.77, 0.1)); await _poll(() => !c.playback.player.state.playing, 'player stopped'); expect(c.playback.positionSecs, greaterThan(12)); - expect(c.starving.peek(), contains('finished')); + expect(c.status.peek().line?.kind, StatusKind.finished); c.seekTo(2); await _poll( diff --git a/apps/desktop/test/home_test.dart b/apps/desktop/test/home_test.dart index 3663956..7ccb02c 100644 --- a/apps/desktop/test/home_test.dart +++ b/apps/desktop/test/home_test.dart @@ -80,9 +80,11 @@ void main() { test('health parse: online, junk and protocol mismatch', () { final ok = ServerHealth.parse(jsonEncode( - {'ok': true, 'name': 'Den', 'proto': RelayConstants.protoVersion, 'viewers': 2})); + {'ok': true, 'name': 'Den', 'proto': RelayConstants.protoVersion, 'viewers': 2, 'version': '1.3.0'})); expect(ok.online, isTrue); expect(ok.name, 'Den'); + expect(ok.version, '1.3.0'); + expect(ServerHealth.parse('{"ok":true}').version, '', reason: 'a server too old to say'); expect(ok.viewers, 2); expect(ok.needsUpdate, isFalse); expect(ServerHealth.parse('{"ok":true,"proto":-1}').needsUpdate, isTrue); diff --git a/apps/desktop/test/notice_table_test.dart b/apps/desktop/test/notice_table_test.dart index ebf025c..924232e 100644 --- a/apps/desktop/test/notice_table_test.dart +++ b/apps/desktop/test/notice_table_test.dart @@ -1,13 +1,16 @@ -// restructure: rewrite in step 2 (Status + Notifier): every row, when -// noticeFor takes a context. -// -// The Notifier policy as a table: one row per AppEvent subtype, what -// noticeFor makes of it (toast text and actor, sound, attention, activity-log -// line). A policy change edits a row here on purpose. +// The Notifier policy as a table: one row per AppEvent subtype in the +// plain context (in, with others, the window focused, paused, default +// choices), then the rows that change with the context (sitting out, +// alone, the window in the background, playing, a replayed line). What +// noticeFor makes of each: toast text and actor, sound, attention, system +// notification, activity-log line. A policy change edits a row here on +// purpose. import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; -import 'package:sync_relay_core/core.dart' show FeedMessage; +import 'package:sync_relay_core/core.dart' show FeedMessage, Presence; +import 'package:starsync/src/platform/ports/system_notifications.dart'; +import 'package:starsync/src/platform/ports/window_activity.dart'; import 'package:starsync/src/session/notifier.dart'; import 'package:starsync/src/sfx/sfx.dart'; @@ -29,11 +32,12 @@ FeedMessage _feed(String action, const _ids = {'Ann': 'm1', 'Bob': 'm2', 'Me': 'm9', 'server': 'server'}; -/// One row: the event, then what it must do. +/// One row: the event (in [context]), then what it must do. class _Row { final String name; final AppEvent event; final Type type; + final NoticeContext context; final String? toast; final String? actor; final SfxKind? sound; @@ -41,10 +45,30 @@ class _Row { final bool attention; /// Whether the event's own feed line goes into the activity log. final bool logged; + /// The system notification's title and body, null for none. + final (String, String)? system; const _Row(this.name, this.event, this.type, - {this.toast, this.actor, this.sound, this.readyIndex = 0, this.attention = false, this.logged = false}); + {this.context = _in, + this.toast, + this.actor, + this.sound, + this.readyIndex = 0, + this.attention = false, + this.logged = false, + this.system}); } +// The contexts. +const _in = NoticeContext(self: Presence.watching); +const _away = NoticeContext(self: Presence.away); +const _awayPeople = NoticeContext(self: Presence.away, prefs: NotifyPrefs(people: true)); +const _alone = NoticeContext(self: Presence.watching, alone: true); +const _background = NoticeContext(self: Presence.watching, window: WindowActivity.unfocused); +const _minimisedAway = NoticeContext(self: Presence.away, window: WindowActivity.minimised); +const _playing = NoticeContext(self: Presence.watching, playing: true); +const _chatPaused = NoticeContext(self: Presence.watching, playing: true, prefs: NotifyPrefs(chat: ChatPopups.paused)); +const _chatOff = NoticeContext(self: Presence.watching, window: WindowActivity.unfocused, prefs: NotifyPrefs(chat: ChatPopups.off)); + /// The feed as the coordinator turns it into events: this member is m9 /// ('Me'), two members ready in the latest state. FeedEvent _of(FeedMessage f) => FeedEvent.of(f, selfId: 'm9', readyCount: 2); @@ -57,8 +81,9 @@ final _rows = <_Row>[ toast: 'Ann left', actor: 'Ann', sound: SfxKind.leave, logged: true), _Row('leave (grace over)', _of(_feed('leave', reason: 'grace')), MemberLeft, toast: 'Ann left', actor: 'Ann', sound: SfxKind.leave, logged: true), - _Row('gone (lost signal)', _of(_feed('gone')), MemberGone, logged: true), - _Row('return', _of(_feed('return')), MemberReturned, logged: true), + _Row('gone (after the delayed notice)', _of(_feed('gone')), MemberGone, + toast: 'Ann lost connection', actor: 'Ann', logged: true), + _Row('return', _of(_feed('return')), MemberReturned, toast: 'Ann is back', actor: 'Ann', logged: true), _Row('away, someone sat themselves out', _of(_feed('away')), MemberSatOut, logged: true), _Row('away, someone else auto sat out', _of(_feed('away', reason: 'idle')), MemberSatOut, logged: true), _Row('away, someone sat someone else out', _of(_feed('away', target: 'Bob')), MemberSatOut, logged: true), @@ -83,6 +108,7 @@ final _rows = <_Row>[ _Row('force-hold', _of(_feed('force-hold')), ForcedTransport, logged: true), _Row('swap', _of(_feed('swap')), MediaSwapped, toast: 'Ann changed media', actor: 'Ann', sound: SfxKind.swap, logged: true), + _Row('own swap: logged only', _of(_feed('swap', actor: 'Me')), MediaSwapped, logged: true), _Row('seek', _of(_feed('seek')), Seeked, logged: true), _Row('pause', _of(_feed('pause', actor: 'server')), Paused, logged: true), _Row('resume', _of(_feed('resume', actor: 'server')), Resumed, logged: true), @@ -93,7 +119,10 @@ final _rows = <_Row>[ toast: 'Me: hi', actor: 'Me', logged: true), _Row('rename', _of(_feed('rename', target: 'Annie')), MemberRenamed, logged: true), _Row('avatar', _of(_feed('avatar')), AvatarChanged, logged: true), - _Row('queue-add', _of(_feed('queue-add', target: 'Ep 6')), QueueAdded, logged: true), + _Row('queue-add: title, picture, who', FeedEvent.of(_feed('queue-add', target: 'Ep 6'), selfId: 'm9', queueThumb: 'h1'), + QueueAdded, toast: 'Ann added Ep 6 to Up next', actor: 'Ann', logged: true), + _Row('own queue-add: logged (the app said "Added")', _of(_feed('queue-add', actor: 'Me', target: 'Ep 6')), + QueueAdded, logged: true), _Row('queue-remove', _of(_feed('queue-remove', target: 'Ep 6')), QueueRemoved, logged: true), _Row('queue-skip with a reason', _of(_feed('queue-skip', actor: 'server', target: 'Ep 6', reason: 'gone')), QueueSkipped, toast: 'Skipped Ep 6: gone', logged: true), @@ -108,6 +137,14 @@ final _rows = <_Row>[ toast: 'Someone nudged you', sound: SfxKind.nudge, attention: true), _Row('member cannot play', const MemberCantPlay('Ann', 'no codec'), MemberCantPlay, toast: "Ann can't play this: no codec", actor: 'Ann'), + _Row('starting now: nothing while you watch it start', const PlaybackStarted('Frieren'), PlaybackStarted), + _Row('the source dropped', const RelaySourceDropped('Ann'), RelaySourceDropped, + toast: "Ann's connection dropped: playing what the server has", actor: 'Ann'), + _Row('the source left', const RelaySourceDropped('Ann', left: true), RelaySourceDropped, + toast: 'Ann left: playing what the server has', actor: 'Ann'), + _Row('server restarted', const ServerRestarted(), ServerRestarted, toast: 'The server restarted; you are back in'), + _Row('replaced', const Replaced(), Replaced, toast: 'Starsync was opened in another window'), + _Row('server refused', const ServerRefused('Up next is full'), ServerRefused, toast: 'Up next is full'), _Row('slow source', const SlowSource('Passing on slower than it plays'), SlowSource, toast: 'Passing on slower than it plays'), _Row('relay started', const RelayStarted(), RelayStarted, @@ -130,26 +167,182 @@ final _rows = <_Row>[ toast: "Marked in this app's log"), _Row('copied', const Copied('Link copied'), Copied, toast: 'Link copied'), _Row('copy failed', const CopyFailed(), CopyFailed, toast: "Couldn't copy"), - _Row('update available', const UpdateAvailable('1.3.0'), UpdateAvailable), + _Row('update available: the band shows it', const UpdateAvailable('1.3.0'), UpdateAvailable), ]; +/// The rows the context changes. +final _contextRows = <_Row>[ + // ---- sitting out (D1, P8): chat, nudges, starting now, Up next only + _Row('away: chat pops up with its sound', _of(_feed('chat', reason: 'hi')), ChatPosted, + context: _away, toast: 'Ann: hi', actor: 'Ann', sound: SfxKind.chat, logged: true), + _Row('away: a nudge', const Nudged('Ann'), Nudged, + context: _away, toast: 'Ann nudged you', actor: 'Ann', sound: SfxKind.nudge, attention: true), + _Row('away: starting now', const PlaybackStarted('Frieren'), PlaybackStarted, + context: _away, toast: 'Starting now: Frieren'), + _Row('away: an Up next add', _of(_feed('queue-add', target: 'Ep 6')), QueueAdded, + context: _away, toast: 'Ann added Ep 6 to Up next', actor: 'Ann', logged: true), + _Row('away: a swap', _of(_feed('swap')), MediaSwapped, + context: _away, toast: 'Ann changed media', actor: 'Ann', sound: SfxKind.swap, logged: true), + _Row('away: a join is logged only', _of(_feed('join')), MemberJoined, context: _away, logged: true), + _Row('away: a leave is logged only', _of(_feed('leave', reason: 'bye')), MemberLeft, context: _away, logged: true), + _Row('away: ready makes no sound', _of(_feed('ready')), ReadyChanged, context: _away, logged: true), + _Row("away: someone can't play: nothing", const MemberCantPlay('Ann', 'no codec'), MemberCantPlay, context: _away), + _Row('away, joins chosen: a join pops up', _of(_feed('join')), MemberJoined, + context: _awayPeople, toast: 'Ann joined', actor: 'Ann', sound: SfxKind.join, logged: true), + _Row('away: your own relay still tells you', const RelayFailed('closed'), RelayFailed, + context: _away, toast: 'Cannot pass the video on: closed'), + _Row('away: app notices still show', const Copied('Link copied'), Copied, context: _away, toast: 'Link copied'), + // ---- alone (D9): no social sounds or pop-ups, app notices show + _Row('alone: ready makes no sound', _of(_feed('ready')), ReadyChanged, context: _alone, logged: true), + _Row('alone: a join is quiet', _of(_feed('join')), MemberJoined, context: _alone, logged: true), + _Row('alone: own chat is quiet', _of(_feed('chat', actor: 'Me', reason: 'hi')), ChatPosted, + context: _alone, logged: true), + _Row('alone: app notices show', const ShaderMessage('Shaders off'), ShaderMessage, + context: _alone, toast: 'Shaders off'), + _Row('alone: problems show', const RelayFailed('closed'), RelayFailed, + context: _alone, toast: 'Cannot pass the video on: closed'), + _Row('alone: connection sounds play', const ConnectionLost(), ConnectionLost, context: _alone, sound: SfxKind.lost), + // ---- replayed: the log only, whatever the context + _Row('replayed chat while away in the background', _of(_feed('chat', reason: 'hi', replayed: true)), ChatPosted, + context: _minimisedAway, logged: true), + // ---- the window in the background: a system notification too + _Row('background: chat goes out as a system notification', _of(_feed('chat', reason: 'hi')), ChatPosted, + context: _background, toast: 'Ann: hi', actor: 'Ann', sound: SfxKind.chat, logged: true, system: ('Ann', 'hi')), + _Row('background: a nudge', const Nudged('Ann'), Nudged, + context: _background, + toast: 'Ann nudged you', + actor: 'Ann', + sound: SfxKind.nudge, + attention: true, + system: ('Ann', 'nudged you')), + _Row('background: starting now', const PlaybackStarted('Frieren'), PlaybackStarted, + context: _background, toast: 'Starting now: Frieren', system: ('Starsync', 'Starting now: Frieren')), + _Row('background: an Up next add', _of(_feed('queue-add', target: 'Ep 6')), QueueAdded, + context: _background, + toast: 'Ann added Ep 6 to Up next', + actor: 'Ann', + logged: true, + system: ('Ann', 'added Ep 6 to Up next')), + _Row('background: a join pops up in the app only (not chosen)', _of(_feed('join')), MemberJoined, + context: _background, toast: 'Ann joined', actor: 'Ann', sound: SfxKind.join, logged: true), + _Row('background: app notices stay in the app', const Copied('Link copied'), Copied, + context: _background, toast: 'Link copied'), + _Row('minimised, away: chat goes out', _of(_feed('chat', reason: 'hi')), ChatPosted, + context: _minimisedAway, toast: 'Ann: hi', actor: 'Ann', sound: SfxKind.chat, logged: true, system: ('Ann', 'hi')), + _Row('minimised, away: a join does not', _of(_feed('join')), MemberJoined, context: _minimisedAway, logged: true), + _Row('background: own chat stays in the app', _of(_feed('chat', actor: 'Me', reason: 'hi')), ChatPosted, + context: _background, toast: 'Me: hi', actor: 'Me', logged: true), + // ---- chat pop-ups (Settings): the sound has its own switch + _Row('playing, chat pop-ups All', _of(_feed('chat', reason: 'hi')), ChatPosted, + context: _playing, toast: 'Ann: hi', actor: 'Ann', sound: SfxKind.chat, logged: true), + _Row('playing, chat pop-ups only when paused', _of(_feed('chat', reason: 'hi')), ChatPosted, + context: _chatPaused, sound: SfxKind.chat, logged: true), + _Row('chat pop-ups off: no system notification either', _of(_feed('chat', reason: 'hi')), ChatPosted, + context: _chatOff, sound: SfxKind.chat, logged: true), +]; + +void _check(_Row r) { + expect(r.event.runtimeType, r.type, reason: 'event type'); + final n = noticeFor(r.event, r.context); + expect(n.toast, r.toast, reason: 'toast'); + expect(n.actor, r.actor, reason: 'actor'); + expect(n.sound?.kind, r.sound, reason: 'sound'); + if (r.sound != null) expect(n.sound!.readyIndex, r.readyIndex, reason: 'ready pitch'); + expect(n.attention, r.attention, reason: 'attention'); + expect(n.system == null ? null : (n.system!.title, n.system!.body), r.system, reason: 'system notification'); + final e = r.event; + expect(n.log, r.logged ? same((e as FeedEvent).feed) : isNull, reason: 'activity log'); +} + +/// Records what the Notifier sends out of the window and lets a test +/// click it. +class _FakeSystem implements SystemNotifications { + final shown = <(String, String, String)>[]; + void Function(String payload)? _click; + @override + set onClick(void Function(String payload)? handler) => _click = handler; + void click(String payload) => _click!(payload); + @override + Future show({required String title, required String body, String tag = '', String payload = ''}) async => + shown.add((title, body, payload)); +} + void main() { group('noticeFor', () { for (final r in _rows) { - test(r.name, () { - expect(r.event.runtimeType, r.type, reason: 'event type'); - final n = noticeFor(r.event); - expect(n.toast, r.toast, reason: 'toast'); - expect(n.actor, r.actor, reason: 'actor'); - expect(n.sound?.kind, r.sound, reason: 'sound'); - if (r.sound != null) expect(n.sound!.readyIndex, r.readyIndex, reason: 'ready pitch'); - expect(n.attention, r.attention, reason: 'attention'); - final e = r.event; - expect(n.log, r.logged ? same((e as FeedEvent).feed) : isNull, reason: 'activity log'); - }); + test(r.name, () => _check(r)); } }); + group('noticeFor in context', () { + for (final r in _contextRows) { + test(r.name, () => _check(r)); + } + }); + + test('the queue-add toast carries the item picture', () { + final n = noticeFor(FeedEvent.of(_feed('queue-add', target: 'Ep 6'), selfId: 'm9', queueThumb: 'h1')); + expect(n.thumb, 'h1'); + }); + + group('Notifier', () { + test('in the background: a system notification; a click focuses the window and opens the chat', () async { + final system = _FakeSystem(); + var focused = 0; + final n = Notifier(system: system, focusWindow: () async => focused++); + n.session = () => (self: Presence.watching, alone: false, playing: false); + n.window = WindowActivity.minimised; + n.notify(_of(_feed('chat', reason: 'hi'))); + expect(system.shown, [('Ann', 'hi', SystemNote.payloadChat)]); + expect(n.toasts.peek().single.chat, isTrue); + system.click(SystemNote.payloadChat); + expect(focused, 1); + expect(n.chatRequests.peek(), 1); + // A click on any other one only brings the window up. + n.notify(const PlaybackStarted('Frieren')); + expect(system.shown.last, ('Starsync', 'Starting now: Frieren', '')); + system.click(''); + expect(focused, 2); + expect(n.chatRequests.peek(), 1); + // Focused: nothing leaves the window. + n.window = WindowActivity.focused; + n.notify(_of(_feed('chat', reason: 'again'))); + expect(system.shown, hasLength(2)); + n.clear(); + }); + + test('owns the toast timer: a toast goes after its time, a dismissed one at once', () async { + final n = Notifier(toastLife: const Duration(milliseconds: 300)); + n.notify(const Copied('Link copied')); + n.notify(const ShaderMessage('Shaders off')); + expect(n.toasts.peek().map((t) => t.text), ['Link copied', 'Shaders off']); + n.dismiss(n.toasts.peek().last.id); + expect(n.toasts.peek().map((t) => t.text), ['Link copied']); + await Future.delayed(const Duration(milliseconds: 100)); + expect(n.toasts.peek(), hasLength(1)); + await Future.delayed(const Duration(milliseconds: 300)); + expect(n.toasts.peek(), isEmpty); + }); + + test('reads the session: sitting out silences the party, alone too', () { + final sounds = []; + final n = Notifier(sfx: _Sfx(sounds)); + var self = Presence.watching; + var alone = false; + n.session = () => (self: self, alone: alone, playing: false); + n.notify(_of(_feed('join'))); + self = Presence.away; + n.notify(_of(_feed('join', actor: 'Bob'))); + self = Presence.watching; + alone = true; + n.notify(_of(_feed('ready'))); + expect(n.toasts.peek().map((t) => t.text), ['Ann joined']); + expect(sounds, [SfxKind.join]); + expect(n.feedLog.peek(), hasLength(3), reason: 'everything is logged'); + n.clear(); + }); + }); + test('every concrete AppEvent subtype has a row', () { // Read from the source, so a new event fails here until it gets a row. final src = File('lib/src/session/app_events.dart').readAsStringSync(); @@ -161,3 +354,10 @@ void main() { expect(_rows.map((r) => r.type.toString()).toSet(), declared); }); } + +class _Sfx implements SfxService { + final List played; + _Sfx(this.played); + @override + Future play(SfxCue cue) async => played.add(cue.kind); +} diff --git a/apps/desktop/test/player_status_test.dart b/apps/desktop/test/player_status_test.dart index 59c5ca4..c0b93d1 100644 --- a/apps/desktop/test/player_status_test.dart +++ b/apps/desktop/test/player_status_test.dart @@ -1,5 +1,3 @@ -// restructure: rewrite in step 2 (Status + Notifier): the "status" group -// (one computed level); the title block and overflow checks stay. import 'dart:typed_data'; import 'package:flutter/material.dart'; @@ -8,16 +6,18 @@ import 'package:sync_relay_core/core.dart'; import 'package:starsync/src/controls/frame.dart'; import 'package:starsync/src/controls/minimal/style.dart'; import 'package:starsync/src/controls/minimal/timeline.dart'; -import 'package:starsync/src/session/health.dart'; +import 'package:starsync/src/session/connection.dart'; +import 'package:starsync/src/session/delivery.dart' show MemberNames; +import 'package:starsync/src/session/app_events.dart'; +import 'package:starsync/src/session/status.dart'; import 'package:starsync/src/session/now_playing.dart'; import 'package:starsync/src/shell/now_playing_title.dart'; -import 'package:starsync/src/shell/player_status.dart'; import 'package:starsync/src/shell/session_player.dart'; import 'package:starsync/src/shell/start_banner.dart'; import 'package:starsync/src/state/session_state.dart'; -// The player's title block, start card and status pill (never a media_kit -// `Video`). +// The one computed status (each input → text and level), the player's +// title block, start card and status pill (never a media_kit `Video`). /// A 1x1 transparent PNG. final png = Uint8List.fromList(const [ @@ -38,18 +38,44 @@ MemberView mv(String id, String name, buffered: buffered, presence: gone ? Presence.gone : away ? Presence.away : Presence.watching); -StateBroadcast held(List members, {String transport = 'held', double position = 0, int mediaId = 1}) => +StateBroadcast held(List members, + {String transport = 'held', double position = 0, int mediaId = 1, MediaRelay? relay}) => StateBroadcast( eventId: 1, transport: transport, position: position, serverTime: DateTime.now().toUtc(), mediaId: mediaId, - media: {'name': 'Movie.mkv', 'size': 1, 'duration': 0}, + media: {'name': 'Movie.mkv', 'size': 1, 'duration': 0, if (relay != null) 'relay': relay.toJson()}, members: members, ); -const ok = HealthStatus(HealthSeverity.ok, 'connected'); +final _now = DateTime.utc(2026, 1, 1, 12); + +/// The status of [state] as member [self] sees it, online unless [conn]. +Status st(StateBroadcast? state, + {MemberView? self, + ConnState conn = const ConnOnline(), + String playerError = '', + String playerFault = '', + bool ownPlan = false, + int fetchFailedFor = -1, + MemberNames? names, + DateTime? now}) => + statusOf(StatusFacts( + conn: conn, + state: state, + selfId: self?.id ?? '', + playerError: playerError, + playerFault: playerFault, + ownPlan: ownPlan, + fetchFailedFor: fetchFailedFor, + names: names, + now: now ?? _now, + )); + +StatusLine? line(StateBroadcast? state, {MemberView? self, ConnState conn = const ConnOnline(), String playerError = ''}) => + st(state, self: self, conn: conn, playerError: playerError).line; void main() { final coord = SessionState.instance.coordinator; @@ -57,82 +83,133 @@ void main() { tearDown(() { debugArtBytes = null; coord.serverState.value = null; - coord.health.value = const HealthStatus(HealthSeverity.info, 'idle'); coord.playback.alive.value = false; }); group('status', () { final me = mv('me', 'Me', ready: true); - test('problems first: reconnecting, health', () { - var v = playerStatusFor(health: const HealthStatus(HealthSeverity.warn, 'connection lost — retrying…'), - reconnecting: true); - expect(v!.text, 'Reconnecting…'); - expect(v.action, StatusAction.cancel); - v = playerStatusFor(health: const HealthStatus(HealthSeverity.warn, 'waiting for Bob to pass the video on'), - state: held([me]), self: me); - expect(v!.kind, StatusKind.problem); - expect(v.text, 'Waiting for Bob to pass the video on'); - expect(v.tone, HealthSeverity.warn); - expect(v.action, StatusAction.retry); + test('the connection first: reconnecting, unstable, refused, replaced, connecting', () { + final r = held([me]); + for (final (conn, text, level, action) in [ + (const ConnLost(), 'Reconnecting…', StatusLevel.warn, StatusAction.cancel), + (const ConnReconnecting(2), 'Reconnecting…', StatusLevel.warn, StatusAction.cancel), + (const ConnUnstable(), 'Connection unstable…', StatusLevel.warn, null), + (const ConnRefused('secret', 'wrong password'), 'Wrong password', StatusLevel.error, StatusAction.retry), + (const ConnReplaced(), replacedText, StatusLevel.error, StatusAction.retry), + (const ConnConnecting(), 'Connecting…', StatusLevel.info, null), + ]) { + final v = line(r, self: me, conn: conn)!; + expect((v.text, v.level, v.action), (text, level, action), reason: '$conn'); + } + // Sitting out does not hide the connection. + expect(line(held([mv('me', 'Me', away: true)]), self: mv('me', 'Me', away: true), conn: const ConnLost())!.text, + 'Reconnecting…'); }); - test('info never shows; connecting only once slow', () { - expect(playerStatusFor(health: const HealthStatus(HealthSeverity.info, 'connected — waiting for host media…')), - isNull); - const connecting = HealthStatus(HealthSeverity.info, 'connecting…'); - expect(playerStatusFor(health: connecting), isNull); - expect(playerStatusFor(health: connecting, slowConnect: true)!.text, 'Connecting…'); - expect(playerStatusFor(health: ok, state: held([me], transport: 'playing'), self: me), isNull); - expect(playerStatusFor(health: ok, state: held([me]), self: me), isNull, reason: 'everyone ready'); + test('the party line: connecting, reconnecting with the state kept, the ready count', () { + expect(st(null, conn: const ConnConnecting()).party, 'connecting…'); + final r = held([me, mv('b', 'Bob'), mv('z', 'Zed', gone: true)]); + expect(st(r, self: me, conn: const ConnLost()).party, 'reconnecting…'); + expect(st(r, self: me).party, 'ready 1/2', reason: 'gone is not counted'); + }); + + test('nothing to say: playing, everyone ready, nothing up', () { + expect(line(held([me], transport: 'playing'), self: me), isNull); + expect(line(held([me]), self: me), isNull, reason: 'everyone ready'); + expect(line(null), isNull); }); test('finished, then whoever is loading', () { - expect(playerStatusFor(health: ok, state: held([me], transport: 'finished'), self: me)!.text, - 'Finished · seek back or pick the next video'); + expect(line(held([me], transport: 'finished'), self: me)!.text, 'Finished · seek back or pick the next video'); final slow = mv('b', 'Bob', ready: true, buffered: RelayConstants.minBufferSecs / 2); - final v = playerStatusFor(health: ok, state: held([me, slow, mv('c', 'Cleo')]), self: me)!; + final v = line(held([me, slow, mv('c', 'Cleo')]), self: me)!; expect(v.kind, StatusKind.loading); expect(v.text, 'Bob is loading…'); expect(v.who.single.id, 'b'); expect(v.progress, closeTo(0.5, 1e-9)); final mine = mv('me', 'Me', ready: true, buffered: 0); - expect(playerStatusFor(health: ok, state: held([mine]), self: mine)!.text, 'Loading the video…'); + expect(line(held([mine]), self: mine)!.text, 'Loading the video…'); }); test('you, then the others with a ready count', () { final notMe = mv('me', 'Me'); - final v = playerStatusFor(health: ok, state: held([notMe, mv('b', 'Bob')]), self: notMe)!; + final v = line(held([notMe, mv('b', 'Bob')]), self: notMe)!; expect(v.kind, StatusKind.you); expect(v.text, "Press Space when you're ready"); expect(v.action, StatusAction.ready); expect(v.self, isTrue); - final w = playerStatusFor( - health: ok, - state: held([me, mv('b', 'Bob'), mv('m', 'Mika'), mv('z', 'Zed', gone: true), mv('o', 'Ola', away: true)]), + final w = line(held([me, mv('b', 'Bob'), mv('m', 'Mika'), mv('z', 'Zed', gone: true), mv('o', 'Ola', away: true)]), self: me)!; expect(w.kind, StatusKind.waiting); expect(w.text, 'Waiting for Bob and Mika', reason: 'nobody waits for who sits out'); expect(w.detail, '1/3 ready'); final outMe = mv('me', 'Me', away: true); - expect(playerStatusFor(health: ok, state: held([outMe, mv('b', 'Bob')]), self: outMe), isNull, - reason: 'sitting out: the join-back card speaks'); + expect(line(held([outMe, mv('b', 'Bob')]), self: outMe), isNull, reason: 'sitting out: the join-back card speaks'); expect(w.who.map((m) => m.name), ['Bob', 'Mika']); }); - test("can't play: yours with Retry (reload); others' instead of waiting", () { - var v = playerStatusFor(health: ok, state: held([me]), self: me, playerError: 'Not allowed (403)')!; - expect((v.kind, v.text, v.action), (StatusKind.problem, "Can't play this: Not allowed (403)", StatusAction.reload)); - final bob = MemberView(id: 'b', name: 'Bob', ready: false, inSet: false, buffered: 0, - error: 'Not allowed (403)'); - v = playerStatusFor(health: ok, state: held([me, bob, mv('m', 'Mika', ready: true)]), self: me)!; + test("can't play: yours with Reload, a broken player too; others' instead of waiting", () { + var v = line(held([me]), self: me, playerError: 'Not allowed (403)')!; + expect((v.kind, v.text, v.action, v.level), + (StatusKind.problem, "Can't play this: Not allowed (403)", StatusAction.reload, StatusLevel.error)); + v = st(held([me]), self: me, playerFault: 'video reload failed: gone').line!; + expect((v.text, v.action), ('Video reload failed: gone', StatusAction.reload)); + final bob = MemberView(id: 'b', name: 'Bob', ready: false, inSet: false, buffered: 0, error: 'Not allowed (403)'); + v = line(held([me, bob, mv('m', 'Mika', ready: true)]), self: me)!; expect((v.kind, v.text, v.action), (StatusKind.cannotPlay, "Bob can't play this: Not allowed (403)", null)); expect(v.who.single.id, 'b'); final notMe = mv('me', 'Me'); - v = playerStatusFor(health: ok, state: held([notMe, bob]), self: notMe)!; + v = line(held([notMe, bob]), self: notMe)!; expect((v.kind, v.action), (StatusKind.cannotPlay, StatusAction.ready)); }); + group('the relay', () { + final bob = mv('b', 'Bob', ready: true); + test('the server fetching, then failing', () { + final r = held([me, bob], relay: const MediaRelay(fetch: true)); + expect((line(r, self: me)!.kind, line(r, self: me)!.text), + (StatusKind.fetching, 'The server is getting the video…')); + final failed = st(r, self: me, fetchFailedFor: 1).line!; + expect((failed.text, failed.level), ("The server couldn't fetch this video", StatusLevel.error)); + }); + + test('waiting for the member passing it on', () { + final v = line(held([me, bob], relay: const MediaRelay(source: 'b')), self: me)!; + expect((v.text, v.level), ('Waiting for Bob to pass the video on', StatusLevel.warn)); + expect(line(held([me, bob], relay: const MediaRelay(source: 'b', live: true)), self: me), isNull); + }); + + test("the source's connection dropped: what the server has, and for how long; even while playing", () { + final gone = MemberView(id: 'b', name: 'Bob', ready: true, inSet: false, buffered: 30, presence: Presence.gone); + final since = _now.subtract(const Duration(seconds: 20)); + final r = held([me, gone], + transport: 'playing', + relay: MediaRelay(source: 'b', live: true, sourceGone: true, sourceGoneSince: since)); + final v = st(r, self: me); + expect((v.line!.text, v.line!.level), ("Bob's connection dropped — playing what the server has", StatusLevel.warn)); + expect(v.source!.left, isFalse); + expect(st(r, self: me, now: _now.add(const Duration(minutes: 2))).line!.text, + "Bob's connection dropped — playing what the server has (2 min)"); + expect(v.delivery!.title, "Bob's connection dropped — playing what the server has"); + // With its own Jellyfin account this viewer does not read the relay. + expect(st(r, self: me, ownPlan: true).line, isNull); + }); + + test('the source left: named from before', () { + final names = MemberNames()..observe(held([me, bob])); + final r = held([me], transport: 'playing', relay: const MediaRelay(source: 'b', sourceGone: true)); + final v = st(r, self: me, names: names); + expect(v.line!.text, 'Bob left — playing what the server has'); + expect(v.source!.left, isTrue); + }); + + test('the source itself sees none of it', () { + final r = held([me, bob], relay: const MediaRelay(source: 'me')); + expect(line(r, self: me), isNull); + }); + }); + test('names read as a sentence', () { expect(nameList(['Bob']), 'Bob'); expect(nameList(['Bob', 'Mika']), 'Bob and Mika'); @@ -141,6 +218,46 @@ void main() { }); }); + group('status transitions', () { + final me = mv('me', 'Me', ready: true); + final gone = MemberView(id: 'b', name: 'Bob', ready: true, inSet: false, buffered: 30, presence: Presence.gone); + + test('the source gone a few seconds: one notice; left: another', () { + final w = StatusWatch(); + final r = held([me, gone], + transport: 'playing', relay: MediaRelay(source: 'b', live: true, sourceGone: true, sourceGoneSince: _now)); + expect(w.step(st(r, self: me), _now.add(const Duration(seconds: 2))), isEmpty, reason: 'a quick drop says nothing'); + final at = _now.add(sourceDropNotice); + expect(w.step(st(r, self: me), at).single, isA().having((e) => e.name, 'name', 'Bob')); + expect(w.step(st(r, self: me), at.add(const Duration(seconds: 1))), isEmpty, reason: 'once'); + final names = MemberNames()..observe(r); + final left = held([me], transport: 'playing', relay: const MediaRelay(source: 'b', sourceGone: true)); + expect(w.step(st(left, self: me, names: names), at).single, + isA().having((e) => e.left, 'left', isTrue)); + }); + + test('starting now: the first start of a media seen held, not a resume or a join mid-way', () { + final w = StatusWatch(); + expect(w.step(st(held([me], transport: 'playing', mediaId: 7), self: me), _now), isEmpty, + reason: 'joined while it played'); + w.step(st(held([me]), self: me), _now); + final started = w.step(st(held([me], transport: 'playing'), self: me), _now); + expect(started.single, isA().having((e) => e.title, 'title', isNotEmpty)); + w.step(st(held([me]), self: me), _now); + expect(w.step(st(held([me], transport: 'playing'), self: me), _now), isEmpty, reason: 'a resume'); + }); + + test('replaced and a restarted server, once each', () { + final w = StatusWatch(); + w.step(statusOf(StatusFacts(conn: const ConnOnline(), now: _now)), _now); + expect(w.step(statusOf(StatusFacts(conn: const ConnReplaced(), now: _now)), _now).single, isA()); + expect(w.step(statusOf(StatusFacts(conn: const ConnReplaced(), now: _now)), _now), isEmpty); + expect(w.step(statusOf(StatusFacts(conn: const ConnOnline(), restarts: 1, now: _now)), _now).single, + isA()); + expect(w.step(statusOf(StatusFacts(conn: const ConnOnline(), restarts: 1, now: _now)), _now), isEmpty); + }); + }); + group('title block', () { Widget host(Widget child, {double width = 400}) => MaterialApp( home: Scaffold(backgroundColor: Colors.black, body: Center(child: SizedBox(width: width, child: child))), diff --git a/apps/desktop/test/relay_e2e_test.dart b/apps/desktop/test/relay_e2e_test.dart index b83dd2d..2e19c85 100644 --- a/apps/desktop/test/relay_e2e_test.dart +++ b/apps/desktop/test/relay_e2e_test.dart @@ -206,7 +206,7 @@ void main() { await _poll(() => stateMediaRelay(alice.serverState.peek()!)?.source.isNotEmpty ?? false, 'relay state'); await Future.delayed(const Duration(seconds: 7)); expect(alice.playerError.peek(), isEmpty, reason: 'no "could not be opened" while the source is on its way'); - expect(alice.health.peek().message, contains('waiting for bob')); + expect(alice.status.peek().line?.text, 'Waiting for bob to pass the video on'); final src = RelaySource(Uri.parse('ws://127.0.0.1:${server.boundPort}/r/default/$token/source'), RelayMedia.single(FileRelayBytes(File(file)))); await src.start(); diff --git a/apps/desktop/test/shell_test.dart b/apps/desktop/test/shell_test.dart index 56741b1..bfbe17c 100644 --- a/apps/desktop/test/shell_test.dart +++ b/apps/desktop/test/shell_test.dart @@ -1,9 +1,14 @@ -// restructure: rewrite in step 2 (Status + Notifier): toasts and the status -// pill; in step 3 (Your server): the solo strip and Invite friends. +// restructure: rewrite in step 3 (Your server): the solo strip and Invite +// friends. import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:media_kit_video/media_kit_video.dart' show VideoState; import 'package:sync_relay_core/core.dart'; +import 'package:starsync/src/controls/frame.dart' show ControlsFrame, ControlsFrameState; +import 'package:starsync/src/controls/model/controls_prefs.dart'; +import 'package:starsync/src/controls/session_controls.dart' show controlsBottomHeight; +import 'package:starsync/src/session/notifier.dart' show Copied; import 'package:starsync/src/session/identity.dart'; import 'package:starsync/src/shell/app_shell.dart'; import 'package:starsync/src/shell/avatar.dart'; @@ -15,6 +20,7 @@ import 'package:starsync/src/shell/settings_tab.dart' show SettingsTab; import 'package:starsync/src/shell/toasts.dart'; import 'package:starsync/src/session/session_coordinator.dart' show ToastItem; import 'package:starsync/src/state/session_state.dart'; +import 'package:starsync/src/state/settings.dart'; import 'package:starsync/src/update/update_controller.dart'; /// Stands in for media_kit's `Video` (which hangs flutter_test) and @@ -38,13 +44,14 @@ class _FakeVideoState extends State { Widget build(BuildContext context) => const ColoredBox(color: Colors.black); } -MemberView member(String id, String name, {bool ready = false, bool gone = false}) => +MemberView member(String id, String name, {bool ready = false, bool gone = false, String app = ''}) => MemberView( id: id, name: name, ready: ready, inSet: ready, buffered: 30, + app: app, presence: gone ? Presence.gone : Presence.watching); void main() { @@ -73,10 +80,10 @@ void main() { session.route.value = AppRoute.home; coord.serverState.value = null; coord.playback.alive.value = false; - coord.toasts.value = []; + session.notifier.clear(); }); - Future pumpShell(WidgetTester tester, Size size, {UpdateController? updates}) async { + Future pumpShell(WidgetTester tester, Size size, {UpdateController? updates, VideoViewBuilder? video}) async { tester.view.physicalSize = size; tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); @@ -86,7 +93,7 @@ void main() { updates: updates ?? UpdateController(currentVersion: '0.0.0-dev'), autoUpdateCheck: false, windowControls: false, - videoBuilder: (context, controls) => const FakeVideo(), + videoBuilder: video ?? (context, controls) => const FakeVideo(), // A real probe (network, yt-dlp) never answers on the test clock. linkProbe: (url) async => LinkProbe.file(Uri.parse(url), 1000), ), @@ -104,11 +111,18 @@ void main() { ]; group('version and update bar', () { - testWidgets('the version under Settings; the bar on the tabs, off the full player until the video ends', - (tester) async { + testWidgets('the version at the bottom of Settings, not in the strip; the bar on the tabs, off the full player ' + 'until the video ends', (tester) async { final updates = UpdateController(currentVersion: '0.0.0-dev'); await pumpShell(tester, const Size(1280, 800), updates: updates); - expect(find.text('dev run'), findsOneWidget); + expect(find.byKey(const ValueKey('strip-version')), findsNothing); + expect(find.textContaining('dev run'), findsNothing); + await tester.tap(find.byKey(const ValueKey('tab-settings'))); + await tester.pumpAndSettle(); + expect( + tester.widget(find.byKey(const ValueKey('settings-version'))).data, contains('dev run')); + await tester.tap(find.byKey(const ValueKey('tab-library'))); + await tester.pumpAndSettle(); expect(find.byKey(const ValueKey('update-bar')), findsNothing); updates.status.value = const UpdateStatus(UpdatePhase.available, manifest: UpdateManifest(version: '9.9.9', protocol: 1, url: 'http://x/a.tgz', notes: ''), @@ -207,7 +221,9 @@ void main() { group('popovers', () { testWidgets('members card opens from the avatars and closes outside', (tester) async { await pumpShell(tester, const Size(1280, 800)); - online(party, media: false); + session.serverVersion.value = '1.3.0-dev.1'; + addTearDown(() => session.serverVersion.value = ''); + online([member('a', 'Alex', ready: true, app: '1.3.0-dev.1'), ...party.skip(1)], media: false); await tester.pumpAndSettle(); await tester.tap(find.byKey(const ValueKey('avatar-stack'))); await tester.pumpAndSettle(); @@ -217,6 +233,10 @@ void main() { expect(find.descendant(of: card, matching: find.text(m.name)), findsOneWidget); } expect(find.descendant(of: card, matching: find.text('reconnecting…')), findsOneWidget); + // Each member's version in its row, the server's in the header. + expect(find.descendant(of: card, matching: find.byKey(const ValueKey('version-a'))), findsOneWidget); + expect(find.descendant(of: card, matching: find.text('1.3.0-dev.1')), findsOneWidget); + expect(find.descendant(of: card, matching: find.text('Server 1.3.0-dev.1')), findsOneWidget); // Someone else: nudge or sit them out (no kicking). await tester.tap(find.descendant(of: find.byKey(const ValueKey('member-b')), matching: find.byTooltip('More'))); await tester.pumpAndSettle(); @@ -386,11 +406,18 @@ void main() { expect(find.text('1/3 ready'), findsOneWidget); }); + test('a version that is not ours stands out (a dev run matches any)', () { + expect(versionDiffers('1.3.0', own: '1.3.0'), isFalse); + expect(versionDiffers('1.2.4', own: '1.3.0'), isTrue); + expect(versionDiffers('', own: '1.3.0'), isFalse); + expect(versionDiffers('1.2.4', own: '0.0.0-dev'), isFalse); + }); + test('toasts: feed toasts carry the actor, the rest are the app\'s', () { AvatarInfo who(String n) => AvatarInfo(name: n, color: Colors.teal); final at = DateTime.now(); // The actor is exact: "Bob" is not mistaken for "Bob Jr". - final chat = shellToastFor(ToastItem(id: 1, text: 'Bob Jr: hi there', at: at, actor: 'Bob Jr'), who); + final chat = shellToastFor(ToastItem(id: 1, text: 'Bob Jr: hi there', at: at, actor: 'Bob Jr', chat: true), who); expect(chat.who!.name, 'Bob Jr'); expect(chat.text, 'hi there'); expect(chat.chat, isTrue); @@ -404,16 +431,62 @@ void main() { expect(app.text, 'Bob joined'); final notice = shellToastFor(ToastItem(id: 4, text: 'Shaders off', at: at), who); expect(notice.who, isNull); + // An Up next add: who, what, and the item's picture. + final added = shellToastFor( + ToastItem(id: 5, text: 'Bob added Ep 6 to Up next', at: at, actor: 'Bob', thumb: 'h1'), who); + expect((added.who!.name, added.text, added.icon, added.thumb), + ('Bob', 'added Ep 6 to Up next', Icons.playlist_add_rounded, 'h1')); }); - testWidgets('toasts slide in with an avatar and leave after their time', (tester) async { - await tester.pumpWidget(const MaterialApp(home: Scaffold(body: PartyToasts(life: Duration(seconds: 1))))); - coord.toasts.value = [ToastItem(id: 1, text: 'Shaders off', at: DateTime.now())]; + testWidgets("toasts slide in and leave when the Notifier's time is up", (tester) async { + await tester.pumpWidget(const MaterialApp(home: Scaffold(body: PartyToasts()))); + session.notifier.notify(const Copied('Shaders off')); await tester.pump(); await tester.pump(); expect(find.text('Shaders off'), findsOneWidget); - await tester.pump(const Duration(seconds: 1)); + await tester.pump(session.notifier.toastLife); await tester.pumpAndSettle(); expect(find.text('Shaders off'), findsNothing); }); + + for (final skin in ControlsSkin.values) { + testWidgets('toasts sit above the control bar and follow it (${skin.name})', (tester) async { + final s = AppSettings.instance; + final hide = s.controlsHideSecs.peek(); + s.controlsSkin.value = skin; + s.controlsHideSecs.value = 2; + addTearDown(() { + s.controlsSkin.value = ControlsSkin.standard; + s.controlsHideSecs.value = hide; + }); + coord.playback.alive.value = true; + await pumpShell(tester, const Size(1280, 800), + video: (context, controls) => Stack(fit: StackFit.expand, children: [ + const ColoredBox(color: Colors.black), + if (controls != null) controls(VideoState()), + ])); + online(party); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + expect(tester.state(find.byType(AppShell)).mode, PlayerMode.full); + expect(tester.state(find.byType(ControlsFrame)).visible, isTrue); + session.notifier.notify(const Copied('Link copied')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + final body = tester.getRect(find.byKey(const ValueKey('body'))); + final bar = controlsBottomHeight(skin, s.controlsSize.peek()); + final up = tester.getRect(find.byType(ToastCard)); + expect(up.bottom, lessThanOrEqualTo(body.bottom - bar), reason: 'clear of the control bar'); + // The controls hide: the toasts come down to the edge. + await tester.pump(const Duration(seconds: 2)); + await tester.pump(const Duration(milliseconds: 600)); + expect(tester.state(find.byType(ControlsFrame)).visible, isFalse); + final down = tester.getRect(find.byType(ToastCard)); + expect(down.bottom, closeTo(body.bottom - AppShellState.toastInset, 1)); + expect(down.bottom, greaterThan(up.bottom)); + session.route.value = AppRoute.home; + await tester.pump(const Duration(seconds: 6)); + await tester.pumpAndSettle(); + }); + } } diff --git a/apps/desktop/windows/flutter/generated_plugins.cmake b/apps/desktop/windows/flutter/generated_plugins.cmake index ab1568f..983663f 100644 --- a/apps/desktop/windows/flutter/generated_plugins.cmake +++ b/apps/desktop/windows/flutter/generated_plugins.cmake @@ -11,6 +11,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + flutter_local_notifications_windows flutter_soloud jni ) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 537640e..7eae996 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -316,11 +316,11 @@ always an older host), `secret` a wrong password, `replaced` "Starsync was opened in another window". Close code 4015 (the server found it silent) and any other drop reconnect. -The coordinator maps the state to `health` (`connectionHealth`: -"connecting…", "connected", "connection unstable…", "connection lost — -retrying…", "reconnecting…", the reason; closed writes nothing, a cancel -writes "cancelled", "Retry" "retrying…"), to `reconnecting` (lost or -reconnecting), `unstable` and `stateStale`. A drop that lasted +The state is the first input of the session's status (see "App events +and platform ports": "Connecting…", "Reconnecting…" with Cancel, +"Connection unstable…", the refusal or "replaced" with Retry; the strip's +"reconnecting…"); the coordinator also reads it as `reconnecting` (lost +or reconnecting), `unstable` and `stateStale`. A drop that lasted `lostSoundAfter` (4 s; a silent link counts from its last frame) raises `ConnectionLost`, the reconnect that follows `ConnectionBack` (each once per drop, through the Notifier): a quick flap makes no sound. @@ -786,11 +786,13 @@ How bytes move: 4002 and ends the relay (`ServerHub.relayOtherMedia`); a second channel replaces the first (4003). The source's leave (grace over, `bye`) or a swap ends the relay: its buffer goes, `live` drops, `/r` answers 404. - `state.media.relay.live` follows the channel, and viewers show "waiting - for to pass the video on" while it is down, " lost - signal: playing what the server has" while the source is `gone` (a - viewer opening then opens the buffer instead of waiting), " left: - playing what the server has" once the source left the server. + `state.media.relay.live` follows the channel. Viewers' status (see + "App events and platform ports") says "Waiting for to pass the + video on" while it is down, "'s connection dropped — playing what + the server has (2 min)" while the source is `gone` (from `sourceGoneSince`; + a viewer opening then opens the buffer instead of waiting) and " + left — playing what the server has" once it left the server; the source + gone for 5 s (`sourceDropNotice`) or leaving is also a toast, once each. - Stats (`relay_stats.dart`, `ServerHub.sampleRates` on the 1 s heartbeat; wire in [PROTOCOL.md](PROTOCOL.md)): the source's rate is its bytes over the time a pull is in flight (`FeedClock`, one per media @@ -807,8 +809,9 @@ How bytes move: fetch, Jellyfin), each member's route (source; via server: relayed, `/m`, or Jellyfin `jf` false; direct: links, Jellyfin `jf` true) and buffer health (enough for `bufferNeed`, low, empty while playing). The - members card (`shell/delivery_line.dart`) shows it as a source line and - a dot per member; nothing goes on the picture. The source gets one toast + status carries it (`Status.delivery`) and the members card + (`shell/delivery_line.dart`) shows it as a source line and a dot per + member; nothing goes on the picture. The source gets one toast per media while `rate` < `need`. Not shown: a direct member's speed (the server never sees its bytes). @@ -1219,32 +1222,97 @@ every `state` and feed), the pure rules in `queue_logic.dart`, the UI in ## App events and platform ports +**Status.** What the session is like right now is one computed value, +`SessionCoordinator.status` (`session/status.dart`, `statusOf` over +`StatusFacts`; nothing sets it): the connection's `ConnState`, the +server's state (members and self presence, `state.hold` through who is +in the set and their buffers, the relay's `live`, `fetch`, `sourceGone` / +`sourceGoneSince`), this player's facts (`playerError`: it cannot open the +media; `playerFault`: a "Reload video" failed; `jellyfinPlan`: it plays a +Jellyfin item with its own account, so the relay is not its concern), +`fetchFailedFor` (the server's `fetch-fail` for the current media), +`restarts` (reconnects that found another `welcome.instance`) and a clock +that moves by the minute. `Status.line` is the player's pill (most +pressing first: reconnecting with Cancel, unstable, refused or replaced +with Retry, connecting, your player's fault or error with Reload, the +server getting the video, the relay's source dropped / left / not +passing it on yet, finished, someone loading, someone who cannot play +it, you not ready, who everyone waits for; nothing while it plays and +everyone is fine, and only connection lines while you sit out), each +with a `StatusLevel` (ok and info never colour anything, warn amber, +error red). `Status.party` is the strip's line ("connecting…", +"reconnecting…", "ready 2/3"), `Status.delivery` the members card's +source line ("Relay", Stats). `StatusWatch` turns the transitions worth a +notice into events (Status → Notifier, never back): the relay's source +gone for 5 s or left, another window took over (`Replaced`), the server +restarted, a media starting for the first time (`PlaybackStarted`, seen +held before); the coordinator runs it on every status change and every +second. + **Events.** Everything that may become a toast, a sound, a window -attention request or an activity-log line is an `AppEvent` -(`session/app_events.dart`, a sealed type, pure Dart): the server feed as -typed `FeedEvent`s (`FeedEvent.of`, one per feed action: joins, leaves -(`bye` or `grace`), gone (lost signal), return, sat out (`away`: by -oneself, by someone else, or by this app's auto sit-out), back (`in`), -ready/unready, force ready/play/hold, swap, seek, pause, resume, -finished, chat, rename, avatar, queue add/remove/skip, fetch failed; -people are matched by `actorId` / `targetId`, chat by `fromId`, never by -name), the session's own (nudged, a member can't play, slow source, relay -started/failed, connection lost/back, Jellyfin progress not saved) and the app's (shader message, added to Up next, a put-up or -queue add that failed, Schedule results, problem marked, copied / copy -failed, update available). Code with news calls -`SessionCoordinator.notify` (= `Notifier.notify`, `session/notifier.dart`); -nothing else writes toasts or plays sounds (Settings' sound preview -aside). `noticeFor` is the whole policy, one `Notice` per event: the -toast text and its actor, the `SfxCue`, attention, the feed line for the -activity log. Today: every feed line is logged (`Notifier.feedLog`, the -last 100); lines a reconnect replayed (`replayed`) are only logged; -joins, leaves, swaps, chat, being sat out by someone else, being sat out -by auto sit-out and Up next skips toast (a toast keeps its actor's member -id, so its avatar is found by id); the sounds are listed under "Sounds"; a nudge also -asks for attention; an available update does nothing (the update band -shows it). `Notifier.toasts` holds the toasts, dropped 4 s after they came -(`expire`, the session's 1 s tick), cleared on connect. The Library -tab's card errors are still its own snack bars. +attention request, a system notification or an activity-log line is an +`AppEvent` (`session/app_events.dart`, a sealed type, pure Dart): the +server feed as typed `FeedEvent`s (`FeedEvent.of`, one per feed action: +joins, leaves (`bye` or `grace`), gone (lost signal), return, sat out +(`away`: by oneself, by someone else, or by this app's auto sit-out), back +(`in`), ready/unready, force ready/play/hold, swap, seek, pause, resume, +finished, chat, rename, avatar, queue add (with the item's picture from +the state that came first)/remove/skip, fetch failed; `own` when this +member is the actor; people are matched by `actorId` / `targetId`, never +by name), the session's (nudged, a member can't play, slow source, relay +started/failed, the source dropped, starting now, connection lost/back, +server restarted, replaced, a server refusal, Jellyfin progress not +saved) and the app's (shader message, added to Up next, a put-up or +queue add that failed (the Library's card errors too), Schedule results, +problem marked, copied / copy failed, update available). Code with news +calls `SessionCoordinator.notify` (= `Notifier.notify`, +`session/notifier.dart`); nothing else writes toasts, plays sounds or +sends system notifications (Settings' sound preview aside). + +**Notifier policy.** `noticeFor(event, NoticeContext)` is the whole +policy, one `Notice` per event: its `NoticeCategory` (chat, nudge, start, +upNext, people, party, problem, self, app), the toast text, its actor and +picture, the `SfxCue`, attention, the system notification, the feed line +for the activity log. The context: self presence (in, away, gone), alone +(core's `isAlone` over the others' presence), the window +(`WindowActivity` from `ShellPort.watchActivity`, wired in `main`), +playing, the user's choices (`NotifyPrefs`: chat pop-ups All / Only when +paused / Off, and `notifyPeople`, joins and leaves while away or in the +background, default off). The coordinator gives the session's part +(`Notifier.session`). The rules (D1, D4, D9): +- Every feed line is logged (`Notifier.feedLog`, the last 100); lines a + reconnect replayed (`replayed`) are only logged. +- Toasts (D4): joins, leaves, lost connection (after the server's + delayed `gone` line) and returns, swaps, Up next adds (title, the + item's picture, who) and skips, chat, being sat out by someone else or + by auto sit-out, nudges, problems (someone can't play it, the source + dropped, a slow or failed relay of yours, a server refusal, the server + restarted, replaced) and the app's notices; the rest (ready, seeks, + pauses) only in the activity log. Your own joins, swaps and Up next + adds pop up nothing (the app already said so); your own chat pops up + without a sound. +- Sitting out (D1): only chat, nudges, "starting now", Up next, your own + and the app's notices, plus joins and leaves when chosen; the rest is + logged without a sound. +- Alone (D9): no social toasts or sounds (people, chat, ready and the + like, "starting now"); the app's notices, problems and your own show. +- "Starting now" pops up only while you sit out or the window is in the + background. +- Chat pop-ups follow the setting (it is in the chat either way); the + chat sound has its own switch. +- The window in the background (unfocused, minimised, hidden): what + reaches a member who sits out (chat, nudges, "starting now", Up next, + joins and leaves when chosen) also goes out as a system notification + (title the person, body the rest; silent, the app plays its sound). A + click brings the window up (`ShellPort.focus`) and, for chat, opens the + chat (`Notifier.chatRequests`, read by the shell). +- A nudge also asks for attention; an available update makes no notice + (the update band shows it, off the full player while a video is on). +- The unread badge counts chat in the log (never on the picture while it + plays). +The Notifier owns its toasts' timer: `Notifier.toasts` holds what is on +screen, each dropped `toastLife` (5 s) after it came (`dismiss` earlier, a +click on a chat toast); cleared on connect. **Ports.** UI and session code reach the platform through interfaces (`platform/ports/`), made once in the composition root @@ -1256,7 +1324,8 @@ handed on from there (the coordinator gets its `Notifier`, `Connection` and and their changes, the window's activity for auto sit-out (`watchActivity`: focused, unfocused, minimised, hidden; window_manager's focus, blur, minimise and restore, and the shell's own `hide`, wired to - `SessionCoordinator.windowActivity` in `main`), dragging (`WindowDragArea`), close, the way out + `SessionCoordinator.windowActivity` and `Notifier.window` in `main`), + bringing the window up and focusing it (`focus`), dragging (`WindowDragArea`), close, the way out (save, hide, quit), attention, the clipboard paste (files, else text) and drop targets (fox_utils). - `UrlOpener` (`DesktopUrls`): `xdg-open`, `open`, Windows' `url.dll`. @@ -1265,8 +1334,15 @@ handed on from there (the coordinator gets its `Notifier`, `Connection` and folder in the file manager. Paths for now. - `LinkResolver` (`AppYtDlp`): the app's yt-dlp, link probes, mpv's raw options, warm-up. -- `SystemNotifications`: an interface only; desktop shows none - (`NoSystemNotifications`). +- `SystemNotifications` (`DesktopNotifications`, + `platform/desktop/desktop_notifications.dart`): `show(title, body, tag, + payload)` and `onClick(payload)`, through flutter_local_notifications' + platform plugins, picked by the OS: Linux `org.freedesktop.Notifications` + over D-Bus (pure Dart; the bundled 256 px icon, else the launcher + entry's icon by name; a click is the default action), Windows toast + notifications (WinRT, app id `Foxyz.Starsync`; an unpackaged app keeps + no history, a click activates it while it runs). Silent; anything that + fails (no notification daemon) is logged and dropped. - `AppDirsPort` (`DesktopAppDirs`): where Hive and the logs live (`AppDirs`, "Desktop UI"), and the copy of the data from before the rename; resolved in `main` before the composition root exists, so not a @@ -1319,19 +1395,22 @@ panel) and chat messages (Copy message, and a link in it). - **Party strip** (`party_strip.dart`, 60 px): your avatar (profile card; 42 px like the party's), the Library / Schedule / Settings tabs with a growing underline (an - `HScroll` when narrow; the version in small text under Settings, - `shortVersion`, the full one in its tooltip), then offline a grey dot, + `HScroll` when narrow; the version in the Settings tab's tooltip, at + the bottom of every Settings page and in About), then offline a grey dot, "Offline", Join a friend and Host (connect cards; icon-less "Join" and "Host" under 1100 px), solo a "Solo · only you" chip, Invite friends (the Host card, titled "Invite friends", "Start hosting") and Stop (no dialog; no avatars or chat), online the party: `AvatarStack` (host first, then ready; spreads on hover; 5 shown, 3 under 1100 px), the server name and - "ready n/m" (who the ready counter counts: `countsForReady` in - `session/party.dart`, on `PresencePolicy`, so not away, gone or - errored; hidden under 1100 px), chat with an unread badge, and Leave - (asks first). The stack opens the members card (the source line, then - per member status, route and buffer dot (see "Relay", Stats), host / - relay pills; "joining…", "sitting out", or for a `gone` member + `Status.party` ("ready n/m": who the ready counter counts, + `countsForReady` in `session/party.dart`, on `PresencePolicy`, so not + away, gone or errored; "connecting…", "reconnecting…"; hidden under + 1100 px), chat with an unread badge, and Leave (asks first). The stack + opens the members card (the server's version under its title: a hosted + server is this app's, a joined one's from `/health.version`; the source + line, then per member status, route and buffer dot (see "Relay", + Stats), their Starsync version (`members[].app`, amber when it is not + this app's; also in the avatar's hover), host / relay pills; "joining…", "sitting out", or for a `gone` member "reconnecting… (2 min)" with how long; per member force ready, Nudge and Sit out (anyone, the host too), your own row Sit out / Join back; "Copy invite address": the dialled address when joined; hosting, `inviteAddresses` @@ -1456,7 +1535,8 @@ panel) and chat messages (Copy message, and a link in it). shader files), Jellyfin & integrations (linked servers, AnimeFlow's `/me` via `AnimeFlowClient.fromAccounts`, auto-advance), notifications & sounds - (what pops up; no switches), updates, shortcuts, about. + (what pops up, chat pop-ups, joins and leaves while sitting out or in + the background), updates, shortcuts, about. - **Drop and paste** (`handoff.dart`, `fox_utils`): the shell is one `DropTarget` (a "Drop to open" overlay while dragging) and a `HardwareKeyboard` handler for Ctrl+V outside a text field (clipboard @@ -1474,7 +1554,9 @@ panel) and chat messages (Copy message, and a link in it). events), being sat out by someone (the `kick` sound), a nudge (always, whatever its switch), auto sit-out setting you away (`warn`), and connection lost / back (once per drop that lasted a few seconds). - Nothing for what the video already says (play, pause, seeks, the end). + Nothing for what the video already says (play, pause, seeks, the end), + nothing about the party while watching alone, and while sitting out + only for what still reaches you (see "Notifier policy"). The sounds are synthesized, not sampled: `synth.dart` renders voices (plucked strings with bends, glass and star bells, a singing bowl, hollow knocks, wooden clacks, filtered-noise air and stardust, a long @@ -1528,9 +1610,10 @@ panel) and chat messages (Copy message, and a link in it). big title and episode; it fades out on play and stays gone for that media; under 320 px tall (the mini) it drops the cover and shrinks the title. One status pill (`player_status.dart`) sits centred in the - action area (see "Player overlay"): `playerStatusFor` (pure) picks the most pressing of - removed, reconnecting (Cancel), a warn/error health message (Retry), - connecting (only after 2 s), finished, someone loading (below + action area (see "Player overlay"): it shows `Status.line` (see "App + events and platform ports"), connecting only after 2 s: reconnecting + (Cancel), refused or replaced (Retry), your player's problem (Reload), + the relay, finished, someone loading (below `StateBroadcast.bufferNeed`, the gate's own need at the current position: less near the end, none at finished; their avatar with the progress round it), you not ready (a click marks you ready) and who everyone waits for (avatars, @@ -1593,13 +1676,17 @@ panel) and chat messages (Copy message, and a link in it). picture). **Toasts** (`toasts.dart`): the `Notifier`'s toasts bottom left, with the avatar of their `ToastItem.actor` (feed and chat toasts; the app's - own have none); each - slides in, stays 5 s, then slides out and collapses its gap. A click on - a chat toast opens the chat; the others let clicks through. Chat toasts - follow `AppSettings.chatPopups` (Settings → Notifications: All - (default), Only when paused, Off); the message is in the chat either - way, and its sound plays whatever this setting (its own switch in - Sounds). + own have none) and an Up next item's picture (`ToastItem.thumb`, from + the queue's picture cache); each slides in, stays while the Notifier + keeps it (5 s), then slides out and collapses its gap. A click on a chat + toast opens the chat; the others let clicks through. They never cover + the player's controls: over the full player (both skins, windowed and + fullscreen) they sit `toastInset` (16 px) above the control bar + (`controlsBottomHeight` of the skin and size in use) while the controls + are up and come down to 16 px from the edge while they are hidden + (`AnimatedPositioned`, the shell's `ease`; the player reports its + controls through the shell's `controlsShown`); elsewhere 16 px from the + bottom left. What pops up at all is the Notifier's policy. Settings persist in two Hive boxes: `connect_form` (last form, recent servers and links, the profile picture and colour; saved even when a join @@ -1929,9 +2016,10 @@ dedicated server (`package:logging` loggers named by area: `relay`, ## Health reporting Every failure must show in the UI with a cause and a next step; nothing -stalls silently. The app has a `HealthStatus` signal (severity + message) -for connecting, reconnecting, wrong password, version mismatch, -host offline, and starvation ("waiting for X - N.N s buffered"). +stalls silently. The session's status (`SessionCoordinator.status`, see +"App events and platform ports") says it: connecting, reconnecting, wrong +password, version mismatch, replaced, the player's problems, the relay's +source and who everyone waits for. Errors are logged with a stack trace at the boundary, then turned into a displayable state. The server's `GET /health` returns JSON (`ok`, `name`, `proto`, `viewers`, `bytesServed`, `uptimeSecs`); it is unauthenticated, so diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index e1e5913..afdb2e1 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -155,7 +155,7 @@ in [ROADMAP.md](ROADMAP.md)): |---|---|---| | Drop and reconnect while sharing a relay | server `flaky_link_test` (blackhole: `gone` with ready, queue item and relay kept, the same member back; reset), `sync_wave_test` (takeover by client id, `replaced`, `stale`), `presence_test` (grace on a fake clock, restart), `relay_test` "the source drops (D6)" (`sourceGone`, buffer served, same `info` continues, another 4002, a second channel 4003, leave ends it); desktop `relay_e2e` "the source drops uncleanly and comes back" (gone past the server's silence, the relay kept, the source re-dials and continues), `coord_presence` (a half-open drop while playing: same member, no reopen, the state kept; 10 flaps: at most one lost/back sound; `replaced` is terminal; a restarted server gets the ready again), `connection_test` (the hello's id, `seq`, caps, `feedAfter`) | | | Join mid-playback | core `sync_engine_test` "D2: joiners" (never changes the transport while playing, at most 15 s while held, joining ends on error or timeout), `presence_test` (the table); server `relay_test` "a late joiner gets its own /r/ URL"; desktop `coord_presence` (the returning member plays on in sync) | | -| Sit out / Away and back | core `sync_engine_test` "away" (only oneself comes back, through joining), "return" (away survives a drop), `presence_test` (`isAlone`); server `hardening_test` (anyone sets anyone away, `forbidden`); desktop `server_isolate_test` (host sat out through `presence`), `notice_table_test` (the feed actions one-to-one, matched by id), `session_logic_test` (auto sit-out on a fake clock: never alone, a manual away stays), `player_status_test` / `shell_test` (the ready counter leaves away and gone out) | step 2: notifications while away (P8); step 6: not tracked as watched (P7) | +| Sit out / Away and back | core `sync_engine_test` "away" (only oneself comes back, through joining), "return" (away survives a drop), `presence_test` (`isAlone`); server `hardening_test` (anyone sets anyone away, `forbidden`); desktop `server_isolate_test` (host sat out through `presence`), `notice_table_test` (the feed actions one-to-one, matched by id; sitting out gets only chat, nudges, "starting now", Up next and the chosen joins and leaves; alone no social sounds), `session_logic_test` (auto sit-out on a fake clock: never alone, a manual away stays), `player_status_test` / `shell_test` (the ready counter leaves away and gone out) | step 6: not tracked as watched (P7) | | Swap, also while someone is Away or gone | core `sync_engine_test` "swap" (a gone member stays out of the gate, comes back joining), "away" (a swap leaves it away), `queue_test` (a swap takes its item, drops the `auto` one); server `queue_test` (a gone adder's item waits), `relay_test` (a swap drops the buffer); desktop `sync_playback_e2e` | step 5: no commands reach a player mid-switch (P15) | | Up next advance | core `queue_test` (skip, play request timeout, `auto` and `cancel`); server `queue_test`, `relay_test` "warm relay"; desktop `jellyfin_session_test` (auto-advance swapper, end card countdown), `queue_switch_e2e` | step 5: preparing it through the media pipeline; step 6: the countdown as server state (`state.advance`) | | Close the app cleanly, incl. `bye` | server `lifecycle_test` (a close keeps the member `gone`, `bye` leaves at once, server stop closes sockets); desktop `server_isolate_test` (stop frees the port), `coord_presence` (cancelled host setup releases the port; leave sends `bye` and others see "left" at once, leaving for a restart keeps the member `gone`) | step 5: the Windows close smoke job (P32) | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 5bad76e..7285a92 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -38,10 +38,7 @@ Problems are numbered; delete a line when its fix ships. - P2 Viewers of a relay whose source is gone: mpv loops on failed seeks past what the server buffered (viewer-side recovery, step 5). - P7 Sat-out people get tracked as watched on Jellyfin. -- P8 Sat-out people get the normal notifications. - P9 The watched rule (position coverage) misses real watches (D3). -- P11 Toasts cover the video controls. -- P12 Queue additions don't show as toasts. - P15 Commands reach a player mid-switch (`seek 90.0`, `event:5` errors). - P17 Preload, warm relay and the fresh player per video are layered on the player instead of being one media pipeline. @@ -78,14 +75,6 @@ the automatic continuation and countdown, relay state), the device (client id, library and folders, match corrections, watch history, settings, notification prefs, what this app is sourcing) and the integrations (Jellyfin played/resume, AniList list). -2. Status (one computed level, incl. the server's hold reason) + Notifier - (`noticeFor(event, context)`: self presence, alone, window activity, - playing, prefs, replayed; owns its toast timer; system notifications - port; library snack bars moved in). Fixes P8 P11 P12 P21 P33, "source - left"; D4, D1 notifications, D9 alone policy (`isAlone` in core). Step 1 - left over for it: the presence notices are today's (sat you out, a - toast when auto sit-out sets you away, lost signal and return only in - the activity log); a replayed feed only logs. 3. Your server: one hub, listeners attached and detached without members reconnecting (old isolate path kept until that's proven); Invite as a value (filtered addresses, version gate from `/health.version`, an `fp` @@ -141,6 +130,13 @@ restarted app, but the app starts on Home (it does not rejoin by itself) and does not remember what it was passing on, so a relay it served ends with its grace unless it is put up again; worth doing only if updates ever happen mid-session (D8 says never during playback). +Left of step 2: system notifications need a human check (Linux: the +notification shows and a click brings the window up, also on Wayland, +where focus may be refused without an activation token; Windows: toasts +from an unpackaged install, at 1.3.0). On Linux the notification carries +no `desktop-entry` hint (flutter_local_notifications_linux does not +export its hint types), so a shell may name it "starsync" from the +bundle rather than "Starsync". **Open decisions:** - Starting together: today someone has to stay unready so others can