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();
@@ -0,0 +1,328 @@
import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
import '../../jellyfin/session_media.dart';
import '../../jellyfin/track_prefs.dart';
import '../../session/session_coordinator.dart' show AutoAdvance, autoAdvanceSecs;
// Jellyfin pieces of the session screen. Plain widgets fed by the
// coordinator's signals, so tests can pump them without the player.
/// Previous / next episode in the video controls. Nothing when there is
/// no such episode or this viewer cannot put it up (no account).
class EpisodeSkipButton extends StatelessWidget {
final EpisodeLink? episode;
final bool next;
final VoidCallback onPressed;
final FocusNode? focusNode;
const EpisodeSkipButton(
{super.key, required this.episode, required this.next, required this.onPressed, this.focusNode});
@override
Widget build(BuildContext context) {
final e = episode;
if (e == null) return const SizedBox.shrink();
return Tooltip(
message: '${next ? 'Next' : 'Previous'}: ${e.label} (${next ? '> or Shift+N' : '< or Shift+P'})',
child: IconButton(
key: ValueKey(next ? 'episode-next' : 'episode-previous'),
focusNode: focusNode,
color: Colors.white,
iconSize: 22,
visualDensity: VisualDensity.compact,
icon: Icon(next ? Icons.skip_next_rounded : Icons.skip_previous_rounded),
onPressed: onPressed,
),
);
}
}
/// Floating "Next episode ›" near the end of an episode: faint at rest,
/// lit on hover. Lives outside the auto-hiding controls, so it shows in
/// fullscreen and with the controls hidden.
class NextEpisodePill extends StatefulWidget {
final EpisodeLink episode;
final VoidCallback onPressed;
const NextEpisodePill({super.key, required this.episode, required this.onPressed});
@override
State<NextEpisodePill> createState() => _NextEpisodePillState();
}
class _NextEpisodePillState extends State<NextEpisodePill> {
bool _hover = false;
@override
Widget build(BuildContext context) {
final primary = Theme.of(context).colorScheme.primary;
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: Tooltip(
message: 'Next: ${widget.episode.label}',
child: AnimatedScale(
duration: const Duration(milliseconds: 150),
scale: _hover ? 1.06 : 1,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 150),
opacity: _hover ? 1 : 0.45,
child: GestureDetector(
key: const ValueKey('next-episode-pill'),
onTap: widget.onPressed,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 9),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.75),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: _hover ? primary : Colors.white24),
boxShadow: _hover
? [BoxShadow(color: primary.withValues(alpha: 0.45), blurRadius: 16)]
: const [],
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('Next episode', style: TextStyle(color: Colors.white, fontSize: 13)),
SizedBox(width: 4),
Icon(Icons.chevron_right, color: Colors.white, size: 18),
],
),
),
),
),
),
),
);
}
}
/// At the end of an episode: "Next: S1E7 title" with Play now, and while
/// the auto-advance countdown runs, the seconds left and Cancel.
class EndOfEpisodeCard extends StatelessWidget {
final EpisodeLink next;
final AutoAdvance? countdown;
final VoidCallback onPlayNow;
final VoidCallback onCancel;
const EndOfEpisodeCard({
super.key,
required this.next,
required this.countdown,
required this.onPlayNow,
required this.onCancel,
});
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
final c = countdown;
return Card(
key: const ValueKey('end-of-episode'),
color: Colors.black.withValues(alpha: 0.85),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 12, 12),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 380),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(c == null ? 'Up next' : 'Next episode in ${c.secondsLeft} s',
style: t.labelLarge?.copyWith(color: Colors.white70)),
const SizedBox(height: 4),
Text('Next: ${next.label}',
maxLines: 2, overflow: TextOverflow.ellipsis, style: t.titleMedium),
if (c != null) ...[
const SizedBox(height: 8),
LinearProgressIndicator(value: c.secondsLeft / autoAdvanceSecs),
],
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (c != null)
TextButton(
key: const ValueKey('auto-advance-cancel'),
onPressed: onCancel,
child: const Text('Cancel')),
const SizedBox(width: 8),
FilledButton.icon(
key: const ValueKey('play-next'),
onPressed: onPlayNow,
icon: const Icon(Icons.skip_next_rounded, size: 18),
label: Text(c == null ? 'Play next' : 'Play now'),
),
],
),
],
),
),
),
);
}
}
/// This viewer cannot play the Jellyfin media: say why and offer to link
/// an account (never a silent stall).
class JellyfinBlockedCard extends StatelessWidget {
final BlockedPlay blocked;
final VoidCallback onLinkAccount;
final VoidCallback onRetry;
const JellyfinBlockedCard(
{super.key, required this.blocked, required this.onLinkAccount, required this.onRetry});
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Card(
key: const ValueKey('jellyfin-blocked'),
color: Colors.black.withValues(alpha: 0.85),
child: Padding(
padding: const EdgeInsets.all(20),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.lock_outline, color: Colors.orange, size: 32),
const SizedBox(height: 10),
Text(blocked.message, textAlign: TextAlign.center, style: t.titleSmall),
const SizedBox(height: 6),
Text('Link your own account in Integrations, or ask the host to relay it.',
textAlign: TextAlign.center, style: t.bodySmall?.copyWith(color: Colors.grey)),
const SizedBox(height: 14),
Wrap(
spacing: 8,
children: [
FilledButton(
key: const ValueKey('link-jellyfin-account'),
onPressed: onLinkAccount,
child: const Text('Link a Jellyfin account')),
TextButton(onPressed: onRetry, child: const Text('Try again')),
],
),
],
),
),
),
);
}
}
/// Small member-list badge: plays the Jellyfin item through the host.
class ViaHostBadge extends StatelessWidget {
const ViaHostBadge({super.key});
@override
Widget build(BuildContext context) {
return Tooltip(
message: 'No Jellyfin account here: watches through the host',
child: Container(
key: const ValueKey('via-host'),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: Colors.white10,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: Colors.white24),
),
child: const Text('via host', style: TextStyle(fontSize: 10, color: Colors.white70)),
),
);
}
}
/// A track pick from [TracksControlButton]: audio id, subtitle id, or off.
typedef TrackPick = ({int? aid, int? sid, bool subtitlesOff});
/// Audio / subtitle menu for the controls bar. The choice is remembered
/// for the next Jellyfin item (per viewer).
class TracksControlButton extends StatelessWidget {
final FocusNode? focusNode;
final List<MpvTrack> Function() tracks;
final Future<({int? aid, int? sid})> Function() current;
final void Function(TrackPick pick) onPick;
final ValueChanged<bool>? onMenuOpen;
const TracksControlButton({
super.key,
this.focusNode,
required this.tracks,
required this.current,
required this.onPick,
this.onMenuOpen,
});
@override
Widget build(BuildContext context) {
return Tooltip(
message: 'Audio and subtitles',
child: IconButton(
key: const ValueKey('tracks-button'),
focusNode: focusNode,
color: Colors.white,
iconSize: 20,
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.subtitles_outlined),
onPressed: () => _open(context),
),
);
}
Future<void> _open(BuildContext context) async {
final list = tracks();
final button = context.findRenderObject() as RenderBox;
final overlay = Navigator.of(context).overlay!.context.findRenderObject() as RenderBox;
final position = RelativeRect.fromRect(
Rect.fromPoints(
button.localToGlobal(Offset.zero, ancestor: overlay),
button.localToGlobal(button.size.bottomRight(Offset.zero), ancestor: overlay),
),
Offset.zero & overlay.size,
);
final navigator = Navigator.of(context);
final now = await current();
if (!navigator.mounted) return;
final audio = list.where((t) => t.type == JellyfinStreamType.audio).toList();
final subs = list.where((t) => t.type == JellyfinStreamType.subtitle).toList();
PopupMenuItem<TrackPick> header(String text) => PopupMenuItem(
enabled: false,
height: 28,
child: Text(text, style: const TextStyle(fontSize: 12, color: Colors.grey)));
onMenuOpen?.call(true);
final pick = await showMenu<TrackPick>(
context: navigator.context,
position: position,
popUpAnimationStyle: AnimationStyle.noAnimation,
constraints: const BoxConstraints(minWidth: 280, maxWidth: 420),
items: [
header('Audio'),
if (audio.isEmpty)
const PopupMenuItem(enabled: false, height: 32, child: Text('None')),
for (final t in audio)
CheckedPopupMenuItem(
value: (aid: t.id, sid: null, subtitlesOff: false),
checked: now.aid == t.id,
height: 36,
child: Text(t.label, maxLines: 1, overflow: TextOverflow.ellipsis),
),
const PopupMenuDivider(),
header('Subtitles'),
CheckedPopupMenuItem(
value: (aid: null, sid: null, subtitlesOff: true),
checked: now.sid == null,
height: 36,
child: const Text('Off'),
),
for (final t in subs)
CheckedPopupMenuItem(
value: (aid: null, sid: t.id, subtitlesOff: false),
checked: now.sid == t.id,
height: 36,
child: Text(t.label, maxLines: 1, overflow: TextOverflow.ellipsis),
),
],
);
onMenuOpen?.call(false);
if (pick != null) onPick(pick);
}
}
@@ -0,0 +1,136 @@
import 'package:flutter/material.dart';
import '../../jellyfin/accounts.dart';
import '../../jellyfin/browse_scope.dart' show BrowseStart;
import '../home/media_picker.dart';
/// "Change video" in a session: the same [MediaPicker] as Home's Host tab
/// (Jellyfin browser, Link, File) and a button that puts the pick up for
/// everyone. File is offered to the host only (the file streams from the
/// host's disk). [onSwap] returns an error text, or null when sent.
class SwapPanel extends StatefulWidget {
final MediaPickerController controller;
final JellyfinAccounts accounts;
final bool isHost;
final BrowseStart? jellyfinStart;
final Future<String?> Function(PickedMedia media) onSwap;
final VoidCallback onClose;
final VoidCallback? onOpenIntegrations;
const SwapPanel({
super.key,
required this.controller,
required this.accounts,
required this.isHost,
required this.onSwap,
required this.onClose,
this.jellyfinStart,
this.onOpenIntegrations,
});
@override
State<SwapPanel> createState() => _SwapPanelState();
}
class _SwapPanelState extends State<SwapPanel> {
bool _busy = false;
String? _error;
@override
void initState() {
super.initState();
final c = widget.controller;
if (!widget.isHost && c.source == MediaKind.file) c.source = MediaKind.link;
}
Future<void> _swap() async {
final media = widget.controller.selection;
if (media == null || _busy) return;
setState(() {
_busy = true;
_error = null;
});
String? error;
try {
error = await widget.onSwap(media);
} catch (e) {
error = '$e';
}
if (!mounted) return;
setState(() {
_busy = false;
_error = error;
});
if (error == null) widget.onClose();
}
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Material(
key: const ValueKey('swap-panel'),
elevation: 12,
borderRadius: BorderRadius.circular(16),
clipBehavior: Clip.antiAlias,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 12, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(child: Text('Change video', style: t.titleLarge)),
IconButton(
tooltip: 'Close (Esc)', icon: const Icon(Icons.close), onPressed: widget.onClose),
],
),
const SizedBox(height: 8),
Expanded(
child: Padding(
padding: const EdgeInsets.only(right: 8),
child: MediaPicker(
controller: widget.controller,
jellyfin: widget.accounts,
enabled: !_busy,
allowFile: widget.isHost,
jellyfinStart: widget.jellyfinStart,
onOpenIntegrations: widget.onOpenIntegrations,
onSubmit: _swap,
),
),
),
const SizedBox(height: 12),
ListenableBuilder(
listenable: widget.controller,
builder: (context, _) {
final sel = widget.controller.selection;
return Row(
children: [
Expanded(
child: Text(
_error ?? (sel == null ? 'Pick something to watch' : 'Put up: ${sel.label}'),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: t.bodyMedium?.copyWith(
color: _error != null ? Theme.of(context).colorScheme.error : null),
),
),
const SizedBox(width: 12),
FilledButton.icon(
key: const ValueKey('swap-go'),
onPressed: sel == null || _busy ? null : _swap,
icon: _busy
? const SizedBox(
width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.swap_horiz),
label: const Text('Put it up for everyone'),
),
],
);
},
),
],
),
),
);
}
}
+219 -36
View File
@@ -4,6 +4,8 @@ import 'package:media_kit_video/media_kit_video.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart';
import '../jellyfin/browse_scope.dart' show BrowseStart;
import '../jellyfin/session_media.dart';
import '../session/health.dart';
import '../session/session_coordinator.dart';
import '../shaders/shader_menu.dart';
@@ -17,6 +19,10 @@ import '../widgets/shown_frame_probe.dart';
import '../widgets/text_focus.dart';
import '../widgets/window_buttons.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 {
const SessionScreen({super.key});
@@ -38,7 +44,10 @@ class _SessionScreenState extends State<SessionScreen> {
DateTime? _lastKeyAt;
final _chat = TextEditingController();
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() _stopSheetWatch;
late final void Function() _stopPlayerWatch;
@@ -54,6 +63,7 @@ class _SessionScreenState extends State<SessionScreen> {
super.initState();
final session = SessionState.instance;
_noFocus = FocusNode(skipTraversal: true, canRequestFocus: false);
session.coordinator.jellyfin.ensureLoaded();
session.coordinator.playback.shownFrame = _frameProbe.sample;
HardwareKeyboard.instance.addHandler(_globalKey);
_stopKickWatch = effect(() {
@@ -144,14 +154,70 @@ class _SessionScreenState extends State<SessionScreen> {
_stopShaderWatch();
_chat.dispose();
_chatFocus.dispose();
_swapLink.dispose();
_swapPicker?.dispose();
_noFocus.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) {
if (event is! KeyDownEvent) 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;
final now = DateTime.now().toUtc();
if (_lastKeyAt != null && now.difference(_lastKeyAt!).inMilliseconds < 350) {
@@ -177,6 +243,20 @@ class _SessionScreenState extends State<SessionScreen> {
coord.toggleReady();
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) {
_lastKeyAt = now;
_sheetOpen.value = !_sheetOpen.peek();
@@ -254,12 +334,35 @@ class _SessionScreenState extends State<SessionScreen> {
),
],
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(),
_ReadyControlButton(focusNode: _noFocus),
_MarkAllReadyControlButton(focusNode: _noFocus),
_ReloadVideoControlButton(focusNode: _noFocus, onPressed: _reloadVideo),
const MaterialDesktopPositionIndicator(),
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(
focusNode: _noFocus,
renderMode: coord.playback.renderMode,
@@ -320,8 +423,10 @@ class _SessionScreenState extends State<SessionScreen> {
MaterialDesktopVideoControls(state),
_healthOverlay(),
_waitingOverlay(),
_jellyfinOverlay(),
_toastsOverlay(),
_sheetOverlay(),
_swapOverlay(),
_leaveOverlay(),
Positioned.fill(
child: SignalBuilder(
@@ -329,7 +434,7 @@ class _SessionScreenState extends State<SessionScreen> {
final sheetOpen = _sheetOpen.value;
final confirm = _confirmLeave.value;
return WindowDragLayer(
enabled: !confirm,
enabled: !confirm && !_swapOpen.value,
exclusions: [
(s) => Rect.fromLTWH(0, s.height - 120, s.width, 120),
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() {
final coord = SessionState.instance.coordinator;
return Positioned(
@@ -526,7 +719,7 @@ class _SessionScreenState extends State<SessionScreen> {
child: _SheetPanel(
chat: _chat,
chatFocus: _chatFocus,
swapLink: _swapLink,
onChangeVideo: _openSwap,
onClose: () => _sheetOpen.value = false,
onLeave: () {
_sheetOpen.value = false;
@@ -697,10 +890,10 @@ class _ToastCardState extends State<_ToastCard> {
class _SheetPanel extends StatelessWidget {
final TextEditingController chat;
final FocusNode chatFocus;
final TextEditingController swapLink;
final VoidCallback onChangeVideo;
final VoidCallback onClose;
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
Widget build(BuildContext context) {
@@ -750,7 +943,12 @@ class _SheetPanel extends StatelessWidget {
const Divider(height: 20),
Text('Users (${members.length})', style: sectionStyle),
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),
Text('Activity', style: sectionStyle),
const SizedBox(height: 6),
@@ -790,34 +988,14 @@ class _SheetPanel extends StatelessWidget {
],
),
const SizedBox(height: 12),
Text('Share a link', style: sectionStyle),
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: TextField(
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();
},
);
},
),
],
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
key: const ValueKey('change-video'),
icon: const Icon(Icons.swap_horiz),
label: const Text('Change video'),
onPressed: onChangeVideo,
),
),
const Divider(height: 20),
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 {
final MemberView member;
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
Widget build(BuildContext context) {
@@ -920,6 +1102,7 @@ class _MemberRow extends StatelessWidget {
status.$1,
style: theme.textTheme.bodySmall?.copyWith(color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.75)),
),
if (viaHost) ...[const SizedBox(width: 6), const ViaHostBadge()],
],
),
],