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