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
@@ -0,0 +1,187 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hive_ce/hive.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
// The device's watch history (device-owned, one Hive box): what this app
|
||||
// watched of files and links, by [WatchKey], with the verdict of the
|
||||
// watched rule (`WatchSession`, D3). Jellyfin keeps its own (reported by
|
||||
// `JellyfinProgressReporter`); AniList later reads the tracker's records.
|
||||
// See ARCHITECTURE.md "Watch tracking".
|
||||
|
||||
const watchHistoryBoxName = 'watch_history';
|
||||
|
||||
/// What a watch is about, independent of the opening: a file by its path
|
||||
/// on this computer, a link by its URL, a Jellyfin item by its ids, else
|
||||
/// the media's name and size (someone else's file, a link passed on).
|
||||
class WatchKey {
|
||||
final String value;
|
||||
const WatchKey._(this.value);
|
||||
|
||||
factory WatchKey.file(String path) => WatchKey._('file:$path');
|
||||
factory WatchKey.link(String url) => WatchKey._('link:$url');
|
||||
factory WatchKey.jellyfin(JellyfinRef ref) => WatchKey._('jf:${ref.serverId}/${ref.itemId}');
|
||||
factory WatchKey.media(String name, int size) => WatchKey._('media:$name|$size');
|
||||
|
||||
bool get isJellyfin => value.startsWith('jf:');
|
||||
|
||||
/// The key of `state.media` [media]: [ownFile] / [ownLink] when this app
|
||||
/// put it up from there, else its Jellyfin ref, its shared link, its
|
||||
/// name and size.
|
||||
static WatchKey? of(Map<String, Object?>? media, {String? ownFile, String? ownLink}) {
|
||||
if (media == null) return null;
|
||||
if (ownFile != null) return WatchKey.file(ownFile);
|
||||
final jf = media['jellyfin'];
|
||||
if (jf is Map) {
|
||||
final ref = JellyfinRef.fromJson(Map<String, Object?>.from(jf));
|
||||
if (ref.serverId.isNotEmpty && ref.itemId.isNotEmpty) return WatchKey.jellyfin(ref);
|
||||
}
|
||||
if (ownLink != null) return WatchKey.link(ownLink);
|
||||
final url = media['url'];
|
||||
if (url is String && (url.startsWith('http://') || url.startsWith('https://'))) return WatchKey.link(url);
|
||||
final name = media['name'];
|
||||
final size = media['size'];
|
||||
return WatchKey.media(name is String ? name : '', size is num ? size.toInt() : 0);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => other is WatchKey && other.value == value;
|
||||
@override
|
||||
int get hashCode => value.hashCode;
|
||||
@override
|
||||
String toString() => value;
|
||||
}
|
||||
|
||||
/// One closed watch: what the tracker hands its sinks (the device history,
|
||||
/// AniList later).
|
||||
class WatchRecord {
|
||||
final WatchKey key;
|
||||
final String title;
|
||||
final MediaRef? ref;
|
||||
final WatchVerdict verdict;
|
||||
/// Seconds the media played while this viewer was In, and its runtime
|
||||
/// (0 when unknown).
|
||||
final double timeIn;
|
||||
final double runtime;
|
||||
final DateTime at;
|
||||
const WatchRecord({
|
||||
required this.key,
|
||||
required this.title,
|
||||
required this.verdict,
|
||||
required this.timeIn,
|
||||
required this.runtime,
|
||||
required this.at,
|
||||
this.ref,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() => 'WatchRecord($key, $verdict, ${timeIn.round()} s In of ${runtime.round()} s)';
|
||||
}
|
||||
|
||||
/// What the history holds for a key: watched (played), or where to
|
||||
/// resume, and when.
|
||||
class WatchEntry {
|
||||
final bool played;
|
||||
/// Seconds; 0 when played or none.
|
||||
final double resumeAt;
|
||||
final String title;
|
||||
final MediaRef? ref;
|
||||
final DateTime at;
|
||||
const WatchEntry({required this.played, this.resumeAt = 0, this.title = '', this.ref, required this.at});
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'played': played,
|
||||
if (resumeAt > 0) 'resumeAt': resumeAt,
|
||||
if (title.isNotEmpty) 'title': title,
|
||||
if (ref != null) 'ref': ref!.toJson(),
|
||||
'at': at.toUtc().toIso8601String(),
|
||||
};
|
||||
|
||||
static WatchEntry? fromJson(Object? j) {
|
||||
if (j is! Map) return null;
|
||||
final at = DateTime.tryParse('${j['at']}');
|
||||
if (at == null) return null;
|
||||
final resume = j['resumeAt'];
|
||||
MediaRef? ref;
|
||||
try {
|
||||
ref = MediaRef.fromJson(j['ref']);
|
||||
} catch (_) {}
|
||||
return WatchEntry(
|
||||
played: j['played'] == true,
|
||||
resumeAt: resume is num && resume.isFinite && resume > 0 ? resume.toDouble() : 0,
|
||||
title: j['title'] is String ? j['title'] as String : '',
|
||||
ref: ref,
|
||||
at: at,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The device's watch history: [entry] per key, [record] from the
|
||||
/// tracker. [revision] bumps on every change (pages that show it listen).
|
||||
class WatchHistory {
|
||||
final Map<String, Object?> _memory = {};
|
||||
/// False: memory only (tests).
|
||||
final bool persist;
|
||||
Box? _box;
|
||||
Future<void>? _load;
|
||||
final revision = signal(0);
|
||||
|
||||
WatchHistory({this.persist = true});
|
||||
|
||||
Future<void> ensureLoaded() => _load ??= () async {
|
||||
if (!persist) return;
|
||||
try {
|
||||
final box = _box = await Hive.openBox(watchHistoryBoxName);
|
||||
for (final e in _memory.entries) {
|
||||
await box.put(e.key, e.value);
|
||||
}
|
||||
_memory.clear();
|
||||
revision.value++;
|
||||
} catch (e, st) {
|
||||
debugPrint('open watch history failed: $e\n$st');
|
||||
}
|
||||
}();
|
||||
|
||||
WatchEntry? entry(WatchKey key) => WatchEntry.fromJson(_box?.get(key.value) ?? _memory[key.value]);
|
||||
|
||||
/// A closed watch: played -> played (the resume point goes); part way
|
||||
/// -> its resume point, unless it was played before (it stays played);
|
||||
/// a peek changes nothing. Jellyfin items are not kept here.
|
||||
void record(WatchRecord r) {
|
||||
if (r.key.isJellyfin) return;
|
||||
final before = entry(r.key);
|
||||
final WatchEntry next;
|
||||
switch (r.verdict.outcome) {
|
||||
case WatchOutcome.played || WatchOutcome.keepPlayed:
|
||||
next = WatchEntry(played: true, title: r.title, ref: r.ref, at: r.at);
|
||||
case WatchOutcome.partial:
|
||||
next = WatchEntry(
|
||||
played: before?.played ?? false,
|
||||
resumeAt: (before?.played ?? false) ? 0 : r.verdict.resumeAt ?? 0,
|
||||
title: r.title,
|
||||
ref: r.ref,
|
||||
at: r.at);
|
||||
case WatchOutcome.peek:
|
||||
return;
|
||||
}
|
||||
final json = next.toJson();
|
||||
final box = _box;
|
||||
if (box == null) {
|
||||
_memory[r.key.value] = json;
|
||||
} else {
|
||||
box.put(r.key.value, json).catchError((Object e) => debugPrint('watch history put ${r.key}: $e'));
|
||||
}
|
||||
revision.value++;
|
||||
}
|
||||
|
||||
/// Every entry, newest first.
|
||||
List<(WatchKey, WatchEntry)> get entries {
|
||||
final raw = <String, Object?>{..._memory, if (_box != null) for (final k in _box!.keys) '$k': _box!.get(k)};
|
||||
final out = [
|
||||
for (final e in raw.entries)
|
||||
if (WatchEntry.fromJson(e.value) case final w?) (WatchKey._(e.key), w),
|
||||
];
|
||||
out.sort((a, b) => b.$2.at.compareTo(a.$2.at));
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import 'watch_history.dart';
|
||||
|
||||
// Watch tracking (ARCHITECTURE.md "Watch tracking", D3): one watch per
|
||||
// media of the server (its `mediaId`), fed every second by the session
|
||||
// with what this app sees, judged by core's `WatchSession` when it ends.
|
||||
// The verdict goes to the device history (files and links) and out on
|
||||
// [WatchTracker.records] (the seam for AniList later); Jellyfin's
|
||||
// played/resume reports read the same session.
|
||||
|
||||
final _log = createLogger('watch');
|
||||
|
||||
/// What the session hands the tracker each second.
|
||||
class WatchSample {
|
||||
/// The server's media and its id.
|
||||
final int mediaId;
|
||||
final Map<String, Object?>? media;
|
||||
/// This app's own source of it: the file it put up, the link it put up.
|
||||
final String? ownFile;
|
||||
final String? ownLink;
|
||||
final double position;
|
||||
/// The media plays here: the server plays it and this player has it.
|
||||
final bool playing;
|
||||
/// This viewer is In (`in`): not away, joining or gone.
|
||||
final bool present;
|
||||
/// The player's duration (0 while unknown) and where the ending starts.
|
||||
final double duration;
|
||||
final double? endingStart;
|
||||
final DateTime at;
|
||||
const WatchSample({
|
||||
required this.mediaId,
|
||||
required this.media,
|
||||
required this.position,
|
||||
required this.playing,
|
||||
required this.present,
|
||||
required this.at,
|
||||
this.ownFile,
|
||||
this.ownLink,
|
||||
this.duration = 0,
|
||||
this.endingStart,
|
||||
});
|
||||
}
|
||||
|
||||
class WatchTracker {
|
||||
final WatchHistory history;
|
||||
WatchTracker({WatchHistory? history}) : history = history ?? WatchHistory(persist: false);
|
||||
|
||||
final _records = StreamController<WatchRecord>.broadcast();
|
||||
|
||||
/// Every closed watch (peeks too): the device history takes files and
|
||||
/// links; AniList progress later listens here.
|
||||
Stream<WatchRecord> get records => _records.stream;
|
||||
|
||||
int _mediaId = -1;
|
||||
WatchKey? _key;
|
||||
String _title = '';
|
||||
MediaRef? _ref;
|
||||
double _runtime = 0;
|
||||
double? _endingStart;
|
||||
WatchSession? _session;
|
||||
|
||||
/// The watch of the media playing (null before the first sample).
|
||||
WatchSession? get session => _session;
|
||||
WatchKey? get key => _key;
|
||||
|
||||
/// One second of the session. A new media closes the watch of the one
|
||||
/// before.
|
||||
void sample(WatchSample s) {
|
||||
if (s.mediaId != _mediaId) {
|
||||
close(at: s.at);
|
||||
if (s.media == null) return;
|
||||
_open(s);
|
||||
}
|
||||
final session = _session;
|
||||
if (session == null) return;
|
||||
// A file or link this app put up is known once its swap landed.
|
||||
final key = WatchKey.of(s.media, ownFile: s.ownFile, ownLink: s.ownLink);
|
||||
if (key != null && key != _key && !(_key?.isJellyfin ?? false)) _key = key;
|
||||
if (s.duration > 0) _runtime = s.duration;
|
||||
_endingStart = s.endingStart;
|
||||
session.sample(s.position, playing: s.playing, present: s.present, at: s.at);
|
||||
}
|
||||
|
||||
void _open(WatchSample s) {
|
||||
final key = WatchKey.of(s.media, ownFile: s.ownFile, ownLink: s.ownLink);
|
||||
final before = key == null ? null : history.entry(key);
|
||||
_mediaId = s.mediaId;
|
||||
_key = key;
|
||||
_title = '${s.media?['name'] ?? ''}';
|
||||
try {
|
||||
_ref = MediaRef.fromJson(s.media?['ref']);
|
||||
} catch (_) {
|
||||
_ref = null;
|
||||
}
|
||||
final d = s.media?['duration'];
|
||||
_runtime = d is num && d > 0 ? d.toDouble() : 0;
|
||||
_endingStart = null;
|
||||
_session = WatchSession(playedBefore: before?.played ?? false, resumeBefore: before?.resumeAt ?? 0);
|
||||
}
|
||||
|
||||
/// The same media goes on under another [mediaId] (a restarted server
|
||||
/// numbers it anew): the watch carries on.
|
||||
void renumber(int from, int to) {
|
||||
if (_mediaId == from) _mediaId = to;
|
||||
}
|
||||
|
||||
/// What Jellyfin says about the item before this opening (its played
|
||||
/// state and resume point, read once it is open).
|
||||
void before({required bool played, double resumeAt = 0}) {
|
||||
final session = _session;
|
||||
if (session == null) return;
|
||||
session
|
||||
..playedBefore = played
|
||||
..resumeBefore = resumeAt;
|
||||
}
|
||||
|
||||
/// The verdict so far (the runtime as last seen); null without a watch.
|
||||
WatchVerdict? get verdict => _session?.verdict(runtime: _runtime, endingStart: _endingStart);
|
||||
|
||||
/// Records the watch as it stands (the end of the media; it may go on
|
||||
/// after a seek back and be recorded again).
|
||||
WatchRecord? checkpoint({DateTime? at}) {
|
||||
final session = _session, key = _key;
|
||||
if (session == null || key == null) return null;
|
||||
final v = session.verdict(runtime: _runtime, endingStart: _endingStart);
|
||||
final r = WatchRecord(
|
||||
key: key,
|
||||
title: _title,
|
||||
ref: _ref,
|
||||
verdict: v,
|
||||
timeIn: session.timeIn,
|
||||
runtime: _runtime,
|
||||
at: at ?? DateTime.now().toUtc());
|
||||
_log.info('$key: ${v.outcome.name} (${session.timeIn.round()} s In of ${_runtime.round()} s)');
|
||||
history.record(r);
|
||||
_records.add(r);
|
||||
return r;
|
||||
}
|
||||
|
||||
/// The watch ends (another media, leaving): recorded and forgotten.
|
||||
WatchRecord? close({DateTime? at}) {
|
||||
final r = _session == null ? null : checkpoint(at: at);
|
||||
_mediaId = -1;
|
||||
_key = null;
|
||||
_session = null;
|
||||
_ref = null;
|
||||
_runtime = 0;
|
||||
_endingStart = null;
|
||||
return r;
|
||||
}
|
||||
|
||||
/// The media the current watch is of (-1: none).
|
||||
int get mediaId => _mediaId;
|
||||
|
||||
Future<void> dispose() => _records.close();
|
||||
}
|
||||
Reference in new issue
Block a user