Files
sync_replay_project/apps/desktop/lib/src/session/app_events.dart
T
foxyzandClaude Opus 5.5 9f2586b7ff
ci / core (push) Successful in 28s
ci / server (push) Successful in 1m5s
ci / updater-windows (push) Successful in 5m48s
Restructure step 1: lasting identity, presence and protocol 16
Server and core: version ranges on both sides with a coded refusal; a
client id maps to the same member and token for the server's life; a
drop is `gone` for a 10-minute grace keeping ready, presence, queue
items, relay and avatar; `bye` leaves at once; ordered takeover (seq,
replaced/stale); presence joining/in/away/gone with one PresencePolicy;
joiners never pause playback and hold a held gate at most ~15 s;
state.hold; feed ids, seq and replay; the relay lives through its
source's grace and continues on re-dial; queue `by` lasting, auto
items and cancel; MediaRef carried; client ids never logged.

App: persisted client id and seq; Connection with unstable/lost/
replaced states, backoff with jitter, no lost/back pair per flap; a drop
keeps the session (state, player, media, relay source); presence in the
strip and members card; sit out = away intent; feed matched by id;
auto sit-out (idle when not playing, minimised for a while; never when
alone) replaces auto-unready; bye on close and Leave, not on update
restart; relay source re-dials through its grace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:39:47 +03:00

266 lines
7.3 KiB
Dart

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. [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}) {
bool isSelf(String id) => selfId.isNotEmpty && id == selfId;
return switch (f.action) {
'join' => MemberJoined(f),
'leave' => MemberLeft(f),
'gone' => MemberGone(f),
'return' => MemberReturned(f),
'away' => MemberSatOut(f,
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),
};
}
}
class MemberJoined extends FeedEvent {
const MemberJoined(super.feed);
}
/// Left for good (`reason` `bye`: the app closed or Leave; `grace`: gone
/// too long).
class MemberLeft extends FeedEvent {
const MemberLeft(super.feed);
}
/// Lost signal: gone for a while, kept in its grace period.
class MemberGone extends FeedEvent {
const MemberGone(super.feed);
}
/// Back within its grace after a [MemberGone].
class MemberReturned extends FeedEvent {
const MemberReturned(super.feed);
}
/// Someone sat out (`away`: the actor itself, or `target` sat out by the
/// actor; `reason` `idle` / `minimised` for an automatic one); [atYou]:
/// someone else sat this member out; [autoSelf]: this app's auto sit-out
/// set this member away.
class MemberSatOut extends FeedEvent {
final bool atYou;
final bool autoSelf;
const MemberSatOut(super.feed, {this.atYou = false, this.autoSelf = false});
}
/// Back from sitting out (`in`).
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);
}
/// The connection to the server dropped and stayed down a few seconds
/// (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);
}