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
+1285 -447

No files matched your search

+18 -12
View File
@@ -84,7 +84,7 @@ bar while its controls are up), Esc leaves it. The controls show the
show's cover, title and episode top left (a web video: its thumbnail and show's cover, title and episode top left (a web video: its thumbnail and
name; guests get the art too); before name; guests get the art too); before
a video first plays, its banner fills the picture until playback starts. a video first plays, its banner fills the picture until playback starts.
One pill above the controls says what playback waits for ("Press Space One pill bottom centre, just above the controls, says what playback waits for ("Press Space
when you're ready", "Waiting for Bob and Mika · 2/3 ready", "Bob is when you're ready", "Waiting for Bob and Mika · 2/3 ready", "Bob is
loading…") or what went wrong, with Retry or Cancel when it helps. Chat and activity open in a panel on the right (chat button or loading…") or what went wrong, with Retry or Cancel when it helps. Chat and activity open in a panel on the right (chat button or
Tab), ready to type; it shows messages, and its "Activity" toggle adds Tab), ready to type; it shows messages, and its "Activity" toggle adds
@@ -127,8 +127,8 @@ disk. Nothing is uploaded or stored anywhere.
What to watch, from three sources (the switch at the top). Drop a video, What to watch, from three sources (the switch at the top). Drop a video,
a folder or a link anywhere on the window, or press Ctrl+V outside a a folder or a link anywhere on the window, or press Ctrl+V outside a
text field, and a small card asks how to open it (put it up, add the text field, and a small card asks how to open it (put it up, add it to
folder to Files, or fill Link). Up next, add the folder to Files, or fill Link).
- **Jellyfin**: your linked servers (see below); with none linked it - **Jellyfin**: your linked servers (see below); with none linked it
points to Files and Link and offers Jellyfin as optional. Library chips beside the points to Files and Link and offers Jellyfin as optional. Library chips beside the
@@ -162,14 +162,16 @@ and it plays for you alone ([Watch solo](#getting-started)).
In a server (solo too), anything can wait its turn instead of going up In a server (solo too), anything can wait its turn instead of going up
now: **Add to queue** on the Link page (in the way you picked), or now: **Add to queue** on the Link page (in the way you picked), or
right-click a Jellyfin card or episode, a video in Files or a recent link right-click a Jellyfin card or episode, a video in Files, a recent link
for **Add to queue** / **Play next**; the Jellyfin details panel has a or an episode on the Schedule for **Add to queue** / **Play next**; the
queue button (right-click it to play next). A link is checked first, like Jellyfin details panel has a queue button (right-click it to play next),
Put it up. Everyone shares one Up next and anyone can change it; what and the card for a dropped or pasted video or link has both. A link is
checked first, like Put it up. The next item is kept ready on the app
that will put it up, so skipping to it starts at once. Everyone shares one Up next and anyone can change it; what
you added stays when you leave. you added stays when you leave.
In the player, **Up next · title** sits top right with the controls: ▶ In the player, **Up next · title** sits in the top bar next to the chat
plays it now, the title opens the queue: drag rows to reorder, hover a button: ⏭ plays it now, the title opens the queue: drag rows to reorder, hover a
row to play it now (▶) or remove it (×), paste a link in the field or row to play it now (▶) or remove it (×), paste a link in the field or
drop a link or a video on it to add. When a video ends, a card shows drop a link or a video on it to add. When a video ends, a card shows
what is up next with a 10 s countdown (Cancel stops it on your screen) what is up next with a 10 s countdown (Cancel stops it on your screen)
@@ -269,10 +271,11 @@ chapter title and the time there. With the controls hidden it stays as a
thin line (unless "Progress line when hidden" is off). Above it flat buttons: ≡ menu, Subtitles and Audio (with the thin line (unless "Progress line when hidden" is off). Above it flat buttons: ≡ menu, Subtitles and Audio (with the
track count), the shader badge (the active Anime4K mode, e.g. "A HQ"), track count), the shader badge (the active Anime4K mode, e.g. "A HQ"),
ready, chapters, 1:30, previous / next episode, change video, volume, ⚙ ready, chapters, 1:30, previous / next episode, change video, volume, ⚙
and fullscreen; the title top left, chat and back-to-library top right. Keys and skip buttons work and fullscreen; the title top left, Up next, chat and back-to-library top right. Keys and skip buttons work
the same. the same.
Skipping: during the opening a "Skip OP" button floats in the corner (it Skipping: during the opening a "Skip OP" button shows bottom right, just
above the controls (it
jumps to the end of the opening), during the ending "Skip ED" (to the end jumps to the end of the opening), during the ending "Skip ED" (to the end
of the ending only; what comes after is yours to skip). "Skip OP" and of the ending only; what comes after is yours to skip). "Skip OP" and
"Skip 1:30" show for 5 seconds when they come up, then only while the "Skip 1:30" show for 5 seconds when they come up, then only while the
@@ -324,7 +327,10 @@ Watching:
directly with their own sign-in. Jellyfin tracks progress per account: directly with their own sign-in. Jellyfin tracks progress per account:
your watched state, Continue watching and resume point follow what you your watched state, Continue watching and resume point follow what you
saw (reported on start, every 10 s, on pause, resume, seek and track saw (reported on start, every 10 s, on pause, resume, seek and track
change, and when it stops). change, and when it stops). Watched means you actually played most of
it (or reached the ending after half), not where you stopped: opening
a watched episode to check a moment keeps it watched, and a quick peek
leaves no resume point.
- Friends without an account watch through the server: whoever put the - Friends without an account watch through the server: whoever put the
item up passes it on automatically, with their own sign-in, which never item up passes it on automatically, with their own sign-in, which never
leaves their computer (only the current item, only to people in the leaves their computer (only the current item, only to people in the
@@ -2,16 +2,92 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// What keeps the player's controls up besides the mouse on them: the
/// mouse on a floating element over the picture (a pill, a card, the
/// status pill: [ControlsHoldRegion]) or an open popover. While anything
/// holds, the controls stay up; on release they hide on their usual
/// countdown. Both designs follow it ([ControlsHoverProbe] with
/// media_kit's controls, `UoscControls`).
class ControlsHold extends ChangeNotifier {
final _by = <Object>{};
bool _told = false;
bool _queued = false;
bool _disposed = false;
bool get held => _told;
/// [who] holds ([on]) or lets go. Listeners hear it in a microtask, never
/// during a build or a dispose.
void set(Object who, bool on) {
if (on ? !_by.add(who) : !_by.remove(who)) return;
if (_queued) return;
_queued = true;
scheduleMicrotask(() {
_queued = false;
if (_disposed) return;
final now = _by.isNotEmpty;
if (now == _told) return;
_told = now;
notifyListeners();
});
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
/// The mouse on [child] counts as the mouse on the controls: it holds
/// [hold] while it is there.
class ControlsHoldRegion extends StatefulWidget {
final ControlsHold? hold;
final Widget child;
const ControlsHoldRegion({super.key, required this.hold, required this.child});
@override
State<ControlsHoldRegion> createState() => _ControlsHoldRegionState();
}
class _ControlsHoldRegionState extends State<ControlsHoldRegion> {
@override
void didUpdateWidget(ControlsHoldRegion oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.hold != widget.hold) oldWidget.hold?.set(this, false);
}
@override
void dispose() {
widget.hold?.set(this, false);
super.dispose();
}
@override
Widget build(BuildContext context) {
final hold = widget.hold;
if (hold == null) return widget.child;
return MouseRegion(
onEnter: (_) => hold.set(this, true),
onExit: (_) => hold.set(this, false),
child: widget.child,
);
}
}
/// Follows whether media_kit's desktop controls around [child] are up, /// Follows whether media_kit's desktop controls around [child] are up,
/// by the same rule they use: up on mount and on any mouse movement, /// by the same rule they use: up on mount and on any mouse movement,
/// down [hideAfter] later or when the mouse leaves. media_kit does not /// down [hideAfter] later or when the mouse leaves. media_kit does not
/// expose its visibility; this lets the player draw a progress line while /// expose its visibility; this lets the player draw a progress line while
/// they are down. Never takes input. /// they are down. With [hold] held it keeps them (and itself) up. Never
/// takes input.
class ControlsHoverProbe extends StatefulWidget { class ControlsHoverProbe extends StatefulWidget {
final ValueNotifier<bool> shown; final ValueNotifier<bool> shown;
final Duration hideAfter; final Duration hideAfter;
final ControlsHold? hold;
final Widget child; final Widget child;
const ControlsHoverProbe({super.key, required this.shown, required this.hideAfter, required this.child}); const ControlsHoverProbe(
{super.key, required this.shown, required this.hideAfter, this.hold, required this.child});
@override @override
State<ControlsHoverProbe> createState() => _ControlsHoverProbeState(); State<ControlsHoverProbe> createState() => _ControlsHoverProbeState();
@@ -19,22 +95,67 @@ class ControlsHoverProbe extends StatefulWidget {
class _ControlsHoverProbeState extends State<ControlsHoverProbe> { class _ControlsHoverProbeState extends State<ControlsHoverProbe> {
Timer? _timer; Timer? _timer;
// While held: media_kit's own countdown is restarted before it ends.
Timer? _keep;
bool get _held => widget.hold?.held ?? false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
widget.hold?.addListener(_holdChanged);
// Not during this build: the line listening to [shown] may be built. // Not during this build: the line listening to [shown] may be built.
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _wake(); if (mounted) _wake();
}); });
} }
@override
void didUpdateWidget(ControlsHoverProbe oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.hold != widget.hold) {
oldWidget.hold?.removeListener(_holdChanged);
widget.hold?.addListener(_holdChanged);
_holdChanged();
}
}
@override @override
void dispose() { void dispose() {
widget.hold?.removeListener(_holdChanged);
_timer?.cancel(); _timer?.cancel();
_keep?.cancel();
super.dispose(); super.dispose();
} }
void _holdChanged() {
if (!mounted) return;
_keep?.cancel();
_keep = null;
if (_held) {
_keep = Timer.periodic(widget.hideAfter * 0.5, (_) => _pokeControls());
}
_pokeControls();
_wake();
}
/// media_kit's controls state is private: a guarded dynamic call to its
/// hover handler, as a mouse movement on it would make.
void _pokeControls() {
if (!mounted) return;
void visit(Element e) {
if (e is StatefulElement && e.state.runtimeType.toString() == '_MaterialDesktopVideoControlsState') {
try {
(e.state as dynamic).onHover();
} catch (_) {}
return;
}
e.visitChildElements(visit);
}
context.visitChildElements(visit);
}
void _wake() { void _wake() {
widget.shown.value = true; widget.shown.value = true;
_timer?.cancel(); _timer?.cancel();
@@ -43,7 +164,8 @@ class _ControlsHoverProbeState extends State<ControlsHoverProbe> {
void _hide() { void _hide() {
_timer?.cancel(); _timer?.cancel();
if (mounted) widget.shown.value = false; if (!mounted || _held) return;
widget.shown.value = false;
} }
@override @override
@@ -52,7 +174,9 @@ class _ControlsHoverProbeState extends State<ControlsHoverProbe> {
hitTestBehavior: HitTestBehavior.translucent, hitTestBehavior: HitTestBehavior.translucent,
onEnter: (_) => _wake(), onEnter: (_) => _wake(),
onHover: (_) => _wake(), onHover: (_) => _wake(),
onExit: (_) => _hide(), // Held (the mouse went onto a pill or the popover): media_kit's
// controls just hid on their own exit; bring them straight back.
onExit: (_) => _held ? _pokeControls() : _hide(),
child: widget.child, child: widget.child,
); );
} }
@@ -312,19 +312,18 @@ class PlayerSettingsCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Material( // The app's glass, like the Up next popover and the menus.
return AppGlass(
key: const ValueKey('player-settings-card'), key: const ValueKey('player-settings-card'),
elevation: 12,
borderRadius: BorderRadius.circular(16),
child: SizedBox( child: SizedBox(
width: 420, width: 420,
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(18, 14, 18, 10), padding: const EdgeInsets.fromLTRB(16, 12, 16, 10),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
const Text('Player', style: TextStyle(fontWeight: FontWeight.w700, fontSize: 16)), const Text('Player', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
const SizedBox(height: 4), const SizedBox(height: 4),
...playerSettingRows(renderMode: renderMode), ...playerSettingRows(renderMode: renderMode),
if (tracks) ...[ if (tracks) ...[
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import '../../state/settings.dart'; import '../../state/settings.dart';
import '../controls_probe.dart' show ControlsHold;
// The mpv-style (uosc) controls frame: a full-width timeline at the // The mpv-style (uosc) controls frame: a full-width timeline at the
// bottom with a row of flat buttons above it, the title top left and a // bottom with a row of flat buttons above it, the title top left and a
@@ -28,6 +29,8 @@ class UoscControls extends StatefulWidget {
final Duration hideAfter; final Duration hideAfter;
/// Follows whether the controls are up (for overlays outside them). /// Follows whether the controls are up (for overlays outside them).
final ValueNotifier<bool>? shown; final ValueNotifier<bool>? shown;
/// Keeps them up while held (the mouse on an overlay, an open popover).
final ControlsHold? holdBy;
const UoscControls({ const UoscControls({
super.key, super.key,
required this.timeline, required this.timeline,
@@ -38,9 +41,12 @@ class UoscControls extends StatefulWidget {
this.onDoubleTap, this.onDoubleTap,
this.hideAfter = const Duration(seconds: 3), this.hideAfter = const Duration(seconds: 3),
this.shown, this.shown,
this.holdBy,
}); });
static const buttonRowHeight = 40.0; static const buttonRowHeight = 40.0;
/// Under the button row, above the timeline.
static const rowBottomPadding = 4.0;
/// The controls around [context], for widgets that need them kept up /// The controls around [context], for widgets that need them kept up
/// (a timeline drag, an open menu). /// (a timeline drag, an open menu).
@@ -54,6 +60,7 @@ class UoscControls extends StatefulWidget {
class UoscControlsState extends State<UoscControls> { class UoscControlsState extends State<UoscControls> {
bool _visible = true; bool _visible = true;
int _holds = 0; int _holds = 0;
bool _heldBy = false;
Timer? _timer; Timer? _timer;
bool get visible => _visible; bool get visible => _visible;
@@ -61,6 +68,7 @@ class UoscControlsState extends State<UoscControls> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
widget.holdBy?.addListener(_holdByChanged);
// Not during this build: an overlay listening to [shown] may be built. // Not during this build: an overlay listening to [shown] may be built.
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) widget.shown?.value = true; if (mounted) widget.shown?.value = true;
@@ -68,12 +76,30 @@ class UoscControlsState extends State<UoscControls> {
_hideLater(); _hideLater();
} }
@override
void didUpdateWidget(UoscControls oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.holdBy != widget.holdBy) {
oldWidget.holdBy?.removeListener(_holdByChanged);
widget.holdBy?.addListener(_holdByChanged);
_holdByChanged();
}
}
@override @override
void dispose() { void dispose() {
widget.holdBy?.removeListener(_holdByChanged);
_timer?.cancel(); _timer?.cancel();
super.dispose(); super.dispose();
} }
void _holdByChanged() {
final on = widget.holdBy?.held ?? false;
if (on == _heldBy || !mounted) return;
_heldBy = on;
hold(on);
}
/// Show the controls and restart the hide countdown. /// Show the controls and restart the hide countdown.
void wake() { void wake() {
if (!_visible && mounted) { if (!_visible && mounted) {
@@ -185,8 +211,9 @@ class UoscControlsState extends State<UoscControls> {
child: AnimatedOpacity( child: AnimatedOpacity(
duration: fade, duration: fade,
opacity: visible ? 1 : 0, opacity: visible ? 1 : 0,
// The buttons centred on the title, like the standard
// design's top bar.
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded( Expanded(
child: Padding( child: Padding(
@@ -196,7 +223,8 @@ class UoscControlsState extends State<UoscControls> {
: Align(alignment: AlignmentDirectional.centerStart, child: widget.topLeft), : Align(alignment: AlignmentDirectional.centerStart, child: widget.topLeft),
), ),
), ),
?widget.top, if (widget.top != null)
Padding(padding: const EdgeInsets.fromLTRB(0, 12, 6, 0), child: widget.top),
], ],
), ),
), ),
@@ -217,7 +245,7 @@ class UoscControlsState extends State<UoscControls> {
duration: fade, duration: fade,
opacity: visible ? 1 : 0, opacity: visible ? 1 : 0,
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(6, 0, 6, 4), padding: const EdgeInsets.fromLTRB(6, 0, 6, UoscControls.rowBottomPadding),
child: SizedBox( child: SizedBox(
height: UoscControls.buttonRowHeight, height: UoscControls.buttonRowHeight,
child: Row(children: [...widget.left, const Spacer(), ...widget.right]), child: Row(children: [...widget.left, const Spacer(), ...widget.right]),
+77 -7
View File
@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
@@ -24,6 +25,14 @@ class PlayerSnapshot {
/// go out one after another; a failure is logged and handed to [onError] /// go out one after another; a failure is logged and handed to [onError]
/// (the session shows one quiet toast) and never touches playback. A 401 /// (the session shows one quiet toast) and never touches playback. A 401
/// calls [onUnauthorized] and ends reporting. /// calls [onUnauthorized] and ends reporting.
///
/// Watched or not is [WatchSession]'s rule, not Jellyfin's position
/// percentages (see ARCHITECTURE.md "Jellyfin in a session"): an item
/// played before ([playedBefore]) stays played (re-marked after Playing,
/// Progress without a position, Stopped without one, then marked played
/// again); otherwise Progress leaves the position out past
/// [_noPositionFrom] of the runtime so Jellyfin cannot mark it played on
/// its own, and Stopped carries what the rule says.
class JellyfinProgressReporter { class JellyfinProgressReporter {
final JellyfinClient client; final JellyfinClient client;
final String itemId; final String itemId;
@@ -34,20 +43,36 @@ class JellyfinProgressReporter {
final void Function(Object error)? onError; final void Function(Object error)? onError;
final VoidCallback? onUnauthorized; final VoidCallback? onUnauthorized;
/// What this viewer watched of the item while it was open here.
final WatchSession watch;
JellyfinProgressReporter({ JellyfinProgressReporter({
required this.client, required this.client,
required this.itemId, required this.itemId,
required this.source, required this.source,
required this.playSessionId, required this.playSessionId,
bool playedBefore = false,
int resumeTicksBefore = 0,
this.interval = const Duration(seconds: 10), this.interval = const Duration(seconds: 10),
this.onError, this.onError,
this.onUnauthorized, this.onUnauthorized,
DateTime Function()? clock, DateTime Function()? clock,
}) : _clock = clock ?? DateTime.now; }) : _clock = clock ?? DateTime.now,
_keepPlayed = playedBefore,
_runtime = (source.runTimeTicks ?? 0) / jellyfinTicksPerSecond,
watch = WatchSession(
playedBefore: playedBefore, resumeBefore: resumeTicksBefore / jellyfinTicksPerSecond);
/// Past this share of the runtime Progress carries no position: Jellyfin
/// marks an item played past its "max resume" share (90 % by default).
static const _noPositionFrom = 0.85;
Future<void> _chain = Future.value(); Future<void> _chain = Future.value();
bool _playing = false; bool _playing = false;
bool _dead = false; bool _dead = false;
bool _keepPlayed;
double _runtime;
double? _endingStart;
PlayerSnapshot? _last; PlayerSnapshot? _last;
DateTime? _lastAt; DateTime? _lastAt;
DateTime? _lastSentAt; DateTime? _lastSentAt;
@@ -55,13 +80,23 @@ class JellyfinProgressReporter {
/// Between [start] and [stop]. /// Between [start] and [stop].
bool get active => _playing && !_dead; bool get active => _playing && !_dead;
/// The item counts as played here (before, or by the rule at a stop).
bool get keepsPlayed => _keepPlayed;
/// The player's duration (used when Jellyfin gave no runtime) and where
/// the ending starts (Outro segment / ED chapter), seconds.
void timeline({double duration = 0, double? endingStart}) {
if ((source.runTimeTicks ?? 0) <= 0 && duration > 0) _runtime = duration;
_endingStart = endingStart;
}
/// The report body for [s]: ticks, pause, and mpv's aid/sid mapped to /// The report body for [s]: ticks, pause, and mpv's aid/sid mapped to
/// Jellyfin stream indices (-1 = subtitles off). /// Jellyfin stream indices (-1 = subtitles off).
JellyfinPlaybackReport reportFor(PlayerSnapshot s) => JellyfinPlaybackReport( JellyfinPlaybackReport reportFor(PlayerSnapshot s, {bool withPosition = true}) => JellyfinPlaybackReport(
itemId: itemId, itemId: itemId,
mediaSourceId: source.id, mediaSourceId: source.id,
playSessionId: playSessionId, playSessionId: playSessionId,
positionTicks: (s.position * jellyfinTicksPerSecond).round(), positionTicks: withPosition ? _ticks(s.position) : null,
isPaused: s.paused, isPaused: s.paused,
audioStreamIndex: s.aid == null audioStreamIndex: s.aid == null
? null ? null
@@ -71,13 +106,24 @@ class JellyfinProgressReporter {
: source.jellyfinIndexForMpvTrack(JellyfinStreamType.subtitle, s.sid!), : source.jellyfinIndexForMpvTrack(JellyfinStreamType.subtitle, s.sid!),
); );
static int _ticks(double secs) => (secs * jellyfinTicksPerSecond).round();
/// Progress may carry the position: not for a played item, not without
/// a runtime and not near the end (either would let Jellyfin decide).
bool _progressPosition(double position) =>
!_keepPlayed && _runtime > 0 && position < _noPositionFrom * _runtime;
/// Playing: once the media is open. Again after [stop] (played to the /// Playing: once the media is open. Again after [stop] (played to the
/// end, then someone seeked back) starts reporting anew. /// end, then someone seeked back) starts reporting anew. A played item
/// is marked played again right after (Jellyfin up to 10.11 clears it
/// on Playing).
void start(PlayerSnapshot s) { void start(PlayerSnapshot s) {
if (_playing || _dead) return; if (_playing || _dead) return;
_playing = true; _playing = true;
_mark(s); _mark(s);
watch.sample(s.position, playing: !s.paused, at: _clock());
_send('Playing', () => client.reportPlaybackStart(reportFor(s))); _send('Playing', () => client.reportPlaybackStart(reportFor(s)));
if (_keepPlayed) _send('played', () => client.markPlayed(itemId));
} }
/// Progress when due (see the class doc). /// Progress when due (see the class doc).
@@ -85,6 +131,7 @@ class JellyfinProgressReporter {
if (!_playing || _dead) return; if (!_playing || _dead) return;
final last = _last, lastAt = _lastAt, sentAt = _lastSentAt; final last = _last, lastAt = _lastAt, sentAt = _lastSentAt;
final now = _clock(); final now = _clock();
watch.sample(s.position, playing: !s.paused, at: now);
var due = force || last == null || lastAt == null || sentAt == null; var due = force || last == null || lastAt == null || sentAt == null;
if (!due) { if (!due) {
final elapsed = now.difference(lastAt).inMilliseconds / 1000; final elapsed = now.difference(lastAt).inMilliseconds / 1000;
@@ -99,15 +146,38 @@ class JellyfinProgressReporter {
_lastAt = now; _lastAt = now;
if (!due) return; if (!due) return;
_lastSentAt = now; _lastSentAt = now;
_send('Progress', () => client.reportPlaybackProgress(reportFor(s))); final report = reportFor(s, withPosition: _progressPosition(s.position));
_send('Progress', () => client.reportPlaybackProgress(report));
} }
/// Stopped (swap, leave, kick, window close, played to the end). /// Stopped (swap, leave, kick, window close, played to the end), as
/// [WatchSession.verdict] says: played (or kept played) -> no position,
/// then marked played; a peek -> the resume point from before; part way
/// -> the position, held under [_noPositionFrom] of the runtime.
/// Completes when it and everything before it went out. /// Completes when it and everything before it went out.
Future<void> stop(PlayerSnapshot s) { Future<void> stop(PlayerSnapshot s) {
if (!_playing || _dead) return _chain; if (!_playing || _dead) return _chain;
_playing = false; _playing = false;
_send('Stopped', () => client.reportPlaybackStopped(reportFor(s))); watch.sample(s.position, playing: !s.paused, at: _clock());
final v = _keepPlayed
? const WatchVerdict(WatchOutcome.keepPlayed)
: watch.verdict(runtime: _runtime, endingStart: _endingStart);
debugPrint('jellyfin $itemId: $v (covered ${watch.covered.round()} s of ${_runtime.round()} s)');
final int? ticks;
switch (v.outcome) {
case WatchOutcome.played || WatchOutcome.keepPlayed:
_keepPlayed = true;
ticks = null;
case WatchOutcome.peek:
ticks = _ticks(watch.resumeBefore);
case WatchOutcome.partial:
final at = v.resumeAt ?? s.position;
ticks = _ticks(_runtime > 0 ? math.min(at, _noPositionFrom * _runtime) : at);
}
final report = JellyfinPlaybackReport(
itemId: itemId, mediaSourceId: source.id, playSessionId: playSessionId, positionTicks: ticks);
_send('Stopped', () => client.reportPlaybackStopped(report));
if (ticks == null) _send('played', () => client.markPlayed(itemId));
return _chain; return _chain;
} }
+30 -6
View File
@@ -46,20 +46,44 @@ class AppYtDlp {
} }
} }
final _probes = <String, (DateTime, Future<LinkProbe>)>{}; final _probes = <String, ({DateTime at, DateTime until, Future<LinkProbe> f})>{};
static const _probeTtl = Duration(minutes: 10); static const _probeTtl = Duration(minutes: 10);
Future<LinkProbe> probe(String url) { /// What [url] is, from memory while it is kept (10 minutes from when it
/// was probed; [keep] keeps it longer, for Up next's first item, which
/// is probed again before that runs out). [fresh] probes again: the
/// remembered result stays until the new one is in, and stays when the
/// new one fails.
Future<LinkProbe> probe(String url, {Duration keep = _probeTtl, bool fresh = false}) {
final now = DateTime.now(); final now = DateTime.now();
_probes.removeWhere((_, v) => now.difference(v.$1) > _probeTtl); _probes.removeWhere((_, v) => now.isAfter(v.until));
final hit = _probes[url]; final hit = _probes[url];
if (hit != null) return hit.$2; if (hit != null && !fresh) {
final longer = hit.at.add(keep);
if (longer.isAfter(hit.until)) _probes[url] = (at: hit.at, until: longer, f: hit.f);
return hit.f;
}
final f = ytDlp.then((y) => probeLink(Uri.parse(url), ytDlp: y)); final f = ytDlp.then((y) => probeLink(Uri.parse(url), ytDlp: y));
_probes[url] = (now, f); final until = now.add(keep);
if (hit == null) {
_probes[url] = (at: now, until: until, f: f);
f.catchError((Object _) { f.catchError((Object _) {
_probes.remove(url); if (_probes[url]?.f == f) _probes.remove(url);
return LinkProbe.file(Uri(), -1); return LinkProbe.file(Uri(), -1);
}); });
} else {
f.then<void>((_) {
_probes[url] = (at: now, until: until, f: f);
}, onError: (Object _) {});
}
return f; return f;
} }
/// [url] no longer needs keeping longer: back to the usual 10 minutes.
void release(String url) {
final hit = _probes[url];
if (hit == null) return;
final usual = hit.at.add(_probeTtl);
if (usual.isBefore(hit.until)) _probes[url] = (at: hit.at, until: usual, f: hit.f);
}
} }
@@ -22,7 +22,7 @@ import 'queue_logic.dart';
// Up next on this client (see ARCHITECTURE.md "Up next"): the server's // Up next on this client (see ARCHITECTURE.md "Up next"): the server's
// queue as signals, adding to it (probed, with a picture), the end-of-media // queue as signals, adding to it (probed, with a picture), the end-of-media
// countdown for its first item, putting an item up when this app is the // countdown for its first item, putting an item up when this app is the
// one that can, and getting that item ready shortly before. // one that can, and keeping that item ready.
/// The end-of-media card for the first Up next item. [secondsLeft] null: /// The end-of-media card for the first Up next item. [secondsLeft] null:
/// no countdown here (auto-advance off, cancelled, or another app puts /// no countdown here (auto-advance off, cancelled, or another app puts
@@ -62,7 +62,7 @@ class QueueController {
/// The mediaId whose continuation was cleared here. /// The mediaId whose continuation was cleared here.
final _hiddenFor = signal(-1); final _hiddenFor = signal(-1);
/// The queue popover is open: the chip stays up. /// The queue popover is open: the Up next button holds the controls up.
final popoverOpen = signal(false); final popoverOpen = signal(false);
// What adder-only items stand for, by their key (a link passed on from // What adder-only items stand for, by their key (a link passed on from
@@ -88,6 +88,12 @@ class QueueController {
final plan = coord.jellyfinPlan.value; final plan = coord.jellyfinPlan.value;
unawaited(_loadContinuation(next, plan)); unawaited(_loadContinuation(next, plan));
}); });
// The continuation and the state decide what to get ready.
effect(() {
shownContinuation.value;
final r = coord.serverState.value;
untracked(() => _warm(r));
});
} }
// ------------------------------------------------------------- adding // ------------------------------------------------------------- adding
@@ -474,44 +480,101 @@ class QueueController {
// ------------------------------------------------------------ warming // ------------------------------------------------------------ warming
/// Near the end of what plays (or once it is buffered to its end), the /// How long a warm result is kept, when it is resolved again, and when a
/// app that will put the first item up resolves its link, so the swap /// failed one is tried again.
/// does not wait for yt-dlp. A Jellyfin item, a file or a link the static const _warmKeep = Duration(minutes: 35);
/// server fetches has nothing to get ready here. static const _warmRefresh = Duration(minutes: 30);
void _warm(StateBroadcast r) { static const _warmRetry = Duration(minutes: 2);
String _warmKey = '';
String? _warmUrl;
Timer? _warmTimer;
int _warmGen = 0;
/// What this app gets ready: Up next's first item when it is the one
/// that puts it up; with Up next empty, the first continuation episode
/// (only here when this app plays the series with its own account, and
/// its ▶ puts it up from here). Null: nothing.
({String id, String? url, JellyfinRef? ref})? _warmTarget(StateBroadcast? r) {
if (r == null) return null;
final head = r.queue.firstOrNull; final head = r.queue.firstOrNull;
if (head == null || queuePerformer(head, r.members).id != _me) { if (head != null) {
if (queuePerformer(head, r.members).id != _me) return null;
final local = _local[head.key]?.pick;
return switch (head.how) {
QueueHow.direct => (id: head.id, url: head.media.url, ref: null),
QueueHow.through => (id: head.id, url: local is LinkMedia ? local.url : null, ref: null),
QueueHow.jellyfin => (id: head.id, url: null, ref: head.media.jellyfin),
QueueHow.file || QueueHow.server => (id: head.id, url: null, ref: null),
};
}
final next = shownContinuation.peek().firstOrNull;
if (next == null) return null;
return (id: 'next:${next.item.id}', url: null, ref: mediaForPick(next.pick).jellyfin);
}
/// Keeps the one item this app puts up next ready, so a skip or the end
/// of what plays starts it at once: a link probed (`AppYtDlp.probe`,
/// kept while it stays first), a Jellyfin item resolved with this app's
/// account ([SessionCoordinator.warmJellyfin]). Done as soon as it is
/// first here, again before that runs out, dropped when it is no longer
/// first. A file or a link the server fetches has nothing to get ready.
void _warm(StateBroadcast? r) {
final t = _warmTarget(r);
final ref = t?.ref;
final key = t == null ? '' : '${t.id}|${t.url}|${ref?.serverId}/${ref?.itemId}/${ref?.mediaSourceId}';
if (key == _warmKey) return;
_dropWarm(r);
_warmKey = key;
if (t == null) {
if (warm.peek() != null) warm.value = null; if (warm.peek() != null) warm.value = null;
return; return;
} }
final w = warm.peek(); if (t.url == null && ref == null) {
if (w == null || w.id != head.id) warm.value = (id: head.id, state: WarmState.idle); warm.value = (id: t.id, state: WarmState.ready);
if (warm.peek()!.state != WarmState.idle) return;
final duration = (r.media?['duration'] as num?)?.toDouble() ?? 0;
final position = coord.playback.positionSecs;
final due = r.media == null ||
queueWarmDue(
transport: r.transport,
position: position,
duration: duration,
bufferedTo: position + coord.playback.bufferedSecsAhead);
if (!due) return;
final local = _local[head.key]?.pick;
final url = switch (head.how) {
QueueHow.direct => head.media.url,
QueueHow.through => local is LinkMedia ? local.url : null,
_ => null,
};
if (url == null) {
warm.value = (id: head.id, state: WarmState.ready);
return; return;
} }
warm.value = (id: head.id, state: WarmState.warming); warm.value = (id: t.id, state: WarmState.warming);
// The probe is kept a few minutes: the swap reads it from there. _resolveWarm(t, fresh: false);
unawaited(coord.ytDlp.probe(url).then((p) => p.problem == null, onError: (_) => false).then((ok) { }
if (warm.peek()?.id != head.id) return;
warm.value = (id: head.id, state: ok ? WarmState.ready : WarmState.failed); void _dropWarm(StateBroadcast? r) {
})); _warmTimer?.cancel();
_warmTimer = null;
_warmGen++;
final url = _warmUrl;
_warmUrl = null;
if (url != null) coord.ytDlp.release(url);
// Kept while it is what just went up: that open takes it.
final w = coord.warmJellyfin;
if (w != null && !sameJellyfinItem(w.ref, refOfStateMedia(r?.media))) coord.warmJellyfin = null;
}
void _resolveWarm(({String id, String? url, JellyfinRef? ref}) t, {required bool fresh}) {
final gen = ++_warmGen;
unawaited(() async {
var ok = false;
try {
final url = t.url, ref = t.ref;
if (url != null) {
_warmUrl = url;
final p = await coord.ytDlp.probe(url, keep: _warmKeep, fresh: fresh).timeout(const Duration(seconds: 60));
ok = p.problem == null;
} else if (ref != null) {
final plan = await resolveJellyfinRef(coord.jellyfin, ref, relay: false, relayUrl: null);
if (gen != _warmGen) return;
if (plan is OwnPlay) {
coord.warmJellyfin = (ref: ref, plan: plan, until: DateTime.now().add(_warmKeep));
ok = true;
}
}
} catch (e) {
debugPrint('queue: getting ${t.id} ready: $e');
}
if (gen != _warmGen) return;
warm.value = (id: t.id, state: ok ? WarmState.ready : WarmState.failed);
_warmTimer = Timer(ok ? _warmRefresh : _warmRetry, () => _resolveWarm(t, fresh: true));
}());
} }
// -------------------------------------------------------- continuation // -------------------------------------------------------- continuation
+3 -19
View File
@@ -2,9 +2,9 @@ import 'package:sync_relay_core/core.dart';
import '../jellyfin/session_media.dart' show compareMemberIds; import '../jellyfin/session_media.dart' show compareMemberIds;
// Who puts an Up next item up, and when to get the first one ready. Pure: // Who puts an Up next item up. Pure: only what every client sees in
// only what every client sees in `state` decides, so all clients agree // `state` decides, so all clients agree without a wire message (like the
// without a wire message (like the Jellyfin auto-advance swapper). // Jellyfin auto-advance swapper).
/// Who puts [item] up: [id] a member id; null with [why] when nobody here /// Who puts [item] up: [id] a member id; null with [why] when nobody here
/// can (the item is skipped), null without [why] when nobody is around /// can (the item is skipped), null without [why] when nobody is around
@@ -66,22 +66,6 @@ String queueHowLabel(QueueItem item, {required String selfId}) {
}; };
} }
/// Seconds before the end at which the first Up next item is got ready.
const queueWarmLeadSecs = 90.0;
/// Whether to get the next item ready now: the current media is in its
/// last [queueWarmLeadSecs], is buffered to its end, or has finished.
bool queueWarmDue({
required String transport,
required double position,
required double duration,
required double bufferedTo,
}) {
if (transport == 'finished') return true;
if (duration <= 0) return false;
return duration - position <= queueWarmLeadSecs || bufferedTo >= duration - 1;
}
/// "1:23:45" / "23:45" for [secs]; empty when unknown. /// "1:23:45" / "23:45" for [secs]; empty when unknown.
String queueClock(double secs) { String queueClock(double secs) {
if (secs <= 0) return ''; if (secs <= 0) return '';
+147 -136
View File
@@ -1,5 +1,4 @@
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:ui' show ImageFilter;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:fox_utils/fox_utils.dart' show DesktopDropX, DropTarget; import 'package:fox_utils/fox_utils.dart' show DesktopDropX, DropTarget;
@@ -7,6 +6,9 @@ import 'package:leaf_widgets/leaf_widgets.dart' show LeafPopover, LeafPopoverCon
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import '../controls/uosc/buttons.dart' show UoscButton;
import '../controls/uosc/controls.dart' show UoscControls;
import '../controls/uosc/style.dart';
import '../files/scanner.dart' show FileKind, kindOf; import '../files/scanner.dart' show FileKind, kindOf;
import '../jellyfin/images.dart'; import '../jellyfin/images.dart';
import '../jellyfin/session_media.dart' show EpisodeLink; import '../jellyfin/session_media.dart' show EpisodeLink;
@@ -16,65 +18,41 @@ import '../shell/avatar.dart';
import '../shell/handoff.dart' show handoffHolds; import '../shell/handoff.dart' show handoffHolds;
import '../shell/motion.dart'; import '../shell/motion.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
import '../widgets/app_menu.dart' show AppMenuStyle; import '../widgets/app_menu.dart' show AppGlass, AppMenuStyle;
import 'queue_controller.dart'; import 'queue_controller.dart';
import 'queue_logic.dart'; import 'queue_logic.dart';
// Up next in the player: the chip top right (with the controls), the // Up next in the player: its control in the top bar (with chat and
// popover it opens (the queue, the continuation, a field and a drop zone // fullscreen), the popover it opens (the queue, the continuation, a field
// to add), and the end-of-media card for the first item. // and a drop zone to add), and the end-of-media card for the first item.
/// Frosted dark glass like the app's menus. // ---------------------------------------------------------------- button
class _Glass extends StatelessWidget {
final Widget child;
final double radius;
final Color color;
const _Glass({super.key, required this.child, this.radius = AppMenuStyle.radius, this.color = AppMenuStyle.glass});
@override /// Which controls design the Up next control sits in: it takes the look
Widget build(BuildContext context) { /// of that bar's buttons.
final r = BorderRadius.circular(radius); enum UpNextLook { standard, uosc }
return DecoratedBox(
decoration: BoxDecoration(
borderRadius: r,
boxShadow: const [BoxShadow(color: Color(0x66000000), blurRadius: 24, offset: Offset(0, 8))],
),
child: ClipRRect(
borderRadius: r,
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16),
child: Material(
color: color,
shape: RoundedRectangleBorder(
borderRadius: r, side: const BorderSide(color: AppMenuStyle.border, width: 0.5)),
child: child,
),
),
),
);
}
}
// ------------------------------------------------------------------ chip /// "Up next · <title>" and ▶ (play it now) in the player's top bar, one
/// flat control among its buttons; the title opens the queue. Without Up
/// "Up next · <title>" with ▶ (play it now), top right while the controls /// next, the continuation ("Next · S1E8 …", ▶ plays it). Nothing when
/// are up; the title opens the queue. Without Up next, the continuation /// both are empty.
/// ("Next · S1E8 …", ▶ plays it). Nothing when both are empty. class UpNextButton extends StatefulWidget {
class UpNextChip extends StatefulWidget {
final QueueController queue; final QueueController queue;
/// Plays the continuation's first episode (the next episode). /// Plays the continuation's first episode (the next episode).
final VoidCallback onPlayNextEpisode; final VoidCallback onPlayNextEpisode;
const UpNextChip({super.key, required this.queue, required this.onPlayNextEpisode}); final UpNextLook look;
const UpNextButton({super.key, required this.queue, required this.onPlayNextEpisode, required this.look});
@override @override
State<UpNextChip> createState() => _UpNextChipState(); State<UpNextButton> createState() => _UpNextButtonState();
} }
class _UpNextChipState extends State<UpNextChip> { class _UpNextButtonState extends State<UpNextButton> {
final _popover = LeafPopoverController(); final _popover = LeafPopoverController();
bool _hoverTitle = false; bool _hoverTitle = false;
QueueController get _q => widget.queue; QueueController get _q => widget.queue;
bool get _uosc => widget.look == UpNextLook.uosc;
@override @override
void dispose() { void dispose() {
@@ -102,8 +80,19 @@ class _UpNextChipState extends State<UpNextChip> {
handoffHolds = math.max(0, handoffHolds - 1); handoffHolds = math.max(0, handoffHolds - 1);
} }
Widget _tip(String message, Widget child) => _uosc
? UoscTooltip(message: message, child: child)
: Tooltip(message: message, waitDuration: const Duration(milliseconds: 500), child: child);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// The bar's own metrics: uosc's flat 40 px boxes, or media_kit's
// round 44 px icon buttons.
final height = _uosc ? UoscControls.buttonRowHeight : 44.0;
final radius = _uosc ? UoscStyle.radius : height / 2;
final hover = _uosc ? UoscStyle.hover : Colors.white10;
final fontSize = _uosc ? UoscStyle.fontSize : 14.0;
final dim = _uosc ? UoscStyle.dim : Colors.white70;
return SignalBuilder(builder: (context) { return SignalBuilder(builder: (context) {
final items = _q.items.value; final items = _q.items.value;
final cont = _q.shownContinuation.value; final cont = _q.shownContinuation.value;
@@ -112,41 +101,30 @@ class _UpNextChipState extends State<UpNextChip> {
final queued = items.isNotEmpty; final queued = items.isNotEmpty;
final title = queued ? items.first.title : (cont.isNotEmpty ? cont.first.label : 'Nothing yet'); final title = queued ? items.first.title : (cont.isNotEmpty ? cont.first.label : 'Nothing yet');
final more = items.length - 1; final more = items.length - 1;
return _Glass( final label = MouseRegion(
radius: 999,
color: const Color(0xB3141418),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
LeafPopover(
controller: _popover,
popoverAnchor: Alignment.topRight,
childAnchor: Alignment.bottomRight,
spacing: 8,
onDismissed: _closed,
popoverBuilder: (context, c) => QueuePanel(queue: _q, onClose: c.hide),
child: MouseRegion(
cursor: SystemMouseCursors.click, cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hoverTitle = true), onEnter: (_) => setState(() => _hoverTitle = true),
onExit: (_) => setState(() => _hoverTitle = false), onExit: (_) => setState(() => _hoverTitle = false),
child: GestureDetector( child: GestureDetector(
key: const ValueKey('up-next-chip'), key: const ValueKey('up-next-button'),
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onTap: _toggle, onTap: _toggle,
child: AnimatedContainer( child: AnimatedContainer(
duration: const Duration(milliseconds: 150), duration: const Duration(milliseconds: 150),
height: 36, height: height,
padding: const EdgeInsets.only(left: 12, right: 10), padding: const EdgeInsets.only(left: 10, right: 6),
color: _hoverTitle || open ? const Color(0x14FFFFFF) : Colors.transparent, decoration: BoxDecoration(
color: _hoverTitle || open ? hover : Colors.transparent,
borderRadius: BorderRadius.circular(radius),
),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(queued ? Icons.queue_play_next_rounded : Icons.playlist_play_rounded, Icon(queued ? Icons.queue_play_next_rounded : Icons.playlist_play_rounded,
size: 17, color: Colors.white70), size: _uosc ? 22 : 24, color: Colors.white),
const SizedBox(width: 8), const SizedBox(width: 8),
Text(queued ? 'Up next' : 'Next', Text(queued ? 'Up next' : 'Next', style: TextStyle(color: dim, fontSize: fontSize)),
style: const TextStyle(color: Colors.white60, fontSize: 12.5)), Text(' · ', style: TextStyle(color: dim, fontSize: fontSize)),
const Text(' · ', style: TextStyle(color: Colors.white38, fontSize: 12.5)),
ConstrainedBox( ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 240), constraints: const BoxConstraints(maxWidth: 240),
child: Text( child: Text(
@@ -154,43 +132,58 @@ class _UpNextChipState extends State<UpNextChip> {
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
color: queued ? Colors.white : Colors.white70, color: queued ? Colors.white : dim, fontSize: fontSize, fontWeight: FontWeight.w500),
fontSize: 13,
fontWeight: FontWeight.w500),
), ),
), ),
if (more > 0) ...[ if (more > 0) ...[
const SizedBox(width: 8), const SizedBox(width: 8),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration( decoration: BoxDecoration(color: UoscStyle.hover, borderRadius: BorderRadius.circular(999)),
color: const Color(0x24FFFFFF), borderRadius: BorderRadius.circular(999)), child: Text('+$more', style: TextStyle(color: dim, fontSize: 11)),
child: Text('+$more', style: const TextStyle(color: Colors.white70, fontSize: 11)),
), ),
], ],
const SizedBox(width: 4), const SizedBox(width: 2),
AnimatedRotation( AnimatedRotation(
duration: easeTime, duration: easeTime,
curve: ease, curve: ease,
turns: open ? 0.5 : 0, turns: open ? 0.5 : 0,
child: const Icon(Icons.expand_more_rounded, size: 18, color: Colors.white54), child: Icon(Icons.expand_more_rounded, size: 18, color: dim),
), ),
], ],
), ),
), ),
), ),
), );
), final playTip = queued ? 'Play ${items.first.title} now' : 'Play ${cont.isEmpty ? '' : cont.first.label} now';
if (queued || cont.isNotEmpty) ...[ final VoidCallback? play = queued ? _q.skip : (cont.isNotEmpty ? widget.onPlayNextEpisode : null);
Container(width: 0.5, height: 20, color: Colors.white24), final playButton = _uosc
_RoundIcon( ? UoscButton(key: const ValueKey('up-next-play'), icon: Icons.skip_next_rounded, tooltip: playTip, onPressed: play)
: _tip(
playTip,
IconButton(
key: const ValueKey('up-next-play'), key: const ValueKey('up-next-play'),
icon: Icons.play_arrow_rounded, iconSize: 28,
tooltip: queued ? 'Play ${items.first.title} now' : 'Play ${cont.first.label} now', color: Colors.white,
onPressed: queued ? _q.skip : widget.onPlayNextEpisode, onPressed: play,
icon: const Icon(Icons.skip_next_rounded),
),
);
// The popover hangs under the whole control, right edges aligned.
return LeafPopover(
controller: _popover,
popoverAnchor: Alignment.topRight,
childAnchor: Alignment.bottomRight,
spacing: 8,
onDismissed: _closed,
popoverBuilder: (context, c) => QueuePanel(queue: _q, onClose: c.hide),
// A little apart from the bar's other buttons: its own group.
child: Padding(
padding: const EdgeInsets.only(right: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [label, if (play != null) playButton],
), ),
],
],
), ),
); );
}); });
@@ -378,7 +371,7 @@ class _QueuePanelState extends State<QueuePanel> {
width: 424, width: 424,
child: ConstrainedBox( child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: math.max(260, math.min(600, height - 140))), constraints: BoxConstraints(maxHeight: math.max(260, math.min(600, height - 140))),
child: _Glass( child: AppGlass(
child: AnimatedContainer( child: AnimatedContainer(
duration: easeTime, duration: easeTime,
curve: ease, curve: ease,
@@ -403,13 +396,13 @@ class _QueuePanelState extends State<QueuePanel> {
? _empty() ? _empty()
: ListView( : ListView(
shrinkWrap: true, shrinkWrap: true,
padding: const EdgeInsets.fromLTRB(6, 0, 6, 6), padding: const EdgeInsets.fromLTRB(4, 0, 4, 4),
children: [ children: [
if (items.isEmpty) if (items.isEmpty)
const Padding( const Padding(
padding: EdgeInsets.fromLTRB(10, 2, 10, 8), padding: EdgeInsets.fromLTRB(12, 2, 12, 8),
child: Text('Nothing added yet. What plays next is below.', child: Text('Nothing added yet. What plays next is below.',
style: TextStyle(color: Colors.white54, fontSize: 12.5)), style: TextStyle(color: Colors.white54, fontSize: 12)),
) )
else else
ReorderableListView.builder( ReorderableListView.builder(
@@ -458,17 +451,18 @@ class _QueuePanelState extends State<QueuePanel> {
); );
} }
// Text lines up with the rows' (4 panel + 12 row padding).
Widget _header(int count) => Padding( Widget _header(int count) => Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 8, 8), padding: const EdgeInsets.fromLTRB(16, 8, 6, 4),
child: Row( child: Row(
children: [ children: [
const Text('Up next', style: TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600)), const Text('Up next', style: TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600)),
if (count > 0) ...[ if (count > 0) ...[
const SizedBox(width: 8), const SizedBox(width: 8),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 1), padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 1),
decoration: BoxDecoration(color: const Color(0x24FFFFFF), borderRadius: BorderRadius.circular(999)), decoration: BoxDecoration(color: AppMenuStyle.focus, borderRadius: BorderRadius.circular(999)),
child: Text('$count', style: const TextStyle(color: Colors.white70, fontSize: 11.5)), child: Text('$count', style: const TextStyle(color: Colors.white70, fontSize: 11)),
), ),
], ],
const Spacer(), const Spacer(),
@@ -484,13 +478,13 @@ class _QueuePanelState extends State<QueuePanel> {
children: [ children: [
Icon(Icons.queue_play_next_rounded, size: 34, color: Colors.white30), Icon(Icons.queue_play_next_rounded, size: 34, color: Colors.white30),
SizedBox(height: 10), SizedBox(height: 10),
Text('Nothing up next', style: TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500)), Text('Nothing up next', style: TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600)),
SizedBox(height: 4), SizedBox(height: 4),
Text( Text(
'Paste a link below, drop a video or a link here, or pick "Add to queue" in the library. ' 'Paste a link below, drop a video or a link here, or pick "Add to queue" in the library. '
'It plays when this one ends.', 'It plays when this one ends.',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle(color: Colors.white54, fontSize: 12.5, height: 1.35), style: TextStyle(color: Colors.white54, fontSize: 12, height: 1.35),
), ),
], ],
), ),
@@ -498,11 +492,11 @@ class _QueuePanelState extends State<QueuePanel> {
List<Widget> _continuation(List<EpisodeLink> cont, bool alone) => [ List<Widget> _continuation(List<EpisodeLink> cont, bool alone) => [
Padding( Padding(
padding: EdgeInsets.fromLTRB(10, alone ? 0 : 10, 2, 2), padding: EdgeInsets.fromLTRB(12, alone ? 0 : 8, 2, 0),
child: Row( child: Row(
children: [ children: [
const Text('CONTINUES WITH', const Text('CONTINUES WITH',
style: TextStyle(color: Colors.white38, fontSize: 10.5, letterSpacing: 0.8, fontWeight: FontWeight.w600)), style: TextStyle(color: Colors.white38, fontSize: 11, letterSpacing: 0.8, fontWeight: FontWeight.w600)),
const Spacer(), const Spacer(),
TextButton( TextButton(
key: const ValueKey('queue-clear-continuation'), key: const ValueKey('queue-clear-continuation'),
@@ -518,7 +512,7 @@ class _QueuePanelState extends State<QueuePanel> {
Widget _footer() => Container( Widget _footer() => Container(
decoration: const BoxDecoration(border: Border(top: BorderSide(color: AppMenuStyle.border, width: 0.5))), decoration: const BoxDecoration(border: Border(top: BorderSide(color: AppMenuStyle.border, width: 0.5))),
padding: const EdgeInsets.fromLTRB(10, 8, 10, 10), padding: const EdgeInsets.all(8),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -530,14 +524,16 @@ class _QueuePanelState extends State<QueuePanel> {
// Read-only while adding, not disabled: focus stays here, so // Read-only while adding, not disabled: focus stays here, so
// Space and the arrows do not reach the player. // Space and the arrows do not reach the player.
readOnly: _busy, readOnly: _busy,
style: const TextStyle(fontSize: 13.5), style: const TextStyle(fontSize: 14),
onSubmitted: (_) => _submit(), onSubmitted: (_) => _submit(),
// The app's field look (as `AppSelect`): a faint fill, a
// hairline, the accent while focused.
decoration: InputDecoration( decoration: InputDecoration(
isDense: true, isDense: true,
filled: true, filled: true,
fillColor: const Color(0x14FFFFFF), fillColor: const Color(0x0DFFFFFF),
hintText: _dragging ? 'Drop to add' : 'Paste a link to add', hintText: _dragging ? 'Drop to add' : 'Paste a link to add',
hintStyle: const TextStyle(color: Colors.white38, fontSize: 13.5), hintStyle: const TextStyle(color: Colors.white38, fontSize: 14),
prefixIcon: const Icon(Icons.add_link_rounded, size: 18, color: Colors.white54), prefixIcon: const Icon(Icons.add_link_rounded, size: 18, color: Colors.white54),
prefixIconConstraints: const BoxConstraints(minWidth: 38), prefixIconConstraints: const BoxConstraints(minWidth: 38),
suffixIcon: _busy suffixIcon: _busy
@@ -547,7 +543,14 @@ class _QueuePanelState extends State<QueuePanel> {
) )
: null, : null,
contentPadding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8), contentPadding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide.none), enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppMenuStyle.rowRadius),
borderSide: const BorderSide(color: AppMenuStyle.border, width: 0.5),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppMenuStyle.rowRadius),
borderSide: BorderSide(color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.6), width: 0.5),
),
), ),
), ),
AnimatedSize( AnimatedSize(
@@ -557,7 +560,7 @@ class _QueuePanelState extends State<QueuePanel> {
child: _error == null child: _error == null
? const SizedBox(width: double.infinity) ? const SizedBox(width: double.infinity)
: Padding( : Padding(
padding: const EdgeInsets.fromLTRB(4, 6, 4, 0), padding: const EdgeInsets.fromLTRB(8, 6, 8, 0),
child: Text(_error!, style: TextStyle(color: Colors.orange.shade300, fontSize: 12)), child: Text(_error!, style: TextStyle(color: Colors.orange.shade300, fontSize: 12)),
), ),
), ),
@@ -629,7 +632,7 @@ class _MetaLine extends StatelessWidget {
/// Wraps onto a second line instead of cutting the end off. /// Wraps onto a second line instead of cutting the end off.
final bool wrap; final bool wrap;
const _MetaLine( const _MetaLine(
{required this.item, required this.members, required this.selfId, this.fontSize = 11.5, this.wrap = false}); {required this.item, required this.members, required this.selfId, this.fontSize = 12, this.wrap = false});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -637,7 +640,7 @@ class _MetaLine extends StatelessWidget {
final dur = queueClock(item.duration); final dur = queueClock(item.duration);
final mine = item.by == selfId && selfId.isNotEmpty; final mine = item.by == selfId && selfId.isNotEmpty;
final name = mine ? 'you' : (item.byName.isEmpty ? '?' : item.byName); final name = mine ? 'you' : (item.byName.isEmpty ? '?' : item.byName);
const dot = Text(' · ', style: TextStyle(color: Colors.white24, fontSize: 11)); final dot = Text(' · ', style: TextStyle(color: Colors.white24, fontSize: fontSize));
if (wrap) { if (wrap) {
final how = queueHowLabel(item, selfId: selfId); final how = queueHowLabel(item, selfId: selfId);
return Wrap( return Wrap(
@@ -681,6 +684,15 @@ class _MetaLine extends StatelessWidget {
} }
} }
/// Every row of the popover (queued and continuation): one height, the
/// drag handle's width before the picture (a spacer where there is none),
/// one picture size and one type scale.
const queueRowHeight = 64.0;
const _lead = 22.0;
const _thumb = 80.0;
const _titleStyle = TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500);
const _subStyle = TextStyle(color: Colors.white60, fontSize: 12);
class _QueueRow extends StatefulWidget { class _QueueRow extends StatefulWidget {
final int index; final int index;
final QueueItem item; final QueueItem item;
@@ -711,8 +723,8 @@ class _QueueRowState extends State<_QueueRow> {
final item = widget.item; final item = widget.item;
final row = AnimatedContainer( final row = AnimatedContainer(
duration: const Duration(milliseconds: 120), duration: const Duration(milliseconds: 120),
margin: const EdgeInsets.symmetric(vertical: 1), height: queueRowHeight,
padding: const EdgeInsets.fromLTRB(2, 6, 8, 6), padding: const EdgeInsets.only(right: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _hover ? AppMenuStyle.hover : Colors.transparent, color: _hover ? AppMenuStyle.hover : Colors.transparent,
borderRadius: BorderRadius.circular(AppMenuStyle.rowRadius), borderRadius: BorderRadius.circular(AppMenuStyle.rowRadius),
@@ -724,8 +736,8 @@ class _QueueRowState extends State<_QueueRow> {
child: MouseRegion( child: MouseRegion(
cursor: SystemMouseCursors.grab, cursor: SystemMouseCursors.grab,
child: SizedBox( child: SizedBox(
width: 22, width: _lead,
height: 40, height: queueRowHeight,
child: AnimatedOpacity( child: AnimatedOpacity(
duration: const Duration(milliseconds: 120), duration: const Duration(milliseconds: 120),
opacity: _hover ? 0.8 : 0.25, opacity: _hover ? 0.8 : 0.25,
@@ -734,29 +746,22 @@ class _QueueRowState extends State<_QueueRow> {
), ),
), ),
), ),
QueueThumb(queue: widget.queue, item: item), // Its readiness rides on the picture's corner.
const SizedBox(width: 10), Stack(
children: [
QueueThumb(queue: widget.queue, item: item, width: _thumb),
if (widget.warm != null) Positioned(top: 3, right: 3, child: _WarmDot(state: widget.warm!)),
],
),
const SizedBox(width: 12),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Row( Text(item.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: _titleStyle),
children: [
Flexible(
child: Text(item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white, fontSize: 13.5, fontWeight: FontWeight.w500)),
),
if (widget.warm != null) ...[const SizedBox(width: 6), _WarmDot(state: widget.warm!)],
],
),
if (item.sub.isNotEmpty) if (item.sub.isNotEmpty)
Text(item.sub, Text(item.sub, maxLines: 1, overflow: TextOverflow.ellipsis, style: _subStyle),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white60, fontSize: 11.5)),
const SizedBox(height: 2), const SizedBox(height: 2),
_MetaLine(item: item, members: widget.members, selfId: widget.selfId), _MetaLine(item: item, members: widget.members, selfId: widget.selfId),
], ],
@@ -859,13 +864,20 @@ class _WarmDotState extends State<_WarmDot> with SingleTickerProviderStateMixin
Widget build(BuildContext context) { Widget build(BuildContext context) {
final green = Colors.greenAccent.shade400; final green = Colors.greenAccent.shade400;
final (color, filled, tip) = switch (widget.state) { final (color, filled, tip) = switch (widget.state) {
WarmState.idle => (Colors.white54, false, 'Gets ready near the end of this one'), WarmState.idle => (Colors.white54, false, 'Waiting to get ready'),
WarmState.warming => (green, true, 'Getting it ready…'), WarmState.warming => (green, true, 'Getting it ready…'),
WarmState.ready => (green, true, 'Ready to start'), WarmState.ready => (green, true, 'Ready to start'),
WarmState.failed => (Colors.redAccent, true, "Couldn't get it ready; it is tried again when it comes up"), WarmState.failed => (Colors.redAccent, true, "Couldn't get it ready; trying again shortly"),
}; };
return Tooltip( return Tooltip(
message: tip, message: tip,
// On a dark disc: it sits on the picture's corner.
child: Container(
key: const ValueKey('queue-warm-dot'),
width: 14,
height: 14,
alignment: Alignment.center,
decoration: const BoxDecoration(shape: BoxShape.circle, color: Color(0xB3000000)),
child: FadeTransition( child: FadeTransition(
opacity: Tween(begin: 0.35, end: 1.0).animate(_pulse), opacity: Tween(begin: 0.35, end: 1.0).animate(_pulse),
child: Container( child: Container(
@@ -878,6 +890,7 @@ class _WarmDotState extends State<_WarmDot> with SingleTickerProviderStateMixin
), ),
), ),
), ),
),
); );
} }
} }
@@ -907,7 +920,8 @@ class _ContinuationRowState extends State<_ContinuationRow> {
duration: const Duration(milliseconds: 120), duration: const Duration(milliseconds: 120),
opacity: _hover ? 0.9 : 0.55, opacity: _hover ? 0.9 : 0.55,
child: Container( child: Container(
padding: const EdgeInsets.fromLTRB(24, 5, 6, 5), height: queueRowHeight,
padding: const EdgeInsets.only(left: _lead, right: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _hover ? AppMenuStyle.hover : Colors.transparent, color: _hover ? AppMenuStyle.hover : Colors.transparent,
borderRadius: BorderRadius.circular(AppMenuStyle.rowRadius), borderRadius: BorderRadius.circular(AppMenuStyle.rowRadius),
@@ -917,30 +931,27 @@ class _ContinuationRowState extends State<_ContinuationRow> {
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(6),
child: SizedBox( child: SizedBox(
width: 72, width: _thumb,
height: 40.5, height: _thumb * 9 / 16,
child: client == null child: client == null
? const ColoredBox(color: Color(0x1FFFFFFF)) ? const ColoredBox(color: Color(0x1FFFFFFF))
: JellyfinImage(client: client, image: image, icon: Icons.tv_rounded), : JellyfinImage(client: client, image: image, icon: Icons.tv_rounded),
), ),
), ),
const SizedBox(width: 10), const SizedBox(width: 12),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text(e.label, Text(e.label, maxLines: 1, overflow: TextOverflow.ellipsis, style: _titleStyle),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white, fontSize: 13)),
Text( Text(
[if (runtime != null) queueClock(runtime.inSeconds.toDouble()), e.item.seriesName ?? ''] [if (runtime != null) queueClock(runtime.inSeconds.toDouble()), e.item.seriesName ?? '']
.where((s) => s.isNotEmpty) .where((s) => s.isNotEmpty)
.join(' · '), .join(' · '),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white54, fontSize: 11.5), style: _subStyle,
), ),
], ],
), ),
@@ -976,7 +987,7 @@ class UpNextCard extends StatelessWidget {
final r = queue.coord.serverState.value; final r = queue.coord.serverState.value;
final selfId = queue.coord.sync.memberId; final selfId = queue.coord.sync.memberId;
final waiting = left == null ? _waitingFor(item, r) : null; final waiting = left == null ? _waitingFor(item, r) : null;
return _Glass( return AppGlass(
key: const ValueKey('up-next-card'), key: const ValueKey('up-next-card'),
child: SizedBox( child: SizedBox(
width: 400, width: 400,
@@ -137,6 +137,9 @@ class AiringCard extends StatelessWidget {
final bool busy; final bool busy;
final VoidCallback onAction; final VoidCallback onAction;
/// Right-click ([context]: the card): the page's menu; null: none.
final void Function(BuildContext context)? onMenu;
const AiringCard({ const AiringCard({
super.key, super.key,
required this.entry, required this.entry,
@@ -146,6 +149,7 @@ class AiringCard extends StatelessWidget {
required this.onAction, required this.onAction,
this.disabledHint, this.disabledHint,
this.busy = false, this.busy = false,
this.onMenu,
}); });
String get _id => '${entry.airing.anilistId}-${entry.airing.episode}'; String get _id => '${entry.airing.anilistId}-${entry.airing.episode}';
@@ -192,6 +196,8 @@ class AiringCard extends StatelessWidget {
), ),
); );
final tap = (action == CardAction.putUp || action == CardAction.play) && disabledHint == null && !busy ? onAction : null; final tap = (action == CardAction.putUp || action == CardAction.play) && disabledHint == null && !busy ? onAction : null;
final onMenu = this.onMenu;
final menu = onMenu == null || busy ? null : () => onMenu(context);
if (compact) { if (compact) {
final requestable = action == CardAction.request || action == CardAction.requestSeries; final requestable = action == CardAction.request || action == CardAction.requestSeries;
return Material( return Material(
@@ -201,6 +207,7 @@ class AiringCard extends StatelessWidget {
child: InkWell( child: InkWell(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
onTap: tap, onTap: tap,
onSecondaryTap: menu,
child: Padding( child: Padding(
padding: const EdgeInsets.all(6), padding: const EdgeInsets.all(6),
child: Row( child: Row(
@@ -255,6 +262,7 @@ class AiringCard extends StatelessWidget {
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: InkWell( child: InkWell(
onTap: tap, onTap: tap,
onSecondaryTap: menu,
child: Padding( child: Padding(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
child: Column( child: Column(
@@ -6,6 +6,7 @@ import '../../jellyfin/accounts.dart';
import '../../screens/home/picked_media.dart'; import '../../screens/home/picked_media.dart';
import '../../shell/avatar.dart'; import '../../shell/avatar.dart';
import '../../shell/motion.dart'; import '../../shell/motion.dart';
import '../../widgets/app_menu.dart';
import '../anilist.dart'; import '../anilist.dart';
import '../animeflow.dart'; import '../animeflow.dart';
import '../schedule_model.dart'; import '../schedule_model.dart';
@@ -15,8 +16,9 @@ import 'schedule_card.dart';
// The Schedule tab: one column per day from last week's Monday to four // The Schedule tab: one column per day from last week's Monday to four
// weeks out (AniList), each airing with what AnimeFlow has of it, and the // weeks out (AniList), each airing with what AnimeFlow has of it, and the
// one thing to do about it (put it up, request it). Refreshes every // one thing to do about it (put it up, request it; right-click an
// minute while visible; ETags keep that cheap. // available one to queue it). Refreshes every minute while visible; ETags
// keep that cheap.
enum ScheduleFilter { jellyfin, all } enum ScheduleFilter { jellyfin, all }
@@ -27,6 +29,9 @@ class SchedulePage extends StatefulWidget {
/// Puts a Jellyfin episode up in the server; returns an error to show. /// Puts a Jellyfin episode up in the server; returns an error to show.
final Future<String?> Function(JellyfinMedia pick) onPutUp; final Future<String?> Function(JellyfinMedia pick) onPutUp;
/// Adds an episode to Up next; null: no server, the cards have no menu.
final AddToQueue? onAddToQueue;
final void Function(String message) onToast; final void Function(String message) onToast;
/// Null = the page makes (and closes) its own. /// Null = the page makes (and closes) its own.
@@ -39,6 +44,7 @@ class SchedulePage extends StatefulWidget {
super.key, super.key,
this.solo = false, this.solo = false,
required this.onPutUp, required this.onPutUp,
this.onAddToQueue,
required this.onToast, required this.onToast,
this.repository, this.repository,
this.accounts, this.accounts,
@@ -267,6 +273,32 @@ class _SchedulePageState extends State<SchedulePage> {
} }
} }
/// Right-click on an available episode ([context]: its card): Put it
/// up, Add to queue, Play next.
Future<void> _menu(BuildContext context, ScheduleEntry e) async {
final add = widget.onAddToQueue;
if (add == null) return;
final pick = await showAppMenu<int>(context, items: [
AppMenuItem(value: 0, label: widget.solo ? 'Play' : 'Put it up'),
const AppMenuItem(value: 1, label: 'Add to queue'),
const AppMenuItem(value: 2, label: 'Play next'),
]);
if (pick == null || !mounted) return;
if (pick == 0) return _putUp(e);
final key = 'play:${_key(e)}';
if (_busy.contains(key)) return;
setState(() => _busy.add(key));
try {
final media = await resolveJellyfinEpisode(_accounts, _flow, e);
final err = await add(media, next: pick == 2);
if (err != null) _toast(err);
} catch (err) {
_toast(friendlyError(err));
} finally {
if (mounted) setState(() => _busy.remove(key));
}
}
Future<void> _request(ScheduleEntry e) async { Future<void> _request(ScheduleEntry e) async {
final c = _flow; final c = _flow;
if (c == null) return; if (c == null) return;
@@ -520,6 +552,9 @@ class _SchedulePageState extends State<SchedulePage> {
disabledHint: hint, disabledHint: hint,
busy: busy, busy: busy,
onAction: () => _onAction(e, action), onAction: () => _onAction(e, action),
onMenu: widget.onAddToQueue != null && (action == CardAction.putUp || action == CardAction.play) && hint == null
? (context) => _menu(context, e)
: null,
); );
} }
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../../jellyfin/session_media.dart'; import '../../jellyfin/session_media.dart';
import '../../session/session_coordinator.dart' show AutoAdvance, autoAdvanceSecs; import '../../session/session_coordinator.dart' show AutoAdvance, autoAdvanceSecs;
import '../../widgets/app_menu.dart' show AppGlass;
import 'player_controls.dart' show FloatingPill; import 'player_controls.dart' show FloatingPill;
// Jellyfin pieces of the session player. Plain widgets fed by the // Jellyfin pieces of the session player. Plain widgets fed by the
@@ -69,27 +70,36 @@ class EndOfEpisodeCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
final c = countdown; final c = countdown;
return Card( // The same card as Up next's (`UpNextCard`): the app's glass, 400 wide.
return AppGlass(
key: const ValueKey('end-of-episode'), key: const ValueKey('end-of-episode'),
color: Colors.black.withValues(alpha: 0.85), child: SizedBox(
width: 400,
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 12, 12), padding: const EdgeInsets.all(12),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 380),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text(c == null ? 'Up next' : 'Next episode in ${c.secondsLeft} s', Text(c == null ? 'Up next' : 'Next episode in ${c.secondsLeft} s',
style: t.labelLarge?.copyWith(color: Colors.white70)), style: const TextStyle(color: Colors.white60, fontSize: 12)),
const SizedBox(height: 4), const SizedBox(height: 2),
Text('Next: ${next.label}', Text('Next: ${next.label}',
maxLines: 2, overflow: TextOverflow.ellipsis, style: t.titleMedium), maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600, height: 1.25)),
if (c != null) ...[ if (c != null) ...[
const SizedBox(height: 8), const SizedBox(height: 10),
LinearProgressIndicator(value: c.secondsLeft / autoAdvanceSecs), ClipRRect(
borderRadius: BorderRadius.circular(2),
child: TweenAnimationBuilder<double>(
tween: Tween(end: c.secondsLeft / autoAdvanceSecs),
duration: const Duration(seconds: 1),
builder: (context, v, _) =>
LinearProgressIndicator(value: v, minHeight: 3, backgroundColor: const Color(0x1FFFFFFF)),
),
),
], ],
const SizedBox(height: 10), const SizedBox(height: 10),
Row( Row(
@@ -7,6 +7,7 @@ import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
import '../../jellyfin/track_prefs.dart' show MpvTrack; import '../../jellyfin/track_prefs.dart' show MpvTrack;
import '../../playback/media_marks.dart'; import '../../playback/media_marks.dart';
import '../../widgets/app_menu.dart' show AppGlass;
// Session control-bar pieces built on the playback signals (chapters, // Session control-bar pieces built on the playback signals (chapters,
// tracks, skip offers): the seek bar with chapter marks, the chapter and // tracks, skip offers): the seek bar with chapter marks, the chapter and
@@ -525,10 +526,13 @@ class SkipForwardButton extends StatelessWidget {
} }
} }
/// Floating pill over the video (Next episode, Skip OP / ED / 1:30): /// The player's action pill (Next episode, Skip OP / ED / 1:30): the
/// faint at rest, lit on hover. Lives outside the auto-hiding controls /// status pill's glass ([AppGlass.pill]) at [height], faint at rest, lit
/// (also in fullscreen); `PillGate` shows it with them. /// on hover. Lives in the player's action area, outside the auto-hiding
/// controls (also in fullscreen); `PillGate` shows it with them.
class FloatingPill extends StatefulWidget { class FloatingPill extends StatefulWidget {
/// Every action pill's height: the status pill's without avatars.
static const height = 44.0;
final String label; final String label;
final String tooltip; final String tooltip;
final IconData icon; final IconData icon;
@@ -540,7 +544,7 @@ class FloatingPill extends StatefulWidget {
required this.label, required this.label,
required this.tooltip, required this.tooltip,
required this.onPressed, required this.onPressed,
this.icon = Icons.chevron_right, this.icon = Icons.chevron_right_rounded,
this.tapKey, this.tapKey,
}); });
@@ -553,38 +557,31 @@ class _FloatingPillState extends State<FloatingPill> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final primary = Theme.of(context).colorScheme.primary; const fade = Duration(milliseconds: 150);
return MouseRegion( return MouseRegion(
cursor: SystemMouseCursors.click, cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hover = true), onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false), onExit: (_) => setState(() => _hover = false),
child: Tooltip( child: Tooltip(
message: widget.tooltip, message: widget.tooltip,
child: AnimatedScale(
duration: const Duration(milliseconds: 150),
scale: _hover ? 1.06 : 1,
child: AnimatedOpacity( child: AnimatedOpacity(
duration: const Duration(milliseconds: 150), duration: fade,
opacity: _hover ? 1 : 0.45, opacity: _hover ? 1 : 0.45,
child: GestureDetector( child: GestureDetector(
key: widget.tapKey, key: widget.tapKey,
onTap: widget.onPressed, onTap: widget.onPressed,
child: AppGlass.pill(
child: AnimatedContainer( child: AnimatedContainer(
duration: const Duration(milliseconds: 150), duration: fade,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 9), height: FloatingPill.height,
decoration: BoxDecoration( padding: const EdgeInsets.only(left: 16, right: 12),
color: Colors.black.withValues(alpha: 0.75), color: _hover ? Colors.white12 : Colors.transparent,
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: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text(widget.label, style: const TextStyle(color: Colors.white, fontSize: 13)), Text(widget.label,
const SizedBox(width: 4), style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600)),
const SizedBox(width: 6),
Icon(widget.icon, color: Colors.white, size: 18), Icon(widget.icon, color: Colors.white, size: 18),
], ],
), ),
@@ -204,6 +204,11 @@ class SessionCoordinator {
/// Set when this viewer cannot play the Jellyfin media: no account and /// Set when this viewer cannot play the Jellyfin media: no account and
/// no relay. The session shows it with a way to link an account. /// no relay. The session shows it with a way to link an account.
final jellyfinBlocked = signal<BlockedPlay?>(null); final jellyfinBlocked = signal<BlockedPlay?>(null);
/// An item this app is about to put up, resolved ahead with its own
/// account (Up next's first item, the next episode; see
/// `QueueController`): opening it then skips PlaybackInfo. Used once,
/// until [until].
({JellyfinRef ref, OwnPlay plan, DateTime until})? warmJellyfin;
/// Neighbouring episodes this viewer can put up (own account only). /// Neighbouring episodes this viewer can put up (own account only).
final previousEpisode = signal<EpisodeLink?>(null); final previousEpisode = signal<EpisodeLink?>(null);
final nextEpisode = signal<EpisodeLink?>(null); final nextEpisode = signal<EpisodeLink?>(null);
@@ -1620,7 +1625,8 @@ class SessionCoordinator {
} }
JellyfinPlan plan; JellyfinPlan plan;
try { try {
plan = await resolveJellyfinRef(jellyfin, ref, plan = _takeWarmJellyfin(ref) ??
await resolveJellyfinRef(jellyfin, ref,
relay: r.relay, relayUrl: _relayUrlFor(ref), memberRelayUrl: memberRelayUrl); relay: r.relay, relayUrl: _relayUrlFor(ref), memberRelayUrl: memberRelayUrl);
} finally { } finally {
if (gen == _jfGen) _opening = false; if (gen == _jfGen) _opening = false;
@@ -1656,11 +1662,27 @@ class SessionCoordinator {
case OwnPlay(:final url, :final headers): case OwnPlay(:final url, :final headers):
jellyfinBlocked.value = null; jellyfinBlocked.value = null;
if (!await _openMedia(url, headers: headers) || gen != _jfGen) return; if (!await _openMedia(url, headers: headers) || gen != _jfGen) return;
// Played before and the resume point, fresh (a warmed plan's item
// is older): the watched rule never unmarks a played item.
final known = plan.item?.userData;
final userDataF = plan.client
.userData(ref.itemId)
.timeout(const Duration(seconds: 5))
.then<JellyfinUserData?>((d) => d, onError: (_) => known);
unawaited(_findEpisodes(plan, gen));
unawaited(_loadSegments(plan, ref, gen));
// Playing carries the tracks: wait (bounded) until the file is in.
await playback.waitForTracks();
if (gen != _jfGen) return;
final before = await userDataF;
if (gen != _jfGen) return;
final reporter = JellyfinProgressReporter( final reporter = JellyfinProgressReporter(
client: plan.client, client: plan.client,
itemId: ref.itemId, itemId: ref.itemId,
source: plan.source, source: plan.source,
playSessionId: plan.info.playSessionId, playSessionId: plan.info.playSessionId,
playedBefore: before?.played ?? false,
resumeTicksBefore: before?.playbackPositionTicks ?? 0,
onUnauthorized: () => jellyfin.markExpired(ref.serverId), onUnauthorized: () => jellyfin.markExpired(ref.serverId),
onError: (e) { onError: (e) {
if (_jfToastShown) return; if (_jfToastShown) return;
@@ -1668,11 +1690,6 @@ class SessionCoordinator {
_pushToast('Jellyfin progress not saved: ${e is JellyfinException && e.isUnauthorized ? 'sign-in expired' : 'server unreachable'}'); _pushToast('Jellyfin progress not saved: ${e is JellyfinException && e.isUnauthorized ? 'sign-in expired' : 'server unreachable'}');
}, },
); );
unawaited(_findEpisodes(plan, gen));
unawaited(_loadSegments(plan, ref, gen));
// Playing carries the tracks: wait (bounded) until the file is in.
await playback.waitForTracks();
if (gen != _jfGen) return;
final snap = await _snapshot(); final snap = await _snapshot();
if (gen != _jfGen || snap == null) return; if (gen != _jfGen || snap == null) return;
// Installed once started: the per-state restart (after the end) // Installed once started: the per-state restart (after the end)
@@ -1682,6 +1699,18 @@ class SessionCoordinator {
await _applyTrackChoice(plan, gen); await _applyTrackChoice(plan, gen);
} }
/// [warmJellyfin] when it is [ref], still fresh and its account
/// unchanged.
OwnPlay? _takeWarmJellyfin(JellyfinRef ref) {
final w = warmJellyfin;
if (w == null || !sameJellyfinItem(w.ref, ref)) return null;
warmJellyfin = null;
final fresh = DateTime.now().isBefore(w.until) &&
jellyfin.account(ref.serverId)?.token == w.plan.account.token &&
jellyfin.statusOf(ref.serverId) != JellyfinAccountStatus.expired;
return fresh ? w.plan : null;
}
Future<void> _findEpisodes(OwnPlay plan, int gen) async { Future<void> _findEpisodes(OwnPlay plan, int gen) async {
final around = await findAdjacentEpisodes(plan); final around = await findAdjacentEpisodes(plan);
if (gen != _jfGen) return; if (gen != _jfGen) return;
@@ -1734,7 +1763,10 @@ class SessionCoordinator {
final snap = await _snapshot(); final snap = await _snapshot();
if (snap == null) return; if (snap == null) return;
if (reporter != null && identical(reporter, _reporter) && r.transport != 'finished') { if (reporter != null && identical(reporter, _reporter) && r.transport != 'finished') {
reporter.update(snap); final ending = playback.ending.peek()?.start;
reporter
..timeline(duration: playback.durationSecs, endingStart: ending == null ? null : ending.inMilliseconds / 1000)
..update(snap);
} }
final next = nextEpisode.peek(); final next = nextEpisode.peek();
final duration = playback.durationSecs; final duration = playback.durationSecs;
+19 -1
View File
@@ -460,7 +460,8 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
_handoffOpen = true; _handoffOpen = true;
final HandoffAction? action; final HandoffAction? action;
try { 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 { } finally {
_handoffOpen = false; _handoffOpen = false;
} }
@@ -474,6 +475,22 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
await FolderStore.instance.ensureLoaded(); await FolderStore.instance.ensureLoaded();
FolderStore.instance.addRecent(paths.first); 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): case (HandoffVideos(:final paths), HandoffAction.addFolder):
await _addFolder(File(paths.first).parent.path); await _addFolder(File(paths.first).parent.path);
case (HandoffFolder(:final path), _): case (HandoffFolder(:final path), _):
@@ -680,6 +697,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
ShellTab.schedule => SchedulePage( ShellTab.schedule => SchedulePage(
solo: alone, solo: alone,
onPutUp: _putUp, onPutUp: _putUp,
onAddToQueue: online ? coord.queue.add : null,
onToast: coord.showToast, onToast: coord.showToast,
), ),
ShellTab.settings => SettingsTab( ShellTab.settings => SettingsTab(
+16 -1
View File
@@ -105,6 +105,10 @@ enum HandoffAction {
addFolder, addFolder,
/// Open Library → Link with the URL filled. /// Open Library → Link with the URL filled.
openLink, 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); /// 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; final Handoff item;
/// Nobody else watches (offline or solo): videos play, no relay talk. /// Nobody else watches (offline or solo): videos play, no relay talk.
final bool solo; 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 @override
Widget build(BuildContext context) { 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)) key: ValueKey('handoff-${a.name}'), onPressed: () => pick(a), icon: Icon(icon, size: 18), label: Text(label))
: OutlinedButton.icon( : OutlinedButton.icon(
key: ValueKey('handoff-${a.name}'), onPressed: () => pick(a), icon: Icon(icon, size: 18), label: Text(label)); 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) { final (IconData icon, String title, String detail, List<Widget> buttons) = switch (item) {
HandoffVideos(:final paths) => ( 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', 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), 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'), 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…', button(HandoffAction.openLink, Icons.link, solo ? 'Open in Link' : 'Put it up as a link…',
primary: true), primary: true),
...queueButtons,
], ],
), ),
HandoffImage() || HandoffNothing() => (Icons.help_outline, '', '', const <Widget>[]), HandoffImage() || HandoffNothing() => (Icons.help_outline, '', '', const <Widget>[]),
+5 -20
View File
@@ -1,5 +1,4 @@
import 'dart:async'; import 'dart:async';
import 'dart:ui' show ImageFilter;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.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/health.dart';
import '../session/session_coordinator.dart'; import '../session/session_coordinator.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
import '../widgets/app_menu.dart' show AppGlass;
import 'avatar.dart'; import 'avatar.dart';
import 'motion.dart'; import 'motion.dart';
@@ -241,33 +241,18 @@ 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 { class PlayerGlass extends StatelessWidget {
final Widget child; final Widget child;
const PlayerGlass({super.key, required this.child}); const PlayerGlass({super.key, required this.child});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) => ConstrainedBox(
final r = BorderRadius.circular(999);
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560), constraints: const BoxConstraints(maxWidth: 560),
child: ClipRRect( child: AppGlass.pill(child: child),
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,
),
),
),
); );
} }
}
class _StatusLine extends StatelessWidget { class _StatusLine extends StatelessWidget {
final StatusView view; final StatusView view;
+124 -80
View File
@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart' show ValueListenable; import 'package:flutter/foundation.dart' show ValueListenable;
import 'package:flutter/gestures.dart' show kPrimaryMouseButton; 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 // does (shown on mount and on mouse movement, hidden 3 s later or on
// exit), the uosc ones reporting it. // exit), the uosc ones reporting it.
final _controlsShown = ValueNotifier(true); 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; SessionCoordinator get _coord => SessionState.instance.coordinator;
bool get _full => widget.mode == PlayerMode.full; bool get _full => widget.mode == PlayerMode.full;
@@ -124,6 +129,9 @@ class _SessionPlayerState extends State<SessionPlayer> {
_shaderNoticeSeen = n.id; _shaderNoticeSeen = n.id;
_toast(n.text); _toast(n.text);
}); });
_stopPopoverWatch = effect(() {
_hold.set(#upNextPopover, _coord.queue.popoverOpen.value);
});
} }
@override @override
@@ -131,8 +139,10 @@ class _SessionPlayerState extends State<SessionPlayer> {
HardwareKeyboard.instance.removeHandler(_globalKey); HardwareKeyboard.instance.removeHandler(_globalKey);
AppKeys.removeChatToggle(_toggleChat); AppKeys.removeChatToggle(_toggleChat);
_stopShaderWatch(); _stopShaderWatch();
_stopPopoverWatch();
_noFocus.dispose(); _noFocus.dispose();
_controlsShown.dispose(); _controlsShown.dispose();
_hold.dispose();
super.dispose(); super.dispose();
} }
@@ -291,10 +301,9 @@ class _SessionPlayerState extends State<SessionPlayer> {
children: [ children: [
video, video,
if (_full) ...[ if (_full) ...[
_status(),
_progressLine(), _progressLine(),
_jellyfinOverlay(), _blockedCard(),
_upNextChip(), _actionArea(),
if (widget.fullscreen) ...[if (!SessionState.instance.solo.value) _pausedParty(), _unreadChip()], if (widget.fullscreen) ...[if (!SessionState.instance.solo.value) _pausedParty(), _unreadChip()],
] else ] else
_miniOverlay(), _miniOverlay(),
@@ -390,6 +399,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
child: Row( child: Row(
children: [ children: [
const Expanded(child: NowPlayingTitle()), const Expanded(child: NowPlayingTitle()),
UpNextButton(queue: coord.queue, onPlayNextEpisode: coord.playNextEpisode, look: UpNextLook.standard),
MaterialDesktopCustomButton( MaterialDesktopCustomButton(
icon: const Icon(Icons.video_library_outlined), icon: const Icon(Icons.video_library_outlined),
onPressed: widget.onChangeVideo, onPressed: widget.onChangeVideo,
@@ -522,6 +532,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
material: (_) => ControlsHoverProbe( material: (_) => ControlsHoverProbe(
shown: _controlsShown, shown: _controlsShown,
hideAfter: _controlsHide, hideAfter: _controlsHide,
hold: _hold,
child: DoubleClickListener( child: DoubleClickListener(
onDoubleClick: widget.onToggleFullscreen, onDoubleClick: widget.onToggleFullscreen,
// Clicks on the bars are button clicks, not the video. // Clicks on the bars are button clicks, not the video.
@@ -553,11 +564,14 @@ class _SessionPlayerState extends State<SessionPlayer> {
}); });
return UoscControls( return UoscControls(
shown: _controlsShown, shown: _controlsShown,
holdBy: _hold,
onDoubleTap: widget.onToggleFullscreen, onDoubleTap: widget.onToggleFullscreen,
topLeft: const NowPlayingTitle(), topLeft: const NowPlayingTitle(),
// Up next is one of the top bar's controls, before chat.
top: Row( top: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
UpNextButton(queue: coord.queue, onPlayNextEpisode: coord.playNextEpisode, look: UpNextLook.uosc),
UoscButton( UoscButton(
key: const ValueKey('uosc-chat'), key: const ValueKey('uosc-chat'),
icon: widget.chatOpen ? Icons.chat_bubble_rounded : Icons.chat_bubble_outline_rounded, icon: widget.chatOpen ? Icons.chat_bubble_rounded : Icons.chat_bubble_outline_rounded,
@@ -670,20 +684,54 @@ class _SessionPlayerState extends State<SessionPlayer> {
); );
} }
/// The one status pill, bottom centre above the controls. /// The side inset every floating element keeps (the bars' own), and
Widget _status() => /// the gap between them and the controls.
const Positioned(left: 16, right: 16, bottom: 124, child: Center(child: PlayerStatus())); static const _edge = 16.0;
/// Jellyfin: the blocked card, the end-of-episode card (countdown) and /// The bottom controls' height in the design in use.
/// the "Next episode" pill; for any media the skip pill (Skip OP / ED / static double _bottomControlsHeight(bool uosc) => uosc
/// 1:30) above it. Outside the auto-hiding controls; the cards stay up, ? UoscControls.buttonRowHeight + UoscControls.rowBottomPadding + UoscTimeline.height
/// the pills show only with the controls ([PillGate]). : _barHeight + ChapterSeekBar.height;
Widget _jellyfinOverlay() {
/// 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; final coord = _coord;
return Positioned.fill( return SignalBuilder(
child: SignalBuilder(
builder: (context) { builder: (context) {
final blocked = coord.jellyfinBlocked.value;
final next = coord.nextEpisode.value; final next = coord.nextEpisode.value;
final countdown = coord.autoAdvance.value; final countdown = coord.autoAdvance.value;
// Up next goes before the next episode. // Up next goes before the next episode.
@@ -692,43 +740,26 @@ class _SessionPlayerState extends State<SessionPlayer> {
final pill = coord.nextPromptVisible.value; final pill = coord.nextPromptVisible.value;
final SkipOffer? skip = coord.skipOffer.value; final SkipOffer? skip = coord.skipOffer.value;
final showNext = next != null && pill; final showNext = next != null && pill;
return Stack( if (upNext != null && finished) {
children: [ return ControlsHoldRegion(hold: _hold, child: UpNextCard(queue: coord.queue, countdown: upNext));
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. // "Clear" in Up next hides the next episode for this media.
else if (next != null && if (next != null && (finished || countdown != null) && coord.queue.shownContinuation.value.isNotEmpty) {
(finished || countdown != null) && return ControlsHoldRegion(
coord.queue.shownContinuation.value.isNotEmpty) hold: _hold,
Positioned(
right: 24,
bottom: 138,
child: EndOfEpisodeCard( child: EndOfEpisodeCard(
next: next, next: next,
countdown: countdown, countdown: countdown,
onPlayNow: coord.playNextEpisode, onPlayNow: coord.playNextEpisode,
onCancel: coord.cancelAutoAdvance, onCancel: coord.cancelAutoAdvance,
), ),
) );
else if (showNext || skip != null) }
Positioned( if (!showNext && skip == null) return const SizedBox.shrink();
right: 24, return Column(
bottom: 124, mainAxisSize: MainAxisSize.min,
child: Column(
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
spacing: 10, spacing: 8,
children: [ children: [
if (skip != null) if (skip != null)
PillGate( PillGate(
@@ -737,6 +768,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
// Skip ED: no announcing, only with the controls. // Skip ED: no announcing, only with the controls.
announce: skip.kind != SkipKind.ending, announce: skip.kind != SkipKind.ending,
controlsShown: _controlsShown, controlsShown: _controlsShown,
hold: _hold,
child: SkipPill(offer: skip, onPressed: () => coord.skip(skip)), child: SkipPill(offer: skip, onPressed: () => coord.skip(skip)),
), ),
if (showNext) if (showNext)
@@ -744,40 +776,37 @@ class _SessionPlayerState extends State<SessionPlayer> {
key: const ValueKey('next-gate'), key: const ValueKey('next-gate'),
kind: next, kind: next,
controlsShown: _controlsShown, controlsShown: _controlsShown,
hold: _hold,
child: NextEpisodePill(episode: next, onPressed: coord.playNextEpisode), child: NextEpisodePill(episode: next, onPressed: coord.playNextEpisode),
), ),
], ],
),
),
],
); );
}, },
),
); );
} }
/// "Up next · <title>" top right, with the controls (and while its /// Jellyfin refused this viewer: the card in the middle (it stays up).
/// popover is open); under the party in fullscreen. Widget _blockedCard() => Positioned.fill(
Widget _upNextChip() { child: SignalBuilder(builder: (context) {
final queue = _coord.queue; final blocked = _coord.jellyfinBlocked.value;
return Positioned( if (blocked == null) return const SizedBox.shrink();
right: 16, return Center(
top: widget.fullscreen ? _barHeight + 60 : _topBarHeight + 8, child: ControlsHoldRegion(
child: SignalBuilder( hold: _hold,
builder: (context) => PillGate( child: JellyfinBlockedCard(
key: const ValueKey('up-next-gate'), blocked: blocked,
kind: 'up-next', onLinkAccount: widget.onOpenIntegrations,
controlsShown: queue.popoverOpen.value ? const _AlwaysOn() : _controlsShown, onRetry: _coord.retryJellyfin,
child: UpNextChip(queue: queue, onPlayNextEpisode: _coord.playNextEpisode),
), ),
), ),
); );
} }),
);
/// Fullscreen hides the strip: while paused the party stays in a corner. /// Fullscreen hides the strip: while paused the party stays in a corner.
Widget _pausedParty() => Positioned( Widget _pausedParty() => Positioned(
right: 16, right: _edge,
top: _barHeight + 8, top: _topBarHeight + 8,
child: PartyWatch(builder: (context) { child: PartyWatch(builder: (context) {
final r = _coord.serverState.value; final r = _coord.serverState.value;
final paused = r != null && r.transport != 'playing'; 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 /// glass chip says so top right (while it plays, only the controls' chat
/// button shows the count: nothing sits on the picture). /// button shows the count: nothing sits on the picture).
Widget _unreadChip() => Positioned( Widget _unreadChip() => Positioned(
right: 16, right: _edge,
top: 16, top: _edge,
child: IgnorePointer( child: IgnorePointer(
child: ValueListenableBuilder<bool>( child: ValueListenableBuilder<bool>(
valueListenable: _controlsShown, 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 /// 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 /// 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). /// [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 { class PillGate extends StatefulWidget {
static const firstShown = Duration(seconds: 5); static const firstShown = Duration(seconds: 5);
final Object kind; final Object kind;
final bool announce; final bool announce;
final ValueListenable<bool> controlsShown; final ValueListenable<bool> controlsShown;
final ControlsHold? hold;
final Widget child; final Widget child;
const PillGate({ const PillGate({
super.key, super.key,
required this.kind, required this.kind,
this.announce = false, this.announce = false,
required this.controlsShown, required this.controlsShown,
this.hold,
required this.child, required this.child,
}); });
@@ -1014,8 +1035,8 @@ class _PillGateState extends State<PillGate> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// The pill covers the controls' hover area: on it, they may hide while // The pill covers the controls' hover area: on it, [hold] keeps them
// it stays. // up (and it stays while the mouse is on it).
return ValueListenableBuilder<bool>( return ValueListenableBuilder<bool>(
valueListenable: widget.controlsShown, valueListenable: widget.controlsShown,
builder: (context, shown, child) { 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 = [ const _shaderKeys = [
LogicalKeyboardKey.digit0, LogicalKeyboardKey.digit0,
LogicalKeyboardKey.digit1, LogicalKeyboardKey.digit1,
+51 -16
View File
@@ -22,6 +22,9 @@ abstract final class AppMenuStyle {
static const border = Color(0x1FFFFFFF); static const border = Color(0x1FFFFFFF);
static const hover = Color(0x14FFFFFF); static const hover = Color(0x14FFFFFF);
static const focus = Color(0x24FFFFFF); static const focus = Color(0x24FFFFFF);
/// The player's pills over the picture: lighter, so less of it is hidden.
static const pillGlass = Color(0x8C000000);
static const blur = 16.0;
static const radius = 12.0; static const radius = 12.0;
static const rowRadius = 8.0; static const rowRadius = 8.0;
static const rowHeight = 36.0; static const rowHeight = 36.0;
@@ -53,6 +56,53 @@ abstract final class AppMenuStyle {
); );
} }
/// The app's one frosted surface: a blur under a dark tint, a hairline
/// border and (cards, menus) a soft shadow. Menus, popovers and the
/// player's cards use it as is; the player's pills with [pill].
class AppGlass extends StatelessWidget {
final Widget child;
final double radius;
final Color color;
final bool shadow;
const AppGlass({
super.key,
required this.child,
this.radius = AppMenuStyle.radius,
this.color = AppMenuStyle.glass,
this.shadow = true,
});
/// A stadium over the picture: the lighter tint, no shadow.
const AppGlass.pill({super.key, required this.child})
: radius = 999,
color = AppMenuStyle.pillGlass,
shadow = false;
@override
Widget build(BuildContext context) {
final r = BorderRadius.circular(radius);
final glass = ClipRRect(
borderRadius: r,
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: AppMenuStyle.blur, sigmaY: AppMenuStyle.blur),
child: Material(
color: color,
shape: RoundedRectangleBorder(borderRadius: r, side: const BorderSide(color: AppMenuStyle.border, width: 0.5)),
child: child,
),
),
);
if (!shadow) return glass;
return DecoratedBox(
decoration: BoxDecoration(
borderRadius: r,
boxShadow: const [BoxShadow(color: Color(0x66000000), blurRadius: 24, offset: Offset(0, 8))],
),
child: glass,
);
}
}
/// One row: [label], a dim [hint] at the right, [checked] = the current /// One row: [label], a dim [hint] at the right, [checked] = the current
/// one (null: the menu has no checks for this row). /// one (null: the menu has no checks for this row).
class AppMenuItem<T> { class AppMenuItem<T> {
@@ -220,20 +270,8 @@ class _AppMenuPanelState extends State<_AppMenuPanel> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final checks = widget.items.any((i) => i.checked != null); final checks = widget.items.any((i) => i.checked != null);
final r = BorderRadius.circular(AppMenuStyle.radius); return AppGlass(
return DecoratedBox(
decoration: BoxDecoration(
borderRadius: r,
boxShadow: const [BoxShadow(color: Color(0x66000000), blurRadius: 24, offset: Offset(0, 8))],
),
child: ClipRRect(
borderRadius: r,
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16),
child: Material(
key: const ValueKey('app-menu'), key: const ValueKey('app-menu'),
color: AppMenuStyle.glass,
shape: AppMenuStyle.shape,
child: IntrinsicWidth( child: IntrinsicWidth(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.all(4), padding: const EdgeInsets.all(4),
@@ -253,9 +291,6 @@ class _AppMenuPanelState extends State<_AppMenuPanel> {
), ),
), ),
), ),
),
),
),
); );
} }
} }
+10 -2
View File
@@ -149,6 +149,9 @@ class _FakeJellyfin {
res.statusCode = 204; res.statusCode = 204;
return res.close(); return res.close();
} }
if (path.startsWith('/UserPlayedItems/') || path.startsWith('/UserItems/')) {
return json({'Played': path.startsWith('/UserPlayedItems/'), 'PlaybackPositionTicks': 0});
}
if (RegExp(r'^/Videos/\w+/stream$').hasMatch(path)) return _stream(req); if (RegExp(r'^/Videos/\w+/stream$').hasMatch(path)) return _stream(req);
res.statusCode = 404; res.statusCode = 404;
await res.close(); await res.close();
@@ -378,8 +381,13 @@ void main() {
expect(ep1.last, 'Stopped'); expect(ep1.last, 'Stopped');
expect(ep1.where((k) => k == 'Stopped'), hasLength(1)); expect(ep1.where((k) => k == 'Stopped'), hasLength(1));
final stopped1 = aCalls.lastWhere((s) => s.body?['ItemId'] == _ep1); final stopped1 = aCalls.lastWhere((s) => s.body?['ItemId'] == _ep1);
expect(stopped1.body!['PositionTicks'], greaterThan(19 * jellyfinTicksPerSecond), // Part of it watched, then a seek to the end: not watched (see
reason: 'played to the end'); // WatchSession). A resume point capped at 85 % of the 20 s, so
// Jellyfin cannot mark it played on its own.
expect(stopped1.body!['PositionTicks'], lessThan(18 * 10000000),
reason: 'part way: resume point under 90 %, so not marked played');
expect(jelly.calls.any((c) => c.method == 'POST' && c.path == '/UserPlayedItems/$_ep1'), isFalse,
reason: 'seeking to the end is not watching');
expect(stopped1.body!['PlaySessionId'], 'ps-ua-$_ep1'); expect(stopped1.body!['PlaySessionId'], 'ps-ua-$_ep1');
expect(ep2.first, 'Playing'); expect(ep2.first, 'Playing');
expect(ep2.last, 'Stopped', reason: 'leaving reports Stopped'); expect(ep2.last, 'Stopped', reason: 'leaving reports Stopped');
+40 -2
View File
@@ -41,6 +41,10 @@ FakeJellyfin playbackFake() {
return const JellyfinHttpResponse(204, ''); return const JellyfinHttpResponse(204, '');
}); });
} }
fake.on('POST', '/UserPlayedItems/$episodeId', (_, _) {
reports.add((path: '/UserPlayedItems', body: const {}));
return const JellyfinHttpResponse(200, '{"Played":true}');
});
return fake; return fake;
} }
@@ -261,11 +265,45 @@ void main() {
'ItemId': episodeId, 'ItemId': episodeId,
'MediaSourceId': episodeId, 'MediaSourceId': episodeId,
'PlaySessionId': playSessionId, 'PlaySessionId': playSessionId,
'PositionTicks': 3005000000, 'PositionTicks': 0,
}); }, reason: '11 s watched is a peek: the resume point from before (none) stays');
expect(errors, isEmpty); expect(errors, isEmpty);
}); });
test('watched to the end: Stopped without a position, then marked played', () async {
reporter.start(const PlayerSnapshot(position: 0, paused: false));
for (var i = 1; i <= 1420; i++) {
now = DateTime(2026, 1, 1, 20).add(Duration(seconds: i));
reporter.update(PlayerSnapshot(position: i.toDouble(), paused: false));
}
await reporter.stop(const PlayerSnapshot(position: 1420, paused: true));
expect(reports.map((r) => r.path.split('/').last).toList().sublist(reports.length - 2),
['Stopped', 'UserPlayedItems']);
expect(reports[reports.length - 2].body.containsKey('PositionTicks'), isFalse);
final nearEnd = [for (final r in reports) if (r.path.endsWith('Progress')) r.body]
.where((b) => b.containsKey('PositionTicks') && b['PositionTicks'] >= 1207 * jellyfinTicksPerSecond);
expect(nearEnd, isEmpty, reason: 'near the end Progress carries no position: Jellyfin must not decide');
});
test('played before: a peek never unmarks it', () async {
final r = JellyfinProgressReporter(
client: accounts.clientFor(fixtureServerId)!,
itemId: episodeId,
source: fixtureSource(),
playSessionId: playSessionId,
playedBefore: true,
clock: () => now,
);
r.start(const PlayerSnapshot(position: 700, paused: false));
now = now.add(const Duration(seconds: 1));
r.update(const PlayerSnapshot(position: 701, paused: true));
await r.stop(const PlayerSnapshot(position: 701, paused: true));
expect(reports.map((r) => r.path.split('/').last),
['Playing', 'UserPlayedItems', 'Progress', 'Stopped', 'UserPlayedItems']);
expect(reports[2].body.containsKey('PositionTicks'), isFalse);
expect(reports[3].body.containsKey('PositionTicks'), isFalse);
});
test('played to the end, then seeked back: a fresh Playing', () async { test('played to the end, then seeked back: a fresh Playing', () async {
reporter.start(const PlayerSnapshot(position: 0, paused: false)); reporter.start(const PlayerSnapshot(position: 0, paused: false));
await reporter.stop(const PlayerSnapshot(position: 1440, paused: true)); await reporter.stop(const PlayerSnapshot(position: 1440, paused: true));
-10
View File
@@ -54,16 +54,6 @@ void main() {
expect(queueJanitor([member('m9'), member('m1', stale: true), member('m2')]), 'm2'); expect(queueJanitor([member('m9'), member('m1', stale: true), member('m2')]), 'm2');
}); });
test('warming starts in the last 90 s, once buffered to the end, or at the end', () {
bool due(double pos, {double buffered = 0, String t = 'playing'}) =>
queueWarmDue(transport: t, position: pos, duration: 1000, bufferedTo: pos + buffered);
expect(due(100), isFalse);
expect(due(911), isTrue);
expect(due(100, buffered: 900), isTrue);
expect(due(0, t: 'finished'), isTrue);
expect(queueWarmDue(transport: 'playing', position: 5, duration: 0, bufferedTo: 5), isFalse);
});
test('labels', () { test('labels', () {
expect(queueHowLabel(item(QueueHow.through), selfId: 'm2'), 'through you'); expect(queueHowLabel(item(QueueHow.through), selfId: 'm2'), 'through you');
expect(queueHowLabel(item(QueueHow.through), selfId: 'm1'), 'through Bob'); expect(queueHowLabel(item(QueueHow.through), selfId: 'm1'), 'through Bob');
+121 -41
View File
@@ -7,7 +7,7 @@ Plain overview first: [HOW-IT-WORKS.md](HOW-IT-WORKS.md). Wire format:
| Path | What | Rule | | Path | What | Rule |
|---|---|---| |---|---|---|
| `packages/core` | protocol messages, sync engine (`SyncEngine`), constants, config, Range parsing, logging, Jellyfin client, updater | pure Dart, no Flutter | | `packages/core` | protocol messages, sync engine (`SyncEngine`), constants, config, Range parsing, logging, Jellyfin client, updater, watched rule (`WatchSession`) | pure Dart, no Flutter |
| `packages/server` | Relic HTTP + WebSocket server (`SyncServer`, `ServerHub`, `Session`, media endpoint, relay buffer, Jellyfin relay), the `serve()` entrypoint, `bin/server.dart` (dedicated server) | pure Dart, no Flutter | | `packages/server` | Relic HTTP + WebSocket server (`SyncServer`, `ServerHub`, `Session`, media endpoint, relay buffer, Jellyfin relay), the `serve()` entrypoint, `bin/server.dart` (dedicated server) | pure Dart, no Flutter |
| `apps/desktop` | Flutter app: UI, `SyncClient`, `SessionCoordinator`, `PlaybackController` (media_kit/mpv), `ServerIsolate`, update UI (`UpdateController`) | ships with `watch-together-server` | | `apps/desktop` | Flutter app: UI, `SyncClient`, `SessionCoordinator`, `PlaybackController` (media_kit/mpv), `ServerIsolate`, update UI (`UpdateController`) | ships with `watch-together-server` |
| `tool` | harnesses: `range_logger`, `shaper`, `fake_viewer`, `e2e_check` | dev only | | `tool` | harnesses: `range_logger`, `shaper`, `fake_viewer`, `e2e_check` | dev only |
@@ -688,7 +688,8 @@ all three link modes. Nothing is bundled or installed by hand.
reason and the GET's HTTP status. `LinkProbe.problem`: why it will not reason and the GET's HTTP status. `LinkProbe.problem`: why it will not
play, in plain words (`playErrorText` of yt-dlp's reason, else of a 4xx play, in plain words (`playErrorText` of yt-dlp's reason, else of a 4xx
status), null for a file or a page yt-dlp read. The app probes on paste status), null for a file or a page yt-dlp read. The app probes on paste
(`AppYtDlp.probe`, remembered 10 minutes; media file names too, not a (`AppYtDlp.probe`, remembered 10 minutes, longer for Up next's first
item (see "Up next"); media file names too, not a
Jellyfin item link) for the Jellyfin item link) for the
Link preview and the swap: a page is named by its title, never its URL Link preview and the swap: a page is named by its title, never its URL
tail, and its thumbnail goes up as its art (see "Media art"). A link with a tail, and its thumbnail goes up as its art (see "Media art"). A link with a
@@ -754,13 +755,43 @@ Client side (`lib/src/jellyfin/session_media.dart`, `progress.dart`,
file is open and mpv has picked its tracks; Progress every 10 s and at file is open and mpv has picked its tracks; Progress every 10 s and at
once on pause, resume, a seek (position more than 3 s from where playback once on pause, resume, a seek (position more than 3 s from where playback
should be) or a track change; Stopped on swap, leave, sitting out, window close should be) or a track change; Stopped on swap, leave, sitting out, window close
(bounded to 1.5 s inside the graceful leave) and at `finished` (position = (bounded to 1.5 s inside the graceful leave) and at `finished`; seeking
duration, so Jellyfin marks it played); seeking back from the end sends back from the end sends Playing again. Reports carry `PositionTicks`
Playing again. Reports carry `PositionTicks`, `IsPaused` and mpv's (see "Watched rule"), `IsPaused` and mpv's `aid`/`sid` as Jellyfin
`aid`/`sid` as Jellyfin stream indices (`jellyfinIndexForMpvTrack`, stream indices (`jellyfinIndexForMpvTrack`, `SubtitleStreamIndex -1` =
`SubtitleStreamIndex -1` = off). They go out in order on one chain; off). They go out in order on one chain; failures are logged, one quiet
failures are logged, one quiet toast per session, playback is never toast per session, playback is never touched; a 401 marks the account
touched; a 401 marks the account expired and ends reporting. expired and ends reporting.
- **Watched rule** (`WatchSession` in `packages/core/lib/src/watch_session.dart`,
pure, meant for any tracker): each opening samples the position every
tick; only normal playback counts (forward, at most 2x speed + 1.5 s,
samples at most 10 s apart; seeks, skips and pauses add nothing). The
union of the watched stretches is the coverage (rewatching counts once),
alongside the plain watched time, first / last / furthest position and
start / end time. At a stop: played before this opening -> keep played;
coverage >= 80 % of the runtime, or the ending reached (furthest position
past the Outro / ED start, else the real end) with coverage >= 50 % ->
played; coverage under max(2 min, 5 %) (at most a quarter of a short
item) -> a peek; else part way, resuming at the last position. An
unknown runtime never makes it played. Jellyfin decides played from
positions alone (10.11): Playing sets a resumable item unplayed, a
Progress or Stopped position past "max resume" (90 %) marks it played,
one in between keeps a resume point. So `JellyfinProgressReporter`
(played before = `UserData.Played` read at open with
`/UserItems/{id}/UserData`):
- played (before, or by the rule at an earlier stop of this opening):
`UserPlayedItems` right after Playing, Progress without
`PositionTicks` (leaves the saved position and played state), Stopped
without `PositionTicks` (Jellyfin marks it played, position 0), then
`UserPlayedItems` again;
- peek: Stopped with the resume point from before the opening (0 when
none), so the item is as it was;
- part way: Stopped at the last position, held under 85 % of the
runtime so Jellyfin cannot mark it played;
- not played yet, any time: Progress leaves `PositionTicks` out from
85 % of the runtime on (and without a runtime), so only the rule
marks it played.
Members playing through the server report nothing.
- **Episodes**: with `OwnPlay` on an episode, `adjacentEpisodes` (one - **Episodes**: with `OwnPlay` on an episode, `adjacentEpisodes` (one
`/Shows/{id}/Episodes` call; `episodeBefore` / `episodeAfter`) gives the `/Shows/{id}/Episodes` call; `episodeBefore` / `episodeAfter`) gives the
previous and next episode. The controls show previous / next buttons previous and next episode. The controls show previous / next buttons
@@ -832,7 +863,7 @@ every `state` and feed), the pure rules in `queue_logic.dart`, the UI in
puts the first item up does, with a swap naming it (`swap.queueId`), so puts the first item up does, with a swap naming it (`swap.queueId`), so
the server takes it off in the same step and a second swap of it is the server takes it off in the same step and a second swap of it is
ignored. Cancel is per screen like the next-episode countdown. "Play ignored. Cancel is per screen like the next-episode countdown. "Play
now" anywhere (a row, the card, the chip's ▶, `>` / Shift+N) swaps now" anywhere (a row, the card, the Up next button's ⏭, `>` / Shift+N) swaps
directly from the app that can, else sends `play` and that app reacts directly from the app that can, else sends `play` and that app reacts
to `state.queuePlay`. With nothing up at all, the first item goes up as to `state.queuePlay`. With nothing up at all, the first item goes up as
soon as it is added. The swap goes through the usual paths soon as it is added. The swap goes through the usual paths
@@ -842,37 +873,52 @@ every `state` and feed), the pure rules in `queue_logic.dart`, the UI in
with the reason. A swap without `queueId` (a manual one) leaves the with the reason. A swap without `queueId` (a manual one) leaves the
queue alone. A member who puts its own link or file up while sitting queue alone. A member who puts its own link or file up while sitting
out comes back first; any other queued swap leaves the swapper out. out comes back first; any other queued swap leaves the swapper out.
- **Getting ready** (`queueWarmDue`): in the last 90 s of the current - **Getting ready** (`QueueController._warm`): the one item this app
media, once it is buffered to its end, or at `finished`, the app that puts up next is kept ready all the time, so a skip, Play now or the
will put the first item up resolves its link (`AppYtDlp.probe`, kept 10 end of the media starts it at once. That is the first Up next item on
minutes, which the swap then reads), so the swap does not wait for the app that puts it up (`queuePerformer`), else, with Up next empty,
yt-dlp. A Jellyfin item, a file or a server-fetched link has nothing to the first "Continues with" episode on an app that has it. It is
get ready there. Only the first item, only that app, nothing on the resolved as soon as it is first there (or this app becomes the one),
server. Its row shows a dot: hollow waiting, pulsing getting ready, again every 30 minutes, and dropped when it stops being first: a link
filled ready, red failed (tried again when it comes up). is probed (`AppYtDlp.probe` with `keep`: the result stays 35 minutes
instead of 10 and a refresh replaces it only once it is in; swaps read
it from there); a Jellyfin item is resolved with this app's account
(`resolveJellyfinRef`: PlaybackInfo and the item) into
`SessionCoordinator.warmJellyfin`, which `_playJellyfin` takes instead
of resolving when the same ref goes up within those 35 minutes and the
account is unchanged. A file or a server-fetched link has nothing to
get ready. One item, one app, nothing on the server. Its row shows a
dot on its picture's corner (a tooltip says which): hollow waiting, pulsing getting ready, filled ready, red failed
(tried again every 2 minutes).
- **Continues with**: not queued, computed here like the next episode: - **Continues with**: not queued, computed here like the next episode:
with an own-account Jellyfin episode playing, the next three episodes with an own-account Jellyfin episode playing, the next three episodes
of the series (`/Shows/{id}/Episodes`, cached), greyed under Up next, of the series (`/Shows/{id}/Episodes`, cached), greyed under Up next,
"+" adds one. "Clear" hides them for the media playing and keeps its "+" adds one. "Clear" hides them for the media playing and keeps its
next-episode countdown from starting. next-episode countdown from starting.
- **UI**: `UpNextChip` top right under the top bar (under the party in - **UI**: `UpNextButton` is one of the player's top-bar controls, before
fullscreen), shown with the controls like the pills (`PillGate`) and chat (see "Player overlay" under Desktop UI), in the look of the
while its popover is open: "Up next · title", a +n count, ▶ plays it design's buttons (`UpNextLook`); it shows and hides with the controls,
now; without Up next the continuation ("Next · S1E8 …", ▶ = next which an open popover holds up: "Up next · title", a +n count, ⏭ plays
it now; without Up next the continuation ("Next · S1E8 …", ⏭ = next
episode); nothing when both are empty. Its title opens the queue as a episode); nothing when both are empty. Its title opens the queue as a
`LeafPopover` (package `leaf_widgets`, an overlay rather than a route): `LeafPopover` (package `leaf_widgets`, an overlay rather than a route)
frosted glass like the app menus; rows with a 16:9 picture, title, second hanging under it, right edges aligned: the app's glass (`AppGlass`),
line, "length · added by (avatar) name · how", a drag handle the menus' paddings and row hover, 64 px rows with an 80 px 16:9
picture, title, second line, "length · added by (avatar) name · how", a drag handle
(`ReorderableListView`, reordered here at once and sent as `move`), ▶ (`ReorderableListView`, reordered here at once and sent as `move`), ▶
and × floating in on hover; "Continues with"; a "Paste a link to add" and × floating in on hover; "Continues with"; a "Paste a link to add"
field (focused on open, so the player's keys and Space rest while it field (focused on open, so the player's keys and Space rest while it
is up; Esc closes) and a drop zone for links and video files. Being no is up; Esc closes) and a drop zone for links and video files. Being no
route, it holds the shell's own drop and paste handling off while open route, it holds the shell's own drop and paste handling off while open
(`handoffHolds`). "Add to queue" elsewhere: the Link page's button (`handoffHolds`). "Add to queue" elsewhere, on every source: the Link
next to Put it up (in the chosen mode), a right-click menu on recent page's button next to Put it up (in the chosen mode), a right-click
links, Jellyfin cards, episodes and details-panel episodes, and Files `showAppMenu` (Put it up / Add to queue / Play next) on recent links,
videos (Put it up / Add to queue / Play next), and a button in the Jellyfin cards (home rows, libraries, search), series episodes and
Jellyfin details panel (right-click: play next). None of it offline. details-panel episodes, Files videos and Schedule cards that can be
put up, a button in the Jellyfin details panel (right-click: play
next), and Add to queue / Play next on the drop and paste card (videos
in order, a link in the Link page's mode). All go through
`QueueController.add`. None of it offline.
## Desktop UI ## Desktop UI
@@ -988,7 +1034,10 @@ from `AppMenuStyle.theme`; the uosc controls keep their own flat menus.
by the linked account (`jellyfinEpisodeNumbers`, one request per series by the linked account (`jellyfinEpisodeNumbers`, one request per series
memoised 3 min; `confirmOnJellyfin` is the pure step); absent reads memoised 3 min; `confirmOnJellyfin` is the pure step); absent reads
"aired · not on Jellyfin yet", unknown (no linked account, lookup "aired · not on Jellyfin yet", unknown (no linked account, lookup
failed) "aired", neither with Put it up. Requests and the owner's inbox are in failed) "aired", neither with Put it up. Put it up resolves the Jellyfin
episode (`resolveJellyfinEpisode`: AnimeFlow's numbering, else the
season and offset, else the air day) and goes through the shell's
put-up; right-click queues it the same way. Requests and the owner's inbox are in
`requests_sheet.dart`. It refreshes every minute; mouse drag and `requests_sheet.dart`. It refreshes every minute; mouse drag and
Shift+wheel scroll the board sideways, the plain wheel scrolls a column. Shift+wheel scroll the board sideways, the plain wheel scrolls a column.
Settings (`settings_tab.dart`) has a left nav with icons Settings (`settings_tab.dart`) has a left nav with icons
@@ -1008,7 +1057,8 @@ from `AppMenuStyle.theme`; the uosc controls keep their own flat menus.
`HandoffCard` opens as a popover at the pointer: a video goes up through `HandoffCard` opens as a popover at the pointer: a video goes up through
the shell's put-up (Play when alone; offline that starts solo) or adds the shell's put-up (Play when alone; offline that starts solo) or adds
its folder to Files; a folder is added to Files and opened; a link fills its folder to Files; a folder is added to Files and opened; a link fills
Library → Link (with others the relay mode is picked there). Images are ignored. Library → Link (with others the relay mode is picked there). In a
server, videos and links also get Add to queue / Play next. Images are ignored.
Nothing happens while a dialog or popover is over the shell. Nothing happens while a dialog or popover is over the shell.
- **Sounds** (`sfx/`): the coordinator plays an `SfxCue` for ready, - **Sounds** (`sfx/`): the coordinator plays an `SfxCue` for ready,
unready, force-ready, join, leave, swap and others' chat (feed unready, force-ready, join, leave, swap and others' chat (feed
@@ -1059,8 +1109,8 @@ from `AppMenuStyle.theme`; the uosc controls keep their own flat menus.
the banner (else a blur of the cover, else dark), darkened, with cover, the banner (else a blur of the cover, else dark), darkened, with cover,
big title and episode; it fades out on play and stays gone for that big title and episode; it fades out on play and stays gone for that
media; under 320 px tall (the mini) it drops the cover and shrinks the media; under 320 px tall (the mini) it drops the cover and shrinks the
title. One status pill (`player_status.dart`) sits bottom centre above title. One status pill (`player_status.dart`) sits centred in the
the controls: `playerStatusFor` (pure) picks the most pressing of action area (see "Player overlay"): `playerStatusFor` (pure) picks the most pressing of
removed, reconnecting (Cancel), a warn/error health message (Retry), removed, reconnecting (Cancel), a warn/error health message (Retry),
connecting (only after 2 s), finished, someone loading (below connecting (only after 2 s), finished, someone loading (below
`StateBroadcast.bufferNeed`, the gate's own need at the current `StateBroadcast.bufferNeed`, the gate's own need at the current
@@ -1073,10 +1123,10 @@ from `AppMenuStyle.theme`; the uosc controls keep their own flat menus.
line) and are never in "waiting for". A member's new error also toasts line) and are never in "waiting for". A member's new error also toasts
once (the pill is hidden while playing); avatars carry a red error mark once (the pill is hidden while playing); avatars carry a red error mark
and the members card says "can't play this: ...". 34 px avatars, a main line and the members card says "can't play this: ...". 34 px avatars, a main line
and a muted detail line. Frosted glass, colour only for problems; it fades through and a muted detail line. The pill glass, colour only for problems; it fades through
between kinds. The mini shows its short line in its hover bar. ⚙ in both between kinds. The mini shows its short line in its hover bar. ⚙ in both
control designs opens the player settings card (`controls/ control designs opens the player settings card (`controls/
player_settings.dart`, `showPopover`): player design, progress line player_settings.dart`, `showPopover`, on `AppGlass`): player design, progress line
when hidden, upscaling shader, hardware decoding (set live on mpv's when hidden, upscaling shader, hardware decoding (set live on mpv's
`hwdec`), subtitle and audio track. The rows (`playerSettingRows`) are `hwdec`), subtitle and audio track. The rows (`playerSettingRows`) are
the same widgets Settings → Player shows; all apply live. The progress the same widgets Settings → Player shows; all apply live. The progress
@@ -1086,6 +1136,35 @@ from `AppMenuStyle.theme`; the uosc controls keep their own flat menus.
exit), as media_kit does not expose it. Fullscreen adds the exit), as media_kit does not expose it. Fullscreen adds the
`WindowButtons` to the top bar while the controls are up (restore `WindowButtons` to the top bar while the controls are up (restore
leaves fullscreen). leaves fullscreen).
- **Player overlay** (full player, both designs; `session_player.dart`):
everything over the picture has a fixed place on one grid (16 px from
the sides, the bars' inset), so nothing floats on its own. The top
bar (part of the controls): now-playing title left; right the Up next
button (`UpNextButton`), chat, back-to-library or exit fullscreen,
then the window buttons in fullscreen, all in that design's button
look. Under it at the right, in fullscreen while paused, the party's
avatars; with the controls down and unread chat, the unread chip top
right. The action area: one band just above the bottom controls
(`_actionArea`; their height per design, 16 px gap), sliding down to
16 px from the bottom while they are hidden (`AnimatedPositioned`,
the shell's `ease` / `easeTime`); in it the status pill centred
(moved left only as far as it must to clear the right end, never
overlapping it) and at the right end the end card (`UpNextCard`, else
`EndOfEpisodeCard`, which stay up) or the action pills (Skip OP / ED /
1:30 over Next episode, `PillGate`). The Jellyfin blocked card sits in
the middle. Two surfaces: cards and popovers use `AppGlass`
(`widgets/app_menu.dart`, the menus' recipe: blur, dark tint, hairline
border, 12 px radius, shadow); pills use `PlayerGlass`
(`AppGlass.pill`: a stadium, lighter tint, no shadow): the status
pill, the unread chip and `FloatingPill` (the action pills, 44 px
tall like the status pill without avatars). The mouse on any
floating element counts as the mouse on the controls: a
`ControlsHold` (`controls/controls_probe.dart`) held by
`ControlsHoldRegion`s (and by the open Up next popover) keeps either
design's controls up (`UoscControls.holdBy`; `ControlsHoverProbe.hold`,
which re-wakes media_kit's controls through their private hover
handler, the same guarded dynamic call as `keepVideoControlsAwake`),
and lets them hide on their usual countdown once released.
- **Chat** (`chat_panel.dart`): a 340 px panel sliding in at the right - **Chat** (`chat_panel.dart`): a 340 px panel sliding in at the right
(strip button or Tab): chat with avatars; the "Activity" chip (off by (strip button or Tab): chat with avatars; the "Activity" chip (off by
default, `showActivity`, kept for the app's run only) adds the other default, `showActivity`, kept for the app's run only) adds the other
@@ -1164,7 +1243,7 @@ Session controls (the Standard player design): media_kit's
`screens/session/player_controls.dart`); its own double-click and keys are `screens/session/player_controls.dart`); its own double-click and keys are
off (its double-click pushes a fullscreen route with a second `Video`), the off (its double-click pushes a fullscreen route with a second `Video`), the
picture's double-click is a raw pointer observer (`DoubleClickListener`). picture's double-click is a raw pointer observer (`DoubleClickListener`).
The top bar holds the now-playing title, Change video, chat and back-to-library The top bar holds the now-playing title, Up next, Change video, chat and back-to-library
(fullscreen: exit, then the window buttons); the bottom bar ends with the (fullscreen: exit, then the window buttons); the bottom bar ends with the
shader button, ⚙ and fullscreen. Its seek bar is off; ours shader button, ⚙ and fullscreen. Its seek bar is off; ours
(`ChapterSeekBar`: chapter diamonds, hover time with the chapter title) (`ChapterSeekBar`: chapter diamonds, hover time with the chapter title)
@@ -1172,10 +1251,11 @@ sits in the bottom bar, which is taller by the bar's height (the top bar
keeps its row at the top). A drag sends no hover events, so the bar keeps keeps its row at the top). A drag sends no hover events, so the bar keeps
the controls up through a guarded dynamic `onHover()` on media_kit's the controls up through a guarded dynamic `onHover()` on media_kit's
private controls state (`keepVideoControlsAwake`). Pills (Skip OP / ED / private controls state (`keepVideoControlsAwake`). Pills (Skip OP / ED /
1:30, Next episode) and cards live outside the auto-hiding controls. The 1:30, Next episode) and cards live outside the auto-hiding controls, in
the action area ("Player overlay"). The
cards stay up; a pill (`PillGate`) shows only while the controls are up cards stay up; a pill (`PillGate`) shows only while the controls are up
(`ControlsHoverProbe` for this design, `UoscControls.shown` for uosc) or (`ControlsHoverProbe` for this design, `UoscControls.shown` for uosc) or
the mouse is on it, and Skip OP / Skip 1:30 also for 5 s when an offer of the mouse is on it (which holds the controls up too), and Skip OP / Skip 1:30 also for 5 s when an offer of
a new kind appears (Skip ED and Next episode never without the controls). a new kind appears (Skip ED and Next episode never without the controls).
Volume (`controls/volume.dart`, both Volume (`controls/volume.dart`, both
designs): mpv's `volume-max` is pinned to `PlaybackController.maxVolume` designs): mpv's `volume-max` is pinned to `PlaybackController.maxVolume`
@@ -1200,8 +1280,8 @@ leave),
subtitles and audio with track counts, a shader badge ("A HQ", opening the subtitles and audio with track counts, a shader badge ("A HQ", opening the
shared `showShaderMenu`), ready, mark everyone ready, reload; 1:30, shared `showShaderMenu`), ready, mark everyone ready, reload; 1:30,
chapters, previous / next episode, change video, volume, ⚙, fullscreen; the chapters, previous / next episode, change video, volume, ⚙, fullscreen; the
now-playing title top left (`UoscControls.topLeft`, with a top gradient), chat now-playing title top left (`UoscControls.topLeft`, with a top gradient), Up next, chat
and back-to-library (fullscreen: exit) top right. Menus (`showUoscMenu`: a popup route above the button, the and back-to-library (fullscreen: exit) top right, centred on the title. Menus (`showUoscMenu`: a popup route above the button, the
current row highlighted) list tracks and chapters with start times. All current row highlighted) list tracks and chapters with start times. All
actions are the coordinator's (seeks through `player.seek`, the user path; actions are the coordinator's (seeks through `player.seek`, the user path;
`selectTrack`, `skipForward`, `seekTo`); keys and the floating pills are `selectTrack`, `skipForward`, `seekTo`); keys and the floating pills are
+1
View File
@@ -13,6 +13,7 @@ export 'src/cookies.dart';
export 'src/media_source.dart'; export 'src/media_source.dart';
export 'src/sync_engine.dart'; export 'src/sync_engine.dart';
export 'src/updater.dart'; export 'src/updater.dart';
export 'src/watch_session.dart';
export 'src/jellyfin/client.dart'; export 'src/jellyfin/client.dart';
export 'src/jellyfin/client_info.dart'; export 'src/jellyfin/client_info.dart';
export 'src/jellyfin/http.dart'; export 'src/jellyfin/http.dart';
+12 -6
View File
@@ -92,7 +92,9 @@ class JellyfinPlaybackReport {
final String itemId; final String itemId;
final String mediaSourceId; final String mediaSourceId;
final String playSessionId; final String playSessionId;
final int positionTicks; /// Null leaves it out: a Progress without it does not touch the saved
/// position or played state; a Stopped without it marks the item played.
final int? positionTicks;
final bool isPaused; final bool isPaused;
/// Jellyfin stream index ([JellyfinMediaStream.index]) of the audio track. /// Jellyfin stream index ([JellyfinMediaStream.index]) of the audio track.
final int? audioStreamIndex; final int? audioStreamIndex;
@@ -136,7 +138,7 @@ class JellyfinPlaybackReport {
'ItemId': itemId, 'ItemId': itemId,
'MediaSourceId': mediaSourceId, 'MediaSourceId': mediaSourceId,
'PlaySessionId': playSessionId, 'PlaySessionId': playSessionId,
'PositionTicks': positionTicks, if (positionTicks != null) 'PositionTicks': positionTicks,
'IsPaused': isPaused, 'IsPaused': isPaused,
'CanSeek': canSeek, 'CanSeek': canSeek,
'PlayMethod': playMethod, 'PlayMethod': playMethod,
@@ -149,7 +151,7 @@ class JellyfinPlaybackReport {
'ItemId': itemId, 'ItemId': itemId,
'MediaSourceId': mediaSourceId, 'MediaSourceId': mediaSourceId,
'PlaySessionId': playSessionId, 'PlaySessionId': playSessionId,
'PositionTicks': positionTicks, if (positionTicks != null) 'PositionTicks': positionTicks,
}; };
} }
@@ -540,19 +542,23 @@ class JellyfinClient {
// ------------------------------------------------------------ progress // ------------------------------------------------------------ progress
/// `POST /Sessions/Playing`: playback started (Jellyfin shows the session /// `POST /Sessions/Playing`: playback started (Jellyfin shows the session
/// as playing and starts tracking the item). /// as playing and starts tracking the item). Up to 10.11 this also sets
/// the item unplayed (resumable items); later servers leave it.
Future<void> reportPlaybackStart(JellyfinPlaybackReport r) => Future<void> reportPlaybackStart(JellyfinPlaybackReport r) =>
_json('POST', '/Sessions/Playing', body: r.toProgressJson()); _json('POST', '/Sessions/Playing', body: r.toProgressJson());
/// `POST /Sessions/Playing/Progress`: position, pause state and tracks. /// `POST /Sessions/Playing/Progress`: position, pause state and tracks.
/// Jellyfin saves the resume position from these. /// With a position Jellyfin saves it like a stop does (below
/// "min resume" cleared, past "max resume" marked played); without one
/// the saved position and played state stay.
Future<void> reportPlaybackProgress(JellyfinPlaybackReport r) => Future<void> reportPlaybackProgress(JellyfinPlaybackReport r) =>
_json('POST', '/Sessions/Playing/Progress', body: r.toProgressJson()); _json('POST', '/Sessions/Playing/Progress', body: r.toProgressJson());
/// `POST /Sessions/Playing/Stopped`: ends the session. The server then /// `POST /Sessions/Playing/Stopped`: ends the session. The server then
/// marks the item played if the position is past its "max resume" share /// marks the item played if the position is past its "max resume" share
/// (90% by default), keeps it as a resume point between 5% and 90%, and /// (90% by default), keeps it as a resume point between 5% and 90%, and
/// clears the position below 5%. /// clears the position below 5% (the played flag stays as it is in both).
/// Without a position it marks the item played and clears the position.
Future<void> reportPlaybackStopped(JellyfinPlaybackReport r) => Future<void> reportPlaybackStopped(JellyfinPlaybackReport r) =>
_json('POST', '/Sessions/Playing/Stopped', body: r.toStoppedJson()); _json('POST', '/Sessions/Playing/Stopped', body: r.toStoppedJson());
+132
View File
@@ -0,0 +1,132 @@
import 'dart:math' as math;
// What one viewer actually watched of one opened item, and the rule that
// turns it into "watched", "barely looked" or "stopped part way". Pure, so
// any tracker (Jellyfin today) applies the same rule. See ARCHITECTURE.md
// "Watched rule".
/// What a [WatchSession] amounts to when it ends.
enum WatchOutcome {
/// Seen: mark it played and clear the resume point.
played,
/// Already played when opened (or earlier in this session): stays played
/// whatever happened, with no resume point.
keepPlayed,
/// Barely watched (a peek, a screenshot): leave the item as it was.
peek,
/// Part way: resume from [WatchVerdict.resumeAt].
partial,
}
class WatchVerdict {
final WatchOutcome outcome;
/// Where to resume, seconds ([WatchOutcome.partial] only).
final double? resumeAt;
const WatchVerdict(this.outcome, {this.resumeAt});
@override
String toString() => 'WatchVerdict(${outcome.name}${resumeAt == null ? '' : ', $resumeAt'})';
}
/// One opening of one item on this client. Fed the player position about
/// once a second ([sample]); only normal playback counts as watched: the
/// stretch between two samples while playing, when the position moved
/// forward no faster than [maxRate] (plus [slack]) and the samples are at
/// most [maxGap] apart. Seeks, skips and pauses add nothing. [covered] is the union of the watched stretches,
/// so rewatching a part does not count twice; [watched] is the plain sum.
class WatchSession {
/// Played before this opening (Jellyfin `UserData.Played`).
final bool playedBefore;
/// The resume point before this opening, seconds (0 = none).
final double resumeBefore;
final double maxRate;
final double slack;
final double maxGap;
WatchSession(
{this.playedBefore = false, this.resumeBefore = 0, this.maxRate = 2, this.slack = 1.5, this.maxGap = 10});
DateTime? startedAt;
DateTime? endedAt;
double? firstPosition;
double? lastPosition;
/// The furthest position seen, reached by playing or by a jump.
double furthest = 0;
/// Seconds of normal playback, rewatches included.
double watched = 0;
final _spans = <(double, double)>[];
({double pos, bool playing, DateTime at})? _prev;
/// Seconds of the item covered by normal playback (rewatches once).
double get covered => _spans.fold(0.0, (s, e) => s + e.$2 - e.$1);
/// The watched stretches, sorted and merged.
List<(double, double)> get spans => List.unmodifiable(_spans);
void sample(double position, {required bool playing, required DateTime at}) {
final prev = _prev;
_prev = (pos: position, playing: playing, at: at);
startedAt ??= at;
endedAt = at;
firstPosition ??= position;
lastPosition = position;
furthest = math.max(furthest, position);
if (prev == null || !prev.playing) return;
final dt = at.difference(prev.at).inMilliseconds / 1000;
final dp = position - prev.pos;
if (dt <= 0 || dt > maxGap || dp <= 0 || dp > dt * maxRate + slack) return;
watched += dp;
_add(prev.pos, position);
}
void _add(double a, double b) {
final out = <(double, double)>[];
var (lo, hi) = (a, b);
for (final (s, e) in _spans) {
if (e < lo || s > hi) {
out.add((s, e));
} else {
lo = math.min(lo, s);
hi = math.max(hi, e);
}
}
out
..add((lo, hi))
..sort((x, y) => x.$1.compareTo(y.$1));
_spans
..clear()
..addAll(out);
}
/// The rule, for an item [runtime] seconds long whose ending starts at
/// [endingStart] (an Outro segment or ED chapter; null = the real end):
/// - played before: [WatchOutcome.keepPlayed];
/// - [covered] >= 80 % of the runtime, or the ending was reached
/// ([furthest]) with [covered] >= 50 %: [WatchOutcome.played];
/// - [covered] under max(2 min, 5 %) (at most a quarter of a short
/// item): [WatchOutcome.peek];
/// - else [WatchOutcome.partial] at [lastPosition].
/// An unknown runtime (0) never makes it played.
WatchVerdict verdict({required double runtime, double? endingStart}) {
if (playedBefore) return const WatchVerdict(WatchOutcome.keepPlayed);
final c = covered;
if (runtime > 0) {
final end = endingStart != null && endingStart > 0 && endingStart < runtime ? endingStart : runtime - 2;
if (c >= 0.8 * runtime || (furthest >= end && c >= 0.5 * runtime)) {
return const WatchVerdict(WatchOutcome.played);
}
}
final peekBelow = runtime > 0 ? math.min(math.max(120.0, 0.05 * runtime), 0.25 * runtime) : 120.0;
if (c < peekBelow) return const WatchVerdict(WatchOutcome.peek);
return WatchVerdict(WatchOutcome.partial, resumeAt: lastPosition ?? 0);
}
}
@@ -0,0 +1,95 @@
import 'package:sync_relay_core/core.dart';
import 'package:test/test.dart';
/// Plays [from]..[to] at normal speed, one sample a second, starting at
/// wall second [t]; returns the wall second after.
int play(WatchSession w, int t, double from, double to) {
var pos = from;
w.sample(pos, playing: true, at: DateTime(2026, 1, 1).add(Duration(seconds: t)));
while (pos < to) {
t++;
pos = pos + 1 > to ? to : pos + 1;
w.sample(pos, playing: true, at: DateTime(2026, 1, 1).add(Duration(seconds: t)));
}
return t + 1;
}
void main() {
const ep = 1440.0; // 24 min
const ed = 1350.0; // the ending starts 1.5 min before the end
test('normal playback counts; seeks, skips and pauses do not', () {
final w = WatchSession();
var t = play(w, 0, 0, 60);
// A jump ahead between two samples: not watched.
w.sample(600, playing: true, at: DateTime(2026, 1, 1).add(Duration(seconds: t)));
t = play(w, t + 1, 600, 630);
// Paused: the position does not move, nothing counts.
w.sample(630, playing: false, at: DateTime(2026, 1, 1).add(Duration(seconds: t + 30)));
expect(w.covered, closeTo(90, 1));
expect(w.spans, hasLength(2));
expect(w.firstPosition, 0);
expect(w.lastPosition, 630);
expect(w.furthest, 630);
});
test('rewatching a part counts once in coverage, twice in watched time', () {
final w = WatchSession();
var t = play(w, 0, 0, 300);
t = play(w, t, 100, 200); // went back for a moment
expect(w.covered, closeTo(300, 1));
expect(w.watched, closeTo(400, 1));
});
test('played: 80 % covered, or the ending reached after 50 %', () {
final full = WatchSession();
play(full, 0, 0, 1200); // 83 %
expect(full.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.played);
final skipped = WatchSession();
final t = play(skipped, 0, 0, 800); // 56 %, then skipped to the ED
play(skipped, t, ed, ed + 5);
expect(skipped.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.played);
// Without a known ending only the real end counts.
expect(skipped.verdict(runtime: ep).outcome, WatchOutcome.partial);
final jumped = WatchSession();
play(jumped, 0, 0, 400); // 28 %, then jumped to the end
jumped.sample(ep, playing: false, at: DateTime(2026, 1, 2));
expect(jumped.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.partial);
});
test('played before: stays played however little was watched', () {
final w = WatchSession(playedBefore: true);
play(w, 0, 700, 730); // checking a moment
expect(w.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.keepPlayed);
});
test('a peek leaves nothing; part way resumes at the last position', () {
final peek = WatchSession(resumeBefore: 300);
play(peek, 0, 900, 990); // 90 s
expect(peek.verdict(runtime: ep).outcome, WatchOutcome.peek);
final movie = WatchSession();
play(movie, 0, 0, 300); // 5 min of 2 h: under 5 %
expect(movie.verdict(runtime: 7200).outcome, WatchOutcome.peek);
final part = WatchSession();
final t = play(part, 0, 0, 500);
part.sample(450, playing: false, at: DateTime(2026, 1, 1).add(Duration(seconds: t)));
final v = part.verdict(runtime: ep);
expect(v.outcome, WatchOutcome.partial);
expect(v.resumeAt, 450);
// A 3 min clip: half of it is not a peek.
final clip = WatchSession();
play(clip, 0, 0, 90);
expect(clip.verdict(runtime: 180).outcome, WatchOutcome.partial);
});
test('an unknown runtime never makes it played', () {
final w = WatchSession();
play(w, 0, 0, 1000);
expect(w.verdict(runtime: 0).outcome, WatchOutcome.partial);
});
}