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
@@ -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()],
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user