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

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

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

No files matched your search

@@ -0,0 +1,100 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import '../desktop_entry.dart' show appId;
import '../ports/system_notifications.dart';
/// [SystemNotifications] on the desktop, through flutter_local_notifications'
/// platform plugins (used directly, not through its facade, so the platform
/// is picked by the OS and not by Flutter's target platform):
/// - Linux: `org.freedesktop.Notifications` over D-Bus (pure Dart), with the
/// app's icon (the bundled PNG, else the launcher entry's icon by name);
/// a click is the notification's default action.
/// - Windows: toast notifications (WinRT), the app's name and icon, a click
/// activates the app while it runs (an unpackaged app has no history).
/// Silent: the app plays its own sound for the same notice. Anything that
/// fails (no notification daemon, no session bus) is logged and dropped.
class DesktopNotifications implements SystemNotifications {
DesktopNotifications({this.appName = 'Starsync'});
final String appName;
void Function(String payload)? _onClick;
Future<bool>? _ready;
LinuxFlutterLocalNotificationsPlugin? _linux;
FlutterLocalNotificationsWindows? _windows;
// Tag -> id, so a notification with the same tag replaces the last one.
final _ids = <String, int>{};
int _next = 1;
@override
set onClick(void Function(String payload)? handler) => _onClick = handler;
void _clicked(NotificationResponse r) => _onClick?.call(r.payload ?? '');
/// The bundled 256 px icon next to the executable (a release or a
/// `flutter run` build), else the icon the launcher entry installed.
static LinuxNotificationIcon _linuxIcon() {
final exe = File(Platform.resolvedExecutable).parent.path;
final png = File('$exe/data/flutter_assets/assets/icon/icon_256.png');
return png.existsSync() ? FilePathLinuxIcon(png.path) : ThemeLinuxIcon(appId);
}
Future<bool> _init() => _ready ??= () async {
try {
if (Platform.isLinux) {
final p = _linux = LinuxFlutterLocalNotificationsPlugin();
return await p.initialize(
settings: LinuxInitializationSettings(defaultActionName: 'Open', defaultIcon: _linuxIcon()),
onDidReceiveNotificationResponse: _clicked,
) ??
false;
}
if (Platform.isWindows) {
final exe = File(Platform.resolvedExecutable).parent.path;
final icon = File('$exe/data/flutter_assets/assets/icon/icon_256.png');
final p = _windows = FlutterLocalNotificationsWindows();
return await p.initialize(
settings: WindowsInitializationSettings(
appName: appName,
appUserModelId: 'Foxyz.Starsync',
guid: '6c0f4b4e-3e8a-4b8e-9d0c-5a7f2b1e9c31',
iconPath: icon.existsSync() ? icon.path : null,
),
onDidReceiveNotificationResponse: _clicked,
);
}
} catch (e) {
debugPrint('system notifications unavailable: $e');
}
return false;
}();
@override
Future<void> show({required String title, required String body, String tag = '', String payload = ''}) async {
if (!await _init()) return;
final id = tag.isEmpty ? _next++ : _ids[tag] ??= _next++;
try {
if (_linux case final p?) {
await p.show(
id: id,
title: title,
body: body,
payload: payload,
notificationDetails: const LinuxNotificationDetails(suppressSound: true),
);
} else if (_windows case final p?) {
await p.show(
id: id,
title: title,
body: body,
payload: payload,
notificationDetails: WindowsNotificationDetails(audio: WindowsNotificationAudio.silent()),
);
}
} catch (e) {
debugPrint('system notification failed: $e');
}
}
}
@@ -1,6 +1,7 @@
import '../../playback/yt_dlp_app.dart';
import '../ports/ports.dart';
import 'desktop_folders.dart';
import 'desktop_notifications.dart';
import 'desktop_shell.dart';
import 'desktop_urls.dart';
@@ -12,6 +13,6 @@ Ports desktopPorts() {
urls: urls,
folders: const DesktopFolders(urls),
links: AppYtDlp.instance,
notifications: const NoSystemNotifications(),
notifications: DesktopNotifications(),
);
}
@@ -97,6 +97,17 @@ class DesktopShell implements ShellPort {
}
}
@override
Future<void> focus() async {
try {
if (await windowManager.isMinimized()) await windowManager.restore();
if (!await windowManager.isVisible()) await windowManager.show();
await windowManager.focus();
} catch (e) {
debugPrint('window focus failed: $e');
}
}
@override
Future<void> requestAttention() => requestWindowAttention();
@@ -1,20 +1,8 @@
import 'package:flutter/widgets.dart';
/// Where the app window stands, for auto sit-out
/// ([ShellPort.watchActivity]).
enum WindowActivity {
/// The window has the keyboard focus.
focused,
import 'window_activity.dart';
/// Shown, but another window has the focus.
unfocused,
/// Minimised to the taskbar.
minimised,
/// Hidden (closing, or a platform that hides instead of minimising).
hidden,
}
export 'window_activity.dart';
/// What the clipboard holds for a paste: its files, else its text.
typedef Paste = ({List<String> files, String? text});
@@ -54,6 +42,10 @@ abstract interface class ShellPort {
Future<void> hide();
Future<void> quit();
/// Brings the window up and gives it the focus (restored when
/// minimised, shown when hidden): a click on a system notification.
Future<void> focus();
/// Points the desktop at the window when it is not focused (taskbar
/// flash, urgency hint); nothing when it is.
Future<void> requestAttention();
@@ -1,14 +1,23 @@
/// Notifications outside the window (the desktop's notification center,
/// Android's shade). The policy that uses them comes with the Notifier.
/// Android's shade). The Notifier decides what goes out here (see
/// `noticeFor`); the port only shows them and reports clicks.
abstract interface class SystemNotifications {
/// Shows [title] / [body]; replaces the one with the same [tag].
Future<void> show({required String title, required String body, String tag = ''});
/// Shows [title] / [body] with the app's name and icon; one with the
/// same [tag] replaces it. [payload] comes back to [onClick].
Future<void> show({required String title, required String body, String tag = '', String payload = ''});
/// Called with a notification's payload when the user clicks it (set
/// once, by the Notifier).
set onClick(void Function(String payload)? handler);
}
/// Shows nothing (desktop today).
/// Shows nothing (tests, platforms without one).
class NoSystemNotifications implements SystemNotifications {
const NoSystemNotifications();
@override
Future<void> show({required String title, required String body, String tag = ''}) async {}
Future<void> show({required String title, required String body, String tag = '', String payload = ''}) async {}
@override
set onClick(void Function(String payload)? handler) {}
}
@@ -0,0 +1,19 @@
/// Where the app window stands ([ShellPort.watchActivity]): auto sit-out
/// and the Notifier (system notifications while it is not focused) read
/// it. Pure Dart.
enum WindowActivity {
/// The window has the keyboard focus.
focused,
/// Shown, but another window has the focus.
unfocused,
/// Minimised to the taskbar.
minimised,
/// Hidden (closing, or a platform that hides instead of minimising).
hidden;
/// Not in front of the user: notices go out as system notifications.
bool get background => this != focused;
}
@@ -16,12 +16,15 @@ class ServerHealth {
final String name;
final int? proto;
final int viewers;
/// Its Starsync version (`version`), '' from a server too old to say.
final String version;
const ServerHealth.offline()
: online = false,
name = '',
proto = null,
viewers = 0;
const ServerHealth.online({required this.name, this.proto, this.viewers = 0})
viewers = 0,
version = '';
const ServerHealth.online({required this.name, this.proto, this.viewers = 0, this.version = ''})
: online = true;
/// True when the server speaks another protocol than this build.
@@ -33,11 +36,12 @@ class ServerHealth {
try {
final j = jsonDecode(body);
if (j is! Map || j['ok'] != true) return const ServerHealth.offline();
final name = j['name'], proto = j['proto'], viewers = j['viewers'];
final name = j['name'], proto = j['proto'], viewers = j['viewers'], version = j['version'];
return ServerHealth.online(
name: name is String ? name.trim() : '',
proto: proto is num ? proto.toInt() : null,
viewers: viewers is num ? viewers.toInt() : 0,
version: version is String ? version.trim() : '',
);
} catch (_) {
return const ServerHealth.offline();
+90 -50
View File
@@ -15,65 +15,71 @@ sealed class AppEvent {
/// in the activity log.
sealed class FeedEvent extends AppEvent {
final FeedMessage feed;
const FeedEvent(this.feed);
/// This member is the actor (its own chat, ready, queue add...).
final bool own;
const FeedEvent(this.feed, {this.own = false});
String get actor => feed.actor;
/// [feed] as its typed event. [selfId]: this member's id (people are
/// matched by `actorId` / `targetId`, never by name); [readyCount]:
/// members ready in the latest state (the pitch of a `ready` sound).
factory FeedEvent.of(FeedMessage f, {String selfId = '', int readyCount = 0}) {
/// members ready in the latest state (the pitch of a `ready` sound);
/// [queueThumb]: the picture hash of the Up next item a `queue-add`
/// added (from the state that came with it), '' when none.
factory FeedEvent.of(FeedMessage f, {String selfId = '', int readyCount = 0, String queueThumb = ''}) {
bool isSelf(String id) => selfId.isNotEmpty && id == selfId;
final own = isSelf(f.actorId);
return switch (f.action) {
'join' => MemberJoined(f),
'leave' => MemberLeft(f),
'gone' => MemberGone(f),
'return' => MemberReturned(f),
'join' => MemberJoined(f, own: own),
'leave' => MemberLeft(f, own: own),
'gone' => MemberGone(f, own: own),
'return' => MemberReturned(f, own: own),
'away' => MemberSatOut(f,
own: own,
atYou: isSelf(f.targetId) && !isSelf(f.actorId),
autoSelf: isSelf(f.actorId) && f.targetId.isEmpty && f.reason.isNotEmpty),
'in' => MemberBack(f),
'ready' => ReadyChanged(f, ready: true, readyIndex: readyCount),
'unready' => ReadyChanged(f, ready: false),
'force-ready' => ForcedReady(f, ready: true),
'force-unready' => ForcedReady(f, ready: false),
'force-play' => ForcedTransport(f, play: true),
'force-hold' => ForcedTransport(f, play: false),
'swap' => MediaSwapped(f),
'seek' => Seeked(f),
'pause' => Paused(f),
'resume' => Resumed(f),
'finished' => Finished(f),
'chat' => ChatPosted(f, own: isSelf(f.actorId)),
'rename' => MemberRenamed(f),
'avatar' => AvatarChanged(f),
'queue-add' => QueueAdded(f),
'queue-remove' => QueueRemoved(f),
'queue-skip' => QueueSkipped(f),
'fetch-fail' => FetchFailed(f),
_ => OtherFeed(f),
'in' => MemberBack(f, own: own),
'ready' => ReadyChanged(f, own: own, ready: true, readyIndex: readyCount),
'unready' => ReadyChanged(f, own: own, ready: false),
'force-ready' => ForcedReady(f, own: own, ready: true),
'force-unready' => ForcedReady(f, own: own, ready: false),
'force-play' => ForcedTransport(f, own: own, play: true),
'force-hold' => ForcedTransport(f, own: own, play: false),
'swap' => MediaSwapped(f, own: own),
'seek' => Seeked(f, own: own),
'pause' => Paused(f, own: own),
'resume' => Resumed(f, own: own),
'finished' => Finished(f, own: own),
'chat' => ChatPosted(f, own: own),
'rename' => MemberRenamed(f, own: own),
'avatar' => AvatarChanged(f, own: own),
'queue-add' => QueueAdded(f, own: own, thumb: queueThumb),
'queue-remove' => QueueRemoved(f, own: own),
'queue-skip' => QueueSkipped(f, own: own),
'fetch-fail' => FetchFailed(f, own: own),
_ => OtherFeed(f, own: own),
};
}
}
class MemberJoined extends FeedEvent {
const MemberJoined(super.feed);
const MemberJoined(super.feed, {super.own});
}
/// Left for good (`reason` `bye`: the app closed or Leave; `grace`: gone
/// too long).
class MemberLeft extends FeedEvent {
const MemberLeft(super.feed);
const MemberLeft(super.feed, {super.own});
}
/// Lost signal: gone for a while, kept in its grace period.
class MemberGone extends FeedEvent {
const MemberGone(super.feed);
const MemberGone(super.feed, {super.own});
}
/// Back within its grace after a [MemberGone].
class MemberReturned extends FeedEvent {
const MemberReturned(super.feed);
const MemberReturned(super.feed, {super.own});
}
/// Someone sat out (`away`: the actor itself, or `target` sat out by the
@@ -83,87 +89,89 @@ class MemberReturned extends FeedEvent {
class MemberSatOut extends FeedEvent {
final bool atYou;
final bool autoSelf;
const MemberSatOut(super.feed, {this.atYou = false, this.autoSelf = false});
const MemberSatOut(super.feed, {super.own, this.atYou = false, this.autoSelf = false});
}
/// Back from sitting out (`in`).
class MemberBack extends FeedEvent {
const MemberBack(super.feed);
const MemberBack(super.feed, {super.own});
}
class ReadyChanged extends FeedEvent {
final bool ready;
/// Members ready when it came (pitch of the ready sound).
final int readyIndex;
const ReadyChanged(super.feed, {required this.ready, this.readyIndex = 0});
const ReadyChanged(super.feed, {super.own, required this.ready, this.readyIndex = 0});
}
class ForcedReady extends FeedEvent {
final bool ready;
const ForcedReady(super.feed, {required this.ready});
const ForcedReady(super.feed, {super.own, required this.ready});
}
/// Force play / hold.
class ForcedTransport extends FeedEvent {
final bool play;
const ForcedTransport(super.feed, {required this.play});
const ForcedTransport(super.feed, {super.own, required this.play});
}
class MediaSwapped extends FeedEvent {
const MediaSwapped(super.feed);
const MediaSwapped(super.feed, {super.own});
}
class Seeked extends FeedEvent {
const Seeked(super.feed);
const Seeked(super.feed, {super.own});
}
class Paused extends FeedEvent {
const Paused(super.feed);
const Paused(super.feed, {super.own});
}
class Resumed extends FeedEvent {
const Resumed(super.feed);
const Resumed(super.feed, {super.own});
}
class Finished extends FeedEvent {
const Finished(super.feed);
const Finished(super.feed, {super.own});
}
/// A chat message (`reason` carries the text); [own]: this member sent it.
class ChatPosted extends FeedEvent {
final bool own;
const ChatPosted(super.feed, {this.own = false});
const ChatPosted(super.feed, {super.own});
}
class MemberRenamed extends FeedEvent {
const MemberRenamed(super.feed);
const MemberRenamed(super.feed, {super.own});
}
class AvatarChanged extends FeedEvent {
const AvatarChanged(super.feed);
const AvatarChanged(super.feed, {super.own});
}
/// Someone added an item to Up next (`target` its title); [thumb]: the
/// item's picture hash ('' when none).
class QueueAdded extends FeedEvent {
const QueueAdded(super.feed);
final String thumb;
const QueueAdded(super.feed, {super.own, this.thumb = ''});
}
class QueueRemoved extends FeedEvent {
const QueueRemoved(super.feed);
const QueueRemoved(super.feed, {super.own});
}
/// Up next skipped an item (`target` its title, `reason` why).
class QueueSkipped extends FeedEvent {
const QueueSkipped(super.feed);
const QueueSkipped(super.feed, {super.own});
}
/// The server could not fetch the current media.
class FetchFailed extends FeedEvent {
const FetchFailed(super.feed);
const FetchFailed(super.feed, {super.own});
}
/// An action this app does not know (a newer server).
class OtherFeed extends FeedEvent {
const OtherFeed(super.feed);
const OtherFeed(super.feed, {super.own});
}
// -------------------------------------------------------------- session
@@ -209,6 +217,38 @@ class ConnectionBack extends AppEvent {
const ConnectionBack();
}
/// The server refused something this app sent (`err`: Up next is full, a
/// forbidden action...).
class ServerRefused extends AppEvent {
final String text;
const ServerRefused(this.text);
}
/// The member passing the current video on has been gone a few seconds
/// ([left]: it left the server); viewers play what the server has.
class RelaySourceDropped extends AppEvent {
final String name;
final bool left;
const RelaySourceDropped(this.name, {this.left = false});
}
/// The current media started playing for the first time ("starting
/// now"; [title]: what plays, '' when unknown).
class PlaybackStarted extends AppEvent {
final String title;
const PlaybackStarted(this.title);
}
/// A reconnect found the server restarted (another `welcome.instance`).
class ServerRestarted extends AppEvent {
const ServerRestarted();
}
/// Another window of this install took the member over (`replaced`).
class Replaced extends AppEvent {
const Replaced();
}
/// Jellyfin progress reports fail (once per media).
class JellyfinProgressFailed extends AppEvent {
final bool signInExpired;
+32 -4
View File
@@ -6,6 +6,12 @@ import 'party.dart';
// Who fetches the current media and how it spreads, from the state alone:
// the members card's source line and each member's route and buffer.
/// `media.relay` of a state, or null when the media is not relayed.
MediaRelay? stateMediaRelay(StateBroadcast state) {
final r = state.media?['relay'];
return r is Map ? MediaRelay.fromJson(Map<String, Object?>.from(r)) : null;
}
/// "4.1 MB/s", "850 kB/s".
String formatRate(int bytesPerSec) {
if (bytesPerSec >= 1000000) {
@@ -50,6 +56,9 @@ enum SourceState {
slow,
/// Its channel is down (or the server has not read the link yet).
waiting,
/// The source member's connection dropped (`gone`, within its grace):
/// viewers play what the server has.
gone,
/// It left the server.
left,
}
@@ -114,6 +123,9 @@ class Delivery {
final String sourceName;
final bool sourceIsSelf;
final SourceState state;
/// [SourceState.gone]: since when; and the time the line is read at.
final DateTime? goneSince;
final DateTime? at;
/// `media.relay.rate` / `need` (0 unknown).
final int rate, need;
/// Members (not sitting out, not the source) reading from the server,
@@ -125,6 +137,8 @@ class Delivery {
this.sourceName = '',
this.sourceIsSelf = false,
this.state = SourceState.ok,
this.goneSince,
this.at,
this.rate = 0,
this.need = 0,
this.viaServer = 0,
@@ -155,7 +169,10 @@ class Delivery {
};
case DeliveryKind.memberRelay:
return switch (state) {
SourceState.left => '$sourceName left: playing what the server has',
SourceState.left => '$sourceName left — playing what the server has',
SourceState.gone => sourceIsSelf
? 'Reconnecting you to the server…'
: "$sourceName's connection dropped — playing what the server has${_for()}",
SourceState.waiting => sourceIsSelf ? 'Reconnecting you to the server…' : 'Waiting for $who to reconnect',
SourceState.slow => sourceIsSelf ? 'Your upload is slower than the video' : "$who's upload is slower than the video",
SourceState.ok => sourceIsSelf ? 'You pass it on through the server' : '$who passes it on through the server',
@@ -163,6 +180,12 @@ class Delivery {
}
}
String _for() {
final since = goneSince, now = at;
if (since == null || now == null || now.difference(since).inMinutes < 1) return '';
return ' (${sinceText(since, now)})';
}
/// "3 through the server · 4.1 MB/s, needs 2.3 MB/s"; empty when
/// there is nothing to add.
String get detail {
@@ -170,7 +193,7 @@ class Delivery {
if (direct > 0 && (kind == DeliveryKind.jellyfin || kind == DeliveryKind.memberRelay))
'$direct with their own account',
if (viaServer > 0 && kind != DeliveryKind.link) '$viaServer through the server',
if (rate > 0 && state != SourceState.left) need > 0 ? '${formatRate(rate)}, needs ${formatRate(need)}' : formatRate(rate),
if (rate > 0 && state != SourceState.left && state != SourceState.gone) need > 0 ? '${formatRate(rate)}, needs ${formatRate(need)}' : formatRate(rate),
];
return parts.join(' · ');
}
@@ -193,8 +216,9 @@ class Delivery {
}
/// The current media's [Delivery]; null with nothing up or nobody else in
/// the server. [names] remembers a source that left.
Delivery? deliveryOf(StateBroadcast r, {required String selfId, MemberNames? names}) {
/// the server. [names] remembers a source that left; [now] reads how long
/// a gone source has been gone.
Delivery? deliveryOf(StateBroadcast r, {required String selfId, MemberNames? names, DateTime? now}) {
final media = r.media;
if (media == null || r.members.length < 2) return null;
final rel = media['relay'];
@@ -237,6 +261,8 @@ Delivery? deliveryOf(StateBroadcast r, {required String selfId, MemberNames? nam
? SourceState.ok
: kind == DeliveryKind.memberRelay && source == null
? SourceState.left
: kind == DeliveryKind.memberRelay && (relay.sourceGone || source?.presence == Presence.gone)
? SourceState.gone
: !relay.live
? SourceState.waiting
: relay.slow
@@ -248,6 +274,8 @@ Delivery? deliveryOf(StateBroadcast r, {required String selfId, MemberNames? nam
sourceName: source?.name ?? names?[sourceId] ?? 'The member passing it on',
sourceIsSelf: sourceId.isNotEmpty && sourceId == selfId,
state: state,
goneSince: state == SourceState.gone ? relay?.sourceGoneSince ?? source?.goneSince : null,
at: now ?? DateTime.now().toUtc(),
rate: relay?.rate ?? 0,
need: relay?.need ?? 0,
viaServer: via,
-7
View File
@@ -1,7 +0,0 @@
enum HealthSeverity { ok, info, warn, error }
class HealthStatus {
final HealthSeverity severity;
final String message;
const HealthStatus(this.severity, this.message);
}
@@ -0,0 +1,29 @@
// The user's notification choices (Settings → Notifications & sounds), as
// the Notifier's policy reads them. Pure Dart; [AppSettings] stores them.
/// When chat messages pop up as toasts (they always reach the chat).
enum ChatPopups {
all('All'),
paused('Only when paused'),
off('Off');
final String label;
const ChatPopups(this.label);
/// Whether a chat message pops up now ([playing]: the video plays).
bool shows({required bool playing}) => switch (this) {
ChatPopups.all => true,
ChatPopups.paused => !playing,
ChatPopups.off => false,
};
}
/// What the user picked: chat pop-ups, and whether joins and leaves also
/// reach them while they sit out or the window is in the background
/// ([people]; off by default: then only chat, nudges, "starting now" and
/// Up next do).
class NotifyPrefs {
final ChatPopups chat;
final bool people;
const NotifyPrefs({this.chat = ChatPopups.all, this.people = false});
}
+328 -44
View File
@@ -1,12 +1,16 @@
import 'dart:async';
import 'package:signals/signals_core.dart';
import 'package:sync_relay_core/core.dart' show FeedMessage;
import 'package:sync_relay_core/core.dart' show FeedMessage, Presence;
import '../platform/ports/system_notifications.dart';
import '../platform/ports/window_activity.dart';
import '../sfx/sfx.dart';
import 'app_events.dart';
import 'notice_prefs.dart';
export 'app_events.dart';
export 'notice_prefs.dart';
class ToastItem {
final int id;
@@ -17,67 +21,243 @@ class ToastItem {
final String? actor;
/// [actor]'s member id (feed and chat toasts); '' when unknown.
final String actorId;
const ToastItem({required this.id, required this.text, required this.at, this.actor, this.actorId = ''});
/// A chat message: a click opens the chat.
final bool chat;
/// A picture hash to show (an Up next item's), '' when none.
final String thumb;
const ToastItem(
{required this.id,
required this.text,
required this.at,
this.actor,
this.actorId = '',
this.chat = false,
this.thumb = ''});
}
/// What a notice is about; the policy filters by it ([noticeFor]).
enum NoticeCategory {
/// Chat messages.
chat,
/// Someone poked this member.
nudge,
/// The media starts playing for the first time ("starting now").
start,
/// Up next and the video changing (adds, skips, swaps).
upNext,
/// Joins, leaves, lost signal and returns.
people,
/// The rest of what the party does (ready, seeks, pauses, someone sat
/// out, renames): the activity log and a few sounds.
party,
/// Something keeps the watch from going smoothly (someone cannot play
/// it, the source dropped, the server could not fetch it).
problem,
/// About this member itself (sat out, auto sit-out, the relay it
/// serves, its connection, another window took over).
self,
/// The app's own notices (copied, shaders, update, errors): never
/// filtered.
app;
/// What reaches you while you sit out or the window is in the
/// background (D1): chat, nudges, "starting now" and Up next, plus joins
/// and leaves when [NotifyPrefs.people] is on.
bool reachesAway(NotifyPrefs prefs) => switch (this) {
chat || nudge || start || upNext || self || app => true,
people => prefs.people,
party || problem => false,
};
/// Social: nothing of it plays or pops up while you watch alone (D9).
bool get social => this == chat || this == start || this == people || this == party;
/// May go out as a system notification while the window is in the
/// background.
bool get system => this == chat || this == nudge || this == start || this == upNext || this == people;
}
/// The session as the policy sees it.
typedef NoticeSession = ({Presence? self, bool alone, bool playing});
/// Where the user stands when a notice comes: their presence ([self],
/// null outside a session), whether anyone else is there ([alone]), the
/// window ([window]), whether the video plays ([playing]) and their
/// choices ([prefs]). A replayed feed line says so itself.
class NoticeContext {
final Presence? self;
final bool alone;
final WindowActivity window;
final bool playing;
final NotifyPrefs prefs;
const NoticeContext({
this.self,
this.alone = false,
this.window = WindowActivity.focused,
this.playing = false,
this.prefs = const NotifyPrefs(),
});
bool get away => self == Presence.away;
}
/// A notification outside the window ([SystemNotifications]).
class SystemNote {
final String title;
final String body;
final String tag;
/// [payloadChat]: a click opens the chat.
final String payload;
const SystemNote({required this.title, required this.body, this.tag = '', this.payload = ''});
static const payloadChat = 'chat';
}
/// What one [AppEvent] does: a toast ([toast], about [actor]), a sound,
/// the window asking for attention, an activity-log line.
/// the window asking for attention, a system notification, an
/// activity-log line.
class Notice {
final NoticeCategory category;
final String? toast;
final String? actor;
/// The toast is a chat message (a click opens the chat).
final bool chat;
/// A picture hash for the toast (an Up next item's).
final String thumb;
final SfxCue? sound;
final bool attention;
final SystemNote? system;
/// The feed line it goes into the activity log as.
final FeedMessage? log;
const Notice({this.toast, this.actor, this.sound, this.attention = false, this.log});
const Notice({
this.category = NoticeCategory.app,
this.toast,
this.actor,
this.chat = false,
this.thumb = '',
this.sound,
this.attention = false,
this.system,
this.log,
});
static const none = Notice();
Notice _with({bool toast = true, bool sound = true, SystemNote? system}) => Notice(
category: category,
toast: toast ? this.toast : null,
actor: toast ? actor : null,
chat: toast && chat,
thumb: toast ? thumb : '',
sound: sound ? this.sound : null,
attention: attention,
system: system,
log: log,
);
}
/// The policy: what each event does.
Notice noticeFor(AppEvent e) => switch (e) {
// Lines a reconnect replayed (`welcome.feed`): the log only.
FeedEvent(:final feed) when feed.replayed => Notice(log: feed),
// The feed: everything is logged; joins, leaves, swaps and chat
// pop up; the social actions have a sound.
String _title(String t) => t.isEmpty ? 'the video' : t;
/// The event alone: its category, toast, sound and log line.
Notice _plain(AppEvent e) => switch (e) {
// The feed: everything is logged.
MemberJoined(:final feed) => Notice(
log: feed, toast: '${feed.actor} joined', actor: feed.actor, sound: const SfxCue(SfxKind.join)),
category: NoticeCategory.people,
log: feed,
toast: '${feed.actor} joined',
actor: feed.actor,
sound: const SfxCue(SfxKind.join)),
MemberLeft(:final feed) => Notice(
log: feed, toast: '${feed.actor} left', actor: feed.actor, sound: const SfxCue(SfxKind.leave)),
category: NoticeCategory.people,
log: feed,
toast: '${feed.actor} left',
actor: feed.actor,
sound: const SfxCue(SfxKind.leave)),
// After the server's delayed `gone` line (a quick reconnect says
// nothing), and back after it.
MemberGone(:final feed) => Notice(
category: NoticeCategory.people, log: feed, toast: '${feed.actor} lost connection', actor: feed.actor),
MemberReturned(:final feed) =>
Notice(category: NoticeCategory.people, log: feed, toast: '${feed.actor} is back', actor: feed.actor),
MediaSwapped(:final feed) => Notice(
log: feed, toast: '${feed.actor} changed media', actor: feed.actor, sound: const SfxCue(SfxKind.swap)),
ChatPosted(:final feed, :final own) => Notice(
category: NoticeCategory.upNext,
log: feed,
toast: '${feed.actor} changed media',
actor: feed.actor,
sound: const SfxCue(SfxKind.swap)),
QueueAdded(:final feed, :final thumb) => Notice(
category: NoticeCategory.upNext,
log: feed,
toast: '${feed.actor} added ${_title(feed.target ?? '')} to Up next',
actor: feed.actor,
thumb: thumb),
QueueSkipped(:final feed) => Notice(
category: NoticeCategory.upNext,
log: feed,
toast: 'Skipped ${feed.target ?? 'an item'}${feed.reason.isEmpty ? '' : ': ${feed.reason}'}'),
QueueRemoved(:final feed) => Notice(category: NoticeCategory.upNext, log: feed),
ChatPosted(:final feed) => Notice(
category: NoticeCategory.chat,
log: feed,
toast: '${feed.actor}: ${feed.reason}',
actor: feed.actor,
// Your own message needs no sound.
sound: own ? null : const SfxCue(SfxKind.chat)),
chat: true,
sound: const SfxCue(SfxKind.chat)),
MemberSatOut(:final feed, atYou: true) => Notice(
log: feed, toast: '${feed.actor} sat you out', actor: feed.actor, sound: const SfxCue(SfxKind.kick)),
category: NoticeCategory.self,
log: feed,
toast: '${feed.actor} sat you out',
actor: feed.actor,
sound: const SfxCue(SfxKind.kick)),
MemberSatOut(:final feed, autoSelf: true) => Notice(
category: NoticeCategory.self,
log: feed,
toast: 'You sit out (${feed.reason == 'minimised' ? 'minimised' : 'idle'}); you are back as soon as you return',
sound: const SfxCue(SfxKind.warn)),
ReadyChanged(:final feed, ready: true, :final readyIndex) =>
Notice(log: feed, sound: SfxCue(SfxKind.ready, readyIndex: readyIndex)),
ReadyChanged(:final feed, ready: false) => Notice(log: feed, sound: const SfxCue(SfxKind.unready)),
ForcedReady(:final feed, ready: true) => Notice(log: feed, sound: const SfxCue(SfxKind.forceReady)),
QueueSkipped(:final feed) => Notice(
log: feed, toast: 'Skipped ${feed.target ?? 'an item'}${feed.reason.isEmpty ? '' : ': ${feed.reason}'}'),
FeedEvent(:final feed) => Notice(log: feed),
Notice(category: NoticeCategory.party, log: feed, sound: SfxCue(SfxKind.ready, readyIndex: readyIndex)),
ReadyChanged(:final feed, ready: false) =>
Notice(category: NoticeCategory.party, log: feed, sound: const SfxCue(SfxKind.unready)),
ForcedReady(:final feed, ready: true) =>
Notice(category: NoticeCategory.party, log: feed, sound: const SfxCue(SfxKind.forceReady)),
FetchFailed(:final feed) => Notice(category: NoticeCategory.problem, log: feed),
FeedEvent(:final feed) => Notice(category: NoticeCategory.party, log: feed),
// The session.
Nudged(:final name) => Notice(
category: NoticeCategory.nudge,
toast: '${name.isEmpty ? 'Someone' : name} nudged you',
actor: name.isEmpty ? null : name,
sound: const SfxCue(SfxKind.nudge),
attention: true),
MemberCantPlay(:final name, :final error) => Notice(toast: "$name can't play this: $error", actor: name),
SlowSource(:final text) => Notice(toast: text),
RelayStarted() =>
const Notice(toast: 'Passing the video on through the server. Keep the app open while it plays.'),
RelayFailed(:final error) => Notice(toast: 'Cannot pass the video on: $error'),
ConnectionLost() => const Notice(sound: SfxCue(SfxKind.lost)),
ConnectionBack() => const Notice(sound: SfxCue(SfxKind.back)),
PlaybackStarted(:final title) =>
Notice(category: NoticeCategory.start, toast: title.isEmpty ? 'Starting now' : 'Starting now: $title'),
MemberCantPlay(:final name, :final error) =>
Notice(category: NoticeCategory.problem, toast: "$name can't play this: $error", actor: name),
RelaySourceDropped(:final name, :final left) => Notice(
category: NoticeCategory.problem,
toast: left
? '$name left: playing what the server has'
: "$name's connection dropped: playing what the server has",
actor: name),
SlowSource(:final text) => Notice(category: NoticeCategory.self, toast: text),
RelayStarted() => const Notice(
category: NoticeCategory.self,
toast: 'Passing the video on through the server. Keep the app open while it plays.'),
RelayFailed(:final error) => Notice(category: NoticeCategory.self, toast: 'Cannot pass the video on: $error'),
ConnectionLost() => const Notice(category: NoticeCategory.self, sound: SfxCue(SfxKind.lost)),
ConnectionBack() => const Notice(category: NoticeCategory.self, sound: SfxCue(SfxKind.back)),
ServerRestarted() =>
const Notice(category: NoticeCategory.self, toast: 'The server restarted; you are back in'),
Replaced() => const Notice(category: NoticeCategory.self, toast: 'Starsync was opened in another window'),
ServerRefused(:final text) => Notice(category: NoticeCategory.self, toast: text),
JellyfinProgressFailed(:final signInExpired) =>
Notice(toast: 'Jellyfin progress not saved: ${signInExpired ? 'sign-in expired' : 'server unreachable'}'),
// The app.
@@ -89,29 +269,115 @@ Notice noticeFor(AppEvent e) => switch (e) {
Notice(toast: inSession ? 'Marked in the session log' : "Marked in this app's log"),
Copied(:final text) => Notice(toast: text),
CopyFailed() => const Notice(toast: "Couldn't copy"),
// The update band shows it.
// The update band shows it (D8: a clear prompt, never over playback).
UpdateAvailable() => Notice.none,
};
/// The policy: what [e] does for a user in [c].
/// - Lines a reconnect replayed (`welcome.feed`): the activity log only.
/// - Your own joins, leaves, swaps and Up next adds: logged, no pop-up or
/// sound (the app already said so); your own chat pops up, silently.
/// - Alone (D9): no social sounds or pop-ups (nobody is there); the app's
/// notices, problems and your own still show.
/// - Sitting out (D1): only chat, nudges, "starting now", Up next, your
/// own and the app's, plus joins and leaves when chosen
/// ([NotifyPrefs.people]); the rest is logged only.
/// - "Starting now" pops up only while you sit out or the window is in
/// the background (otherwise you see it start).
/// - Chat pop-ups follow [NotifyPrefs.chat]; the chat sound has its own
/// switch (Settings → Sounds).
/// - The window in the background: what reaches you there also goes out
/// as a system notification (chat, nudges, "starting now", Up next;
/// joins and leaves when chosen).
Notice noticeFor(AppEvent e, [NoticeContext c = const NoticeContext()]) {
if (e is FeedEvent && e.feed.replayed) return Notice(category: _plain(e).category, log: e.feed);
final n = _plain(e);
final cat = n.category;
final own = e is FeedEvent && e.own;
var toast = n.toast != null;
var sound = n.sound != null;
if (own && (cat == NoticeCategory.people || cat == NoticeCategory.upNext)) {
toast = false;
sound = false;
}
if (own && cat == NoticeCategory.chat) sound = false;
if (c.alone && cat.social) {
toast = false;
sound = false;
}
if (c.away && !cat.reachesAway(c.prefs)) {
toast = false;
sound = false;
}
if (cat == NoticeCategory.start && !c.away && !c.window.background) toast = false;
if (cat == NoticeCategory.chat && !c.prefs.chat.shows(playing: c.playing)) toast = false;
SystemNote? system;
if (toast && !own && c.window.background && cat.system && cat.reachesAway(c.prefs)) {
final actor = n.actor;
final text = n.toast!;
final body = actor != null && text.startsWith(actor) ? text.substring(actor.length).replaceFirst(RegExp(r'^:? '), '') : text;
system = SystemNote(
title: actor ?? 'Starsync',
body: actor == null ? text : body,
tag: cat == NoticeCategory.chat ? '' : cat.name,
payload: cat == NoticeCategory.chat ? SystemNote.payloadChat : '',
);
}
return n._with(toast: toast, sound: sound, system: system);
}
/// The one way news reaches the user: [notify] turns an [AppEvent] into
/// toasts ([toasts]), sounds, an attention request and the activity log
/// ([feedLog]) per [noticeFor].
/// toasts ([toasts], each dropped after [toastLife] by the Notifier's
/// own timer), sounds, an attention request, a system notification and
/// the activity log ([feedLog]) per [noticeFor], in the context the
/// session ([session]), the window ([window]) and the user's choices
/// ([prefs]) give.
class Notifier {
final SfxService sfx;
final Future<void> Function() attention;
Notifier({this.sfx = const NoopSfxService(), Future<void> Function()? attention})
: attention = attention ?? _noAttention;
final SystemNotifications system;
/// Brings the window up (a click on a system notification).
final Future<void> Function() focusWindow;
final Duration toastLife;
/// The session's part of the context; set by the coordinator.
NoticeSession Function() session = () => (self: null, alone: false, playing: false);
NotifyPrefs Function() prefs;
/// The window's activity ([ShellPort.watchActivity], wired in `main`).
WindowActivity window = WindowActivity.focused;
static Future<void> _noAttention() async {}
Notifier({
this.sfx = const NoopSfxService(),
Future<void> Function()? attention,
this.system = const NoSystemNotifications(),
Future<void> Function()? focusWindow,
this.toastLife = const Duration(seconds: 5),
NotifyPrefs Function()? prefs,
}) : attention = attention ?? _nothing,
focusWindow = focusWindow ?? _nothing,
prefs = prefs ?? _defaultPrefs {
system.onClick = _clicked;
}
/// Toasts on screen (the shell shows them; [expire] drops old ones).
static Future<void> _nothing() async {}
static NotifyPrefs _defaultPrefs() => const NotifyPrefs();
/// Toasts on screen (the shell shows them).
final toasts = signal<List<ToastItem>>([]);
/// The activity log: the last 100 feed lines.
final feedLog = signal<List<FeedMessage>>([]);
/// Bumped when a click on a chat notification asks for the chat (the
/// shell opens it).
final chatRequests = signal(0);
int _toastId = 0;
final _timers = <int, Timer>{};
NoticeContext get context {
final s = session();
return NoticeContext(self: s.self, alone: s.alone, window: window, playing: s.playing, prefs: prefs());
}
void notify(AppEvent e) {
final n = noticeFor(e);
final n = noticeFor(e, context);
final log = n.log;
if (log != null) {
final all = [...feedLog.peek(), log];
@@ -119,29 +385,47 @@ class Notifier {
}
final text = n.toast;
if (text != null) {
_toastId++;
final id = ++_toastId;
toasts.value = [
...toasts.peek(),
ToastItem(
id: _toastId,
id: id,
text: text,
at: DateTime.now().toUtc(),
actor: n.actor,
actorId: n.actor == null ? '' : n.log?.actorId ?? ''),
actorId: n.actor == null ? '' : n.log?.actorId ?? '',
chat: n.chat,
thumb: n.thumb),
];
_timers[id] = Timer(toastLife, () => dismiss(id));
}
final sound = n.sound;
if (sound != null) unawaited(sfx.play(sound));
if (n.attention) unawaited(attention());
final note = n.system;
if (note != null) {
unawaited(system.show(title: note.title, body: note.body, tag: note.tag, payload: note.payload));
}
}
/// Drops toasts 4 s old or older.
void expire(DateTime now) {
toasts.value = toasts.peek().where((t) => now.difference(t.at).inSeconds < 4).toList();
void _clicked(String payload) {
unawaited(focusWindow());
if (payload == SystemNote.payloadChat) chatRequests.value++;
}
/// Takes toast [id] off the screen (its time is up, or it was clicked).
void dismiss(int id) {
_timers.remove(id)?.cancel();
final now = toasts.peek();
if (now.any((t) => t.id == id)) toasts.value = [for (final t in now) if (t.id != id) t];
}
/// A new session: no toasts, an empty log.
void clear() {
for (final t in _timers.values) {
t.cancel();
}
_timers.clear();
feedLog.value = [];
toasts.value = [];
}
@@ -28,16 +28,18 @@ import '../platform/ports/shell_port.dart' show WindowActivity;
import 'auto_sit_out.dart';
import 'connection.dart';
import 'delivery.dart';
import 'health.dart';
import 'media_art.dart';
import 'notifier.dart';
import 'now_playing.dart';
import 'now_playing_info.dart';
import 'party.dart';
import 'status.dart';
import 'switch_trace.dart';
export 'connection.dart' show ConnectCancelled, ConnectParams;
export 'notifier.dart' show ToastItem;
export 'delivery.dart' show stateMediaRelay;
export 'status.dart';
final _log = createLogger('session');
@@ -71,12 +73,6 @@ bool unreadyForNewMedia(
required bool ready}) =>
enabled && lastMediaId >= 0 && mediaId != lastMediaId && ready;
/// `media.relay` of a state, or null when the media is not relayed.
MediaRelay? stateMediaRelay(StateBroadcast state) {
final r = state.media?['relay'];
return r is Map ? MediaRelay.fromJson(Map<String, Object?>.from(r)) : null;
}
/// What this member serves when its own swap is relayed: set at swap time,
/// claimed by the state that names this member as the source.
sealed class _RelayOrigin {
@@ -103,19 +99,6 @@ final class _JellyfinOrigin extends _RelayOrigin {
const _JellyfinOrigin(this.link);
}
/// The health line a connection change writes; null writes none (closed:
/// whoever closed says why).
HealthStatus? connectionHealth(ConnState s) => switch (s) {
ConnConnecting() => const HealthStatus(HealthSeverity.info, 'connecting…'),
ConnOnline() => const HealthStatus(HealthSeverity.ok, 'connected'),
ConnUnstable() => const HealthStatus(HealthSeverity.warn, 'connection unstable…'),
ConnLost() => const HealthStatus(HealthSeverity.warn, 'connection lost — retrying…'),
ConnReconnecting() => const HealthStatus(HealthSeverity.info, 'reconnecting…'),
ConnRefused(:final message) => HealthStatus(HealthSeverity.error, message),
ConnReplaced(:final message) => HealthStatus(HealthSeverity.error, message),
ConnClosed() => null,
};
String? stateMediaUrl(StateBroadcast state) {
final media = state.media;
if (media == null) return null;
@@ -171,8 +154,6 @@ class SessionCoordinator {
final SwitchTrace switchTrace = SwitchTrace();
final StateDiff diff = StateDiff();
final health =
signal(const HealthStatus(HealthSeverity.info, 'idle'));
/// The server's latest state. A drop keeps it (with the player, the
/// media and a relay this member serves): [stateStale] says it is not
/// current; only leaving clears it.
@@ -191,7 +172,38 @@ class SessionCoordinator {
late final Computed<bool> reconnecting = computed(() => connection.state.value.retrying);
/// The link is up but went quiet ([ConnUnstable]).
late final Computed<bool> unstable = computed(() => connection.state.value is ConnUnstable);
final starving = signal<String?>(null);
/// The player itself failed (a "Reload video" that failed); '' when
/// fine. A fact for [status].
final playerFault = signal('');
/// The mediaId the server said it could not fetch (`fetch-fail`); -1.
final fetchFailedFor = signal(-1);
/// Reconnects that found the server restarted (another
/// `welcome.instance`).
final restarts = signal(0);
// The status's clock, moved on by the tick when the minute changes (a
// "(2 min)" in it).
final _clock = signal(DateTime.now().toUtc());
/// The one computed status (session/status.dart): the player's pill,
/// the strip's line, the members card's source line. Nothing sets it;
/// [StatusWatch] turns its transitions into notices.
late final Computed<Status> status = computed(() {
final plan = jellyfinPlan.value;
return statusOf(StatusFacts(
conn: connection.state.value,
state: serverState.value,
selfId: connection.memberId,
playerError: playerError.value,
playerFault: playerFault.value,
ownPlan: plan is OwnPlay,
fetchFailedFor: fetchFailedFor.value,
names: memberNames,
restarts: restarts.value,
now: _clock.value,
));
});
final StatusWatch _statusWatch = StatusWatch();
void Function()? _statusEffect;
/// Why this player cannot open the current media, in plain words
/// ([playErrorText]); empty while it can. It rides in `report.error`, so
/// the server stops waiting for this member and the others see it.
@@ -255,7 +267,6 @@ class SessionCoordinator {
StreamSubscription? _feedSub;
StreamSubscription? _nudgeSub;
StreamSubscription? _errSub;
void Function()? _connectionHealth;
/// A solo session (set by SessionState): no auto sit-out, there is
/// nobody to hold up.
@@ -372,6 +383,27 @@ class SessionCoordinator {
connection
..onWelcome = _onWelcome
..caps = () => caps;
// The Notifier's view of the session (Status → Notifier, never back).
notifier.session = () {
final r = serverState.peek();
return (
self: selfMember.peek()?.presence,
alone: r != null && aloneIn(r.members, connection.memberId),
playing: r?.transport == 'playing',
);
};
_statusEffect = effect(() => _stepStatus(status.value));
}
/// Status transitions worth a notice (see [StatusWatch]).
void _stepStatus(Status s) {
final events = _statusWatch.step(s, DateTime.now().toUtc());
if (events.isEmpty) return;
untracked(() {
for (final e in events) {
notify(e);
}
});
}
// yt-dlp is there to resolve links (checked at each connect).
@@ -406,8 +438,6 @@ class SessionCoordinator {
_playerReady = true;
} catch (e, st) {
debugPrint('player init failed: $e\n$st');
health.value =
HealthStatus(HealthSeverity.error, 'player failed to start: $e');
rethrow;
}
}
@@ -465,6 +495,7 @@ class SessionCoordinator {
if (connection.params == null || _reloadingVideo) return;
_reloadingVideo = true;
_playerReady = false;
playerFault.value = '';
try {
await playback.recreate();
final url = _openUrl;
@@ -474,8 +505,7 @@ class SessionCoordinator {
if (url != null) await _openMedia(url, headers: headers);
} catch (e, st) {
debugPrint('video reload failed: $e\n$st');
health.value =
HealthStatus(HealthSeverity.error, 'video reload failed: $e');
playerFault.value = 'video reload failed: $e';
} finally {
_playerReady = playback.alive.peek();
_reloadingVideo = false;
@@ -504,6 +534,10 @@ class SessionCoordinator {
settledMediaId.value = -1;
_openUrl = null;
playerError.value = '';
playerFault.value = '';
fetchFailedFor.value = -1;
restarts.value = 0;
_statusWatch.reset();
_memberErrors = const {};
_lastReady = false;
_restoringReady = false;
@@ -535,12 +569,7 @@ class SessionCoordinator {
_startTicker();
try {
final url = welcome.mediaUrl;
if (url != null && url.isNotEmpty) {
await _openMedia(url);
} else {
health.value = const HealthStatus(
HealthSeverity.info, 'connected — waiting for host media…');
}
if (url != null && url.isNotEmpty) await _openMedia(url);
final media = params.media ??
(params.linkUrl.isEmpty ? null : await mediaForLink(jellyfin, params.linkUrl));
if (!connection.isCurrent(epoch)) throw const ConnectCancelled();
@@ -549,8 +578,6 @@ class SessionCoordinator {
if (!connection.isCurrent(epoch)) throw const ConnectCancelled();
debugPrint('connect failed: $e');
connection.scheduleReconnect();
health.value =
HealthStatus(HealthSeverity.error, connectErrorText(e));
rethrow;
}
}
@@ -560,13 +587,8 @@ class SessionCoordinator {
_stateSub ??= connection.states.listen(_onState);
_feedSub ??= connection.feeds.listen(_onFeed);
_nudgeSub ??= connection.nudges.listen(_onNudge);
_errSub ??= connection.errors.listen((msg) {
health.value = HealthStatus(HealthSeverity.warn, msg);
});
_connectionHealth ??= connection.state.subscribe((s) {
final h = connectionHealth(s);
if (h != null) health.value = h;
});
// The server refused something this app sent: a toast.
_errSub ??= connection.errors.listen((msg) => notify(ServerRefused(_sentenceCase(msg))));
}
/// Opens [url] (with [headers] on every request); false on failure. A
@@ -593,8 +615,6 @@ class SessionCoordinator {
keepPlayer: keepPlayer,
onFreshPlayer: () => switchTrace.mark(mediaId, SwitchTrace.player));
switchTrace.mark(mediaId, promoted ? SwitchTrace.preloaded : SwitchTrace.opened);
health.value =
const HealthStatus(HealthSeverity.ok, 'connected');
ok = true;
} catch (e) {
debugPrint('open media failed: $e');
@@ -688,8 +708,6 @@ class SessionCoordinator {
return playback.preload(url, headers: headers, ytdlPath: path, fresh: fresh);
}
bool _relayWaitShown = false;
/// The server-fetched media this viewer opens once it is live.
int _awaitLiveFor = -1;
@@ -700,33 +718,6 @@ class SessionCoordinator {
bool _waitForSource(StateBroadcast r, MediaRelay relay) =>
!relay.live && !relay.sourceGone && (relay.fetch || r.members.any((m) => m.id == relay.source));
/// Relayed through a member whose channel is down: say who everyone is
/// waiting for (the buffer still plays what it has).
void _relayStatus(StateBroadcast r) {
final relay = stateMediaRelay(r);
final waiting = relay != null &&
relay.source.isNotEmpty &&
relay.source != connection.memberId &&
!relay.live &&
_openUrl != null &&
jellyfinPlan.peek() is! OwnPlay;
if (waiting) {
final who = r.members.where((m) => m.id == relay.source).firstOrNull?.name;
final gone = memberNames[relay.source] ?? 'the member who put this up';
health.value = HealthStatus(
HealthSeverity.warn,
who == null
? '$gone left: playing what the server has'
: relay.sourceGone
? '$who lost signal: playing what the server has'
: 'waiting for $who to pass the video on');
_relayWaitShown = true;
} else if (_relayWaitShown) {
_relayWaitShown = false;
health.value = const HealthStatus(HealthSeverity.ok, 'connected');
}
}
/// This viewer's `/r/` URL; with tracks, a pieces track's playlist, or
/// an mpv EDL of every track when there are several (a page's separate
/// video and audio). A relay started in Up next ([queueId]) keeps its
@@ -948,8 +939,6 @@ class SessionCoordinator {
_awaitLiveFor = r.mediaId;
final who = r.members.where((m) => m.id == relay.source).firstOrNull?.name;
_log.info('media ${r.mediaId}: waiting for ${relay.fetch ? 'the server' : who} before opening the relay');
health.value = HealthStatus(HealthSeverity.info,
relay.fetch ? 'the server is getting the video…' : 'waiting for $who to pass the video on…');
} else {
await _openMedia(_relayMediaUrlFor(name, r.mediaId, relay.tracks, relay.queueId));
}
@@ -976,10 +965,8 @@ class SessionCoordinator {
_relayMediaUrlFor((r.media?['name'] as String?) ?? '', r.mediaId, relay.tracks, relay.queueId));
}
_jellyfinTransport(r);
_relayStatus(r);
if (!_playerReady || _openUrl == null) {
_wasPlayAllowed = false;
_updateStarving(r, 0);
return;
}
// Server clock: serverTime in the state is the server's.
@@ -1014,7 +1001,6 @@ class SessionCoordinator {
_seekRetries = 0;
}
await playback.apply(actions);
_updateStarving(r, playback.bufferedSecsAhead);
}
/// Mirrors the server's ready flag for self into [_lastReady] (force
@@ -1051,7 +1037,6 @@ class SessionCoordinator {
Future<void> _unloadForOut() async {
_wasPlayAllowed = false;
_awaitLiveFor = -1;
starving.value = null;
if (_mediaId == -1 && _openUrl == null) return;
_mediaId = -1;
_playerMediaId = -1;
@@ -1085,61 +1070,30 @@ class SessionCoordinator {
} catch (_) {}
}
void _updateStarving(StateBroadcast r, double bufferedAhead) {
if (r.transport == 'playing') {
starving.value = null;
return;
}
if (r.transport == 'finished') {
starving.value = 'finished — seek back or change media';
return;
}
for (final m in r.members) {
if (m.inSet && m.buffered < r.bufferNeed) {
starving.value =
'waiting for ${m.name} — ${m.buffered.toStringAsFixed(1)} s buffered';
return;
}
}
final self = selfMember.peek();
if (self != null && self.ready) {
// Who the ready counter counts (not away, not gone, can play it).
final present = r.members.where(countsForReady).toList();
final waiters = present
.where((m) => m.id != self.id && !(m.inSet && m.ready))
.map((m) => m.name)
.toList();
if (waiters.isNotEmpty) {
final readyCount = present.where((m) => m.ready).length;
starving.value =
'waiting for ${waiters.join(', ')} ($readyCount/${present.length} ready)';
return;
}
}
if (self != null && !self.ready) {
starving.value = 'press Space when ready';
return;
}
if (r.members.any((m) => m.inSet)) {
starving.value = 'held — waiting for readiness';
return;
}
starving.value = 'held — press Space when ready';
}
void _onFeed(FeedMessage f) {
final r = serverState.peek();
final event = FeedEvent.of(f,
selfId: connection.memberId,
readyCount: f.action == 'ready'
? serverState.peek()?.members.where((m) => m.ready && countsForReady(m)).length ?? 0
: 0);
readyCount: f.action == 'ready' ? r?.members.where((m) => m.ready && countsForReady(m)).length ?? 0 : 0,
// The state goes out before its feed: the item is in it.
queueThumb: f.action == 'queue-add' ? _addedThumb(r, f) : '');
// The server gave up fetching what everyone waits for (a fact for
// [status]).
if (event is FetchFailed && r != null && !f.replayed) fetchFailedFor.value = r.mediaId;
notify(event);
// The server gave up fetching what everyone waits for.
if (event is FetchFailed && _awaitLiveFor != -1 && _awaitLiveFor == serverState.peek()?.mediaId) {
health.value = const HealthStatus(HealthSeverity.error, "the server couldn't fetch this video");
}
}
/// The picture of the Up next item [f] (`queue-add`) added: the newest
/// item of that title by that member.
static String _addedThumb(StateBroadcast? r, FeedMessage f) {
for (final q in (r?.queue ?? const <QueueItem>[]).reversed) {
if (q.title == f.target && (f.actorId.isEmpty || q.by == f.actorId)) return q.thumb;
}
return '';
}
static String _sentenceCase(String s) => s.isEmpty ? s : s[0].toUpperCase() + s.substring(1);
/// Someone poked this member: sound (even when its switch is off; the
/// volume still applies), a toast, and the window asks for attention.
void _onNudge(NudgeNotice n) => notify(Nudged(n.name));
@@ -1176,7 +1130,10 @@ class SessionCoordinator {
Future<void> _tick() async {
final now = DateTime.now().toUtc();
notifier.expire(now);
// The status's clock moves by the minute; its transitions (a source
// gone long enough) are checked every second.
if (now.difference(_clock.peek()).inMinutes >= 1 || now.minute != _clock.peek().minute) _clock.value = now;
_stepStatus(status.peek());
final r = serverState.peek();
final online = connection.state.peek().online;
if (online && connection.token.isNotEmpty && r != null && _playerReady) {
@@ -2038,6 +1995,7 @@ class SessionCoordinator {
if (!rejoin.reconnect) return;
_log.info('reconnected: ${rejoin.resumed ? 'resumed' : 'a new member'}'
'${rejoin.sameServer ? '' : ' (the server restarted)'}');
if (!rejoin.sameServer) restarts.value++;
if (rejoin.resumed) return;
final before = serverState.peek();
final raw = welcome.state;
@@ -2107,16 +2065,9 @@ class SessionCoordinator {
/// "Stop reconnecting".
void cancelReconnect() => connection.cancelReconnect();
Future<void> cancelConnect() async {
await connection.cancelConnect();
health.value = const HealthStatus(HealthSeverity.info, 'cancelled');
}
Future<void> cancelConnect() => connection.cancelConnect();
void retry() {
if (!connection.retry()) return;
health.value =
const HealthStatus(HealthSeverity.info, 'retrying…');
}
void retry() => connection.retry();
/// App close, before [leave]: the players go first (no mpv may outlive
/// the engine). The Jellyfin stop report [leave] sends keeps the
@@ -2158,11 +2109,7 @@ class SessionCoordinator {
} catch (_) {}
// The last lines go before the token does, then the bye.
await connection.close(bye: bye);
if (!silent) {
serverState.value = null;
starving.value = null;
health.value = const HealthStatus(HealthSeverity.info, 'idle');
}
if (!silent) serverState.value = null;
}
Future<void> dispose() async {
@@ -2175,7 +2122,7 @@ class SessionCoordinator {
await _feedSub?.cancel();
await _nudgeSub?.cancel();
await _errSub?.cancel();
_connectionHealth?.call();
_statusEffect?.call();
_bufferSub?.call();
_aniList?.close();
await connection.dispose();
+330
View File
@@ -0,0 +1,330 @@
import 'package:sync_relay_core/core.dart';
import 'app_events.dart';
import 'connection.dart';
import 'delivery.dart';
import 'now_playing_info.dart';
import 'party.dart';
// The session's one computed status: what the player's status pill, the
// party strip's server line and the members card's source line say, from
// facts only (the connection, the server's state with its hold reason and
// relay, this player's open/error facts). Nothing writes a status; the
// coordinator computes it ([statusOf]) and [StatusWatch] turns the
// transitions worth a notice into [AppEvent]s for the Notifier (Status →
// Notifier, never back).
/// How bad it is: [ok] and [info] never colour anything, [warn] amber,
/// [error] red.
enum StatusLevel { ok, info, warn, error }
/// What the player's status pill says, most pressing first.
enum StatusKind { reconnecting, problem, connecting, fetching, finished, loading, you, cannotPlay, waiting }
/// The pill's button: stop reconnecting, retry (reconnect), reload the
/// video (your player could not open it), or mark yourself ready (the
/// whole pill).
enum StatusAction { cancel, retry, reload, ready }
/// One status line: [text], a muted [detail], its [level], the members it
/// is about ([who]; [self] for you) and a loading [progress] (0..1).
class StatusLine {
final StatusKind kind;
final String text;
final String? detail;
final StatusLevel level;
final List<MemberView> who;
final bool self;
final double? progress;
final StatusAction? action;
/// The mini player's shorter line.
final String short;
const StatusLine(
this.kind,
this.text, {
this.detail,
this.level = StatusLevel.info,
this.who = const [],
this.self = false,
this.progress,
this.action,
String? short,
}) : short = short ?? text;
/// Coloured: a problem.
bool get problem => level == StatusLevel.warn || level == StatusLevel.error;
}
/// The facts a status is computed from.
class StatusFacts {
final ConnState conn;
/// The server's latest state (kept through a drop).
final StateBroadcast? state;
/// This member's id ('' before the welcome).
final String selfId;
/// Why this player cannot open the current media ([playErrorText]).
final String playerError;
/// The player itself failed (a reload that failed); '' when fine.
final String playerFault;
/// This viewer plays the current Jellyfin item with its own account:
/// the relay's troubles are not its own.
final bool ownPlan;
/// The mediaId the server said it could not fetch (`fetch-fail`), -1.
final int fetchFailedFor;
/// Names of members that left (a relay source that left).
final MemberNames? names;
/// A reconnect found the server restarted this many times.
final int restarts;
final DateTime now;
const StatusFacts({
this.conn = const ConnClosed(),
this.state,
this.selfId = '',
this.playerError = '',
this.playerFault = '',
this.ownPlan = false,
this.fetchFailedFor = -1,
this.names,
this.restarts = 0,
required this.now,
});
}
/// The relay source's trouble, as the transitions watch it.
class SourceTrouble {
final int mediaId;
final String sourceId;
final String name;
/// Gone since (the source member is `gone`); null when it left.
final DateTime? since;
bool get left => since == null;
const SourceTrouble({required this.mediaId, required this.sourceId, required this.name, this.since});
}
class Status {
/// The player's pill; null when there is nothing to say (playing,
/// connected, everyone ready).
final StatusLine? line;
/// The party strip's line under the server name: "connecting…",
/// "reconnecting…", "ready 2/3".
final String party;
/// Where the current media comes from (the members card's source line);
/// null with nothing up or nobody else there.
final Delivery? delivery;
/// The relay source this viewer depends on is gone or left.
final SourceTrouble? source;
final ConnState conn;
final StateBroadcast? state;
final int restarts;
const Status({
this.line,
this.party = '',
this.delivery,
this.source,
this.conn = const ConnClosed(),
this.state,
this.restarts = 0,
});
}
/// "Bob", "Bob and Mika", "Bob, Mika and Cleo", "Bob, Mika and 3 others".
String nameList(List<String> names) {
if (names.length <= 1) return names.join();
if (names.length <= 3) return '${names.sublist(0, names.length - 1).join(', ')} and ${names.last}';
return '${names[0]}, ${names[1]} and ${names.length - 2} others';
}
String _sentence(String s) => s.isEmpty ? s : s[0].toUpperCase() + s.substring(1);
/// "(2 min)" from a minute on, nothing before.
String _forHow(DateTime since, DateTime now) =>
now.difference(since).inMinutes >= 1 ? ' (${sinceText(since, now)})' : '';
/// "Bob's connection dropped — playing what the server has (2 min)",
/// "Bob left — playing what the server has".
String sourceTroubleText(SourceTrouble t, DateTime now) => t.left
? '${t.name} left — playing what the server has'
: "${t.name}'s connection dropped — playing what the server has${_forHow(t.since!, now)}";
/// The relayed media's source this viewer reads from is gone or left.
SourceTrouble? sourceTroubleOf(StateBroadcast r, String selfId, MemberNames? names) {
final relay = stateMediaRelay(r);
if (relay == null || relay.fetch || relay.source.isEmpty || relay.source == selfId) return null;
final m = r.members.where((m) => m.id == relay.source).firstOrNull;
final name = m?.name ?? names?[relay.source] ?? 'The member passing it on';
if (m == null) return SourceTrouble(mediaId: r.mediaId, sourceId: relay.source, name: name);
if (!relay.sourceGone && m.presence != Presence.gone) return null;
return SourceTrouble(
mediaId: r.mediaId,
sourceId: relay.source,
name: name,
since: relay.sourceGoneSince ?? m.goneSince ?? r.serverTime);
}
/// The status from [f].
Status statusOf(StatusFacts f) {
final r = f.state;
final stale = r != null && (f.conn.retrying || f.conn is ConnReplaced || f.conn is ConnRefused);
final counted = (r?.members ?? const <MemberView>[]).where(countsForReady).toList();
final party = r == null
? 'connecting…'
: stale
? 'reconnecting…'
: 'ready ${counted.where((m) => m.ready).length}/${counted.length}';
final self = r == null || f.selfId.isEmpty ? null : r.members.where((m) => m.id == f.selfId).firstOrNull;
final source = r == null || f.ownPlan ? null : sourceTroubleOf(r, f.selfId, f.names);
return Status(
line: _line(f, r, self, source),
party: party,
delivery: r == null ? null : deliveryOf(r, selfId: f.selfId, names: f.names, now: f.now),
source: source,
conn: f.conn,
state: r,
restarts: f.restarts,
);
}
StatusLine? _line(StatusFacts f, StateBroadcast? r, MemberView? self, SourceTrouble? source) {
switch (f.conn) {
case ConnLost() || ConnReconnecting():
return const StatusLine(StatusKind.reconnecting, 'Reconnecting…',
level: StatusLevel.warn, action: StatusAction.cancel);
case ConnUnstable():
return const StatusLine(StatusKind.reconnecting, 'Connection unstable…', level: StatusLevel.warn);
case ConnReplaced(:final message) || ConnRefused(:final message):
return StatusLine(StatusKind.problem, _sentence(message), level: StatusLevel.error, action: StatusAction.retry);
case ConnConnecting():
return const StatusLine(StatusKind.connecting, 'Connecting…');
case ConnOnline() || ConnClosed():
break;
}
final away = self?.presence == Presence.away;
if (r == null || r.media == null) return null;
// Sitting out: the join-back card says it; nobody waits for you.
if (away) return null;
if (f.playerFault.isNotEmpty) {
return StatusLine(StatusKind.problem, _sentence(f.playerFault),
level: StatusLevel.error, action: StatusAction.reload, short: "Can't play this");
}
if (f.playerError.isNotEmpty) {
return StatusLine(StatusKind.problem, "Can't play this: ${f.playerError}",
level: StatusLevel.error, action: StatusAction.reload, short: "Can't play this");
}
// The relay: what everyone reads comes through the server.
final relay = f.ownPlan ? null : stateMediaRelay(r);
if (relay != null && relay.source != f.selfId) {
if (relay.fetch && !relay.live) {
if (f.fetchFailedFor == r.mediaId) {
return const StatusLine(StatusKind.problem, "The server couldn't fetch this video", level: StatusLevel.error);
}
return const StatusLine(StatusKind.fetching, 'The server is getting the video…');
}
if (source != null) {
return StatusLine(StatusKind.problem, sourceTroubleText(source, f.now),
level: StatusLevel.warn,
short: source.left ? '${source.name} left' : "${source.name}'s connection dropped");
}
if (!relay.fetch && !relay.live) {
final who = r.members.where((m) => m.id == relay.source).firstOrNull?.name ?? 'the member who put it up';
return StatusLine(StatusKind.problem, 'Waiting for $who to pass the video on', level: StatusLevel.warn);
}
}
if (r.transport == 'playing') return null;
if (r.transport == 'finished') {
return const StatusLine(StatusKind.finished, 'Finished · seek back or pick the next video', short: 'Finished');
}
// Members whose player cannot open it: the server does not wait for
// them; say why instead of "waiting for".
final failed = [
for (final m in r.members)
if (m.error.isNotEmpty && isPresent(m) && (self == null || m.id != self.id)) m,
];
// As the server holds playback: someone in the ready set short of the
// minimum buffer.
final need = r.bufferNeed;
for (final m in r.members) {
if (m.inSet && m.buffered < need) {
final you = self != null && m.id == self.id;
final p = (m.buffered / need).clamp(0.0, 1.0);
return StatusLine(StatusKind.loading, you ? 'Loading the video…' : '${m.name} is loading…',
who: [m], self: you, progress: p, detail: '${(p * 100).round()}%');
}
}
if (failed.isNotEmpty) {
final first = failed.first;
final who = nameList([for (final m in failed) m.name]);
final unready = self != null && !self.ready;
return StatusLine(StatusKind.cannotPlay, "$who can't play this: ${first.error}",
who: failed,
short: "$who can't play this",
detail: unready ? "Press Space when you're ready" : null,
action: unready ? StatusAction.ready : null);
}
if (self != null && !self.ready) {
return StatusLine(StatusKind.you, "Press Space when you're ready",
who: [self], self: true, action: StatusAction.ready, detail: 'or click here', short: 'Press Space when ready');
}
// Members sitting out or gone are never waited for nor counted.
final active = [for (final m in r.members) if (countsForReady(m)) m];
final others = [
for (final m in active)
if ((self == null || m.id != self.id) && !(m.inSet && m.ready)) m,
];
if (others.isEmpty) return null;
final ready = active.where((m) => m.ready).length;
return StatusLine(StatusKind.waiting, 'Waiting for ${nameList([for (final m in others) m.name])}',
who: others, detail: '$ready/${active.length} ready');
}
/// How long the relay's source must be gone before it is worth a notice
/// (a quick reconnect says nothing).
const sourceDropNotice = Duration(seconds: 5);
/// Turns status transitions worth a notice into events: the source this
/// viewer reads from gone for [sourceDropNotice] (or left), another window
/// took over, the server restarted, the media starting for the first time.
/// Idempotent: [step] may run on every status change and every second.
class StatusWatch {
final _dropped = <String>{};
final _seenHeld = <int>{};
final _started = <int>{};
bool _replaced = false;
int _restarts = 0;
bool _first = true;
List<AppEvent> step(Status s, DateTime now) {
final out = <AppEvent>[];
final src = s.source;
if (src != null) {
final key = '${src.mediaId}/${src.sourceId}/${src.left ? 'left' : src.since!.toIso8601String()}';
final due = src.left || now.difference(src.since!) >= sourceDropNotice;
// Once per drop, and once when it left.
if (due && _dropped.add(key)) out.add(RelaySourceDropped(src.name, left: src.left));
}
final replaced = s.conn is ConnReplaced;
if (replaced && !_replaced) out.add(const Replaced());
_replaced = replaced;
if (!_first && s.restarts > _restarts) out.add(const ServerRestarted());
_restarts = s.restarts;
_first = false;
final r = s.state;
if (r != null && r.media != null) {
if (r.transport == 'held') _seenHeld.add(r.mediaId);
if (r.transport == 'playing' && _seenHeld.contains(r.mediaId) && _started.add(r.mediaId)) {
final now = nowPlayingOf(r.media);
out.add(PlaybackStarted([?now?.title, ?now?.episode].join(' · ')));
}
}
return out;
}
/// A new session.
void reset() {
_dropped.clear();
_seenHeld.clear();
_started.clear();
_replaced = false;
_restarts = 0;
_first = true;
}
}
+50 -5
View File
@@ -6,6 +6,7 @@ import 'package:flutter/services.dart';
import 'package:signals/signals_flutter.dart';
import '../controls/frame.dart' show DoubleClickListener;
import '../controls/session_controls.dart' show controlsBottomHeight;
import '../files/folder_store.dart';
import '../files/ui/files_page.dart' show showFilesFolder;
import '../jellyfin/accounts.dart';
@@ -98,6 +99,9 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
final _chat = TextEditingController();
final _chatFocus = FocusNode();
final _playerKey = GlobalKey();
/// Whether the full player's controls are up (the player's frame
/// reports it): the toasts sit above them.
final _controlsShown = ValueNotifier(true);
Alignment _corner = Alignment.bottomRight;
Offset? _drag; // mini top-left while dragging
@@ -187,6 +191,17 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
_autoFull = false;
});
}));
// A click on a chat system notification: the window is up, open the
// chat.
var chatAsks = coord.notifier.chatRequests.peek();
_stops.add(effect(() {
final asks = coord.notifier.chatRequests.value;
if (asks == chatAsks) return;
chatAsks = asks;
_later(() {
if (!_chatOpen && _online.peek()) _toggleChat();
});
}));
// Chat that arrives while the panel is closed.
_stops.add(effect(() {
final log = coord.feedLog.value;
@@ -250,6 +265,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
_picker.dispose();
_chat.dispose();
_chatFocus.dispose();
_controlsShown.dispose();
if (_fullscreen) _setWindowFullscreen(false);
super.dispose();
}
@@ -771,16 +787,44 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
),
),
if (online && _shown != PlayerMode.none) _playerLayer(area),
Positioned(left: 16, bottom: _fullscreen ? 90 : 16, child: PartyToasts(
onOpenChat: () {
if (!_chatOpen) _toggleChat();
},
)),
_toasts(full && online),
],
);
});
}
/// The toasts, bottom left; over the full player above its control
/// bar (the bar's height in the skin and size in use), moving up while
/// the controls show and down while they are hidden, so they never
/// cover the controls (both skins).
Widget _toasts(bool overPlayer) {
final toasts = PartyToasts(
onOpenChat: () {
if (!_chatOpen) _toggleChat();
},
);
return ValueListenableBuilder<bool>(
valueListenable: _controlsShown,
builder: (context, shown, child) => SignalBuilder(builder: (context) {
final s = AppSettings.instance;
final bar = controlsBottomHeight(s.controlsSkin.value, s.controlsSize.value);
return AnimatedPositioned(
key: const ValueKey('toasts'),
duration: easeTime,
curve: ease,
left: toastInset,
bottom: overPlayer && shown ? bar + toastInset : toastInset,
child: child!,
);
}),
child: toasts,
);
}
/// The toasts' distance from the body's left and bottom edges (and from
/// the player's control bar).
static const toastInset = 16.0;
Offset _cornerPos(Size area, Alignment a, Size size) => Offset(
a.x < 0 ? _miniMargin : (area.width - size.width - _miniMargin).clamp(0, double.infinity),
a.y < 0 ? _miniMargin : (area.height - size.height - _miniMargin).clamp(0, double.infinity),
@@ -852,6 +896,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
onLeave: _confirmLeave,
videoBuilder: widget.videoBuilder,
windowControls: widget.windowControls,
controlsShown: _controlsShown,
),
),
),
+5
View File
@@ -30,6 +30,8 @@ class AvatarInfo {
/// How it gets the media and its buffer ([MemberDelivery.detail]), for
/// the hover; empty when unknown.
final String delivery;
/// Their Starsync version (`members[].app`), for the hover; '' unknown.
final String app;
const AvatarInfo({
this.id = '',
required this.name,
@@ -44,6 +46,7 @@ class AvatarInfo {
this.self = false,
this.error = '',
this.delivery = '',
this.app = '',
});
/// Sitting out.
@@ -136,6 +139,7 @@ AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator
self: self,
error: m.error,
delivery: delivery == null ? '' : memberDeliveryOf(m, r!, delivery).detail,
app: m.app,
);
}
@@ -332,6 +336,7 @@ class _AvatarStackState extends State<AvatarStack> {
? visible[i].name
: "${visible[i].name} can't play this: ${visible[i].error}",
if (visible[i].delivery.isNotEmpty) visible[i].delivery,
if (visible[i].app.isNotEmpty) 'Starsync ${visible[i].app}',
].join('\n'),
child: StatusAvatar(info: visible[i], size: size),
),
@@ -8,13 +8,14 @@ import 'motion.dart';
Color _tone(SourceState s, ColorScheme scheme) => switch (s) {
SourceState.ok => scheme.primary,
SourceState.slow || SourceState.waiting => Colors.amber,
SourceState.slow || SourceState.waiting || SourceState.gone => Colors.amber,
SourceState.left => Colors.redAccent.shade200,
};
IconData _icon(Delivery d) => switch (d.state) {
SourceState.left => Icons.link_off_rounded,
SourceState.waiting => Icons.hourglass_top_rounded,
SourceState.gone => Icons.wifi_off_rounded,
_ => switch (d.kind) {
DeliveryKind.memberRelay => Icons.cell_tower,
DeliveryKind.serverFetch => Icons.cloud_download_outlined,
@@ -28,8 +29,8 @@ IconData _icon(Delivery d) => switch (d.state) {
/// Where the current media comes from and how it reaches the others:
/// "Foxyz passes it on through the server", "3 through the server ·
/// 4.1 MB/s, needs 2.3 MB/s" and a bar of the source's speed against the
/// video's (past the mark: keeping up). Amber when slow or waiting, red
/// when the source left.
/// video's (past the mark: keeping up). Amber when slow, waiting or the
/// source's connection dropped, red when the source left.
class SourceLine extends StatelessWidget {
final Delivery delivery;
const SourceLine({super.key, required this.delivery});
@@ -40,7 +41,7 @@ class SourceLine extends StatelessWidget {
final scheme = Theme.of(context).colorScheme;
final tone = _tone(d.state, scheme);
final detail = d.detail;
final headroom = d.state == SourceState.left ? null : d.headroom;
final headroom = d.state == SourceState.left || d.state == SourceState.gone ? null : d.headroom;
return Tooltip(
message: d.explain,
waitDuration: const Duration(milliseconds: 400),
+5 -8
View File
@@ -13,6 +13,7 @@ import '../jellyfin/home_page.dart' show libraryIcon, isVideoLibrary;
import '../jellyfin/picker_source.dart';
import '../jellyfin/session_media.dart';
import '../screens/home/media_picker.dart';
import '../session/app_events.dart' show ActionFailed;
import '../state/session_state.dart';
import 'library_link.dart';
import 'motion.dart';
@@ -196,21 +197,17 @@ class _LibraryTabState extends State<LibraryTab> {
return pick is JellyfinMedia ? add(pick, next: next) : pick as String;
}
/// A card's menu: errors as a snack bar.
/// A card's menu: errors as a toast (the Notifier's).
Future<void> _cardQueue(JellyfinSelection sel, {bool next = false}) async {
final error = await _queueJellyfin(sel, next: next);
if (error != null && mounted) {
ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(error)));
}
if (error != null) SessionState.instance.notifier.notify(ActionFailed(error));
}
/// Double-click on a card: put it up, errors as a snack bar.
/// Double-click on a card: put it up, errors as a toast.
Future<void> _doublePutUp(JellyfinSelection sel) async {
_c.select(sel);
final error = await _putUpJellyfin(sel);
if (error != null && mounted) {
ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(error)));
}
if (error != null) SessionState.instance.notifier.notify(ActionFailed(error));
}
// -------------------------------------------------------------- build
+55 -46
View File
@@ -121,10 +121,9 @@ class PartyStrip extends StatelessWidget {
label: t.label,
selected: t == tab,
compact: compact,
// The version: always in sight under Settings,
// the full one a rest of the mouse away.
// The version a rest of the mouse away (also at
// the bottom of Settings and in About).
tooltip: t == ShellTab.settings ? '$appName ${displayVersion()}' : null,
caption: t == ShellTab.settings ? shortVersion() : null,
onTap: () => onTab(t),
),
],
@@ -281,13 +280,9 @@ class _ServerLabel extends StatelessWidget {
@override
Widget build(BuildContext context) => SignalBuilder(
builder: (context) {
final coord = SessionState.instance.coordinator;
final r = coord.serverState.value;
// The ready counter counts who the presence policy counts (not away,
// not gone, able to play it).
final counted = (r?.members ?? const <MemberView>[]).where(countsForReady).toList();
final ready = counted.where((m) => m.ready).length;
final stale = coord.stateStale.value;
// "connecting…", "reconnecting…", "ready 2/3" (the ready counter
// counts who the presence policy counts), from the one status.
final line = SessionState.instance.coordinator.status.value.party;
return Column(
key: const ValueKey('server-label'),
mainAxisSize: MainAxisSize.min,
@@ -298,11 +293,7 @@ class _ServerLabel extends StatelessWidget {
style: const TextStyle(fontWeight: FontWeight.w600, height: 1.1),
),
Text(
r == null
? 'connecting…'
: stale
? 'reconnecting…'
: 'ready $ready/${counted.length}',
line,
style: TextStyle(
color: scheme.onSurfaceVariant,
fontSize: 12,
@@ -373,8 +364,6 @@ class StripTab extends StatelessWidget {
final VoidCallback onTap;
/// Shown after resting on the tab.
final String? tooltip;
/// Small text under the tab (the version under Settings).
final String? caption;
const StripTab({
super.key,
required this.label,
@@ -382,7 +371,6 @@ class StripTab extends StatelessWidget {
required this.onTap,
this.compact = false,
this.tooltip,
this.caption,
});
@override
@@ -399,7 +387,6 @@ class StripTab extends StatelessWidget {
child: SizedBox(
height: partyStripHeight,
child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
Column(
@@ -426,27 +413,6 @@ class StripTab extends StatelessWidget {
),
],
),
if (caption case final c?)
Positioned(
left: 0,
right: 0,
bottom: 2,
height: 13,
child: OverflowBox(
maxWidth: 160,
child: Text(
c,
key: const ValueKey('strip-version'),
maxLines: 1,
softWrap: false,
style: TextStyle(
fontSize: 10,
height: 1.2,
color: scheme.onSurfaceVariant.withValues(alpha: 0.7),
),
),
),
),
],
),
),
@@ -522,18 +488,31 @@ class MembersCard extends StatelessWidget {
final address = hosting
? null
: '${session.lastForm.host}:${session.lastForm.port}';
final delivery = r == null
? null
: deliveryOf(r, selfId: coord.connection.memberId, names: coord.memberNames);
final delivery = coord.status.value.delivery;
final server = session.serverVersion.value;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: Text(
['In the server', ?address].join(' · '),
style: const TextStyle(fontWeight: FontWeight.w600),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
['In the server', ?address].join(' · '),
style: const TextStyle(fontWeight: FontWeight.w600),
),
if (server.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 2),
child: VersionText(
key: const ValueKey('server-version'),
label: 'Server',
version: server,
),
),
],
),
),
if (delivery != null) SourceLine(delivery: delivery),
@@ -618,6 +597,7 @@ class _MemberTile extends StatelessWidget {
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(info.self ? '${m.name} (you)' : m.name),
if (m.app.isNotEmpty) VersionText(key: ValueKey('version-${m.id}'), version: m.app),
if (info.host) const Pill('host', Colors.amber),
if (info.relaying)
const Pill('passing the video on', Colors.lightBlueAccent),
@@ -651,6 +631,35 @@ class _MemberTile extends StatelessWidget {
}
}
/// A Starsync version, quiet ("1.3.0"), amber with a hint when it is not
/// the one this app runs; [label] before it ("Server 1.3.0").
class VersionText extends StatelessWidget {
final String version;
final String? label;
const VersionText({super.key, required this.version, this.label});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final differs = versionDiffers(version);
final text = Text(
[?label, version].join(' '),
style: TextStyle(
fontSize: 11,
color: differs ? Colors.amber.shade300 : scheme.onSurfaceVariant.withValues(alpha: 0.8),
fontWeight: differs ? FontWeight.w600 : FontWeight.w400,
),
);
if (!differs) return text;
return Tooltip(message: 'You run ${shortVersion()}', child: text);
}
}
/// [version] is not the one this app runs (a dev run matches anything:
/// it has no version of its own).
bool versionDiffers(String version, {String own = appVersion}) =>
version.isNotEmpty && own != '0.0.0-dev' && version != '0.0.0-dev' && version != own;
/// Hosting: the addresses friends on this network can join, the first
/// one behind "Copy invite address", and what friends elsewhere need.
class _HostInvite extends StatefulWidget {
+15 -146
View File
@@ -1,154 +1,22 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart';
import '../session/health.dart';
import '../session/party.dart';
import '../session/session_coordinator.dart';
import '../state/session_state.dart';
import '../widgets/app_menu.dart' show AppGlass;
import 'avatar.dart';
import 'motion.dart';
/// What the player's status pill says, most pressing first.
enum StatusKind { reconnecting, problem, connecting, finished, loading, you, cannotPlay, waiting }
export '../session/status.dart' show StatusAction, StatusKind, StatusLevel, StatusLine, nameList;
/// The pill's button: stop reconnecting, retry (reconnect), reload the
/// video (your player could not open it), or mark yourself ready (the
/// whole pill).
enum StatusAction { cancel, retry, reload, ready }
/// One status line: [text], a muted [detail], a coloured [tone] for
/// problems (warn, error), the members it is about ([who]; [self] for you)
/// and a loading [progress] (0..1).
class StatusView {
final StatusKind kind;
final String text;
final String? detail;
final HealthSeverity? tone;
final List<MemberView> who;
final bool self;
final double? progress;
final StatusAction? action;
/// The mini player's shorter line.
final String short;
const StatusView(
this.kind,
this.text, {
this.detail,
this.tone,
this.who = const [],
this.self = false,
this.progress,
this.action,
String? short,
}) : short = short ?? text;
}
/// "Bob", "Bob and Mika", "Bob, Mika and Cleo", "Bob, Mika and 3 others".
String nameList(List<String> names) {
if (names.length <= 1) return names.join();
if (names.length <= 3) return '${names.sublist(0, names.length - 1).join(', ')} and ${names.last}';
return '${names[0]}, ${names[1]} and ${names.length - 2} others';
}
String _sentence(String s) => s.isEmpty ? s : s[0].toUpperCase() + s.substring(1);
/// The player's status from the session's signals; null when there is
/// nothing to say (playing, connected, everyone ready). [slowConnect]:
/// connecting has taken over 2 s. [playerError]: why your player cannot
/// open the media (the coordinator's `playerError`).
StatusView? playerStatusFor({
required HealthStatus health,
bool reconnecting = false,
bool unstable = false,
StateBroadcast? state,
MemberView? self,
bool slowConnect = false,
String playerError = '',
}) {
if (reconnecting) {
return const StatusView(StatusKind.reconnecting, 'Reconnecting…',
tone: HealthSeverity.warn, action: StatusAction.cancel);
}
if (unstable) {
return const StatusView(StatusKind.reconnecting, 'Connection unstable…', tone: HealthSeverity.warn);
}
final away = self?.presence == Presence.away;
if (playerError.isNotEmpty && state?.media != null && !away) {
return StatusView(StatusKind.problem, "Can't play this: $playerError",
tone: HealthSeverity.error, action: StatusAction.reload, short: "Can't play this");
}
if (health.severity == HealthSeverity.warn || health.severity == HealthSeverity.error) {
return StatusView(StatusKind.problem, _sentence(health.message),
tone: health.severity, action: StatusAction.retry);
}
if (slowConnect && health.severity == HealthSeverity.info && health.message.startsWith('connecting')) {
return const StatusView(StatusKind.connecting, 'Connecting…');
}
final r = state;
if (r == null || r.media == null || r.transport == 'playing') return null;
// Sitting out: the join-back card says it; nobody waits for you.
if (away) return null;
if (r.transport == 'finished') {
return const StatusView(StatusKind.finished, 'Finished · seek back or pick the next video', short: 'Finished');
}
// Members whose player cannot open it: the server does not wait for
// them; say why instead of "waiting for".
final failed = [
for (final m in r.members)
if (m.error.isNotEmpty && isPresent(m) && (self == null || m.id != self.id)) m,
];
// As the server holds playback: someone in the ready set short of the
// minimum buffer.
final need = r.bufferNeed;
for (final m in r.members) {
if (m.inSet && m.buffered < need) {
final you = self != null && m.id == self.id;
final p = (m.buffered / need).clamp(0.0, 1.0);
return StatusView(StatusKind.loading, you ? 'Loading the video…' : '${m.name} is loading…',
who: [m], self: you, progress: p, detail: '${(p * 100).round()}%');
}
}
if (failed.isNotEmpty) {
final f = failed.first;
final who = nameList([for (final m in failed) m.name]);
final unready = self != null && !self.ready;
return StatusView(StatusKind.cannotPlay, "$who can't play this: ${f.error}",
who: failed,
short: "$who can't play this",
detail: unready ? "Press Space when you're ready" : null,
action: unready ? StatusAction.ready : null);
}
if (self != null && !self.ready) {
return StatusView(StatusKind.you, "Press Space when you're ready",
who: [self], self: true, action: StatusAction.ready, detail: 'or click here', short: 'Press Space when ready');
}
// Members sitting out or gone are never waited for nor counted.
final active = [for (final m in r.members) if (countsForReady(m)) m];
final others = [
for (final m in active)
if ((self == null || m.id != self.id) && !(m.inSet && m.ready)) m,
];
if (others.isEmpty) return null;
final ready = active.where((m) => m.ready).length;
return StatusView(StatusKind.waiting, 'Waiting for ${nameList([for (final m in others) m.name])}',
who: others, detail: '$ready/${active.length} ready');
}
/// [playerStatusFor] from the session (tracked: call inside a builder).
StatusView? playerStatusNow({bool slowConnect = false}) {
final c = SessionState.instance.coordinator;
return playerStatusFor(
health: c.health.value,
reconnecting: c.reconnecting.value,
unstable: c.unstable.value,
state: c.serverState.value,
self: c.selfMember.value,
slowConnect: slowConnect,
playerError: c.playerError.value,
);
/// The pill's line from the session's [SessionCoordinator.status]
/// (tracked: call inside a builder); "Connecting…" only once it has taken
/// [PlayerStatus.connectGrace] ([slowConnect]).
StatusLine? playerStatusNow({bool slowConnect = false}) {
final line = SessionState.instance.coordinator.status.value.line;
if (line?.kind == StatusKind.connecting && !slowConnect) return null;
return line;
}
/// The player's one status pill (bottom centre): frosted dark glass with
@@ -167,7 +35,7 @@ class PlayerStatus extends StatefulWidget {
class _PlayerStatusState extends State<PlayerStatus> {
Timer? _connectTimer;
bool _slow = false;
StatusView? _last;
StatusLine? _last;
@override
void dispose() {
@@ -176,8 +44,8 @@ class _PlayerStatusState extends State<PlayerStatus> {
}
// "Connecting…" only once it takes a while.
void _trackConnecting(HealthStatus h) {
final connecting = h.severity == HealthSeverity.info && h.message.startsWith('connecting');
void _trackConnecting(StatusLine? line) {
final connecting = line?.kind == StatusKind.connecting;
if (!connecting) {
_connectTimer?.cancel();
_connectTimer = null;
@@ -206,7 +74,7 @@ class _PlayerStatusState extends State<PlayerStatus> {
Widget build(BuildContext context) {
return PartyWatch(builder: (context) {
final coord = SessionState.instance.coordinator;
_trackConnecting(coord.health.value);
_trackConnecting(coord.status.value.line);
final view = playerStatusNow(slowConnect: _slow);
if (view != null) _last = view;
final shown = _last;
@@ -262,7 +130,7 @@ class PlayerGlass extends StatelessWidget {
}
class _StatusLine extends StatelessWidget {
final StatusView view;
final StatusLine view;
final SessionCoordinator coord;
final ValueChanged<StatusAction> onAction;
const _StatusLine({super.key, required this.view, required this.coord, required this.onAction});
@@ -273,12 +141,13 @@ class _StatusLine extends StatelessWidget {
final v = view;
switch (v.kind) {
case StatusKind.problem:
final error = v.tone == HealthSeverity.error;
final error = v.level == StatusLevel.error;
return Icon(error ? Icons.error_outline_rounded : Icons.warning_amber_rounded,
size: 20, color: error ? Colors.redAccent.shade100 : Colors.amber);
case StatusKind.reconnecting:
return const _Spinner(color: Colors.amber);
case StatusKind.connecting:
case StatusKind.fetching:
return const _Spinner(color: Colors.white70);
case StatusKind.finished:
return null;
@@ -63,6 +63,9 @@ class SessionPlayer extends StatefulWidget {
/// Window buttons in the fullscreen top bar (off in tests: they need
/// window_manager).
final bool windowControls;
/// Follows whether the full player's controls are up (the shell lifts
/// its toasts above them); the player keeps its own when null.
final ValueNotifier<bool>? controlsShown;
const SessionPlayer({
super.key,
required this.mode,
@@ -79,6 +82,7 @@ class SessionPlayer extends StatefulWidget {
this.unread = 0,
this.videoBuilder,
this.windowControls = true,
this.controlsShown,
});
@override
@@ -96,7 +100,8 @@ class _SessionPlayerState extends State<SessionPlayer> {
int _shaderNoticeSeen = 0;
bool _miniHover = false;
// Whether the controls are up (their frame reports it).
final _controlsShown = ValueNotifier(true);
final _ownShown = ValueNotifier(true);
ValueNotifier<bool> get _controlsShown => widget.controlsShown ?? _ownShown;
// The mouse on a floating element, or the Up next popover open, keeps
// the controls up.
final _hold = ControlsHold();
@@ -155,7 +160,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
_stopShaderWatch();
_stopPopoverWatch();
_noFocus.dispose();
_controlsShown.dispose();
_ownShown.dispose();
_hold.dispose();
_model.dispose();
super.dispose();
+44 -5
View File
@@ -13,6 +13,7 @@ import '../jellyfin/accounts_panel.dart';
import '../schedule/animeflow.dart';
import '../screens/home/about_page.dart';
import '../screens/home/settings_page.dart';
import '../screens/home/ui.dart' show appName, displayVersion;
import '../session/auto_sit_out.dart' show AutoSitOutTimes;
import '../sfx/sfx.dart';
import '../sfx/sound_bank.dart';
@@ -228,6 +229,19 @@ class SettingsTab extends StatelessWidget {
Text(section.label, style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 14),
..._page(context),
// The version, quietly (also the Settings tab's
// tooltip and About).
Padding(
padding: const EdgeInsets.only(top: 8),
child: SelectableText(
'$appName ${displayVersion()}',
key: const ValueKey('settings-version'),
style: TextStyle(
fontSize: 11,
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
),
),
),
],
),
),
@@ -730,8 +744,9 @@ class _AnimeFlowCardState extends State<AnimeFlowCard> {
}
}
/// What pops up (chat pop-ups: [AppSettings.chatPopups]) and what makes
/// a sound.
/// What pops up (chat pop-ups: [AppSettings.chatPopups]), what reaches
/// you while you sit out or the window is in the background
/// ([AppSettings.notifyPeople]), and what makes a sound.
class NotificationsInfo extends StatelessWidget {
const NotificationsInfo({super.key});
@@ -753,8 +768,9 @@ class NotificationsInfo extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
line('Joins, leaves and video changes',
'Pop up bottom left for 5 s, with the person\'s picture'),
line('Joins, leaves, lost connections and video changes',
"Pop up bottom left for 5 s with the person's picture, above the player's controls"),
line('Added to Up next', 'With the title, its picture and who added it'),
Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
@@ -781,7 +797,30 @@ class NotificationsInfo extends StatelessWidget {
),
line('Sat out or nudged by someone', 'Only on your end, with their picture'),
line('Ready, seeks and pauses', 'Only in Chat & activity (Tab), never as a pop-up'),
line('App notices', 'Shader changes, auto sit-out setting you away'),
line('Watching alone', 'No pop-ups or sounds about the party: there is nobody else'),
line('App notices', 'Shader changes, copies, errors, auto sit-out setting you away'),
],
),
),
SettingsPanel(
title: 'Sitting out and in the background',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
line('What still reaches you',
'While you sit out, only chat, nudges, "starting now" and Up next pop up. With the window '
'in the background or minimised they also go out as system notifications; a click brings '
'the window up (a chat one opens the chat)'),
SignalBuilder(
builder: (context) => SwitchListTile(
key: const ValueKey('notify-people'),
contentPadding: EdgeInsets.zero,
title: const Text('Joins and leaves too'),
subtitle: Text('Who joins, leaves, loses connection or comes back', style: muted),
value: AppSettings.instance.notifyPeople.value,
onChanged: AppSettings.instance.setNotifyPeople,
),
),
],
),
),
+87 -38
View File
@@ -5,29 +5,29 @@ import 'package:signals/signals_flutter.dart';
import '../session/session_coordinator.dart' show ToastItem;
import '../state/session_state.dart';
import '../state/settings.dart';
import 'avatar.dart';
import 'motion.dart';
/// One toast on screen: who (null = the app itself), what, and whether it
/// is on its way out.
/// One toast on screen: who (null = the app itself), what, a picture
/// (an Up next item's), and whether it is on its way out.
class ShellToast {
final int id;
final AvatarInfo? who;
final IconData? icon;
final String text;
final bool chat;
final String thumb;
bool leaving = false;
ShellToast({required this.id, required this.text, this.who, this.icon, this.chat = false});
ShellToast({required this.id, required this.text, this.who, this.icon, this.chat = false, this.thumb = ''});
}
/// Splits a coordinator toast into who and what: feed and chat toasts
/// name their [ToastItem.actor] and start with it ("Bob joined", "Bob:
/// hi"); the rest are the app's.
/// Splits a Notifier toast into who and what: feed and chat toasts name
/// their [ToastItem.actor] and start with it ("Bob joined", "Bob: hi");
/// the rest are the app's.
ShellToast shellToastFor(ToastItem t, AvatarInfo Function(String name) avatar) {
final n = t.actor;
if (n == null || n.isEmpty) return ShellToast(id: t.id, text: t.text, icon: Icons.info_outline);
if (t.text.startsWith('$n: ')) {
if (n == null || n.isEmpty) return ShellToast(id: t.id, text: t.text, icon: Icons.info_outline, thumb: t.thumb);
if (t.chat && t.text.startsWith('$n: ')) {
return ShellToast(id: t.id, who: avatar(n), text: t.text.substring(n.length + 2), chat: true);
}
final rest = t.text.startsWith('$n ') ? t.text.substring(n.length + 1) : t.text;
@@ -35,24 +35,30 @@ ShellToast shellToastFor(ToastItem t, AvatarInfo Function(String name) avatar) {
? Icons.login
: rest.startsWith('left')
? Icons.logout
: rest.startsWith('sat you out')
? Icons.bedtime
: rest.startsWith('nudged')
? Icons.notifications_active_outlined
: rest.startsWith('changed')
? Icons.swap_horiz
: Icons.info_outline;
return ShellToast(id: t.id, who: avatar(n), text: rest, icon: icon);
: rest.startsWith('lost connection')
? Icons.wifi_off_rounded
: rest.startsWith('is back')
? Icons.wifi_rounded
: rest.startsWith('sat you out')
? Icons.bedtime
: rest.startsWith('nudged')
? Icons.notifications_active_outlined
: rest.startsWith('changed')
? Icons.swap_horiz
: rest.startsWith('added')
? Icons.playlist_add_rounded
: Icons.info_outline;
return ShellToast(id: t.id, who: avatar(n), text: rest, icon: icon, thumb: t.thumb);
}
/// The coordinator's toasts as cards with avatars, bottom-left: each
/// slides in, stays [life], then slides out and collapses its gap. A click
/// on a chat toast opens the chat ([onOpenChat]); the rest let clicks
/// through. Chat toasts follow [AppSettings.chatPopups].
/// The Notifier's toasts as cards with avatars, bottom-left: each slides
/// in, stays while the Notifier keeps it (it drops it after its time),
/// then slides out and collapses its gap. A click on a chat toast opens
/// the chat ([onOpenChat]); the rest let clicks through. What pops up at
/// all (chat pop-ups, sitting out, alone) is the Notifier's policy.
class PartyToasts extends StatefulWidget {
final Duration life;
final VoidCallback? onOpenChat;
const PartyToasts({super.key, this.life = const Duration(seconds: 5), this.onOpenChat});
const PartyToasts({super.key, this.onOpenChat});
@override
State<PartyToasts> createState() => _PartyToastsState();
@@ -72,33 +78,40 @@ class _PartyToastsState extends State<PartyToasts> {
_seen = {for (final t in coord.toasts.peek()) t.id};
_stop = effect(() {
final items = coord.toasts.value;
final ids = {for (final t in items) t.id};
final fresh = [for (final t in items) if (!_seen.contains(t.id)) t];
_seen = {for (final t in items) t.id};
if (fresh.isEmpty) return;
final gone = _seen.difference(ids);
_seen = ids;
if (fresh.isEmpty && gone.isEmpty) return;
// Only new toasts re-run this, not the party they mention.
untracked(() {
final playing = coord.serverState.value?.transport == 'playing';
final popups = AppSettings.instance.chatPopups.value;
for (final t in fresh) {
final toast = shellToastFor(t, (n) => avatarForName(n, coord, t.actorId));
// Chat per Settings → Notifications (it is in the chat either way).
if (toast.chat && !popups.shows(playing: playing)) continue;
_add(toast);
_add(shellToastFor(t, (n) => avatarForName(n, coord, t.actorId)));
}
if (gone.isNotEmpty) _leave(gone);
});
});
}
void _add(ShellToast t) {
// Not from inside the effect's build-time notification.
scheduleMicrotask(() {
if (mounted) setState(() => _shown.add(t));
});
}
void _leave(Set<int> ids) {
scheduleMicrotask(() {
if (!mounted) return;
setState(() => _shown.add(t));
_timers.add(Timer(widget.life, () {
if (mounted) setState(() => t.leaving = true);
}));
_timers.add(Timer(widget.life + easeTime, () {
if (mounted) setState(() => _shown.remove(t));
final leaving = [for (final t in _shown) if (ids.contains(t.id) && !t.leaving) t];
if (leaving.isEmpty) return;
setState(() {
for (final t in leaving) {
t.leaving = true;
}
});
_timers.add(Timer(easeTime, () {
if (mounted) setState(() => _shown.removeWhere(leaving.contains));
}));
});
}
@@ -114,7 +127,7 @@ class _PartyToastsState extends State<PartyToasts> {
void _open(ShellToast t) {
widget.onOpenChat?.call();
setState(() => t.leaving = true);
SessionState.instance.notifier.dismiss(t.id);
}
@override
@@ -206,6 +219,8 @@ class ToastCard extends StatelessWidget {
children: [
if (who != null)
StatusAvatar(info: who, size: 38, badges: false)
else if (toast.thumb.isNotEmpty)
_Thumb(hash: toast.thumb)
else
CircleAvatar(
radius: 19,
@@ -237,6 +252,8 @@ class ToastCard extends StatelessWidget {
],
Text(
toast.text,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: toast.chat ? 15 : 14,
color: toast.chat || who == null ? scheme.onSurface : scheme.onSurfaceVariant,
@@ -245,6 +262,10 @@ class ToastCard extends StatelessWidget {
],
),
),
if (who != null && toast.thumb.isNotEmpty) ...[
const SizedBox(width: 12),
_Thumb(hash: toast.thumb),
],
],
),
),
@@ -252,3 +273,31 @@ class ToastCard extends StatelessWidget {
);
}
}
/// An Up next item's picture (16:9, from the queue's picture cache);
/// a blank until it is fetched.
class _Thumb extends StatelessWidget {
final String hash;
const _Thumb({required this.hash});
@override
Widget build(BuildContext context) {
final thumbs = SessionState.instance.coordinator.queue.thumbs;
return ClipRRect(
key: const ValueKey('toast-thumb'),
borderRadius: BorderRadius.circular(6),
child: SizedBox(
width: 68,
height: 38,
child: ListenableBuilder(
listenable: thumbs,
builder: (context, _) {
final bytes = thumbs.bytesFor(hash);
if (bytes == null) return ColoredBox(color: Theme.of(context).colorScheme.surfaceContainerHighest);
return Image.memory(bytes, fit: BoxFit.cover, gaplessPlayback: true);
},
),
),
);
}
}
+18 -13
View File
@@ -16,7 +16,6 @@ import '../platform/ports/ports.dart';
import '../sfx/sfx.dart';
import '../sfx/sound_bank.dart';
import '../sfx/synth_sfx.dart';
import '../session/health.dart';
import '../session/notifier.dart';
import '../session/connection.dart' show Connection;
import '../session/identity.dart' show ClientIdentity;
@@ -163,7 +162,6 @@ class SessionState {
final route = signal(AppRoute.home);
final recents = signal<List<RecentServer>>(const []);
final status = signal(const HealthStatus(HealthSeverity.info, 'idle'));
/// Owns the app's audio device ([SynthSfx.dispose] on close).
final SynthSfx sfx = SynthSfx(
volume: () => AppSettings.instance.soundVolume.peek(),
@@ -173,8 +171,15 @@ class SessionState {
);
/// The platform (window, links, folders, yt-dlp, notifications).
final Ports ports = desktopPorts();
/// Every toast, sound and activity-log line goes through it.
late final Notifier notifier = Notifier(sfx: sfx, attention: ports.shell.requestAttention);
/// Every toast, sound, system notification and activity-log line goes
/// through it.
late final Notifier notifier = Notifier(
sfx: sfx,
attention: ports.shell.requestAttention,
system: ports.notifications,
focusWindow: ports.shell.focus,
prefs: () => AppSettings.instance.notifyPrefs,
);
/// The link to the server; lost/back go to the notifier.
/// The link to the server, with this install's stored identity
/// (`hello.client` / `seq`); lost/back go to the notifier.
@@ -192,6 +197,9 @@ class SessionState {
/// The joined server's own name (`/health`), '' until known or when it
/// only has the default name.
final serverLabel = signal('');
/// The server's Starsync version: a hosted (or solo) server is this
/// app's own; a joined one's comes from its `/health`; '' until known.
final serverVersion = signal('');
/// A solo session is becoming a hosted one: the player has nothing open
/// until the media is up again on the new server.
final switching = signal(false);
@@ -367,8 +375,6 @@ class SessionState {
final op = ++_op;
if (form != null) _rememberForm(form);
try {
status.value =
const HealthStatus(HealthSeverity.info, 'starting server…');
await stopServer();
if (op != _op) throw const ConnectCancelled();
final hostKey = newHostKey();
@@ -403,8 +409,6 @@ class SessionState {
);
} catch (e, st) {
debugPrint('server start failed: $e\n$st');
status.value = HealthStatus(
HealthSeverity.error, 'cannot start server on port ${params.port}: $e');
rethrow;
}
if (op != _op) {
@@ -414,8 +418,7 @@ class SessionState {
_server = server;
coordinator.hostFile = (path) async =>
identical(_server, server) ? server.setFile(path) : null;
status.value = HealthStatus(
HealthSeverity.ok, soloSession ? 'watching solo' : 'hosting on port ${server.port}');
serverVersion.value = appVersion;
try {
await coordinator.connect(ConnectParams(
host: '127.0.0.1',
@@ -452,7 +455,6 @@ class SessionState {
_setupCrash = cause;
return;
}
coordinator.health.value = HealthStatus(HealthSeverity.error, message);
serverCrash.value = message;
unawaited(leave().catchError((Object e) => debugPrint('leave after crash failed: $e')));
}
@@ -465,9 +467,12 @@ class SessionState {
if (op != _op) throw const ConnectCancelled();
route.value = AppRoute.session;
serverLabel.value = '';
serverVersion.value = '';
unawaited(probeHealth(params.host, '${params.port}', defaultHealthGet).then((h) {
if (op != _op || !h.online) return;
// 'server' is the dedicated server's default --name: not a name.
if (op == _op && h.online && h.name.isNotEmpty && h.name != 'server') serverLabel.value = h.name;
if (h.name.isNotEmpty && h.name != 'server') serverLabel.value = h.name;
serverVersion.value = h.version;
}).catchError((Object _) {}));
} finally {
await _saveFormQuietly();
@@ -524,7 +529,7 @@ class SessionState {
solo.value = false;
coordinator.solo = false;
serverLabel.value = '';
status.value = const HealthStatus(HealthSeverity.info, 'idle');
serverVersion.value = '';
route.value = AppRoute.home;
}
}
+17 -17
View File
@@ -6,8 +6,11 @@ import 'package:signals/signals_flutter.dart';
import '../controls/model/controls_prefs.dart';
import '../shaders/shader_presets.dart';
import '../session/notice_prefs.dart';
import 'settings_schema.dart';
export '../session/notice_prefs.dart' show ChatPopups, NotifyPrefs;
// App preferences (Settings page), one Hive box. Signals start at their
// defaults and take the stored values once [AppSettings.ensureLoaded]
// finishes; setters write through.
@@ -39,6 +42,7 @@ const _keySoundPresetFor = 'soundPresetFor';
const _keyUnreadyOnSwap = 'unreadyOnSwap';
const keyAutoSitOut = 'autoSitOut';
const _keyChatPopups = 'chatPopups';
const _keyNotifyPeople = 'notifyPeople';
const _keyShareDiagnostics = 'shareDiagnostics';
enum HwdecMode { auto, off, custom }
@@ -49,23 +53,6 @@ enum HwdecMode { auto, off, custom }
/// surface there ("Failed to create EGL surface"). Elsewhere 'auto'.
String get autoHwdec => Platform.isWindows ? 'auto-copy' : 'auto';
/// When chat messages pop up as toasts (they always reach the chat).
enum ChatPopups {
all('All'),
paused('Only when paused'),
off('Off');
final String label;
const ChatPopups(this.label);
/// Whether a chat message pops up now ([playing]: the video plays).
bool shows({required bool playing}) => switch (this) {
ChatPopups.all => true,
ChatPopups.paused => !playing,
ChatPopups.off => false,
};
}
class AppSettings {
static final AppSettings instance = AppSettings();
@@ -100,6 +87,12 @@ class AppSettings {
final shareDiagnostics = signal(true);
/// Chat message toasts (default: all). Applies live.
final chatPopups = signal(ChatPopups.all);
/// Joins and leaves also notify while sitting out or with the window in
/// the background (default off). Applies live.
final notifyPeople = signal(false);
/// The notification choices as the Notifier reads them.
NotifyPrefs get notifyPrefs => NotifyPrefs(chat: chatPopups.peek(), people: notifyPeople.peek());
/// App sounds (`sfx/`): 0..1, 0 = silent.
final soundVolume = signal(1.0);
/// `SfxKind` names switched off one by one.
@@ -174,6 +167,8 @@ class AppSettings {
if (share is bool) shareDiagnostics.value = share;
final popups = box.get(_keyChatPopups);
chatPopups.value = ChatPopups.values.where((m) => m.name == popups).firstOrNull ?? ChatPopups.all;
final people = box.get(_keyNotifyPeople);
if (people is bool) notifyPeople.value = people;
final vol = box.get(_keySoundVolume);
if (vol is num) soundVolume.value = vol.toDouble().clamp(0, 1);
final off = box.get(_keySoundsOff);
@@ -278,6 +273,11 @@ class AppSettings {
_put(_keyChatPopups, mode.name);
}
void setNotifyPeople(bool on) {
notifyPeople.value = on;
_put(_keyNotifyPeople, on);
}
void setSoundVolume(double v) {
soundVolume.value = v.clamp(0, 1);
_put(_keySoundVolume, soundVolume.value);