forked from Foxyz/sync_replay_project
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>
This commit is contained in:
1 parent
a87ee98faa
commit
741b258ac1
55 files changed
+2724
-964
No files matched your search
@@ -16,6 +16,7 @@ export 'src/media_source.dart';
|
||||
export 'src/sync_engine.dart';
|
||||
export 'src/updater.dart';
|
||||
export 'src/watch_session.dart';
|
||||
export 'src/whats_next.dart';
|
||||
export 'src/jellyfin/client.dart';
|
||||
export 'src/jellyfin/client_info.dart';
|
||||
export 'src/jellyfin/http.dart';
|
||||
|
||||
@@ -92,10 +92,12 @@ abstract class RelayConstants {
|
||||
/// Up next (`queue`): at most this many items; a title or second line
|
||||
/// is cut to [maxQueueTextLength] characters; an item's picture is
|
||||
/// capped like a poster ([maxPosterBytes]). A play request nobody acts on
|
||||
/// ends after [queuePlayTimeoutSecs].
|
||||
/// ends after [queuePlayTimeoutSecs]. The countdown to the first item at
|
||||
/// the end of the media (`state.advance`) runs [advanceSecs].
|
||||
static const int maxQueueItems = 100;
|
||||
static const int maxQueueTextLength = 200;
|
||||
static const int queuePlayTimeoutSecs = 30;
|
||||
static const int advanceSecs = 10;
|
||||
|
||||
/// Diagnostics sharing (`/l/...`, ARCHITECTURE "Logging"): a member's
|
||||
/// batch carries at most [diagBatchBytes] of lines (gzip on the wire, at
|
||||
|
||||
@@ -836,16 +836,42 @@ class SeekIntent {
|
||||
}
|
||||
}
|
||||
|
||||
/// `swap.restore`: what played before the server restarted, offered back
|
||||
/// by an app that still holds it: at [position] (seconds), [transport]
|
||||
/// as it was (`playing`, `held` or `finished`). The server takes it only
|
||||
/// while it has no media (the first offer wins, later ones are ignored).
|
||||
class SwapRestore {
|
||||
final double position;
|
||||
final String transport;
|
||||
const SwapRestore({required this.position, this.transport = 'held'});
|
||||
|
||||
Map<String, Object?> toJson() => {'position': position, 'transport': transport};
|
||||
|
||||
static SwapRestore? fromJson(Object? j) {
|
||||
if (j is! Map) return null;
|
||||
final t = j['transport'];
|
||||
return SwapRestore(
|
||||
position: finiteDouble(j['position'], 'restore.position'),
|
||||
transport: t == 'playing' || t == 'finished' ? t as String : 'held');
|
||||
}
|
||||
}
|
||||
|
||||
class SwapIntent {
|
||||
final HelloMedia media;
|
||||
/// The Up next item this swap puts up (`queue[].id`); the server takes
|
||||
/// it out of the queue with the swap, and ignores the swap when it is no
|
||||
/// longer queued (someone else put it up first).
|
||||
final String? queueId;
|
||||
const SwapIntent({required this.media, this.queueId});
|
||||
/// Puts back what played before a server restart ([SwapRestore]).
|
||||
final SwapRestore? restore;
|
||||
const SwapIntent({required this.media, this.queueId, this.restore});
|
||||
|
||||
Map<String, Object?> toJson() =>
|
||||
{'t': 'swap', 'media': media.toJson(), if (queueId != null) 'queueId': queueId};
|
||||
Map<String, Object?> toJson() => {
|
||||
't': 'swap',
|
||||
'media': media.toJson(),
|
||||
if (queueId != null) 'queueId': queueId,
|
||||
if (restore != null) 'restore': restore!.toJson(),
|
||||
};
|
||||
|
||||
factory SwapIntent.fromJson(Map<String, Object?> json) {
|
||||
if (json['t'] != 'swap') throw const ProtocolException('not a swap intent');
|
||||
@@ -854,7 +880,8 @@ class SwapIntent {
|
||||
final queueId = json['queueId'];
|
||||
return SwapIntent(
|
||||
media: HelloMedia.fromJson(Map<String, Object?>.from(media)),
|
||||
queueId: queueId is String && queueId.isNotEmpty ? queueId : null);
|
||||
queueId: queueId is String && queueId.isNotEmpty ? queueId : null,
|
||||
restore: SwapRestore.fromJson(json['restore']));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1147,11 +1174,11 @@ class HoldInfo {
|
||||
}
|
||||
}
|
||||
|
||||
/// `state.advance` (reserved, step 6): the shared countdown to Up next's
|
||||
/// first item when the media finishes. At [at] + [secs] (server clock) the
|
||||
/// server sets `queuePlay` to [id]; `{"t":"queue","op":"cancel"}` clears
|
||||
/// it for everyone. The server parses and broadcasts it but never starts
|
||||
/// one yet.
|
||||
/// `state.advance`: the shared countdown to Up next's first item when the
|
||||
/// media finishes. At [at] + [secs] (server clock) the server sets
|
||||
/// `queuePlay` to [id] (holding while its performer or its relay's source
|
||||
/// is `gone`); `{"t":"queue","op":"cancel"}` and a seek clear it for
|
||||
/// everyone (see `SyncEngine.whatsNext`).
|
||||
class QueueAdvance {
|
||||
final String id;
|
||||
final DateTime at;
|
||||
@@ -1211,7 +1238,7 @@ class StateBroadcast {
|
||||
final String? queuePlay;
|
||||
/// Why it is held ([HoldInfo]); null unless `held` with media up.
|
||||
final HoldInfo? hold;
|
||||
/// The shared countdown to Up next (reserved, [QueueAdvance]).
|
||||
/// The shared countdown to Up next's first item ([QueueAdvance]); null when none runs.
|
||||
final QueueAdvance? advance;
|
||||
const StateBroadcast({
|
||||
required this.eventId,
|
||||
|
||||
@@ -2,9 +2,9 @@ import 'dart:typed_data';
|
||||
|
||||
import 'constants.dart';
|
||||
import 'errors.dart';
|
||||
import 'presence.dart';
|
||||
import 'protocol.dart';
|
||||
import 'sync_engine.dart';
|
||||
import 'whats_next.dart';
|
||||
|
||||
// Up next: what plays after the current media, held by the server for
|
||||
// everyone (see PROTOCOL.md "Queue"). Wire types, and [EngineQueue], the
|
||||
@@ -342,14 +342,15 @@ typedef QueueRefusal = ({ErrCode code, String message});
|
||||
|
||||
/// The `queue` messages applied to the engine's [SyncEngine.queue], with
|
||||
/// their feed lines (`queue-add`, `queue-remove`, `queue-skip`,
|
||||
/// `advance-cancel`). Each returns whether the queue changed;
|
||||
/// [applyQueueAdd] a refusal for the sender instead (null when added).
|
||||
/// `advance-cancel`), then what's next ([SyncEngine.whatsNext]). Each
|
||||
/// returns whether the queue changed; [applyQueueAdd] a refusal for the
|
||||
/// sender instead (null when added).
|
||||
extension EngineQueueOps on SyncEngine {
|
||||
/// [item] is already cleaned by the server (see `cleanQueueAdd`). An
|
||||
/// [QueueItem.auto] item must continue the current media
|
||||
/// ([QueueItem.forMedia] = [SyncEngine.mediaId]) and come from who put
|
||||
/// it up ([SyncEngine.swapBy]) or, when that member is not `in`, from the
|
||||
/// first member who is.
|
||||
/// it up or, when that member is not `in`, from the first member who is
|
||||
/// ([mayAddContinuation]); it adds no feed line (it is no news).
|
||||
QueueRefusal? applyQueueAdd(String token, QueueItem item,
|
||||
{bool next = false, String? privateUrl, Uint8List? thumb}) {
|
||||
final member = members[token];
|
||||
@@ -358,24 +359,27 @@ extension EngineQueueOps on SyncEngine {
|
||||
if (media == null || item.forMedia != mediaId) {
|
||||
return (code: ErrCode.bad, message: 'bad message: an auto item must name the current media');
|
||||
}
|
||||
final swapper = members[swapBy];
|
||||
final firstIn = members.values.where((m) => m.presence == Presence.watching).firstOrNull;
|
||||
final allowed = token == swapBy ||
|
||||
((swapper == null || swapper.presence != Presence.watching) && identical(firstIn, member));
|
||||
if (!allowed) {
|
||||
final views = [for (final m in members.values) m.view(inGate: false)];
|
||||
if (!mayAddContinuation(member.id, members[swapBy]?.id, views)) {
|
||||
return (code: ErrCode.forbidden, message: 'only who put this media up adds what continues it');
|
||||
}
|
||||
}
|
||||
final added = queue.add(item.copyWith(by: member.id, byName: member.name),
|
||||
next: next, privateUrl: privateUrl, thumb: thumb);
|
||||
if (added == null) return (code: ErrCode.full, message: 'Up next is full (${RelayConstants.maxQueueItems} items)');
|
||||
feed.add(FeedMessage(
|
||||
actor: member.name, actorId: member.id, action: 'queue-add', target: added.title, at: now));
|
||||
if (!added.auto) {
|
||||
feed.add(FeedMessage(
|
||||
actor: member.name, actorId: member.id, action: 'queue-add', target: added.title, at: now));
|
||||
}
|
||||
whatsNext();
|
||||
return null;
|
||||
}
|
||||
|
||||
bool applyQueueMove(String token, String id, int to) =>
|
||||
members.containsKey(token) && queue.move(id, to);
|
||||
bool applyQueueMove(String token, String id, int to) {
|
||||
if (!members.containsKey(token) || !queue.move(id, to)) return false;
|
||||
whatsNext();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// [why] set: skipped because it cannot play (`queue-skip`, with the
|
||||
/// reason), else removed by hand (`queue-remove`).
|
||||
@@ -391,6 +395,7 @@ extension EngineQueueOps on SyncEngine {
|
||||
target: gone.title,
|
||||
reason: why,
|
||||
at: now));
|
||||
whatsNext();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -399,17 +404,20 @@ extension EngineQueueOps on SyncEngine {
|
||||
if (!members.containsKey(token)) return false;
|
||||
final target = id.isEmpty ? (queue.isEmpty ? null : queue.items.first.id) : id;
|
||||
if (target == null || queue.play == target) return false;
|
||||
return queue.requestPlay(target, now);
|
||||
if (!queue.requestPlay(target, now)) return false;
|
||||
whatsNext();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// `cancel`: drops the continuation ([EngineQueue.auto]) and the
|
||||
/// countdown ([SyncEngine.advance]) for everyone (feed `advance-cancel`).
|
||||
/// countdown ([SyncEngine.advance]) for everyone (feed `advance-cancel`);
|
||||
/// no countdown starts again for this media.
|
||||
bool applyQueueCancel(String token) {
|
||||
final member = members[token];
|
||||
if (member == null) return false;
|
||||
final dropped = queue.dropAuto();
|
||||
final hadAdvance = advance != null;
|
||||
advance = null;
|
||||
cancelAdvance();
|
||||
if (dropped == null && !hadAdvance) return false;
|
||||
feed.add(FeedMessage(
|
||||
actor: member.name, actorId: member.id, action: 'advance-cancel', target: dropped?.title, at: now));
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'constants.dart';
|
||||
import 'presence.dart';
|
||||
import 'protocol.dart';
|
||||
import 'queue.dart';
|
||||
import 'whats_next.dart';
|
||||
|
||||
typedef SyncClock = DateTime Function();
|
||||
|
||||
@@ -245,9 +246,19 @@ class SyncEngine {
|
||||
/// Up next ([EngineQueue]): in `state.queue`, edited by `queue`
|
||||
/// messages, taken from by swaps that name an item.
|
||||
final EngineQueue queue = EngineQueue();
|
||||
/// The shared countdown to Up next (`state.advance`); reserved: only
|
||||
/// `cancel` touches it today.
|
||||
/// The shared countdown to Up next's first item at the end of the media
|
||||
/// (`state.advance`, see [whatsNext]).
|
||||
QueueAdvance? advance;
|
||||
/// The relay of Up next item `queueId` waits for its source, who is
|
||||
/// `gone` (set by the server: the engine does not hold relays). The
|
||||
/// countdown holds while it does.
|
||||
bool Function(String queueId)? itemSourceGone;
|
||||
// The mediaId whose countdown was cancelled (`cancel`): it does not
|
||||
// start again for that media.
|
||||
int _advanceOffFor = -1;
|
||||
// The item last asked to play for the current media: not counted down
|
||||
// again.
|
||||
String? _advancedId;
|
||||
/// A member went `gone` (its socket closed or fell silent): the server
|
||||
/// closes the socket and marks a relay it serves.
|
||||
void Function(EngineMember member)? onGone;
|
||||
@@ -316,12 +327,97 @@ class SyncEngine {
|
||||
at: now);
|
||||
|
||||
/// Heartbeat (1 s): silence, joining timeouts, gone notices, grace,
|
||||
/// a stale play request, end of media.
|
||||
/// a stale play request, end of media, what's next.
|
||||
void tick() {
|
||||
sweepSilent();
|
||||
sweepPresence();
|
||||
queue.tick(now);
|
||||
evaluate();
|
||||
whatsNext();
|
||||
}
|
||||
|
||||
/// What's next (PROTOCOL.md "Queue"), on every tick and queue change:
|
||||
/// - a play request (`queuePlay`) nobody here can serve is skipped
|
||||
/// (feed `queue-skip`, actor `server`, with why: [queuePerformer]);
|
||||
/// - with nothing up, the first item is asked to play at once;
|
||||
/// - at the end of the media with an item first, the countdown
|
||||
/// ([advance], [RelayConstants.advanceSecs]) starts (not again for a
|
||||
/// media whose countdown was cancelled, nor for the item it already
|
||||
/// asked for); it follows the first item when that changes, keeping
|
||||
/// its end; it ends with the end of `finished` (a seek), an empty
|
||||
/// queue, `cancel` or a swap. At its end the first item is asked to
|
||||
/// play (`queuePlay`) and the countdown is over, unless nobody can
|
||||
/// put it up (skipped) or its performer is not around or its relay's
|
||||
/// source is `gone` ([itemSourceGone]): then it holds until they are
|
||||
/// back.
|
||||
/// Returns whether anything changed.
|
||||
bool whatsNext() {
|
||||
var changed = false;
|
||||
final views = [for (final m in members.values) m.view(inGate: false)];
|
||||
final play = queue.play;
|
||||
if (play != null) {
|
||||
final item = queue.item(play);
|
||||
final why = item == null ? null : queuePerformer(item, views).why;
|
||||
if (item != null && why != null) {
|
||||
_skipQueued(item, why);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
final first = queue.items.firstOrNull;
|
||||
if (media == null) {
|
||||
if (first != null && queue.play == null) changed = _due(first, views) || changed;
|
||||
return changed;
|
||||
}
|
||||
var a = advance;
|
||||
if (a != null && (transport != Transport.finished || first == null || queue.play != null)) {
|
||||
advance = a = null;
|
||||
changed = true;
|
||||
}
|
||||
if (transport != Transport.finished || first == null || queue.play != null) return changed;
|
||||
if (a == null) {
|
||||
if (_advanceOffFor == mediaId || _advancedId == first.id) return changed;
|
||||
advance = a = QueueAdvance(id: first.id, at: now, secs: RelayConstants.advanceSecs);
|
||||
changed = true;
|
||||
} else if (a.id != first.id) {
|
||||
advance = a = QueueAdvance(id: first.id, at: a.at, secs: a.secs);
|
||||
changed = true;
|
||||
}
|
||||
if (now.isBefore(a.at.add(Duration(seconds: a.secs)))) return changed;
|
||||
return _due(first, views) || changed;
|
||||
}
|
||||
|
||||
/// [item] should play now: asked for (`queuePlay`), skipped when nobody
|
||||
/// can put it up, or held. Returns whether anything changed.
|
||||
bool _due(QueueItem item, List<MemberView> views) {
|
||||
final perf = queuePerformer(item, views);
|
||||
final why = perf.why;
|
||||
if (why != null) {
|
||||
_skipQueued(item, why);
|
||||
return true;
|
||||
}
|
||||
if (perf.id == null || (itemSourceGone?.call(item.id) ?? false)) return false;
|
||||
queue.requestPlay(item.id, now);
|
||||
advance = null;
|
||||
_advancedId = item.id;
|
||||
return true;
|
||||
}
|
||||
|
||||
void _skipQueued(QueueItem item, String why) {
|
||||
queue.remove(item.id);
|
||||
// The countdown goes on with the next item (asked for on the next
|
||||
// tick when its time is up).
|
||||
final a = advance, first = queue.items.firstOrNull;
|
||||
if (a != null && a.id == item.id) {
|
||||
advance = first == null ? null : QueueAdvance(id: first.id, at: a.at, secs: a.secs);
|
||||
}
|
||||
feed.add(FeedMessage(
|
||||
actor: 'server', actorId: 'server', action: 'queue-skip', target: item.title, reason: why, at: now));
|
||||
}
|
||||
|
||||
/// `cancel` for the current media: no countdown for it any more.
|
||||
void cancelAdvance() {
|
||||
advance = null;
|
||||
_advanceOffFor = mediaId;
|
||||
}
|
||||
|
||||
/// Joining: loads in the background until its reports hold the media
|
||||
@@ -617,6 +713,8 @@ class SyncEngine {
|
||||
_seekSeq = seq;
|
||||
_seekEventId = eventId;
|
||||
feed.add(_line('seek', actor: member));
|
||||
// A seek stops the countdown to Up next.
|
||||
advance = null;
|
||||
// Seeking back from the end: held, and resumes once the gate passes.
|
||||
if (transport == Transport.finished) {
|
||||
transport = Transport.held;
|
||||
@@ -627,11 +725,15 @@ class SyncEngine {
|
||||
/// Returns false (no state change) for media with non-finite numbers.
|
||||
/// [queueId]: the Up next item this swap puts up; it leaves the queue
|
||||
/// (any other swap drops the continuation item, [EngineQueue.auto]).
|
||||
bool applySwap(String actorToken, HelloMedia next, {String? queueId}) {
|
||||
/// [restore]: what played before a restart, put back where it was (the
|
||||
/// server takes it only while nothing is up; see `swap.restore`): held
|
||||
/// at its position (the gate starts it again), or finished.
|
||||
bool applySwap(String actorToken, HelloMedia next, {String? queueId, SwapRestore? restore}) {
|
||||
if (!next.duration.isFinite || next.size < 0) return false;
|
||||
final actor = members[actorToken];
|
||||
queue.swapped(queueId);
|
||||
advance = null;
|
||||
_advancedId = null;
|
||||
mediaId++;
|
||||
media = next;
|
||||
swapBy = actorToken;
|
||||
@@ -648,7 +750,15 @@ class SyncEngine {
|
||||
member.inSet = true;
|
||||
member.buffered = 0;
|
||||
}
|
||||
feed.add(_line('swap', actor: actor));
|
||||
if (restore != null) {
|
||||
final d = next.duration;
|
||||
position = restore.position < 0 ? 0 : (d > 0 && restore.position > d ? d : restore.position);
|
||||
if (restore.transport == 'finished' && d > 0) {
|
||||
position = d;
|
||||
transport = Transport.finished;
|
||||
}
|
||||
}
|
||||
feed.add(_line('swap', actor: actor, reason: restore == null ? '' : 'restore'));
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -715,6 +825,7 @@ class SyncEngine {
|
||||
eventId++;
|
||||
transport = Transport.finished;
|
||||
feed.add(_line('finished'));
|
||||
whatsNext();
|
||||
return;
|
||||
}
|
||||
final blocked =
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
// What one viewer actually watched of one opened item, and the rule that
|
||||
// turns it into "watched", "barely looked" or "stopped part way". Pure, so
|
||||
// any tracker (Jellyfin today) applies the same rule. See ARCHITECTURE.md
|
||||
// "Watched rule".
|
||||
// turns it into "watched", "barely looked" or "stopped part way" (D3).
|
||||
// Pure, so every tracker (the device history, Jellyfin, AniList later)
|
||||
// applies the same rule. See ARCHITECTURE.md "Watch tracking".
|
||||
|
||||
/// What a [WatchSession] amounts to when it ends.
|
||||
enum WatchOutcome {
|
||||
@@ -14,7 +14,8 @@ enum WatchOutcome {
|
||||
/// whatever happened, with no resume point.
|
||||
keepPlayed,
|
||||
|
||||
/// Barely watched (a peek, a screenshot): leave the item as it was.
|
||||
/// Barely watched (a peek, a screenshot visit, skipping around): leave
|
||||
/// the item as it was.
|
||||
peek,
|
||||
|
||||
/// Part way: resume from [WatchVerdict.resumeAt].
|
||||
@@ -28,108 +29,105 @@ class WatchVerdict {
|
||||
final double? resumeAt;
|
||||
const WatchVerdict(this.outcome, {this.resumeAt});
|
||||
|
||||
/// It counts as watched (played now, or played before).
|
||||
bool get watched => outcome == WatchOutcome.played || outcome == WatchOutcome.keepPlayed;
|
||||
|
||||
@override
|
||||
String toString() => 'WatchVerdict(${outcome.name}${resumeAt == null ? '' : ', $resumeAt'})';
|
||||
}
|
||||
|
||||
/// One opening of one item on this client. Fed the player position about
|
||||
/// once a second ([sample]); only normal playback counts as watched: the
|
||||
/// stretch between two samples while playing, when the position moved
|
||||
/// forward no faster than [maxRate] (plus [slack]) and the samples are at
|
||||
/// most [maxGap] apart. Seeks, skips and pauses add nothing. [covered] is the union of the watched stretches,
|
||||
/// so rewatching a part does not count twice; [watched] is the plain sum.
|
||||
/// One opening of one item on this app. Fed about once a second
|
||||
/// ([sample]) with the player's position, whether the media plays here
|
||||
/// (the server's transport is playing and this player plays it) and
|
||||
/// whether this viewer is In (present: `in`, from the presence the server
|
||||
/// reports for it). The basis is [timeIn]: wall time the media played
|
||||
/// while this viewer was In. Time sat out (Away), joining, gone, paused or
|
||||
/// held never counts, nor does a jump: skipping around adds only the time
|
||||
/// actually played. Two samples further apart than [maxGap] add nothing
|
||||
/// (the app stalled). A resume carries the earlier watch: when what played
|
||||
/// here starts at about the resume point ([resumeBefore], 60 s either
|
||||
/// way) and nothing before it played, the time up to it counts as watched
|
||||
/// before ([credited]).
|
||||
class WatchSession {
|
||||
/// Played before this opening (Jellyfin `UserData.Played`).
|
||||
final bool playedBefore;
|
||||
/// Played before this opening (Jellyfin `UserData.Played`, the device's
|
||||
/// watch history); set once known (Jellyfin answers after the open).
|
||||
bool playedBefore;
|
||||
|
||||
/// The resume point before this opening, seconds (0 = none).
|
||||
final double resumeBefore;
|
||||
double resumeBefore;
|
||||
|
||||
final double maxRate;
|
||||
final double slack;
|
||||
final double maxGap;
|
||||
|
||||
WatchSession(
|
||||
{this.playedBefore = false, this.resumeBefore = 0, this.maxRate = 2, this.slack = 1.5, this.maxGap = 10});
|
||||
WatchSession({this.playedBefore = false, this.resumeBefore = 0, this.maxGap = 10});
|
||||
|
||||
DateTime? startedAt;
|
||||
DateTime? endedAt;
|
||||
double? firstPosition;
|
||||
double? lastPosition;
|
||||
|
||||
/// The furthest position seen, reached by playing or by a jump.
|
||||
/// The furthest position seen while In, reached by playing or by a jump.
|
||||
double furthest = 0;
|
||||
|
||||
/// Seconds of normal playback, rewatches included.
|
||||
double watched = 0;
|
||||
/// Seconds the media played while this viewer was In.
|
||||
double timeIn = 0;
|
||||
|
||||
final _spans = <(double, double)>[];
|
||||
({double pos, bool playing, DateTime at})? _prev;
|
||||
/// The lowest position it played from while In; null before any.
|
||||
double? playedFrom;
|
||||
|
||||
/// Seconds of the item covered by normal playback (rewatches once).
|
||||
double get covered => _spans.fold(0.0, (s, e) => s + e.$2 - e.$1);
|
||||
|
||||
/// The watched stretches, sorted and merged.
|
||||
List<(double, double)> get spans => List.unmodifiable(_spans);
|
||||
|
||||
void sample(double position, {required bool playing, required DateTime at}) {
|
||||
final prev = _prev;
|
||||
_prev = (pos: position, playing: playing, at: at);
|
||||
startedAt ??= at;
|
||||
endedAt = at;
|
||||
firstPosition ??= position;
|
||||
lastPosition = position;
|
||||
furthest = math.max(furthest, position);
|
||||
if (prev == null || !prev.playing) return;
|
||||
final dt = at.difference(prev.at).inMilliseconds / 1000;
|
||||
final dp = position - prev.pos;
|
||||
if (dt <= 0 || dt > maxGap || dp <= 0 || dp > dt * maxRate + slack) return;
|
||||
watched += dp;
|
||||
_add(prev.pos, position);
|
||||
/// [timeIn] plus the time before the resume point when this opening
|
||||
/// continued from it.
|
||||
double get credited {
|
||||
final from = playedFrom;
|
||||
final resumed = resumeBefore > 0 && from != null && (from - resumeBefore).abs() <= 60;
|
||||
return timeIn + (resumed ? resumeBefore : 0);
|
||||
}
|
||||
|
||||
void _add(double a, double b) {
|
||||
final out = <(double, double)>[];
|
||||
var (lo, hi) = (a, b);
|
||||
for (final (s, e) in _spans) {
|
||||
if (e < lo || s > hi) {
|
||||
out.add((s, e));
|
||||
} else {
|
||||
lo = math.min(lo, s);
|
||||
hi = math.max(hi, e);
|
||||
}
|
||||
({DateTime at, double pos, bool counting})? _prev;
|
||||
|
||||
void sample(double position, {required bool playing, required bool present, required DateTime at}) {
|
||||
final prev = _prev;
|
||||
_prev = (at: at, pos: position, counting: playing && present);
|
||||
startedAt ??= at;
|
||||
endedAt = at;
|
||||
if (present) {
|
||||
firstPosition ??= position;
|
||||
lastPosition = position;
|
||||
furthest = math.max(furthest, position);
|
||||
}
|
||||
out
|
||||
..add((lo, hi))
|
||||
..sort((x, y) => x.$1.compareTo(y.$1));
|
||||
_spans
|
||||
..clear()
|
||||
..addAll(out);
|
||||
if (prev == null || !prev.counting) return;
|
||||
final dt = at.difference(prev.at).inMilliseconds / 1000;
|
||||
if (dt <= 0 || dt > maxGap) return;
|
||||
timeIn += dt;
|
||||
final from = playedFrom;
|
||||
playedFrom = from == null ? prev.pos : math.min(from, prev.pos);
|
||||
}
|
||||
|
||||
/// The rule, for an item [runtime] seconds long whose ending starts at
|
||||
/// [endingStart] (an Outro segment or ED chapter; null = the real end):
|
||||
/// - played before: [WatchOutcome.keepPlayed];
|
||||
/// - [covered] >= 70 % of the runtime, or the ending was reached
|
||||
/// ([furthest]) with [covered] >= 40 %: [WatchOutcome.played]. The
|
||||
/// - [credited] time In >= 70 % of the runtime, or the ending was
|
||||
/// reached ([furthest]) with it >= 40 %: [WatchOutcome.played]. The
|
||||
/// ending is the earlier of [endingStart] and the last 15 % (at most
|
||||
/// 4 min): someone who watched that far and stops is done with it;
|
||||
/// - [covered] under max(2 min, 5 %) (at most a quarter of a short
|
||||
/// 4 min): someone who watched that far and stops is done with it.
|
||||
/// Any watch Jellyfin's own rule counts (a position past 90 %, reached
|
||||
/// by playing) counts here too, also with the opening and ending
|
||||
/// skipped;
|
||||
/// - [credited] under max(2 min, 5 %) (at most a quarter of a short
|
||||
/// item): [WatchOutcome.peek];
|
||||
/// - else [WatchOutcome.partial] at [lastPosition].
|
||||
/// An unknown runtime (0) never makes it played.
|
||||
WatchVerdict verdict({required double runtime, double? endingStart}) {
|
||||
if (playedBefore) return const WatchVerdict(WatchOutcome.keepPlayed);
|
||||
final c = covered;
|
||||
final t = credited;
|
||||
if (runtime > 0) {
|
||||
final tail = runtime - math.min(240.0, 0.15 * runtime);
|
||||
final end = endingStart != null && endingStart > 0 && endingStart < tail ? endingStart : tail;
|
||||
if (c >= 0.7 * runtime || (furthest >= end && c >= 0.4 * runtime)) {
|
||||
if (t >= 0.7 * runtime || (furthest >= end && t >= 0.4 * runtime)) {
|
||||
return const WatchVerdict(WatchOutcome.played);
|
||||
}
|
||||
}
|
||||
final peekBelow = runtime > 0 ? math.min(math.max(120.0, 0.05 * runtime), 0.25 * runtime) : 120.0;
|
||||
if (c < peekBelow) return const WatchVerdict(WatchOutcome.peek);
|
||||
if (t < peekBelow) return const WatchVerdict(WatchOutcome.peek);
|
||||
return WatchVerdict(WatchOutcome.partial, resumeAt: lastPosition ?? 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import 'presence.dart';
|
||||
import 'protocol.dart';
|
||||
import 'queue.dart';
|
||||
|
||||
// What's next, the rules every side reads the same way (PROTOCOL.md
|
||||
// "Queue", ARCHITECTURE.md "What's next"): who puts an Up next item up.
|
||||
// Pure and only on what `state` carries, so the server (which holds the
|
||||
// countdown and skips what nobody can play) and every app agree without
|
||||
// a wire message.
|
||||
|
||||
/// Who puts an Up next item up: [id] a member id; null with [why] when
|
||||
/// nobody here can (the server skips the item), null without [why] when
|
||||
/// nobody is around right now (it waits).
|
||||
typedef QueuePerformer = ({String? id, String? why});
|
||||
|
||||
/// Connected and watching (`in` or `joining`): can do things for the
|
||||
/// party now (put an Up next item up).
|
||||
bool isPresentMember(MemberView m) => m.presence == Presence.watching || m.presence == Presence.joining;
|
||||
|
||||
/// - A link passed on by its adder, or the adder's file: only the adder
|
||||
/// (their app has the file, the cookies). Gone (disconnected, in its
|
||||
/// grace): it waits for them; left for good: skipped.
|
||||
/// - Jellyfin: the adder when it plays that server with its own account,
|
||||
/// else the lowest member id that does (`jf[serverId] == true`,
|
||||
/// present). Nobody: skipped.
|
||||
/// - A link everyone opens, or one the server fetches: the adder when
|
||||
/// present, else the lowest present member id; nobody present: it waits.
|
||||
QueuePerformer queuePerformer(QueueItem item, List<MemberView> members) {
|
||||
MemberView? adder;
|
||||
for (final m in members) {
|
||||
if (m.id == item.by) adder = m;
|
||||
}
|
||||
if (item.how.adderOnly) {
|
||||
if (adder == null) return (id: null, why: '${item.byName.isEmpty ? 'who added it' : item.byName} left');
|
||||
if (adder.presence == Presence.gone) return (id: null, why: null);
|
||||
return (id: adder.id, why: null);
|
||||
}
|
||||
final ref = item.media.jellyfin;
|
||||
if (item.how == QueueHow.jellyfin && ref != null) {
|
||||
bool can(MemberView m) => isPresentMember(m) && m.jf[ref.serverId] == true;
|
||||
if (adder != null && can(adder)) return (id: adder.id, why: null);
|
||||
final id = lowestMemberId(members.where(can));
|
||||
return id == null ? (id: null, why: 'nobody here has that Jellyfin server') : (id: id, why: null);
|
||||
}
|
||||
if (adder != null && isPresentMember(adder)) return (id: adder.id, why: null);
|
||||
return (id: lowestMemberId(members.where(isPresentMember)), why: null);
|
||||
}
|
||||
|
||||
/// The lowest member id of [members] ("m2" before "m10"), or null.
|
||||
String? lowestMemberId(Iterable<MemberView> members) {
|
||||
final ids = [for (final m in members) m.id]..sort(compareMemberIds);
|
||||
return ids.isEmpty ? null : ids.first;
|
||||
}
|
||||
|
||||
/// "m2" before "m10": numeric suffix order, then plain text.
|
||||
int compareMemberIds(String a, String b) {
|
||||
final na = int.tryParse(a.replaceFirst(RegExp(r'^\D+'), ''));
|
||||
final nb = int.tryParse(b.replaceFirst(RegExp(r'^\D+'), ''));
|
||||
if (na != null && nb != null && na != nb) return na.compareTo(nb);
|
||||
return a.compareTo(b);
|
||||
}
|
||||
|
||||
/// Who may add the continuation ([QueueItem.auto]) of the current media:
|
||||
/// who put it up ([swapBy], a member id), or, when that member is not
|
||||
/// `in`, the first member (in [members] order, the join order) who is.
|
||||
bool mayAddContinuation(String memberId, String? swapBy, List<MemberView> members) {
|
||||
if (memberId.isEmpty) return false;
|
||||
if (memberId == swapBy) return true;
|
||||
final swapper = members.where((m) => m.id == swapBy).firstOrNull;
|
||||
if (swapper != null && swapper.presence == Presence.watching) return false;
|
||||
return members.where((m) => m.presence == Presence.watching).firstOrNull?.id == memberId;
|
||||
}
|
||||
|
||||
/// Whole seconds left of [advance] at [now] (server clock), at least 0.
|
||||
int advanceSecondsLeft(QueueAdvance advance, DateTime now) {
|
||||
final end = advance.at.add(Duration(seconds: advance.secs));
|
||||
final ms = end.difference(now).inMilliseconds;
|
||||
return ms <= 0 ? 0 : (ms / 1000).ceil();
|
||||
}
|
||||
Reference in new issue
Block a user