Player overlay as one piece, Up next always warm and on every source, Jellyfin never unmarks a watched episode

- Overlay: one layout for both designs (top bar with Up next as one of
  its buttons; one action band above the controls for the status pill,
  skips, next episode and end cards), one glass recipe (AppGlass) and
  one pill (FloatingPill); hovering any of them, or the open queue,
  holds the controls up.
- Up next: the first item (or the first continuation episode) is kept
  resolved on the app that puts it up, refreshed before it expires;
  Schedule and drop/paste can queue too.
- Jellyfin: WatchSession (core) counts real watched coverage; an
  episode is watched at 80 % or at its ending after half; a played one
  is re-marked after Playing (10.11 unmarks on open) and never gets a
  position; a peek leaves the old resume point; part-way resume points
  stay under 85 %.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-11 07:53:42 +03:00
1 parent 6b10cae4e6
commit 771b961b32
27 files changed
+1396 -558

No files matched your search

+19 -1
View File
@@ -460,7 +460,8 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
_handoffOpen = true;
final HandoffAction? action;
try {
action = await showPopover<HandoffAction>(context, at: align, child: HandoffCard(item: item, solo: _alone.peek()));
action = await showPopover<HandoffAction>(context,
at: align, child: HandoffCard(item: item, solo: _alone.peek(), queue: _online.peek()));
} finally {
_handoffOpen = false;
}
@@ -474,6 +475,22 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
await FolderStore.instance.ensureLoaded();
FolderStore.instance.addRecent(paths.first);
}
case (HandoffVideos(:final paths), HandoffAction.queue || HandoffAction.playNext):
// In order: with Play next, the last one added goes first.
final next = action == HandoffAction.playNext;
for (final p in next ? paths.reversed : paths) {
final error = await SessionState.instance.coordinator.queue.add(FileMedia(p), next: next);
if (error != null) {
SessionState.instance.coordinator.showToast(error);
break;
}
}
case (HandoffLink(:final url), HandoffAction.queue || HandoffAction.playNext):
// In the Link page's mode, probed and refused like there.
final coord = SessionState.instance.coordinator;
final error = await coord.queue
.add(LinkMedia(url, relay: _picker.linkRelay), next: action == HandoffAction.playNext);
if (error != null) coord.showToast(error);
case (HandoffVideos(:final paths), HandoffAction.addFolder):
await _addFolder(File(paths.first).parent.path);
case (HandoffFolder(:final path), _):
@@ -680,6 +697,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
ShellTab.schedule => SchedulePage(
solo: alone,
onPutUp: _putUp,
onAddToQueue: online ? coord.queue.add : null,
onToast: coord.showToast,
),
ShellTab.settings => SettingsTab(
+16 -1
View File
@@ -105,6 +105,10 @@ enum HandoffAction {
addFolder,
/// Open Library → Link with the URL filled.
openLink,
/// Add the videos (or the link, in the Link page's mode) to Up next.
queue,
/// The same, right after what plays.
playNext,
}
/// The card asking how to open a [Handoff] (videos, a folder or a link);
@@ -113,7 +117,9 @@ class HandoffCard extends StatelessWidget {
final Handoff item;
/// Nobody else watches (offline or solo): videos play, no relay talk.
final bool solo;
const HandoffCard({super.key, required this.item, required this.solo});
/// In a server: videos and links can go to Up next.
final bool queue;
const HandoffCard({super.key, required this.item, required this.solo, this.queue = false});
@override
Widget build(BuildContext context) {
@@ -124,6 +130,13 @@ class HandoffCard extends StatelessWidget {
key: ValueKey('handoff-${a.name}'), onPressed: () => pick(a), icon: Icon(icon, size: 18), label: Text(label))
: OutlinedButton.icon(
key: ValueKey('handoff-${a.name}'), onPressed: () => pick(a), icon: Icon(icon, size: 18), label: Text(label));
final many = switch (item) { HandoffVideos(:final paths) => paths.length > 1, _ => false };
final queueButtons = [
if (queue) ...[
button(HandoffAction.queue, Icons.playlist_add_rounded, many ? 'Add all to queue' : 'Add to queue'),
button(HandoffAction.playNext, Icons.queue_play_next_rounded, many ? 'Play all next' : 'Play next'),
],
];
final (IconData icon, String title, String detail, List<Widget> buttons) = switch (item) {
HandoffVideos(:final paths) => (
@@ -132,6 +145,7 @@ class HandoffCard extends StatelessWidget {
paths.length > 1 ? 'and ${paths.length - 1} more · the first one goes up' : 'A video on this computer',
[
button(HandoffAction.putUp, Icons.play_arrow, solo ? 'Play' : 'Put it up', primary: true),
...queueButtons,
button(HandoffAction.addFolder, Icons.create_new_folder_outlined, 'Add its folder to Files'),
],
),
@@ -148,6 +162,7 @@ class HandoffCard extends StatelessWidget {
[
button(HandoffAction.openLink, Icons.link, solo ? 'Open in Link' : 'Put it up as a link…',
primary: true),
...queueButtons,
],
),
HandoffImage() || HandoffNothing() => (Icons.help_outline, '', '', const <Widget>[]),
+7 -22
View File
@@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:ui' show ImageFilter;
import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart';
@@ -7,6 +6,7 @@ import 'package:sync_relay_core/core.dart';
import '../session/health.dart';
import '../session/session_coordinator.dart';
import '../state/session_state.dart';
import '../widgets/app_menu.dart' show AppGlass;
import 'avatar.dart';
import 'motion.dart';
@@ -241,32 +241,17 @@ class _PlayerStatusState extends State<PlayerStatus> {
}
}
/// Frosted dark glass in a pill: the status pill, the unread chip.
/// The player's pill surface (the status pill, the action pills, the
/// unread chip): the app's glass as a stadium ([AppGlass.pill]).
class PlayerGlass extends StatelessWidget {
final Widget child;
const PlayerGlass({super.key, required this.child});
@override
Widget build(BuildContext context) {
final r = BorderRadius.circular(999);
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: ClipRRect(
borderRadius: r,
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12),
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.55),
borderRadius: r,
border: Border.all(color: Colors.white12, width: 0.5),
),
child: child,
),
),
),
);
}
Widget build(BuildContext context) => ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: AppGlass.pill(child: child),
);
}
class _StatusLine extends StatelessWidget {
+164 -120
View File
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart' show ValueListenable;
import 'package:flutter/gestures.dart' show kPrimaryMouseButton;
@@ -105,6 +106,10 @@ class _SessionPlayerState extends State<SessionPlayer> {
// does (shown on mount and on mouse movement, hidden 3 s later or on
// exit), the uosc ones reporting it.
final _controlsShown = ValueNotifier(true);
// The mouse on a floating element, or the Up next popover open, keeps
// the controls up.
final _hold = ControlsHold();
late final void Function() _stopPopoverWatch;
SessionCoordinator get _coord => SessionState.instance.coordinator;
bool get _full => widget.mode == PlayerMode.full;
@@ -124,6 +129,9 @@ class _SessionPlayerState extends State<SessionPlayer> {
_shaderNoticeSeen = n.id;
_toast(n.text);
});
_stopPopoverWatch = effect(() {
_hold.set(#upNextPopover, _coord.queue.popoverOpen.value);
});
}
@override
@@ -131,8 +139,10 @@ class _SessionPlayerState extends State<SessionPlayer> {
HardwareKeyboard.instance.removeHandler(_globalKey);
AppKeys.removeChatToggle(_toggleChat);
_stopShaderWatch();
_stopPopoverWatch();
_noFocus.dispose();
_controlsShown.dispose();
_hold.dispose();
super.dispose();
}
@@ -291,10 +301,9 @@ class _SessionPlayerState extends State<SessionPlayer> {
children: [
video,
if (_full) ...[
_status(),
_progressLine(),
_jellyfinOverlay(),
_upNextChip(),
_blockedCard(),
_actionArea(),
if (widget.fullscreen) ...[if (!SessionState.instance.solo.value) _pausedParty(), _unreadChip()],
] else
_miniOverlay(),
@@ -390,6 +399,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
child: Row(
children: [
const Expanded(child: NowPlayingTitle()),
UpNextButton(queue: coord.queue, onPlayNextEpisode: coord.playNextEpisode, look: UpNextLook.standard),
MaterialDesktopCustomButton(
icon: const Icon(Icons.video_library_outlined),
onPressed: widget.onChangeVideo,
@@ -522,6 +532,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
material: (_) => ControlsHoverProbe(
shown: _controlsShown,
hideAfter: _controlsHide,
hold: _hold,
child: DoubleClickListener(
onDoubleClick: widget.onToggleFullscreen,
// Clicks on the bars are button clicks, not the video.
@@ -553,11 +564,14 @@ class _SessionPlayerState extends State<SessionPlayer> {
});
return UoscControls(
shown: _controlsShown,
holdBy: _hold,
onDoubleTap: widget.onToggleFullscreen,
topLeft: const NowPlayingTitle(),
// Up next is one of the top bar's controls, before chat.
top: Row(
mainAxisSize: MainAxisSize.min,
children: [
UpNextButton(queue: coord.queue, onPlayNextEpisode: coord.playNextEpisode, look: UpNextLook.uosc),
UoscButton(
key: const ValueKey('uosc-chat'),
icon: widget.chatOpen ? Icons.chat_bubble_rounded : Icons.chat_bubble_outline_rounded,
@@ -670,114 +684,129 @@ class _SessionPlayerState extends State<SessionPlayer> {
);
}
/// The one status pill, bottom centre above the controls.
Widget _status() =>
const Positioned(left: 16, right: 16, bottom: 124, child: Center(child: PlayerStatus()));
/// The side inset every floating element keeps (the bars' own), and
/// the gap between them and the controls.
static const _edge = 16.0;
/// Jellyfin: the blocked card, the end-of-episode card (countdown) and
/// the "Next episode" pill; for any media the skip pill (Skip OP / ED /
/// 1:30) above it. Outside the auto-hiding controls; the cards stay up,
/// the pills show only with the controls ([PillGate]).
Widget _jellyfinOverlay() {
final coord = _coord;
return Positioned.fill(
child: SignalBuilder(
builder: (context) {
final blocked = coord.jellyfinBlocked.value;
final next = coord.nextEpisode.value;
final countdown = coord.autoAdvance.value;
// Up next goes before the next episode.
final upNext = coord.queue.countdown.value;
final finished = coord.serverState.value?.transport == 'finished';
final pill = coord.nextPromptVisible.value;
final SkipOffer? skip = coord.skipOffer.value;
final showNext = next != null && pill;
return Stack(
children: [
if (blocked != null)
Center(
child: JellyfinBlockedCard(
blocked: blocked,
onLinkAccount: widget.onOpenIntegrations,
onRetry: coord.retryJellyfin,
),
),
if (upNext != null && finished)
Positioned(
right: 24,
bottom: 138,
child: UpNextCard(queue: coord.queue, countdown: upNext),
)
// "Clear" in Up next hides the next episode for this media.
else if (next != null &&
(finished || countdown != null) &&
coord.queue.shownContinuation.value.isNotEmpty)
Positioned(
right: 24,
bottom: 138,
child: EndOfEpisodeCard(
next: next,
countdown: countdown,
onPlayNow: coord.playNextEpisode,
onCancel: coord.cancelAutoAdvance,
),
)
else if (showNext || skip != null)
Positioned(
right: 24,
bottom: 124,
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
spacing: 10,
children: [
if (skip != null)
PillGate(
key: const ValueKey('skip-gate'),
kind: skip.kind,
// Skip ED: no announcing, only with the controls.
announce: skip.kind != SkipKind.ending,
controlsShown: _controlsShown,
child: SkipPill(offer: skip, onPressed: () => coord.skip(skip)),
),
if (showNext)
PillGate(
key: const ValueKey('next-gate'),
kind: next,
controlsShown: _controlsShown,
child: NextEpisodePill(episode: next, onPressed: coord.playNextEpisode),
),
],
),
),
],
);
},
),
);
}
/// The bottom controls' height in the design in use.
static double _bottomControlsHeight(bool uosc) => uosc
? UoscControls.buttonRowHeight + UoscControls.rowBottomPadding + UoscTimeline.height
: _barHeight + ChapterSeekBar.height;
/// "Up next · <title>" top right, with the controls (and while its
/// popover is open); under the party in fullscreen.
Widget _upNextChip() {
final queue = _coord.queue;
return Positioned(
right: 16,
top: widget.fullscreen ? _barHeight + 60 : _topBarHeight + 8,
child: SignalBuilder(
builder: (context) => PillGate(
key: const ValueKey('up-next-gate'),
kind: 'up-next',
controlsShown: queue.popoverOpen.value ? const _AlwaysOn() : _controlsShown,
child: UpNextChip(queue: queue, onPlayNextEpisode: _coord.playNextEpisode),
/// The action area: one band above the bottom controls for everything
/// that floats there. The status pill (the ready prompt among it)
/// centred; at the right edge the end cards, else the action pills
/// (Skip OP / ED / 1:30 over Next episode). It rides on the controls:
/// just above them while they are up, down near the edge while they are
/// hidden. The mouse on any of it counts as the mouse on the controls
/// ([ControlsHold]).
Widget _actionArea() {
return SignalBuilder(builder: (context) {
final controls = _bottomControlsHeight(AppSettings.instance.mpvControls.value);
return ValueListenableBuilder<bool>(
valueListenable: _controlsShown,
builder: (context, shown, child) => AnimatedPositioned(
duration: easeTime,
curve: ease,
left: _edge,
right: _edge,
top: 0,
bottom: shown ? controls + _edge : _edge,
child: child!,
),
),
child: CustomMultiChildLayout(
delegate: _ActionAreaLayout(gap: _edge),
children: [
LayoutId(id: _ActionSlot.status, child: ControlsHoldRegion(hold: _hold, child: const PlayerStatus())),
LayoutId(id: _ActionSlot.side, child: _actionSide()),
],
),
);
});
}
/// The action area's right end: the end-of-media card (Up next, else the
/// next episode; it stays up), else the pills, shown only with the
/// controls ([PillGate]).
Widget _actionSide() {
final coord = _coord;
return SignalBuilder(
builder: (context) {
final next = coord.nextEpisode.value;
final countdown = coord.autoAdvance.value;
// Up next goes before the next episode.
final upNext = coord.queue.countdown.value;
final finished = coord.serverState.value?.transport == 'finished';
final pill = coord.nextPromptVisible.value;
final SkipOffer? skip = coord.skipOffer.value;
final showNext = next != null && pill;
if (upNext != null && finished) {
return ControlsHoldRegion(hold: _hold, child: UpNextCard(queue: coord.queue, countdown: upNext));
}
// "Clear" in Up next hides the next episode for this media.
if (next != null && (finished || countdown != null) && coord.queue.shownContinuation.value.isNotEmpty) {
return ControlsHoldRegion(
hold: _hold,
child: EndOfEpisodeCard(
next: next,
countdown: countdown,
onPlayNow: coord.playNextEpisode,
onCancel: coord.cancelAutoAdvance,
),
);
}
if (!showNext && skip == null) return const SizedBox.shrink();
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
spacing: 8,
children: [
if (skip != null)
PillGate(
key: const ValueKey('skip-gate'),
kind: skip.kind,
// Skip ED: no announcing, only with the controls.
announce: skip.kind != SkipKind.ending,
controlsShown: _controlsShown,
hold: _hold,
child: SkipPill(offer: skip, onPressed: () => coord.skip(skip)),
),
if (showNext)
PillGate(
key: const ValueKey('next-gate'),
kind: next,
controlsShown: _controlsShown,
hold: _hold,
child: NextEpisodePill(episode: next, onPressed: coord.playNextEpisode),
),
],
);
},
);
}
/// Jellyfin refused this viewer: the card in the middle (it stays up).
Widget _blockedCard() => Positioned.fill(
child: SignalBuilder(builder: (context) {
final blocked = _coord.jellyfinBlocked.value;
if (blocked == null) return const SizedBox.shrink();
return Center(
child: ControlsHoldRegion(
hold: _hold,
child: JellyfinBlockedCard(
blocked: blocked,
onLinkAccount: widget.onOpenIntegrations,
onRetry: _coord.retryJellyfin,
),
),
);
}),
);
/// Fullscreen hides the strip: while paused the party stays in a corner.
Widget _pausedParty() => Positioned(
right: 16,
top: _barHeight + 8,
right: _edge,
top: _topBarHeight + 8,
child: PartyWatch(builder: (context) {
final r = _coord.serverState.value;
final paused = r != null && r.transport != 'playing';
@@ -795,8 +824,8 @@ class _SessionPlayerState extends State<SessionPlayer> {
/// glass chip says so top right (while it plays, only the controls' chat
/// button shows the count: nothing sits on the picture).
Widget _unreadChip() => Positioned(
right: 16,
top: 16,
right: _edge,
top: _edge,
child: IgnorePointer(
child: ValueListenableBuilder<bool>(
valueListenable: _controlsShown,
@@ -945,32 +974,24 @@ class _SessionPlayerState extends State<SessionPlayer> {
}
}
/// Always up (a pill whose popover is open).
class _AlwaysOn implements ValueListenable<bool> {
const _AlwaysOn();
@override
bool get value => true;
@override
void addListener(VoidCallback listener) {}
@override
void removeListener(VoidCallback listener) {}
}
/// A floating pill's visibility: only while the controls are up (the
/// mouse moved) or the mouse is on it. With [announce] it is also up for
/// [firstShown] whenever [kind] changes (a new offer: Skip OP, Skip 1:30).
/// Gone with the offer (the parent drops it).
/// Gone with the offer (the parent drops it). The mouse on it holds
/// [hold]: it counts as the mouse on the controls.
class PillGate extends StatefulWidget {
static const firstShown = Duration(seconds: 5);
final Object kind;
final bool announce;
final ValueListenable<bool> controlsShown;
final ControlsHold? hold;
final Widget child;
const PillGate({
super.key,
required this.kind,
this.announce = false,
required this.controlsShown,
this.hold,
required this.child,
});
@@ -1014,8 +1035,8 @@ class _PillGateState extends State<PillGate> {
@override
Widget build(BuildContext context) {
// The pill covers the controls' hover area: on it, they may hide while
// it stays.
// The pill covers the controls' hover area: on it, [hold] keeps them
// up (and it stays while the mouse is on it).
return ValueListenableBuilder<bool>(
valueListenable: widget.controlsShown,
builder: (context, shown, child) {
@@ -1029,11 +1050,34 @@ class _PillGateState extends State<PillGate> {
),
);
},
child: widget.child,
child: ControlsHoldRegion(hold: widget.hold, child: widget.child),
);
}
}
enum _ActionSlot { status, side }
/// The action area's two slots, at its bottom: [_ActionSlot.side] at the
/// right end, [_ActionSlot.status] centred, moved left only as far as it
/// must to keep [gap] from the side (and never wider than what is left).
class _ActionAreaLayout extends MultiChildLayoutDelegate {
final double gap;
_ActionAreaLayout({required this.gap});
@override
void performLayout(Size size) {
final side = layoutChild(_ActionSlot.side, BoxConstraints.loose(size));
positionChild(_ActionSlot.side, Offset(size.width - side.width, size.height - side.height));
final room = side.width > 0 ? size.width - side.width - gap : size.width;
final status = layoutChild(_ActionSlot.status, BoxConstraints.loose(Size(math.max(0, room), size.height)));
final x = math.min((size.width - status.width) / 2, room - status.width);
positionChild(_ActionSlot.status, Offset(math.max(0, x), size.height - status.height));
}
@override
bool shouldRelayout(_ActionAreaLayout old) => old.gap != gap;
}
const _shaderKeys = [
LogicalKeyboardKey.digit0,
LogicalKeyboardKey.digit1,