Jellyfin end to end: per-viewer playback, progress, episodes, swap browser

- Host/swap send refs only; pasted Jellyfin links become refs; the host
  hands the relay its own app-obtained credential.
- Each viewer resolves with their own account (token in a header) or
  via the relay, else a clear blocked card with Link account.
- Per-account progress reporting (Playing/Progress/Stopped), 401 marks
  the account expired.
- Previous/next episode in the controls (< > / Shift+P/N), floating
  Next episode pill near the end, auto-advance countdown (one
  designated swapper), track carry-over per series.
- Swap panel uses the shared MediaPicker; browser location remembered
  per server and opens on the current series.
- Fix: states are applied one at a time (a stale pause could land
  after a newer play).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-07 02:38:24 +03:00
1 parent 773a04369e
commit ae4edc29e8
33 files changed
+3541 -175

No files matched your search

+16 -1
View File
@@ -6,6 +6,8 @@ import 'package:flutter/widgets.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart';
import '../jellyfin/accounts.dart';
import '../jellyfin/session_media.dart';
import '../sfx/sfx.dart';
import '../session/health.dart';
import '../session/session_coordinator.dart';
@@ -154,6 +156,7 @@ class SessionState {
final SfxService sfx = const NoopSfxService();
late final SessionCoordinator coordinator = SessionCoordinator(sfx: sfx);
final lastForm = ConnectForm();
JellyfinAccounts get jellyfin => JellyfinAccounts.instance;
/// Set when the hosted server died mid-session; Home shows it on the
/// Host card and clears it.
final serverCrash = signal<String?>(null);
@@ -232,6 +235,14 @@ class SessionState {
if (op != _op) throw const ConnectCancelled();
final hostKey = newHostKey();
_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;
try {
server = await ServerIsolate.start(
@@ -241,6 +252,7 @@ class SessionState {
name: 'server',
mediaPath: params.mediaPath,
hostKey: hostKey,
jellyfin: relay,
),
onLog: (line) => debugPrint('[server] $line'),
onCrash: (cause) => _onServerCrash(server, cause),
@@ -256,6 +268,8 @@ class SessionState {
throw const ConnectCancelled();
}
_server = server;
coordinator.hostFile = (path) async =>
identical(_server, server) ? server.setFile(path) : null;
status.value =
HealthStatus(HealthSeverity.ok, 'hosting on port ${server.port}');
try {
@@ -265,8 +279,8 @@ class SessionState {
secret: params.secret,
name: params.name,
avatar: params.avatar,
linkUrl: params.linkUrl,
hostKey: hostKey,
media: media,
));
if (op != _op) throw const ConnectCancelled();
} catch (e) {
@@ -353,6 +367,7 @@ class SessionState {
}
Future<void> stopServer() async {
coordinator.hostFile = null;
_stopKickWatch?.call();
_stopKickWatch = null;
final server = _server;