Restructure step 0: test safety net, AppEvent/Notifier seam, platform ports, protocol 16 planned
ci / core (push) Successful in 29s
ci / server (push) Successful in 1m9s
ci / updater-windows (push) Successful in 5m58s

- 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:
foxyzandClaude Opus 5.5 committed 2026-10-15 19:24:11 +03:00
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);
}
+140
View File
@@ -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() {