Restructure step 0: test safety net, AppEvent/Notifier seam, platform ports, protocol 16 planned
- Tests: the four known failures fixed (test-side causes), one duplicate file and four detail-pinning cases removed, every test that pins behaviour the restructure changes tagged with its step. - Every toast, sound and activity-log line goes through Notifier.notify (AppEvent), mapped one-to-one to today's behaviour. - Platform ports (shell, URLs, folders, link resolver, system notifications) with desktop implementations; window_manager, Process and file_picker now live only in platform/desktop/. - ROADMAP: protocol 16 planned; app id dev.foxyz.starsync. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7e98ebd1db
commit
a1b270fd4e
63 files changed
+1231
-434
No files matched your search
@@ -0,0 +1,252 @@
|
||||
import 'package:sync_relay_core/core.dart' show FeedMessage;
|
||||
|
||||
// Everything that may become a toast, a sound, an attention request or an
|
||||
// activity-log line, as one sealed type. Code that has news emits an
|
||||
// event ([Notifier.notify]); only the notifier decides what the user sees
|
||||
// and hears. Pure Dart (no Flutter): it can move to core with the policy.
|
||||
|
||||
sealed class AppEvent {
|
||||
const AppEvent();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- feed
|
||||
|
||||
/// A server `feed` line (see PROTOCOL.md, `feed.action`); every one lands
|
||||
/// in the activity log.
|
||||
sealed class FeedEvent extends AppEvent {
|
||||
final FeedMessage feed;
|
||||
const FeedEvent(this.feed);
|
||||
|
||||
String get actor => feed.actor;
|
||||
|
||||
/// [feed] as its typed event. [selfName]: this member's name (the feed
|
||||
/// names members by name); [readyCount]: members ready in the latest
|
||||
/// state (the pitch of a `ready` sound).
|
||||
factory FeedEvent.of(FeedMessage f, {String? selfName, int readyCount = 0}) => switch (f.action) {
|
||||
'join' => MemberJoined(f),
|
||||
'leave' => MemberLeft(f),
|
||||
'stale' => MemberStale(f),
|
||||
'out' => MemberSatOut(f, atYou: f.target != null && f.target == selfName && f.actor != selfName),
|
||||
'back' => 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: f.actor == selfName),
|
||||
'rename' => MemberRenamed(f),
|
||||
'avatar' => AvatarChanged(f),
|
||||
'queue-add' => QueueAdded(f),
|
||||
'queue-remove' => QueueRemoved(f),
|
||||
'queue-skip' => QueueSkipped(f),
|
||||
'fetch-fail' => FetchFailed(f),
|
||||
_ => OtherFeed(f),
|
||||
};
|
||||
}
|
||||
|
||||
class MemberJoined extends FeedEvent {
|
||||
const MemberJoined(super.feed);
|
||||
}
|
||||
|
||||
class MemberLeft extends FeedEvent {
|
||||
const MemberLeft(super.feed);
|
||||
}
|
||||
|
||||
class MemberStale extends FeedEvent {
|
||||
const MemberStale(super.feed);
|
||||
}
|
||||
|
||||
/// Someone sat out (`target` sat out by `actor`); [atYou]: someone else
|
||||
/// sat this member out.
|
||||
class MemberSatOut extends FeedEvent {
|
||||
final bool atYou;
|
||||
const MemberSatOut(super.feed, {this.atYou = false});
|
||||
}
|
||||
|
||||
class MemberBack extends FeedEvent {
|
||||
const MemberBack(super.feed);
|
||||
}
|
||||
|
||||
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});
|
||||
}
|
||||
|
||||
class ForcedReady extends FeedEvent {
|
||||
final bool ready;
|
||||
const ForcedReady(super.feed, {required this.ready});
|
||||
}
|
||||
|
||||
/// Force play / hold.
|
||||
class ForcedTransport extends FeedEvent {
|
||||
final bool play;
|
||||
const ForcedTransport(super.feed, {required this.play});
|
||||
}
|
||||
|
||||
class MediaSwapped extends FeedEvent {
|
||||
const MediaSwapped(super.feed);
|
||||
}
|
||||
|
||||
class Seeked extends FeedEvent {
|
||||
const Seeked(super.feed);
|
||||
}
|
||||
|
||||
class Paused extends FeedEvent {
|
||||
const Paused(super.feed);
|
||||
}
|
||||
|
||||
class Resumed extends FeedEvent {
|
||||
const Resumed(super.feed);
|
||||
}
|
||||
|
||||
class Finished extends FeedEvent {
|
||||
const Finished(super.feed);
|
||||
}
|
||||
|
||||
/// 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});
|
||||
}
|
||||
|
||||
class MemberRenamed extends FeedEvent {
|
||||
const MemberRenamed(super.feed);
|
||||
}
|
||||
|
||||
class AvatarChanged extends FeedEvent {
|
||||
const AvatarChanged(super.feed);
|
||||
}
|
||||
|
||||
class QueueAdded extends FeedEvent {
|
||||
const QueueAdded(super.feed);
|
||||
}
|
||||
|
||||
class QueueRemoved extends FeedEvent {
|
||||
const QueueRemoved(super.feed);
|
||||
}
|
||||
|
||||
/// Up next skipped an item (`target` its title, `reason` why).
|
||||
class QueueSkipped extends FeedEvent {
|
||||
const QueueSkipped(super.feed);
|
||||
}
|
||||
|
||||
/// The server could not fetch the current media.
|
||||
class FetchFailed extends FeedEvent {
|
||||
const FetchFailed(super.feed);
|
||||
}
|
||||
|
||||
/// An action this app does not know (a newer server).
|
||||
class OtherFeed extends FeedEvent {
|
||||
const OtherFeed(super.feed);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- session
|
||||
|
||||
/// Someone poked this member.
|
||||
class Nudged extends AppEvent {
|
||||
/// Their name; '' when unknown.
|
||||
final String name;
|
||||
const Nudged(this.name);
|
||||
}
|
||||
|
||||
/// Another member's player cannot open the current media.
|
||||
class MemberCantPlay extends AppEvent {
|
||||
final String name;
|
||||
final String error;
|
||||
const MemberCantPlay(this.name, this.error);
|
||||
}
|
||||
|
||||
/// This member passes the video on slower than it plays.
|
||||
class SlowSource extends AppEvent {
|
||||
final String text;
|
||||
const SlowSource(this.text);
|
||||
}
|
||||
|
||||
/// This member started passing the video on through the server.
|
||||
class RelayStarted extends AppEvent {
|
||||
const RelayStarted();
|
||||
}
|
||||
|
||||
class RelayFailed extends AppEvent {
|
||||
final String error;
|
||||
const RelayFailed(this.error);
|
||||
}
|
||||
|
||||
/// Idle: auto-unready (or sit-out, [sitsOut]) in 2 minutes.
|
||||
class IdleWarning extends AppEvent {
|
||||
final bool sitsOut;
|
||||
const IdleWarning({required this.sitsOut});
|
||||
}
|
||||
|
||||
/// The connection to the server dropped (once per drop).
|
||||
class ConnectionLost extends AppEvent {
|
||||
const ConnectionLost();
|
||||
}
|
||||
|
||||
/// It came back after [ConnectionLost].
|
||||
class ConnectionBack extends AppEvent {
|
||||
const ConnectionBack();
|
||||
}
|
||||
|
||||
/// Jellyfin progress reports fail (once per media).
|
||||
class JellyfinProgressFailed extends AppEvent {
|
||||
final bool signInExpired;
|
||||
const JellyfinProgressFailed({required this.signInExpired});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ app
|
||||
|
||||
/// The player's shader message (a preset change, shaders turned off).
|
||||
class ShaderMessage extends AppEvent {
|
||||
final String text;
|
||||
const ShaderMessage(this.text);
|
||||
}
|
||||
|
||||
/// This app added an item to Up next.
|
||||
class AddedToUpNext extends AppEvent {
|
||||
final String title;
|
||||
const AddedToUpNext(this.title);
|
||||
}
|
||||
|
||||
/// Putting something up or adding it to Up next failed ([text]: why).
|
||||
class ActionFailed extends AppEvent {
|
||||
final String text;
|
||||
const ActionFailed(this.text);
|
||||
}
|
||||
|
||||
/// The Schedule tab's request results and errors.
|
||||
class ScheduleResult extends AppEvent {
|
||||
final String text;
|
||||
const ScheduleResult(this.text);
|
||||
}
|
||||
|
||||
/// "Mark a problem" done; [inSession]: in the server's session log.
|
||||
class ProblemMarked extends AppEvent {
|
||||
final bool inSession;
|
||||
const ProblemMarked({required this.inSession});
|
||||
}
|
||||
|
||||
/// Something went on the clipboard ([text]: the confirmation, e.g.
|
||||
/// "Link copied").
|
||||
class Copied extends AppEvent {
|
||||
final String text;
|
||||
const Copied(this.text);
|
||||
}
|
||||
|
||||
class CopyFailed extends AppEvent {
|
||||
const CopyFailed();
|
||||
}
|
||||
|
||||
/// A newer release is out (the update band shows it).
|
||||
class UpdateAvailable extends AppEvent {
|
||||
final String version;
|
||||
const UpdateAvailable(this.version);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:signals/signals_core.dart';
|
||||
import 'package:sync_relay_core/core.dart' show FeedMessage;
|
||||
|
||||
import '../sfx/sfx.dart';
|
||||
import 'app_events.dart';
|
||||
|
||||
export 'app_events.dart';
|
||||
|
||||
class ToastItem {
|
||||
final int id;
|
||||
final String text;
|
||||
final DateTime at;
|
||||
/// The member a feed or chat toast is about (their name, as the feed
|
||||
/// carries it; [text] starts with it); null for the app's own toasts.
|
||||
final String? actor;
|
||||
const ToastItem({required this.id, required this.text, required this.at, this.actor});
|
||||
}
|
||||
|
||||
/// What one [AppEvent] does: a toast ([toast], about [actor]), a sound,
|
||||
/// the window asking for attention, an activity-log line.
|
||||
class Notice {
|
||||
final String? toast;
|
||||
final String? actor;
|
||||
final SfxCue? sound;
|
||||
final bool attention;
|
||||
/// 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});
|
||||
|
||||
static const none = Notice();
|
||||
}
|
||||
|
||||
/// The policy: what each event does.
|
||||
Notice noticeFor(AppEvent e) => switch (e) {
|
||||
// The feed: everything is logged; joins, leaves, swaps and chat
|
||||
// pop up; the social actions have a sound.
|
||||
MemberJoined(:final feed) => Notice(
|
||||
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)),
|
||||
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(
|
||||
log: feed,
|
||||
toast: '${feed.actor}: ${feed.reason}',
|
||||
actor: feed.actor,
|
||||
// Your own message needs no sound.
|
||||
sound: own ? null : 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)),
|
||||
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),
|
||||
// The session.
|
||||
Nudged(:final name) => Notice(
|
||||
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'),
|
||||
IdleWarning(:final sitsOut) => Notice(
|
||||
toast: sitsOut
|
||||
? 'idle — sitting out in 2 min unless you press Space'
|
||||
: 'idle — auto-unready in 2 min unless you press Space',
|
||||
sound: const SfxCue(SfxKind.warn)),
|
||||
ConnectionLost() => const Notice(sound: SfxCue(SfxKind.lost)),
|
||||
ConnectionBack() => const Notice(sound: SfxCue(SfxKind.back)),
|
||||
JellyfinProgressFailed(:final signInExpired) =>
|
||||
Notice(toast: 'Jellyfin progress not saved: ${signInExpired ? 'sign-in expired' : 'server unreachable'}'),
|
||||
// The app.
|
||||
ShaderMessage(:final text) => Notice(toast: text),
|
||||
AddedToUpNext(:final title) => Notice(toast: 'Added $title to Up next'),
|
||||
ActionFailed(:final text) => Notice(toast: text),
|
||||
ScheduleResult(:final text) => Notice(toast: text),
|
||||
ProblemMarked(:final inSession) =>
|
||||
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.
|
||||
UpdateAvailable() => Notice.none,
|
||||
};
|
||||
|
||||
/// 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].
|
||||
class Notifier {
|
||||
final SfxService sfx;
|
||||
final Future<void> Function() attention;
|
||||
Notifier({this.sfx = const NoopSfxService(), Future<void> Function()? attention})
|
||||
: attention = attention ?? _noAttention;
|
||||
|
||||
static Future<void> _noAttention() async {}
|
||||
|
||||
/// Toasts on screen (the shell shows them; [expire] drops old ones).
|
||||
final toasts = signal<List<ToastItem>>([]);
|
||||
/// The activity log: the last 100 feed lines.
|
||||
final feedLog = signal<List<FeedMessage>>([]);
|
||||
int _toastId = 0;
|
||||
|
||||
void notify(AppEvent e) {
|
||||
final n = noticeFor(e);
|
||||
final log = n.log;
|
||||
if (log != null) {
|
||||
final all = [...feedLog.peek(), log];
|
||||
feedLog.value = all.length > 100 ? all.sublist(all.length - 100) : all;
|
||||
}
|
||||
final text = n.toast;
|
||||
if (text != null) {
|
||||
_toastId++;
|
||||
toasts.value = [
|
||||
...toasts.peek(),
|
||||
ToastItem(id: _toastId, text: text, at: DateTime.now().toUtc(), actor: n.actor),
|
||||
];
|
||||
}
|
||||
final sound = n.sound;
|
||||
if (sound != null) unawaited(sfx.play(sound));
|
||||
if (n.attention) unawaited(attention());
|
||||
}
|
||||
|
||||
/// Drops toasts 4 s old or older.
|
||||
void expire(DateTime now) {
|
||||
toasts.value = toasts.peek().where((t) => now.difference(t.at).inSeconds < 4).toList();
|
||||
}
|
||||
|
||||
/// A new session: no toasts, an empty log.
|
||||
void clear() {
|
||||
feedLog.value = [];
|
||||
toasts.value = [];
|
||||
}
|
||||
}
|
||||
@@ -14,12 +14,12 @@ import '../jellyfin/session_media.dart';
|
||||
import '../jellyfin/track_prefs.dart';
|
||||
import '../playback/media_marks.dart';
|
||||
import '../playback/playback_controller.dart';
|
||||
import '../platform/attention.dart';
|
||||
import '../playback/yt_dlp_app.dart';
|
||||
import '../platform/ports/link_resolver.dart';
|
||||
import '../playback/yt_dlp_app.dart' show AppYtDlp;
|
||||
import '../schedule/anilist.dart';
|
||||
import '../screens/home/ui.dart' show appName, displayVersion;
|
||||
import '../screens/home/picked_media.dart';
|
||||
import '../sfx/sfx.dart';
|
||||
import '../sfx/sfx.dart' show NoopSfxService, SfxService;
|
||||
import '../state/settings.dart';
|
||||
import '../sync/avatar_cache.dart';
|
||||
import '../sync/state_diff.dart';
|
||||
@@ -30,10 +30,13 @@ import 'delivery.dart';
|
||||
import 'health.dart';
|
||||
import 'idle.dart';
|
||||
import 'media_art.dart';
|
||||
import 'notifier.dart';
|
||||
import 'now_playing.dart';
|
||||
import 'now_playing_info.dart';
|
||||
import 'switch_trace.dart';
|
||||
|
||||
export 'notifier.dart' show ToastItem;
|
||||
|
||||
final _log = createLogger('session');
|
||||
|
||||
/// Thrown when a connect is superseded by cancel/leave before it finished.
|
||||
@@ -100,18 +103,6 @@ class AutoAdvance {
|
||||
/// Seconds of the end-of-episode countdown.
|
||||
const autoAdvanceSecs = 10;
|
||||
|
||||
class ToastItem {
|
||||
final int id;
|
||||
final String text;
|
||||
final DateTime at;
|
||||
/// The member a feed or chat toast is about (their name, as the feed
|
||||
/// carries it; [text] starts with it); null for the app's own toasts.
|
||||
final String? actor;
|
||||
const ToastItem({required this.id, required this.text, required this.at, this.actor});
|
||||
}
|
||||
|
||||
const _toastActions = {'join', 'leave', 'swap', 'chat'};
|
||||
|
||||
/// "Unready when the video changes": a state with a newer [mediaId] than
|
||||
/// the last one this session saw ([lastMediaId], -1 before the first) while
|
||||
/// self is ready (or a reconnect is restoring it). A swap keeps the
|
||||
@@ -164,7 +155,9 @@ String? stateMediaUrl(StateBroadcast state) {
|
||||
}
|
||||
|
||||
class SessionCoordinator {
|
||||
final SfxService sfx;
|
||||
/// Toasts, sounds and the activity log: every event goes through
|
||||
/// [notify].
|
||||
final Notifier notifier;
|
||||
/// Linked Jellyfin servers: how this viewer plays Jellyfin refs.
|
||||
final JellyfinAccounts jellyfin;
|
||||
/// Audio/subtitle carry-over between Jellyfin items.
|
||||
@@ -173,7 +166,7 @@ class SessionCoordinator {
|
||||
/// null when this app is not the host.
|
||||
Future<({String name, int size})?> Function(String path)? hostFile;
|
||||
/// The app's yt-dlp and link probes (tests pass their own).
|
||||
final AppYtDlp ytDlp;
|
||||
final LinkResolver ytDlp;
|
||||
final SyncClient sync = SyncClient();
|
||||
/// This app's log lines to the server's session log (Settings: "Share
|
||||
/// diagnostics with the server"), and "Mark a problem".
|
||||
@@ -217,8 +210,10 @@ class SessionCoordinator {
|
||||
final health =
|
||||
signal(const HealthStatus(HealthSeverity.info, 'idle'));
|
||||
final serverState = signal<StateBroadcast?>(null);
|
||||
final feedLog = signal<List<FeedMessage>>([]);
|
||||
final toasts = signal<List<ToastItem>>([]);
|
||||
/// The activity log ([Notifier.feedLog]).
|
||||
Signal<List<FeedMessage>> get feedLog => notifier.feedLog;
|
||||
/// Toasts on screen ([Notifier.toasts]).
|
||||
Signal<List<ToastItem>> get toasts => notifier.toasts;
|
||||
final reconnecting = signal(false);
|
||||
final starving = signal<String?>(null);
|
||||
/// Why this player cannot open the current media, in plain words
|
||||
@@ -279,9 +274,6 @@ class SessionCoordinator {
|
||||
/// Self sits out: the player has nothing open until [joinBack].
|
||||
late final Computed<bool> selfOut = computed(() => selfMember.value?.out ?? false);
|
||||
|
||||
/// Asks the desktop to point at the window (a nudge); tests swap it.
|
||||
Future<void> Function() requestAttention = requestWindowAttention;
|
||||
|
||||
Timer? _ticker;
|
||||
StreamSubscription? _stateSub;
|
||||
StreamSubscription? _feedSub;
|
||||
@@ -362,7 +354,6 @@ class SessionCoordinator {
|
||||
String _resumeToken = '';
|
||||
DateTime? _lastStateAt;
|
||||
final IdleTracker _idle = IdleTracker(DateTime.now().toUtc());
|
||||
int _toastId = 0;
|
||||
ConnectParams? _params;
|
||||
bool _everConnected = false;
|
||||
/// The connection-lost sound played; the reconnected one answers it once.
|
||||
@@ -388,9 +379,15 @@ class SessionCoordinator {
|
||||
// Members' last `error`, by id, so a new one toasts once.
|
||||
Map<String, String> _memberErrors = const {};
|
||||
|
||||
/// [notifier] defaults to one playing [sfx] (tests).
|
||||
SessionCoordinator(
|
||||
{this.sfx = const NoopSfxService(), JellyfinAccounts? jellyfin, TrackPrefs? trackPrefs, AppYtDlp? ytDlp})
|
||||
: ytDlp = ytDlp ?? AppYtDlp.instance,
|
||||
{SfxService sfx = const NoopSfxService(),
|
||||
Notifier? notifier,
|
||||
JellyfinAccounts? jellyfin,
|
||||
TrackPrefs? trackPrefs,
|
||||
LinkResolver? ytDlp})
|
||||
: notifier = notifier ?? Notifier(sfx: sfx),
|
||||
ytDlp = ytDlp ?? AppYtDlp.instance,
|
||||
jellyfin = jellyfin ?? JellyfinAccounts.instance,
|
||||
trackPrefs = trackPrefs ?? TrackPrefs();
|
||||
|
||||
@@ -438,7 +435,7 @@ class SessionCoordinator {
|
||||
for (final m in r.members) {
|
||||
final e = now[m.id];
|
||||
if (e != null && m.id != sync.memberId && _memberErrors[m.id] != e) {
|
||||
_pushToast("${m.name} can't play this: $e", actor: m.name);
|
||||
notify(MemberCantPlay(m.name, e));
|
||||
}
|
||||
}
|
||||
_memberErrors = now;
|
||||
@@ -452,7 +449,7 @@ class SessionCoordinator {
|
||||
final warning = slowSourceWarning(r, sync.memberId);
|
||||
if (warning == null) return;
|
||||
_slowWarnedFor = r.mediaId;
|
||||
_pushToast(warning);
|
||||
notify(SlowSource(warning));
|
||||
}
|
||||
|
||||
bool _reloadingVideo = false;
|
||||
@@ -528,8 +525,7 @@ class SessionCoordinator {
|
||||
diag
|
||||
..share = AppSettings.instance.shareDiagnostics.peek()
|
||||
..start();
|
||||
feedLog.value = [];
|
||||
toasts.value = [];
|
||||
notifier.clear();
|
||||
await initPlayer();
|
||||
if (epoch != _epoch) throw const ConnectCancelled();
|
||||
health.value =
|
||||
@@ -807,9 +803,7 @@ class SessionCoordinator {
|
||||
_relaySource = warm.src;
|
||||
_relaySourceMediaId = mediaId;
|
||||
relaying.value = true;
|
||||
if (announce) {
|
||||
_pushToast('Passing the video on through the server. Keep the app open while it plays.');
|
||||
}
|
||||
if (announce) notify(const RelayStarted());
|
||||
unawaited(warm.src.done.then((_) {
|
||||
if (identical(_relaySource, warm.src)) {
|
||||
_relaySource = null;
|
||||
@@ -823,9 +817,7 @@ class SessionCoordinator {
|
||||
_relaySource = src;
|
||||
_relaySourceMediaId = mediaId;
|
||||
relaying.value = true;
|
||||
if (announce) {
|
||||
_pushToast('Passing the video on through the server. Keep the app open while it plays.');
|
||||
}
|
||||
if (announce) notify(const RelayStarted());
|
||||
unawaited(() async {
|
||||
try {
|
||||
await src.start();
|
||||
@@ -834,7 +826,7 @@ class SessionCoordinator {
|
||||
if (identical(_relaySource, src)) {
|
||||
_relaySource = null;
|
||||
relaying.value = false;
|
||||
_pushToast('Cannot pass the video on: $e');
|
||||
notify(RelayFailed('$e'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1138,62 +1130,19 @@ class SessionCoordinator {
|
||||
}
|
||||
|
||||
void _onFeed(FeedMessage f) {
|
||||
feedLog.value = [...feedLog.peek(), f].length > 100
|
||||
? [...feedLog.peek(), f].sublist(feedLog.peek().length - 99)
|
||||
: [...feedLog.peek(), f];
|
||||
if (_toastActions.contains(f.action)) {
|
||||
final text = f.action == 'chat'
|
||||
? '${f.actor}: ${f.reason}'
|
||||
: _feedText(f);
|
||||
_toastId++;
|
||||
toasts.value = [
|
||||
...toasts.peek(),
|
||||
ToastItem(id: _toastId, text: text, at: DateTime.now().toUtc(), actor: f.actor)
|
||||
];
|
||||
}
|
||||
final event = FeedEvent.of(f,
|
||||
selfName: selfMember.peek()?.name,
|
||||
readyCount: f.action == 'ready' ? serverState.peek()?.members.where((m) => m.ready).length ?? 0 : 0);
|
||||
notify(event);
|
||||
// The server gave up fetching what everyone waits for.
|
||||
if (f.action == 'fetch-fail' && _awaitLiveFor != -1 && _awaitLiveFor == serverState.peek()?.mediaId) {
|
||||
if (event is FetchFailed && _awaitLiveFor != -1 && _awaitLiveFor == serverState.peek()?.mediaId) {
|
||||
health.value = const HealthStatus(HealthSeverity.error, "the server couldn't fetch this video");
|
||||
}
|
||||
final selfName = selfMember.peek()?.name;
|
||||
// Sat out by someone else: say who (the feed names members by name).
|
||||
if (f.action == 'out' && f.target != null && f.target == selfName && f.actor != selfName) {
|
||||
_pushToast('${f.actor} sat you out', actor: f.actor);
|
||||
unawaited(sfx.play(const SfxCue(SfxKind.kick)));
|
||||
}
|
||||
final kind = sfxForFeedAction(f.action);
|
||||
// Your own message needs no sound.
|
||||
final own = f.action == 'chat' && f.actor == selfName;
|
||||
if (kind != null && !own) {
|
||||
var readyIndex = 0;
|
||||
if (f.action == 'ready') {
|
||||
readyIndex = serverState.peek()?.members.where((m) => m.ready).length ?? 0;
|
||||
}
|
||||
unawaited(sfx.play(SfxCue(kind, readyIndex: readyIndex)));
|
||||
}
|
||||
}
|
||||
|
||||
String _feedText(FeedMessage f) {
|
||||
switch (f.action) {
|
||||
case 'join':
|
||||
return '${f.actor} joined';
|
||||
case 'leave':
|
||||
return '${f.actor} left';
|
||||
case 'swap':
|
||||
return '${f.actor} changed media';
|
||||
default:
|
||||
return '${f.actor} ${f.action}';
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
final name = n.name.isEmpty ? 'Someone' : n.name;
|
||||
_pushToast('$name nudged you', actor: n.name.isEmpty ? null : n.name);
|
||||
unawaited(sfx.play(const SfxCue(SfxKind.nudge)));
|
||||
unawaited(requestAttention());
|
||||
}
|
||||
void _onNudge(NudgeNotice n) => notify(Nudged(n.name));
|
||||
|
||||
void _startTicker() {
|
||||
_ticker?.cancel();
|
||||
@@ -1227,8 +1176,7 @@ class SessionCoordinator {
|
||||
|
||||
Future<void> _tick() async {
|
||||
final now = DateTime.now().toUtc();
|
||||
toasts.value =
|
||||
toasts.peek().where((t) => now.difference(t.at).inSeconds < 4).toList();
|
||||
notifier.expire(now);
|
||||
final r = serverState.peek();
|
||||
if (sync.token.isNotEmpty) sync.sendPing();
|
||||
diag
|
||||
@@ -1271,17 +1219,7 @@ class SessionCoordinator {
|
||||
selfReady: selfMember.peek()?.ready ?? false);
|
||||
switch (event) {
|
||||
case IdleEvent.warn:
|
||||
_toastId++;
|
||||
toasts.value = [
|
||||
...toasts.peek(),
|
||||
ToastItem(
|
||||
id: _toastId,
|
||||
text: sitOutWhenIdle
|
||||
? 'idle — sitting out in 2 min unless you press Space'
|
||||
: 'idle — auto-unready in 2 min unless you press Space',
|
||||
at: now)
|
||||
];
|
||||
unawaited(sfx.play(const SfxCue(SfxKind.warn)));
|
||||
notify(IdleWarning(sitsOut: sitOutWhenIdle));
|
||||
case IdleEvent.unready:
|
||||
_lastReady = false;
|
||||
if (sitOutWhenIdle) {
|
||||
@@ -1738,17 +1676,9 @@ class SessionCoordinator {
|
||||
|
||||
// ------------------------------------------------------------- jellyfin
|
||||
|
||||
/// A toast from outside the session (e.g. the Schedule tab's request
|
||||
/// results), shown like the session's own.
|
||||
void showToast(String text) => _pushToast(text);
|
||||
|
||||
void _pushToast(String text, {String? actor}) {
|
||||
_toastId++;
|
||||
toasts.value = [
|
||||
...toasts.peek(),
|
||||
ToastItem(id: _toastId, text: text, at: DateTime.now().toUtc(), actor: actor)
|
||||
];
|
||||
}
|
||||
/// News for the user, from the session or anywhere in the app (see
|
||||
/// [Notifier]).
|
||||
void notify(AppEvent event) => notifier.notify(event);
|
||||
|
||||
void _resetJellyfin() {
|
||||
_jfGen++;
|
||||
@@ -1928,7 +1858,7 @@ class SessionCoordinator {
|
||||
onError: (e) {
|
||||
if (_jfToastShown) return;
|
||||
_jfToastShown = true;
|
||||
_pushToast('Jellyfin progress not saved: ${e is JellyfinException && e.isUnauthorized ? 'sign-in expired' : 'server unreachable'}');
|
||||
notify(JellyfinProgressFailed(signInExpired: e is JellyfinException && e.isUnauthorized));
|
||||
},
|
||||
);
|
||||
final snap = await _snapshot();
|
||||
@@ -2153,7 +2083,7 @@ class SessionCoordinator {
|
||||
_everConnected = true;
|
||||
if (_lostSounded) {
|
||||
_lostSounded = false;
|
||||
unawaited(sfx.play(const SfxCue(SfxKind.back)));
|
||||
notify(const ConnectionBack());
|
||||
}
|
||||
_resumeToken = sync.token;
|
||||
_lastStateAt = DateTime.now().toUtc();
|
||||
@@ -2195,7 +2125,7 @@ class SessionCoordinator {
|
||||
void _soundLost() {
|
||||
if (!_everConnected || _lostSounded) return;
|
||||
_lostSounded = true;
|
||||
unawaited(sfx.play(const SfxCue(SfxKind.lost)));
|
||||
notify(const ConnectionLost());
|
||||
}
|
||||
|
||||
void _invalidateAttempts() {
|
||||
|
||||
Reference in new issue
Block a user