Jellyfin end to end: per-viewer playback, progress, episodes, swap browser
- Host/swap send refs only; pasted Jellyfin links become refs; the host hands the relay its own app-obtained credential. - Each viewer resolves with their own account (token in a header) or via the relay, else a clear blocked card with Link account. - Per-account progress reporting (Playing/Progress/Stopped), 401 marks the account expired. - Previous/next episode in the controls (< > / Shift+P/N), floating Next episode pill near the end, auto-advance countdown (one designated swapper), track carry-over per series. - Swap panel uses the shared MediaPicker; browser location remembered per server and opens on the current series. - Fix: states are applied one at a time (a stale pause could land after a newer play). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
773a04369e
commit
ae4edc29e8
33 files changed
+3541
-175
No files matched your search
@@ -4,8 +4,14 @@ import 'package:flutter/widgets.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../jellyfin/accounts.dart';
|
||||
import '../jellyfin/progress.dart';
|
||||
import '../jellyfin/session_media.dart';
|
||||
import '../jellyfin/track_prefs.dart';
|
||||
import '../playback/playback_controller.dart';
|
||||
import '../screens/home/picked_media.dart';
|
||||
import '../sfx/sfx.dart';
|
||||
import '../state/settings.dart';
|
||||
import '../sync/state_diff.dart';
|
||||
import '../sync/sync_client.dart';
|
||||
import '../sync/user_seek.dart';
|
||||
@@ -21,19 +27,7 @@ class ConnectCancelled implements Exception {
|
||||
|
||||
/// Display name for a link: last path segment, decoded, never the query
|
||||
/// (Jellyfin-style links carry api_key there and names are broadcast).
|
||||
String linkDisplayName(String url) {
|
||||
final uri = Uri.tryParse(url.trim());
|
||||
if (uri == null) return 'link';
|
||||
final segs = uri.pathSegments.where((s) => s.isNotEmpty).toList();
|
||||
if (segs.isNotEmpty) {
|
||||
try {
|
||||
return Uri.decodeComponent(segs.last);
|
||||
} catch (_) {
|
||||
return segs.last;
|
||||
}
|
||||
}
|
||||
return uri.host.isNotEmpty ? uri.host : 'link';
|
||||
}
|
||||
String linkDisplayName(String url) => linkName(url);
|
||||
|
||||
class ConnectParams {
|
||||
final String host;
|
||||
@@ -45,6 +39,9 @@ class ConnectParams {
|
||||
final String linkUrl;
|
||||
/// Sent in hello by the hosting app's own client (see AppConfig.hostKey).
|
||||
final String hostKey;
|
||||
/// Put up with a `swap` right after joining (a Jellyfin ref or a link);
|
||||
/// wins over [linkUrl].
|
||||
final HelloMedia? media;
|
||||
const ConnectParams({
|
||||
required this.host,
|
||||
required this.port,
|
||||
@@ -54,9 +51,37 @@ class ConnectParams {
|
||||
this.mediaPath = '',
|
||||
this.linkUrl = '',
|
||||
this.hostKey = '',
|
||||
this.media,
|
||||
});
|
||||
|
||||
ConnectParams withMedia(HelloMedia? media) => ConnectParams(
|
||||
host: host,
|
||||
port: port,
|
||||
secret: secret,
|
||||
name: name,
|
||||
avatar: avatar,
|
||||
mediaPath: mediaPath,
|
||||
linkUrl: media == null ? linkUrl : '',
|
||||
hostKey: hostKey,
|
||||
media: media,
|
||||
);
|
||||
}
|
||||
|
||||
/// The end-of-episode countdown (auto-advance). Every client with the
|
||||
/// setting on shows it; only [swapper] puts the next episode up.
|
||||
class AutoAdvance {
|
||||
final int mediaId;
|
||||
final int secondsLeft;
|
||||
final EpisodeLink next;
|
||||
/// This client performs the swap (see [autoAdvanceSwapper]).
|
||||
final bool swapper;
|
||||
const AutoAdvance(
|
||||
{required this.mediaId, required this.secondsLeft, required this.next, required this.swapper});
|
||||
}
|
||||
|
||||
/// Seconds of the end-of-episode countdown.
|
||||
const autoAdvanceSecs = 10;
|
||||
|
||||
class ToastItem {
|
||||
final int id;
|
||||
final String text;
|
||||
@@ -76,6 +101,13 @@ String? stateMediaUrl(StateBroadcast state) {
|
||||
|
||||
class SessionCoordinator {
|
||||
final SfxService sfx;
|
||||
/// Linked Jellyfin servers: how this viewer plays Jellyfin refs.
|
||||
final JellyfinAccounts jellyfin;
|
||||
/// Audio/subtitle carry-over between Jellyfin items.
|
||||
final TrackPrefs trackPrefs;
|
||||
/// Set by the hosting app: makes a local file the hosted one (File swap);
|
||||
/// null when this app is not the host.
|
||||
Future<({String name, int size})?> Function(String path)? hostFile;
|
||||
final SyncClient sync = SyncClient();
|
||||
final PlaybackController playback = PlaybackController();
|
||||
final StateDiff diff = StateDiff();
|
||||
@@ -89,6 +121,21 @@ class SessionCoordinator {
|
||||
final kickReason = signal<String?>(null);
|
||||
final starving = signal<String?>(null);
|
||||
|
||||
/// The current media's Jellyfin ref (null for links and files).
|
||||
final currentRef = signal<JellyfinRef?>(null);
|
||||
/// How this viewer plays [currentRef]; null while resolving.
|
||||
final jellyfinPlan = signal<JellyfinPlan?>(null);
|
||||
/// Set when this viewer cannot play the Jellyfin media: no account and
|
||||
/// no relay. The session shows it with a way to link an account.
|
||||
final jellyfinBlocked = signal<BlockedPlay?>(null);
|
||||
/// Neighbouring episodes this viewer can put up (own account only).
|
||||
final previousEpisode = signal<EpisodeLink?>(null);
|
||||
final nextEpisode = signal<EpisodeLink?>(null);
|
||||
/// The floating "Next episode" pill near the end of an episode.
|
||||
final nextPromptVisible = signal(false);
|
||||
/// End-of-episode countdown; null when none runs here.
|
||||
final autoAdvance = signal<AutoAdvance?>(null);
|
||||
|
||||
late final Computed<MemberView?> selfMember = computed(() {
|
||||
final r = serverState.value;
|
||||
if (r == null || sync.memberId.isEmpty) return null;
|
||||
@@ -139,8 +186,25 @@ class SessionCoordinator {
|
||||
int _toastId = 0;
|
||||
ConnectParams? _params;
|
||||
bool _everConnected = false;
|
||||
Map<String, String>? _openHeaders;
|
||||
|
||||
SessionCoordinator({this.sfx = const NoopSfxService()});
|
||||
// Jellyfin: the resolved ref, the mediaId it belongs to, a generation
|
||||
// that stale resolutions check, and the progress reporter.
|
||||
JellyfinRef? _jfRef;
|
||||
int _jfMediaId = -1;
|
||||
int _jfGen = 0;
|
||||
JellyfinProgressReporter? _reporter;
|
||||
PlayerSnapshot? _lastSnap;
|
||||
bool _jfToastShown = false;
|
||||
Timer? _advanceTimer;
|
||||
int _advanceDoneFor = -1;
|
||||
String _blockedKey = '';
|
||||
bool _jellyfinListening = false;
|
||||
|
||||
SessionCoordinator(
|
||||
{this.sfx = const NoopSfxService(), JellyfinAccounts? jellyfin, TrackPrefs? trackPrefs})
|
||||
: jellyfin = jellyfin ?? JellyfinAccounts.instance,
|
||||
trackPrefs = trackPrefs ?? TrackPrefs();
|
||||
|
||||
/// Player lifetime = one session: created here on connect, disposed by
|
||||
/// [leave]. Never reused across sessions (a GPU-side video freeze sticks
|
||||
@@ -175,9 +239,10 @@ class SessionCoordinator {
|
||||
try {
|
||||
await playback.recreate();
|
||||
final url = _openUrl;
|
||||
final headers = _openHeaders;
|
||||
_openUrl = null;
|
||||
_clearPendingSeek();
|
||||
if (url != null) await _openMedia(url);
|
||||
if (url != null) await _openMedia(url, headers: headers);
|
||||
} catch (e, st) {
|
||||
debugPrint('video reload failed: $e\n$st');
|
||||
health.value =
|
||||
@@ -221,6 +286,12 @@ class SessionCoordinator {
|
||||
_seekGate.reset();
|
||||
_clearPendingSeek();
|
||||
_lastEventId = -1;
|
||||
_resetJellyfin();
|
||||
_jfToastShown = false;
|
||||
if (!_jellyfinListening) {
|
||||
_jellyfinListening = true;
|
||||
jellyfin.addListener(_onAccountsChanged);
|
||||
}
|
||||
markInput();
|
||||
_subscribe();
|
||||
feedLog.value = [];
|
||||
@@ -252,13 +323,10 @@ class SessionCoordinator {
|
||||
health.value = const HealthStatus(
|
||||
HealthSeverity.info, 'connected — waiting for host media…');
|
||||
}
|
||||
if (params.linkUrl.isNotEmpty) {
|
||||
sync.sendSwap(HelloMedia(
|
||||
name: linkDisplayName(params.linkUrl),
|
||||
size: 0,
|
||||
duration: 0,
|
||||
url: params.linkUrl));
|
||||
}
|
||||
final media = params.media ??
|
||||
(params.linkUrl.isEmpty ? null : await mediaForLink(jellyfin, params.linkUrl));
|
||||
if (epoch != _epoch) throw const ConnectCancelled();
|
||||
if (media != null) sync.sendSwap(media);
|
||||
_startTicker();
|
||||
} catch (e) {
|
||||
if (epoch != _epoch) throw const ConnectCancelled();
|
||||
@@ -286,19 +354,23 @@ class SessionCoordinator {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _openMedia(String url) async {
|
||||
if (url == _openUrl) return;
|
||||
/// Opens [url] (with [headers] on every request); false on failure.
|
||||
Future<bool> _openMedia(String url, {Map<String, String>? headers}) async {
|
||||
if (url == _openUrl) return true;
|
||||
_openUrl = url;
|
||||
_openHeaders = headers;
|
||||
diff.reset();
|
||||
_opening = true;
|
||||
try {
|
||||
await playback.openUrl(url);
|
||||
await playback.openUrl(url, headers: headers);
|
||||
health.value =
|
||||
const HealthStatus(HealthSeverity.ok, 'connected');
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('open media failed: $e');
|
||||
health.value =
|
||||
HealthStatus(HealthSeverity.error, 'cannot open media: $e');
|
||||
return false;
|
||||
} finally {
|
||||
_opening = false;
|
||||
}
|
||||
@@ -308,28 +380,73 @@ class SessionCoordinator {
|
||||
return 'http://$_wsHost:$_wsPort/m/default/${sync.token}/${Uri.encodeComponent(name)}';
|
||||
}
|
||||
|
||||
Future<void> _onState(StateBroadcast r) async {
|
||||
StateBroadcast? _queuedState;
|
||||
bool _applying = false;
|
||||
|
||||
/// States are applied one at a time, newest first: two applies running
|
||||
/// interleaved could land an older pause after a newer play (seen with
|
||||
/// three viewers readying at once). A state that arrives meanwhile
|
||||
/// replaces any still waiting.
|
||||
void _onState(StateBroadcast r) {
|
||||
_lastStateAt = DateTime.now().toUtc();
|
||||
serverState.value = r;
|
||||
_queuedState = r;
|
||||
if (_applying) return;
|
||||
_applying = true;
|
||||
() async {
|
||||
try {
|
||||
while (_queuedState != null) {
|
||||
final next = _queuedState!;
|
||||
_queuedState = null;
|
||||
try {
|
||||
await _applyState(next);
|
||||
} catch (e, st) {
|
||||
debugPrint('apply state failed: $e\n$st');
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
_applying = false;
|
||||
}
|
||||
}();
|
||||
}
|
||||
|
||||
Future<void> _applyState(StateBroadcast r) async {
|
||||
_trackSelfReady(r);
|
||||
_seekGate.observe(r, sync.memberId);
|
||||
final newEvent = r.eventId != _lastEventId;
|
||||
_lastEventId = r.eventId;
|
||||
if (r.mediaId != _mediaId) {
|
||||
_mediaId = r.mediaId;
|
||||
var url = stateMediaUrl(r);
|
||||
if ((url == null || url.isEmpty) && sync.token.isNotEmpty) {
|
||||
final name = (r.media?['name'] as String?) ?? '';
|
||||
if (name.isNotEmpty) url = _fileUrlFor(name);
|
||||
}
|
||||
if (url != null && url.isNotEmpty) {
|
||||
await _openMedia(url);
|
||||
} else {
|
||||
_openUrl = null;
|
||||
final ref = refOfStateMedia(r.media);
|
||||
// Back after a dropped connection on the same item played with our
|
||||
// own account: nothing to resolve or reopen.
|
||||
final same = ref != null &&
|
||||
r.mediaId == _jfMediaId &&
|
||||
sameJellyfinItem(ref, _jfRef) &&
|
||||
jellyfinPlan.peek() is OwnPlay;
|
||||
if (!same) {
|
||||
_seekGate.reset();
|
||||
_clearPendingSeek();
|
||||
_leaveJellyfinItem();
|
||||
if (ref != null) {
|
||||
await _playJellyfin(ref, r);
|
||||
} else {
|
||||
var url = stateMediaUrl(r);
|
||||
if ((url == null || url.isEmpty) && sync.token.isNotEmpty) {
|
||||
final name = (r.media?['name'] as String?) ?? '';
|
||||
if (name.isNotEmpty) url = _fileUrlFor(name);
|
||||
}
|
||||
if (url != null && url.isNotEmpty) {
|
||||
await _openMedia(url);
|
||||
} else {
|
||||
_openUrl = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
_seekGate.reset();
|
||||
_clearPendingSeek();
|
||||
}
|
||||
_jellyfinTransport(r);
|
||||
if (!_playerReady || _openUrl == null) {
|
||||
_wasPlayAllowed = false;
|
||||
_updateStarving(r, 0);
|
||||
@@ -475,6 +592,7 @@ class SessionCoordinator {
|
||||
// Stop, not pause: a paused player keeps filling its cache from the
|
||||
// host's media URL.
|
||||
_openUrl = null;
|
||||
await _stopJellyfin();
|
||||
try {
|
||||
await playback.stop();
|
||||
} catch (_) {}
|
||||
@@ -504,8 +622,10 @@ class SessionCoordinator {
|
||||
eventId: r.eventId,
|
||||
duration: known ? playback.durationSecs : 0,
|
||||
mediaId: known ? r.mediaId : 0,
|
||||
jf: _jfAccessFor(r),
|
||||
);
|
||||
_checkSeekLanded(r);
|
||||
unawaited(_tickJellyfin(r));
|
||||
}
|
||||
if (!_manualClose &&
|
||||
kickReason.peek() == null &&
|
||||
@@ -661,10 +781,362 @@ class SessionCoordinator {
|
||||
sync.sendKick(memberId, reason: reason);
|
||||
}
|
||||
|
||||
/// A pasted link: a recognised Jellyfin item link goes up as a ref
|
||||
/// (see [mediaForLink]), anything else as the link.
|
||||
Future<void> swapLink(String url) async {
|
||||
markInput();
|
||||
sync.sendSwap(HelloMedia(
|
||||
name: linkDisplayName(url), size: 0, duration: 0, url: url));
|
||||
sync.sendSwap(await mediaForLink(jellyfin, url));
|
||||
}
|
||||
|
||||
/// A Jellyfin pick (browser, next / previous episode) goes up as a ref.
|
||||
void swapJellyfin(JellyfinMedia media) {
|
||||
markInput();
|
||||
sync.sendSwap(mediaForPick(media));
|
||||
}
|
||||
|
||||
/// The host's File swap: the server takes the file, then the swap puts
|
||||
/// it up. Returns an error text, or null when sent.
|
||||
Future<String?> swapFile(String path) async {
|
||||
markInput();
|
||||
final host = hostFile;
|
||||
if (host == null) return 'only the host can share a file';
|
||||
final file = await host(path);
|
||||
if (file == null) return 'file not found — check the path';
|
||||
sync.sendSwap(HelloMedia(name: file.name, size: file.size, duration: 0));
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Any picker selection.
|
||||
Future<String?> swapPicked(PickedMedia media) async {
|
||||
switch (media) {
|
||||
case LinkMedia(:final url):
|
||||
await swapLink(url);
|
||||
case JellyfinMedia():
|
||||
swapJellyfin(media);
|
||||
case FileMedia(:final path):
|
||||
return swapFile(path);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void playNextEpisode() {
|
||||
final n = nextEpisode.peek();
|
||||
if (n != null) swapJellyfin(n.pick);
|
||||
}
|
||||
|
||||
void playPreviousEpisode() {
|
||||
final p = previousEpisode.peek();
|
||||
if (p != null) swapJellyfin(p.pick);
|
||||
}
|
||||
|
||||
/// Hides the countdown here; this client will not auto-advance this
|
||||
/// item (if it is the swapper, nobody does).
|
||||
void cancelAutoAdvance() {
|
||||
_advanceTimer?.cancel();
|
||||
_advanceTimer = null;
|
||||
final a = autoAdvance.peek();
|
||||
if (a != null) _advanceDoneFor = a.mediaId;
|
||||
autoAdvance.value = null;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- jellyfin
|
||||
|
||||
void _pushToast(String text) {
|
||||
_toastId++;
|
||||
toasts.value = [
|
||||
...toasts.peek(),
|
||||
ToastItem(id: _toastId, text: text, at: DateTime.now().toUtc())
|
||||
];
|
||||
}
|
||||
|
||||
void _resetJellyfin() {
|
||||
_jfGen++;
|
||||
_jfRef = null;
|
||||
_jfMediaId = -1;
|
||||
_reporter = null;
|
||||
_lastSnap = null;
|
||||
_openHeaders = null;
|
||||
_blockedKey = '';
|
||||
currentRef.value = null;
|
||||
jellyfinPlan.value = null;
|
||||
jellyfinBlocked.value = null;
|
||||
previousEpisode.value = null;
|
||||
nextEpisode.value = null;
|
||||
nextPromptVisible.value = false;
|
||||
_advanceTimer?.cancel();
|
||||
_advanceTimer = null;
|
||||
_advanceDoneFor = -1;
|
||||
autoAdvance.value = null;
|
||||
}
|
||||
|
||||
/// The current item is being replaced: Stopped goes out (not awaited:
|
||||
/// reports are sent in order), per-item state clears.
|
||||
void _leaveJellyfinItem() {
|
||||
final reporter = _reporter;
|
||||
final snap = _snapNow();
|
||||
_resetJellyfin();
|
||||
if (reporter != null && snap != null) unawaited(reporter.stop(snap));
|
||||
}
|
||||
|
||||
/// Leave / kick / window close: Stopped, bounded so leaving never hangs.
|
||||
Future<void> _stopJellyfin() async {
|
||||
final reporter = _reporter;
|
||||
final snap = _snapNow();
|
||||
_resetJellyfin();
|
||||
if (reporter == null || snap == null) return;
|
||||
try {
|
||||
await reporter.stop(snap).timeout(const Duration(milliseconds: 1500));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// The last sampled tracks with the position as of now.
|
||||
PlayerSnapshot? _snapNow() {
|
||||
if (!_playerReady) return _lastSnap;
|
||||
final last = _lastSnap;
|
||||
return PlayerSnapshot(
|
||||
position: playback.positionSecs,
|
||||
paused: !playback.player.state.playing,
|
||||
aid: last?.aid,
|
||||
sid: last?.sid,
|
||||
);
|
||||
}
|
||||
|
||||
Future<PlayerSnapshot?> _snapshot() async {
|
||||
if (!_playerReady) return null;
|
||||
final ids = await playback.currentTrackIds();
|
||||
if (!_playerReady) return null;
|
||||
return _lastSnap = PlayerSnapshot(
|
||||
position: playback.positionSecs,
|
||||
paused: !playback.player.state.playing,
|
||||
aid: ids.aid,
|
||||
sid: ids.sid,
|
||||
);
|
||||
}
|
||||
|
||||
String? _relayUrlFor(JellyfinRef ref) {
|
||||
if (sync.token.isEmpty) return null;
|
||||
final welcome = sync.relayUrl;
|
||||
if (welcome != null &&
|
||||
welcome.contains('/${sync.token}/') &&
|
||||
welcome.endsWith('/${Uri.encodeComponent(ref.itemId)}')) {
|
||||
return welcome;
|
||||
}
|
||||
return relayUrlFor(_wsHost, _wsPort, sync.token, ref.itemId);
|
||||
}
|
||||
|
||||
Map<String, bool>? _jfAccessFor(StateBroadcast r) {
|
||||
final ref = _jfRef;
|
||||
final access = jellyfinPlan.peek()?.jfAccess;
|
||||
if (ref == null || access == null || r.mediaId != _jfMediaId) return null;
|
||||
return {ref.serverId: access};
|
||||
}
|
||||
|
||||
/// Resolves [ref] for this viewer and opens it: own account (direct
|
||||
/// play, progress reports, neighbouring episodes), the relay, or the
|
||||
/// blocked state. Then carries the audio/subtitle choice over.
|
||||
Future<void> _playJellyfin(JellyfinRef ref, StateBroadcast r) async {
|
||||
final gen = ++_jfGen;
|
||||
_jfRef = ref;
|
||||
_jfMediaId = r.mediaId;
|
||||
currentRef.value = ref;
|
||||
// Until it resolves nothing is open: no syncing the previous media.
|
||||
final prevUrl = _openUrl;
|
||||
_openUrl = null;
|
||||
_opening = true;
|
||||
if (_playerReady) {
|
||||
try {
|
||||
await playback.player.pause();
|
||||
} catch (_) {}
|
||||
}
|
||||
final JellyfinPlan plan;
|
||||
try {
|
||||
plan = await resolveJellyfinRef(jellyfin, ref, relay: r.relay, relayUrl: _relayUrlFor(ref));
|
||||
} finally {
|
||||
if (gen == _jfGen) _opening = false;
|
||||
}
|
||||
if (gen != _jfGen) return;
|
||||
jellyfinPlan.value = plan;
|
||||
switch (plan) {
|
||||
case BlockedPlay():
|
||||
jellyfinBlocked.value = plan;
|
||||
_blockedKey = _accountKey(ref.serverId);
|
||||
try {
|
||||
await playback.stop();
|
||||
} catch (_) {}
|
||||
return;
|
||||
case RelayPlay(:final url):
|
||||
jellyfinBlocked.value = null;
|
||||
// Same relay URL (resumed member): the player still has it.
|
||||
if (url == prevUrl) {
|
||||
_openUrl = url;
|
||||
return;
|
||||
}
|
||||
if (!await _openMedia(url) || gen != _jfGen) return;
|
||||
case OwnPlay(:final url, :final headers):
|
||||
jellyfinBlocked.value = null;
|
||||
if (!await _openMedia(url, headers: headers) || gen != _jfGen) return;
|
||||
final reporter = JellyfinProgressReporter(
|
||||
client: plan.client,
|
||||
itemId: ref.itemId,
|
||||
source: plan.source,
|
||||
playSessionId: plan.info.playSessionId,
|
||||
onUnauthorized: () => jellyfin.markExpired(ref.serverId),
|
||||
onError: (e) {
|
||||
if (_jfToastShown) return;
|
||||
_jfToastShown = true;
|
||||
_pushToast('Jellyfin progress not saved: ${e is JellyfinException && e.isUnauthorized ? 'sign-in expired' : 'server unreachable'}');
|
||||
},
|
||||
);
|
||||
unawaited(_findEpisodes(plan, gen));
|
||||
// Playing carries the tracks: wait (bounded) until the file is in.
|
||||
await playback.waitForTracks();
|
||||
if (gen != _jfGen) return;
|
||||
final snap = await _snapshot();
|
||||
if (gen != _jfGen || snap == null) return;
|
||||
// Installed once started: the per-state restart (after the end)
|
||||
// must not send the first Playing without the tracks.
|
||||
_reporter = reporter..start(snap);
|
||||
}
|
||||
await _applyTrackChoice(plan, gen);
|
||||
}
|
||||
|
||||
Future<void> _findEpisodes(OwnPlay plan, int gen) async {
|
||||
final around = await findAdjacentEpisodes(plan);
|
||||
if (gen != _jfGen) return;
|
||||
previousEpisode.value = around.previous;
|
||||
nextEpisode.value = around.next;
|
||||
final r = serverState.peek();
|
||||
if (r != null) _jellyfinTransport(r);
|
||||
}
|
||||
|
||||
Future<void> _applyTrackChoice(JellyfinPlan plan, int gen) async {
|
||||
final own = plan is OwnPlay ? plan : null;
|
||||
final choice = await trackPrefs.load(seriesId: own?.item?.seriesId);
|
||||
if (choice == null || gen != _jfGen || !_playerReady) return;
|
||||
final tracks = await playback.waitForTracks();
|
||||
if (gen != _jfGen) return;
|
||||
final pick = chooseTracks(choice, source: own?.source, tracks: tracks);
|
||||
await playback.selectTracks(aid: pick.aid, sid: pick.sid, subtitlesOff: pick.subtitlesOff);
|
||||
}
|
||||
|
||||
/// A track picked in the player: applied, remembered for the next
|
||||
/// Jellyfin item (per series and overall), reported.
|
||||
Future<void> selectTrack({int? aid, int? sid, bool subtitlesOff = false}) async {
|
||||
markInput();
|
||||
await playback.selectTracks(aid: aid, sid: sid, subtitlesOff: subtitlesOff);
|
||||
final ids = await playback.currentTrackIds();
|
||||
final plan = jellyfinPlan.peek();
|
||||
final own = plan is OwnPlay ? plan : null;
|
||||
await trackPrefs.save(
|
||||
TrackChoice.of(aid: ids.aid, sid: ids.sid, source: own?.source, tracks: playback.tracks),
|
||||
seriesId: own?.item?.seriesId);
|
||||
final snap = await _snapshot();
|
||||
if (snap != null) _reporter?.update(snap, force: true);
|
||||
}
|
||||
|
||||
Future<void> _tickJellyfin(StateBroadcast r) async {
|
||||
final reporter = _reporter;
|
||||
if (_jfRef == null || r.mediaId != _jfMediaId) return;
|
||||
final snap = await _snapshot();
|
||||
if (snap == null) return;
|
||||
if (reporter != null && identical(reporter, _reporter) && r.transport != 'finished') {
|
||||
reporter.update(snap);
|
||||
}
|
||||
final next = nextEpisode.peek();
|
||||
final duration = playback.durationSecs;
|
||||
var show = false;
|
||||
if (next != null && duration > 0 && autoAdvance.peek() == null && r.transport != 'finished') {
|
||||
final item = (jellyfinPlan.peek() as OwnPlay?)?.item;
|
||||
final at = nextPromptStart(item, Duration(milliseconds: (duration * 1000).round()));
|
||||
show = snap.position * 1000 >= at.inMilliseconds;
|
||||
}
|
||||
nextPromptVisible.value = show;
|
||||
}
|
||||
|
||||
/// Per state: played to the end -> Stopped (Jellyfin marks it watched)
|
||||
/// and the auto-advance countdown; seeked back from the end -> Playing
|
||||
/// again.
|
||||
void _jellyfinTransport(StateBroadcast r) {
|
||||
final ref = _jfRef;
|
||||
if (ref == null || r.mediaId != _jfMediaId) return;
|
||||
final reporter = _reporter;
|
||||
final finished = r.transport == 'finished';
|
||||
if (reporter != null && _openUrl != null && !_opening) {
|
||||
final snap = _snapNow();
|
||||
if (finished && reporter.active && snap != null) {
|
||||
unawaited(reporter.stop(PlayerSnapshot(
|
||||
position: r.position, paused: true, aid: snap.aid, sid: snap.sid)));
|
||||
} else if (!finished && !reporter.active && snap != null) {
|
||||
reporter.start(snap);
|
||||
}
|
||||
}
|
||||
if (!finished) {
|
||||
if (autoAdvance.peek() != null) cancelAutoAdvance();
|
||||
_advanceDoneFor = -1;
|
||||
return;
|
||||
}
|
||||
nextPromptVisible.value = false;
|
||||
final next = nextEpisode.peek();
|
||||
if (next == null ||
|
||||
!AppSettings.instance.autoAdvance.peek() ||
|
||||
_advanceDoneFor == r.mediaId ||
|
||||
autoAdvance.peek()?.mediaId == r.mediaId) {
|
||||
return;
|
||||
}
|
||||
autoAdvance.value = AutoAdvance(
|
||||
mediaId: r.mediaId, secondsLeft: autoAdvanceSecs, next: next, swapper: _isSwapper(r, ref));
|
||||
_advanceTimer?.cancel();
|
||||
_advanceTimer = Timer.periodic(const Duration(seconds: 1), (_) => _advanceTick());
|
||||
}
|
||||
|
||||
bool _isSwapper(StateBroadcast r, JellyfinRef ref) =>
|
||||
autoAdvanceSwapper(r.members, ref.serverId) == sync.memberId;
|
||||
|
||||
void _advanceTick() {
|
||||
final a = autoAdvance.peek();
|
||||
final r = serverState.peek();
|
||||
final ref = _jfRef;
|
||||
if (a == null || r == null || ref == null || r.mediaId != a.mediaId || r.transport != 'finished') {
|
||||
cancelAutoAdvance();
|
||||
return;
|
||||
}
|
||||
final left = a.secondsLeft - 1;
|
||||
if (left > 0) {
|
||||
autoAdvance.value = AutoAdvance(
|
||||
mediaId: a.mediaId, secondsLeft: left, next: a.next, swapper: _isSwapper(r, ref));
|
||||
return;
|
||||
}
|
||||
// Decided on the latest state: one swapper among the clients counting.
|
||||
final swap = _isSwapper(r, ref);
|
||||
cancelAutoAdvance();
|
||||
if (swap) swapJellyfin(a.next.pick);
|
||||
}
|
||||
|
||||
String _accountKey(String serverId) =>
|
||||
'${jellyfin.account(serverId)?.token}|${jellyfin.statusOf(serverId).name}';
|
||||
|
||||
/// A blocked viewer links (or reconnects) the account: try again.
|
||||
void _onAccountsChanged() {
|
||||
final blocked = jellyfinBlocked.peek();
|
||||
if (blocked == null) return;
|
||||
final key = _accountKey(blocked.serverId);
|
||||
if (key == _blockedKey) return;
|
||||
_blockedKey = key;
|
||||
final status = jellyfin.statusOf(blocked.serverId);
|
||||
if (jellyfin.account(blocked.serverId) == null ||
|
||||
status == JellyfinAccountStatus.expired ||
|
||||
status == JellyfinAccountStatus.checking) {
|
||||
return;
|
||||
}
|
||||
retryJellyfin();
|
||||
}
|
||||
|
||||
/// "Try again" on the blocked card (and after linking an account).
|
||||
void retryJellyfin() {
|
||||
final r = serverState.peek();
|
||||
final ref = refOfStateMedia(r?.media);
|
||||
if (r == null || ref == null) return;
|
||||
unawaited(_playJellyfin(ref, r));
|
||||
}
|
||||
|
||||
void _scheduleReconnect({bool manual = false}) {
|
||||
@@ -790,6 +1262,7 @@ class SessionCoordinator {
|
||||
_seekTimer = null;
|
||||
_seekGate.reset();
|
||||
reconnecting.value = false;
|
||||
await _stopJellyfin();
|
||||
_playerReady = false;
|
||||
try {
|
||||
await playback.dispose();
|
||||
@@ -806,6 +1279,10 @@ class SessionCoordinator {
|
||||
|
||||
Future<void> dispose() async {
|
||||
await leave(silent: true);
|
||||
if (_jellyfinListening) {
|
||||
_jellyfinListening = false;
|
||||
jellyfin.removeListener(_onAccountsChanged);
|
||||
}
|
||||
await _stateSub?.cancel();
|
||||
await _feedSub?.cancel();
|
||||
await _kickSub?.cancel();
|
||||
|
||||
Reference in new issue
Block a user