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

@@ -1,6 +1,7 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:sync_relay_core/core.dart' show HelloMedia;
import '../../session/session_coordinator.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
/// other card's saved values survive. [linkOverride] (host only) is played
/// as the link but never stored in the form: the interim Jellyfin stream
/// URL carries a token.
/// other card's saved values survive. [media] (host only) is put up
/// instead of the form's link or file (a Jellyfin ref; the pick itself is
/// remembered in the Jellyfin box, not the form).
Future<void> submit(ConnectMode mode, void Function(ConnectForm f) edit,
{String? linkOverride}) async {
{HelloMedia? media}) async {
if (busy != null) return;
final myAttempt = ++_attempt;
final form = _session.lastForm.copy();
@@ -113,15 +114,10 @@ class ConnectFlow extends ChangeNotifier {
notices[mode] = null;
_notify();
final ConnectParams params;
ConnectParams params;
try {
params = await buildParams(
mode,
linkOverride == null || mode != ConnectMode.host
? form
: (form.copy()
..mediaPath = ''
..linkUrl = linkOverride));
params = await buildParams(mode, form);
if (media != null && mode == ConnectMode.host) params = params.withMedia(media);
} catch (e) {
if (myAttempt != _attempt) return;
busy = null;
+11 -12
View File
@@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart' show HelloMedia;
import '../../jellyfin/accounts.dart';
import '../../jellyfin/session_media.dart';
import '../../state/session_state.dart';
import 'connect_flow.dart';
import 'connect_status.dart';
@@ -48,20 +50,17 @@ class HostFields {
}
}
/// INTERIM(jellyfin-protocol): until the protocol carries a Jellyfin item,
/// the host plays it as a link: this install's authorised static stream URL
/// (token in the query, so viewers stream with the host's sign-in and the
/// URL must not be persisted). Throws [FormatException] with a user-facing
/// message when the server is not linked any more.
String interimJellyfinLink(JellyfinAccounts accounts, JellyfinMedia media) {
final client = accounts.clientFor(media.serverId);
if (client == null) {
/// What hosting a picked Jellyfin item sends: its ref (no URL, no token).
/// Throws [FormatException] with a user-facing message when the server is
/// not linked any more or its sign-in expired.
HelloMedia hostJellyfinMedia(JellyfinAccounts accounts, JellyfinMedia media) {
if (accounts.account(media.serverId) == null) {
throw const FormatException('that Jellyfin server is not linked any more — pick again');
}
if (accounts.statusOf(media.serverId) == JellyfinAccountStatus.expired) {
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.
@@ -79,10 +78,10 @@ class HostTab extends StatelessWidget {
void _host() {
if (flow.busy != null) return;
final media = fields.media.selection;
String? link;
HelloMedia? jellyfinMedia;
if (media is JellyfinMedia) {
try {
link = interimJellyfinLink(fields.jellyfin, media);
jellyfinMedia = hostJellyfinMedia(fields.jellyfin, media);
} on FormatException catch (e) {
flow.showError(ConnectMode.host, CardError('Check the form', e.message));
return;
@@ -96,7 +95,7 @@ class HostTab extends StatelessWidget {
..hostSecret = fields.secret.text
..mediaPath = media is FileMedia ? media.path : ''
..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 '../../jellyfin/accounts.dart';
import '../../jellyfin/browse_scope.dart' show BrowseStart;
import '../../jellyfin/picker_source.dart';
import '../../widgets/media_input.dart';
import 'picked_media.dart';
@@ -114,8 +115,17 @@ class MediaPicker extends StatefulWidget {
/// Jellyfin source only: the browser's "bigger" toggle.
final VoidCallback? onToggleExpanded;
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({
super.key,
this.jellyfinStart,
this.allowFile = true,
this.linkHelp,
required this.controller,
this.onChanged,
this.onSubmit,
@@ -221,6 +231,7 @@ class _MediaPickerState extends State<MediaPicker> {
onOpenIntegrations: widget.onOpenIntegrations,
onToggleExpanded: widget.onToggleExpanded,
expanded: widget.expanded,
start: widget.jellyfinStart,
),
MediaKind.link => _FieldRow(
key: const ValueKey('media-link'),
@@ -236,7 +247,9 @@ class _MediaPickerState extends State<MediaPicker> {
icon: const Icon(Icons.content_paste, size: 18),
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,
),
MediaKind.file => _FieldRow(
@@ -260,17 +273,17 @@ class _MediaPickerState extends State<MediaPicker> {
child: SegmentedButton<MediaKind>(
key: const ValueKey('media-source'),
showSelectedIcon: false,
segments: const [
ButtonSegment(
segments: [
const ButtonSegment(
value: MediaKind.jellyfin,
icon: Icon(Icons.video_library_outlined),
label: Text('Jellyfin')),
ButtonSegment(value: MediaKind.link, icon: Icon(Icons.link), label: Text('Link')),
ButtonSegment(
const ButtonSegment(value: MediaKind.link, icon: Icon(Icons.link), label: Text('Link')),
const ButtonSegment(
value: MediaKind.file,
icon: Icon(Icons.insert_drive_file_outlined),
label: Text('File')),
],
].where((s) => widget.allowFile || s.value != MediaKind.file).toList(),
selected: {source},
onSelectionChanged: (s) => _c.source = s.first,
),
@@ -17,6 +17,7 @@ const sessionShortcuts = [
('F', 'Toggle fullscreen'),
('Tab', 'Open / close the people & chat panel'),
('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+0', 'Shaders off'),
('Esc', 'Close panel or fullscreen, else ask to leave'),
@@ -34,6 +35,7 @@ class SettingsPage extends StatelessWidget {
const PageTitle('Settings'),
const _Section(title: 'Profile', child: ProfileEditor()),
const _Section(title: 'Player', child: _HwdecSetting()),
const _Section(title: 'Jellyfin', child: _AutoAdvanceSetting()),
_Section(title: 'Updates', child: _UpdateSettings(controller: updates)),
const _Section(title: 'Keyboard shortcuts', child: _ShortcutList()),
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 {
const _ShortcutList();