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:
foxyzandClaude Opus 5.5 committed 2026-10-07 02:38:24 +03:00
1 parent 773a04369e
commit ae4edc29e8
33 files changed
+3541 -175

No files matched your search

+42 -8
View File
@@ -1,7 +1,7 @@
# watch-together # watch-together
Watch a video with friends, in sync, with the quality of a real mpv player. Watch a video with friends, in sync, with the quality of a real mpv player.
Paste a Jellyfin link, any direct video link, or pick a file on your own disk; Pick from your Jellyfin library, paste any direct video link, or pick a file on your own disk;
everyone's play, pause and seek stay together. everyone's play, pause and seek stay together.
Why not Jellyfin SyncPlay: it runs in the browser and its sync is poor. Why not Jellyfin SyncPlay: it runs in the browser and its sync is poor.
@@ -83,12 +83,14 @@ x to forget it. Ctrl+1 / Ctrl+2 switch between Join and Host.
Profile, hardware decoding (auto, off or custom; the env var Profile, hardware decoding (auto, off or custom; the env var
`SYNC_RELAY_HWDEC` overrides it; applies on the next join or Reload video), `SYNC_RELAY_HWDEC` overrides it; applies on the next join or Reload video),
updates, video shaders (see below), and the keyboard shortcuts (read-only). updates, playing the next Jellyfin episode automatically, video shaders
(see below), and the keyboard shortcuts (read-only).
Integrations links Jellyfin servers (below). About shows the version. Integrations links Jellyfin servers (below). About shows the version.
Keys in a session: Space = ready / unready, Tab = side panel (people, log, Keys in a session: Space = ready / unready, Tab = side panel (people, log,
chat, change video), Left/Right = seek 10 s, F = fullscreen, Esc = close chat, change video), Left/Right = seek 10 s, F = fullscreen, Esc = close
panel or ask to leave, Ctrl+R = reload video, Ctrl+1..6 = Anime4K mode A, B, panel or ask to leave, Ctrl+R = reload video, `<` / `>` or Shift+P /
Shift+N = previous / next Jellyfin episode, Ctrl+1..6 = Anime4K mode A, B,
C, A+A, B+B, C+A, Ctrl+0 = shaders off. C, A+A, B+B, C+A, Ctrl+0 = shaders off.
## Ports ## Ports
@@ -122,11 +124,43 @@ Keys in the browser: arrows move, Enter picks or opens, I = details,
Esc / Backspace / Alt+Left = back (Esc first clears a search), / or Ctrl+F = Esc / Backspace / Alt+Left = back (Esc first clears a search), / or Ctrl+F =
search. search.
For now a hosted Jellyfin pick is shared as a stream link carrying the Host from the library: pick a movie or episode and Host. Only the item
host's sign-in, so everyone watches through the host's account. Per-person goes to the server (which Jellyfin server and which item), never a link or
playback, watched status and progress sync are planned: see your sign-in. Pasting a Jellyfin link (web page or stream link) does the
[docs/ROADMAP.md](docs/ROADMAP.md). Pasting a Jellyfin direct-play link same when the app can reach that Jellyfin; otherwise it is shared as a
still works like any link. plain link.
Watching:
- Everyone with their own account on that Jellyfin server plays the file
directly with their own sign-in. Jellyfin tracks progress per account:
your watched state, Continue watching and resume point follow what you
saw (reported on start, every 10 s, on pause, resume, seek and track
change, and when it stops).
- Friends without an account watch through the host: the server fetches
the file with the host's sign-in and passes it on (only the current item,
only to people in the session). They show "via host" in the member list.
A dedicated server relays when started with `--jellyfin <file>` (see
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), Jellyfin relay).
- Without either, the app says "This needs a Jellyfin account on ..." with
a button to link one; it plays as soon as you are signed in.
Episodes: the ⏮ / ⏭ buttons next to the controls (or `<` / `>`,
Shift+P / Shift+N) put up the previous or next episode for everyone. In
the last two minutes a "Next episode" button floats in the corner. When an
episode ends, a card shows what is next with Play next; with "Play the
next episode automatically" (Settings, on by default) it counts down 10 s
first, and Cancel stops it on your screen (seeking back stops it for
everyone). Anyone with an account can put the next one up; friends
watching via the host cannot.
Audio and subtitles: the subtitles button in the controls picks tracks.
Your choice is remembered per series (and as a default) and the closest
match (language, then title, then codec and channels) is selected on the
next episode. This is per person; nobody else is affected.
"Change video" in the session panel (Tab) opens the same picker as the Host
tab: the library starts on the series you are watching with the current
season, else where you left it. File is offered to the host only.
## Video quality and shaders ## Video quality and shaders
+24 -1
View File
@@ -23,6 +23,7 @@ const _keyDeviceId = 'deviceId';
const _keyAccounts = 'accounts'; const _keyAccounts = 'accounts';
const _keyDefault = 'defaultServer'; const _keyDefault = 'defaultServer';
const _keyLastPick = 'lastPick'; const _keyLastPick = 'lastPick';
const _keyBrowsePrefix = 'browse:';
/// One signed-in user on one server. /// One signed-in user on one server.
class JellyfinAccount { class JellyfinAccount {
@@ -124,10 +125,12 @@ abstract class JellyfinStore {
} }
class HiveJellyfinStore implements JellyfinStore { class HiveJellyfinStore implements JellyfinStore {
HiveJellyfinStore({this.boxName = jellyfinBoxName});
final String boxName;
Box? _box; Box? _box;
@override @override
Future<void> open() async => _box ??= await Hive.openBox(jellyfinBoxName); Future<void> open() async => _box ??= await Hive.openBox(boxName);
@override @override
Object? get(String key) => _box?.get(key); 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 // --------------------------------------------------------------- cache
/// Short-lived memo for browse calls, so going back does not refetch. /// 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}'; 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]. /// What every browser page needs, provided by [JellyfinBrowser].
class BrowseScope extends InheritedWidget { class BrowseScope extends InheritedWidget {
final JellyfinAccounts accounts; final JellyfinAccounts accounts;
+31 -4
View File
@@ -36,7 +36,11 @@ class JellyfinBrowser extends StatefulWidget {
final bool expanded; final bool expanded;
final VoidCallback? onOpenIntegrations; final VoidCallback? onOpenIntegrations;
final Duration searchDebounce; 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({ const JellyfinBrowser({
this.start,
super.key, super.key,
required this.accounts, required this.accounts,
required this.onPick, required this.onPick,
@@ -67,12 +71,32 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_serverId = widget.selected != null && _accounts.account(widget.selected!.serverId) != null final start = widget.start;
? widget.selected!.serverId if (start != null && _accounts.account(start.serverId) != null) {
: _accounts.defaultServerId; _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); _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 @override
void didUpdateWidget(JellyfinBrowser old) { void didUpdateWidget(JellyfinBrowser old) {
super.didUpdateWidget(old); super.didUpdateWidget(old);
@@ -104,7 +128,7 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
if (serverId == _account?.serverId) return; if (serverId == _account?.serverId) return;
setState(() { setState(() {
_serverId = serverId; _serverId = serverId;
_stack = const [HomeRoute()]; _stack = _restored(serverId);
_peek.value = null; _peek.value = null;
_clearSearch(); _clearSearch();
}); });
@@ -117,12 +141,14 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
_clearSearch(); _clearSearch();
if (_stack.last.key != route.key) _stack = [..._stack, route]; if (_stack.last.key != route.key) _stack = [..._stack, route];
}); });
_remember();
_body.requestFocus(); _body.requestFocus();
} }
bool _back() { bool _back() {
if (_stack.length <= 1) return false; if (_stack.length <= 1) return false;
setState(() => _stack = _stack.sublist(0, _stack.length - 1)); setState(() => _stack = _stack.sublist(0, _stack.length - 1));
_remember();
_body.requestFocus(); _body.requestFocus();
return true; return true;
} }
@@ -132,6 +158,7 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
_clearSearch(); _clearSearch();
_stack = _stack.sublist(0, index + 1); _stack = _stack.sublist(0, index + 1);
}); });
_remember();
_body.requestFocus(); _body.requestFocus();
} }
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import '../screens/home/picked_media.dart'; import '../screens/home/picked_media.dart';
import '../screens/home/ui.dart'; import '../screens/home/ui.dart';
import 'accounts.dart'; import 'accounts.dart';
import 'browse_scope.dart';
import 'browser.dart'; import 'browser.dart';
import 'sign_in_panel.dart'; import 'sign_in_panel.dart';
@@ -15,8 +16,10 @@ class JellyfinPickerSource extends StatefulWidget {
final VoidCallback? onOpenIntegrations; final VoidCallback? onOpenIntegrations;
final VoidCallback? onToggleExpanded; final VoidCallback? onToggleExpanded;
final bool expanded; final bool expanded;
final BrowseStart? start;
const JellyfinPickerSource({ const JellyfinPickerSource({
super.key, super.key,
this.start,
required this.accounts, required this.accounts,
required this.onPick, required this.onPick,
this.selected, this.selected,
@@ -54,6 +57,7 @@ class _JellyfinPickerSourceState extends State<JellyfinPickerSource> {
onOpenIntegrations: widget.onOpenIntegrations, onOpenIntegrations: widget.onOpenIntegrations,
onToggleExpanded: widget.onToggleExpanded, onToggleExpanded: widget.onToggleExpanded,
expanded: widget.expanded, expanded: widget.expanded,
start: widget.start,
); );
}, },
); );
+134
View File
@@ -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');
}
}
}
@@ -5,6 +5,9 @@ import 'package:flutter/widgets.dart';
import 'package:media_kit/media_kit.dart'; import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart'; import 'package:media_kit_video/media_kit_video.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
import '../jellyfin/track_prefs.dart' show MpvTrack;
import '../shaders/shader_assets.dart'; import '../shaders/shader_assets.dart';
import '../shaders/shader_presets.dart'; import '../shaders/shader_presets.dart';
@@ -356,16 +359,89 @@ class PlaybackController {
} }
} }
Future<void> openUrl(String url) async { /// Opens [url] paused. [headers] go with every request for it (mpv
/// `http-header-fields`): a Jellyfin token travels there, not in the URL.
Future<void> openUrl(String url, {Map<String, String>? headers}) async {
if (!_ready) return; if (!_ready) return;
try { try {
await player.open(Media(url), play: false); // An empty map, not null: media_kit caches headers per URI.
await player.open(Media(url, httpHeaders: headers ?? const {}), play: false);
} catch (e, st) { } catch (e, st) {
debugPrint('open $url failed: $e\n$st'); debugPrint('open ${Uri.tryParse(url)?.replace(query: '') ?? 'media'} failed: $e\n$st');
rethrow; rethrow;
} }
} }
/// The audio / subtitle tracks of the open file (mpv's track-list).
List<MpvTrack> get tracks {
final p = _player;
if (p == null) return const [];
MpvTrack? of(dynamic t, JellyfinStreamType type) {
final id = int.tryParse('${t.id}');
if (id == null) return null;
return MpvTrack(
id: id,
type: type,
language: t.language as String?,
title: t.title as String?,
codec: t.codec as String?,
channels: (t.channelscount ?? t.audiochannels) as int?,
isDefault: t.isDefault == true,
);
}
return [
for (final t in p.state.tracks.audio) ?of(t, JellyfinStreamType.audio),
for (final t in p.state.tracks.subtitle) ?of(t, JellyfinStreamType.subtitle),
];
}
/// Waits (bounded) until the open file's tracks are listed and mpv has
/// selected its audio track, so a report or a carried-over choice sees
/// the real selection.
Future<List<MpvTrack>> waitForTracks({Duration timeout = const Duration(seconds: 5)}) async {
final end = DateTime.now().add(timeout);
while (_player != null && DateTime.now().isBefore(end)) {
final t = tracks;
final hasAudio = t.any((x) => x.type == JellyfinStreamType.audio);
if (t.isNotEmpty && (!hasAudio || (await currentTrackIds()).aid != null)) return t;
await Future.delayed(const Duration(milliseconds: 100));
}
return tracks;
}
/// mpv's selected audio and subtitle track ids (null = none / off).
Future<({int? aid, int? sid})> currentTrackIds() async {
final p = _player;
if (p == null) return (aid: null, sid: null);
try {
final dynamic platform = p.platform;
final aid = int.tryParse('${await platform?.getProperty('current-tracks/audio/id')}');
final sid = int.tryParse('${await platform?.getProperty('current-tracks/sub/id')}');
return (aid: aid, sid: sid);
} catch (_) {
return (aid: null, sid: null);
}
}
/// Selects tracks by mpv id; a null [aid] keeps audio, [subtitlesOff]
/// turns subtitles off, else a null [sid] keeps them.
Future<void> selectTracks({int? aid, int? sid, bool subtitlesOff = false}) async {
final p = _player;
if (p == null) return;
try {
final dynamic platform = p.platform;
if (aid != null) await platform?.setProperty('aid', '$aid');
if (subtitlesOff) {
await platform?.setProperty('sid', 'no');
} else if (sid != null) {
await platform?.setProperty('sid', '$sid');
}
} catch (e) {
debugPrint('select tracks failed: $e');
}
}
double get positionSecs { double get positionSecs {
final p = _player; final p = _player;
if (p == null) return 0; if (p == null) return 0;
@@ -1,6 +1,7 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:sync_relay_core/core.dart' show HelloMedia;
import '../../session/session_coordinator.dart'; import '../../session/session_coordinator.dart';
import '../../state/session_state.dart'; import '../../state/session_state.dart';
@@ -91,11 +92,11 @@ class ConnectFlow extends ChangeNotifier {
} }
/// [edit] fills the card's fields into a copy of the last form, so the /// [edit] fills the card's fields into a copy of the last form, so the
/// other card's saved values survive. [linkOverride] (host only) is played /// other card's saved values survive. [media] (host only) is put up
/// as the link but never stored in the form: the interim Jellyfin stream /// instead of the form's link or file (a Jellyfin ref; the pick itself is
/// URL carries a token. /// remembered in the Jellyfin box, not the form).
Future<void> submit(ConnectMode mode, void Function(ConnectForm f) edit, Future<void> submit(ConnectMode mode, void Function(ConnectForm f) edit,
{String? linkOverride}) async { {HelloMedia? media}) async {
if (busy != null) return; if (busy != null) return;
final myAttempt = ++_attempt; final myAttempt = ++_attempt;
final form = _session.lastForm.copy(); final form = _session.lastForm.copy();
@@ -113,15 +114,10 @@ class ConnectFlow extends ChangeNotifier {
notices[mode] = null; notices[mode] = null;
_notify(); _notify();
final ConnectParams params; ConnectParams params;
try { try {
params = await buildParams( params = await buildParams(mode, form);
mode, if (media != null && mode == ConnectMode.host) params = params.withMedia(media);
linkOverride == null || mode != ConnectMode.host
? form
: (form.copy()
..mediaPath = ''
..linkUrl = linkOverride));
} catch (e) { } catch (e) {
if (myAttempt != _attempt) return; if (myAttempt != _attempt) return;
busy = null; busy = null;
+11 -12
View File
@@ -1,7 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart' show HelloMedia;
import '../../jellyfin/accounts.dart'; import '../../jellyfin/accounts.dart';
import '../../jellyfin/session_media.dart';
import '../../state/session_state.dart'; import '../../state/session_state.dart';
import 'connect_flow.dart'; import 'connect_flow.dart';
import 'connect_status.dart'; import 'connect_status.dart';
@@ -48,20 +50,17 @@ class HostFields {
} }
} }
/// INTERIM(jellyfin-protocol): until the protocol carries a Jellyfin item, /// What hosting a picked Jellyfin item sends: its ref (no URL, no token).
/// the host plays it as a link: this install's authorised static stream URL /// Throws [FormatException] with a user-facing message when the server is
/// (token in the query, so viewers stream with the host's sign-in and the /// not linked any more or its sign-in expired.
/// URL must not be persisted). Throws [FormatException] with a user-facing HelloMedia hostJellyfinMedia(JellyfinAccounts accounts, JellyfinMedia media) {
/// message when the server is not linked any more. if (accounts.account(media.serverId) == null) {
String interimJellyfinLink(JellyfinAccounts accounts, JellyfinMedia media) {
final client = accounts.clientFor(media.serverId);
if (client == null) {
throw const FormatException('that Jellyfin server is not linked any more — pick again'); throw const FormatException('that Jellyfin server is not linked any more — pick again');
} }
if (accounts.statusOf(media.serverId) == JellyfinAccountStatus.expired) { if (accounts.statusOf(media.serverId) == JellyfinAccountStatus.expired) {
throw const FormatException('Jellyfin sign-in expired — reconnect it in Integrations'); throw const FormatException('Jellyfin sign-in expired — reconnect it in Integrations');
} }
return client.streamUrl(media.itemId, mediaSourceId: media.mediaSourceId).toString(); return mediaForPick(media);
} }
/// Below this width the server column moves under the media picker. /// Below this width the server column moves under the media picker.
@@ -79,10 +78,10 @@ class HostTab extends StatelessWidget {
void _host() { void _host() {
if (flow.busy != null) return; if (flow.busy != null) return;
final media = fields.media.selection; final media = fields.media.selection;
String? link; HelloMedia? jellyfinMedia;
if (media is JellyfinMedia) { if (media is JellyfinMedia) {
try { try {
link = interimJellyfinLink(fields.jellyfin, media); jellyfinMedia = hostJellyfinMedia(fields.jellyfin, media);
} on FormatException catch (e) { } on FormatException catch (e) {
flow.showError(ConnectMode.host, CardError('Check the form', e.message)); flow.showError(ConnectMode.host, CardError('Check the form', e.message));
return; return;
@@ -96,7 +95,7 @@ class HostTab extends StatelessWidget {
..hostSecret = fields.secret.text ..hostSecret = fields.secret.text
..mediaPath = media is FileMedia ? media.path : '' ..mediaPath = media is FileMedia ? media.path : ''
..linkUrl = media is LinkMedia ? media.url : '', ..linkUrl = media is LinkMedia ? media.url : '',
linkOverride: link, media: jellyfinMedia,
); );
} }
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../../jellyfin/accounts.dart'; import '../../jellyfin/accounts.dart';
import '../../jellyfin/browse_scope.dart' show BrowseStart;
import '../../jellyfin/picker_source.dart'; import '../../jellyfin/picker_source.dart';
import '../../widgets/media_input.dart'; import '../../widgets/media_input.dart';
import 'picked_media.dart'; import 'picked_media.dart';
@@ -114,8 +115,17 @@ class MediaPicker extends StatefulWidget {
/// Jellyfin source only: the browser's "bigger" toggle. /// Jellyfin source only: the browser's "bigger" toggle.
final VoidCallback? onToggleExpanded; final VoidCallback? onToggleExpanded;
final bool expanded; final bool expanded;
/// Where the Jellyfin browser opens instead of its remembered location.
final BrowseStart? jellyfinStart;
/// False hides the File source (only the host can serve a file).
final bool allowFile;
/// Shown under the Link field instead of the default help.
final String? linkHelp;
const MediaPicker({ const MediaPicker({
super.key, super.key,
this.jellyfinStart,
this.allowFile = true,
this.linkHelp,
required this.controller, required this.controller,
this.onChanged, this.onChanged,
this.onSubmit, this.onSubmit,
@@ -221,6 +231,7 @@ class _MediaPickerState extends State<MediaPicker> {
onOpenIntegrations: widget.onOpenIntegrations, onOpenIntegrations: widget.onOpenIntegrations,
onToggleExpanded: widget.onToggleExpanded, onToggleExpanded: widget.onToggleExpanded,
expanded: widget.expanded, expanded: widget.expanded,
start: widget.jellyfinStart,
), ),
MediaKind.link => _FieldRow( MediaKind.link => _FieldRow(
key: const ValueKey('media-link'), key: const ValueKey('media-link'),
@@ -236,7 +247,9 @@ class _MediaPickerState extends State<MediaPicker> {
icon: const Icon(Icons.content_paste, size: 18), icon: const Icon(Icons.content_paste, size: 18),
label: const Text('Paste'), label: const Text('Paste'),
), ),
help: 'A direct http(s) link to a video file; everyone streams it from there.', help: widget.linkHelp ??
'A direct http(s) link to a video file; everyone streams it from there. '
'Jellyfin links are shared as the item, without your sign-in.',
notice: _c.notice, notice: _c.notice,
), ),
MediaKind.file => _FieldRow( MediaKind.file => _FieldRow(
@@ -260,17 +273,17 @@ class _MediaPickerState extends State<MediaPicker> {
child: SegmentedButton<MediaKind>( child: SegmentedButton<MediaKind>(
key: const ValueKey('media-source'), key: const ValueKey('media-source'),
showSelectedIcon: false, showSelectedIcon: false,
segments: const [ segments: [
ButtonSegment( const ButtonSegment(
value: MediaKind.jellyfin, value: MediaKind.jellyfin,
icon: Icon(Icons.video_library_outlined), icon: Icon(Icons.video_library_outlined),
label: Text('Jellyfin')), label: Text('Jellyfin')),
ButtonSegment(value: MediaKind.link, icon: Icon(Icons.link), label: Text('Link')), const ButtonSegment(value: MediaKind.link, icon: Icon(Icons.link), label: Text('Link')),
ButtonSegment( const ButtonSegment(
value: MediaKind.file, value: MediaKind.file,
icon: Icon(Icons.insert_drive_file_outlined), icon: Icon(Icons.insert_drive_file_outlined),
label: Text('File')), label: Text('File')),
], ].where((s) => widget.allowFile || s.value != MediaKind.file).toList(),
selected: {source}, selected: {source},
onSelectionChanged: (s) => _c.source = s.first, onSelectionChanged: (s) => _c.source = s.first,
), ),
@@ -17,6 +17,7 @@ const sessionShortcuts = [
('F', 'Toggle fullscreen'), ('F', 'Toggle fullscreen'),
('Tab', 'Open / close the people & chat panel'), ('Tab', 'Open / close the people & chat panel'),
('Ctrl+R', 'Reload the video'), ('Ctrl+R', 'Reload the video'),
('> or Shift+N', 'Put up the next Jellyfin episode'),
('Ctrl+1…6', 'Shaders: Anime4K mode A, B, C, A+A, B+B, C+A'), ('Ctrl+1…6', 'Shaders: Anime4K mode A, B, C, A+A, B+B, C+A'),
('Ctrl+0', 'Shaders off'), ('Ctrl+0', 'Shaders off'),
('Esc', 'Close panel or fullscreen, else ask to leave'), ('Esc', 'Close panel or fullscreen, else ask to leave'),
@@ -34,6 +35,7 @@ class SettingsPage extends StatelessWidget {
const PageTitle('Settings'), const PageTitle('Settings'),
const _Section(title: 'Profile', child: ProfileEditor()), const _Section(title: 'Profile', child: ProfileEditor()),
const _Section(title: 'Player', child: _HwdecSetting()), const _Section(title: 'Player', child: _HwdecSetting()),
const _Section(title: 'Jellyfin', child: _AutoAdvanceSetting()),
_Section(title: 'Updates', child: _UpdateSettings(controller: updates)), _Section(title: 'Updates', child: _UpdateSettings(controller: updates)),
const _Section(title: 'Keyboard shortcuts', child: _ShortcutList()), const _Section(title: 'Keyboard shortcuts', child: _ShortcutList()),
const _Section(title: 'Video quality & shaders', child: _ShaderSettings()), const _Section(title: 'Video quality & shaders', child: _ShaderSettings()),
@@ -290,6 +292,26 @@ class _UpdateSettings extends StatelessWidget {
} }
} }
class _AutoAdvanceSetting extends StatelessWidget {
const _AutoAdvanceSetting();
@override
Widget build(BuildContext context) {
final s = AppSettings.instance;
return SignalBuilder(
builder: (context) => SwitchListTile(
key: const ValueKey('auto-advance'),
contentPadding: EdgeInsets.zero,
title: const Text('Play the next episode automatically'),
subtitle: const Text('When an episode ends, a 10 s countdown puts up the next one; '
'anyone can cancel it on their screen.'),
value: s.autoAdvance.value,
onChanged: s.setAutoAdvance,
),
);
}
}
class _ShortcutList extends StatelessWidget { class _ShortcutList extends StatelessWidget {
const _ShortcutList(); const _ShortcutList();
@@ -0,0 +1,328 @@
import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
import '../../jellyfin/session_media.dart';
import '../../jellyfin/track_prefs.dart';
import '../../session/session_coordinator.dart' show AutoAdvance, autoAdvanceSecs;
// Jellyfin pieces of the session screen. Plain widgets fed by the
// coordinator's signals, so tests can pump them without the player.
/// Previous / next episode in the video controls. Nothing when there is
/// no such episode or this viewer cannot put it up (no account).
class EpisodeSkipButton extends StatelessWidget {
final EpisodeLink? episode;
final bool next;
final VoidCallback onPressed;
final FocusNode? focusNode;
const EpisodeSkipButton(
{super.key, required this.episode, required this.next, required this.onPressed, this.focusNode});
@override
Widget build(BuildContext context) {
final e = episode;
if (e == null) return const SizedBox.shrink();
return Tooltip(
message: '${next ? 'Next' : 'Previous'}: ${e.label} (${next ? '> or Shift+N' : '< or Shift+P'})',
child: IconButton(
key: ValueKey(next ? 'episode-next' : 'episode-previous'),
focusNode: focusNode,
color: Colors.white,
iconSize: 22,
visualDensity: VisualDensity.compact,
icon: Icon(next ? Icons.skip_next_rounded : Icons.skip_previous_rounded),
onPressed: onPressed,
),
);
}
}
/// Floating "Next episode ›" near the end of an episode: faint at rest,
/// lit on hover. Lives outside the auto-hiding controls, so it shows in
/// fullscreen and with the controls hidden.
class NextEpisodePill extends StatefulWidget {
final EpisodeLink episode;
final VoidCallback onPressed;
const NextEpisodePill({super.key, required this.episode, required this.onPressed});
@override
State<NextEpisodePill> createState() => _NextEpisodePillState();
}
class _NextEpisodePillState extends State<NextEpisodePill> {
bool _hover = false;
@override
Widget build(BuildContext context) {
final primary = Theme.of(context).colorScheme.primary;
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: Tooltip(
message: 'Next: ${widget.episode.label}',
child: AnimatedScale(
duration: const Duration(milliseconds: 150),
scale: _hover ? 1.06 : 1,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 150),
opacity: _hover ? 1 : 0.45,
child: GestureDetector(
key: const ValueKey('next-episode-pill'),
onTap: widget.onPressed,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 9),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.75),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: _hover ? primary : Colors.white24),
boxShadow: _hover
? [BoxShadow(color: primary.withValues(alpha: 0.45), blurRadius: 16)]
: const [],
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('Next episode', style: TextStyle(color: Colors.white, fontSize: 13)),
SizedBox(width: 4),
Icon(Icons.chevron_right, color: Colors.white, size: 18),
],
),
),
),
),
),
),
);
}
}
/// At the end of an episode: "Next: S1E7 title" with Play now, and while
/// the auto-advance countdown runs, the seconds left and Cancel.
class EndOfEpisodeCard extends StatelessWidget {
final EpisodeLink next;
final AutoAdvance? countdown;
final VoidCallback onPlayNow;
final VoidCallback onCancel;
const EndOfEpisodeCard({
super.key,
required this.next,
required this.countdown,
required this.onPlayNow,
required this.onCancel,
});
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
final c = countdown;
return Card(
key: const ValueKey('end-of-episode'),
color: Colors.black.withValues(alpha: 0.85),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 12, 12),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 380),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(c == null ? 'Up next' : 'Next episode in ${c.secondsLeft} s',
style: t.labelLarge?.copyWith(color: Colors.white70)),
const SizedBox(height: 4),
Text('Next: ${next.label}',
maxLines: 2, overflow: TextOverflow.ellipsis, style: t.titleMedium),
if (c != null) ...[
const SizedBox(height: 8),
LinearProgressIndicator(value: c.secondsLeft / autoAdvanceSecs),
],
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (c != null)
TextButton(
key: const ValueKey('auto-advance-cancel'),
onPressed: onCancel,
child: const Text('Cancel')),
const SizedBox(width: 8),
FilledButton.icon(
key: const ValueKey('play-next'),
onPressed: onPlayNow,
icon: const Icon(Icons.skip_next_rounded, size: 18),
label: Text(c == null ? 'Play next' : 'Play now'),
),
],
),
],
),
),
),
);
}
}
/// This viewer cannot play the Jellyfin media: say why and offer to link
/// an account (never a silent stall).
class JellyfinBlockedCard extends StatelessWidget {
final BlockedPlay blocked;
final VoidCallback onLinkAccount;
final VoidCallback onRetry;
const JellyfinBlockedCard(
{super.key, required this.blocked, required this.onLinkAccount, required this.onRetry});
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Card(
key: const ValueKey('jellyfin-blocked'),
color: Colors.black.withValues(alpha: 0.85),
child: Padding(
padding: const EdgeInsets.all(20),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.lock_outline, color: Colors.orange, size: 32),
const SizedBox(height: 10),
Text(blocked.message, textAlign: TextAlign.center, style: t.titleSmall),
const SizedBox(height: 6),
Text('Link your own account in Integrations, or ask the host to relay it.',
textAlign: TextAlign.center, style: t.bodySmall?.copyWith(color: Colors.grey)),
const SizedBox(height: 14),
Wrap(
spacing: 8,
children: [
FilledButton(
key: const ValueKey('link-jellyfin-account'),
onPressed: onLinkAccount,
child: const Text('Link a Jellyfin account')),
TextButton(onPressed: onRetry, child: const Text('Try again')),
],
),
],
),
),
),
);
}
}
/// Small member-list badge: plays the Jellyfin item through the host.
class ViaHostBadge extends StatelessWidget {
const ViaHostBadge({super.key});
@override
Widget build(BuildContext context) {
return Tooltip(
message: 'No Jellyfin account here: watches through the host',
child: Container(
key: const ValueKey('via-host'),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: Colors.white10,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: Colors.white24),
),
child: const Text('via host', style: TextStyle(fontSize: 10, color: Colors.white70)),
),
);
}
}
/// A track pick from [TracksControlButton]: audio id, subtitle id, or off.
typedef TrackPick = ({int? aid, int? sid, bool subtitlesOff});
/// Audio / subtitle menu for the controls bar. The choice is remembered
/// for the next Jellyfin item (per viewer).
class TracksControlButton extends StatelessWidget {
final FocusNode? focusNode;
final List<MpvTrack> Function() tracks;
final Future<({int? aid, int? sid})> Function() current;
final void Function(TrackPick pick) onPick;
final ValueChanged<bool>? onMenuOpen;
const TracksControlButton({
super.key,
this.focusNode,
required this.tracks,
required this.current,
required this.onPick,
this.onMenuOpen,
});
@override
Widget build(BuildContext context) {
return Tooltip(
message: 'Audio and subtitles',
child: IconButton(
key: const ValueKey('tracks-button'),
focusNode: focusNode,
color: Colors.white,
iconSize: 20,
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.subtitles_outlined),
onPressed: () => _open(context),
),
);
}
Future<void> _open(BuildContext context) async {
final list = tracks();
final button = context.findRenderObject() as RenderBox;
final overlay = Navigator.of(context).overlay!.context.findRenderObject() as RenderBox;
final position = RelativeRect.fromRect(
Rect.fromPoints(
button.localToGlobal(Offset.zero, ancestor: overlay),
button.localToGlobal(button.size.bottomRight(Offset.zero), ancestor: overlay),
),
Offset.zero & overlay.size,
);
final navigator = Navigator.of(context);
final now = await current();
if (!navigator.mounted) return;
final audio = list.where((t) => t.type == JellyfinStreamType.audio).toList();
final subs = list.where((t) => t.type == JellyfinStreamType.subtitle).toList();
PopupMenuItem<TrackPick> header(String text) => PopupMenuItem(
enabled: false,
height: 28,
child: Text(text, style: const TextStyle(fontSize: 12, color: Colors.grey)));
onMenuOpen?.call(true);
final pick = await showMenu<TrackPick>(
context: navigator.context,
position: position,
popUpAnimationStyle: AnimationStyle.noAnimation,
constraints: const BoxConstraints(minWidth: 280, maxWidth: 420),
items: [
header('Audio'),
if (audio.isEmpty)
const PopupMenuItem(enabled: false, height: 32, child: Text('None')),
for (final t in audio)
CheckedPopupMenuItem(
value: (aid: t.id, sid: null, subtitlesOff: false),
checked: now.aid == t.id,
height: 36,
child: Text(t.label, maxLines: 1, overflow: TextOverflow.ellipsis),
),
const PopupMenuDivider(),
header('Subtitles'),
CheckedPopupMenuItem(
value: (aid: null, sid: null, subtitlesOff: true),
checked: now.sid == null,
height: 36,
child: const Text('Off'),
),
for (final t in subs)
CheckedPopupMenuItem(
value: (aid: null, sid: t.id, subtitlesOff: false),
checked: now.sid == t.id,
height: 36,
child: Text(t.label, maxLines: 1, overflow: TextOverflow.ellipsis),
),
],
);
onMenuOpen?.call(false);
if (pick != null) onPick(pick);
}
}
@@ -0,0 +1,136 @@
import 'package:flutter/material.dart';
import '../../jellyfin/accounts.dart';
import '../../jellyfin/browse_scope.dart' show BrowseStart;
import '../home/media_picker.dart';
/// "Change video" in a session: the same [MediaPicker] as Home's Host tab
/// (Jellyfin browser, Link, File) and a button that puts the pick up for
/// everyone. File is offered to the host only (the file streams from the
/// host's disk). [onSwap] returns an error text, or null when sent.
class SwapPanel extends StatefulWidget {
final MediaPickerController controller;
final JellyfinAccounts accounts;
final bool isHost;
final BrowseStart? jellyfinStart;
final Future<String?> Function(PickedMedia media) onSwap;
final VoidCallback onClose;
final VoidCallback? onOpenIntegrations;
const SwapPanel({
super.key,
required this.controller,
required this.accounts,
required this.isHost,
required this.onSwap,
required this.onClose,
this.jellyfinStart,
this.onOpenIntegrations,
});
@override
State<SwapPanel> createState() => _SwapPanelState();
}
class _SwapPanelState extends State<SwapPanel> {
bool _busy = false;
String? _error;
@override
void initState() {
super.initState();
final c = widget.controller;
if (!widget.isHost && c.source == MediaKind.file) c.source = MediaKind.link;
}
Future<void> _swap() async {
final media = widget.controller.selection;
if (media == null || _busy) return;
setState(() {
_busy = true;
_error = null;
});
String? error;
try {
error = await widget.onSwap(media);
} catch (e) {
error = '$e';
}
if (!mounted) return;
setState(() {
_busy = false;
_error = error;
});
if (error == null) widget.onClose();
}
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Material(
key: const ValueKey('swap-panel'),
elevation: 12,
borderRadius: BorderRadius.circular(16),
clipBehavior: Clip.antiAlias,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 12, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(child: Text('Change video', style: t.titleLarge)),
IconButton(
tooltip: 'Close (Esc)', icon: const Icon(Icons.close), onPressed: widget.onClose),
],
),
const SizedBox(height: 8),
Expanded(
child: Padding(
padding: const EdgeInsets.only(right: 8),
child: MediaPicker(
controller: widget.controller,
jellyfin: widget.accounts,
enabled: !_busy,
allowFile: widget.isHost,
jellyfinStart: widget.jellyfinStart,
onOpenIntegrations: widget.onOpenIntegrations,
onSubmit: _swap,
),
),
),
const SizedBox(height: 12),
ListenableBuilder(
listenable: widget.controller,
builder: (context, _) {
final sel = widget.controller.selection;
return Row(
children: [
Expanded(
child: Text(
_error ?? (sel == null ? 'Pick something to watch' : 'Put up: ${sel.label}'),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: t.bodyMedium?.copyWith(
color: _error != null ? Theme.of(context).colorScheme.error : null),
),
),
const SizedBox(width: 12),
FilledButton.icon(
key: const ValueKey('swap-go'),
onPressed: sel == null || _busy ? null : _swap,
icon: _busy
? const SizedBox(
width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.swap_horiz),
label: const Text('Put it up for everyone'),
),
],
);
},
),
],
),
),
);
}
}
+219 -36
View File
@@ -4,6 +4,8 @@ import 'package:media_kit_video/media_kit_video.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import '../jellyfin/browse_scope.dart' show BrowseStart;
import '../jellyfin/session_media.dart';
import '../session/health.dart'; import '../session/health.dart';
import '../session/session_coordinator.dart'; import '../session/session_coordinator.dart';
import '../shaders/shader_menu.dart'; import '../shaders/shader_menu.dart';
@@ -17,6 +19,10 @@ import '../widgets/shown_frame_probe.dart';
import '../widgets/text_focus.dart'; import '../widgets/text_focus.dart';
import '../widgets/window_buttons.dart'; import '../widgets/window_buttons.dart';
import '../widgets/window_drag_layer.dart'; import '../widgets/window_drag_layer.dart';
import 'home/integrations_page.dart';
import 'home/media_picker.dart';
import 'session/jellyfin_widgets.dart';
import 'session/swap_panel.dart';
class SessionScreen extends StatefulWidget { class SessionScreen extends StatefulWidget {
const SessionScreen({super.key}); const SessionScreen({super.key});
@@ -38,7 +44,10 @@ class _SessionScreenState extends State<SessionScreen> {
DateTime? _lastKeyAt; DateTime? _lastKeyAt;
final _chat = TextEditingController(); final _chat = TextEditingController();
final _chatFocus = FocusNode(); final _chatFocus = FocusNode();
final _swapLink = TextEditingController(); // "Change video": the shared media picker over the video.
final _swapOpen = signal(false);
MediaPickerController? _swapPicker;
bool _integrationsOpen = false;
late final void Function() _stopKickWatch; late final void Function() _stopKickWatch;
late final void Function() _stopSheetWatch; late final void Function() _stopSheetWatch;
late final void Function() _stopPlayerWatch; late final void Function() _stopPlayerWatch;
@@ -54,6 +63,7 @@ class _SessionScreenState extends State<SessionScreen> {
super.initState(); super.initState();
final session = SessionState.instance; final session = SessionState.instance;
_noFocus = FocusNode(skipTraversal: true, canRequestFocus: false); _noFocus = FocusNode(skipTraversal: true, canRequestFocus: false);
session.coordinator.jellyfin.ensureLoaded();
session.coordinator.playback.shownFrame = _frameProbe.sample; session.coordinator.playback.shownFrame = _frameProbe.sample;
HardwareKeyboard.instance.addHandler(_globalKey); HardwareKeyboard.instance.addHandler(_globalKey);
_stopKickWatch = effect(() { _stopKickWatch = effect(() {
@@ -144,14 +154,70 @@ class _SessionScreenState extends State<SessionScreen> {
_stopShaderWatch(); _stopShaderWatch();
_chat.dispose(); _chat.dispose();
_chatFocus.dispose(); _chatFocus.dispose();
_swapLink.dispose(); _swapPicker?.dispose();
_noFocus.dispose(); _noFocus.dispose();
super.dispose(); super.dispose();
} }
/// Opens the swap panel: the browser starts on the series of the
/// episode playing (season selected, episode highlighted), else where it
/// was last.
void _openSwap() {
final coord = SessionState.instance.coordinator;
coord.markInput();
final ref = coord.currentRef.peek();
final accounts = coord.jellyfin;
final pick = ref == null ? null : pickForRef(accounts, ref);
final plan = coord.jellyfinPlan.peek();
_swapPicker?.dispose();
_swapPicker = MediaPickerController(
jellyfin: pick,
source: pick != null || (accounts.loaded && !accounts.isEmpty)
? MediaKind.jellyfin
: MediaKind.link,
);
_swapStart = ref != null && plan is OwnPlay && plan.item != null
? BrowseStart.seriesOf(ref.serverId, plan.item!)
: null;
_sheetOpen.value = false;
_swapOpen.value = true;
}
BrowseStart? _swapStart;
void _closeSwap() {
_swapOpen.value = false;
FocusManager.instance.primaryFocus?.unfocus();
}
/// Integrations over the session (link an account for a blocked item).
Future<void> _openIntegrations() async {
if (_integrationsOpen) return;
_integrationsOpen = true;
try {
await Navigator.of(context, rootNavigator: true).push(MaterialPageRoute<void>(
builder: (_) => Scaffold(
appBar: AppBar(title: const Text('Integrations')),
body: IntegrationsPage(jellyfin: SessionState.instance.coordinator.jellyfin),
),
));
} finally {
_integrationsOpen = false;
}
}
bool _globalKey(KeyEvent event) { bool _globalKey(KeyEvent event) {
if (event is! KeyDownEvent) return false; if (event is! KeyDownEvent) return false;
if (SessionState.instance.route.peek() != AppRoute.session) return false; if (SessionState.instance.route.peek() != AppRoute.session) return false;
if (_integrationsOpen) return false;
if (_swapOpen.peek()) {
// The picker owns the keyboard; Esc outside a field closes it.
if (event.logicalKey == LogicalKeyboardKey.escape && !textInputFocused()) {
_closeSwap();
return true;
}
return false;
}
if (textInputFocused()) return false; if (textInputFocused()) return false;
final now = DateTime.now().toUtc(); final now = DateTime.now().toUtc();
if (_lastKeyAt != null && now.difference(_lastKeyAt!).inMilliseconds < 350) { if (_lastKeyAt != null && now.difference(_lastKeyAt!).inMilliseconds < 350) {
@@ -177,6 +243,20 @@ class _SessionScreenState extends State<SessionScreen> {
coord.toggleReady(); coord.toggleReady();
return true; return true;
} }
// mpv's playlist keys (< >) and Shift+P / Shift+N: Jellyfin episodes.
final shift = HardwareKeyboard.instance.isShiftPressed;
if (event.character == '>' || (shift && event.logicalKey == LogicalKeyboardKey.keyN)) {
if (coord.nextEpisode.peek() == null) return false;
_lastKeyAt = now;
coord.playNextEpisode();
return true;
}
if (event.character == '<' || (shift && event.logicalKey == LogicalKeyboardKey.keyP)) {
if (coord.previousEpisode.peek() == null) return false;
_lastKeyAt = now;
coord.playPreviousEpisode();
return true;
}
if (event.logicalKey == LogicalKeyboardKey.tab) { if (event.logicalKey == LogicalKeyboardKey.tab) {
_lastKeyAt = now; _lastKeyAt = now;
_sheetOpen.value = !_sheetOpen.peek(); _sheetOpen.value = !_sheetOpen.peek();
@@ -254,12 +334,35 @@ class _SessionScreenState extends State<SessionScreen> {
), ),
], ],
bottomButtonBar: [ bottomButtonBar: [
SignalBuilder(
builder: (context) => EpisodeSkipButton(
focusNode: _noFocus,
episode: coord.previousEpisode.value,
next: false,
onPressed: coord.playPreviousEpisode,
),
),
SignalBuilder(
builder: (context) => EpisodeSkipButton(
focusNode: _noFocus,
episode: coord.nextEpisode.value,
next: true,
onPressed: coord.playNextEpisode,
),
),
const MaterialDesktopVolumeButton(), const MaterialDesktopVolumeButton(),
_ReadyControlButton(focusNode: _noFocus), _ReadyControlButton(focusNode: _noFocus),
_MarkAllReadyControlButton(focusNode: _noFocus), _MarkAllReadyControlButton(focusNode: _noFocus),
_ReloadVideoControlButton(focusNode: _noFocus, onPressed: _reloadVideo), _ReloadVideoControlButton(focusNode: _noFocus, onPressed: _reloadVideo),
const MaterialDesktopPositionIndicator(), const MaterialDesktopPositionIndicator(),
const Spacer(), const Spacer(),
TracksControlButton(
focusNode: _noFocus,
tracks: () => coord.playback.tracks,
current: coord.playback.currentTrackIds,
onPick: (p) => coord.selectTrack(aid: p.aid, sid: p.sid, subtitlesOff: p.subtitlesOff),
onMenuOpen: (open) => _shaderMenuOpen = open,
),
ShaderControlButton( ShaderControlButton(
focusNode: _noFocus, focusNode: _noFocus,
renderMode: coord.playback.renderMode, renderMode: coord.playback.renderMode,
@@ -320,8 +423,10 @@ class _SessionScreenState extends State<SessionScreen> {
MaterialDesktopVideoControls(state), MaterialDesktopVideoControls(state),
_healthOverlay(), _healthOverlay(),
_waitingOverlay(), _waitingOverlay(),
_jellyfinOverlay(),
_toastsOverlay(), _toastsOverlay(),
_sheetOverlay(), _sheetOverlay(),
_swapOverlay(),
_leaveOverlay(), _leaveOverlay(),
Positioned.fill( Positioned.fill(
child: SignalBuilder( child: SignalBuilder(
@@ -329,7 +434,7 @@ class _SessionScreenState extends State<SessionScreen> {
final sheetOpen = _sheetOpen.value; final sheetOpen = _sheetOpen.value;
final confirm = _confirmLeave.value; final confirm = _confirmLeave.value;
return WindowDragLayer( return WindowDragLayer(
enabled: !confirm, enabled: !confirm && !_swapOpen.value,
exclusions: [ exclusions: [
(s) => Rect.fromLTWH(0, s.height - 120, s.width, 120), (s) => Rect.fromLTWH(0, s.height - 120, s.width, 120),
if (sheetOpen) (s) => Rect.fromLTWH(s.width - 380, 0, 380, s.height), if (sheetOpen) (s) => Rect.fromLTWH(s.width - 380, 0, 380, s.height),
@@ -469,6 +574,94 @@ class _SessionScreenState extends State<SessionScreen> {
); );
} }
/// Jellyfin: the blocked card, the end-of-episode card (countdown) and
/// the "Next episode" pill. Outside the auto-hiding controls, so they
/// also show in fullscreen.
Widget _jellyfinOverlay() {
final coord = SessionState.instance.coordinator;
return Positioned.fill(
child: SignalBuilder(
builder: (context) {
final blocked = coord.jellyfinBlocked.value;
final next = coord.nextEpisode.value;
final countdown = coord.autoAdvance.value;
final finished = coord.serverState.value?.transport == 'finished';
final pill = coord.nextPromptVisible.value;
return Stack(
children: [
if (blocked != null)
Center(
child: JellyfinBlockedCard(
blocked: blocked,
onLinkAccount: _openIntegrations,
onRetry: coord.retryJellyfin,
),
),
if (next != null && (finished || countdown != null))
Positioned(
right: 24,
bottom: 110,
child: EndOfEpisodeCard(
next: next,
countdown: countdown,
onPlayNow: coord.playNextEpisode,
onCancel: coord.cancelAutoAdvance,
),
)
else if (next != null && pill)
Positioned(
right: 24,
bottom: 96,
child: NextEpisodePill(episode: next, onPressed: coord.playNextEpisode),
),
],
);
},
),
);
}
Widget _swapOverlay() {
final coord = SessionState.instance.coordinator;
return SignalBuilder(
builder: (context) {
final picker = _swapPicker;
if (!_swapOpen.value || picker == null) return const SizedBox.shrink();
return Positioned.fill(
child: Stack(
children: [
Positioned.fill(
child: GestureDetector(
onTap: _closeSwap,
child: Container(color: Colors.black54),
),
),
Positioned.fill(
child: Padding(
padding: const EdgeInsets.fromLTRB(32, 48, 32, 32),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1400),
child: SwapPanel(
controller: picker,
accounts: coord.jellyfin,
isHost: coord.hostFile != null,
jellyfinStart: _swapStart,
onSwap: coord.swapPicked,
onClose: _closeSwap,
onOpenIntegrations: _openIntegrations,
),
),
),
),
),
],
),
);
},
);
}
Widget _toastsOverlay() { Widget _toastsOverlay() {
final coord = SessionState.instance.coordinator; final coord = SessionState.instance.coordinator;
return Positioned( return Positioned(
@@ -526,7 +719,7 @@ class _SessionScreenState extends State<SessionScreen> {
child: _SheetPanel( child: _SheetPanel(
chat: _chat, chat: _chat,
chatFocus: _chatFocus, chatFocus: _chatFocus,
swapLink: _swapLink, onChangeVideo: _openSwap,
onClose: () => _sheetOpen.value = false, onClose: () => _sheetOpen.value = false,
onLeave: () { onLeave: () {
_sheetOpen.value = false; _sheetOpen.value = false;
@@ -697,10 +890,10 @@ class _ToastCardState extends State<_ToastCard> {
class _SheetPanel extends StatelessWidget { class _SheetPanel extends StatelessWidget {
final TextEditingController chat; final TextEditingController chat;
final FocusNode chatFocus; final FocusNode chatFocus;
final TextEditingController swapLink; final VoidCallback onChangeVideo;
final VoidCallback onClose; final VoidCallback onClose;
final VoidCallback onLeave; final VoidCallback onLeave;
const _SheetPanel({required this.chat, required this.chatFocus, required this.swapLink, required this.onClose, required this.onLeave}); const _SheetPanel({required this.chat, required this.chatFocus, required this.onChangeVideo, required this.onClose, required this.onLeave});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -750,7 +943,12 @@ class _SheetPanel extends StatelessWidget {
const Divider(height: 20), const Divider(height: 20),
Text('Users (${members.length})', style: sectionStyle), Text('Users (${members.length})', style: sectionStyle),
const SizedBox(height: 2), const SizedBox(height: 2),
for (final m in members) _MemberRow(member: m, isSelf: m.id == coord.sync.memberId), for (final m in members)
_MemberRow(
member: m,
isSelf: m.id == coord.sync.memberId,
viaHost: viaHostOf(m, coord.currentRef.value),
),
const Divider(height: 20), const Divider(height: 20),
Text('Activity', style: sectionStyle), Text('Activity', style: sectionStyle),
const SizedBox(height: 6), const SizedBox(height: 6),
@@ -790,34 +988,14 @@ class _SheetPanel extends StatelessWidget {
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Text('Share a link', style: sectionStyle), SizedBox(
const SizedBox(height: 8), width: double.infinity,
Row( child: OutlinedButton.icon(
crossAxisAlignment: CrossAxisAlignment.start, key: const ValueKey('change-video'),
children: [ icon: const Icon(Icons.swap_horiz),
Expanded( label: const Text('Change video'),
child: TextField( onPressed: onChangeVideo,
controller: swapLink, ),
decoration: const InputDecoration(labelText: 'Swap to link URL', border: OutlineInputBorder(), isDense: true),
),
),
const SizedBox(width: 8),
ValueListenableBuilder<TextEditingValue>(
valueListenable: swapLink,
builder: (context, value, _) {
final link = value.text.trim();
return IconButton(
icon: const Icon(Icons.swap_horiz),
onPressed: link.isEmpty
? null
: () {
coord.swapLink(link);
swapLink.clear();
},
);
},
),
],
), ),
const Divider(height: 20), const Divider(height: 20),
SizedBox( SizedBox(
@@ -880,10 +1058,14 @@ class _StatusChip extends StatelessWidget {
} }
} }
/// The member plays the current Jellyfin item through the host's relay.
bool viaHostOf(MemberView m, JellyfinRef? ref) => ref != null && m.jf[ref.serverId] == false;
class _MemberRow extends StatelessWidget { class _MemberRow extends StatelessWidget {
final MemberView member; final MemberView member;
final bool isSelf; final bool isSelf;
const _MemberRow({required this.member, required this.isSelf}); final bool viaHost;
const _MemberRow({required this.member, required this.isSelf, this.viaHost = false});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -920,6 +1102,7 @@ class _MemberRow extends StatelessWidget {
status.$1, status.$1,
style: theme.textTheme.bodySmall?.copyWith(color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.75)), style: theme.textTheme.bodySmall?.copyWith(color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.75)),
), ),
if (viaHost) ...[const SizedBox(width: 6), const ViaHostBadge()],
], ],
), ),
], ],
@@ -4,8 +4,14 @@ import 'package:flutter/widgets.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.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 '../playback/playback_controller.dart';
import '../screens/home/picked_media.dart';
import '../sfx/sfx.dart'; import '../sfx/sfx.dart';
import '../state/settings.dart';
import '../sync/state_diff.dart'; import '../sync/state_diff.dart';
import '../sync/sync_client.dart'; import '../sync/sync_client.dart';
import '../sync/user_seek.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 /// Display name for a link: last path segment, decoded, never the query
/// (Jellyfin-style links carry api_key there and names are broadcast). /// (Jellyfin-style links carry api_key there and names are broadcast).
String linkDisplayName(String url) { String linkDisplayName(String url) => linkName(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';
}
class ConnectParams { class ConnectParams {
final String host; final String host;
@@ -45,6 +39,9 @@ class ConnectParams {
final String linkUrl; final String linkUrl;
/// Sent in hello by the hosting app's own client (see AppConfig.hostKey). /// Sent in hello by the hosting app's own client (see AppConfig.hostKey).
final String 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({ const ConnectParams({
required this.host, required this.host,
required this.port, required this.port,
@@ -54,9 +51,37 @@ class ConnectParams {
this.mediaPath = '', this.mediaPath = '',
this.linkUrl = '', this.linkUrl = '',
this.hostKey = '', 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 { class ToastItem {
final int id; final int id;
final String text; final String text;
@@ -76,6 +101,13 @@ String? stateMediaUrl(StateBroadcast state) {
class SessionCoordinator { class SessionCoordinator {
final SfxService sfx; 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 SyncClient sync = SyncClient();
final PlaybackController playback = PlaybackController(); final PlaybackController playback = PlaybackController();
final StateDiff diff = StateDiff(); final StateDiff diff = StateDiff();
@@ -89,6 +121,21 @@ class SessionCoordinator {
final kickReason = signal<String?>(null); final kickReason = signal<String?>(null);
final starving = 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(() { late final Computed<MemberView?> selfMember = computed(() {
final r = serverState.value; final r = serverState.value;
if (r == null || sync.memberId.isEmpty) return null; if (r == null || sync.memberId.isEmpty) return null;
@@ -139,8 +186,25 @@ class SessionCoordinator {
int _toastId = 0; int _toastId = 0;
ConnectParams? _params; ConnectParams? _params;
bool _everConnected = false; 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 /// Player lifetime = one session: created here on connect, disposed by
/// [leave]. Never reused across sessions (a GPU-side video freeze sticks /// [leave]. Never reused across sessions (a GPU-side video freeze sticks
@@ -175,9 +239,10 @@ class SessionCoordinator {
try { try {
await playback.recreate(); await playback.recreate();
final url = _openUrl; final url = _openUrl;
final headers = _openHeaders;
_openUrl = null; _openUrl = null;
_clearPendingSeek(); _clearPendingSeek();
if (url != null) await _openMedia(url); if (url != null) await _openMedia(url, headers: headers);
} catch (e, st) { } catch (e, st) {
debugPrint('video reload failed: $e\n$st'); debugPrint('video reload failed: $e\n$st');
health.value = health.value =
@@ -221,6 +286,12 @@ class SessionCoordinator {
_seekGate.reset(); _seekGate.reset();
_clearPendingSeek(); _clearPendingSeek();
_lastEventId = -1; _lastEventId = -1;
_resetJellyfin();
_jfToastShown = false;
if (!_jellyfinListening) {
_jellyfinListening = true;
jellyfin.addListener(_onAccountsChanged);
}
markInput(); markInput();
_subscribe(); _subscribe();
feedLog.value = []; feedLog.value = [];
@@ -252,13 +323,10 @@ class SessionCoordinator {
health.value = const HealthStatus( health.value = const HealthStatus(
HealthSeverity.info, 'connected — waiting for host media…'); HealthSeverity.info, 'connected — waiting for host media…');
} }
if (params.linkUrl.isNotEmpty) { final media = params.media ??
sync.sendSwap(HelloMedia( (params.linkUrl.isEmpty ? null : await mediaForLink(jellyfin, params.linkUrl));
name: linkDisplayName(params.linkUrl), if (epoch != _epoch) throw const ConnectCancelled();
size: 0, if (media != null) sync.sendSwap(media);
duration: 0,
url: params.linkUrl));
}
_startTicker(); _startTicker();
} catch (e) { } catch (e) {
if (epoch != _epoch) throw const ConnectCancelled(); if (epoch != _epoch) throw const ConnectCancelled();
@@ -286,19 +354,23 @@ class SessionCoordinator {
}); });
} }
Future<void> _openMedia(String url) async { /// Opens [url] (with [headers] on every request); false on failure.
if (url == _openUrl) return; Future<bool> _openMedia(String url, {Map<String, String>? headers}) async {
if (url == _openUrl) return true;
_openUrl = url; _openUrl = url;
_openHeaders = headers;
diff.reset(); diff.reset();
_opening = true; _opening = true;
try { try {
await playback.openUrl(url); await playback.openUrl(url, headers: headers);
health.value = health.value =
const HealthStatus(HealthSeverity.ok, 'connected'); const HealthStatus(HealthSeverity.ok, 'connected');
return true;
} catch (e) { } catch (e) {
debugPrint('open media failed: $e'); debugPrint('open media failed: $e');
health.value = health.value =
HealthStatus(HealthSeverity.error, 'cannot open media: $e'); HealthStatus(HealthSeverity.error, 'cannot open media: $e');
return false;
} finally { } finally {
_opening = false; _opening = false;
} }
@@ -308,28 +380,73 @@ class SessionCoordinator {
return 'http://$_wsHost:$_wsPort/m/default/${sync.token}/${Uri.encodeComponent(name)}'; 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(); _lastStateAt = DateTime.now().toUtc();
serverState.value = r; 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); _trackSelfReady(r);
_seekGate.observe(r, sync.memberId); _seekGate.observe(r, sync.memberId);
final newEvent = r.eventId != _lastEventId; final newEvent = r.eventId != _lastEventId;
_lastEventId = r.eventId; _lastEventId = r.eventId;
if (r.mediaId != _mediaId) { if (r.mediaId != _mediaId) {
_mediaId = r.mediaId; _mediaId = r.mediaId;
var url = stateMediaUrl(r); final ref = refOfStateMedia(r.media);
if ((url == null || url.isEmpty) && sync.token.isNotEmpty) { // Back after a dropped connection on the same item played with our
final name = (r.media?['name'] as String?) ?? ''; // own account: nothing to resolve or reopen.
if (name.isNotEmpty) url = _fileUrlFor(name); final same = ref != null &&
} r.mediaId == _jfMediaId &&
if (url != null && url.isNotEmpty) { sameJellyfinItem(ref, _jfRef) &&
await _openMedia(url); jellyfinPlan.peek() is OwnPlay;
} else { if (!same) {
_openUrl = null; _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(); _seekGate.reset();
_clearPendingSeek(); _clearPendingSeek();
} }
_jellyfinTransport(r);
if (!_playerReady || _openUrl == null) { if (!_playerReady || _openUrl == null) {
_wasPlayAllowed = false; _wasPlayAllowed = false;
_updateStarving(r, 0); _updateStarving(r, 0);
@@ -475,6 +592,7 @@ class SessionCoordinator {
// Stop, not pause: a paused player keeps filling its cache from the // Stop, not pause: a paused player keeps filling its cache from the
// host's media URL. // host's media URL.
_openUrl = null; _openUrl = null;
await _stopJellyfin();
try { try {
await playback.stop(); await playback.stop();
} catch (_) {} } catch (_) {}
@@ -504,8 +622,10 @@ class SessionCoordinator {
eventId: r.eventId, eventId: r.eventId,
duration: known ? playback.durationSecs : 0, duration: known ? playback.durationSecs : 0,
mediaId: known ? r.mediaId : 0, mediaId: known ? r.mediaId : 0,
jf: _jfAccessFor(r),
); );
_checkSeekLanded(r); _checkSeekLanded(r);
unawaited(_tickJellyfin(r));
} }
if (!_manualClose && if (!_manualClose &&
kickReason.peek() == null && kickReason.peek() == null &&
@@ -661,10 +781,362 @@ class SessionCoordinator {
sync.sendKick(memberId, reason: reason); 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 { Future<void> swapLink(String url) async {
markInput(); markInput();
sync.sendSwap(HelloMedia( sync.sendSwap(await mediaForLink(jellyfin, url));
name: linkDisplayName(url), size: 0, duration: 0, url: 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}) { void _scheduleReconnect({bool manual = false}) {
@@ -790,6 +1262,7 @@ class SessionCoordinator {
_seekTimer = null; _seekTimer = null;
_seekGate.reset(); _seekGate.reset();
reconnecting.value = false; reconnecting.value = false;
await _stopJellyfin();
_playerReady = false; _playerReady = false;
try { try {
await playback.dispose(); await playback.dispose();
@@ -806,6 +1279,10 @@ class SessionCoordinator {
Future<void> dispose() async { Future<void> dispose() async {
await leave(silent: true); await leave(silent: true);
if (_jellyfinListening) {
_jellyfinListening = false;
jellyfin.removeListener(_onAccountsChanged);
}
await _stateSub?.cancel(); await _stateSub?.cancel();
await _feedSub?.cancel(); await _feedSub?.cancel();
await _kickSub?.cancel(); await _kickSub?.cancel();
@@ -131,6 +131,27 @@ class ServerIsolate {
if (!_stopping) crash?.call(_lastError ?? 'the server stopped unexpectedly'); if (!_stopping) crash?.call(_lastError ?? 'the server stopped unexpectedly');
} }
/// Makes [path] the hosted file (the in-session File swap). Returns its
/// name and size for the `swap` that puts it up, or null when the file
/// is missing or the server is gone.
Future<({String name, int size})?> setFile(String path,
{Duration timeout = const Duration(seconds: 5)}) async {
if (_exited.isCompleted) return null;
final reply = ReceivePort('server file');
try {
_stop.send({'t': 'file', 'path': path, 'reply': reply.sendPort});
final msg = await reply.first.timeout(timeout);
if (msg is Map && msg['ok'] == true) {
return (name: msg['name'] as String, size: msg['size'] as int);
}
return null;
} catch (_) {
return null;
} finally {
reply.close();
}
}
/// Asks the server to close its sockets and exit; kills the isolate if it /// Asks the server to close its sockets and exit; kills the isolate if it
/// has not exited within [timeout]. /// has not exited within [timeout].
Future<void> stop({Duration timeout = const Duration(seconds: 3)}) async { Future<void> stop({Duration timeout = const Duration(seconds: 3)}) async {
+16 -1
View File
@@ -6,6 +6,8 @@ import 'package:flutter/widgets.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import '../jellyfin/accounts.dart';
import '../jellyfin/session_media.dart';
import '../sfx/sfx.dart'; import '../sfx/sfx.dart';
import '../session/health.dart'; import '../session/health.dart';
import '../session/session_coordinator.dart'; import '../session/session_coordinator.dart';
@@ -154,6 +156,7 @@ class SessionState {
final SfxService sfx = const NoopSfxService(); final SfxService sfx = const NoopSfxService();
late final SessionCoordinator coordinator = SessionCoordinator(sfx: sfx); late final SessionCoordinator coordinator = SessionCoordinator(sfx: sfx);
final lastForm = ConnectForm(); final lastForm = ConnectForm();
JellyfinAccounts get jellyfin => JellyfinAccounts.instance;
/// Set when the hosted server died mid-session; Home shows it on the /// Set when the hosted server died mid-session; Home shows it on the
/// Host card and clears it. /// Host card and clears it.
final serverCrash = signal<String?>(null); final serverCrash = signal<String?>(null);
@@ -232,6 +235,14 @@ class SessionState {
if (op != _op) throw const ConnectCancelled(); if (op != _op) throw const ConnectCancelled();
final hostKey = newHostKey(); final hostKey = newHostKey();
_setupCrash = null; _setupCrash = null;
// A pasted Jellyfin link goes up as a ref like a browser pick.
final media = params.media ??
(params.linkUrl.isEmpty ? null : await mediaForLink(jellyfin, params.linkUrl));
if (op != _op) throw const ConnectCancelled();
// Guests without an account watch through the server, with the
// host's own sign-in (the hosted item's server, else the default).
await jellyfin.ensureLoaded(validate: false);
final relay = relayCredentialFor(jellyfin, serverId: media?.jellyfin?.serverId);
late final ServerIsolate server; late final ServerIsolate server;
try { try {
server = await ServerIsolate.start( server = await ServerIsolate.start(
@@ -241,6 +252,7 @@ class SessionState {
name: 'server', name: 'server',
mediaPath: params.mediaPath, mediaPath: params.mediaPath,
hostKey: hostKey, hostKey: hostKey,
jellyfin: relay,
), ),
onLog: (line) => debugPrint('[server] $line'), onLog: (line) => debugPrint('[server] $line'),
onCrash: (cause) => _onServerCrash(server, cause), onCrash: (cause) => _onServerCrash(server, cause),
@@ -256,6 +268,8 @@ class SessionState {
throw const ConnectCancelled(); throw const ConnectCancelled();
} }
_server = server; _server = server;
coordinator.hostFile = (path) async =>
identical(_server, server) ? server.setFile(path) : null;
status.value = status.value =
HealthStatus(HealthSeverity.ok, 'hosting on port ${server.port}'); HealthStatus(HealthSeverity.ok, 'hosting on port ${server.port}');
try { try {
@@ -265,8 +279,8 @@ class SessionState {
secret: params.secret, secret: params.secret,
name: params.name, name: params.name,
avatar: params.avatar, avatar: params.avatar,
linkUrl: params.linkUrl,
hostKey: hostKey, hostKey: hostKey,
media: media,
)); ));
if (op != _op) throw const ConnectCancelled(); if (op != _op) throw const ConnectCancelled();
} catch (e) { } catch (e) {
@@ -353,6 +367,7 @@ class SessionState {
} }
Future<void> stopServer() async { Future<void> stopServer() async {
coordinator.hostFile = null;
_stopKickWatch?.call(); _stopKickWatch?.call();
_stopKickWatch = null; _stopKickWatch = null;
final server = _server; final server = _server;
+10
View File
@@ -16,6 +16,7 @@ const _keyAutoCheckUpdates = 'autoCheckUpdates';
const _keyShaderPreset = 'shaderPreset'; const _keyShaderPreset = 'shaderPreset';
const _keyShaderQuality = 'shaderQuality'; const _keyShaderQuality = 'shaderQuality';
const _keyCustomShaders = 'customShaders'; const _keyCustomShaders = 'customShaders';
const _keyAutoAdvance = 'autoAdvance';
enum HwdecMode { auto, off, custom } enum HwdecMode { auto, off, custom }
@@ -25,6 +26,8 @@ class AppSettings {
final hwdecMode = signal(HwdecMode.auto); final hwdecMode = signal(HwdecMode.auto);
final hwdecCustom = signal(''); final hwdecCustom = signal('');
final autoCheckUpdates = signal(true); final autoCheckUpdates = signal(true);
/// Jellyfin episodes: at the end, count down 10 s and put up the next.
final autoAdvance = signal(true);
/// Video shaders: one choice, used on join and changed both here and /// Video shaders: one choice, used on join and changed both here and
/// in the session (Ctrl+0..6, the shader button). /// in the session (Ctrl+0..6, the shader button).
@@ -59,6 +62,8 @@ class AppSettings {
if (custom is String) hwdecCustom.value = custom; if (custom is String) hwdecCustom.value = custom;
final auto = box.get(_keyAutoCheckUpdates); final auto = box.get(_keyAutoCheckUpdates);
if (auto is bool) autoCheckUpdates.value = auto; if (auto is bool) autoCheckUpdates.value = auto;
final advance = box.get(_keyAutoAdvance);
if (advance is bool) autoAdvance.value = advance;
final preset = box.get(_keyShaderPreset); final preset = box.get(_keyShaderPreset);
shaderPreset.value = shaderPreset.value =
ShaderPreset.values.where((p) => p.name == preset).firstOrNull ?? ShaderPreset.values.where((p) => p.name == preset).firstOrNull ??
@@ -91,6 +96,11 @@ class AppSettings {
_put(_keyAutoCheckUpdates, on); _put(_keyAutoCheckUpdates, on);
} }
void setAutoAdvance(bool on) {
autoAdvance.value = on;
_put(_keyAutoAdvance, on);
}
void setShaderPreset(ShaderPreset preset) { void setShaderPreset(ShaderPreset preset) {
shaderPreset.value = preset; shaderPreset.value = preset;
_put(_keyShaderPreset, preset.name); _put(_keyShaderPreset, preset.name);
@@ -36,6 +36,9 @@ class SyncClient {
String token = ''; String token = '';
String memberId = ''; String memberId = '';
String? mediaUrl; String? mediaUrl;
/// This member's `/j/...` URL from `welcome` (Jellyfin media the server
/// relays); private like [token].
String? relayUrl;
StateBroadcast? lastState; StateBroadcast? lastState;
Completer<WelcomeMessage>? _pendingWelcome; Completer<WelcomeMessage>? _pendingWelcome;
@@ -89,6 +92,7 @@ class SyncClient {
token = welcome.token; token = welcome.token;
memberId = welcome.memberId; memberId = welcome.memberId;
mediaUrl = welcome.mediaUrl; mediaUrl = welcome.mediaUrl;
relayUrl = welcome.relayUrl;
if (welcome.state != null) { if (welcome.state != null) {
try { try {
lastState = StateBroadcast.fromJson(welcome.state!); lastState = StateBroadcast.fromJson(welcome.state!);
@@ -214,6 +218,7 @@ class SyncClient {
required int eventId, required int eventId,
double duration = 0, double duration = 0,
int mediaId = 0, int mediaId = 0,
Map<String, bool>? jf,
}) { }) {
final safeBuffered = buffered.isNaN || buffered < 0 ? 0.0 : buffered; final safeBuffered = buffered.isNaN || buffered < 0 ? 0.0 : buffered;
_send( _send(
@@ -223,6 +228,7 @@ class SyncClient {
eventId: eventId, eventId: eventId,
duration: duration.isFinite && duration > 0 ? duration : 0, duration: duration.isFinite && duration > 0 ? duration : 0,
mediaId: mediaId, mediaId: mediaId,
jf: jf,
).toJson(), ).toJson(),
); );
} }
@@ -254,6 +260,7 @@ class SyncClient {
token = ''; token = '';
memberId = ''; memberId = '';
mediaUrl = null; mediaUrl = null;
relayUrl = null;
lastState = null; lastState = null;
} }
+1 -17
View File
@@ -7,7 +7,7 @@ import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/jellyfin/accounts.dart'; import 'package:watch_together/src/jellyfin/accounts.dart';
import 'package:watch_together/src/jellyfin/format.dart'; import 'package:watch_together/src/jellyfin/format.dart';
import 'package:watch_together/src/jellyfin/sign_in.dart'; import 'package:watch_together/src/jellyfin/sign_in.dart';
import 'package:watch_together/src/screens/home/host_tab.dart' show interimJellyfinLink, mediaSummary; import 'package:watch_together/src/screens/home/host_tab.dart' show mediaSummary;
import 'package:watch_together/src/screens/home/media_picker.dart'; import 'package:watch_together/src/screens/home/media_picker.dart';
/// Captured Jellyfin 10.11 answers shared with packages/core's client tests. /// Captured Jellyfin 10.11 answers shared with packages/core's client tests.
@@ -342,22 +342,6 @@ void main() {
expect(m.subtitle, 'Black Summoner · S1E6'); expect(m.subtitle, 'Black Summoner · S1E6');
expect(m.runtime, isNotNull); expect(m.runtime, isNotNull);
}); });
test('INTERIM: the host plays a Jellyfin pick as its authorised stream link', () async {
final (accounts, _) = await linkedAccounts();
const m = JellyfinMedia(
serverId: fixtureServerId, baseUrl: 'https://example.invalid', itemId: 'abc', title: 't');
final url = Uri.parse(interimJellyfinLink(accounts, m));
expect(url.path, '/Videos/abc/stream');
expect(url.queryParameters['static'], 'true');
expect(url.queryParameters['api_key'], 'TEST_ACCESS_TOKEN');
expect(
() => interimJellyfinLink(
accounts, const JellyfinMedia(serverId: 'gone', baseUrl: 'x', itemId: 'i', title: 't')),
throwsFormatException);
accounts.markExpired(fixtureServerId);
expect(() => interimJellyfinLink(accounts, m), throwsFormatException);
});
}); });
group('format', () { group('format', () {
+448
View File
@@ -0,0 +1,448 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.dart';
import 'package:watch_together/src/jellyfin/accounts.dart';
import 'package:watch_together/src/jellyfin/session_media.dart';
import 'package:watch_together/src/jellyfin/track_prefs.dart';
import 'package:watch_together/src/session/session_coordinator.dart';
// Two account holders and a guest watch Jellyfin episodes together over a
// real sync server, real mpv and a local fake Jellyfin (never a real one):
// account holders direct-play with their own token and report progress,
// the guest plays through the server's /j relay with the host credential.
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
const _sid = 'f00dfeedf00dfeedf00dfeedf00dfeed';
const _series = '5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e';
const _ep1 = 'e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1';
const _ep2 = 'e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2';
/// 20 s, h264 + two audio tracks (Japanese first, English second).
Future<String> _media() async {
final path = '$_tmp/jellyfin_e2e_20s.mkv';
if (await File(path).exists()) return path;
final r = await Process.run('ffmpeg', [
'-y', '-v', 'error',
'-f', 'lavfi', '-i', 'testsrc2=size=320x180:rate=24',
'-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=48000',
'-f', 'lavfi', '-i', 'sine=frequency=660:sample_rate=48000',
'-map', '0:v', '-map', '1:a', '-map', '2:a',
'-t', '20', '-c:v', 'libx264', '-preset', 'ultrafast', '-g', '24', '-pix_fmt', 'yuv420p',
'-c:a', 'aac',
'-metadata:s:a:0', 'language=jpn', '-metadata:s:a:0', 'title=Japanese',
'-metadata:s:a:1', 'language=eng', '-metadata:s:a:1', 'title=English',
path,
]);
if (r.exitCode != 0) fail('ffmpeg failed: ${r.stderr}');
return path;
}
class _Call {
final String method;
final String path;
final String auth;
final Map<String, dynamic>? body;
_Call(this.method, this.path, this.auth, this.body);
}
/// Just enough Jellyfin: public info, users, PlaybackInfo, items, episodes,
/// the static stream with ranges, and the three Sessions reports. Every
/// request is recorded with its Authorization header.
class _FakeJellyfin {
final String file;
final tokens = {'ATOKEN': 'ua', 'CTOKEN': 'uc', 'HOSTTOKEN': 'uh'};
final calls = <_Call>[];
late HttpServer _server;
_FakeJellyfin(this.file);
String get base => 'http://127.0.0.1:${_server.port}';
Future<void> start() async {
_server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
_server.listen(_handle);
}
Future<void> stop() => _server.close(force: true);
static Map<String, String> authFields(String header) => {
for (final m in RegExp(r'(\w+)="([^"]*)"').allMatches(header))
m.group(1)!: Uri.decodeComponent(m.group(2)!),
};
Map<String, Object?> _item(String id) => {
'Id': id,
'Name': id == _ep1 ? 'Pilot' : 'Second',
'Type': 'Episode',
'ServerId': _sid,
'SeriesId': _series,
'SeriesName': 'Fake Show',
'SeasonId': 'seas1',
'ParentIndexNumber': 1,
'IndexNumber': id == _ep1 ? 1 : 2,
'RunTimeTicks': 20 * jellyfinTicksPerSecond,
};
Future<void> _handle(HttpRequest req) async {
final auth = req.headers.value('authorization') ?? '';
final bodyText = await utf8.decoder.bind(req).join();
Map<String, dynamic>? body;
try {
body = bodyText.isEmpty ? null : jsonDecode(bodyText) as Map<String, dynamic>;
} catch (_) {}
calls.add(_Call(req.method, req.uri.path, auth, body));
final res = req.response;
Future<void> json(Object v) async {
res.headers.contentType = ContentType.json;
res.write(jsonEncode(v));
await res.close();
}
final path = req.uri.path;
if (path == '/System/Info/Public') {
return json({'Id': _sid, 'ServerName': 'Fake Jellyfin', 'Version': '10.11.11'});
}
final token = authFields(auth)['Token'] ?? req.uri.queryParameters['api_key'];
final user = tokens[token];
if (user == null) {
res.statusCode = 401;
return res.close();
}
if (path == '/Users/Me') return json({'Id': user, 'Name': user, 'ServerId': _sid});
final m = RegExp(r'^/Items/(\w+)(/PlaybackInfo)?$').firstMatch(path);
if (m != null && m.group(2) != null) {
return json({
'PlaySessionId': 'ps-$user-${m.group(1)}',
'MediaSources': [
{
'Id': m.group(1),
'SupportsDirectPlay': true,
'MediaStreams': [
{'Index': 0, 'Type': 'Video', 'Codec': 'h264'},
{'Index': 1, 'Type': 'Audio', 'Language': 'jpn', 'Title': 'Japanese', 'Codec': 'aac'},
{'Index': 2, 'Type': 'Audio', 'Language': 'eng', 'Title': 'English', 'Codec': 'aac'},
],
}
],
});
}
if (m != null) return json(_item(m.group(1)!));
if (path == '/Shows/$_series/Episodes') {
return json({'Items': [_item(_ep1), _item(_ep2)], 'TotalRecordCount': 2});
}
if (path.startsWith('/Sessions/Playing')) {
res.statusCode = 204;
return res.close();
}
if (RegExp(r'^/Videos/\w+/stream$').hasMatch(path)) return _stream(req);
res.statusCode = 404;
await res.close();
}
Future<void> _stream(HttpRequest req) async {
final f = File(file);
final size = await f.length();
final res = req.response;
res.headers.set('Accept-Ranges', 'bytes');
res.headers.contentType = ContentType('video', 'x-matroska');
var start = 0, end = size - 1;
final range = RegExp(r'bytes=(\d*)-(\d*)').firstMatch(req.headers.value('range') ?? '');
if (range != null) {
start = int.tryParse(range.group(1)!) ?? 0;
end = int.tryParse(range.group(2)!) ?? size - 1;
if (end >= size) end = size - 1;
res.statusCode = 206;
res.headers.set('Content-Range', 'bytes $start-$end/$size');
}
res.contentLength = end - start + 1;
try {
await res.addStream(f.openRead(start, end + 1));
await res.close();
} catch (_) {}
}
List<_Call> sessions({String? token}) => [
for (final c in calls)
if (c.path.startsWith('/Sessions/Playing') &&
(token == null || authFields(c.auth)['Token'] == token))
c
];
List<_Call> streams(String token) => [
for (final c in calls)
if (c.path.startsWith('/Videos/') && authFields(c.auth)['Token'] == token) c
];
}
Future<void> _poll(FutureOr<bool> Function() cond, String what,
{Duration timeout = const Duration(seconds: 30)}) async {
final deadline = DateTime.now().add(timeout);
while (DateTime.now().isBefore(deadline)) {
if (await cond()) return;
await Future.delayed(const Duration(milliseconds: 200));
}
fail('timed out waiting for $what');
}
Future<JellyfinAccounts> _account(String base, String token, String userId, String device) async {
final a = JellyfinAccounts(store: MemoryJellyfinStore(), deviceName: device);
await a.ensureLoaded(validate: false);
await a.link(
baseUrl: base,
server: const JellyfinServerInfo(id: _sid, name: 'Fake Jellyfin', version: '10.11.11'),
auth: JellyfinAuthResult(
accessToken: token, user: JellyfinUser(id: userId, name: userId), serverId: _sid),
);
return a;
}
String _kind(_Call c) => c.path.split('/').last;
void main() {
test(
'account holders direct-play and report, the guest plays through the relay, all in sync',
timeout: const Timeout(Duration(minutes: 4)),
() async {
MediaKit.ensureInitialized();
final jelly = _FakeJellyfin(await _media());
await jelly.start();
const relayCred = (client: 'Watch Together', device: 'HostBox', deviceId: 'host-dev-1', version: '9.9.9');
final server = SyncServer(AppConfig(
port: 0,
secret: 'jf',
name: 'jf',
jellyfin: JellyfinCredential(
baseUrl: jelly.base,
serverId: _sid,
token: 'HOSTTOKEN',
client: relayCred.client,
device: relayCred.device,
deviceId: relayCred.deviceId,
version: relayCred.version,
),
));
await server.start();
final accA = await _account(jelly.base, 'ATOKEN', 'ua', 'A-PC');
final accC = await _account(jelly.base, 'CTOKEN', 'uc', 'C-PC');
final accB = JellyfinAccounts(store: MemoryJellyfinStore());
await accB.ensureLoaded(validate: false);
final a = SessionCoordinator(jellyfin: accA, trackPrefs: TrackPrefs(store: MemoryJellyfinStore()));
final b = SessionCoordinator(jellyfin: accB, trackPrefs: TrackPrefs(store: MemoryJellyfinStore()));
final c = SessionCoordinator(jellyfin: accC, trackPrefs: TrackPrefs(store: MemoryJellyfinStore()));
final all = [a, b, c];
ConnectParams params(String name, [HelloMedia? media]) => ConnectParams(
host: '127.0.0.1', port: server.boundPort, secret: 'jf', name: name, media: media);
try {
// A hosts the episode as a ref: no URL or token on the wire.
await a.connect(params('alice', const HelloMedia(
name: 'Pilot',
size: 0,
duration: 0,
jellyfin: JellyfinRef(serverId: _sid, itemId: _ep1, title: 'Pilot', subtitle: 'Fake Show · S1E1'))));
await _poll(() => a.serverState.peek()?.media?['jellyfin'] != null, 'ref on the server');
expect(a.serverState.peek()!.media!.containsKey('url'), isFalse);
expect(a.serverState.peek()!.relay, isTrue);
await b.connect(params('guest'));
await c.connect(params('carol'));
for (final x in all) {
await x.playback.player.setVolume(0);
}
await _poll(() => all.every((x) => x.playback.durationSecs > 19), 'all three opened the item');
expect(a.jellyfinPlan.peek(), isA<OwnPlay>());
await _poll(() {
final members = a.serverState.peek()?.members ?? const [];
final jf = {for (final m in members) m.name: m.jf[_sid]};
return jf['alice'] == true && jf['carol'] == true && jf['guest'] == false;
}, 'jf access in the member list');
expect(a.nextEpisode.peek()?.item.id, _ep2);
expect(b.nextEpisode.peek(), isNull, reason: 'guests cannot resolve the next one');
for (final x in all) {
x.toggleReady();
}
await _poll(() => all.every((x) => x.playback.player.state.playing), 'all playing');
await Future.delayed(const Duration(seconds: 4));
final pos = [for (final x in all) x.playback.positionSecs];
// ignore: avoid_print
print('positions after 4 s: $pos');
for (final p in pos) {
expect((p - pos.first).abs(), lessThan(1.0), reason: 'in sync: $pos');
}
// Pause (A unreadies): an immediate Progress with IsPaused.
a.toggleReady();
await _poll(() => !a.playback.player.state.playing, 'held');
await _poll(
() => jelly.sessions(token: 'ATOKEN').any((s) =>
_kind(s) == 'Progress' && s.body?['IsPaused'] == true),
'paused progress');
a.toggleReady();
await _poll(() => all.every((x) => x.playback.player.state.playing), 'playing again');
// Account holders see the "Next episode" prompt (this item is
// shorter than its 2 min lead); the guest cannot put it up.
await _poll(() => a.nextPromptVisible.peek(), 'next prompt');
expect(b.nextPromptVisible.peek(), isFalse);
// Alice and the guest pick English; it carries to the next episode.
await a.selectTrack(aid: 2);
await b.selectTrack(aid: 2);
await _poll(
() => jelly.sessions(token: 'ATOKEN').any((s) =>
_kind(s) == 'Progress' && s.body?['AudioStreamIndex'] == 2),
'track change reported');
// Near the end: finished -> Stopped, then one auto-advance swap.
a.seekTo(16);
await _poll(() => a.serverState.peek()?.transport == 'finished', 'finished',
timeout: const Duration(seconds: 20));
final finishedId = a.serverState.peek()!.mediaId;
await _poll(() => a.autoAdvance.peek() != null && c.autoAdvance.peek() != null,
'both account holders count down');
expect(b.autoAdvance.peek(), isNull);
final swappers = [a.autoAdvance.peek()!.swapper, c.autoAdvance.peek()!.swapper];
expect(swappers.where((s) => s), hasLength(1),
reason: 'exactly one designated swapper: the lowest member id (alice, m1)');
expect(a.autoAdvance.peek()!.swapper, isTrue);
await _poll(() => (a.serverState.peek()?.mediaId ?? 0) > finishedId, 'advanced',
timeout: const Duration(seconds: 20));
await Future.delayed(const Duration(seconds: 3));
expect(a.serverState.peek()!.mediaId, finishedId + 1, reason: 'no double swap');
expect(refOfStateMedia(a.serverState.peek()!.media)!.itemId, _ep2);
await _poll(() => all.every((x) => x.playback.durationSecs > 19 && x.jellyfinPlan.peek() != null),
'all opened the next episode');
expect(b.jellyfinPlan.peek(), isA<RelayPlay>());
expect((b.jellyfinPlan.peek() as RelayPlay).url, endsWith('/$_ep2'));
await _poll(() async => (await a.playback.currentTrackIds()).aid == 2, 'alice keeps English');
await _poll(() async => (await b.playback.currentTrackIds()).aid == 2, 'guest keeps English');
expect((await c.playback.currentTrackIds()).aid, 1, reason: 'carol never chose');
expect(a.previousEpisode.peek()?.item.id, _ep1);
expect(a.nextEpisode.peek(), isNull);
await a.leave();
await c.leave();
await Future.delayed(const Duration(milliseconds: 300));
// Progress only from account holders, exactly their own device.
final infoA = accA.clientInfo, infoC = accC.clientInfo;
void ownHeader(_Call call, JellyfinClientInfo info, String token) {
final f = _FakeJellyfin.authFields(call.auth);
expect(f, {
'Client': info.client,
'Device': info.device,
'DeviceId': info.deviceId,
'Version': info.version,
'Token': token,
}, reason: '${call.method} ${call.path}');
}
final sessions = jelly.sessions();
expect(sessions, isNotEmpty);
expect(sessions.every((s) => ['ATOKEN', 'CTOKEN'].contains(_FakeJellyfin.authFields(s.auth)['Token'])),
isTrue, reason: 'no reports from the guest or the relay credential');
final aCalls = jelly.sessions(token: 'ATOKEN');
for (final s in aCalls) {
ownHeader(s, infoA, 'ATOKEN');
}
for (final s in jelly.sessions(token: 'CTOKEN')) {
ownHeader(s, infoC, 'CTOKEN');
}
final ep1 = [for (final s in aCalls) if (s.body?['ItemId'] == _ep1) _kind(s)];
final ep2 = [for (final s in aCalls) if (s.body?['ItemId'] == _ep2) _kind(s)];
// ignore: avoid_print
print('alice reports: ep1 $ep1, ep2 $ep2');
expect(ep1.first, 'Playing');
expect(ep1, contains('Progress'));
expect(ep1.last, 'Stopped');
expect(ep1.where((k) => k == 'Stopped'), hasLength(1));
final stopped1 = aCalls.lastWhere((s) => s.body?['ItemId'] == _ep1);
expect(stopped1.body!['PositionTicks'], greaterThan(19 * jellyfinTicksPerSecond),
reason: 'played to the end');
expect(stopped1.body!['PlaySessionId'], 'ps-ua-$_ep1');
expect(ep2.first, 'Playing');
expect(ep2.last, 'Stopped', reason: 'leaving reports Stopped');
final playing = aCalls.firstWhere((s) => _kind(s) == 'Playing');
expect(playing.body!['AudioStreamIndex'], 1, reason: 'mpv aid 1 = Jellyfin index 1');
expect(playing.body!['SubtitleStreamIndex'], -1);
final stopped2 = aCalls.lastWhere((s) => s.body?['ItemId'] == _ep2);
expect(stopped2.body!['PlaySessionId'], 'ps-ua-$_ep2');
expect(aCalls.lastWhere((s) => _kind(s) != 'Stopped' && s.body?['ItemId'] == _ep2)
.body!['AudioStreamIndex'], 2, reason: 'English carried over and reported');
// Streams: own token with own fields; the relay with exactly the
// configured credential.
expect(jelly.streams('ATOKEN'), isNotEmpty);
for (final s in jelly.streams('ATOKEN')) {
ownHeader(s, infoA, 'ATOKEN');
}
final relayed = jelly.streams('HOSTTOKEN');
expect(relayed, isNotEmpty, reason: 'the guest played through the relay');
for (final s in relayed) {
expect(_FakeJellyfin.authFields(s.auth), {
'Client': relayCred.client,
'Device': relayCred.device,
'DeviceId': relayCred.deviceId,
'Version': relayCred.version,
'Token': 'HOSTTOKEN',
});
}
expect(jelly.calls.any((c) => c.path.startsWith('/Videos/') && c.auth.isEmpty), isFalse,
reason: 'no token-less (api_key) stream requests');
} finally {
for (final x in all) {
await x.dispose();
}
await server.stop();
await jelly.stop();
}
},
);
test(
'a guest with no account and no relay sees the blocked state, then plays after linking',
timeout: const Timeout(Duration(minutes: 2)),
() async {
MediaKit.ensureInitialized();
final jelly = _FakeJellyfin(await _media());
await jelly.start();
final server = SyncServer(const AppConfig(port: 0, secret: '', name: 'nr'));
await server.start();
final accA = await _account(jelly.base, 'ATOKEN', 'ua', 'A-PC');
final accB = JellyfinAccounts(store: MemoryJellyfinStore());
await accB.ensureLoaded(validate: false);
final a = SessionCoordinator(jellyfin: accA, trackPrefs: TrackPrefs(store: MemoryJellyfinStore()));
final b = SessionCoordinator(jellyfin: accB, trackPrefs: TrackPrefs(store: MemoryJellyfinStore()));
try {
await a.connect(ConnectParams(
host: '127.0.0.1',
port: server.boundPort,
secret: '',
name: 'alice',
media: const HelloMedia(
name: 'Pilot', size: 0, duration: 0, jellyfin: JellyfinRef(serverId: _sid, itemId: _ep1))));
await b.connect(ConnectParams(host: '127.0.0.1', port: server.boundPort, secret: '', name: 'bob'));
await _poll(() => b.jellyfinBlocked.peek() != null, 'blocked');
expect(b.serverState.peek()!.relay, isFalse);
expect(b.jellyfinBlocked.peek()!.message, contains('needs a Jellyfin account'));
// Linking an account (Integrations) retries by itself.
await accB.link(
baseUrl: jelly.base,
server: const JellyfinServerInfo(id: _sid, name: 'Fake Jellyfin', version: '10.11.11'),
auth: const JellyfinAuthResult(
accessToken: 'CTOKEN', user: JellyfinUser(id: 'uc', name: 'uc'), serverId: _sid));
await _poll(() => b.jellyfinPlan.peek() is OwnPlay && b.playback.durationSecs > 19,
'plays with the new account');
expect(b.jellyfinBlocked.peek(), isNull);
} finally {
await a.dispose();
await b.dispose();
await server.stop();
await jelly.stop();
}
},
);
}
@@ -0,0 +1,591 @@
import 'dart:convert';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/jellyfin/accounts.dart';
import 'package:watch_together/src/jellyfin/browse_scope.dart';
import 'package:watch_together/src/jellyfin/progress.dart';
import 'package:watch_together/src/jellyfin/session_media.dart';
import 'package:watch_together/src/jellyfin/track_prefs.dart';
import 'package:watch_together/src/screens/home/host_tab.dart' show hostJellyfinMedia;
import 'package:watch_together/src/screens/home/media_picker.dart';
import 'package:watch_together/src/screens/session/jellyfin_widgets.dart';
import 'package:watch_together/src/screens/session/swap_panel.dart';
import 'package:watch_together/src/screens/session_screen.dart' show viaHostOf;
import 'package:watch_together/src/session/session_coordinator.dart' show AutoAdvance;
import 'jellyfin_accounts_test.dart';
import 'jellyfin_browser_test.dart' show app, setSize, settle;
const episodeId = 'b18826be994070a79f9b52bc0f66f052'; // Black Summoner S1E6
const nextEpisodeId = '74c50bdbf83e8fef231dd7710058e197'; // S1E7
const seriesId = 'e9aec9c14a1fcab775111844112b4cf7';
const seasonId = 'fb0b77cfda15445f9319ed9d753dba23';
const playSessionId = 'fc428dbbeb9c46a7885fa053bd46f7e7';
const episodeRef = JellyfinRef(
serverId: fixtureServerId, itemId: episodeId, title: 'Rice and Thieves and Heroes');
/// The fake plus playback: PlaybackInfo, the Black Summoner episodes and
/// the three Sessions reports (recorded with their bodies).
FakeJellyfin playbackFake() {
final fake = FakeJellyfin();
fake.fixture('POST', '/Items/$episodeId/PlaybackInfo', 'playback_info');
fake.on('GET', '/Shows/$seriesId/Episodes',
(_, _) => JellyfinHttpResponse(200, jellyfinFixture('bs_episodes')));
for (final path in ['/Sessions/Playing', '/Sessions/Playing/Progress', '/Sessions/Playing/Stopped']) {
fake.on('POST', path, (_, body) {
reports.add((path: path, body: jsonDecode(body!) as Map<String, dynamic>));
return const JellyfinHttpResponse(204, '');
});
}
return fake;
}
final reports = <({String path, Map<String, dynamic> body})>[];
JellyfinMediaSource fixtureSource() => JellyfinPlaybackInfo.fromJson(
jsonDecode(jellyfinFixture('playback_info')) as Map<String, dynamic>)
.primarySource!;
MemberView member(String id, String name, {Map<String, bool> jf = const {}, bool stale = false}) =>
MemberView(
id: id, name: name, ready: true, inSet: true, buffered: 10, stale: stale, jf: jf);
void main() {
setUp(reports.clear);
group('resolve a ref', () {
test('own account: PlaybackInfo with its own user, static stream, token in the header',
() async {
final (accounts, fake) = await linkedAccounts(http: playbackFake());
final plan = await resolveJellyfinRef(accounts, episodeRef,
relay: true, relayUrl: 'http://h:1/j/default/t/$episodeId');
expect(plan, isA<OwnPlay>());
plan as OwnPlay;
expect(plan.jfAccess, isTrue);
expect(plan.info.playSessionId, playSessionId);
expect(plan.source.id, episodeId);
expect(plan.item?.seriesId, seriesId);
final info = fake.requests.firstWhere((r) => r.uri.path.endsWith('/PlaybackInfo'));
expect(info.uri.queryParameters['userId'], fixtureUserId);
expect(info.headers['Authorization'], contains('Token="TEST_ACCESS_TOKEN"'));
final url = Uri.parse(plan.url);
expect(url.path, '/Videos/$episodeId/stream');
expect(url.queryParameters['static'], 'true');
expect(url.queryParameters.containsKey('api_key'), isFalse, reason: 'token in a header only');
expect(plan.headers['Authorization'], accounts.clientInfo.authorizationHeader('TEST_ACCESS_TOKEN'));
});
test('no account: the relay URL when the server relays', () async {
final accounts = JellyfinAccounts(store: MemoryJellyfinStore(), httpFactory: FakeJellyfin.new);
final plan = await resolveJellyfinRef(accounts, episodeRef,
relay: true, relayUrl: 'http://h:1/j/default/t/$episodeId');
expect(plan, isA<RelayPlay>());
expect((plan as RelayPlay).url, 'http://h:1/j/default/t/$episodeId');
expect(plan.jfAccess, isFalse);
});
test('neither: blocked with a clear message, never a silent stall', () async {
final accounts = JellyfinAccounts(store: MemoryJellyfinStore(), httpFactory: FakeJellyfin.new);
final plan = await resolveJellyfinRef(accounts, episodeRef, relay: false, relayUrl: null);
expect(plan, isA<BlockedPlay>());
expect((plan as BlockedPlay).message,
'This needs a Jellyfin account on that Jellyfin server (or the host can relay)');
expect(plan.jfAccess, isNull);
});
test('a 401 marks the account expired and falls back to the relay', () async {
final fake = playbackFake()
..on('POST', '/Items/$episodeId/PlaybackInfo', (_, _) => const JellyfinHttpResponse(401, ''));
final (accounts, _) = await linkedAccounts(http: fake);
final plan = await resolveJellyfinRef(accounts, episodeRef, relay: true, relayUrl: 'http://r');
expect(plan, isA<RelayPlay>());
expect(accounts.statusOf(fixtureServerId), JellyfinAccountStatus.expired);
final blocked = await resolveJellyfinRef(accounts, episodeRef, relay: false, relayUrl: null);
expect((blocked as BlockedPlay).message, contains('on Example Server'));
expect(blocked.message, contains('sign-in expired'));
});
test('relay URL is built like the /m one', () {
expect(relayUrlFor('10.0.0.2', 8080, 'tok', episodeId),
'http://10.0.0.2:8080/j/default/tok/$episodeId');
});
});
group('wire media', () {
test('a pick goes up as a ref without URL or token', () async {
final (accounts, _) = await linkedAccounts();
const pick = JellyfinMedia(
serverId: fixtureServerId,
baseUrl: 'https://example.invalid',
itemId: episodeId,
title: 'Rice and Thieves and Heroes',
subtitle: 'Black Summoner · S1E6',
runtime: Duration(minutes: 23));
final media = hostJellyfinMedia(accounts, pick);
expect(media.url, isNull);
expect(media.jellyfin!.itemId, episodeId);
expect(media.jellyfin!.subtitle, 'Black Summoner · S1E6');
expect(media.jellyfin!.runtimeSecs, 23 * 60);
expect(jsonEncode(SwapIntent(media: media).toJson()), isNot(contains('TEST_ACCESS_TOKEN')));
expect(() => hostJellyfinMedia(accounts, const JellyfinMedia(serverId: 'gone', baseUrl: 'x', itemId: 'i', title: 't')),
throwsFormatException);
accounts.markExpired(fixtureServerId);
expect(() => hostJellyfinMedia(accounts, pick), throwsFormatException);
});
test('a pasted Jellyfin link becomes a ref (ServerId from /System/Info/Public, no api_key)',
() async {
final (accounts, fake) = await linkedAccounts();
final media = await mediaForLink(accounts,
'https://example.invalid/Videos/$episodeId/stream?static=true&api_key=SOMEONES_TOKEN');
expect(media.url, isNull);
expect(media.jellyfin!.serverId, fixtureServerId);
expect(media.jellyfin!.itemId, episodeId);
expect(media.jellyfin!.title, 'Rice and Thieves and Heroes', reason: 'title via own account');
expect(media.name, 'Rice and Thieves and Heroes');
expect(jsonEncode(media.toJson()), isNot(contains('SOMEONES_TOKEN')));
expect(fake.count('GET', '/System/Info/Public'), 1);
// Never sent with the pasted token.
expect(fake.requests.every((r) => !'${r.uri}${r.headers}'.contains('SOMEONES_TOKEN')), isTrue);
// A web UI link carries the ServerId: no lookup.
final web = await mediaForLink(accounts,
'https://example.invalid/web/#/details?id=$episodeId&serverId=$fixtureServerId');
expect(web.jellyfin!.serverId, fixtureServerId);
expect(fake.count('GET', '/System/Info/Public'), 1);
});
test('other links, or an unreachable Jellyfin, stay plain links', () async {
final fake = FakeJellyfin()..offline = true;
final accounts = JellyfinAccounts(store: MemoryJellyfinStore(), httpFactory: () => fake);
const url = 'https://jelly.example/Items/$episodeId/Download?api_key=x';
final media = await mediaForLink(accounts, url);
expect(media.jellyfin, isNull);
expect(media.url, url);
expect(media.name, 'Download');
final plain = await mediaForLink(accounts, 'https://cdn.example/movie.mkv');
expect(plain.url, 'https://cdn.example/movie.mkv');
expect(plain.name, 'movie.mkv');
});
test('relay credential: the account token with exactly its own device fields', () async {
final (accounts, _) = await linkedAccounts();
final cred = relayCredentialFor(accounts, serverId: fixtureServerId)!;
final info = accounts.clientInfo;
expect(cred.token, 'TEST_ACCESS_TOKEN');
expect(cred.serverId, fixtureServerId);
expect(cred.baseUrl, 'https://example.invalid');
expect([cred.client, cred.device, cred.deviceId, cred.version],
[info.client, info.device, info.deviceId, info.version]);
expect(cred.device, 'Test PC');
// Another server's item: the default account still relays its own.
expect(relayCredentialFor(accounts, serverId: 'other')!.serverId, fixtureServerId);
accounts.markExpired(fixtureServerId);
expect(relayCredentialFor(accounts, serverId: fixtureServerId), isNull);
final none = JellyfinAccounts(store: MemoryJellyfinStore());
await none.ensureLoaded(validate: false);
expect(relayCredentialFor(none), isNull);
});
});
group('progress', () {
late JellyfinAccounts accounts;
late DateTime now;
late JellyfinProgressReporter reporter;
final errors = <Object>[];
var unauthorized = 0;
setUp(() async {
(accounts, _) = await linkedAccounts(http: playbackFake());
now = DateTime(2026, 1, 1, 20);
errors.clear();
unauthorized = 0;
reporter = JellyfinProgressReporter(
client: accounts.clientFor(fixtureServerId)!,
itemId: episodeId,
source: fixtureSource(),
playSessionId: playSessionId,
clock: () => now,
onError: errors.add,
onUnauthorized: () => unauthorized++,
);
});
Future<void> idle() => reporter.stop(const PlayerSnapshot(position: 0, paused: true))
.then((_) {});
test('Playing, Progress every 10 s and on pause / seek / track change, Stopped', () async {
// English audio (mpv aid 2 = Jellyfin index 2), English full subs (sid 1 = 3).
reporter.start(const PlayerSnapshot(position: 0, paused: true, aid: 2, sid: 1));
void at(int secs, double pos, {bool paused = false, int? aid = 2, int? sid = 1}) {
now = DateTime(2026, 1, 1, 20).add(Duration(seconds: secs));
reporter.update(PlayerSnapshot(position: pos, paused: paused, aid: aid, sid: sid));
}
at(1, 0); // resume -> Progress
at(2, 1);
at(5, 4); // nothing due
at(11, 10); // 10 s since the last one
at(12, 11, paused: true); // pause
at(13, 11, paused: true);
at(14, 300, paused: true); // seek
at(15, 300, paused: true, sid: null); // subtitles off
await reporter.stop(const PlayerSnapshot(position: 300.5, paused: true, aid: 2));
expect(reports.map((r) => r.path.split('/').last), [
'Playing',
'Progress', // resumed
'Progress', // 10 s
'Progress', // paused
'Progress', // seek
'Progress', // track change
'Stopped',
]);
final playing = reports.first.body;
expect(playing['ItemId'], episodeId);
expect(playing['MediaSourceId'], episodeId);
expect(playing['PlaySessionId'], playSessionId);
expect(playing['PlayMethod'], 'DirectPlay');
expect(playing['IsPaused'], isTrue);
expect(playing['AudioStreamIndex'], 2);
expect(playing['SubtitleStreamIndex'], 3);
expect(reports[2].body['PositionTicks'], 10 * jellyfinTicksPerSecond);
expect(reports[3].body['IsPaused'], isTrue);
expect(reports[4].body['PositionTicks'], 300 * jellyfinTicksPerSecond);
expect(reports[5].body['SubtitleStreamIndex'], -1);
expect(reports.last.body, {
'ItemId': episodeId,
'MediaSourceId': episodeId,
'PlaySessionId': playSessionId,
'PositionTicks': 3005000000,
});
expect(errors, isEmpty);
});
test('played to the end, then seeked back: a fresh Playing', () async {
reporter.start(const PlayerSnapshot(position: 0, paused: false));
await reporter.stop(const PlayerSnapshot(position: 1440, paused: true));
expect(reporter.active, isFalse);
reporter.update(const PlayerSnapshot(position: 1440, paused: true));
reporter.start(const PlayerSnapshot(position: 600, paused: false));
await idle();
expect(reports.map((r) => r.path.split('/').last), ['Playing', 'Stopped', 'Playing', 'Stopped']);
});
test('failures are handed on and never throw; a 401 ends reporting', () async {
final fake = playbackFake()
..on('POST', '/Sessions/Playing/Progress', (_, _) => const JellyfinHttpResponse(500, ''));
(accounts, _) = await linkedAccounts(http: fake);
final r = JellyfinProgressReporter(
client: accounts.clientFor(fixtureServerId)!,
itemId: episodeId,
source: fixtureSource(),
playSessionId: playSessionId,
clock: () => now,
onError: errors.add,
onUnauthorized: () => unauthorized++,
);
r.start(const PlayerSnapshot(position: 0, paused: false));
r.update(const PlayerSnapshot(position: 0, paused: true));
await r.stop(const PlayerSnapshot(position: 1, paused: true));
expect(errors, hasLength(1));
expect(unauthorized, 0);
expect(reports.map((r) => r.path.split('/').last), ['Playing', 'Stopped']);
fake.on('POST', '/Sessions/Playing', (_, _) => const JellyfinHttpResponse(401, ''));
reports.clear();
r.start(const PlayerSnapshot(position: 0, paused: false));
r.update(const PlayerSnapshot(position: 0, paused: true));
await r.stop(const PlayerSnapshot(position: 1, paused: true));
expect(unauthorized, 1);
expect(reports, isEmpty, reason: 'nothing after the 401');
expect(r.active, isFalse);
});
});
group('episodes', () {
test('neighbours of an episode with the clicker\'s account', () async {
final (accounts, _) = await linkedAccounts(http: playbackFake());
final plan = await resolveJellyfinRef(accounts, episodeRef, relay: false, relayUrl: null)
as OwnPlay;
final around = await findAdjacentEpisodes(plan);
expect(around.previous, isNull, reason: 'S1E6 is first in the fixture');
expect(around.next!.item.id, nextEpisodeId);
expect(around.next!.label, 'S1E7 Battle with the Heroes');
final media = mediaForPick(around.next!.pick);
expect(media.jellyfin!.itemId, nextEpisodeId);
expect(media.jellyfin!.subtitle, 'Black Summoner · S1E7');
});
test('the floating prompt starts two minutes before the end', () {
expect(nextPromptStart(null, const Duration(minutes: 24)), const Duration(minutes: 22));
expect(nextPromptStart(null, const Duration(seconds: 90)), Duration.zero);
});
test('one auto-advance swapper: the lowest id among members with their own account', () {
final members = [
member('m10', 'Ann', jf: {fixtureServerId: true}),
member('m2', 'Bob', jf: {fixtureServerId: true}),
member('m1', 'Guest', jf: {fixtureServerId: false}),
member('m0', 'Old', jf: {fixtureServerId: true}, stale: true),
member('m3', 'Fresh'),
];
expect(autoAdvanceSwapper(members, fixtureServerId), 'm2');
expect(autoAdvanceSwapper(members.reversed.toList(), fixtureServerId), 'm2',
reason: 'order does not matter: every client agrees');
expect(autoAdvanceSwapper(members, 'other-server'), isNull);
expect(compareMemberIds('m9', 'm10'), lessThan(0));
});
test('member badge: via host only for relay viewers of the current item', () {
expect(viaHostOf(member('m1', 'G', jf: {fixtureServerId: false}), episodeRef), isTrue);
expect(viaHostOf(member('m1', 'G', jf: {fixtureServerId: true}), episodeRef), isFalse);
expect(viaHostOf(member('m1', 'G'), episodeRef), isFalse);
expect(viaHostOf(member('m1', 'G', jf: {fixtureServerId: false}), null), isFalse);
});
});
group('track carry-over', () {
test('choice from Jellyfin streams carries to the next episode by traits', () {
final source = fixtureSource();
// mpv aid 2 / sid 2: English eac3 audio, "English (Signs)" subtitles.
final choice = TrackChoice.of(aid: 2, sid: 2, source: source);
expect(choice.audio!.language, 'eng');
expect(choice.subtitle!.title, 'English (Signs) [Generated]');
// Next file: same tracks in another order (English audio first).
final next = JellyfinMediaSource(id: 'n', mediaStreams: [
const JellyfinMediaStream(index: 0, type: JellyfinStreamType.video),
const JellyfinMediaStream(
index: 1, type: JellyfinStreamType.audio, language: 'eng', title: 'English', codec: 'eac3', channels: 2),
const JellyfinMediaStream(
index: 2, type: JellyfinStreamType.audio, language: 'jpn', title: 'Japanese', codec: 'eac3', channels: 2),
const JellyfinMediaStream(
index: 3, type: JellyfinStreamType.subtitle, language: 'eng', title: 'English (Full Subtitle)'),
const JellyfinMediaStream(
index: 4, type: JellyfinStreamType.subtitle, language: 'eng', title: 'English (Signs)'),
]);
final pick = chooseTracks(choice, source: next);
expect(pick.aid, 1, reason: 'English is mpv aid 1 there');
expect(pick.sid, 2, reason: 'Signs, not Full');
expect(pick.subtitlesOff, isFalse);
final off = chooseTracks(TrackChoice.of(aid: 1, sid: null, source: source), source: next);
expect(off.aid, 2, reason: 'Japanese');
expect(off.subtitlesOff, isTrue);
});
test('relay viewers match on mpv\'s track list', () {
const tracks = [
MpvTrack(id: 1, type: JellyfinStreamType.audio, language: 'jpn', title: 'Japanese', codec: 'aac'),
MpvTrack(id: 2, type: JellyfinStreamType.audio, language: 'eng', title: 'English', codec: 'aac'),
MpvTrack(id: 1, type: JellyfinStreamType.subtitle, language: 'eng', title: 'Signs'),
MpvTrack(id: 2, type: JellyfinStreamType.subtitle, language: 'eng', title: 'Full'),
];
final choice = TrackChoice.of(aid: 2, sid: 2, tracks: tracks);
expect(choice.audio!.title, 'English');
final pick = chooseTracks(choice, tracks: const [
MpvTrack(id: 1, type: JellyfinStreamType.audio, language: 'en', title: 'English'),
MpvTrack(id: 2, type: JellyfinStreamType.audio, language: 'ja', title: 'Japanese'),
MpvTrack(id: 1, type: JellyfinStreamType.subtitle, language: 'en', title: 'Full Subtitles'),
MpvTrack(id: 2, type: JellyfinStreamType.subtitle, language: 'en', title: 'Signs & Songs'),
]);
expect(pick.aid, 1);
expect(pick.sid, 1);
});
test('kept per series with a global fallback', () async {
final prefs = TrackPrefs(store: MemoryJellyfinStore());
expect(await prefs.load(seriesId: 'a'), isNull);
final eng = TrackChoice.of(aid: 2, sid: null, source: fixtureSource());
await prefs.save(eng, seriesId: 'a');
final jpn = TrackChoice.of(aid: 1, sid: 1, source: fixtureSource());
await prefs.save(jpn, seriesId: 'b');
expect((await prefs.load(seriesId: 'a'))!.audio!.language, 'eng');
expect((await prefs.load(seriesId: 'a'))!.subtitlesOff, isTrue);
expect((await prefs.load(seriesId: 'b'))!.audio!.language, 'jpn');
expect((await prefs.load(seriesId: 'new'))!.audio!.language, 'jpn', reason: 'global = last');
expect((await prefs.load())!.subtitle!.title, 'English (Full Subtitle)');
});
});
group('browser location', () {
test('route stacks round-trip and survive in the Jellyfin box', () async {
final (accounts, _) = await linkedAccounts();
final stack = [
const HomeRoute(),
const LibraryRoute(
JellyfinItem(id: 'lib1', name: 'Shows', type: 'CollectionFolder', collectionType: 'tvshows')),
SeriesRoute(const JellyfinItem(id: seriesId, name: 'Black Summoner', type: 'Series'),
seasonId: seasonId),
];
await accounts.saveBrowseLocation(fixtureServerId, encodeBrowseStack(stack));
final back = decodeBrowseStack(accounts.browseLocation(fixtureServerId))!;
expect(back.map((r) => r.key), stack.map((r) => r.key));
expect((back[1] as LibraryRoute).folder.collectionType, 'tvshows');
expect((back[2] as SeriesRoute).seasonId, seasonId);
expect(decodeBrowseStack('junk'), isNull);
});
testWidgets('the browser reopens where it was, per server', (tester) async {
await setSize(tester, const Size(1600, 1000));
final (accounts, _) = await linkedAccounts();
await accounts.saveBrowseLocation(fixtureServerId, encodeBrowseStack([
const HomeRoute(),
SeriesRoute(const JellyfinItem(id: arifuretaId, name: 'Arifureta', type: 'Series'),
seasonId: season2Id),
]));
final c = MediaPickerController(source: MediaKind.jellyfin);
addTearDown(c.dispose);
await tester.pumpWidget(app(MediaPicker(controller: c, jellyfin: accounts)));
await settle(tester);
final s2 = tester.widget<ChoiceChip>(find.byKey(const ValueKey('season-$season2Id')));
expect(s2.selected, isTrue);
// Back to Home is remembered too.
await tester.tap(find.byTooltip('Back (Esc)'));
await settle(tester);
expect(decodeBrowseStack(accounts.browseLocation(fixtureServerId))!.map((r) => r.key), ['home']);
});
});
group('session widgets', () {
final next = EpisodeLink(
const JellyfinItem(
id: nextEpisodeId, name: 'Battle with the Heroes', type: 'Episode',
parentIndexNumber: 1, indexNumber: 7),
const JellyfinMedia(
serverId: fixtureServerId, baseUrl: 'b', itemId: nextEpisodeId, title: 'Battle with the Heroes'));
testWidgets('next / previous buttons: tooltip, hidden without an episode', (tester) async {
var pressed = 0;
await tester.pumpWidget(app(Row(children: [
EpisodeSkipButton(episode: null, next: false, onPressed: () {}),
EpisodeSkipButton(episode: next, next: true, onPressed: () => pressed++),
])));
expect(find.byKey(const ValueKey('episode-previous')), findsNothing);
expect(find.byTooltip('Next: S1E7 Battle with the Heroes (> or Shift+N)'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('episode-next')));
expect(pressed, 1);
});
testWidgets('end card: Next prompt, countdown with Cancel', (tester) async {
var play = 0, cancel = 0;
await tester.pumpWidget(app(EndOfEpisodeCard(
next: next, countdown: null, onPlayNow: () => play++, onCancel: () => cancel++)));
expect(find.text('Next: S1E7 Battle with the Heroes'), findsOneWidget);
expect(find.byKey(const ValueKey('auto-advance-cancel')), findsNothing);
await tester.tap(find.byKey(const ValueKey('play-next')));
expect(play, 1);
await tester.pumpWidget(app(EndOfEpisodeCard(
next: next,
countdown: AutoAdvance(mediaId: 3, secondsLeft: 7, next: next, swapper: false),
onPlayNow: () => play++,
onCancel: () => cancel++)));
expect(find.text('Next episode in 7 s'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('auto-advance-cancel')));
expect(cancel, 1);
});
testWidgets('next-episode pill: faint at rest, lit on hover, click puts it up', (tester) async {
var pressed = 0;
await tester.pumpWidget(app(Align(child: NextEpisodePill(episode: next, onPressed: () => pressed++))));
double opacity() => tester.widget<AnimatedOpacity>(find.descendant(
of: find.byType(NextEpisodePill), matching: find.byType(AnimatedOpacity))).opacity;
expect(opacity(), lessThan(0.5));
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
await mouse.addPointer(location: Offset.zero);
addTearDown(mouse.removePointer);
await mouse.moveTo(tester.getCenter(find.byKey(const ValueKey('next-episode-pill'))));
await tester.pumpAndSettle();
expect(opacity(), 1);
await tester.tap(find.byKey(const ValueKey('next-episode-pill')));
expect(pressed, 1);
});
testWidgets('blocked card offers to link an account', (tester) async {
var link = 0;
await tester.pumpWidget(app(JellyfinBlockedCard(
blocked: const BlockedPlay(serverId: fixtureServerId, serverName: 'Example Server'),
onLinkAccount: () => link++,
onRetry: () {})));
expect(find.text('This needs a Jellyfin account on Example Server (or the host can relay)'),
findsOneWidget);
await tester.tap(find.byKey(const ValueKey('link-jellyfin-account')));
expect(link, 1);
});
testWidgets('swap panel: the shared picker, File only for the host', (tester) async {
await setSize(tester, const Size(1400, 900));
final (accounts, _) = await linkedAccounts();
final c = MediaPickerController(source: MediaKind.link);
addTearDown(c.dispose);
final swapped = <PickedMedia>[];
var closed = 0;
await tester.pumpWidget(app(SwapPanel(
controller: c,
accounts: accounts,
isHost: false,
onSwap: (m) async {
swapped.add(m);
return null;
},
onClose: () => closed++,
)));
await settle(tester);
expect(find.widgetWithText(ButtonSegment<MediaKind>, 'File'), findsNothing);
expect(find.text('File'), findsNothing);
expect(find.text('Jellyfin'), findsOneWidget);
await tester.enterText(find.byType(TextField), 'https://cdn.example/movie.mkv');
await tester.pump();
await tester.tap(find.byKey(const ValueKey('swap-go')));
await settle(tester);
expect(swapped, [const LinkMedia('https://cdn.example/movie.mkv')]);
expect(closed, 1);
// The Jellyfin source is the library browser; a pick goes up as is.
await tester.tap(find.text('Jellyfin'));
await settle(tester);
expect(find.byKey(const ValueKey('media-jellyfin')), findsOneWidget);
await tester.pumpWidget(app(SwapPanel(
controller: c, accounts: accounts, isHost: true, onSwap: (_) async => 'x', onClose: () {})));
await settle(tester);
expect(find.text('File'), findsOneWidget);
});
testWidgets('swap panel opens on the series of the episode playing, episode highlighted',
(tester) async {
await setSize(tester, const Size(1600, 1000));
final (accounts, _) = await linkedAccounts();
// Somewhere else remembered: the series start wins.
await accounts.saveBrowseLocation(fixtureServerId, encodeBrowseStack([const HomeRoute()]));
final eps = (jsonDecode(jellyfinFixture('episodes')) as Map)['Items'] as List;
final ep = JellyfinItem.fromJson((eps.firstWhere((e) => e['SeasonId'] == season2Id) as Map)
.cast<String, dynamic>());
final pick = pickForRef(accounts,
JellyfinRef(serverId: fixtureServerId, itemId: ep.id, title: ep.name))!;
final c = MediaPickerController(jellyfin: pick, source: MediaKind.jellyfin);
addTearDown(c.dispose);
await tester.pumpWidget(app(SwapPanel(
controller: c,
accounts: accounts,
isHost: false,
jellyfinStart: BrowseStart.seriesOf(fixtureServerId, ep),
onSwap: (_) async => null,
onClose: () {},
)));
await settle(tester, 10);
expect(tester.widget<ChoiceChip>(find.byKey(const ValueKey('season-$season2Id'))).selected,
isTrue);
expect(find.text('Put up: ${pick.label}'), findsOneWidget);
final row = tester.widget<Material>(find
.ancestor(of: find.byKey(ValueKey('episode-${ep.id}')), matching: find.byType(Material))
.first);
expect((row.shape as RoundedRectangleBorder).side.color, isNot(Colors.transparent),
reason: 'the episode playing is highlighted');
});
});
}
@@ -7,6 +7,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:hive_ce/hive.dart'; import 'package:hive_ce/hive.dart';
import 'package:media_kit/media_kit.dart'; import 'package:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/jellyfin/accounts.dart';
import 'package:watch_together/src/session/session_coordinator.dart'; import 'package:watch_together/src/session/session_coordinator.dart';
import 'package:watch_together/src/state/server_isolate.dart'; import 'package:watch_together/src/state/server_isolate.dart';
import 'package:watch_together/src/state/session_state.dart'; import 'package:watch_together/src/state/session_state.dart';
@@ -162,4 +163,59 @@ void main() {
await session.stopServer(); await session.stopServer();
} }
}); });
test('host path: a Jellyfin pick goes up as a ref with the host relay; File swap',
timeout: const Timeout(Duration(minutes: 2)), () async {
MediaKit.ensureInitialized();
final tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
Hive.init('$tmp/hive_isolate_jf_test');
const sid = 'abcdefabcdefabcdefabcdefabcdef12';
final accounts = JellyfinAccounts.instance;
await accounts.ensureLoaded(validate: false);
// An unreachable Jellyfin: only the wire and the relay flag matter here.
await accounts.link(
baseUrl: 'http://127.0.0.1:9',
server: const JellyfinServerInfo(id: sid, name: 'J', version: '10.11.11'),
auth: const JellyfinAuthResult(
accessToken: 'HOSTTOKEN', user: JellyfinUser(id: 'u', name: 'u'), serverId: sid));
final session = SessionState.instance;
final port = await _freePort();
_Raw? friend;
final file = File('$tmp/host file swap.mkv')..writeAsBytesSync(List.filled(4096, 7));
try {
await session.host(
ConnectParams(
host: '',
port: port,
secret: '',
name: 'host',
media: const HelloMedia(
name: 'Pilot',
size: 0,
duration: 0,
jellyfin: JellyfinRef(serverId: sid, itemId: 'e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1', title: 'Pilot'))),
ConnectForm());
await session.coordinator.playback.player.setVolume(0);
friend = await _Raw.join(port, 'friend');
final state = await friend.next('state', where: (m) => m['media'] != null);
final media = state['media'] as Map;
expect(media['jellyfin'], isA<Map>());
expect(media.containsKey('url'), isFalse);
expect(state['relay'], isTrue, reason: "the host's own sign-in relays for guests");
expect(jsonEncode(state), isNot(contains('HOSTTOKEN')));
expect(await session.coordinator.swapFile(file.path), isNull);
final swapped = await friend.next('state',
where: (m) => (m['media'] as Map?)?['name'] == 'host file swap.mkv');
expect((swapped['media'] as Map)['jellyfin'], isNull);
expect(swapped['relay'], isNull);
expect(await session.coordinator.swapFile('$tmp/missing.mkv'), contains('not found'));
} finally {
await friend?.ws.close();
await session.coordinator.leave(silent: true);
await session.stopServer();
await accounts.signOut(sid);
if (file.existsSync()) file.deleteSync();
}
});
} }
+84 -9
View File
@@ -26,7 +26,10 @@ an `AppConfig.toMap()` (primitives only, so it crosses isolates). It starts a
`SyncServer` (bound to all IPv4 interfaces) and reports on the `events` port: `SyncServer` (bound to all IPv4 interfaces) and reports on the `events` port:
`ready` (with the port and a `stop` SendPort), `error` (start failed), and `ready` (with the port and a `stop` SendPort), `error` (start failed), and
every log record as `log`. Sending `{'t':'stop'}` to the `stop` port closes every log record as `log`. Sending `{'t':'stop'}` to the `stop` port closes
all sockets and returns, and the isolate exits. all sockets and returns, and the isolate exits. `{'t':'file', 'path', 'reply'}`
on the same port makes a local file the hosted one and answers its name and
size (`ServerIsolate.setFile`): the host's in-session File swap then sends a
`swap` with that name and size, which the server matches to the file.
- **Hosting from the app**: `SessionState.host()` spawns an isolate running - **Hosting from the app**: `SessionState.host()` spawns an isolate running
`serve` (`ServerIsolate`), waits for `ready`, then joins it over `serve` (`ServerIsolate`), waits for `ready`, then joins it over
@@ -96,7 +99,10 @@ Loosely modeled on Syncplay, with a different readiness design.
position, bumps `eventId` and records who sent it (`seekBy`, `seekSeq` in position, bumps `eventId` and records who sent it (`seekBy`, `seekSeq` in
`state` for that event); flags are untouched. Clients diff each `state` `state` for that event); flags are untouched. Clients diff each `state`
broadcast by `eventId` (`StateDiff`): new event means seek to the computed broadcast by `eventId` (`StateDiff`): new event means seek to the computed
server position, transport change means play/pause. Between events: drift server position, transport change means play/pause. The coordinator
applies states one at a time, newest first (a state arriving meanwhile
replaces any still waiting): interleaved applies could land an older
pause after a newer play. Between events: drift
over 5 s for more than 2 s seeks, ahead by more than 1.5 s sets rate 0.95 over 5 s for more than 2 s seeks, ahead by more than 1.5 s sets rate 0.95
until level, otherwise nothing. until level, otherwise nothing.
- **User seeks**: the player is a `SyncPlayer` whose public `seek` is the - **User seeks**: the player is a `SyncPlayer` whose public `seek` is the
@@ -141,7 +147,10 @@ Three sources, same sync.
- **Link**: the media field holds an http(s) URL. It travels as `media.url` - **Link**: the media field holds an http(s) URL. It travels as `media.url`
in `swap` and `state`, and as `mediaUrl` in `welcome`. Each app plays it in `swap` and `state`, and as `mediaUrl` in `welcome`. Each app plays it
directly; the server is only the clock. directly; the server is only the clock. A pasted link that
`parseJellyfinLink` recognises goes up as a Jellyfin ref instead when the
ServerId is known (in the link, else `/System/Info/Public`), so its
`api_key` is never shared (`mediaForLink`, host and swap alike).
- **Host local file**: `SyncServer.setLocalFile` / `mediaPath` makes the file - **Host local file**: `SyncServer.setLocalFile` / `mediaPath` makes the file
the session media. `GET /m/<sid>/<token>/<name>` serves it with a Range the session media. `GET /m/<sid>/<token>/<name>` serves it with a Range
table, gated by the viewer's token. The server builds the URL per viewer table, gated by the viewer's token. The server builds the URL per viewer
@@ -319,6 +328,66 @@ errors leave it out).
subscription does). subscription does).
- No progress reporting in the server: watched state is per client. - No progress reporting in the server: watched state is per client.
## Jellyfin in a session
Client side (`lib/src/jellyfin/session_media.dart`, `progress.dart`,
`track_prefs.dart`, wired in `SessionCoordinator`):
- **Resolve**: a `state` whose media is a ref is resolved per viewer
(`resolveJellyfinRef`): an account for that ServerId that is not expired
-> PlaybackInfo with its own user (PlaySessionId, media source; `/Items/{id}`
alongside for series and episode numbers), then mpv plays the static
stream with the token in `http-header-fields` (`Authorization:
MediaBrowser` with this install's fields), not in the URL: `OwnPlay`,
`report.jf {serverId: true}`. Else, when `state.relay`, `welcome.relayUrl`
or `/j/default/<token>/<itemId>` on the dialled host: `RelayPlay`,
`jf: false`. Else `BlockedPlay`: the session shows "This needs a
Jellyfin account on <server> (or the host can relay)" with a button that
opens Integrations over the session; linking (or reconnecting) the
account retries by itself. A 401 marks the account expired. The member
list shows "via host" for members whose `jf` is false for the current
item. A reconnect to the same item with `OwnPlay` keeps playing as is.
- **Progress** (`JellyfinProgressReporter`, `OwnPlay` only): Playing once the
file is open and mpv has picked its tracks; Progress every 10 s and at
once on pause, resume, a seek (position more than 3 s from where playback
should be) or a track change; Stopped on swap, leave, kick, window close
(bounded to 1.5 s inside the graceful leave) and at `finished` (position =
duration, so Jellyfin marks it played); seeking back from the end sends
Playing again. Reports carry `PositionTicks`, `IsPaused` and mpv's
`aid`/`sid` as Jellyfin stream indices (`jellyfinIndexForMpvTrack`,
`SubtitleStreamIndex -1` = off). They go out in order on one chain;
failures are logged, one quiet toast per session, playback is never
touched; a 401 marks the account expired and ends reporting.
- **Episodes**: with `OwnPlay` on an episode, `adjacentEpisodes` (one
`/Shows/{id}/Episodes` call; `episodeBefore` / `episodeAfter`) gives the
previous and next episode. The controls show previous / next buttons
(also `<` `>`, Shift+P / Shift+N); from `nextPromptStart` (last 2 min of
the runtime; the single place to switch to an outro segment later) a
floating "Next episode" pill shows outside the auto-hiding controls. Any
of them swaps to that episode's ref with the clicker's account. Guests
(relay, blocked) see none of it.
- **Auto-advance** (Settings, on by default): at `finished` with a known
next episode, every client with the setting on shows a 10 s countdown
card (Cancel / Play now; without the setting, a "Next" card with Play
next). Exactly one client swaps: `autoAdvanceSwapper`, the lowest member
id among non-stale members whose `jf` for that server is true. Only what
every client sees in `state` decides it (who put the item up is not in
`state`; a late joiner never saw that swap), so clients agree without a
wire change. Cancel is local: it stops the countdown on that screen, and
the swapper's Cancel stops the swap; a seek by anyone ends `finished`
and with it every countdown.
- **Tracks**: the controls' subtitles button lists mpv's audio and subtitle
tracks. A pick is applied, reported, and saved as a `TrackChoice`
(language, title, codec, channels, forced/HI: from the Jellyfin streams
with `OwnPlay`, else from mpv's track list) per series and as the global
fallback in the `track_prefs` Hive box. On the next Jellyfin item it is
matched with `matchTrack` against the source's streams (mapped to mpv ids
with `mpvTrackId`) or mpv's track list, and selected locally. Nothing goes
on the wire.
- **Swap panel**: "Change video" in the side panel opens the shared
`MediaPicker` over the video (`screens/session/swap_panel.dart`); Link
goes through `mediaForLink`, a Jellyfin pick as a ref, File (host only)
through `ServerIsolate.setFile` and a matching `swap`.
## Desktop UI ## Desktop UI
One `HomeScreen` (`screens/home/`) with a single top bar (`top_bar.dart`): One `HomeScreen` (`screens/home/`) with a single top bar (`top_bar.dart`):
@@ -339,7 +408,7 @@ Jellyfin source the browser fills the height and can be enlarged, which
turns the column into a one-row bar under it. Text fields have fixed or capped widths (720 px max) so turns the column into a one-row bar under it. Text fields have fixed or capped widths (720 px max) so
they stay compact on ultrawide screens. Submit state (busy, cancel, they stay compact on ultrawide screens. Submit state (busy, cancel,
per-tab errors, server crash) is `ConnectFlow`. Settings (profile, hardware per-tab errors, server crash) is `ConnectFlow`. Settings (profile, hardware
decoding, updates, video shaders, shortcuts), Integrations decoding, Jellyfin auto-advance, updates, video shaders, shortcuts), Integrations
(linked Jellyfin servers) and About open in the same body. Settings persist (linked Jellyfin servers) and About open in the same body. Settings persist
in two Hive boxes: `connect_form` (last form and recents; saved even when a in two Hive boxes: `connect_form` (last form and recents; saved even when a
join fails) and `settings`. Link media names are derived from the URL path join fails) and `settings`. Link media names are derived from the URL path
@@ -369,11 +438,17 @@ Jellyfin on the desktop (`lib/src/jellyfin/`, over the core client):
hover/focus from 1400 px browser width. hover/focus from 1400 px browser width.
- Picking an item gives a `JellyfinMedia` (serverId, baseUrl, itemId, - Picking an item gives a `JellyfinMedia` (serverId, baseUrl, itemId,
mediaSourceId?, title, subtitle, runtime; no credentials). The Host tab mediaSourceId?, title, subtitle, runtime; no credentials). The Host tab
remembers the last pick in the `jellyfin` box. Interim until the protocol remembers the last pick in the `jellyfin` box and hosts it as a ref
carries Jellyfin items: `interimJellyfinLink` in `host_tab.dart` (`hostJellyfinMedia`, `ConnectFlow.submit(media:)`); the hosted server
(`INTERIM(jellyfin-protocol)`) hosts it as a link to the host's gets the relay credential (`relayCredentialFor`: the account of the
authorised static stream URL; `ConnectFlow.submit(linkOverride:)` keeps item's server, else the default one, not expired; its token with
that token-bearing URL out of the saved form. exactly `JellyfinAccounts.clientInfo`'s device fields).
- Browser location: every navigation saves the route stack per server
(`browse:<serverId>` in the `jellyfin` box, `encodeBrowseStack`: ids,
names and the fields pages start from), so the Host tab, the swap panel
and the next start open where the browser was. A `BrowseStart` overrides
it: the swap panel opens on the series of the episode playing, its
season selected and the episode highlighted.
Window chrome is frameless (`window_manager`): every screen brings its own Window chrome is frameless (`window_manager`): every screen brings its own
drag area and min/max/close buttons (Home's top bar; the session uses drag area and min/max/close buttons (Home's top bar; the session uses
+2 -1
View File
@@ -46,7 +46,8 @@ and `e2e_check` (simulated viewers against a live server).
- `cd packages/core && dart test`, `cd packages/server && dart test`. - `cd packages/core && dart test`, `cd packages/server && dart test`.
- `cd apps/desktop && flutter test test/<file>`. - `cd apps/desktop && flutter test test/<file>`.
- Real-mpv tests: `sync_playback_e2e`, `coord_presence`, `video_reload_e2e`, - Real-mpv tests: `sync_playback_e2e`, `coord_presence`, `video_reload_e2e`,
`seek_drag_e2e`, `end_of_file_e2e`, `readahead_e2e`. The sync and `seek_drag_e2e`, `end_of_file_e2e`, `readahead_e2e`, `jellyfin_e2e` (a
local fake Jellyfin over HTTP; tests never talk to a real Jellyfin). The sync and
end-of-file ones expect `~/Videos/chiyo.mp4`; others generate media with end-of-file ones expect `~/Videos/chiyo.mp4`; others generate media with
ffmpeg into `WT_TEST_TMP`. Headless mpv (no video output attached) won't ffmpeg into `WT_TEST_TMP`. Headless mpv (no video output attached) won't
open video-only files, so test media needs an audio track. Tests always log open video-only files, so test media needs an audio track. Tests always log
+10 -4
View File
@@ -17,7 +17,9 @@ A five-minute tour. Details: [ARCHITECTURE.md](ARCHITECTURE.md), wire format:
+----------------------------------------+ +----------------------------------------+
video bytes come from somewhere else: video bytes come from somewhere else:
- a link (Jellyfin, any http/https URL): each app fetches it directly - a link (any http/https URL): each app fetches it directly
- a Jellyfin item: each app plays it with its own Jellyfin account,
friends without one through the server (/j/... endpoint)
- a file on the host's disk: the server streams it (/m/... endpoint) - a file on the host's disk: the server streams it (/m/... endpoint)
``` ```
@@ -26,8 +28,10 @@ A five-minute tour. Details: [ARCHITECTURE.md](ARCHITECTURE.md), wire format:
- **Server**: keeps one shared clock and the list of people. It never sees - **Server**: keeps one shared clock and the list of people. It never sees
video frames, only "position 123 s, playing". There is exactly one server video frames, only "position 123 s, playing". There is exactly one server
and one watch session on it. and one watch session on it.
- **Jellyfin** (or any web host): where a link's video lives. The server does - **Jellyfin** (or any web host): where the video lives. For a Jellyfin
not touch it today; each app downloads it itself. item the server only knows which item it is; each app signs in with its
own account and reports what it watched. For friends without an account
the server fetches the item with the host's sign-in and passes it on.
## Ways to run ## Ways to run
@@ -71,7 +75,9 @@ anyone except the host.
## Where video comes from ## Where video comes from
- **Link**: you paste an http(s) URL. Every app plays it directly with mpv. - **Link**: you paste an http(s) URL. Every app plays it directly with mpv.
Works for a Jellyfin direct-download link or any file on the web. A pasted Jellyfin link becomes the Jellyfin item below when possible.
- **Jellyfin item**: picked in the app's library browser. Shared as "this
item on this Jellyfin server", never as a link with someone's sign-in.
- **Local file (host only)**: you pick a file on your disk. The server - **Local file (host only)**: you pick a file on your disk. The server
streams it to everyone, host included, over HTTP with range requests, each streams it to everyone, host included, over HTTP with range requests, each
with a per-person token. Nothing is stored or converted. with a per-person token. Nothing is stored or converted.
+14 -22
View File
@@ -24,8 +24,7 @@ off by deleting it in the same commit as the code. Wire changes also edit
next suspect is media_kit's context switching on the raster thread next suspect is media_kit's context switching on the raster thread
with NVIDIA. with NVIDIA.
- Link conveniences: a link swap in session updates the remembered media; - Link conveniences: a link swap in session updates the remembered media;
show the open link with an open-in-browser button; the session's swap show the open link with an open-in-browser button.
panel reuses Home's `MediaPicker`.
- Time UI: remaining time, scrubber preview. - Time UI: remaining time, scrubber preview.
- mpv / mpv.net shortcut parity. - mpv / mpv.net shortcut parity.
- Sounds: replace `NoopSfxService` (cues already fire). Candidates: - Sounds: replace `NoopSfxService` (cues already fire). Candidates:
@@ -38,32 +37,25 @@ off by deleting it in the same commit as the code. Wire changes also edit
- Avatar caching: serve avatar bytes once by hash instead of base64 in - Avatar caching: serve avatar bytes once by hash instead of base64 in
every `state` broadcast. every `state` broadcast.
- The `state` broadcast carries the full media URL, which can include an - The `state` broadcast carries the full media URL, which can include an
`api_key`. `api_key` (recognised Jellyfin links become refs; other links do not).
- TLS: document a reverse proxy in front of the server. - TLS: document a reverse proxy in front of the server.
- Playlist: `queue-add`, `queue-skip`, auto-advance like a swap. - Playlist: `queue-add`, `queue-skip`, auto-advance like a swap.
- Sidecar subtitles. - Sidecar subtitles.
## Epic: Jellyfin ## Epic: Jellyfin
- Each viewer logs in to Jellyfin themselves (Quick Connect or password). What is left (the rest is in [ARCHITECTURE.md](ARCHITECTURE.md), "Jellyfin
- Several Jellyfin servers can be linked, keyed by `ServerId`. in a session"):
- The sync server shares only `{ServerId, ItemId}`. Pasted Jellyfin links are - Queue: put several library items (a season, a pick list) up as a
recognised and reduced to that pair. playlist that advances like a swap (shares `queue-add` / `queue-skip`
- Viewers with an account direct-play `/Videos/{id}/stream?static=true` with the backlog's Playlist item).
with their own token and report Playing / Progress / Stopped, so watched - The "Next episode" prompt from the item's Outro / credits segment instead
state and Continue Watching update per account. of the last two minutes (`nextPromptStart`).
- Guests without an account play through the sync server, which fetches with a - Auto-advance cancel for everyone: today Cancel is per screen (the
credential that stays server-side (the host's own or a configured one). designated swapper's Cancel stops the swap; a seek stops all). A shared
- Next episode: the successor in `/Shows/{seriesId}/Episodes` (NextUp means cancel needs a wire message.
first unwatched, not next-after-X); audio and subtitle track matching by language, then title, - Sidecar (external) subtitle streams of Jellyfin items: only embedded
then codec/channels. tracks are mapped to mpv today.
- Library browser; anyone can put up items.
Spike facts (Jellyfin 10.11.11): `/Videos/{id}/stream?static=true&mediaSourceId=`
serves the original with byte ranges (206). PlaybackInfo gives `PlaySessionId`
and DirectPlay. All audio and subtitle tracks are embedded in the mkv. Quick
Connect is enabled but `/QuickConnect/Initiate` returned 400: investigate
(likely missing client/device fields in the auth header).
## Deferred ## Deferred
@@ -426,6 +426,16 @@ class JellyfinClient {
await episodes(seriesId, withMediaStreams: withMediaStreams), episode.id); await episodes(seriesId, withMediaStreams: withMediaStreams), episode.id);
} }
/// The episodes before and after [episode] in its series (one
/// `/Shows/{id}/Episodes` call; see [episodeBefore] / [episodeAfter]).
Future<({JellyfinItem? previous, JellyfinItem? next})> adjacentEpisodes(
JellyfinItem episode) async {
final seriesId = episode.seriesId;
if (seriesId == null) return (previous: null, next: null);
final list = await episodes(seriesId);
return (previous: episodeBefore(list, episode.id), next: episodeAfter(list, episode.id));
}
// -------------------------------------------------------------- images // -------------------------------------------------------------- images
/// Image URL for caching and display. Images need no token on Jellyfin, /// Image URL for caching and display. Images need no token on Jellyfin,
@@ -594,3 +604,20 @@ JellyfinItem? episodeAfter(List<JellyfinItem> episodes, String episodeId) {
} }
return null; return null;
} }
/// The predecessor of [episodeId] in [episodes], the mirror of
/// [episodeAfter]: crossing seasons, skipping other copies of the same
/// episode. Null when it is first or not in the list.
JellyfinItem? episodeBefore(List<JellyfinItem> episodes, String episodeId) {
final at = episodes.indexWhere((e) => e.id == episodeId);
if (at < 0) return null;
final cur = episodes[at];
for (var i = at - 1; i >= 0; i--) {
final e = episodes[i];
final sameEpisode = cur.indexNumber != null &&
e.indexNumber == cur.indexNumber &&
e.parentIndexNumber == cur.parentIndexNumber;
if (!sameEpisode) return e;
}
return null;
}
@@ -235,6 +235,22 @@ void main() {
expect(episodeAfter(eps, eps.last.id), isNull); expect(episodeAfter(eps, eps.last.id), isNull);
expect(episodeAfter(eps, 'nope'), isNull); expect(episodeAfter(eps, 'nope'), isNull);
}); });
test('adjacentEpisodes: previous mirrors next across seasons and copies', () async {
http.on('GET', '/Shows/550eff23b37ec61082c988678ccc214c/Episodes', fixture: 'episodes');
final eps = await client.episodes('550eff23b37ec61082c988678ccc214c');
final s3e2 = eps.firstWhere((e) => e.episodeLabel == 'S3E2');
final around = await client.adjacentEpisodes(s3e2);
// S3E1 exists twice: the predecessor is the copy right before S3E2.
expect(around.previous!.episodeLabel, 'S3E1');
expect(around.next!.episodeLabel, 'S3E3');
final s3e1 = eps.lastWhere((e) => e.episodeLabel == 'S3E1');
expect(episodeBefore(eps, s3e1.id)!.episodeLabel, 'S2E12');
expect(episodeBefore(eps, eps.first.id), isNull);
expect(episodeBefore(eps, 'nope'), isNull);
expect((await client.adjacentEpisodes(const JellyfinItem(id: 'x', name: 'm', type: 'Movie'))).next,
isNull);
});
}); });
group('images', () { group('images', () {
+19 -2
View File
@@ -14,7 +14,11 @@ import 'sync_server.dart';
/// `{'t':'error','message':m}`, and forwards log records as /// `{'t':'error','message':m}`, and forwards log records as
/// `{'t':'log','level','logger','message'[,'error']}`. Sending /// `{'t':'log','level','logger','message'[,'error']}`. Sending
/// `{'t':'stop'}` to the `stop` port stops the server; the returned future /// `{'t':'stop'}` to the `stop` port stops the server; the returned future
/// completes once it is down. Without [events] a start failure throws. /// completes once it is down. `{'t':'file','path':p,'reply':SendPort}` makes
/// a file on this machine the hosted file (the hosting app's in-session file
/// swap; a following `swap` with its name and size puts it up) and answers
/// `{'ok':true,'name':n,'size':s}` or `{'ok':false}`. Without [events] a
/// start failure throws.
Future<void> serve(Map<String, Object?> config, {SendPort? events}) async { Future<void> serve(Map<String, Object?> config, {SendPort? events}) async {
StreamSubscription<pkg_logging.LogRecord>? logs; StreamSubscription<pkg_logging.LogRecord>? logs;
if (events != null) { if (events != null) {
@@ -39,7 +43,20 @@ Future<void> serve(Map<String, Object?> config, {SendPort? events}) async {
final control = ReceivePort('serve control'); final control = ReceivePort('serve control');
events?.send({'t': 'ready', 'port': server.boundPort, 'stop': control.sendPort}); events?.send({'t': 'ready', 'port': server.boundPort, 'stop': control.sendPort});
await for (final msg in control) { await for (final msg in control) {
if (msg is Map && msg['t'] == 'stop') break; if (msg is! Map) continue;
if (msg['t'] == 'stop') break;
if (msg['t'] == 'file' && msg['path'] is String && msg['reply'] is SendPort) {
final reply = msg['reply'] as SendPort;
try {
final ok = await server.setLocalFile(msg['path'] as String);
final media = server.session.media;
reply.send(ok && media != null
? {'ok': true, 'name': media.name, 'size': media.size}
: {'ok': false});
} catch (_) {
reply.send({'ok': false});
}
}
} }
control.close(); control.close();
await server.stop(); await server.stop();
@@ -51,6 +51,31 @@ void main() {
errors.close(); errors.close();
}); });
test('serve: the file control message makes a local file the hosted one', () async {
final events = ReceivePort();
final exit = ReceivePort();
final dir = Directory.systemTemp.createTempSync('serve_file_');
final file = File('${dir.path}/ep 2.mkv')..writeAsBytesSync(List.filled(1000, 1));
await Isolate.spawn(_run, (const AppConfig(port: 0, secret: '', name: 'f').toMap(), events.sendPort),
onExit: exit.sendPort);
final ready = await events.firstWhere((m) => m is Map && m['t'] == 'ready') as Map;
final stop = ready['stop'] as SendPort;
Future<Object?> ask(String path) async {
final reply = ReceivePort();
stop.send({'t': 'file', 'path': path, 'reply': reply.sendPort});
final r = await reply.first.timeout(const Duration(seconds: 5));
reply.close();
return r;
}
expect(await ask(file.path), {'ok': true, 'name': 'ep 2.mkv', 'size': 1000});
expect(await ask('${dir.path}/missing.mkv'), {'ok': false});
stop.send({'t': 'stop'});
await exit.first.timeout(const Duration(seconds: 10));
events.close();
dir.deleteSync(recursive: true);
});
test('serve reports a bind failure as an error event', () async { test('serve reports a bind failure as an error event', () async {
final blocker = await ServerSocket.bind(InternetAddress.anyIPv4, 0); final blocker = await ServerSocket.bind(InternetAddress.anyIPv4, 0);
final events = ReceivePort(); final events = ReceivePort();