Restructure step 2: Status as one computed level, the Notifier's real policy, system notifications
ci / core (push) Successful in 28s
ci / server (push) Successful in 1m11s
ci / updater-windows (push) Failing after 6m16s

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

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

No files matched your search

+29 -12
View File
@@ -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, (never over a playing video). "Update and restart" downloads,
verifies, applies in place and restarts; "Later" hides it until the next 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 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 `./starsync-server --upgrade` (no display needed, so it works on a
server box too), `./starsync --upgrade`, or the shipped `update.sh` / server box too), `./starsync --upgrade`, or the shipped `update.sh` /
`update.bat` (which run the first). Each updates the whole folder, app and `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 a little faded joining, dimmed reconnecting, dimmed with a moon sitting
out; a crown on whoever out; a crown on whoever
hosts, a tower on whoever passes the video on). Click the avatars for the 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 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 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 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 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 right-click for Copy message and, with a link in it, Copy link / Open in
browser), and its "Activity" toggle adds browser), and its "Activity" toggle adds
joins, ready, seeks and the like between them. Joins, leaves, chat and joins, ready, seeks and the like between them. Joins, leaves, someone
other news also pop up bottom left with the person's avatar; click a message to open the chat losing connection and coming back, video changes, additions to Up next
(Settings → Notifications → Chat messages: All by default, or Only when (the title, its picture and who added it), chat and problems also pop up
paused, or Off; messages always reach the chat, with their sound). Unread chat shows as a count on the chat 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, buttons and, in fullscreen with the controls hidden and the video paused,
as a small chip top right. 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 & password, recent servers with remove); Player (below); Jellyfin &
integrations (linked Jellyfin servers, below, the AnimeFlow connection: integrations (linked Jellyfin servers, below, the AnimeFlow connection:
"connected as <name>" with what you may do, or why not, and playing the "connected as <name>" 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 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 (the version, also at the bottom of every Settings page and in the
credits, e.g. that the Minimal player design is inspired by uosc). 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 Player settings live in the player too: ⚙ → Player settings, at the
right of its controls, and Settings → Player has the same ones (plus 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 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 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 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 source is too slow or its connection dropped ("Bob's connection dropped —
out what the server already has), and whoever passes it on is told once playing what the server has (2 min)"), red when it left (the others play
when their upload is too slow. Each member's line says how they get it 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 ("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 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. member while the source keeps up is their own connection.
+6 -2
View File
@@ -52,8 +52,12 @@ Future<void> main(List<String> args) async {
final ports = SessionState.instance.ports; final ports = SessionState.instance.ports;
await ports.shell.start( await ports.shell.start(
title: dirs.profile == null ? appName : '$appName [${dirs.profile}]', onCloseRequest: shutDownApp); title: dirs.profile == null ? appName : '$appName [${dirs.profile}]', onCloseRequest: shutDownApp);
// Focus, minimise and restore feed auto sit-out. // Focus, minimise and restore feed auto sit-out and the Notifier (in
ports.shell.watchActivity(SessionState.instance.coordinator.windowActivity); // the background, notices go out as system notifications).
ports.shell.watchActivity((a) {
SessionState.instance.coordinator.windowActivity(a);
SessionState.instance.notifier.window = a;
});
try { try {
await SessionState.instance.loadForm(); await SessionState.instance.loadForm();
} catch (e, st) { } catch (e, st) {
@@ -0,0 +1,100 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import '../desktop_entry.dart' show appId;
import '../ports/system_notifications.dart';
/// [SystemNotifications] on the desktop, through flutter_local_notifications'
/// platform plugins (used directly, not through its facade, so the platform
/// is picked by the OS and not by Flutter's target platform):
/// - Linux: `org.freedesktop.Notifications` over D-Bus (pure Dart), with the
/// app's icon (the bundled PNG, else the launcher entry's icon by name);
/// a click is the notification's default action.
/// - Windows: toast notifications (WinRT), the app's name and icon, a click
/// activates the app while it runs (an unpackaged app has no history).
/// Silent: the app plays its own sound for the same notice. Anything that
/// fails (no notification daemon, no session bus) is logged and dropped.
class DesktopNotifications implements SystemNotifications {
DesktopNotifications({this.appName = 'Starsync'});
final String appName;
void Function(String payload)? _onClick;
Future<bool>? _ready;
LinuxFlutterLocalNotificationsPlugin? _linux;
FlutterLocalNotificationsWindows? _windows;
// Tag -> id, so a notification with the same tag replaces the last one.
final _ids = <String, int>{};
int _next = 1;
@override
set onClick(void Function(String payload)? handler) => _onClick = handler;
void _clicked(NotificationResponse r) => _onClick?.call(r.payload ?? '');
/// The bundled 256 px icon next to the executable (a release or a
/// `flutter run` build), else the icon the launcher entry installed.
static LinuxNotificationIcon _linuxIcon() {
final exe = File(Platform.resolvedExecutable).parent.path;
final png = File('$exe/data/flutter_assets/assets/icon/icon_256.png');
return png.existsSync() ? FilePathLinuxIcon(png.path) : ThemeLinuxIcon(appId);
}
Future<bool> _init() => _ready ??= () async {
try {
if (Platform.isLinux) {
final p = _linux = LinuxFlutterLocalNotificationsPlugin();
return await p.initialize(
settings: LinuxInitializationSettings(defaultActionName: 'Open', defaultIcon: _linuxIcon()),
onDidReceiveNotificationResponse: _clicked,
) ??
false;
}
if (Platform.isWindows) {
final exe = File(Platform.resolvedExecutable).parent.path;
final icon = File('$exe/data/flutter_assets/assets/icon/icon_256.png');
final p = _windows = FlutterLocalNotificationsWindows();
return await p.initialize(
settings: WindowsInitializationSettings(
appName: appName,
appUserModelId: 'Foxyz.Starsync',
guid: '6c0f4b4e-3e8a-4b8e-9d0c-5a7f2b1e9c31',
iconPath: icon.existsSync() ? icon.path : null,
),
onDidReceiveNotificationResponse: _clicked,
);
}
} catch (e) {
debugPrint('system notifications unavailable: $e');
}
return false;
}();
@override
Future<void> show({required String title, required String body, String tag = '', String payload = ''}) async {
if (!await _init()) return;
final id = tag.isEmpty ? _next++ : _ids[tag] ??= _next++;
try {
if (_linux case final p?) {
await p.show(
id: id,
title: title,
body: body,
payload: payload,
notificationDetails: const LinuxNotificationDetails(suppressSound: true),
);
} else if (_windows case final p?) {
await p.show(
id: id,
title: title,
body: body,
payload: payload,
notificationDetails: WindowsNotificationDetails(audio: WindowsNotificationAudio.silent()),
);
}
} catch (e) {
debugPrint('system notification failed: $e');
}
}
}
@@ -1,6 +1,7 @@
import '../../playback/yt_dlp_app.dart'; import '../../playback/yt_dlp_app.dart';
import '../ports/ports.dart'; import '../ports/ports.dart';
import 'desktop_folders.dart'; import 'desktop_folders.dart';
import 'desktop_notifications.dart';
import 'desktop_shell.dart'; import 'desktop_shell.dart';
import 'desktop_urls.dart'; import 'desktop_urls.dart';
@@ -12,6 +13,6 @@ Ports desktopPorts() {
urls: urls, urls: urls,
folders: const DesktopFolders(urls), folders: const DesktopFolders(urls),
links: AppYtDlp.instance, links: AppYtDlp.instance,
notifications: const NoSystemNotifications(), notifications: DesktopNotifications(),
); );
} }
@@ -97,6 +97,17 @@ class DesktopShell implements ShellPort {
} }
} }
@override
Future<void> focus() async {
try {
if (await windowManager.isMinimized()) await windowManager.restore();
if (!await windowManager.isVisible()) await windowManager.show();
await windowManager.focus();
} catch (e) {
debugPrint('window focus failed: $e');
}
}
@override @override
Future<void> requestAttention() => requestWindowAttention(); Future<void> requestAttention() => requestWindowAttention();
@@ -1,20 +1,8 @@
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
/// Where the app window stands, for auto sit-out import 'window_activity.dart';
/// ([ShellPort.watchActivity]).
enum WindowActivity {
/// The window has the keyboard focus.
focused,
/// Shown, but another window has the focus. export 'window_activity.dart';
unfocused,
/// Minimised to the taskbar.
minimised,
/// Hidden (closing, or a platform that hides instead of minimising).
hidden,
}
/// What the clipboard holds for a paste: its files, else its text. /// What the clipboard holds for a paste: its files, else its text.
typedef Paste = ({List<String> files, String? text}); typedef Paste = ({List<String> files, String? text});
@@ -54,6 +42,10 @@ abstract interface class ShellPort {
Future<void> hide(); Future<void> hide();
Future<void> quit(); Future<void> quit();
/// Brings the window up and gives it the focus (restored when
/// minimised, shown when hidden): a click on a system notification.
Future<void> focus();
/// Points the desktop at the window when it is not focused (taskbar /// Points the desktop at the window when it is not focused (taskbar
/// flash, urgency hint); nothing when it is. /// flash, urgency hint); nothing when it is.
Future<void> requestAttention(); Future<void> requestAttention();
@@ -1,14 +1,23 @@
/// Notifications outside the window (the desktop's notification center, /// 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 { abstract interface class SystemNotifications {
/// Shows [title] / [body]; replaces the one with the same [tag]. /// Shows [title] / [body] with the app's name and icon; one with the
Future<void> show({required String title, required String body, String tag = ''}); /// same [tag] replaces it. [payload] comes back to [onClick].
Future<void> show({required String title, required String body, String tag = '', String payload = ''});
/// Called with a notification's payload when the user clicks it (set
/// once, by the Notifier).
set onClick(void Function(String payload)? handler);
} }
/// Shows nothing (desktop today). /// Shows nothing (tests, platforms without one).
class NoSystemNotifications implements SystemNotifications { class NoSystemNotifications implements SystemNotifications {
const NoSystemNotifications(); const NoSystemNotifications();
@override @override
Future<void> show({required String title, required String body, String tag = ''}) async {} Future<void> show({required String title, required String body, String tag = '', String payload = ''}) async {}
@override
set onClick(void Function(String payload)? handler) {}
} }
@@ -0,0 +1,19 @@
/// Where the app window stands ([ShellPort.watchActivity]): auto sit-out
/// and the Notifier (system notifications while it is not focused) read
/// it. Pure Dart.
enum WindowActivity {
/// The window has the keyboard focus.
focused,
/// Shown, but another window has the focus.
unfocused,
/// Minimised to the taskbar.
minimised,
/// Hidden (closing, or a platform that hides instead of minimising).
hidden;
/// Not in front of the user: notices go out as system notifications.
bool get background => this != focused;
}
@@ -16,12 +16,15 @@ class ServerHealth {
final String name; final String name;
final int? proto; final int? proto;
final int viewers; final int viewers;
/// Its Starsync version (`version`), '' from a server too old to say.
final String version;
const ServerHealth.offline() const ServerHealth.offline()
: online = false, : online = false,
name = '', name = '',
proto = null, proto = null,
viewers = 0; viewers = 0,
const ServerHealth.online({required this.name, this.proto, this.viewers = 0}) version = '';
const ServerHealth.online({required this.name, this.proto, this.viewers = 0, this.version = ''})
: online = true; : online = true;
/// True when the server speaks another protocol than this build. /// True when the server speaks another protocol than this build.
@@ -33,11 +36,12 @@ class ServerHealth {
try { try {
final j = jsonDecode(body); final j = jsonDecode(body);
if (j is! Map || j['ok'] != true) return const ServerHealth.offline(); 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( return ServerHealth.online(
name: name is String ? name.trim() : '', name: name is String ? name.trim() : '',
proto: proto is num ? proto.toInt() : null, proto: proto is num ? proto.toInt() : null,
viewers: viewers is num ? viewers.toInt() : 0, viewers: viewers is num ? viewers.toInt() : 0,
version: version is String ? version.trim() : '',
); );
} catch (_) { } catch (_) {
return const ServerHealth.offline(); return const ServerHealth.offline();
+90 -50
View File
@@ -15,65 +15,71 @@ sealed class AppEvent {
/// in the activity log. /// in the activity log.
sealed class FeedEvent extends AppEvent { sealed class FeedEvent extends AppEvent {
final FeedMessage feed; 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; String get actor => feed.actor;
/// [feed] as its typed event. [selfId]: this member's id (people are /// [feed] as its typed event. [selfId]: this member's id (people are
/// matched by `actorId` / `targetId`, never by name); [readyCount]: /// matched by `actorId` / `targetId`, never by name); [readyCount]:
/// members ready in the latest state (the pitch of a `ready` sound). /// members ready in the latest state (the pitch of a `ready` sound);
factory FeedEvent.of(FeedMessage f, {String selfId = '', int readyCount = 0}) { /// [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; bool isSelf(String id) => selfId.isNotEmpty && id == selfId;
final own = isSelf(f.actorId);
return switch (f.action) { return switch (f.action) {
'join' => MemberJoined(f), 'join' => MemberJoined(f, own: own),
'leave' => MemberLeft(f), 'leave' => MemberLeft(f, own: own),
'gone' => MemberGone(f), 'gone' => MemberGone(f, own: own),
'return' => MemberReturned(f), 'return' => MemberReturned(f, own: own),
'away' => MemberSatOut(f, 'away' => MemberSatOut(f,
own: own,
atYou: isSelf(f.targetId) && !isSelf(f.actorId), atYou: isSelf(f.targetId) && !isSelf(f.actorId),
autoSelf: isSelf(f.actorId) && f.targetId.isEmpty && f.reason.isNotEmpty), autoSelf: isSelf(f.actorId) && f.targetId.isEmpty && f.reason.isNotEmpty),
'in' => MemberBack(f), 'in' => MemberBack(f, own: own),
'ready' => ReadyChanged(f, ready: true, readyIndex: readyCount), 'ready' => ReadyChanged(f, own: own, ready: true, readyIndex: readyCount),
'unready' => ReadyChanged(f, ready: false), 'unready' => ReadyChanged(f, own: own, ready: false),
'force-ready' => ForcedReady(f, ready: true), 'force-ready' => ForcedReady(f, own: own, ready: true),
'force-unready' => ForcedReady(f, ready: false), 'force-unready' => ForcedReady(f, own: own, ready: false),
'force-play' => ForcedTransport(f, play: true), 'force-play' => ForcedTransport(f, own: own, play: true),
'force-hold' => ForcedTransport(f, play: false), 'force-hold' => ForcedTransport(f, own: own, play: false),
'swap' => MediaSwapped(f), 'swap' => MediaSwapped(f, own: own),
'seek' => Seeked(f), 'seek' => Seeked(f, own: own),
'pause' => Paused(f), 'pause' => Paused(f, own: own),
'resume' => Resumed(f), 'resume' => Resumed(f, own: own),
'finished' => Finished(f), 'finished' => Finished(f, own: own),
'chat' => ChatPosted(f, own: isSelf(f.actorId)), 'chat' => ChatPosted(f, own: own),
'rename' => MemberRenamed(f), 'rename' => MemberRenamed(f, own: own),
'avatar' => AvatarChanged(f), 'avatar' => AvatarChanged(f, own: own),
'queue-add' => QueueAdded(f), 'queue-add' => QueueAdded(f, own: own, thumb: queueThumb),
'queue-remove' => QueueRemoved(f), 'queue-remove' => QueueRemoved(f, own: own),
'queue-skip' => QueueSkipped(f), 'queue-skip' => QueueSkipped(f, own: own),
'fetch-fail' => FetchFailed(f), 'fetch-fail' => FetchFailed(f, own: own),
_ => OtherFeed(f), _ => OtherFeed(f, own: own),
}; };
} }
} }
class MemberJoined extends FeedEvent { 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 /// Left for good (`reason` `bye`: the app closed or Leave; `grace`: gone
/// too long). /// too long).
class MemberLeft extends FeedEvent { 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. /// Lost signal: gone for a while, kept in its grace period.
class MemberGone extends FeedEvent { class MemberGone extends FeedEvent {
const MemberGone(super.feed); const MemberGone(super.feed, {super.own});
} }
/// Back within its grace after a [MemberGone]. /// Back within its grace after a [MemberGone].
class MemberReturned extends FeedEvent { 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 /// 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 { class MemberSatOut extends FeedEvent {
final bool atYou; final bool atYou;
final bool autoSelf; 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`). /// Back from sitting out (`in`).
class MemberBack extends FeedEvent { class MemberBack extends FeedEvent {
const MemberBack(super.feed); const MemberBack(super.feed, {super.own});
} }
class ReadyChanged extends FeedEvent { class ReadyChanged extends FeedEvent {
final bool ready; final bool ready;
/// Members ready when it came (pitch of the ready sound). /// Members ready when it came (pitch of the ready sound).
final int readyIndex; 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 { class ForcedReady extends FeedEvent {
final bool ready; final bool ready;
const ForcedReady(super.feed, {required this.ready}); const ForcedReady(super.feed, {super.own, required this.ready});
} }
/// Force play / hold. /// Force play / hold.
class ForcedTransport extends FeedEvent { class ForcedTransport extends FeedEvent {
final bool play; final bool play;
const ForcedTransport(super.feed, {required this.play}); const ForcedTransport(super.feed, {super.own, required this.play});
} }
class MediaSwapped extends FeedEvent { class MediaSwapped extends FeedEvent {
const MediaSwapped(super.feed); const MediaSwapped(super.feed, {super.own});
} }
class Seeked extends FeedEvent { class Seeked extends FeedEvent {
const Seeked(super.feed); const Seeked(super.feed, {super.own});
} }
class Paused extends FeedEvent { class Paused extends FeedEvent {
const Paused(super.feed); const Paused(super.feed, {super.own});
} }
class Resumed extends FeedEvent { class Resumed extends FeedEvent {
const Resumed(super.feed); const Resumed(super.feed, {super.own});
} }
class Finished extends FeedEvent { 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. /// A chat message (`reason` carries the text); [own]: this member sent it.
class ChatPosted extends FeedEvent { class ChatPosted extends FeedEvent {
final bool own; const ChatPosted(super.feed, {super.own});
const ChatPosted(super.feed, {this.own = false});
} }
class MemberRenamed extends FeedEvent { class MemberRenamed extends FeedEvent {
const MemberRenamed(super.feed); const MemberRenamed(super.feed, {super.own});
} }
class AvatarChanged extends FeedEvent { 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 { class QueueAdded extends FeedEvent {
const QueueAdded(super.feed); final String thumb;
const QueueAdded(super.feed, {super.own, this.thumb = ''});
} }
class QueueRemoved extends FeedEvent { class QueueRemoved extends FeedEvent {
const QueueRemoved(super.feed); const QueueRemoved(super.feed, {super.own});
} }
/// Up next skipped an item (`target` its title, `reason` why). /// Up next skipped an item (`target` its title, `reason` why).
class QueueSkipped extends FeedEvent { class QueueSkipped extends FeedEvent {
const QueueSkipped(super.feed); const QueueSkipped(super.feed, {super.own});
} }
/// The server could not fetch the current media. /// The server could not fetch the current media.
class FetchFailed extends FeedEvent { class FetchFailed extends FeedEvent {
const FetchFailed(super.feed); const FetchFailed(super.feed, {super.own});
} }
/// An action this app does not know (a newer server). /// An action this app does not know (a newer server).
class OtherFeed extends FeedEvent { class OtherFeed extends FeedEvent {
const OtherFeed(super.feed); const OtherFeed(super.feed, {super.own});
} }
// -------------------------------------------------------------- session // -------------------------------------------------------------- session
@@ -209,6 +217,38 @@ class ConnectionBack extends AppEvent {
const ConnectionBack(); 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). /// Jellyfin progress reports fail (once per media).
class JellyfinProgressFailed extends AppEvent { class JellyfinProgressFailed extends AppEvent {
final bool signInExpired; final bool signInExpired;
+32 -4
View File
@@ -6,6 +6,12 @@ import 'party.dart';
// Who fetches the current media and how it spreads, from the state alone: // 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. // 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<String, Object?>.from(r)) : null;
}
/// "4.1 MB/s", "850 kB/s". /// "4.1 MB/s", "850 kB/s".
String formatRate(int bytesPerSec) { String formatRate(int bytesPerSec) {
if (bytesPerSec >= 1000000) { if (bytesPerSec >= 1000000) {
@@ -50,6 +56,9 @@ enum SourceState {
slow, slow,
/// Its channel is down (or the server has not read the link yet). /// Its channel is down (or the server has not read the link yet).
waiting, waiting,
/// The source member's connection dropped (`gone`, within its grace):
/// viewers play what the server has.
gone,
/// It left the server. /// It left the server.
left, left,
} }
@@ -114,6 +123,9 @@ class Delivery {
final String sourceName; final String sourceName;
final bool sourceIsSelf; final bool sourceIsSelf;
final SourceState state; 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). /// `media.relay.rate` / `need` (0 unknown).
final int rate, need; final int rate, need;
/// Members (not sitting out, not the source) reading from the server, /// Members (not sitting out, not the source) reading from the server,
@@ -125,6 +137,8 @@ class Delivery {
this.sourceName = '', this.sourceName = '',
this.sourceIsSelf = false, this.sourceIsSelf = false,
this.state = SourceState.ok, this.state = SourceState.ok,
this.goneSince,
this.at,
this.rate = 0, this.rate = 0,
this.need = 0, this.need = 0,
this.viaServer = 0, this.viaServer = 0,
@@ -155,7 +169,10 @@ class Delivery {
}; };
case DeliveryKind.memberRelay: case DeliveryKind.memberRelay:
return switch (state) { 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.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.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', 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 /// "3 through the server · 4.1 MB/s, needs 2.3 MB/s"; empty when
/// there is nothing to add. /// there is nothing to add.
String get detail { String get detail {
@@ -170,7 +193,7 @@ class Delivery {
if (direct > 0 && (kind == DeliveryKind.jellyfin || kind == DeliveryKind.memberRelay)) if (direct > 0 && (kind == DeliveryKind.jellyfin || kind == DeliveryKind.memberRelay))
'$direct with their own account', '$direct with their own account',
if (viaServer > 0 && kind != DeliveryKind.link) '$viaServer through the server', 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(' · '); return parts.join(' · ');
} }
@@ -193,8 +216,9 @@ class Delivery {
} }
/// The current media's [Delivery]; null with nothing up or nobody else in /// The current media's [Delivery]; null with nothing up or nobody else in
/// the server. [names] remembers a source that left. /// the server. [names] remembers a source that left; [now] reads how long
Delivery? deliveryOf(StateBroadcast r, {required String selfId, MemberNames? names}) { /// a gone source has been gone.
Delivery? deliveryOf(StateBroadcast r, {required String selfId, MemberNames? names, DateTime? now}) {
final media = r.media; final media = r.media;
if (media == null || r.members.length < 2) return null; if (media == null || r.members.length < 2) return null;
final rel = media['relay']; final rel = media['relay'];
@@ -237,6 +261,8 @@ Delivery? deliveryOf(StateBroadcast r, {required String selfId, MemberNames? nam
? SourceState.ok ? SourceState.ok
: kind == DeliveryKind.memberRelay && source == null : kind == DeliveryKind.memberRelay && source == null
? SourceState.left ? SourceState.left
: kind == DeliveryKind.memberRelay && (relay.sourceGone || source?.presence == Presence.gone)
? SourceState.gone
: !relay.live : !relay.live
? SourceState.waiting ? SourceState.waiting
: relay.slow : 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', sourceName: source?.name ?? names?[sourceId] ?? 'The member passing it on',
sourceIsSelf: sourceId.isNotEmpty && sourceId == selfId, sourceIsSelf: sourceId.isNotEmpty && sourceId == selfId,
state: state, state: state,
goneSince: state == SourceState.gone ? relay?.sourceGoneSince ?? source?.goneSince : null,
at: now ?? DateTime.now().toUtc(),
rate: relay?.rate ?? 0, rate: relay?.rate ?? 0,
need: relay?.need ?? 0, need: relay?.need ?? 0,
viaServer: via, viaServer: via,
-7
View File
@@ -1,7 +0,0 @@
enum HealthSeverity { ok, info, warn, error }
class HealthStatus {
final HealthSeverity severity;
final String message;
const HealthStatus(this.severity, this.message);
}
@@ -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});
}
+328 -44
View File
@@ -1,12 +1,16 @@
import 'dart:async'; import 'dart:async';
import 'package:signals/signals_core.dart'; 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 '../sfx/sfx.dart';
import 'app_events.dart'; import 'app_events.dart';
import 'notice_prefs.dart';
export 'app_events.dart'; export 'app_events.dart';
export 'notice_prefs.dart';
class ToastItem { class ToastItem {
final int id; final int id;
@@ -17,67 +21,243 @@ class ToastItem {
final String? actor; final String? actor;
/// [actor]'s member id (feed and chat toasts); '' when unknown. /// [actor]'s member id (feed and chat toasts); '' when unknown.
final String actorId; 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, /// 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 { class Notice {
final NoticeCategory category;
final String? toast; final String? toast;
final String? actor; 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 SfxCue? sound;
final bool attention; final bool attention;
final SystemNote? system;
/// The feed line it goes into the activity log as. /// The feed line it goes into the activity log as.
final FeedMessage? log; 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(); 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. String _title(String t) => t.isEmpty ? 'the video' : t;
Notice noticeFor(AppEvent e) => switch (e) {
// Lines a reconnect replayed (`welcome.feed`): the log only. /// The event alone: its category, toast, sound and log line.
FeedEvent(:final feed) when feed.replayed => Notice(log: feed), Notice _plain(AppEvent e) => switch (e) {
// The feed: everything is logged; joins, leaves, swaps and chat // The feed: everything is logged.
// pop up; the social actions have a sound.
MemberJoined(:final feed) => Notice( 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( 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( MediaSwapped(:final feed) => Notice(
log: feed, toast: '${feed.actor} changed media', actor: feed.actor, sound: const SfxCue(SfxKind.swap)), category: NoticeCategory.upNext,
ChatPosted(:final feed, :final own) => Notice( 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, log: feed,
toast: '${feed.actor}: ${feed.reason}', toast: '${feed.actor}: ${feed.reason}',
actor: feed.actor, actor: feed.actor,
// Your own message needs no sound. chat: true,
sound: own ? null : const SfxCue(SfxKind.chat)), sound: const SfxCue(SfxKind.chat)),
MemberSatOut(:final feed, atYou: true) => Notice( 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( MemberSatOut(:final feed, autoSelf: true) => Notice(
category: NoticeCategory.self,
log: feed, log: feed,
toast: 'You sit out (${feed.reason == 'minimised' ? 'minimised' : 'idle'}); you are back as soon as you return', toast: 'You sit out (${feed.reason == 'minimised' ? 'minimised' : 'idle'}); you are back as soon as you return',
sound: const SfxCue(SfxKind.warn)), sound: const SfxCue(SfxKind.warn)),
ReadyChanged(:final feed, ready: true, :final readyIndex) => ReadyChanged(:final feed, ready: true, :final readyIndex) =>
Notice(log: feed, sound: SfxCue(SfxKind.ready, readyIndex: readyIndex)), Notice(category: NoticeCategory.party, log: feed, sound: SfxCue(SfxKind.ready, readyIndex: readyIndex)),
ReadyChanged(:final feed, ready: false) => Notice(log: feed, sound: const SfxCue(SfxKind.unready)), ReadyChanged(:final feed, ready: false) =>
ForcedReady(:final feed, ready: true) => Notice(log: feed, sound: const SfxCue(SfxKind.forceReady)), Notice(category: NoticeCategory.party, log: feed, sound: const SfxCue(SfxKind.unready)),
QueueSkipped(:final feed) => Notice( ForcedReady(:final feed, ready: true) =>
log: feed, toast: 'Skipped ${feed.target ?? 'an item'}${feed.reason.isEmpty ? '' : ': ${feed.reason}'}'), Notice(category: NoticeCategory.party, log: feed, sound: const SfxCue(SfxKind.forceReady)),
FeedEvent(:final feed) => Notice(log: feed), FetchFailed(:final feed) => Notice(category: NoticeCategory.problem, log: feed),
FeedEvent(:final feed) => Notice(category: NoticeCategory.party, log: feed),
// The session. // The session.
Nudged(:final name) => Notice( Nudged(:final name) => Notice(
category: NoticeCategory.nudge,
toast: '${name.isEmpty ? 'Someone' : name} nudged you', toast: '${name.isEmpty ? 'Someone' : name} nudged you',
actor: name.isEmpty ? null : name, actor: name.isEmpty ? null : name,
sound: const SfxCue(SfxKind.nudge), sound: const SfxCue(SfxKind.nudge),
attention: true), attention: true),
MemberCantPlay(:final name, :final error) => Notice(toast: "$name can't play this: $error", actor: name), PlaybackStarted(:final title) =>
SlowSource(:final text) => Notice(toast: text), Notice(category: NoticeCategory.start, toast: title.isEmpty ? 'Starting now' : 'Starting now: $title'),
RelayStarted() => MemberCantPlay(:final name, :final error) =>
const Notice(toast: 'Passing the video on through the server. Keep the app open while it plays.'), Notice(category: NoticeCategory.problem, toast: "$name can't play this: $error", actor: name),
RelayFailed(:final error) => Notice(toast: 'Cannot pass the video on: $error'), RelaySourceDropped(:final name, :final left) => Notice(
ConnectionLost() => const Notice(sound: SfxCue(SfxKind.lost)), category: NoticeCategory.problem,
ConnectionBack() => const Notice(sound: SfxCue(SfxKind.back)), 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) => JellyfinProgressFailed(:final signInExpired) =>
Notice(toast: 'Jellyfin progress not saved: ${signInExpired ? 'sign-in expired' : 'server unreachable'}'), Notice(toast: 'Jellyfin progress not saved: ${signInExpired ? 'sign-in expired' : 'server unreachable'}'),
// The app. // 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"), Notice(toast: inSession ? 'Marked in the session log' : "Marked in this app's log"),
Copied(:final text) => Notice(toast: text), Copied(:final text) => Notice(toast: text),
CopyFailed() => const Notice(toast: "Couldn't copy"), 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, 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 /// The one way news reaches the user: [notify] turns an [AppEvent] into
/// toasts ([toasts]), sounds, an attention request and the activity log /// toasts ([toasts], each dropped after [toastLife] by the Notifier's
/// ([feedLog]) per [noticeFor]. /// 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 { class Notifier {
final SfxService sfx; final SfxService sfx;
final Future<void> Function() attention; final Future<void> Function() attention;
Notifier({this.sfx = const NoopSfxService(), Future<void> Function()? attention}) final SystemNotifications system;
: attention = attention ?? _noAttention; /// Brings the window up (a click on a system notification).
final Future<void> 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<void> _noAttention() async {} Notifier({
this.sfx = const NoopSfxService(),
Future<void> Function()? attention,
this.system = const NoSystemNotifications(),
Future<void> 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<void> _nothing() async {}
static NotifyPrefs _defaultPrefs() => const NotifyPrefs();
/// Toasts on screen (the shell shows them).
final toasts = signal<List<ToastItem>>([]); final toasts = signal<List<ToastItem>>([]);
/// The activity log: the last 100 feed lines. /// The activity log: the last 100 feed lines.
final feedLog = signal<List<FeedMessage>>([]); final feedLog = signal<List<FeedMessage>>([]);
/// Bumped when a click on a chat notification asks for the chat (the
/// shell opens it).
final chatRequests = signal(0);
int _toastId = 0; int _toastId = 0;
final _timers = <int, Timer>{};
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) { void notify(AppEvent e) {
final n = noticeFor(e); final n = noticeFor(e, context);
final log = n.log; final log = n.log;
if (log != null) { if (log != null) {
final all = [...feedLog.peek(), log]; final all = [...feedLog.peek(), log];
@@ -119,29 +385,47 @@ class Notifier {
} }
final text = n.toast; final text = n.toast;
if (text != null) { if (text != null) {
_toastId++; final id = ++_toastId;
toasts.value = [ toasts.value = [
...toasts.peek(), ...toasts.peek(),
ToastItem( ToastItem(
id: _toastId, id: id,
text: text, text: text,
at: DateTime.now().toUtc(), at: DateTime.now().toUtc(),
actor: n.actor, 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; final sound = n.sound;
if (sound != null) unawaited(sfx.play(sound)); if (sound != null) unawaited(sfx.play(sound));
if (n.attention) unawaited(attention()); 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 _clicked(String payload) {
void expire(DateTime now) { unawaited(focusWindow());
toasts.value = toasts.peek().where((t) => now.difference(t.at).inSeconds < 4).toList(); 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. /// A new session: no toasts, an empty log.
void clear() { void clear() {
for (final t in _timers.values) {
t.cancel();
}
_timers.clear();
feedLog.value = []; feedLog.value = [];
toasts.value = []; toasts.value = [];
} }
@@ -28,16 +28,18 @@ import '../platform/ports/shell_port.dart' show WindowActivity;
import 'auto_sit_out.dart'; import 'auto_sit_out.dart';
import 'connection.dart'; import 'connection.dart';
import 'delivery.dart'; import 'delivery.dart';
import 'health.dart';
import 'media_art.dart'; import 'media_art.dart';
import 'notifier.dart'; import 'notifier.dart';
import 'now_playing.dart'; import 'now_playing.dart';
import 'now_playing_info.dart'; import 'now_playing_info.dart';
import 'party.dart'; import 'party.dart';
import 'status.dart';
import 'switch_trace.dart'; import 'switch_trace.dart';
export 'connection.dart' show ConnectCancelled, ConnectParams; export 'connection.dart' show ConnectCancelled, ConnectParams;
export 'notifier.dart' show ToastItem; export 'notifier.dart' show ToastItem;
export 'delivery.dart' show stateMediaRelay;
export 'status.dart';
final _log = createLogger('session'); final _log = createLogger('session');
@@ -71,12 +73,6 @@ bool unreadyForNewMedia(
required bool ready}) => required bool ready}) =>
enabled && lastMediaId >= 0 && mediaId != lastMediaId && 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<String, Object?>.from(r)) : null;
}
/// What this member serves when its own swap is relayed: set at swap time, /// 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. /// claimed by the state that names this member as the source.
sealed class _RelayOrigin { sealed class _RelayOrigin {
@@ -103,19 +99,6 @@ final class _JellyfinOrigin extends _RelayOrigin {
const _JellyfinOrigin(this.link); 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) { String? stateMediaUrl(StateBroadcast state) {
final media = state.media; final media = state.media;
if (media == null) return null; if (media == null) return null;
@@ -171,8 +154,6 @@ class SessionCoordinator {
final SwitchTrace switchTrace = SwitchTrace(); final SwitchTrace switchTrace = SwitchTrace();
final StateDiff diff = StateDiff(); 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 /// 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 /// media and a relay this member serves): [stateStale] says it is not
/// current; only leaving clears it. /// current; only leaving clears it.
@@ -191,7 +172,38 @@ class SessionCoordinator {
late final Computed<bool> reconnecting = computed(() => connection.state.value.retrying); late final Computed<bool> reconnecting = computed(() => connection.state.value.retrying);
/// The link is up but went quiet ([ConnUnstable]). /// The link is up but went quiet ([ConnUnstable]).
late final Computed<bool> unstable = computed(() => connection.state.value is ConnUnstable); late final Computed<bool> unstable = computed(() => connection.state.value is ConnUnstable);
final starving = signal<String?>(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> 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 /// Why this player cannot open the current media, in plain words
/// ([playErrorText]); empty while it can. It rides in `report.error`, so /// ([playErrorText]); empty while it can. It rides in `report.error`, so
/// the server stops waiting for this member and the others see it. /// the server stops waiting for this member and the others see it.
@@ -255,7 +267,6 @@ class SessionCoordinator {
StreamSubscription? _feedSub; StreamSubscription? _feedSub;
StreamSubscription? _nudgeSub; StreamSubscription? _nudgeSub;
StreamSubscription? _errSub; StreamSubscription? _errSub;
void Function()? _connectionHealth;
/// A solo session (set by SessionState): no auto sit-out, there is /// A solo session (set by SessionState): no auto sit-out, there is
/// nobody to hold up. /// nobody to hold up.
@@ -372,6 +383,27 @@ class SessionCoordinator {
connection connection
..onWelcome = _onWelcome ..onWelcome = _onWelcome
..caps = () => caps; ..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). // yt-dlp is there to resolve links (checked at each connect).
@@ -406,8 +438,6 @@ class SessionCoordinator {
_playerReady = true; _playerReady = true;
} catch (e, st) { } catch (e, st) {
debugPrint('player init failed: $e\n$st'); debugPrint('player init failed: $e\n$st');
health.value =
HealthStatus(HealthSeverity.error, 'player failed to start: $e');
rethrow; rethrow;
} }
} }
@@ -465,6 +495,7 @@ class SessionCoordinator {
if (connection.params == null || _reloadingVideo) return; if (connection.params == null || _reloadingVideo) return;
_reloadingVideo = true; _reloadingVideo = true;
_playerReady = false; _playerReady = false;
playerFault.value = '';
try { try {
await playback.recreate(); await playback.recreate();
final url = _openUrl; final url = _openUrl;
@@ -474,8 +505,7 @@ class SessionCoordinator {
if (url != null) await _openMedia(url, headers: headers); if (url != null) await _openMedia(url, headers: headers);
} catch (e, st) { } catch (e, st) {
debugPrint('video reload failed: $e\n$st'); debugPrint('video reload failed: $e\n$st');
health.value = playerFault.value = 'video reload failed: $e';
HealthStatus(HealthSeverity.error, 'video reload failed: $e');
} finally { } finally {
_playerReady = playback.alive.peek(); _playerReady = playback.alive.peek();
_reloadingVideo = false; _reloadingVideo = false;
@@ -504,6 +534,10 @@ class SessionCoordinator {
settledMediaId.value = -1; settledMediaId.value = -1;
_openUrl = null; _openUrl = null;
playerError.value = ''; playerError.value = '';
playerFault.value = '';
fetchFailedFor.value = -1;
restarts.value = 0;
_statusWatch.reset();
_memberErrors = const {}; _memberErrors = const {};
_lastReady = false; _lastReady = false;
_restoringReady = false; _restoringReady = false;
@@ -535,12 +569,7 @@ class SessionCoordinator {
_startTicker(); _startTicker();
try { try {
final url = welcome.mediaUrl; final url = welcome.mediaUrl;
if (url != null && url.isNotEmpty) { if (url != null && url.isNotEmpty) await _openMedia(url);
await _openMedia(url);
} else {
health.value = const HealthStatus(
HealthSeverity.info, 'connected — waiting for host media…');
}
final media = params.media ?? final media = params.media ??
(params.linkUrl.isEmpty ? null : await mediaForLink(jellyfin, params.linkUrl)); (params.linkUrl.isEmpty ? null : await mediaForLink(jellyfin, params.linkUrl));
if (!connection.isCurrent(epoch)) throw const ConnectCancelled(); if (!connection.isCurrent(epoch)) throw const ConnectCancelled();
@@ -549,8 +578,6 @@ class SessionCoordinator {
if (!connection.isCurrent(epoch)) throw const ConnectCancelled(); if (!connection.isCurrent(epoch)) throw const ConnectCancelled();
debugPrint('connect failed: $e'); debugPrint('connect failed: $e');
connection.scheduleReconnect(); connection.scheduleReconnect();
health.value =
HealthStatus(HealthSeverity.error, connectErrorText(e));
rethrow; rethrow;
} }
} }
@@ -560,13 +587,8 @@ class SessionCoordinator {
_stateSub ??= connection.states.listen(_onState); _stateSub ??= connection.states.listen(_onState);
_feedSub ??= connection.feeds.listen(_onFeed); _feedSub ??= connection.feeds.listen(_onFeed);
_nudgeSub ??= connection.nudges.listen(_onNudge); _nudgeSub ??= connection.nudges.listen(_onNudge);
_errSub ??= connection.errors.listen((msg) { // The server refused something this app sent: a toast.
health.value = HealthStatus(HealthSeverity.warn, msg); _errSub ??= connection.errors.listen((msg) => notify(ServerRefused(_sentenceCase(msg))));
});
_connectionHealth ??= connection.state.subscribe((s) {
final h = connectionHealth(s);
if (h != null) health.value = h;
});
} }
/// Opens [url] (with [headers] on every request); false on failure. A /// Opens [url] (with [headers] on every request); false on failure. A
@@ -593,8 +615,6 @@ class SessionCoordinator {
keepPlayer: keepPlayer, keepPlayer: keepPlayer,
onFreshPlayer: () => switchTrace.mark(mediaId, SwitchTrace.player)); onFreshPlayer: () => switchTrace.mark(mediaId, SwitchTrace.player));
switchTrace.mark(mediaId, promoted ? SwitchTrace.preloaded : SwitchTrace.opened); switchTrace.mark(mediaId, promoted ? SwitchTrace.preloaded : SwitchTrace.opened);
health.value =
const HealthStatus(HealthSeverity.ok, 'connected');
ok = true; ok = true;
} catch (e) { } catch (e) {
debugPrint('open media failed: $e'); debugPrint('open media failed: $e');
@@ -688,8 +708,6 @@ class SessionCoordinator {
return playback.preload(url, headers: headers, ytdlPath: path, fresh: fresh); return playback.preload(url, headers: headers, ytdlPath: path, fresh: fresh);
} }
bool _relayWaitShown = false;
/// The server-fetched media this viewer opens once it is live. /// The server-fetched media this viewer opens once it is live.
int _awaitLiveFor = -1; int _awaitLiveFor = -1;
@@ -700,33 +718,6 @@ class SessionCoordinator {
bool _waitForSource(StateBroadcast r, MediaRelay relay) => bool _waitForSource(StateBroadcast r, MediaRelay relay) =>
!relay.live && !relay.sourceGone && (relay.fetch || r.members.any((m) => m.id == relay.source)); !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 /// 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 /// 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 /// video and audio). A relay started in Up next ([queueId]) keeps its
@@ -948,8 +939,6 @@ class SessionCoordinator {
_awaitLiveFor = r.mediaId; _awaitLiveFor = r.mediaId;
final who = r.members.where((m) => m.id == relay.source).firstOrNull?.name; 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'); _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 { } else {
await _openMedia(_relayMediaUrlFor(name, r.mediaId, relay.tracks, relay.queueId)); 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)); _relayMediaUrlFor((r.media?['name'] as String?) ?? '', r.mediaId, relay.tracks, relay.queueId));
} }
_jellyfinTransport(r); _jellyfinTransport(r);
_relayStatus(r);
if (!_playerReady || _openUrl == null) { if (!_playerReady || _openUrl == null) {
_wasPlayAllowed = false; _wasPlayAllowed = false;
_updateStarving(r, 0);
return; return;
} }
// Server clock: serverTime in the state is the server's. // Server clock: serverTime in the state is the server's.
@@ -1014,7 +1001,6 @@ class SessionCoordinator {
_seekRetries = 0; _seekRetries = 0;
} }
await playback.apply(actions); await playback.apply(actions);
_updateStarving(r, playback.bufferedSecsAhead);
} }
/// Mirrors the server's ready flag for self into [_lastReady] (force /// Mirrors the server's ready flag for self into [_lastReady] (force
@@ -1051,7 +1037,6 @@ class SessionCoordinator {
Future<void> _unloadForOut() async { Future<void> _unloadForOut() async {
_wasPlayAllowed = false; _wasPlayAllowed = false;
_awaitLiveFor = -1; _awaitLiveFor = -1;
starving.value = null;
if (_mediaId == -1 && _openUrl == null) return; if (_mediaId == -1 && _openUrl == null) return;
_mediaId = -1; _mediaId = -1;
_playerMediaId = -1; _playerMediaId = -1;
@@ -1085,60 +1070,29 @@ class SessionCoordinator {
} catch (_) {} } 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) { void _onFeed(FeedMessage f) {
final r = serverState.peek();
final event = FeedEvent.of(f, final event = FeedEvent.of(f,
selfId: connection.memberId, selfId: connection.memberId,
readyCount: f.action == 'ready' readyCount: f.action == 'ready' ? r?.members.where((m) => m.ready && countsForReady(m)).length ?? 0 : 0,
? serverState.peek()?.members.where((m) => m.ready && countsForReady(m)).length ?? 0 // The state goes out before its feed: the item is in it.
: 0); 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); 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 <QueueItem>[]).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 /// Someone poked this member: sound (even when its switch is off; the
/// volume still applies), a toast, and the window asks for attention. /// volume still applies), a toast, and the window asks for attention.
@@ -1176,7 +1130,10 @@ class SessionCoordinator {
Future<void> _tick() async { Future<void> _tick() async {
final now = DateTime.now().toUtc(); 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 r = serverState.peek();
final online = connection.state.peek().online; final online = connection.state.peek().online;
if (online && connection.token.isNotEmpty && r != null && _playerReady) { if (online && connection.token.isNotEmpty && r != null && _playerReady) {
@@ -2038,6 +1995,7 @@ class SessionCoordinator {
if (!rejoin.reconnect) return; if (!rejoin.reconnect) return;
_log.info('reconnected: ${rejoin.resumed ? 'resumed' : 'a new member'}' _log.info('reconnected: ${rejoin.resumed ? 'resumed' : 'a new member'}'
'${rejoin.sameServer ? '' : ' (the server restarted)'}'); '${rejoin.sameServer ? '' : ' (the server restarted)'}');
if (!rejoin.sameServer) restarts.value++;
if (rejoin.resumed) return; if (rejoin.resumed) return;
final before = serverState.peek(); final before = serverState.peek();
final raw = welcome.state; final raw = welcome.state;
@@ -2107,16 +2065,9 @@ class SessionCoordinator {
/// "Stop reconnecting". /// "Stop reconnecting".
void cancelReconnect() => connection.cancelReconnect(); void cancelReconnect() => connection.cancelReconnect();
Future<void> cancelConnect() async { Future<void> cancelConnect() => connection.cancelConnect();
await connection.cancelConnect();
health.value = const HealthStatus(HealthSeverity.info, 'cancelled');
}
void retry() { void retry() => connection.retry();
if (!connection.retry()) return;
health.value =
const HealthStatus(HealthSeverity.info, 'retrying…');
}
/// App close, before [leave]: the players go first (no mpv may outlive /// App close, before [leave]: the players go first (no mpv may outlive
/// the engine). The Jellyfin stop report [leave] sends keeps the /// the engine). The Jellyfin stop report [leave] sends keeps the
@@ -2158,11 +2109,7 @@ class SessionCoordinator {
} catch (_) {} } catch (_) {}
// The last lines go before the token does, then the bye. // The last lines go before the token does, then the bye.
await connection.close(bye: bye); await connection.close(bye: bye);
if (!silent) { if (!silent) serverState.value = null;
serverState.value = null;
starving.value = null;
health.value = const HealthStatus(HealthSeverity.info, 'idle');
}
} }
Future<void> dispose() async { Future<void> dispose() async {
@@ -2175,7 +2122,7 @@ class SessionCoordinator {
await _feedSub?.cancel(); await _feedSub?.cancel();
await _nudgeSub?.cancel(); await _nudgeSub?.cancel();
await _errSub?.cancel(); await _errSub?.cancel();
_connectionHealth?.call(); _statusEffect?.call();
_bufferSub?.call(); _bufferSub?.call();
_aniList?.close(); _aniList?.close();
await connection.dispose(); await connection.dispose();
+330
View File
@@ -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<MemberView> 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<String> 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 <MemberView>[]).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 = <String>{};
final _seenHeld = <int>{};
final _started = <int>{};
bool _replaced = false;
int _restarts = 0;
bool _first = true;
List<AppEvent> step(Status s, DateTime now) {
final out = <AppEvent>[];
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;
}
}
+50 -5
View File
@@ -6,6 +6,7 @@ import 'package:flutter/services.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import '../controls/frame.dart' show DoubleClickListener; import '../controls/frame.dart' show DoubleClickListener;
import '../controls/session_controls.dart' show controlsBottomHeight;
import '../files/folder_store.dart'; import '../files/folder_store.dart';
import '../files/ui/files_page.dart' show showFilesFolder; import '../files/ui/files_page.dart' show showFilesFolder;
import '../jellyfin/accounts.dart'; import '../jellyfin/accounts.dart';
@@ -98,6 +99,9 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
final _chat = TextEditingController(); final _chat = TextEditingController();
final _chatFocus = FocusNode(); final _chatFocus = FocusNode();
final _playerKey = GlobalKey(); 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; Alignment _corner = Alignment.bottomRight;
Offset? _drag; // mini top-left while dragging Offset? _drag; // mini top-left while dragging
@@ -187,6 +191,17 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
_autoFull = false; _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. // Chat that arrives while the panel is closed.
_stops.add(effect(() { _stops.add(effect(() {
final log = coord.feedLog.value; final log = coord.feedLog.value;
@@ -250,6 +265,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
_picker.dispose(); _picker.dispose();
_chat.dispose(); _chat.dispose();
_chatFocus.dispose(); _chatFocus.dispose();
_controlsShown.dispose();
if (_fullscreen) _setWindowFullscreen(false); if (_fullscreen) _setWindowFullscreen(false);
super.dispose(); super.dispose();
} }
@@ -771,16 +787,44 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
), ),
), ),
if (online && _shown != PlayerMode.none) _playerLayer(area), if (online && _shown != PlayerMode.none) _playerLayer(area),
Positioned(left: 16, bottom: _fullscreen ? 90 : 16, child: PartyToasts( _toasts(full && online),
onOpenChat: () {
if (!_chatOpen) _toggleChat();
},
)),
], ],
); );
}); });
} }
/// 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<bool>(
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( Offset _cornerPos(Size area, Alignment a, Size size) => Offset(
a.x < 0 ? _miniMargin : (area.width - size.width - _miniMargin).clamp(0, double.infinity), 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), a.y < 0 ? _miniMargin : (area.height - size.height - _miniMargin).clamp(0, double.infinity),
@@ -852,6 +896,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
onLeave: _confirmLeave, onLeave: _confirmLeave,
videoBuilder: widget.videoBuilder, videoBuilder: widget.videoBuilder,
windowControls: widget.windowControls, windowControls: widget.windowControls,
controlsShown: _controlsShown,
), ),
), ),
), ),
+5
View File
@@ -30,6 +30,8 @@ class AvatarInfo {
/// How it gets the media and its buffer ([MemberDelivery.detail]), for /// How it gets the media and its buffer ([MemberDelivery.detail]), for
/// the hover; empty when unknown. /// the hover; empty when unknown.
final String delivery; final String delivery;
/// Their Starsync version (`members[].app`), for the hover; '' unknown.
final String app;
const AvatarInfo({ const AvatarInfo({
this.id = '', this.id = '',
required this.name, required this.name,
@@ -44,6 +46,7 @@ class AvatarInfo {
this.self = false, this.self = false,
this.error = '', this.error = '',
this.delivery = '', this.delivery = '',
this.app = '',
}); });
/// Sitting out. /// Sitting out.
@@ -136,6 +139,7 @@ AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator
self: self, self: self,
error: m.error, error: m.error,
delivery: delivery == null ? '' : memberDeliveryOf(m, r!, delivery).detail, delivery: delivery == null ? '' : memberDeliveryOf(m, r!, delivery).detail,
app: m.app,
); );
} }
@@ -332,6 +336,7 @@ class _AvatarStackState extends State<AvatarStack> {
? visible[i].name ? visible[i].name
: "${visible[i].name} can't play this: ${visible[i].error}", : "${visible[i].name} can't play this: ${visible[i].error}",
if (visible[i].delivery.isNotEmpty) visible[i].delivery, if (visible[i].delivery.isNotEmpty) visible[i].delivery,
if (visible[i].app.isNotEmpty) 'Starsync ${visible[i].app}',
].join('\n'), ].join('\n'),
child: StatusAvatar(info: visible[i], size: size), child: StatusAvatar(info: visible[i], size: size),
), ),
@@ -8,13 +8,14 @@ import 'motion.dart';
Color _tone(SourceState s, ColorScheme scheme) => switch (s) { Color _tone(SourceState s, ColorScheme scheme) => switch (s) {
SourceState.ok => scheme.primary, SourceState.ok => scheme.primary,
SourceState.slow || SourceState.waiting => Colors.amber, SourceState.slow || SourceState.waiting || SourceState.gone => Colors.amber,
SourceState.left => Colors.redAccent.shade200, SourceState.left => Colors.redAccent.shade200,
}; };
IconData _icon(Delivery d) => switch (d.state) { IconData _icon(Delivery d) => switch (d.state) {
SourceState.left => Icons.link_off_rounded, SourceState.left => Icons.link_off_rounded,
SourceState.waiting => Icons.hourglass_top_rounded, SourceState.waiting => Icons.hourglass_top_rounded,
SourceState.gone => Icons.wifi_off_rounded,
_ => switch (d.kind) { _ => switch (d.kind) {
DeliveryKind.memberRelay => Icons.cell_tower, DeliveryKind.memberRelay => Icons.cell_tower,
DeliveryKind.serverFetch => Icons.cloud_download_outlined, 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: /// Where the current media comes from and how it reaches the others:
/// "Foxyz passes it on through the server", "3 through the server · /// "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 /// 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 /// video's (past the mark: keeping up). Amber when slow, waiting or the
/// when the source left. /// source's connection dropped, red when the source left.
class SourceLine extends StatelessWidget { class SourceLine extends StatelessWidget {
final Delivery delivery; final Delivery delivery;
const SourceLine({super.key, required this.delivery}); const SourceLine({super.key, required this.delivery});
@@ -40,7 +41,7 @@ class SourceLine extends StatelessWidget {
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
final tone = _tone(d.state, scheme); final tone = _tone(d.state, scheme);
final detail = d.detail; 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( return Tooltip(
message: d.explain, message: d.explain,
waitDuration: const Duration(milliseconds: 400), waitDuration: const Duration(milliseconds: 400),
+5 -8
View File
@@ -13,6 +13,7 @@ import '../jellyfin/home_page.dart' show libraryIcon, isVideoLibrary;
import '../jellyfin/picker_source.dart'; import '../jellyfin/picker_source.dart';
import '../jellyfin/session_media.dart'; import '../jellyfin/session_media.dart';
import '../screens/home/media_picker.dart'; import '../screens/home/media_picker.dart';
import '../session/app_events.dart' show ActionFailed;
import '../state/session_state.dart'; import '../state/session_state.dart';
import 'library_link.dart'; import 'library_link.dart';
import 'motion.dart'; import 'motion.dart';
@@ -196,21 +197,17 @@ class _LibraryTabState extends State<LibraryTab> {
return pick is JellyfinMedia ? add(pick, next: next) : pick as String; 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<void> _cardQueue(JellyfinSelection sel, {bool next = false}) async { Future<void> _cardQueue(JellyfinSelection sel, {bool next = false}) async {
final error = await _queueJellyfin(sel, next: next); final error = await _queueJellyfin(sel, next: next);
if (error != null && mounted) { if (error != null) SessionState.instance.notifier.notify(ActionFailed(error));
ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(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<void> _doublePutUp(JellyfinSelection sel) async { Future<void> _doublePutUp(JellyfinSelection sel) async {
_c.select(sel); _c.select(sel);
final error = await _putUpJellyfin(sel); final error = await _putUpJellyfin(sel);
if (error != null && mounted) { if (error != null) SessionState.instance.notifier.notify(ActionFailed(error));
ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(error)));
}
} }
// -------------------------------------------------------------- build // -------------------------------------------------------------- build
+53 -44
View File
@@ -121,10 +121,9 @@ class PartyStrip extends StatelessWidget {
label: t.label, label: t.label,
selected: t == tab, selected: t == tab,
compact: compact, compact: compact,
// The version: always in sight under Settings, // The version a rest of the mouse away (also at
// the full one a rest of the mouse away. // the bottom of Settings and in About).
tooltip: t == ShellTab.settings ? '$appName ${displayVersion()}' : null, tooltip: t == ShellTab.settings ? '$appName ${displayVersion()}' : null,
caption: t == ShellTab.settings ? shortVersion() : null,
onTap: () => onTab(t), onTap: () => onTab(t),
), ),
], ],
@@ -281,13 +280,9 @@ class _ServerLabel extends StatelessWidget {
@override @override
Widget build(BuildContext context) => SignalBuilder( Widget build(BuildContext context) => SignalBuilder(
builder: (context) { builder: (context) {
final coord = SessionState.instance.coordinator; // "connecting…", "reconnecting…", "ready 2/3" (the ready counter
final r = coord.serverState.value; // counts who the presence policy counts), from the one status.
// The ready counter counts who the presence policy counts (not away, final line = SessionState.instance.coordinator.status.value.party;
// not gone, able to play it).
final counted = (r?.members ?? const <MemberView>[]).where(countsForReady).toList();
final ready = counted.where((m) => m.ready).length;
final stale = coord.stateStale.value;
return Column( return Column(
key: const ValueKey('server-label'), key: const ValueKey('server-label'),
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -298,11 +293,7 @@ class _ServerLabel extends StatelessWidget {
style: const TextStyle(fontWeight: FontWeight.w600, height: 1.1), style: const TextStyle(fontWeight: FontWeight.w600, height: 1.1),
), ),
Text( Text(
r == null line,
? 'connecting…'
: stale
? 'reconnecting…'
: 'ready $ready/${counted.length}',
style: TextStyle( style: TextStyle(
color: scheme.onSurfaceVariant, color: scheme.onSurfaceVariant,
fontSize: 12, fontSize: 12,
@@ -373,8 +364,6 @@ class StripTab extends StatelessWidget {
final VoidCallback onTap; final VoidCallback onTap;
/// Shown after resting on the tab. /// Shown after resting on the tab.
final String? tooltip; final String? tooltip;
/// Small text under the tab (the version under Settings).
final String? caption;
const StripTab({ const StripTab({
super.key, super.key,
required this.label, required this.label,
@@ -382,7 +371,6 @@ class StripTab extends StatelessWidget {
required this.onTap, required this.onTap,
this.compact = false, this.compact = false,
this.tooltip, this.tooltip,
this.caption,
}); });
@override @override
@@ -399,7 +387,6 @@ class StripTab extends StatelessWidget {
child: SizedBox( child: SizedBox(
height: partyStripHeight, height: partyStripHeight,
child: Stack( child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [
Column( 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,19 +488,32 @@ class MembersCard extends StatelessWidget {
final address = hosting final address = hosting
? null ? null
: '${session.lastForm.host}:${session.lastForm.port}'; : '${session.lastForm.host}:${session.lastForm.port}';
final delivery = r == null final delivery = coord.status.value.delivery;
? null final server = session.serverVersion.value;
: deliveryOf(r, selfId: coord.connection.memberId, names: coord.memberNames);
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: Text( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
['In the server', ?address].join(' · '), ['In the server', ?address].join(' · '),
style: const TextStyle(fontWeight: FontWeight.w600), 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), if (delivery != null) SourceLine(delivery: delivery),
for (final m in r?.members ?? const <MemberView>[]) for (final m in r?.members ?? const <MemberView>[])
@@ -618,6 +597,7 @@ class _MemberTile extends StatelessWidget {
crossAxisAlignment: WrapCrossAlignment.center, crossAxisAlignment: WrapCrossAlignment.center,
children: [ children: [
Text(info.self ? '${m.name} (you)' : m.name), 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.host) const Pill('host', Colors.amber),
if (info.relaying) if (info.relaying)
const Pill('passing the video on', Colors.lightBlueAccent), 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 /// Hosting: the addresses friends on this network can join, the first
/// one behind "Copy invite address", and what friends elsewhere need. /// one behind "Copy invite address", and what friends elsewhere need.
class _HostInvite extends StatefulWidget { class _HostInvite extends StatefulWidget {
+15 -146
View File
@@ -1,154 +1,22 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; 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 '../session/session_coordinator.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
import '../widgets/app_menu.dart' show AppGlass; import '../widgets/app_menu.dart' show AppGlass;
import 'avatar.dart'; import 'avatar.dart';
import 'motion.dart'; import 'motion.dart';
/// What the player's status pill says, most pressing first. export '../session/status.dart' show StatusAction, StatusKind, StatusLevel, StatusLine, nameList;
enum StatusKind { reconnecting, problem, connecting, finished, loading, you, cannotPlay, waiting }
/// The pill's button: stop reconnecting, retry (reconnect), reload the /// The pill's line from the session's [SessionCoordinator.status]
/// video (your player could not open it), or mark yourself ready (the /// (tracked: call inside a builder); "Connecting…" only once it has taken
/// whole pill). /// [PlayerStatus.connectGrace] ([slowConnect]).
enum StatusAction { cancel, retry, reload, ready } StatusLine? playerStatusNow({bool slowConnect = false}) {
final line = SessionState.instance.coordinator.status.value.line;
/// One status line: [text], a muted [detail], a coloured [tone] for if (line?.kind == StatusKind.connecting && !slowConnect) return null;
/// problems (warn, error), the members it is about ([who]; [self] for you) return line;
/// and a loading [progress] (0..1).
class StatusView {
final StatusKind kind;
final String text;
final String? detail;
final HealthSeverity? tone;
final List<MemberView> 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<String> 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 player's one status pill (bottom centre): frosted dark glass with /// The player's one status pill (bottom centre): frosted dark glass with
@@ -167,7 +35,7 @@ class PlayerStatus extends StatefulWidget {
class _PlayerStatusState extends State<PlayerStatus> { class _PlayerStatusState extends State<PlayerStatus> {
Timer? _connectTimer; Timer? _connectTimer;
bool _slow = false; bool _slow = false;
StatusView? _last; StatusLine? _last;
@override @override
void dispose() { void dispose() {
@@ -176,8 +44,8 @@ class _PlayerStatusState extends State<PlayerStatus> {
} }
// "Connecting…" only once it takes a while. // "Connecting…" only once it takes a while.
void _trackConnecting(HealthStatus h) { void _trackConnecting(StatusLine? line) {
final connecting = h.severity == HealthSeverity.info && h.message.startsWith('connecting'); final connecting = line?.kind == StatusKind.connecting;
if (!connecting) { if (!connecting) {
_connectTimer?.cancel(); _connectTimer?.cancel();
_connectTimer = null; _connectTimer = null;
@@ -206,7 +74,7 @@ class _PlayerStatusState extends State<PlayerStatus> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return PartyWatch(builder: (context) { return PartyWatch(builder: (context) {
final coord = SessionState.instance.coordinator; final coord = SessionState.instance.coordinator;
_trackConnecting(coord.health.value); _trackConnecting(coord.status.value.line);
final view = playerStatusNow(slowConnect: _slow); final view = playerStatusNow(slowConnect: _slow);
if (view != null) _last = view; if (view != null) _last = view;
final shown = _last; final shown = _last;
@@ -262,7 +130,7 @@ class PlayerGlass extends StatelessWidget {
} }
class _StatusLine extends StatelessWidget { class _StatusLine extends StatelessWidget {
final StatusView view; final StatusLine view;
final SessionCoordinator coord; final SessionCoordinator coord;
final ValueChanged<StatusAction> onAction; final ValueChanged<StatusAction> onAction;
const _StatusLine({super.key, required this.view, required this.coord, required this.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; final v = view;
switch (v.kind) { switch (v.kind) {
case StatusKind.problem: 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, return Icon(error ? Icons.error_outline_rounded : Icons.warning_amber_rounded,
size: 20, color: error ? Colors.redAccent.shade100 : Colors.amber); size: 20, color: error ? Colors.redAccent.shade100 : Colors.amber);
case StatusKind.reconnecting: case StatusKind.reconnecting:
return const _Spinner(color: Colors.amber); return const _Spinner(color: Colors.amber);
case StatusKind.connecting: case StatusKind.connecting:
case StatusKind.fetching:
return const _Spinner(color: Colors.white70); return const _Spinner(color: Colors.white70);
case StatusKind.finished: case StatusKind.finished:
return null; return null;
@@ -63,6 +63,9 @@ class SessionPlayer extends StatefulWidget {
/// Window buttons in the fullscreen top bar (off in tests: they need /// Window buttons in the fullscreen top bar (off in tests: they need
/// window_manager). /// window_manager).
final bool windowControls; 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<bool>? controlsShown;
const SessionPlayer({ const SessionPlayer({
super.key, super.key,
required this.mode, required this.mode,
@@ -79,6 +82,7 @@ class SessionPlayer extends StatefulWidget {
this.unread = 0, this.unread = 0,
this.videoBuilder, this.videoBuilder,
this.windowControls = true, this.windowControls = true,
this.controlsShown,
}); });
@override @override
@@ -96,7 +100,8 @@ class _SessionPlayerState extends State<SessionPlayer> {
int _shaderNoticeSeen = 0; int _shaderNoticeSeen = 0;
bool _miniHover = false; bool _miniHover = false;
// Whether the controls are up (their frame reports it). // Whether the controls are up (their frame reports it).
final _controlsShown = ValueNotifier(true); final _ownShown = ValueNotifier(true);
ValueNotifier<bool> get _controlsShown => widget.controlsShown ?? _ownShown;
// The mouse on a floating element, or the Up next popover open, keeps // The mouse on a floating element, or the Up next popover open, keeps
// the controls up. // the controls up.
final _hold = ControlsHold(); final _hold = ControlsHold();
@@ -155,7 +160,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
_stopShaderWatch(); _stopShaderWatch();
_stopPopoverWatch(); _stopPopoverWatch();
_noFocus.dispose(); _noFocus.dispose();
_controlsShown.dispose(); _ownShown.dispose();
_hold.dispose(); _hold.dispose();
_model.dispose(); _model.dispose();
super.dispose(); super.dispose();
+44 -5
View File
@@ -13,6 +13,7 @@ import '../jellyfin/accounts_panel.dart';
import '../schedule/animeflow.dart'; import '../schedule/animeflow.dart';
import '../screens/home/about_page.dart'; import '../screens/home/about_page.dart';
import '../screens/home/settings_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 '../session/auto_sit_out.dart' show AutoSitOutTimes;
import '../sfx/sfx.dart'; import '../sfx/sfx.dart';
import '../sfx/sound_bank.dart'; import '../sfx/sound_bank.dart';
@@ -228,6 +229,19 @@ class SettingsTab extends StatelessWidget {
Text(section.label, style: Theme.of(context).textTheme.headlineSmall), Text(section.label, style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 14), const SizedBox(height: 14),
..._page(context), ..._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<AnimeFlowCard> {
} }
} }
/// What pops up (chat pop-ups: [AppSettings.chatPopups]) and what makes /// What pops up (chat pop-ups: [AppSettings.chatPopups]), what reaches
/// a sound. /// you while you sit out or the window is in the background
/// ([AppSettings.notifyPeople]), and what makes a sound.
class NotificationsInfo extends StatelessWidget { class NotificationsInfo extends StatelessWidget {
const NotificationsInfo({super.key}); const NotificationsInfo({super.key});
@@ -753,8 +768,9 @@ class NotificationsInfo extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
line('Joins, leaves and video changes', line('Joins, leaves, lost connections and video changes',
'Pop up bottom left for 5 s, with the person\'s picture'), "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(
padding: const EdgeInsets.symmetric(vertical: 6), padding: const EdgeInsets.symmetric(vertical: 6),
child: Row( 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('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('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,
),
),
], ],
), ),
), ),
+80 -31
View File
@@ -5,29 +5,29 @@ import 'package:signals/signals_flutter.dart';
import '../session/session_coordinator.dart' show ToastItem; import '../session/session_coordinator.dart' show ToastItem;
import '../state/session_state.dart'; import '../state/session_state.dart';
import '../state/settings.dart';
import 'avatar.dart'; import 'avatar.dart';
import 'motion.dart'; import 'motion.dart';
/// One toast on screen: who (null = the app itself), what, and whether it /// One toast on screen: who (null = the app itself), what, a picture
/// is on its way out. /// (an Up next item's), and whether it is on its way out.
class ShellToast { class ShellToast {
final int id; final int id;
final AvatarInfo? who; final AvatarInfo? who;
final IconData? icon; final IconData? icon;
final String text; final String text;
final bool chat; final bool chat;
final String thumb;
bool leaving = false; 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 /// Splits a Notifier toast into who and what: feed and chat toasts name
/// name their [ToastItem.actor] and start with it ("Bob joined", "Bob: /// their [ToastItem.actor] and start with it ("Bob joined", "Bob: hi");
/// hi"); the rest are the app's. /// the rest are the app's.
ShellToast shellToastFor(ToastItem t, AvatarInfo Function(String name) avatar) { ShellToast shellToastFor(ToastItem t, AvatarInfo Function(String name) avatar) {
final n = t.actor; final n = t.actor;
if (n == null || n.isEmpty) return ShellToast(id: t.id, text: t.text, icon: Icons.info_outline); if (n == null || n.isEmpty) return ShellToast(id: t.id, text: t.text, icon: Icons.info_outline, thumb: t.thumb);
if (t.text.startsWith('$n: ')) { if (t.chat && t.text.startsWith('$n: ')) {
return ShellToast(id: t.id, who: avatar(n), text: t.text.substring(n.length + 2), chat: true); 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; 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 ? Icons.login
: rest.startsWith('left') : rest.startsWith('left')
? Icons.logout ? Icons.logout
: rest.startsWith('lost connection')
? Icons.wifi_off_rounded
: rest.startsWith('is back')
? Icons.wifi_rounded
: rest.startsWith('sat you out') : rest.startsWith('sat you out')
? Icons.bedtime ? Icons.bedtime
: rest.startsWith('nudged') : rest.startsWith('nudged')
? Icons.notifications_active_outlined ? Icons.notifications_active_outlined
: rest.startsWith('changed') : rest.startsWith('changed')
? Icons.swap_horiz ? Icons.swap_horiz
: rest.startsWith('added')
? Icons.playlist_add_rounded
: Icons.info_outline; : Icons.info_outline;
return ShellToast(id: t.id, who: avatar(n), text: rest, icon: icon); 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 /// The Notifier's toasts as cards with avatars, bottom-left: each slides
/// slides in, stays [life], then slides out and collapses its gap. A click /// in, stays while the Notifier keeps it (it drops it after its time),
/// on a chat toast opens the chat ([onOpenChat]); the rest let clicks /// then slides out and collapses its gap. A click on a chat toast opens
/// through. Chat toasts follow [AppSettings.chatPopups]. /// 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 { class PartyToasts extends StatefulWidget {
final Duration life;
final VoidCallback? onOpenChat; final VoidCallback? onOpenChat;
const PartyToasts({super.key, this.life = const Duration(seconds: 5), this.onOpenChat}); const PartyToasts({super.key, this.onOpenChat});
@override @override
State<PartyToasts> createState() => _PartyToastsState(); State<PartyToasts> createState() => _PartyToastsState();
@@ -72,33 +78,40 @@ class _PartyToastsState extends State<PartyToasts> {
_seen = {for (final t in coord.toasts.peek()) t.id}; _seen = {for (final t in coord.toasts.peek()) t.id};
_stop = effect(() { _stop = effect(() {
final items = coord.toasts.value; 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]; final fresh = [for (final t in items) if (!_seen.contains(t.id)) t];
_seen = {for (final t in items) t.id}; final gone = _seen.difference(ids);
if (fresh.isEmpty) return; _seen = ids;
if (fresh.isEmpty && gone.isEmpty) return;
// Only new toasts re-run this, not the party they mention. // Only new toasts re-run this, not the party they mention.
untracked(() { untracked(() {
final playing = coord.serverState.value?.transport == 'playing';
final popups = AppSettings.instance.chatPopups.value;
for (final t in fresh) { for (final t in fresh) {
final toast = shellToastFor(t, (n) => avatarForName(n, coord, t.actorId)); _add(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);
} }
if (gone.isNotEmpty) _leave(gone);
}); });
}); });
} }
void _add(ShellToast t) { void _add(ShellToast t) {
// Not from inside the effect's build-time notification. // Not from inside the effect's build-time notification.
scheduleMicrotask(() {
if (mounted) setState(() => _shown.add(t));
});
}
void _leave(Set<int> ids) {
scheduleMicrotask(() { scheduleMicrotask(() {
if (!mounted) return; if (!mounted) return;
setState(() => _shown.add(t)); final leaving = [for (final t in _shown) if (ids.contains(t.id) && !t.leaving) t];
_timers.add(Timer(widget.life, () { if (leaving.isEmpty) return;
if (mounted) setState(() => t.leaving = true); setState(() {
})); for (final t in leaving) {
_timers.add(Timer(widget.life + easeTime, () { t.leaving = true;
if (mounted) setState(() => _shown.remove(t)); }
});
_timers.add(Timer(easeTime, () {
if (mounted) setState(() => _shown.removeWhere(leaving.contains));
})); }));
}); });
} }
@@ -114,7 +127,7 @@ class _PartyToastsState extends State<PartyToasts> {
void _open(ShellToast t) { void _open(ShellToast t) {
widget.onOpenChat?.call(); widget.onOpenChat?.call();
setState(() => t.leaving = true); SessionState.instance.notifier.dismiss(t.id);
} }
@override @override
@@ -206,6 +219,8 @@ class ToastCard extends StatelessWidget {
children: [ children: [
if (who != null) if (who != null)
StatusAvatar(info: who, size: 38, badges: false) StatusAvatar(info: who, size: 38, badges: false)
else if (toast.thumb.isNotEmpty)
_Thumb(hash: toast.thumb)
else else
CircleAvatar( CircleAvatar(
radius: 19, radius: 19,
@@ -237,6 +252,8 @@ class ToastCard extends StatelessWidget {
], ],
Text( Text(
toast.text, toast.text,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
fontSize: toast.chat ? 15 : 14, fontSize: toast.chat ? 15 : 14,
color: toast.chat || who == null ? scheme.onSurface : scheme.onSurfaceVariant, 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);
},
),
),
);
}
}
+18 -13
View File
@@ -16,7 +16,6 @@ import '../platform/ports/ports.dart';
import '../sfx/sfx.dart'; import '../sfx/sfx.dart';
import '../sfx/sound_bank.dart'; import '../sfx/sound_bank.dart';
import '../sfx/synth_sfx.dart'; import '../sfx/synth_sfx.dart';
import '../session/health.dart';
import '../session/notifier.dart'; import '../session/notifier.dart';
import '../session/connection.dart' show Connection; import '../session/connection.dart' show Connection;
import '../session/identity.dart' show ClientIdentity; import '../session/identity.dart' show ClientIdentity;
@@ -163,7 +162,6 @@ class SessionState {
final route = signal(AppRoute.home); final route = signal(AppRoute.home);
final recents = signal<List<RecentServer>>(const []); final recents = signal<List<RecentServer>>(const []);
final status = signal(const HealthStatus(HealthSeverity.info, 'idle'));
/// Owns the app's audio device ([SynthSfx.dispose] on close). /// Owns the app's audio device ([SynthSfx.dispose] on close).
final SynthSfx sfx = SynthSfx( final SynthSfx sfx = SynthSfx(
volume: () => AppSettings.instance.soundVolume.peek(), volume: () => AppSettings.instance.soundVolume.peek(),
@@ -173,8 +171,15 @@ class SessionState {
); );
/// The platform (window, links, folders, yt-dlp, notifications). /// The platform (window, links, folders, yt-dlp, notifications).
final Ports ports = desktopPorts(); final Ports ports = desktopPorts();
/// Every toast, sound and activity-log line goes through it. /// Every toast, sound, system notification and activity-log line goes
late final Notifier notifier = Notifier(sfx: sfx, attention: ports.shell.requestAttention); /// 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; lost/back go to the notifier.
/// The link to the server, with this install's stored identity /// The link to the server, with this install's stored identity
/// (`hello.client` / `seq`); lost/back go to the notifier. /// (`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 /// The joined server's own name (`/health`), '' until known or when it
/// only has the default name. /// only has the default name.
final serverLabel = signal(''); 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 /// A solo session is becoming a hosted one: the player has nothing open
/// until the media is up again on the new server. /// until the media is up again on the new server.
final switching = signal(false); final switching = signal(false);
@@ -367,8 +375,6 @@ class SessionState {
final op = ++_op; final op = ++_op;
if (form != null) _rememberForm(form); if (form != null) _rememberForm(form);
try { try {
status.value =
const HealthStatus(HealthSeverity.info, 'starting server…');
await stopServer(); await stopServer();
if (op != _op) throw const ConnectCancelled(); if (op != _op) throw const ConnectCancelled();
final hostKey = newHostKey(); final hostKey = newHostKey();
@@ -403,8 +409,6 @@ class SessionState {
); );
} catch (e, st) { } catch (e, st) {
debugPrint('server start failed: $e\n$st'); debugPrint('server start failed: $e\n$st');
status.value = HealthStatus(
HealthSeverity.error, 'cannot start server on port ${params.port}: $e');
rethrow; rethrow;
} }
if (op != _op) { if (op != _op) {
@@ -414,8 +418,7 @@ class SessionState {
_server = server; _server = server;
coordinator.hostFile = (path) async => coordinator.hostFile = (path) async =>
identical(_server, server) ? server.setFile(path) : null; identical(_server, server) ? server.setFile(path) : null;
status.value = HealthStatus( serverVersion.value = appVersion;
HealthSeverity.ok, soloSession ? 'watching solo' : 'hosting on port ${server.port}');
try { try {
await coordinator.connect(ConnectParams( await coordinator.connect(ConnectParams(
host: '127.0.0.1', host: '127.0.0.1',
@@ -452,7 +455,6 @@ class SessionState {
_setupCrash = cause; _setupCrash = cause;
return; return;
} }
coordinator.health.value = HealthStatus(HealthSeverity.error, message);
serverCrash.value = message; serverCrash.value = message;
unawaited(leave().catchError((Object e) => debugPrint('leave after crash failed: $e'))); unawaited(leave().catchError((Object e) => debugPrint('leave after crash failed: $e')));
} }
@@ -465,9 +467,12 @@ class SessionState {
if (op != _op) throw const ConnectCancelled(); if (op != _op) throw const ConnectCancelled();
route.value = AppRoute.session; route.value = AppRoute.session;
serverLabel.value = ''; serverLabel.value = '';
serverVersion.value = '';
unawaited(probeHealth(params.host, '${params.port}', defaultHealthGet).then((h) { 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. // '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 _) {})); }).catchError((Object _) {}));
} finally { } finally {
await _saveFormQuietly(); await _saveFormQuietly();
@@ -524,7 +529,7 @@ class SessionState {
solo.value = false; solo.value = false;
coordinator.solo = false; coordinator.solo = false;
serverLabel.value = ''; serverLabel.value = '';
status.value = const HealthStatus(HealthSeverity.info, 'idle'); serverVersion.value = '';
route.value = AppRoute.home; route.value = AppRoute.home;
} }
} }
+17 -17
View File
@@ -6,8 +6,11 @@ import 'package:signals/signals_flutter.dart';
import '../controls/model/controls_prefs.dart'; import '../controls/model/controls_prefs.dart';
import '../shaders/shader_presets.dart'; import '../shaders/shader_presets.dart';
import '../session/notice_prefs.dart';
import 'settings_schema.dart'; import 'settings_schema.dart';
export '../session/notice_prefs.dart' show ChatPopups, NotifyPrefs;
// App preferences (Settings page), one Hive box. Signals start at their // App preferences (Settings page), one Hive box. Signals start at their
// defaults and take the stored values once [AppSettings.ensureLoaded] // defaults and take the stored values once [AppSettings.ensureLoaded]
// finishes; setters write through. // finishes; setters write through.
@@ -39,6 +42,7 @@ const _keySoundPresetFor = 'soundPresetFor';
const _keyUnreadyOnSwap = 'unreadyOnSwap'; const _keyUnreadyOnSwap = 'unreadyOnSwap';
const keyAutoSitOut = 'autoSitOut'; const keyAutoSitOut = 'autoSitOut';
const _keyChatPopups = 'chatPopups'; const _keyChatPopups = 'chatPopups';
const _keyNotifyPeople = 'notifyPeople';
const _keyShareDiagnostics = 'shareDiagnostics'; const _keyShareDiagnostics = 'shareDiagnostics';
enum HwdecMode { auto, off, custom } enum HwdecMode { auto, off, custom }
@@ -49,23 +53,6 @@ enum HwdecMode { auto, off, custom }
/// surface there ("Failed to create EGL surface"). Elsewhere 'auto'. /// surface there ("Failed to create EGL surface"). Elsewhere 'auto'.
String get autoHwdec => Platform.isWindows ? 'auto-copy' : '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 { class AppSettings {
static final AppSettings instance = AppSettings(); static final AppSettings instance = AppSettings();
@@ -100,6 +87,12 @@ class AppSettings {
final shareDiagnostics = signal(true); final shareDiagnostics = signal(true);
/// Chat message toasts (default: all). Applies live. /// Chat message toasts (default: all). Applies live.
final chatPopups = signal(ChatPopups.all); 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. /// App sounds (`sfx/`): 0..1, 0 = silent.
final soundVolume = signal(1.0); final soundVolume = signal(1.0);
/// `SfxKind` names switched off one by one. /// `SfxKind` names switched off one by one.
@@ -174,6 +167,8 @@ class AppSettings {
if (share is bool) shareDiagnostics.value = share; if (share is bool) shareDiagnostics.value = share;
final popups = box.get(_keyChatPopups); final popups = box.get(_keyChatPopups);
chatPopups.value = ChatPopups.values.where((m) => m.name == popups).firstOrNull ?? ChatPopups.all; 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); final vol = box.get(_keySoundVolume);
if (vol is num) soundVolume.value = vol.toDouble().clamp(0, 1); if (vol is num) soundVolume.value = vol.toDouble().clamp(0, 1);
final off = box.get(_keySoundsOff); final off = box.get(_keySoundsOff);
@@ -278,6 +273,11 @@ class AppSettings {
_put(_keyChatPopups, mode.name); _put(_keyChatPopups, mode.name);
} }
void setNotifyPeople(bool on) {
notifyPeople.value = on;
_put(_keyNotifyPeople, on);
}
void setSoundVolume(double v) { void setSoundVolume(double v) {
soundVolume.value = v.clamp(0, 1); soundVolume.value = v.clamp(0, 1);
_put(_keySoundVolume, soundVolume.value); _put(_keySoundVolume, soundVolume.value);
+48
View File
@@ -230,6 +230,46 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.0" 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: flutter_localizations:
dependency: transitive dependency: transitive
description: flutter description: flutter
@@ -858,6 +898,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.12" version: "0.7.12"
timezone:
dependency: transitive
description:
name: timezone
sha256: "9f4e5a1a0e4fef7dd3424842c0575db01bfb7a11730e8d55753fcd5c5c98767b"
url: "https://pub.dev"
source: hosted
version: "0.11.2"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
+3
View File
@@ -39,6 +39,9 @@ dependencies:
# App sounds (sfx/, platform/audio/): SoLoud + miniaudio, built from # App sounds (sfx/, platform/audio/): SoLoud + miniaudio, built from
# source by its build hook (needs only a C++ toolchain). # source by its build hook (needs only a C++ toolchain).
flutter_soloud: ^5.2.1 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 # The sounds are WAVs made in memory: no Ogg/Opus/FLAC codecs, so the
# build hook downloads and bundles no Xiph libraries. # build hook downloads and bundles no Xiph libraries.
hooks: hooks:
+2 -4
View File
@@ -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 // The session through drops, takeovers and goodbyes: real coordinators
// (real mpv), a real server, and FlakyProxy between a member and it. // (real mpv), a real server, and FlakyProxy between a member and it.
import 'dart:async'; import 'dart:async';
@@ -242,7 +239,7 @@ void main() {
final second = await member(server.boundPort, 'me', identity: install); final second = await member(server.boundPort, 'me', identity: install);
expect(second.connection.memberId, id, reason: 'the newer connection took the member over'); 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'); 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. // It does not fight back.
await Future.delayed(const Duration(seconds: 4)); await Future.delayed(const Duration(seconds: 4));
expect(first.connection.state.peek(), isA<ConnReplaced>()); expect(first.connection.state.peek(), isA<ConnReplaced>());
@@ -412,6 +409,7 @@ void main() {
final toasts = coord.toasts.peek(); final toasts = coord.toasts.peek();
expect(toasts.firstWhere((t) => t.text == 'Bob Jr joined').actor, 'Bob Jr'); expect(toasts.firstWhere((t) => t.text == 'Bob Jr joined').actor, 'Bob Jr');
final chat = toasts.firstWhere((t) => t.text == 'Bob Jr: hi there'); final chat = toasts.firstWhere((t) => t.text == 'Bob Jr: hi there');
expect(chat.chat, isTrue);
expect(chat.actor, 'Bob Jr'); expect(chat.actor, 'Bob Jr');
expect(chat.actorId, friend.memberId, reason: 'matched by member id'); expect(chat.actorId, friend.memberId, reason: 'matched by member id');
// Others' chat makes a sound, your own does not. // Others' chat makes a sound, your own does not.
+18 -3
View File
@@ -1,5 +1,6 @@
// restructure: rewrite in step 2 (Status + Notifier): delivery lines become // Who fetches the current media and how it spreads (the members card's
// part of the one computed status (and the source-gone half of P2). // source line, part of the one status), and each member's route and
// buffer.
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.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 r = _state(_relayed(const MediaRelay(source: 'm1', rate: 4100000, need: 2300000)), [bob, ann]);
final d = deliveryOf(r, selfId: 'm2', names: names)!; final d = deliveryOf(r, selfId: 'm2', names: names)!;
expect(d.state, SourceState.left); 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'); 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', () { test('channel down: waiting', () {
final r = _state(_relayed(const MediaRelay(source: 'm1')), [foxyz, bob]); final r = _state(_relayed(const MediaRelay(source: 'm1')), [foxyz, bob]);
expect(deliveryOf(r, selfId: 'm2')!.title, 'Waiting for Foxyz to reconnect'); expect(deliveryOf(r, selfId: 'm2')!.title, 'Waiting for Foxyz to reconnect');
+1 -1
View File
@@ -58,7 +58,7 @@ void main() {
expect(c.serverState.peek()!.position, closeTo(13.77, 0.1)); expect(c.serverState.peek()!.position, closeTo(13.77, 0.1));
await _poll(() => !c.playback.player.state.playing, 'player stopped'); await _poll(() => !c.playback.player.state.playing, 'player stopped');
expect(c.playback.positionSecs, greaterThan(12)); expect(c.playback.positionSecs, greaterThan(12));
expect(c.starving.peek(), contains('finished')); expect(c.status.peek().line?.kind, StatusKind.finished);
c.seekTo(2); c.seekTo(2);
await _poll( await _poll(
+3 -1
View File
@@ -80,9 +80,11 @@ void main() {
test('health parse: online, junk and protocol mismatch', () { test('health parse: online, junk and protocol mismatch', () {
final ok = ServerHealth.parse(jsonEncode( 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.online, isTrue);
expect(ok.name, 'Den'); 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.viewers, 2);
expect(ok.needsUpdate, isFalse); expect(ok.needsUpdate, isFalse);
expect(ServerHealth.parse('{"ok":true,"proto":-1}').needsUpdate, isTrue); expect(ServerHealth.parse('{"ok":true,"proto":-1}').needsUpdate, isTrue);
+219 -19
View File
@@ -1,13 +1,16 @@
// restructure: rewrite in step 2 (Status + Notifier): every row, when // The Notifier policy as a table: one row per AppEvent subtype in the
// noticeFor takes a context. // plain context (in, with others, the window focused, paused, default
// // choices), then the rows that change with the context (sitting out,
// The Notifier policy as a table: one row per AppEvent subtype, what // alone, the window in the background, playing, a replayed line). What
// noticeFor makes of it (toast text and actor, sound, attention, activity-log // noticeFor makes of each: toast text and actor, sound, attention, system
// line). A policy change edits a row here on purpose. // notification, activity-log line. A policy change edits a row here on
// purpose.
import 'dart:io'; import 'dart:io';
import 'package:flutter_test/flutter_test.dart'; 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/session/notifier.dart';
import 'package:starsync/src/sfx/sfx.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'}; 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 { class _Row {
final String name; final String name;
final AppEvent event; final AppEvent event;
final Type type; final Type type;
final NoticeContext context;
final String? toast; final String? toast;
final String? actor; final String? actor;
final SfxKind? sound; final SfxKind? sound;
@@ -41,10 +45,30 @@ class _Row {
final bool attention; final bool attention;
/// Whether the event's own feed line goes into the activity log. /// Whether the event's own feed line goes into the activity log.
final bool logged; 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, 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 /// The feed as the coordinator turns it into events: this member is m9
/// ('Me'), two members ready in the latest state. /// ('Me'), two members ready in the latest state.
FeedEvent _of(FeedMessage f) => FeedEvent.of(f, selfId: 'm9', readyCount: 2); 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), toast: 'Ann left', actor: 'Ann', sound: SfxKind.leave, logged: true),
_Row('leave (grace over)', _of(_feed('leave', reason: 'grace')), MemberLeft, _Row('leave (grace over)', _of(_feed('leave', reason: 'grace')), MemberLeft,
toast: 'Ann left', actor: 'Ann', sound: SfxKind.leave, logged: true), toast: 'Ann left', actor: 'Ann', sound: SfxKind.leave, logged: true),
_Row('gone (lost signal)', _of(_feed('gone')), MemberGone, logged: true), _Row('gone (after the delayed notice)', _of(_feed('gone')), MemberGone,
_Row('return', _of(_feed('return')), MemberReturned, logged: true), 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 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 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), _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('force-hold', _of(_feed('force-hold')), ForcedTransport, logged: true),
_Row('swap', _of(_feed('swap')), MediaSwapped, _Row('swap', _of(_feed('swap')), MediaSwapped,
toast: 'Ann changed media', actor: 'Ann', sound: SfxKind.swap, logged: true), 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('seek', _of(_feed('seek')), Seeked, logged: true),
_Row('pause', _of(_feed('pause', actor: 'server')), Paused, logged: true), _Row('pause', _of(_feed('pause', actor: 'server')), Paused, logged: true),
_Row('resume', _of(_feed('resume', actor: 'server')), Resumed, 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), toast: 'Me: hi', actor: 'Me', logged: true),
_Row('rename', _of(_feed('rename', target: 'Annie')), MemberRenamed, logged: true), _Row('rename', _of(_feed('rename', target: 'Annie')), MemberRenamed, logged: true),
_Row('avatar', _of(_feed('avatar')), AvatarChanged, 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-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')), _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), QueueSkipped, toast: 'Skipped Ep 6: gone', logged: true),
@@ -108,6 +137,14 @@ final _rows = <_Row>[
toast: 'Someone nudged you', sound: SfxKind.nudge, attention: true), toast: 'Someone nudged you', sound: SfxKind.nudge, attention: true),
_Row('member cannot play', const MemberCantPlay('Ann', 'no codec'), MemberCantPlay, _Row('member cannot play', const MemberCantPlay('Ann', 'no codec'), MemberCantPlay,
toast: "Ann can't play this: no codec", actor: 'Ann'), 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, _Row('slow source', const SlowSource('Passing on slower than it plays'), SlowSource,
toast: 'Passing on slower than it plays'), toast: 'Passing on slower than it plays'),
_Row('relay started', const RelayStarted(), RelayStarted, _Row('relay started', const RelayStarted(), RelayStarted,
@@ -130,24 +167,180 @@ final _rows = <_Row>[
toast: "Marked in this app's log"), toast: "Marked in this app's log"),
_Row('copied', const Copied('Link copied'), Copied, toast: 'Link copied'), _Row('copied', const Copied('Link copied'), Copied, toast: 'Link copied'),
_Row('copy failed', const CopyFailed(), CopyFailed, toast: "Couldn't copy"), _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),
]; ];
void main() { /// The rows the context changes.
group('noticeFor', () { final _contextRows = <_Row>[
for (final r in _rows) { // ---- sitting out (D1, P8): chat, nudges, starting now, Up next only
test(r.name, () { _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'); expect(r.event.runtimeType, r.type, reason: 'event type');
final n = noticeFor(r.event); final n = noticeFor(r.event, r.context);
expect(n.toast, r.toast, reason: 'toast'); expect(n.toast, r.toast, reason: 'toast');
expect(n.actor, r.actor, reason: 'actor'); expect(n.actor, r.actor, reason: 'actor');
expect(n.sound?.kind, r.sound, reason: 'sound'); expect(n.sound?.kind, r.sound, reason: 'sound');
if (r.sound != null) expect(n.sound!.readyIndex, r.readyIndex, reason: 'ready pitch'); if (r.sound != null) expect(n.sound!.readyIndex, r.readyIndex, reason: 'ready pitch');
expect(n.attention, r.attention, reason: 'attention'); 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; final e = r.event;
expect(n.log, r.logged ? same((e as FeedEvent).feed) : isNull, reason: 'activity log'); 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<void> 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, () => _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<void>.delayed(const Duration(milliseconds: 100));
expect(n.toasts.peek(), hasLength(1));
await Future<void>.delayed(const Duration(milliseconds: 300));
expect(n.toasts.peek(), isEmpty);
});
test('reads the session: sitting out silences the party, alone too', () {
final sounds = <SfxKind>[];
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', () { test('every concrete AppEvent subtype has a row', () {
@@ -161,3 +354,10 @@ void main() {
expect(_rows.map((r) => r.type.toString()).toSet(), declared); expect(_rows.map((r) => r.type.toString()).toSet(), declared);
}); });
} }
class _Sfx implements SfxService {
final List<SfxKind> played;
_Sfx(this.played);
@override
Future<void> play(SfxCue cue) async => played.add(cue.kind);
}
+163 -46
View File
@@ -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 'dart:typed_data';
import 'package:flutter/material.dart'; 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/frame.dart';
import 'package:starsync/src/controls/minimal/style.dart'; import 'package:starsync/src/controls/minimal/style.dart';
import 'package:starsync/src/controls/minimal/timeline.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/session/now_playing.dart';
import 'package:starsync/src/shell/now_playing_title.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/session_player.dart';
import 'package:starsync/src/shell/start_banner.dart'; import 'package:starsync/src/shell/start_banner.dart';
import 'package:starsync/src/state/session_state.dart'; import 'package:starsync/src/state/session_state.dart';
// The player's title block, start card and status pill (never a media_kit // The one computed status (each input → text and level), the player's
// `Video`). // title block, start card and status pill (never a media_kit `Video`).
/// A 1x1 transparent PNG. /// A 1x1 transparent PNG.
final png = Uint8List.fromList(const [ final png = Uint8List.fromList(const [
@@ -38,18 +38,44 @@ MemberView mv(String id, String name,
buffered: buffered, buffered: buffered,
presence: gone ? Presence.gone : away ? Presence.away : Presence.watching); presence: gone ? Presence.gone : away ? Presence.away : Presence.watching);
StateBroadcast held(List<MemberView> members, {String transport = 'held', double position = 0, int mediaId = 1}) => StateBroadcast held(List<MemberView> members,
{String transport = 'held', double position = 0, int mediaId = 1, MediaRelay? relay}) =>
StateBroadcast( StateBroadcast(
eventId: 1, eventId: 1,
transport: transport, transport: transport,
position: position, position: position,
serverTime: DateTime.now().toUtc(), serverTime: DateTime.now().toUtc(),
mediaId: mediaId, 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, 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() { void main() {
final coord = SessionState.instance.coordinator; final coord = SessionState.instance.coordinator;
@@ -57,82 +83,133 @@ void main() {
tearDown(() { tearDown(() {
debugArtBytes = null; debugArtBytes = null;
coord.serverState.value = null; coord.serverState.value = null;
coord.health.value = const HealthStatus(HealthSeverity.info, 'idle');
coord.playback.alive.value = false; coord.playback.alive.value = false;
}); });
group('status', () { group('status', () {
final me = mv('me', 'Me', ready: true); final me = mv('me', 'Me', ready: true);
test('problems first: reconnecting, health', () { test('the connection first: reconnecting, unstable, refused, replaced, connecting', () {
var v = playerStatusFor(health: const HealthStatus(HealthSeverity.warn, 'connection lost — retrying…'), final r = held([me]);
reconnecting: true); for (final (conn, text, level, action) in [
expect(v!.text, 'Reconnecting…'); (const ConnLost(), 'Reconnecting…', StatusLevel.warn, StatusAction.cancel),
expect(v.action, StatusAction.cancel); (const ConnReconnecting(2), 'Reconnecting…', StatusLevel.warn, StatusAction.cancel),
v = playerStatusFor(health: const HealthStatus(HealthSeverity.warn, 'waiting for Bob to pass the video on'), (const ConnUnstable(), 'Connection unstable…', StatusLevel.warn, null),
state: held([me]), self: me); (const ConnRefused('secret', 'wrong password'), 'Wrong password', StatusLevel.error, StatusAction.retry),
expect(v!.kind, StatusKind.problem); (const ConnReplaced(), replacedText, StatusLevel.error, StatusAction.retry),
expect(v.text, 'Waiting for Bob to pass the video on'); (const ConnConnecting(), 'Connecting…', StatusLevel.info, null),
expect(v.tone, HealthSeverity.warn); ]) {
expect(v.action, StatusAction.retry); 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', () { test('the party line: connecting, reconnecting with the state kept, the ready count', () {
expect(playerStatusFor(health: const HealthStatus(HealthSeverity.info, 'connected — waiting for host media…')), expect(st(null, conn: const ConnConnecting()).party, 'connecting…');
isNull); final r = held([me, mv('b', 'Bob'), mv('z', 'Zed', gone: true)]);
const connecting = HealthStatus(HealthSeverity.info, 'connecting…'); expect(st(r, self: me, conn: const ConnLost()).party, 'reconnecting…');
expect(playerStatusFor(health: connecting), isNull); expect(st(r, self: me).party, 'ready 1/2', reason: 'gone is not counted');
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('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', () { test('finished, then whoever is loading', () {
expect(playerStatusFor(health: ok, state: held([me], transport: 'finished'), self: me)!.text, expect(line(held([me], transport: 'finished'), self: me)!.text, 'Finished · seek back or pick the next video');
'Finished · seek back or pick the next video');
final slow = mv('b', 'Bob', ready: true, buffered: RelayConstants.minBufferSecs / 2); 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.kind, StatusKind.loading);
expect(v.text, 'Bob is loading…'); expect(v.text, 'Bob is loading…');
expect(v.who.single.id, 'b'); expect(v.who.single.id, 'b');
expect(v.progress, closeTo(0.5, 1e-9)); expect(v.progress, closeTo(0.5, 1e-9));
final mine = mv('me', 'Me', ready: true, buffered: 0); 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', () { test('you, then the others with a ready count', () {
final notMe = mv('me', 'Me'); 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.kind, StatusKind.you);
expect(v.text, "Press Space when you're ready"); expect(v.text, "Press Space when you're ready");
expect(v.action, StatusAction.ready); expect(v.action, StatusAction.ready);
expect(v.self, isTrue); expect(v.self, isTrue);
final w = playerStatusFor( final w = line(held([me, mv('b', 'Bob'), mv('m', 'Mika'), mv('z', 'Zed', gone: true), mv('o', 'Ola', away: true)]),
health: ok,
state: held([me, mv('b', 'Bob'), mv('m', 'Mika'), mv('z', 'Zed', gone: true), mv('o', 'Ola', away: true)]),
self: me)!; self: me)!;
expect(w.kind, StatusKind.waiting); expect(w.kind, StatusKind.waiting);
expect(w.text, 'Waiting for Bob and Mika', reason: 'nobody waits for who sits out'); expect(w.text, 'Waiting for Bob and Mika', reason: 'nobody waits for who sits out');
expect(w.detail, '1/3 ready'); expect(w.detail, '1/3 ready');
final outMe = mv('me', 'Me', away: true); final outMe = mv('me', 'Me', away: true);
expect(playerStatusFor(health: ok, state: held([outMe, mv('b', 'Bob')]), self: outMe), isNull, expect(line(held([outMe, mv('b', 'Bob')]), self: outMe), isNull, reason: 'sitting out: the join-back card speaks');
reason: 'sitting out: the join-back card speaks');
expect(w.who.map((m) => m.name), ['Bob', 'Mika']); expect(w.who.map((m) => m.name), ['Bob', 'Mika']);
}); });
test("can't play: yours with Retry (reload); others' instead of waiting", () { test("can't play: yours with Reload, a broken player too; others' instead of waiting", () {
var v = playerStatusFor(health: ok, state: held([me]), self: me, playerError: 'Not allowed (403)')!; var v = line(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)); expect((v.kind, v.text, v.action, v.level),
final bob = MemberView(id: 'b', name: 'Bob', ready: false, inSet: false, buffered: 0, (StatusKind.problem, "Can't play this: Not allowed (403)", StatusAction.reload, StatusLevel.error));
error: 'Not allowed (403)'); v = st(held([me]), self: me, playerFault: 'video reload failed: gone').line!;
v = playerStatusFor(health: ok, state: held([me, bob, mv('m', 'Mika', ready: true)]), self: me)!; 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.kind, v.text, v.action), (StatusKind.cannotPlay, "Bob can't play this: Not allowed (403)", null));
expect(v.who.single.id, 'b'); expect(v.who.single.id, 'b');
final notMe = mv('me', 'Me'); 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)); 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', () { test('names read as a sentence', () {
expect(nameList(['Bob']), 'Bob'); expect(nameList(['Bob']), 'Bob');
expect(nameList(['Bob', 'Mika']), 'Bob and Mika'); 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<RelaySourceDropped>().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<RelaySourceDropped>().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<PlaybackStarted>().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<Replaced>());
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<ServerRestarted>());
expect(w.step(statusOf(StatusFacts(conn: const ConnOnline(), restarts: 1, now: _now)), _now), isEmpty);
});
});
group('title block', () { group('title block', () {
Widget host(Widget child, {double width = 400}) => MaterialApp( Widget host(Widget child, {double width = 400}) => MaterialApp(
home: Scaffold(backgroundColor: Colors.black, body: Center(child: SizedBox(width: width, child: child))), home: Scaffold(backgroundColor: Colors.black, body: Center(child: SizedBox(width: width, child: child))),
+1 -1
View File
@@ -206,7 +206,7 @@ void main() {
await _poll(() => stateMediaRelay(alice.serverState.peek()!)?.source.isNotEmpty ?? false, 'relay state'); await _poll(() => stateMediaRelay(alice.serverState.peek()!)?.source.isNotEmpty ?? false, 'relay state');
await Future.delayed(const Duration(seconds: 7)); 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.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'), final src = RelaySource(Uri.parse('ws://127.0.0.1:${server.boundPort}/r/default/$token/source'),
RelayMedia.single(FileRelayBytes(File(file)))); RelayMedia.single(FileRelayBytes(File(file))));
await src.start(); await src.start();
+88 -15
View File
@@ -1,9 +1,14 @@
// restructure: rewrite in step 2 (Status + Notifier): toasts and the status // restructure: rewrite in step 3 (Your server): the solo strip and Invite
// pill; in step 3 (Your server): the solo strip and Invite friends. // friends.
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.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: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/session/identity.dart';
import 'package:starsync/src/shell/app_shell.dart'; import 'package:starsync/src/shell/app_shell.dart';
import 'package:starsync/src/shell/avatar.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/shell/toasts.dart';
import 'package:starsync/src/session/session_coordinator.dart' show ToastItem; import 'package:starsync/src/session/session_coordinator.dart' show ToastItem;
import 'package:starsync/src/state/session_state.dart'; import 'package:starsync/src/state/session_state.dart';
import 'package:starsync/src/state/settings.dart';
import 'package:starsync/src/update/update_controller.dart'; import 'package:starsync/src/update/update_controller.dart';
/// Stands in for media_kit's `Video` (which hangs flutter_test) and /// Stands in for media_kit's `Video` (which hangs flutter_test) and
@@ -38,13 +44,14 @@ class _FakeVideoState extends State<FakeVideo> {
Widget build(BuildContext context) => const ColoredBox(color: Colors.black); 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( MemberView(
id: id, id: id,
name: name, name: name,
ready: ready, ready: ready,
inSet: ready, inSet: ready,
buffered: 30, buffered: 30,
app: app,
presence: gone ? Presence.gone : Presence.watching); presence: gone ? Presence.gone : Presence.watching);
void main() { void main() {
@@ -73,10 +80,10 @@ void main() {
session.route.value = AppRoute.home; session.route.value = AppRoute.home;
coord.serverState.value = null; coord.serverState.value = null;
coord.playback.alive.value = false; coord.playback.alive.value = false;
coord.toasts.value = []; session.notifier.clear();
}); });
Future<void> pumpShell(WidgetTester tester, Size size, {UpdateController? updates}) async { Future<void> pumpShell(WidgetTester tester, Size size, {UpdateController? updates, VideoViewBuilder? video}) async {
tester.view.physicalSize = size; tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset); addTearDown(tester.view.reset);
@@ -86,7 +93,7 @@ void main() {
updates: updates ?? UpdateController(currentVersion: '0.0.0-dev'), updates: updates ?? UpdateController(currentVersion: '0.0.0-dev'),
autoUpdateCheck: false, autoUpdateCheck: false,
windowControls: 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. // A real probe (network, yt-dlp) never answers on the test clock.
linkProbe: (url) async => LinkProbe.file(Uri.parse(url), 1000), linkProbe: (url) async => LinkProbe.file(Uri.parse(url), 1000),
), ),
@@ -104,11 +111,18 @@ void main() {
]; ];
group('version and update bar', () { group('version and update bar', () {
testWidgets('the version under Settings; the bar on the tabs, off the full player until the video ends', testWidgets('the version at the bottom of Settings, not in the strip; the bar on the tabs, off the full player '
(tester) async { 'until the video ends', (tester) async {
final updates = UpdateController(currentVersion: '0.0.0-dev'); final updates = UpdateController(currentVersion: '0.0.0-dev');
await pumpShell(tester, const Size(1280, 800), updates: updates); 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<SelectableText>(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); expect(find.byKey(const ValueKey('update-bar')), findsNothing);
updates.status.value = const UpdateStatus(UpdatePhase.available, updates.status.value = const UpdateStatus(UpdatePhase.available,
manifest: UpdateManifest(version: '9.9.9', protocol: 1, url: 'http://x/a.tgz', notes: ''), manifest: UpdateManifest(version: '9.9.9', protocol: 1, url: 'http://x/a.tgz', notes: ''),
@@ -207,7 +221,9 @@ void main() {
group('popovers', () { group('popovers', () {
testWidgets('members card opens from the avatars and closes outside', (tester) async { testWidgets('members card opens from the avatars and closes outside', (tester) async {
await pumpShell(tester, const Size(1280, 800)); 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.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('avatar-stack'))); await tester.tap(find.byKey(const ValueKey('avatar-stack')));
await tester.pumpAndSettle(); 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(m.name)), findsOneWidget);
} }
expect(find.descendant(of: card, matching: find.text('reconnecting…')), 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). // 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.tap(find.descendant(of: find.byKey(const ValueKey('member-b')), matching: find.byTooltip('More')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -386,11 +406,18 @@ void main() {
expect(find.text('1/3 ready'), findsOneWidget); 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', () { test('toasts: feed toasts carry the actor, the rest are the app\'s', () {
AvatarInfo who(String n) => AvatarInfo(name: n, color: Colors.teal); AvatarInfo who(String n) => AvatarInfo(name: n, color: Colors.teal);
final at = DateTime.now(); final at = DateTime.now();
// The actor is exact: "Bob" is not mistaken for "Bob Jr". // 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.who!.name, 'Bob Jr');
expect(chat.text, 'hi there'); expect(chat.text, 'hi there');
expect(chat.chat, isTrue); expect(chat.chat, isTrue);
@@ -404,16 +431,62 @@ void main() {
expect(app.text, 'Bob joined'); expect(app.text, 'Bob joined');
final notice = shellToastFor(ToastItem(id: 4, text: 'Shaders off', at: at), who); final notice = shellToastFor(ToastItem(id: 4, text: 'Shaders off', at: at), who);
expect(notice.who, isNull); 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 { testWidgets("toasts slide in and leave when the Notifier's time is up", (tester) async {
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: PartyToasts(life: Duration(seconds: 1))))); await tester.pumpWidget(const MaterialApp(home: Scaffold(body: PartyToasts())));
coord.toasts.value = [ToastItem(id: 1, text: 'Shaders off', at: DateTime.now())]; session.notifier.notify(const Copied('Shaders off'));
await tester.pump(); await tester.pump();
await tester.pump(); await tester.pump();
expect(find.text('Shaders off'), findsOneWidget); expect(find.text('Shaders off'), findsOneWidget);
await tester.pump(const Duration(seconds: 1)); await tester.pump(session.notifier.toastLife);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Shaders off'), findsNothing); 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<AppShellState>(find.byType(AppShell)).mode, PlayerMode.full);
expect(tester.state<ControlsFrameState>(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<ControlsFrameState>(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();
});
}
} }
@@ -11,6 +11,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST
flutter_local_notifications_windows
flutter_soloud flutter_soloud
jni jni
) )
+151 -63
View File
@@ -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 was opened in another window". Close code 4015 (the server found it
silent) and any other drop reconnect. silent) and any other drop reconnect.
The coordinator maps the state to `health` (`connectionHealth`: The state is the first input of the session's status (see "App events
"connecting…", "connected", "connection unstable…", "connection lost — and platform ports": "Connecting…", "Reconnecting…" with Cancel,
retrying…", "reconnecting…", the reason; closed writes nothing, a cancel "Connection unstable…", the refusal or "replaced" with Retry; the strip's
writes "cancelled", "Retry" "retrying…"), to `reconnecting` (lost or "reconnecting…"); the coordinator also reads it as `reconnecting` (lost
reconnecting), `unstable` and `stateStale`. A drop that lasted or reconnecting), `unstable` and `stateStale`. A drop that lasted
`lostSoundAfter` (4 s; a silent link counts from its last frame) raises `lostSoundAfter` (4 s; a silent link counts from its last frame) raises
`ConnectionLost`, the reconnect that follows `ConnectionBack` (each once `ConnectionLost`, the reconnect that follows `ConnectionBack` (each once
per drop, through the Notifier): a quick flap makes no sound. 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 4002 and ends the relay (`ServerHub.relayOtherMedia`); a second channel
replaces the first (4003). The source's leave (grace over, `bye`) or a 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. swap ends the relay: its buffer goes, `live` drops, `/r` answers 404.
`state.media.relay.live` follows the channel, and viewers show "waiting `state.media.relay.live` follows the channel. Viewers' status (see
for <name> to pass the video on" while it is down, "<name> lost "App events and platform ports") says "Waiting for <name> to pass the
signal: playing what the server has" while the source is `gone` (a video on" while it is down, "<name>'s connection dropped — playing what
viewer opening then opens the buffer instead of waiting), "<name> left: the server has (2 min)" while the source is `gone` (from `sourceGoneSince`;
playing what the server has" once the source left the server. a viewer opening then opens the buffer instead of waiting) and "<name>
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 - Stats (`relay_stats.dart`, `ServerHub.sampleRates` on the 1 s
heartbeat; wire in [PROTOCOL.md](PROTOCOL.md)): the source's rate is 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 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, fetch, Jellyfin), each member's route (source; via server: relayed,
`/m`, or Jellyfin `jf` false; direct: links, Jellyfin `jf` true) and `/m`, or Jellyfin `jf` false; direct: links, Jellyfin `jf` true) and
buffer health (enough for `bufferNeed`, low, empty while playing). The buffer health (enough for `bufferNeed`, low, empty while playing). The
members card (`shell/delivery_line.dart`) shows it as a source line and status carries it (`Status.delivery`) and the members card
a dot per member; nothing goes on the picture. The source gets one toast (`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 per media while `rate` < `need`. Not shown: a direct member's speed
(the server never sees its bytes). (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 ## 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 **Events.** Everything that may become a toast, a sound, a window
attention request or an activity-log line is an `AppEvent` attention request, a system notification or an activity-log line is an
(`session/app_events.dart`, a sealed type, pure Dart): the server feed as `AppEvent` (`session/app_events.dart`, a sealed type, pure Dart): the
typed `FeedEvent`s (`FeedEvent.of`, one per feed action: joins, leaves server feed as typed `FeedEvent`s (`FeedEvent.of`, one per feed action:
(`bye` or `grace`), gone (lost signal), return, sat out (`away`: by joins, leaves (`bye` or `grace`), gone (lost signal), return, sat out
oneself, by someone else, or by this app's auto sit-out), back (`in`), (`away`: by oneself, by someone else, or by this app's auto sit-out), back
ready/unready, force ready/play/hold, swap, seek, pause, resume, (`in`), ready/unready, force ready/play/hold, swap, seek, pause, resume,
finished, chat, rename, avatar, queue add/remove/skip, fetch failed; finished, chat, rename, avatar, queue add (with the item's picture from
people are matched by `actorId` / `targetId`, chat by `fromId`, never by the state that came first)/remove/skip, fetch failed; `own` when this
name), the session's own (nudged, a member can't play, slow source, relay member is the actor; people are matched by `actorId` / `targetId`, never
started/failed, connection lost/back, Jellyfin progress not saved) and the app's (shader message, added to Up next, a put-up or by name), the session's (nudged, a member can't play, slow source, relay
queue add that failed, Schedule results, problem marked, copied / copy started/failed, the source dropped, starting now, connection lost/back,
failed, update available). Code with news calls server restarted, replaced, a server refusal, Jellyfin progress not
`SessionCoordinator.notify` (= `Notifier.notify`, `session/notifier.dart`); saved) and the app's (shader message, added to Up next, a put-up or
nothing else writes toasts or plays sounds (Settings' sound preview queue add that failed (the Library's card errors too), Schedule results,
aside). `noticeFor` is the whole policy, one `Notice` per event: the problem marked, copied / copy failed, update available). Code with news
toast text and its actor, the `SfxCue`, attention, the feed line for the calls `SessionCoordinator.notify` (= `Notifier.notify`,
activity log. Today: every feed line is logged (`Notifier.feedLog`, the `session/notifier.dart`); nothing else writes toasts, plays sounds or
last 100); lines a reconnect replayed (`replayed`) are only logged; sends system notifications (Settings' sound preview aside).
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 **Notifier policy.** `noticeFor(event, NoticeContext)` is the whole
id, so its avatar is found by id); the sounds are listed under "Sounds"; a nudge also policy, one `Notice` per event: its `NoticeCategory` (chat, nudge, start,
asks for attention; an available update does nothing (the update band upNext, people, party, problem, self, app), the toast text, its actor and
shows it). `Notifier.toasts` holds the toasts, dropped 4 s after they came picture, the `SfxCue`, attention, the system notification, the feed line
(`expire`, the session's 1 s tick), cleared on connect. The Library for the activity log. The context: self presence (in, away, gone), alone
tab's card errors are still its own snack bars. (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 **Ports.** UI and session code reach the platform through interfaces
(`platform/ports/`), made once in the composition root (`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 and their changes, the window's activity for auto sit-out
(`watchActivity`: focused, unfocused, minimised, hidden; window_manager's (`watchActivity`: focused, unfocused, minimised, hidden; window_manager's
focus, blur, minimise and restore, and the shell's own `hide`, wired to 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) (save, hide, quit), attention, the clipboard paste (files, else text)
and drop targets (fox_utils). and drop targets (fox_utils).
- `UrlOpener` (`DesktopUrls`): `xdg-open`, `open`, Windows' `url.dll`. - `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. folder in the file manager. Paths for now.
- `LinkResolver` (`AppYtDlp`): the app's yt-dlp, link probes, mpv's raw - `LinkResolver` (`AppYtDlp`): the app's yt-dlp, link probes, mpv's raw
options, warm-up. options, warm-up.
- `SystemNotifications`: an interface only; desktop shows none - `SystemNotifications` (`DesktopNotifications`,
(`NoSystemNotifications`). `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 - `AppDirsPort` (`DesktopAppDirs`): where Hive and the logs live
(`AppDirs`, "Desktop UI"), and the copy of the data from before the (`AppDirs`, "Desktop UI"), and the copy of the data from before the
rename; resolved in `main` before the composition root exists, so not a 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), - **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 the Library / Schedule / Settings tabs with a growing underline (an
`HScroll` when narrow; the version in small text under Settings, `HScroll` when narrow; the version in the Settings tab's tooltip, at
`shortVersion`, the full one in its tooltip), then offline a grey dot, 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 "Offline", Join a friend and Host (connect cards; icon-less "Join" and
"Host" under 1100 px), solo a "Solo · only you" chip, Invite friends "Host" under 1100 px), solo a "Solo · only you" chip, Invite friends
(the Host card, titled "Invite friends", "Start hosting") and Stop (no (the Host card, titled "Invite friends", "Start hosting") and Stop (no
dialog; no avatars or chat), online the party: `AvatarStack` (host first, then 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; spreads on hover; 5 shown, 3 under 1100 px), the server name and
"ready n/m" (who the ready counter counts: `countsForReady` in `Status.party` ("ready n/m": who the ready counter counts,
`session/party.dart`, on `PresencePolicy`, so not away, gone or `countsForReady` in `session/party.dart`, on `PresencePolicy`, so not
errored; hidden under 1100 px), chat with an unread badge, and Leave away, gone or errored; "connecting…", "reconnecting…"; hidden under
(asks first). The stack opens the members card (the source line, then 1100 px), chat with an unread badge, and Leave (asks first). The stack
per member status, route and buffer dot (see "Relay", Stats), host / opens the members card (the server's version under its title: a hosted
relay pills; "joining…", "sitting out", or for a `gone` member 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 "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 out (anyone, the host too), your own row Sit out / Join back; "Copy invite
address": the dialled address when joined; hosting, `inviteAddresses` 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), shader files),
Jellyfin & integrations (linked servers, AnimeFlow's `/me` via Jellyfin & integrations (linked servers, AnimeFlow's `/me` via
`AnimeFlowClient.fromAccounts`, auto-advance), notifications & sounds `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 - **Drop and paste** (`handoff.dart`, `fox_utils`): the shell is one
`DropTarget` (a "Drop to open" overlay while dragging) and a `DropTarget` (a "Drop to open" overlay while dragging) and a
`HardwareKeyboard` handler for Ctrl+V outside a text field (clipboard `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, events), being sat out by someone (the `kick` sound), a nudge (always,
whatever its switch), auto sit-out setting you away (`warn`), and whatever its switch), auto sit-out setting you away (`warn`), and
connection lost / back (once per drop that lasted a few seconds). 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 The sounds are synthesized, not sampled: `synth.dart` renders voices
(plucked strings with bends, glass and star bells, a singing bowl, (plucked strings with bends, glass and star bells, a singing bowl,
hollow knocks, wooden clacks, filtered-noise air and stardust, a long 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 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 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 title. One status pill (`player_status.dart`) sits centred in the
action area (see "Player overlay"): `playerStatusFor` (pure) picks the most pressing of action area (see "Player overlay"): it shows `Status.line` (see "App
removed, reconnecting (Cancel), a warn/error health message (Retry), events and platform ports"), connecting only after 2 s: reconnecting
connecting (only after 2 s), finished, someone loading (below (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 `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 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, 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). picture).
**Toasts** (`toasts.dart`): the `Notifier`'s toasts bottom left, with **Toasts** (`toasts.dart`): the `Notifier`'s toasts bottom left, with
the avatar of their `ToastItem.actor` (feed and chat toasts; the app's the avatar of their `ToastItem.actor` (feed and chat toasts; the app's
own have none); each own have none) and an Up next item's picture (`ToastItem.thumb`, from
slides in, stays 5 s, then slides out and collapses its gap. A click on the queue's picture cache); each slides in, stays while the Notifier
a chat toast opens the chat; the others let clicks through. Chat toasts keeps it (5 s), then slides out and collapses its gap. A click on a chat
follow `AppSettings.chatPopups` (Settings → Notifications: All toast opens the chat; the others let clicks through. They never cover
(default), Only when paused, Off); the message is in the chat either the player's controls: over the full player (both skins, windowed and
way, and its sound plays whatever this setting (its own switch in fullscreen) they sit `toastInset` (16 px) above the control bar
Sounds). (`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 Settings persist in two Hive boxes: `connect_form` (last form, recent
servers and links, the profile picture and colour; saved even when a join 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 ## Health reporting
Every failure must show in the UI with a cause and a next step; nothing 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) stalls silently. The session's status (`SessionCoordinator.status`, see
for connecting, reconnecting, wrong password, version mismatch, "App events and platform ports") says it: connecting, reconnecting, wrong
host offline, and starvation ("waiting for X - N.N s buffered"). 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 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`, displayable state. The server's `GET /health` returns JSON (`ok`, `name`,
`proto`, `viewers`, `bytesServed`, `uptimeSecs`); it is unauthenticated, so `proto`, `viewers`, `bytesServed`, `uptimeSecs`); it is unauthenticated, so
+1 -1
View File
@@ -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`) | | | 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) | | | 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) | | 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`) | | 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) | | 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) |
+7 -11
View File
@@ -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 - P2 Viewers of a relay whose source is gone: mpv loops on failed seeks
past what the server buffered (viewer-side recovery, step 5). past what the server buffered (viewer-side recovery, step 5).
- P7 Sat-out people get tracked as watched on Jellyfin. - 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). - 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). - 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 - P17 Preload, warm relay and the fresh player per video are layered on
the player instead of being one media pipeline. 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, (client id, library and folders, match corrections, watch history,
settings, notification prefs, what this app is sourcing) and the settings, notification prefs, what this app is sourcing) and the
integrations (Jellyfin played/resume, AniList list). 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 3. Your server: one hub, listeners attached and detached without members
reconnecting (old isolate path kept until that's proven); Invite as a reconnecting (old isolate path kept until that's proven); Invite as a
value (filtered addresses, version gate from `/health.version`, an `fp` 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 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 with its grace unless it is put up again; worth doing only if updates
ever happen mid-session (D8 says never during playback). 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:** **Open decisions:**
- Starting together: today someone has to stay unready so others can - Starting together: today someone has to stay unready so others can