Jellyfin end to end: per-viewer playback, progress, episodes, swap browser
- Host/swap send refs only; pasted Jellyfin links become refs; the host hands the relay its own app-obtained credential. - Each viewer resolves with their own account (token in a header) or via the relay, else a clear blocked card with Link account. - Per-account progress reporting (Playing/Progress/Stopped), 401 marks the account expired. - Previous/next episode in the controls (< > / Shift+P/N), floating Next episode pill near the end, auto-advance countdown (one designated swapper), track carry-over per series. - Swap panel uses the shared MediaPicker; browser location remembered per server and opens on the current series. - Fix: states are applied one at a time (a stale pause could land after a newer play). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
773a04369e
commit
ae4edc29e8
33 files changed
+3541
-175
No files matched your search
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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', () {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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', () {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in new issue
Block a user