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