Restructure step 2: Status as one computed level, the Notifier's real policy, system notifications
- Status: computed from connection, presence, hold, relay, player facts; no setters (health writes and HealthStatus gone); pill, strip and source line read it; transitions go to the Notifier. - Notifier: noticeFor(event, context) with self presence, alone, window activity, playing and prefs; toasts for joins/leaves/returns/swaps/ Up next adds (with picture)/problems/chat, the rest logged; while sitting out only chat, nudges, starting, Up next (+ joins if chosen); alone no social notices; replayed lines logged; owns its toast timer. - System notifications (flutter_local_notifications: D-Bus on Linux, WinRT on Windows) when the window isn't focused; a click focuses it. - Toasts sit above the control bar and move with it. - Versions: each member's in the members card and avatar tooltip, the server's in the card header; the strip no longer shows it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
130c934b9b
commit
629be0d9ed
41 files changed
+2114
-741
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
enum HealthSeverity { ok, info, warn, error }
|
||||
|
||||
class HealthStatus {
|
||||
final HealthSeverity severity;
|
||||
final String message;
|
||||
const HealthStatus(this.severity, this.message);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// The user's notification choices (Settings → Notifications & sounds), as
|
||||
// the Notifier's policy reads them. Pure Dart; [AppSettings] stores them.
|
||||
|
||||
/// When chat messages pop up as toasts (they always reach the chat).
|
||||
enum ChatPopups {
|
||||
all('All'),
|
||||
paused('Only when paused'),
|
||||
off('Off');
|
||||
|
||||
final String label;
|
||||
const ChatPopups(this.label);
|
||||
|
||||
/// Whether a chat message pops up now ([playing]: the video plays).
|
||||
bool shows({required bool playing}) => switch (this) {
|
||||
ChatPopups.all => true,
|
||||
ChatPopups.paused => !playing,
|
||||
ChatPopups.off => false,
|
||||
};
|
||||
}
|
||||
|
||||
/// What the user picked: chat pop-ups, and whether joins and leaves also
|
||||
/// reach them while they sit out or the window is in the background
|
||||
/// ([people]; off by default: then only chat, nudges, "starting now" and
|
||||
/// Up next do).
|
||||
class NotifyPrefs {
|
||||
final ChatPopups chat;
|
||||
final bool people;
|
||||
const NotifyPrefs({this.chat = ChatPopups.all, this.people = false});
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
import 'dart:async';
|
||||
|
||||
import '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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user