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
@@ -23,6 +23,7 @@ const _keyDeviceId = 'deviceId';
|
||||
const _keyAccounts = 'accounts';
|
||||
const _keyDefault = 'defaultServer';
|
||||
const _keyLastPick = 'lastPick';
|
||||
const _keyBrowsePrefix = 'browse:';
|
||||
|
||||
/// One signed-in user on one server.
|
||||
class JellyfinAccount {
|
||||
@@ -124,10 +125,12 @@ abstract class JellyfinStore {
|
||||
}
|
||||
|
||||
class HiveJellyfinStore implements JellyfinStore {
|
||||
HiveJellyfinStore({this.boxName = jellyfinBoxName});
|
||||
final String boxName;
|
||||
Box? _box;
|
||||
|
||||
@override
|
||||
Future<void> open() async => _box ??= await Hive.openBox(jellyfinBoxName);
|
||||
Future<void> open() async => _box ??= await Hive.openBox(boxName);
|
||||
|
||||
@override
|
||||
Object? get(String key) => _box?.get(key);
|
||||
@@ -461,6 +464,26 @@ class JellyfinAccounts extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// The browser's last location on [serverId] (encoded route stack, see
|
||||
/// `browse_scope.dart`), restored by every browser and across restarts.
|
||||
Object? browseLocation(String serverId) {
|
||||
try {
|
||||
final v = _store.get('$_keyBrowsePrefix$serverId');
|
||||
return v is String ? _tryJson(v) : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> saveBrowseLocation(String serverId, Object? location) async {
|
||||
try {
|
||||
await _store.put(
|
||||
'$_keyBrowsePrefix$serverId', location == null ? null : jsonEncode(location));
|
||||
} catch (e) {
|
||||
debugPrint('save browse location failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- cache
|
||||
|
||||
/// Short-lived memo for browse calls, so going back does not refetch.
|
||||
|
||||
@@ -53,6 +53,94 @@ final class DetailRoute extends BrowseRoute {
|
||||
String get key => 'item:${item.id}';
|
||||
}
|
||||
|
||||
/// A route stack as JSON for [JellyfinAccounts.saveBrowseLocation]: only
|
||||
/// ids, names and the fields the pages start from (each page loads the
|
||||
/// full item itself).
|
||||
List<Map<String, Object?>> encodeBrowseStack(List<BrowseRoute> stack) => [
|
||||
for (final r in stack)
|
||||
switch (r) {
|
||||
HomeRoute() => {'r': 'home'},
|
||||
LibraryRoute(:final folder) => {'r': 'lib', ..._itemJson(folder)},
|
||||
SeriesRoute(:final series, :final seasonId) => {
|
||||
'r': 'series',
|
||||
..._itemJson(series),
|
||||
'seasonId': ?seasonId,
|
||||
},
|
||||
DetailRoute(:final item) => {'r': 'item', ..._itemJson(item)},
|
||||
}
|
||||
];
|
||||
|
||||
/// Inverse of [encodeBrowseStack]; always starts with Home. Null when
|
||||
/// [raw] is not a stack.
|
||||
List<BrowseRoute>? decodeBrowseStack(Object? raw) {
|
||||
if (raw is! List) return null;
|
||||
final out = <BrowseRoute>[const HomeRoute()];
|
||||
for (final e in raw) {
|
||||
if (e is! Map) return null;
|
||||
final item = _itemFrom(e);
|
||||
final route = switch (e['r']) {
|
||||
'home' => null,
|
||||
'lib' when item != null => LibraryRoute(item),
|
||||
'series' when item != null =>
|
||||
SeriesRoute(item, seasonId: e['seasonId'] is String ? e['seasonId'] as String : null),
|
||||
'item' when item != null => DetailRoute(item),
|
||||
_ => null,
|
||||
};
|
||||
if (route != null && out.every((r) => r.key != route.key)) out.add(route);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Map<String, Object?> _itemJson(JellyfinItem i) => {
|
||||
'id': i.id,
|
||||
'name': i.name,
|
||||
'type': i.type,
|
||||
if (i.isFolder) 'isFolder': true,
|
||||
'collectionType': ?i.collectionType,
|
||||
'seriesId': ?i.seriesId,
|
||||
'seriesName': ?i.seriesName,
|
||||
'seasonId': ?i.seasonId,
|
||||
'indexNumber': ?i.indexNumber,
|
||||
'parentIndexNumber': ?i.parentIndexNumber,
|
||||
};
|
||||
|
||||
JellyfinItem? _itemFrom(Map e) {
|
||||
String? s(String k) => e[k] is String && (e[k] as String).isNotEmpty ? e[k] as String : null;
|
||||
final id = s('id');
|
||||
if (id == null) return null;
|
||||
return JellyfinItem(
|
||||
id: id,
|
||||
name: s('name') ?? '',
|
||||
type: s('type') ?? '',
|
||||
isFolder: e['isFolder'] == true,
|
||||
collectionType: s('collectionType'),
|
||||
seriesId: s('seriesId'),
|
||||
seriesName: s('seriesName'),
|
||||
seasonId: e['r'] == 'series' ? null : s('seasonId'),
|
||||
indexNumber: e['indexNumber'] is int ? e['indexNumber'] as int : null,
|
||||
parentIndexNumber: e['parentIndexNumber'] is int ? e['parentIndexNumber'] as int : null,
|
||||
);
|
||||
}
|
||||
|
||||
/// Where a browser opens instead of the remembered location: a server and
|
||||
/// a route stack (e.g. the series of the episode playing).
|
||||
class BrowseStart {
|
||||
final String serverId;
|
||||
final List<BrowseRoute> stack;
|
||||
const BrowseStart(this.serverId, this.stack);
|
||||
|
||||
/// The series page of [episode] with its season selected.
|
||||
static BrowseStart? seriesOf(String serverId, JellyfinItem episode) {
|
||||
final sid = episode.seriesId;
|
||||
if (sid == null) return null;
|
||||
return BrowseStart(serverId, [
|
||||
const HomeRoute(),
|
||||
SeriesRoute(JellyfinItem(id: sid, name: episode.seriesName ?? '', type: 'Series'),
|
||||
seasonId: episode.seasonId),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/// What every browser page needs, provided by [JellyfinBrowser].
|
||||
class BrowseScope extends InheritedWidget {
|
||||
final JellyfinAccounts accounts;
|
||||
|
||||
@@ -36,7 +36,11 @@ class JellyfinBrowser extends StatefulWidget {
|
||||
final bool expanded;
|
||||
final VoidCallback? onOpenIntegrations;
|
||||
final Duration searchDebounce;
|
||||
/// Opens here instead of the remembered location (the series of the
|
||||
/// episode playing, in the session's swap panel).
|
||||
final BrowseStart? start;
|
||||
const JellyfinBrowser({
|
||||
this.start,
|
||||
super.key,
|
||||
required this.accounts,
|
||||
required this.onPick,
|
||||
@@ -67,12 +71,32 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_serverId = widget.selected != null && _accounts.account(widget.selected!.serverId) != null
|
||||
? widget.selected!.serverId
|
||||
: _accounts.defaultServerId;
|
||||
final start = widget.start;
|
||||
if (start != null && _accounts.account(start.serverId) != null) {
|
||||
_serverId = start.serverId;
|
||||
_stack = start.stack.isEmpty ? const [HomeRoute()] : start.stack;
|
||||
_remember();
|
||||
} else {
|
||||
_serverId = widget.selected != null && _accounts.account(widget.selected!.serverId) != null
|
||||
? widget.selected!.serverId
|
||||
: _accounts.defaultServerId;
|
||||
_stack = _restored(_serverId);
|
||||
}
|
||||
_accounts.addListener(_changed);
|
||||
}
|
||||
|
||||
/// The remembered location on [serverId], else Home.
|
||||
List<BrowseRoute> _restored(String? serverId) =>
|
||||
(serverId == null ? null : decodeBrowseStack(_accounts.browseLocation(serverId))) ??
|
||||
const [HomeRoute()];
|
||||
|
||||
/// Every navigation is saved per server (Hive), so the next browser
|
||||
/// (Host tab, swap panel, next start) opens where this one was.
|
||||
void _remember() {
|
||||
final id = _account?.serverId;
|
||||
if (id != null) unawaited(_accounts.saveBrowseLocation(id, encodeBrowseStack(_stack)));
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(JellyfinBrowser old) {
|
||||
super.didUpdateWidget(old);
|
||||
@@ -104,7 +128,7 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
|
||||
if (serverId == _account?.serverId) return;
|
||||
setState(() {
|
||||
_serverId = serverId;
|
||||
_stack = const [HomeRoute()];
|
||||
_stack = _restored(serverId);
|
||||
_peek.value = null;
|
||||
_clearSearch();
|
||||
});
|
||||
@@ -117,12 +141,14 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
|
||||
_clearSearch();
|
||||
if (_stack.last.key != route.key) _stack = [..._stack, route];
|
||||
});
|
||||
_remember();
|
||||
_body.requestFocus();
|
||||
}
|
||||
|
||||
bool _back() {
|
||||
if (_stack.length <= 1) return false;
|
||||
setState(() => _stack = _stack.sublist(0, _stack.length - 1));
|
||||
_remember();
|
||||
_body.requestFocus();
|
||||
return true;
|
||||
}
|
||||
@@ -132,6 +158,7 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
|
||||
_clearSearch();
|
||||
_stack = _stack.sublist(0, index + 1);
|
||||
});
|
||||
_remember();
|
||||
_body.requestFocus();
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import '../screens/home/picked_media.dart';
|
||||
import '../screens/home/ui.dart';
|
||||
import 'accounts.dart';
|
||||
import 'browse_scope.dart';
|
||||
import 'browser.dart';
|
||||
import 'sign_in_panel.dart';
|
||||
|
||||
@@ -15,8 +16,10 @@ class JellyfinPickerSource extends StatefulWidget {
|
||||
final VoidCallback? onOpenIntegrations;
|
||||
final VoidCallback? onToggleExpanded;
|
||||
final bool expanded;
|
||||
final BrowseStart? start;
|
||||
const JellyfinPickerSource({
|
||||
super.key,
|
||||
this.start,
|
||||
required this.accounts,
|
||||
required this.onPick,
|
||||
this.selected,
|
||||
@@ -54,6 +57,7 @@ class _JellyfinPickerSourceState extends State<JellyfinPickerSource> {
|
||||
onOpenIntegrations: widget.onOpenIntegrations,
|
||||
onToggleExpanded: widget.onToggleExpanded,
|
||||
expanded: widget.expanded,
|
||||
start: widget.start,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
/// What the player shows, sampled about once a second. [aid] / [sid] are
|
||||
/// mpv track ids (null = none / subtitles off).
|
||||
class PlayerSnapshot {
|
||||
final double position;
|
||||
final bool paused;
|
||||
final int? aid;
|
||||
final int? sid;
|
||||
const PlayerSnapshot({required this.position, required this.paused, this.aid, this.sid});
|
||||
|
||||
@override
|
||||
String toString() => 'PlayerSnapshot($position, paused: $paused, aid: $aid, sid: $sid)';
|
||||
}
|
||||
|
||||
/// Playing / Progress / Stopped for one item played with this viewer's own
|
||||
/// account, so Jellyfin's watched state and Continue Watching follow what
|
||||
/// this viewer saw. Fed a [PlayerSnapshot] every tick ([update]); sends
|
||||
/// Progress every [interval] and at once on pause, resume, seek (a jump of
|
||||
/// more than 3 s from where playback should be) or a track change. Reports
|
||||
/// go out one after another; a failure is logged and handed to [onError]
|
||||
/// (the session shows one quiet toast) and never touches playback. A 401
|
||||
/// calls [onUnauthorized] and ends reporting.
|
||||
class JellyfinProgressReporter {
|
||||
final JellyfinClient client;
|
||||
final String itemId;
|
||||
final JellyfinMediaSource source;
|
||||
final String playSessionId;
|
||||
final Duration interval;
|
||||
final DateTime Function() _clock;
|
||||
final void Function(Object error)? onError;
|
||||
final VoidCallback? onUnauthorized;
|
||||
|
||||
JellyfinProgressReporter({
|
||||
required this.client,
|
||||
required this.itemId,
|
||||
required this.source,
|
||||
required this.playSessionId,
|
||||
this.interval = const Duration(seconds: 10),
|
||||
this.onError,
|
||||
this.onUnauthorized,
|
||||
DateTime Function()? clock,
|
||||
}) : _clock = clock ?? DateTime.now;
|
||||
|
||||
Future<void> _chain = Future.value();
|
||||
bool _playing = false;
|
||||
bool _dead = false;
|
||||
PlayerSnapshot? _last;
|
||||
DateTime? _lastAt;
|
||||
DateTime? _lastSentAt;
|
||||
|
||||
/// Between [start] and [stop].
|
||||
bool get active => _playing && !_dead;
|
||||
|
||||
/// The report body for [s]: ticks, pause, and mpv's aid/sid mapped to
|
||||
/// Jellyfin stream indices (-1 = subtitles off).
|
||||
JellyfinPlaybackReport reportFor(PlayerSnapshot s) => JellyfinPlaybackReport(
|
||||
itemId: itemId,
|
||||
mediaSourceId: source.id,
|
||||
playSessionId: playSessionId,
|
||||
positionTicks: (s.position * jellyfinTicksPerSecond).round(),
|
||||
isPaused: s.paused,
|
||||
audioStreamIndex: s.aid == null
|
||||
? null
|
||||
: source.jellyfinIndexForMpvTrack(JellyfinStreamType.audio, s.aid!),
|
||||
subtitleStreamIndex: s.sid == null
|
||||
? -1
|
||||
: source.jellyfinIndexForMpvTrack(JellyfinStreamType.subtitle, s.sid!),
|
||||
);
|
||||
|
||||
/// Playing: once the media is open. Again after [stop] (played to the
|
||||
/// end, then someone seeked back) starts reporting anew.
|
||||
void start(PlayerSnapshot s) {
|
||||
if (_playing || _dead) return;
|
||||
_playing = true;
|
||||
_mark(s);
|
||||
_send('Playing', () => client.reportPlaybackStart(reportFor(s)));
|
||||
}
|
||||
|
||||
/// Progress when due (see the class doc).
|
||||
void update(PlayerSnapshot s, {bool force = false}) {
|
||||
if (!_playing || _dead) return;
|
||||
final last = _last, lastAt = _lastAt, sentAt = _lastSentAt;
|
||||
final now = _clock();
|
||||
var due = force || last == null || lastAt == null || sentAt == null;
|
||||
if (!due) {
|
||||
final elapsed = now.difference(lastAt).inMilliseconds / 1000;
|
||||
final expected = last.position + (last.paused ? 0 : elapsed);
|
||||
due = s.paused != last.paused ||
|
||||
s.aid != last.aid ||
|
||||
s.sid != last.sid ||
|
||||
(s.position - expected).abs() > 3 ||
|
||||
now.difference(sentAt) >= interval;
|
||||
}
|
||||
_last = s;
|
||||
_lastAt = now;
|
||||
if (!due) return;
|
||||
_lastSentAt = now;
|
||||
_send('Progress', () => client.reportPlaybackProgress(reportFor(s)));
|
||||
}
|
||||
|
||||
/// Stopped (swap, leave, kick, window close, played to the end).
|
||||
/// Completes when it and everything before it went out.
|
||||
Future<void> stop(PlayerSnapshot s) {
|
||||
if (!_playing || _dead) return _chain;
|
||||
_playing = false;
|
||||
_send('Stopped', () => client.reportPlaybackStopped(reportFor(s)));
|
||||
return _chain;
|
||||
}
|
||||
|
||||
void _mark(PlayerSnapshot s) {
|
||||
_last = s;
|
||||
_lastAt = _lastSentAt = _clock();
|
||||
}
|
||||
|
||||
void _send(String what, Future<void> Function() call) {
|
||||
_chain = _chain.then((_) async {
|
||||
if (_dead) return;
|
||||
try {
|
||||
await call().timeout(const Duration(seconds: 10));
|
||||
} catch (e) {
|
||||
debugPrint('jellyfin $what report for $itemId failed: $e');
|
||||
if (e is JellyfinException && e.isUnauthorized) {
|
||||
_dead = true;
|
||||
onUnauthorized?.call();
|
||||
}
|
||||
onError?.call(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../screens/home/picked_media.dart';
|
||||
import 'accounts.dart';
|
||||
import 'format.dart';
|
||||
|
||||
// Jellyfin media in a session: turning picks and pasted links into refs
|
||||
// (the wire carries only `{serverId, itemId, ...}`), the hosted server's
|
||||
// relay credential, and how this viewer plays a ref (own account, relay,
|
||||
// or neither).
|
||||
|
||||
/// The wire ref of a browser pick.
|
||||
JellyfinRef refForPick(JellyfinMedia m) => JellyfinRef(
|
||||
serverId: m.serverId,
|
||||
itemId: m.itemId,
|
||||
mediaSourceId: m.mediaSourceId,
|
||||
title: m.title,
|
||||
subtitle: m.subtitle.isEmpty ? null : m.subtitle,
|
||||
runtimeSecs: m.runtime == null ? null : m.runtime!.inMilliseconds / 1000,
|
||||
);
|
||||
|
||||
/// The `swap` media for a browser pick: a ref, never a URL.
|
||||
HelloMedia mediaForPick(JellyfinMedia m) => HelloMedia(
|
||||
name: m.title.isEmpty ? 'Jellyfin item' : m.title,
|
||||
size: 0,
|
||||
duration: 0,
|
||||
jellyfin: refForPick(m));
|
||||
|
||||
/// The pick a ref stands for on a linked server (for highlighting it in
|
||||
/// the browser); null when this install has no account there.
|
||||
JellyfinMedia? pickForRef(JellyfinAccounts accounts, JellyfinRef ref) {
|
||||
final a = accounts.account(ref.serverId);
|
||||
if (a == null) return null;
|
||||
return JellyfinMedia(
|
||||
serverId: ref.serverId,
|
||||
baseUrl: a.baseUrl,
|
||||
itemId: ref.itemId,
|
||||
mediaSourceId: ref.mediaSourceId,
|
||||
title: ref.title,
|
||||
subtitle: ref.subtitle ?? '',
|
||||
runtime: ref.runtimeSecs == null
|
||||
? null
|
||||
: Duration(milliseconds: (ref.runtimeSecs! * 1000).round()),
|
||||
);
|
||||
}
|
||||
|
||||
/// The ref in a `state.media` map, or null for other media.
|
||||
JellyfinRef? refOfStateMedia(Map<String, Object?>? media) {
|
||||
final jf = media?['jellyfin'];
|
||||
if (jf is! Map) return null;
|
||||
try {
|
||||
final ref = JellyfinRef.fromJson(Map<String, Object?>.from(jf));
|
||||
return ref.serverId.isEmpty || ref.itemId.isEmpty ? null : ref;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Same item (server, item, source); titles may differ.
|
||||
bool sameJellyfinItem(JellyfinRef? a, JellyfinRef? b) =>
|
||||
a != null &&
|
||||
b != null &&
|
||||
a.serverId == b.serverId &&
|
||||
a.itemId == b.itemId &&
|
||||
a.mediaSourceId == b.mediaSourceId;
|
||||
|
||||
final _sourceId = RegExp(r'^[A-Za-z0-9-]{1,64}$');
|
||||
|
||||
/// What a pasted link becomes on the wire. A recognised Jellyfin item link
|
||||
/// (`parseJellyfinLink`) becomes a ref when its ServerId is known (in the
|
||||
/// link or from `/System/Info/Public`); its `api_key` is never shared then.
|
||||
/// Anything else, or a server that does not answer, stays a plain link.
|
||||
/// The title comes from this install's account on that server when there
|
||||
/// is one.
|
||||
Future<HelloMedia> mediaForLink(JellyfinAccounts accounts, String url,
|
||||
{Duration timeout = const Duration(seconds: 6)}) async {
|
||||
final plain = HelloMedia(name: linkName(url), size: 0, duration: 0, url: url);
|
||||
final link = parseJellyfinLink(url);
|
||||
if (link == null) return plain;
|
||||
await accounts.ensureLoaded();
|
||||
var serverId = link.serverId;
|
||||
if (serverId == null || serverId.isEmpty) {
|
||||
final probe = accounts.anonymousClient(link.baseUrl);
|
||||
try {
|
||||
serverId = (await probe.publicInfo().timeout(timeout)).id;
|
||||
} catch (e) {
|
||||
debugPrint('jellyfin link: no server id from ${link.baseUrl}: $e');
|
||||
return plain;
|
||||
} finally {
|
||||
probe.close();
|
||||
}
|
||||
if (serverId.isEmpty) return plain;
|
||||
}
|
||||
final src = Uri.tryParse(url)?.queryParameters['mediaSourceId'];
|
||||
var ref = JellyfinRef(
|
||||
serverId: serverId,
|
||||
itemId: link.itemId,
|
||||
mediaSourceId: src != null && _sourceId.hasMatch(src) && src != link.itemId ? src : null,
|
||||
);
|
||||
final account = accounts.account(serverId);
|
||||
final client = accounts.clientFor(serverId);
|
||||
if (account != null && client != null) {
|
||||
try {
|
||||
final pick = pickFor(account, await client.item(link.itemId).timeout(timeout));
|
||||
ref = refForPick(JellyfinMedia(
|
||||
serverId: serverId,
|
||||
baseUrl: account.baseUrl,
|
||||
itemId: link.itemId,
|
||||
mediaSourceId: ref.mediaSourceId,
|
||||
title: pick.title,
|
||||
subtitle: pick.subtitle,
|
||||
runtime: pick.runtime,
|
||||
));
|
||||
} catch (e) {
|
||||
if (e is JellyfinException && e.isUnauthorized) accounts.markExpired(serverId);
|
||||
}
|
||||
}
|
||||
return HelloMedia(
|
||||
name: ref.title.isEmpty ? 'Jellyfin item' : ref.title, size: 0, duration: 0, jellyfin: ref);
|
||||
}
|
||||
|
||||
/// Display name for a link: last path segment, decoded, never the query
|
||||
/// (Jellyfin-style links carry api_key there and names are broadcast).
|
||||
String linkName(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';
|
||||
}
|
||||
|
||||
/// The hosted server's relay login: the account for [serverId] (the
|
||||
/// hosted item's server), else the default account, when its token is not
|
||||
/// known to be expired. The device fields are exactly the ones this app
|
||||
/// sends with that token ([JellyfinAccounts.clientInfo]): Jellyfin rewrites
|
||||
/// a token's device from the request header, so the relay must not differ.
|
||||
JellyfinCredential? relayCredentialFor(JellyfinAccounts accounts, {String? serverId}) {
|
||||
bool usable(JellyfinAccount? a) =>
|
||||
a != null && accounts.statusOf(a.serverId) != JellyfinAccountStatus.expired;
|
||||
var a = serverId == null ? null : accounts.account(serverId);
|
||||
if (!usable(a)) a = accounts.defaultAccount;
|
||||
if (!usable(a)) return null;
|
||||
final info = accounts.clientInfo;
|
||||
return JellyfinCredential(
|
||||
baseUrl: a!.baseUrl,
|
||||
serverId: a.serverId,
|
||||
token: a.token,
|
||||
client: info.client,
|
||||
device: info.device,
|
||||
deviceId: info.deviceId,
|
||||
version: info.version,
|
||||
);
|
||||
}
|
||||
|
||||
/// `/j/...` URL of the current Jellyfin media on the host this client
|
||||
/// dialled, built like the `/m/...` one.
|
||||
String relayUrlFor(String host, int port, String token, String itemId) =>
|
||||
'http://$host:$port/j/default/$token/${Uri.encodeComponent(itemId)}';
|
||||
|
||||
// ---------------------------------------------------------------- playing
|
||||
|
||||
/// How this viewer plays a ref.
|
||||
sealed class JellyfinPlan {
|
||||
const JellyfinPlan();
|
||||
|
||||
/// `report.jf` for this plan; null when it plays nothing.
|
||||
bool? get jfAccess;
|
||||
}
|
||||
|
||||
/// Direct play with this install's account: the static stream, the token
|
||||
/// in [headers] (not in the URL), progress reported per [info].
|
||||
final class OwnPlay extends JellyfinPlan {
|
||||
final JellyfinClient client;
|
||||
final JellyfinAccount account;
|
||||
final JellyfinPlaybackInfo info;
|
||||
final JellyfinMediaSource source;
|
||||
/// Full item (series, episode numbers); null if that call failed.
|
||||
final JellyfinItem? item;
|
||||
final String url;
|
||||
final Map<String, String> headers;
|
||||
const OwnPlay({
|
||||
required this.client,
|
||||
required this.account,
|
||||
required this.info,
|
||||
required this.source,
|
||||
required this.item,
|
||||
required this.url,
|
||||
required this.headers,
|
||||
});
|
||||
|
||||
@override
|
||||
bool get jfAccess => true;
|
||||
}
|
||||
|
||||
/// Through the sync server's relay (`/j/...`).
|
||||
final class RelayPlay extends JellyfinPlan {
|
||||
final String url;
|
||||
const RelayPlay(this.url);
|
||||
|
||||
@override
|
||||
bool get jfAccess => false;
|
||||
}
|
||||
|
||||
/// Neither: no (usable) account here and the server cannot relay.
|
||||
final class BlockedPlay extends JellyfinPlan {
|
||||
final String serverId;
|
||||
/// Known only when this install has (had) an account there.
|
||||
final String? serverName;
|
||||
/// Why the own account did not work, if there is one.
|
||||
final String? problem;
|
||||
const BlockedPlay({required this.serverId, this.serverName, this.problem});
|
||||
|
||||
@override
|
||||
bool? get jfAccess => null;
|
||||
|
||||
String get message {
|
||||
final where = serverName == null || serverName!.isEmpty ? 'that Jellyfin server' : serverName!;
|
||||
final base = 'This needs a Jellyfin account on $where (or the host can relay)';
|
||||
return problem == null ? base : '$base — $problem';
|
||||
}
|
||||
}
|
||||
|
||||
/// Picks how to play [ref]: this install's account first (PlaybackInfo
|
||||
/// with its own user; a 401 marks the account expired), else the relay
|
||||
/// when the server offers it ([relay] = `state.relay`, [relayUrl] this
|
||||
/// member's `/j/...` URL), else [BlockedPlay].
|
||||
Future<JellyfinPlan> resolveJellyfinRef(
|
||||
JellyfinAccounts accounts,
|
||||
JellyfinRef ref, {
|
||||
required bool relay,
|
||||
required String? relayUrl,
|
||||
Duration timeout = const Duration(seconds: 15),
|
||||
}) async {
|
||||
await accounts.ensureLoaded();
|
||||
final account = accounts.account(ref.serverId);
|
||||
String? problem;
|
||||
if (account != null && accounts.statusOf(ref.serverId) == JellyfinAccountStatus.expired) {
|
||||
problem = 'your sign-in expired, reconnect it in Integrations';
|
||||
} else if (account != null) {
|
||||
final client = accounts.clientFor(ref.serverId)!;
|
||||
try {
|
||||
final infoF = client.playbackInfo(ref.itemId, mediaSourceId: ref.mediaSourceId).timeout(timeout);
|
||||
final itemF = client
|
||||
.item(ref.itemId)
|
||||
.timeout(timeout)
|
||||
.then<JellyfinItem?>((i) => i, onError: (Object _) => null);
|
||||
final info = await infoF;
|
||||
final item = await itemF;
|
||||
final source = (ref.mediaSourceId == null
|
||||
? null
|
||||
: info.mediaSources.where((s) => s.id == ref.mediaSourceId).firstOrNull) ??
|
||||
info.primarySource;
|
||||
if (source == null) throw const JellyfinException('no playable source');
|
||||
return OwnPlay(
|
||||
client: client,
|
||||
account: account,
|
||||
info: info,
|
||||
source: source,
|
||||
item: item,
|
||||
url: client.shareableStreamUrl(ref.itemId, mediaSourceId: source.id).toString(),
|
||||
headers: client.authHeaders,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('jellyfin resolve ${ref.itemId} with own account failed: $e');
|
||||
if (e is JellyfinException && e.isUnauthorized) {
|
||||
accounts.markExpired(ref.serverId);
|
||||
problem = 'your sign-in expired, reconnect it in Integrations';
|
||||
} else if (e is JellyfinException && e.statusCode == 404) {
|
||||
problem = 'your account cannot see this item';
|
||||
} else {
|
||||
problem = 'could not reach it';
|
||||
}
|
||||
}
|
||||
}
|
||||
if (relay && relayUrl != null && relayUrl.isNotEmpty) return RelayPlay(relayUrl);
|
||||
return BlockedPlay(serverId: ref.serverId, serverName: account?.displayName, problem: problem);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ next episode
|
||||
|
||||
/// A neighbouring episode as this viewer can put it up.
|
||||
class EpisodeLink {
|
||||
final JellyfinItem item;
|
||||
final JellyfinMedia pick;
|
||||
const EpisodeLink(this.item, this.pick);
|
||||
|
||||
/// "S1E7 Battle with the Heroes".
|
||||
String get label => [?item.episodeLabel, item.name].join(' ');
|
||||
}
|
||||
|
||||
/// The episodes before and after the one [plan] plays, with its account.
|
||||
/// Both null for movies, at the ends of a series, or on failure.
|
||||
Future<({EpisodeLink? previous, EpisodeLink? next})> findAdjacentEpisodes(OwnPlay plan,
|
||||
{Duration timeout = const Duration(seconds: 15)}) async {
|
||||
const none = (previous: null, next: null);
|
||||
final item = plan.item;
|
||||
if (item == null || item.kind != JellyfinItemKind.episode || item.seriesId == null) return none;
|
||||
try {
|
||||
final around = await plan.client.adjacentEpisodes(item).timeout(timeout);
|
||||
EpisodeLink? link(JellyfinItem? i) => i == null ? null : EpisodeLink(i, pickFor(plan.account, i));
|
||||
return (previous: link(around.previous), next: link(around.next));
|
||||
} catch (e) {
|
||||
debugPrint('episode lookup failed: $e');
|
||||
return none;
|
||||
}
|
||||
}
|
||||
|
||||
/// When the floating "Next episode" pill appears: the last two minutes of
|
||||
/// [runtime] (all of it for shorter media). One place to switch it to the
|
||||
/// item's outro / credits segment later.
|
||||
Duration nextPromptStart(JellyfinItem? item, Duration runtime) {
|
||||
const lead = Duration(minutes: 2);
|
||||
return runtime > lead ? runtime - lead : Duration.zero;
|
||||
}
|
||||
|
||||
/// Who performs the auto-advance swap, so several counting clients do not
|
||||
/// swap twice: the lowest member id among members that play the item with
|
||||
/// their own account (`jf[serverId] == true`, not stale). Only what every
|
||||
/// client sees in `state` decides (who put the item up is not in it: a
|
||||
/// late joiner never saw that swap), so all clients agree.
|
||||
String? autoAdvanceSwapper(List<MemberView> members, String serverId) {
|
||||
final eligible = [
|
||||
for (final m in members)
|
||||
if (!m.stale && m.jf[serverId] == true) m
|
||||
];
|
||||
if (eligible.isEmpty) return null;
|
||||
eligible.sort((a, b) => compareMemberIds(a.id, b.id));
|
||||
return eligible.first.id;
|
||||
}
|
||||
|
||||
/// "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);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import 'accounts.dart';
|
||||
|
||||
// Per-viewer audio/subtitle carry-over: the last choice is kept per series
|
||||
// (and as a global fallback) and matched against the next Jellyfin item's
|
||||
// tracks with core's `matchTrack`. Local only; nothing goes on the wire.
|
||||
|
||||
const trackPrefsBoxName = 'track_prefs';
|
||||
const _keyGlobal = 'global';
|
||||
|
||||
/// One mpv track as media_kit lists it ([id] is mpv's `aid` / `sid`).
|
||||
class MpvTrack {
|
||||
final int id;
|
||||
final JellyfinStreamType type;
|
||||
final String? language;
|
||||
final String? title;
|
||||
final String? codec;
|
||||
final int? channels;
|
||||
final bool isDefault;
|
||||
const MpvTrack({
|
||||
required this.id,
|
||||
required this.type,
|
||||
this.language,
|
||||
this.title,
|
||||
this.codec,
|
||||
this.channels,
|
||||
this.isDefault = false,
|
||||
});
|
||||
|
||||
/// As a stream for [matchTrack]; its index is the mpv id.
|
||||
JellyfinMediaStream get asStream => JellyfinMediaStream(
|
||||
index: id,
|
||||
type: type,
|
||||
language: language,
|
||||
title: title,
|
||||
codec: codec,
|
||||
channels: channels,
|
||||
isDefault: isDefault);
|
||||
|
||||
/// Menu text: "English (eac3 2ch)", "Track 2".
|
||||
String get label {
|
||||
final name = title ?? language ?? 'Track $id';
|
||||
final extra = [?codec, if (channels != null) '${channels}ch'].join(' ');
|
||||
final lang = title != null && language != null ? ' [$language]' : '';
|
||||
return extra.isEmpty ? '$name$lang' : '$name$lang ($extra)';
|
||||
}
|
||||
}
|
||||
|
||||
/// A viewer's audio/subtitle choice as traits (indices shift between
|
||||
/// files). [subtitlesOff] wins over [subtitle].
|
||||
class TrackChoice {
|
||||
final TrackPreference? audio;
|
||||
final TrackPreference? subtitle;
|
||||
final bool subtitlesOff;
|
||||
const TrackChoice({this.audio, this.subtitle, this.subtitlesOff = false});
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'audio': ?_prefToJson(audio),
|
||||
'subtitle': ?_prefToJson(subtitle),
|
||||
'subtitlesOff': subtitlesOff,
|
||||
};
|
||||
|
||||
static TrackChoice? fromJson(Object? j) {
|
||||
if (j is! Map) return null;
|
||||
return TrackChoice(
|
||||
audio: _prefFromJson(j['audio'], JellyfinStreamType.audio),
|
||||
subtitle: _prefFromJson(j['subtitle'], JellyfinStreamType.subtitle),
|
||||
subtitlesOff: j['subtitlesOff'] == true,
|
||||
);
|
||||
}
|
||||
|
||||
/// The choice behind mpv's [aid] / [sid] (null = none / off): traits from
|
||||
/// the Jellyfin [source] when this viewer has it (title, channels,
|
||||
/// forced/HI flags), else from mpv's [tracks].
|
||||
factory TrackChoice.of({
|
||||
required int? aid,
|
||||
required int? sid,
|
||||
JellyfinMediaSource? source,
|
||||
List<MpvTrack> tracks = const [],
|
||||
}) {
|
||||
TrackPreference? pref(JellyfinStreamType type, int? id) {
|
||||
if (id == null) return null;
|
||||
if (source != null) {
|
||||
final index = source.jellyfinIndexForMpvTrack(type, id);
|
||||
final s = index == null ? null : source.streamAt(index);
|
||||
if (s != null) return TrackPreference.fromStream(s);
|
||||
}
|
||||
final t = tracks.where((t) => t.type == type && t.id == id).firstOrNull;
|
||||
return t == null ? null : TrackPreference.fromStream(t.asStream);
|
||||
}
|
||||
|
||||
return TrackChoice(
|
||||
audio: pref(JellyfinStreamType.audio, aid),
|
||||
subtitle: pref(JellyfinStreamType.subtitle, sid),
|
||||
subtitlesOff: sid == null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object?>? _prefToJson(TrackPreference? p) => p == null
|
||||
? null
|
||||
: {
|
||||
'language': ?p.language,
|
||||
'title': ?p.title,
|
||||
'codec': ?p.codec,
|
||||
'channels': ?p.channels,
|
||||
if (p.isForced) 'forced': true,
|
||||
if (p.isHearingImpaired) 'hi': true,
|
||||
};
|
||||
|
||||
TrackPreference? _prefFromJson(Object? j, JellyfinStreamType type) {
|
||||
if (j is! Map) return null;
|
||||
String? s(Object? v) => v is String && v.isNotEmpty ? v : null;
|
||||
return TrackPreference(
|
||||
type: type,
|
||||
language: s(j['language']),
|
||||
title: s(j['title']),
|
||||
codec: s(j['codec']),
|
||||
channels: j['channels'] is int ? j['channels'] as int : null,
|
||||
isForced: j['forced'] == true,
|
||||
isHearingImpaired: j['hi'] == true,
|
||||
);
|
||||
}
|
||||
|
||||
/// mpv tracks to select for [choice] on a new item. Matches against the
|
||||
/// Jellyfin [source]'s streams when this viewer has them (mapped to mpv ids
|
||||
/// with [JellyfinMediaSource.mpvTrackId]), else against mpv's [tracks].
|
||||
/// Null fields keep the player's default; [subtitlesOff] turns them off.
|
||||
({int? aid, int? sid, bool subtitlesOff}) chooseTracks(TrackChoice choice,
|
||||
{JellyfinMediaSource? source, List<MpvTrack> tracks = const []}) {
|
||||
int? pick(TrackPreference? pref) {
|
||||
if (pref == null) return null;
|
||||
if (source != null) {
|
||||
final s = matchTrack(pref, source.mediaStreams);
|
||||
return s == null ? null : source.mpvTrackId(s.index);
|
||||
}
|
||||
return matchTrack(pref, [for (final t in tracks) t.asStream])?.index;
|
||||
}
|
||||
|
||||
return (
|
||||
aid: pick(choice.audio),
|
||||
sid: choice.subtitlesOff ? null : pick(choice.subtitle),
|
||||
subtitlesOff: choice.subtitlesOff,
|
||||
);
|
||||
}
|
||||
|
||||
/// The last choice per series and overall, in their own Hive box.
|
||||
class TrackPrefs {
|
||||
TrackPrefs({JellyfinStore? store}) : _store = store ?? HiveJellyfinStore(boxName: trackPrefsBoxName);
|
||||
final JellyfinStore _store;
|
||||
Future<void>? _open;
|
||||
|
||||
Future<void> _ready() => _open ??= _store.open().catchError((Object e) {
|
||||
debugPrint('open track prefs failed: $e');
|
||||
});
|
||||
|
||||
/// The series' choice, else the global one.
|
||||
Future<TrackChoice?> load({String? seriesId}) async {
|
||||
await _ready();
|
||||
try {
|
||||
Object? read(String key) {
|
||||
final v = _store.get(key);
|
||||
return v is String ? jsonDecode(v) : null;
|
||||
}
|
||||
|
||||
return (seriesId == null ? null : TrackChoice.fromJson(read('series:$seriesId'))) ??
|
||||
TrackChoice.fromJson(read(_keyGlobal));
|
||||
} catch (e) {
|
||||
debugPrint('read track prefs failed: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves [choice] as the global one and, with [seriesId], the series'.
|
||||
Future<void> save(TrackChoice choice, {String? seriesId}) async {
|
||||
await _ready();
|
||||
try {
|
||||
final v = jsonEncode(choice.toJson());
|
||||
await _store.put(_keyGlobal, v);
|
||||
if (seriesId != null) await _store.put('series:$seriesId', v);
|
||||
} catch (e) {
|
||||
debugPrint('save track prefs failed: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user