forked from Foxyz/sync_replay_project
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,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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in new issue
Block a user