Files
sync_replay_project/apps/desktop/lib/src/session/notifier.dart
T
foxyzandClaude Opus 5.5 741b258ac1
ci / core (push) Successful in 29s
ci / server (push) Successful in 53s
ci / updater-windows (push) Failing after 7m45s
Restructure step 6: what's next and watch tracking
- What's next is the server's: state.advance counts down (10 s) when the
  media finishes with an Up next item first, holds while its performer or
  warm source is gone, and sets queuePlay at its end; cancel is shared, a
  seek or swap ends it; the server skips items nobody can put up. Shared
  rules in core whats_next.dart; the app's janitor, per-screen countdown
  and end-of-episode card are gone.
- The continuation (next Jellyfin episode, next file of a local show
  with its gaps) is an auto item under "Continues with", prepared like
  Up next's first item.
- Watch tracking by time In (D3): only time played while this viewer is
  in counts; away, paused, held, jumps and stalls never do (P7, P9).
  WatchTracker + device WatchHistory (files, links); Jellyfin progress
  reads the same watch; records are the AniList seam.
- A server restart no longer loses what played: apps hold their media
  and one offers it back (swap.restore, first offer wins), Up next is
  re-added, the source re-feeds, and the watch carries on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 02:06:04 +03:00

439 lines
17 KiB
Dart

import 'dart:async';
import 'package:signals/signals_core.dart';
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;
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;
/// [actor]'s member id (feed and chat toasts); '' when unknown.
final String 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, 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.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,
);
}
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(
category: NoticeCategory.people,
log: feed,
toast: '${feed.actor} joined',
actor: feed.actor,
sound: const SfxCue(SfxKind.join)),
MemberLeft(:final feed) => Notice(
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),
// Put back after a server restart: news, but not a change.
MediaSwapped(:final feed) when feed.reason == 'restore' => Notice(
category: NoticeCategory.upNext,
log: feed,
toast: '${feed.actor} put back what was playing',
actor: feed.actor),
MediaSwapped(:final feed) => 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,
chat: true,
sound: const SfxCue(SfxKind.chat)),
MemberSatOut(:final feed, atYou: true) => Notice(
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(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),
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.
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 (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], 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;
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;
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;
}
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, context);
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) {
final id = ++_toastId;
toasts.value = [
...toasts.peek(),
ToastItem(
id: id,
text: text,
at: DateTime.now().toUtc(),
actor: n.actor,
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));
}
}
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 = [];
}
}