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

+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;
}
}