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

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

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

No files matched your search

+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
name; guests get the art too); before
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
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
@@ -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,
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
folder to Files, or fill Link).
text field, and a small card asks how to open it (put it up, add it to
Up next, add the folder to Files, or fill Link).
- **Jellyfin**: your linked servers (see below); with none linked it
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
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
for **Add to queue** / **Play next**; the Jellyfin details panel has a
queue button (right-click it to play next). A link is checked first, like
Put it up. Everyone shares one Up next and anyone can change it; what
right-click a Jellyfin card or episode, a video in Files, a recent link
or an episode on the Schedule for **Add to queue** / **Play next**; the
Jellyfin details panel has a queue button (right-click it to play next),
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.
In the player, **Up next · title** sits top right with the controls: ▶
plays it now, the title opens the queue: drag rows to reorder, hover a
In the player, **Up next · title** sits in the top bar next to the chat
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
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)
@@ -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
track count), the shader badge (the active Anime4K mode, e.g. "A HQ"),
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.
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
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
@@ -324,7 +327,10 @@ Watching:
directly with their own sign-in. Jellyfin tracks progress per account:
your watched state, Continue watching and resume point follow what you
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
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
@@ -2,16 +2,92 @@ import 'dart:async';
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,
/// 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
/// 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 {
final ValueNotifier<bool> shown;
final Duration hideAfter;
final ControlsHold? hold;
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
State<ControlsHoverProbe> createState() => _ControlsHoverProbeState();
@@ -19,22 +95,67 @@ class ControlsHoverProbe extends StatefulWidget {
class _ControlsHoverProbeState extends State<ControlsHoverProbe> {
Timer? _timer;
// While held: media_kit's own countdown is restarted before it ends.
Timer? _keep;
bool get _held => widget.hold?.held ?? false;
@override
void initState() {
super.initState();
widget.hold?.addListener(_holdChanged);
// Not during this build: the line listening to [shown] may be built.
WidgetsBinding.instance.addPostFrameCallback((_) {
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
void dispose() {
widget.hold?.removeListener(_holdChanged);
_timer?.cancel();
_keep?.cancel();
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() {
widget.shown.value = true;
_timer?.cancel();
@@ -43,7 +164,8 @@ class _ControlsHoverProbeState extends State<ControlsHoverProbe> {
void _hide() {
_timer?.cancel();
if (mounted) widget.shown.value = false;
if (!mounted || _held) return;
widget.shown.value = false;
}
@override
@@ -52,7 +174,9 @@ class _ControlsHoverProbeState extends State<ControlsHoverProbe> {
hitTestBehavior: HitTestBehavior.translucent,
onEnter: (_) => _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,
);
}
@@ -312,19 +312,18 @@ class PlayerSettingsCard extends StatelessWidget {
@override
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'),
elevation: 12,
borderRadius: BorderRadius.circular(16),
child: SizedBox(
width: 420,
child: Padding(
padding: const EdgeInsets.fromLTRB(18, 14, 18, 10),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 10),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
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),
...playerSettingRows(renderMode: renderMode),
if (tracks) ...[
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import '../../state/settings.dart';
import '../controls_probe.dart' show ControlsHold;
// 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
@@ -28,6 +29,8 @@ class UoscControls extends StatefulWidget {
final Duration hideAfter;
/// Follows whether the controls are up (for overlays outside them).
final ValueNotifier<bool>? shown;
/// Keeps them up while held (the mouse on an overlay, an open popover).
final ControlsHold? holdBy;
const UoscControls({
super.key,
required this.timeline,
@@ -38,9 +41,12 @@ class UoscControls extends StatefulWidget {
this.onDoubleTap,
this.hideAfter = const Duration(seconds: 3),
this.shown,
this.holdBy,
});
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
/// (a timeline drag, an open menu).
@@ -54,6 +60,7 @@ class UoscControls extends StatefulWidget {
class UoscControlsState extends State<UoscControls> {
bool _visible = true;
int _holds = 0;
bool _heldBy = false;
Timer? _timer;
bool get visible => _visible;
@@ -61,6 +68,7 @@ class UoscControlsState extends State<UoscControls> {
@override
void initState() {
super.initState();
widget.holdBy?.addListener(_holdByChanged);
// Not during this build: an overlay listening to [shown] may be built.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) widget.shown?.value = true;
@@ -68,12 +76,30 @@ class UoscControlsState extends State<UoscControls> {
_hideLater();
}
@override
void didUpdateWidget(UoscControls oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.holdBy != widget.holdBy) {
oldWidget.holdBy?.removeListener(_holdByChanged);
widget.holdBy?.addListener(_holdByChanged);
_holdByChanged();
}
}
@override
void dispose() {
widget.holdBy?.removeListener(_holdByChanged);
_timer?.cancel();
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.
void wake() {
if (!_visible && mounted) {
@@ -185,8 +211,9 @@ class UoscControlsState extends State<UoscControls> {
child: AnimatedOpacity(
duration: fade,
opacity: visible ? 1 : 0,
// The buttons centred on the title, like the standard
// design's top bar.
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Padding(
@@ -196,7 +223,8 @@ class UoscControlsState extends State<UoscControls> {
: 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,
opacity: visible ? 1 : 0,
child: Padding(
padding: const EdgeInsets.fromLTRB(6, 0, 6, 4),
padding: const EdgeInsets.fromLTRB(6, 0, 6, UoscControls.rowBottomPadding),
child: SizedBox(
height: UoscControls.buttonRowHeight,
child: Row(children: [...widget.left, const Spacer(), ...widget.right]),
+77 -7
View File
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.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]
/// (the session shows one quiet toast) and never touches playback. A 401
/// 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 {
final JellyfinClient client;
final String itemId;
@@ -34,20 +43,36 @@ class JellyfinProgressReporter {
final void Function(Object error)? onError;
final VoidCallback? onUnauthorized;
/// What this viewer watched of the item while it was open here.
final WatchSession watch;
JellyfinProgressReporter({
required this.client,
required this.itemId,
required this.source,
required this.playSessionId,
bool playedBefore = false,
int resumeTicksBefore = 0,
this.interval = const Duration(seconds: 10),
this.onError,
this.onUnauthorized,
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();
bool _playing = false;
bool _dead = false;
bool _keepPlayed;
double _runtime;
double? _endingStart;
PlayerSnapshot? _last;
DateTime? _lastAt;
DateTime? _lastSentAt;
@@ -55,13 +80,23 @@ class JellyfinProgressReporter {
/// Between [start] and [stop].
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
/// Jellyfin stream indices (-1 = subtitles off).
JellyfinPlaybackReport reportFor(PlayerSnapshot s) => JellyfinPlaybackReport(
JellyfinPlaybackReport reportFor(PlayerSnapshot s, {bool withPosition = true}) => JellyfinPlaybackReport(
itemId: itemId,
mediaSourceId: source.id,
playSessionId: playSessionId,
positionTicks: (s.position * jellyfinTicksPerSecond).round(),
positionTicks: withPosition ? _ticks(s.position) : null,
isPaused: s.paused,
audioStreamIndex: s.aid == null
? null
@@ -71,13 +106,24 @@ class JellyfinProgressReporter {
: 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
/// 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) {
if (_playing || _dead) return;
_playing = true;
_mark(s);
watch.sample(s.position, playing: !s.paused, at: _clock());
_send('Playing', () => client.reportPlaybackStart(reportFor(s)));
if (_keepPlayed) _send('played', () => client.markPlayed(itemId));
}
/// Progress when due (see the class doc).
@@ -85,6 +131,7 @@ class JellyfinProgressReporter {
if (!_playing || _dead) return;
final last = _last, lastAt = _lastAt, sentAt = _lastSentAt;
final now = _clock();
watch.sample(s.position, playing: !s.paused, at: now);
var due = force || last == null || lastAt == null || sentAt == null;
if (!due) {
final elapsed = now.difference(lastAt).inMilliseconds / 1000;
@@ -99,15 +146,38 @@ class JellyfinProgressReporter {
_lastAt = now;
if (!due) return;
_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.
Future<void> stop(PlayerSnapshot s) {
if (!_playing || _dead) return _chain;
_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;
}
+33 -9
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);
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();
_probes.removeWhere((_, v) => now.difference(v.$1) > _probeTtl);
_probes.removeWhere((_, v) => now.isAfter(v.until));
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));
_probes[url] = (now, f);
f.catchError((Object _) {
_probes.remove(url);
return LinkProbe.file(Uri(), -1);
});
final until = now.add(keep);
if (hit == null) {
_probes[url] = (at: now, until: until, f: f);
f.catchError((Object _) {
if (_probes[url]?.f == f) _probes.remove(url);
return LinkProbe.file(Uri(), -1);
});
} else {
f.then<void>((_) {
_probes[url] = (at: now, until: until, f: f);
}, onError: (Object _) {});
}
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
// 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
// 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:
/// no countdown here (auto-advance off, cancelled, or another app puts
@@ -62,7 +62,7 @@ class QueueController {
/// The mediaId whose continuation was cleared here.
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);
// 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;
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
@@ -474,44 +480,101 @@ class QueueController {
// ------------------------------------------------------------ warming
/// Near the end of what plays (or once it is buffered to its end), the
/// app that will put the first item up resolves its link, so the swap
/// does not wait for yt-dlp. A Jellyfin item, a file or a link the
/// server fetches has nothing to get ready here.
void _warm(StateBroadcast r) {
/// How long a warm result is kept, when it is resolved again, and when a
/// failed one is tried again.
static const _warmKeep = Duration(minutes: 35);
static const _warmRefresh = Duration(minutes: 30);
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;
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;
return;
}
final w = warm.peek();
if (w == null || w.id != head.id) warm.value = (id: head.id, state: WarmState.idle);
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);
if (t.url == null && ref == null) {
warm.value = (id: t.id, state: WarmState.ready);
return;
}
warm.value = (id: head.id, state: WarmState.warming);
// The probe is kept a few minutes: the swap reads it from there.
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);
}));
warm.value = (id: t.id, state: WarmState.warming);
_resolveWarm(t, fresh: false);
}
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
+3 -19
View File
@@ -2,9 +2,9 @@ import 'package:sync_relay_core/core.dart';
import '../jellyfin/session_media.dart' show compareMemberIds;
// Who puts an Up next item up, and when to get the first one ready. Pure:
// only what every client sees in `state` decides, so all clients agree
// without a wire message (like the Jellyfin auto-advance swapper).
// Who puts an Up next item up. Pure: only what every client sees in
// `state` decides, so all clients agree without a wire message (like the
// Jellyfin auto-advance swapper).
/// 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
@@ -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.
String queueClock(double secs) {
if (secs <= 0) return '';
+190 -179
View File
@@ -1,5 +1,4 @@
import 'dart:math' as math;
import 'dart:ui' show ImageFilter;
import 'package:flutter/material.dart';
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: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 '../jellyfin/images.dart';
import '../jellyfin/session_media.dart' show EpisodeLink;
@@ -16,65 +18,41 @@ import '../shell/avatar.dart';
import '../shell/handoff.dart' show handoffHolds;
import '../shell/motion.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_logic.dart';
// Up next in the player: the chip top right (with the controls), the
// popover it opens (the queue, the continuation, a field and a drop zone
// to add), and the end-of-media card for the first item.
// Up next in the player: its control in the top bar (with chat and
// fullscreen), the popover it opens (the queue, the continuation, a field
// and a drop zone to add), and the end-of-media card for the first item.
/// Frosted dark glass like the app's menus.
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});
// ---------------------------------------------------------------- button
@override
Widget build(BuildContext context) {
final r = BorderRadius.circular(radius);
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,
),
),
),
);
}
}
/// Which controls design the Up next control sits in: it takes the look
/// of that bar's buttons.
enum UpNextLook { standard, uosc }
// ------------------------------------------------------------------ chip
/// "Up next · <title>" with ▶ (play it now), top right while the controls
/// are up; the title opens the queue. Without Up next, the continuation
/// ("Next · S1E8 …", ▶ plays it). Nothing when both are empty.
class UpNextChip extends StatefulWidget {
/// "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
/// next, the continuation ("Next · S1E8 …", ▶ plays it). Nothing when
/// both are empty.
class UpNextButton extends StatefulWidget {
final QueueController queue;
/// Plays the continuation's first episode (the next episode).
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
State<UpNextChip> createState() => _UpNextChipState();
State<UpNextButton> createState() => _UpNextButtonState();
}
class _UpNextChipState extends State<UpNextChip> {
class _UpNextButtonState extends State<UpNextButton> {
final _popover = LeafPopoverController();
bool _hoverTitle = false;
QueueController get _q => widget.queue;
bool get _uosc => widget.look == UpNextLook.uosc;
@override
void dispose() {
@@ -102,8 +80,19 @@ class _UpNextChipState extends State<UpNextChip> {
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
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) {
final items = _q.items.value;
final cont = _q.shownContinuation.value;
@@ -112,85 +101,89 @@ class _UpNextChipState extends State<UpNextChip> {
final queued = items.isNotEmpty;
final title = queued ? items.first.title : (cont.isNotEmpty ? cont.first.label : 'Nothing yet');
final more = items.length - 1;
return _Glass(
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,
onEnter: (_) => setState(() => _hoverTitle = true),
onExit: (_) => setState(() => _hoverTitle = false),
child: GestureDetector(
key: const ValueKey('up-next-chip'),
behavior: HitTestBehavior.opaque,
onTap: _toggle,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
height: 36,
padding: const EdgeInsets.only(left: 12, right: 10),
color: _hoverTitle || open ? const Color(0x14FFFFFF) : Colors.transparent,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(queued ? Icons.queue_play_next_rounded : Icons.playlist_play_rounded,
size: 17, color: Colors.white70),
const SizedBox(width: 8),
Text(queued ? 'Up next' : 'Next',
style: const TextStyle(color: Colors.white60, fontSize: 12.5)),
const Text(' · ', style: TextStyle(color: Colors.white38, fontSize: 12.5)),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 240),
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: queued ? Colors.white : Colors.white70,
fontSize: 13,
fontWeight: FontWeight.w500),
),
),
if (more > 0) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: const Color(0x24FFFFFF), borderRadius: BorderRadius.circular(999)),
child: Text('+$more', style: const TextStyle(color: Colors.white70, fontSize: 11)),
),
],
const SizedBox(width: 4),
AnimatedRotation(
duration: easeTime,
curve: ease,
turns: open ? 0.5 : 0,
child: const Icon(Icons.expand_more_rounded, size: 18, color: Colors.white54),
),
],
),
final label = MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hoverTitle = true),
onExit: (_) => setState(() => _hoverTitle = false),
child: GestureDetector(
key: const ValueKey('up-next-button'),
behavior: HitTestBehavior.opaque,
onTap: _toggle,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
height: height,
padding: const EdgeInsets.only(left: 10, right: 6),
decoration: BoxDecoration(
color: _hoverTitle || open ? hover : Colors.transparent,
borderRadius: BorderRadius.circular(radius),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(queued ? Icons.queue_play_next_rounded : Icons.playlist_play_rounded,
size: _uosc ? 22 : 24, color: Colors.white),
const SizedBox(width: 8),
Text(queued ? 'Up next' : 'Next', style: TextStyle(color: dim, fontSize: fontSize)),
Text(' · ', style: TextStyle(color: dim, fontSize: fontSize)),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 240),
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: queued ? Colors.white : dim, fontSize: fontSize, fontWeight: FontWeight.w500),
),
),
),
if (more > 0) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(color: UoscStyle.hover, borderRadius: BorderRadius.circular(999)),
child: Text('+$more', style: TextStyle(color: dim, fontSize: 11)),
),
],
const SizedBox(width: 2),
AnimatedRotation(
duration: easeTime,
curve: ease,
turns: open ? 0.5 : 0,
child: Icon(Icons.expand_more_rounded, size: 18, color: dim),
),
],
),
if (queued || cont.isNotEmpty) ...[
Container(width: 0.5, height: 20, color: Colors.white24),
_RoundIcon(
),
),
);
final playTip = queued ? 'Play ${items.first.title} now' : 'Play ${cont.isEmpty ? '' : cont.first.label} now';
final VoidCallback? play = queued ? _q.skip : (cont.isNotEmpty ? widget.onPlayNextEpisode : null);
final playButton = _uosc
? 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'),
icon: Icons.play_arrow_rounded,
tooltip: queued ? 'Play ${items.first.title} now' : 'Play ${cont.first.label} now',
onPressed: queued ? _q.skip : widget.onPlayNextEpisode,
iconSize: 28,
color: Colors.white,
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,
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: math.max(260, math.min(600, height - 140))),
child: _Glass(
child: AppGlass(
child: AnimatedContainer(
duration: easeTime,
curve: ease,
@@ -403,13 +396,13 @@ class _QueuePanelState extends State<QueuePanel> {
? _empty()
: ListView(
shrinkWrap: true,
padding: const EdgeInsets.fromLTRB(6, 0, 6, 6),
padding: const EdgeInsets.fromLTRB(4, 0, 4, 4),
children: [
if (items.isEmpty)
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.',
style: TextStyle(color: Colors.white54, fontSize: 12.5)),
style: TextStyle(color: Colors.white54, fontSize: 12)),
)
else
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(
padding: const EdgeInsets.fromLTRB(16, 12, 8, 8),
padding: const EdgeInsets.fromLTRB(16, 8, 6, 4),
child: Row(
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) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 1),
decoration: BoxDecoration(color: const Color(0x24FFFFFF), borderRadius: BorderRadius.circular(999)),
child: Text('$count', style: const TextStyle(color: Colors.white70, fontSize: 11.5)),
decoration: BoxDecoration(color: AppMenuStyle.focus, borderRadius: BorderRadius.circular(999)),
child: Text('$count', style: const TextStyle(color: Colors.white70, fontSize: 11)),
),
],
const Spacer(),
@@ -484,13 +478,13 @@ class _QueuePanelState extends State<QueuePanel> {
children: [
Icon(Icons.queue_play_next_rounded, size: 34, color: Colors.white30),
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),
Text(
'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.',
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) => [
Padding(
padding: EdgeInsets.fromLTRB(10, alone ? 0 : 10, 2, 2),
padding: EdgeInsets.fromLTRB(12, alone ? 0 : 8, 2, 0),
child: Row(
children: [
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(),
TextButton(
key: const ValueKey('queue-clear-continuation'),
@@ -518,7 +512,7 @@ class _QueuePanelState extends State<QueuePanel> {
Widget _footer() => Container(
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(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
@@ -530,14 +524,16 @@ class _QueuePanelState extends State<QueuePanel> {
// Read-only while adding, not disabled: focus stays here, so
// Space and the arrows do not reach the player.
readOnly: _busy,
style: const TextStyle(fontSize: 13.5),
style: const TextStyle(fontSize: 14),
onSubmitted: (_) => _submit(),
// The app's field look (as `AppSelect`): a faint fill, a
// hairline, the accent while focused.
decoration: InputDecoration(
isDense: true,
filled: true,
fillColor: const Color(0x14FFFFFF),
fillColor: const Color(0x0DFFFFFF),
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),
prefixIconConstraints: const BoxConstraints(minWidth: 38),
suffixIcon: _busy
@@ -547,7 +543,14 @@ class _QueuePanelState extends State<QueuePanel> {
)
: null,
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(
@@ -557,7 +560,7 @@ class _QueuePanelState extends State<QueuePanel> {
child: _error == null
? const SizedBox(width: double.infinity)
: 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)),
),
),
@@ -629,7 +632,7 @@ class _MetaLine extends StatelessWidget {
/// Wraps onto a second line instead of cutting the end off.
final bool wrap;
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
Widget build(BuildContext context) {
@@ -637,7 +640,7 @@ class _MetaLine extends StatelessWidget {
final dur = queueClock(item.duration);
final mine = item.by == selfId && selfId.isNotEmpty;
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) {
final how = queueHowLabel(item, selfId: selfId);
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 {
final int index;
final QueueItem item;
@@ -711,8 +723,8 @@ class _QueueRowState extends State<_QueueRow> {
final item = widget.item;
final row = AnimatedContainer(
duration: const Duration(milliseconds: 120),
margin: const EdgeInsets.symmetric(vertical: 1),
padding: const EdgeInsets.fromLTRB(2, 6, 8, 6),
height: queueRowHeight,
padding: const EdgeInsets.only(right: 8),
decoration: BoxDecoration(
color: _hover ? AppMenuStyle.hover : Colors.transparent,
borderRadius: BorderRadius.circular(AppMenuStyle.rowRadius),
@@ -724,8 +736,8 @@ class _QueueRowState extends State<_QueueRow> {
child: MouseRegion(
cursor: SystemMouseCursors.grab,
child: SizedBox(
width: 22,
height: 40,
width: _lead,
height: queueRowHeight,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 120),
opacity: _hover ? 0.8 : 0.25,
@@ -734,29 +746,22 @@ class _QueueRowState extends State<_QueueRow> {
),
),
),
QueueThumb(queue: widget.queue, item: item),
const SizedBox(width: 10),
// Its readiness rides on the picture's corner.
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(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
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!)],
],
),
Text(item.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: _titleStyle),
if (item.sub.isNotEmpty)
Text(item.sub,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white60, fontSize: 11.5)),
Text(item.sub, maxLines: 1, overflow: TextOverflow.ellipsis, style: _subStyle),
const SizedBox(height: 2),
_MetaLine(item: item, members: widget.members, selfId: widget.selfId),
],
@@ -859,22 +864,30 @@ class _WarmDotState extends State<_WarmDot> with SingleTickerProviderStateMixin
Widget build(BuildContext context) {
final green = Colors.greenAccent.shade400;
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.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(
message: tip,
child: FadeTransition(
opacity: Tween(begin: 0.35, end: 1.0).animate(_pulse),
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: filled ? color : Colors.transparent,
border: Border.all(color: color, width: 1.4),
// 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(
opacity: Tween(begin: 0.35, end: 1.0).animate(_pulse),
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: filled ? color : Colors.transparent,
border: Border.all(color: color, width: 1.4),
),
),
),
),
@@ -907,7 +920,8 @@ class _ContinuationRowState extends State<_ContinuationRow> {
duration: const Duration(milliseconds: 120),
opacity: _hover ? 0.9 : 0.55,
child: Container(
padding: const EdgeInsets.fromLTRB(24, 5, 6, 5),
height: queueRowHeight,
padding: const EdgeInsets.only(left: _lead, right: 8),
decoration: BoxDecoration(
color: _hover ? AppMenuStyle.hover : Colors.transparent,
borderRadius: BorderRadius.circular(AppMenuStyle.rowRadius),
@@ -917,30 +931,27 @@ class _ContinuationRowState extends State<_ContinuationRow> {
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: SizedBox(
width: 72,
height: 40.5,
width: _thumb,
height: _thumb * 9 / 16,
child: client == null
? const ColoredBox(color: Color(0x1FFFFFFF))
: JellyfinImage(client: client, image: image, icon: Icons.tv_rounded),
),
),
const SizedBox(width: 10),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(e.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white, fontSize: 13)),
Text(e.label, maxLines: 1, overflow: TextOverflow.ellipsis, style: _titleStyle),
Text(
[if (runtime != null) queueClock(runtime.inSeconds.toDouble()), e.item.seriesName ?? '']
.where((s) => s.isNotEmpty)
.join(' · '),
maxLines: 1,
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 selfId = queue.coord.sync.memberId;
final waiting = left == null ? _waitingFor(item, r) : null;
return _Glass(
return AppGlass(
key: const ValueKey('up-next-card'),
child: SizedBox(
width: 400,
@@ -137,6 +137,9 @@ class AiringCard extends StatelessWidget {
final bool busy;
final VoidCallback onAction;
/// Right-click ([context]: the card): the page's menu; null: none.
final void Function(BuildContext context)? onMenu;
const AiringCard({
super.key,
required this.entry,
@@ -146,6 +149,7 @@ class AiringCard extends StatelessWidget {
required this.onAction,
this.disabledHint,
this.busy = false,
this.onMenu,
});
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 onMenu = this.onMenu;
final menu = onMenu == null || busy ? null : () => onMenu(context);
if (compact) {
final requestable = action == CardAction.request || action == CardAction.requestSeries;
return Material(
@@ -201,6 +207,7 @@ class AiringCard extends StatelessWidget {
child: InkWell(
borderRadius: BorderRadius.circular(10),
onTap: tap,
onSecondaryTap: menu,
child: Padding(
padding: const EdgeInsets.all(6),
child: Row(
@@ -255,6 +262,7 @@ class AiringCard extends StatelessWidget {
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: tap,
onSecondaryTap: menu,
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
@@ -6,6 +6,7 @@ import '../../jellyfin/accounts.dart';
import '../../screens/home/picked_media.dart';
import '../../shell/avatar.dart';
import '../../shell/motion.dart';
import '../../widgets/app_menu.dart';
import '../anilist.dart';
import '../animeflow.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
// 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
// minute while visible; ETags keep that cheap.
// one thing to do about it (put it up, request it; right-click an
// available one to queue it). Refreshes every minute while visible; ETags
// keep that cheap.
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.
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;
/// Null = the page makes (and closes) its own.
@@ -39,6 +44,7 @@ class SchedulePage extends StatefulWidget {
super.key,
this.solo = false,
required this.onPutUp,
this.onAddToQueue,
required this.onToast,
this.repository,
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 {
final c = _flow;
if (c == null) return;
@@ -520,6 +552,9 @@ class _SchedulePageState extends State<SchedulePage> {
disabledHint: hint,
busy: busy,
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 '../../session/session_coordinator.dart' show AutoAdvance, autoAdvanceSecs;
import '../../widgets/app_menu.dart' show AppGlass;
import 'player_controls.dart' show FloatingPill;
// Jellyfin pieces of the session player. Plain widgets fed by the
@@ -69,27 +70,36 @@ class EndOfEpisodeCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
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'),
color: Colors.black.withValues(alpha: 0.85),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 12, 12),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 380),
child: SizedBox(
width: 400,
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(c == null ? 'Up next' : 'Next episode in ${c.secondsLeft} s',
style: t.labelLarge?.copyWith(color: Colors.white70)),
const SizedBox(height: 4),
style: const TextStyle(color: Colors.white60, fontSize: 12)),
const SizedBox(height: 2),
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) ...[
const SizedBox(height: 8),
LinearProgressIndicator(value: c.secondsLeft / autoAdvanceSecs),
const SizedBox(height: 10),
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),
Row(
@@ -7,6 +7,7 @@ import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
import '../../jellyfin/track_prefs.dart' show MpvTrack;
import '../../playback/media_marks.dart';
import '../../widgets/app_menu.dart' show AppGlass;
// Session control-bar pieces built on the playback signals (chapters,
// 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):
/// faint at rest, lit on hover. Lives outside the auto-hiding controls
/// (also in fullscreen); `PillGate` shows it with them.
/// The player's action pill (Next episode, Skip OP / ED / 1:30): the
/// status pill's glass ([AppGlass.pill]) at [height], faint at rest, lit
/// 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 {
/// Every action pill's height: the status pill's without avatars.
static const height = 44.0;
final String label;
final String tooltip;
final IconData icon;
@@ -540,7 +544,7 @@ class FloatingPill extends StatefulWidget {
required this.label,
required this.tooltip,
required this.onPressed,
this.icon = Icons.chevron_right,
this.icon = Icons.chevron_right_rounded,
this.tapKey,
});
@@ -553,38 +557,31 @@ class _FloatingPillState extends State<FloatingPill> {
@override
Widget build(BuildContext context) {
final primary = Theme.of(context).colorScheme.primary;
const fade = Duration(milliseconds: 150);
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: Tooltip(
message: widget.tooltip,
child: AnimatedScale(
duration: const Duration(milliseconds: 150),
scale: _hover ? 1.06 : 1,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 150),
opacity: _hover ? 1 : 0.45,
child: GestureDetector(
key: widget.tapKey,
onTap: widget.onPressed,
child: AnimatedOpacity(
duration: fade,
opacity: _hover ? 1 : 0.45,
child: GestureDetector(
key: widget.tapKey,
onTap: widget.onPressed,
child: AppGlass.pill(
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 9),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.75),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: _hover ? primary : Colors.white24),
boxShadow: _hover
? [BoxShadow(color: primary.withValues(alpha: 0.45), blurRadius: 16)]
: const [],
),
duration: fade,
height: FloatingPill.height,
padding: const EdgeInsets.only(left: 16, right: 12),
color: _hover ? Colors.white12 : Colors.transparent,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(widget.label, style: const TextStyle(color: Colors.white, fontSize: 13)),
const SizedBox(width: 4),
Text(widget.label,
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600)),
const SizedBox(width: 6),
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
/// no relay. The session shows it with a way to link an account.
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).
final previousEpisode = signal<EpisodeLink?>(null);
final nextEpisode = signal<EpisodeLink?>(null);
@@ -1620,8 +1625,9 @@ class SessionCoordinator {
}
JellyfinPlan plan;
try {
plan = await resolveJellyfinRef(jellyfin, ref,
relay: r.relay, relayUrl: _relayUrlFor(ref), memberRelayUrl: memberRelayUrl);
plan = _takeWarmJellyfin(ref) ??
await resolveJellyfinRef(jellyfin, ref,
relay: r.relay, relayUrl: _relayUrlFor(ref), memberRelayUrl: memberRelayUrl);
} finally {
if (gen == _jfGen) _opening = false;
}
@@ -1656,11 +1662,27 @@ class SessionCoordinator {
case OwnPlay(:final url, :final headers):
jellyfinBlocked.value = null;
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(
client: plan.client,
itemId: ref.itemId,
source: plan.source,
playSessionId: plan.info.playSessionId,
playedBefore: before?.played ?? false,
resumeTicksBefore: before?.playbackPositionTicks ?? 0,
onUnauthorized: () => jellyfin.markExpired(ref.serverId),
onError: (e) {
if (_jfToastShown) return;
@@ -1668,11 +1690,6 @@ class SessionCoordinator {
_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();
if (gen != _jfGen || snap == null) return;
// Installed once started: the per-state restart (after the end)
@@ -1682,6 +1699,18 @@ class SessionCoordinator {
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 {
final around = await findAdjacentEpisodes(plan);
if (gen != _jfGen) return;
@@ -1734,7 +1763,10 @@ class SessionCoordinator {
final snap = await _snapshot();
if (snap == null) return;
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 duration = playback.durationSecs;
+19 -1
View File
@@ -460,7 +460,8 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
_handoffOpen = true;
final HandoffAction? action;
try {
action = await showPopover<HandoffAction>(context, at: align, child: HandoffCard(item: item, solo: _alone.peek()));
action = await showPopover<HandoffAction>(context,
at: align, child: HandoffCard(item: item, solo: _alone.peek(), queue: _online.peek()));
} finally {
_handoffOpen = false;
}
@@ -474,6 +475,22 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
await FolderStore.instance.ensureLoaded();
FolderStore.instance.addRecent(paths.first);
}
case (HandoffVideos(:final paths), HandoffAction.queue || HandoffAction.playNext):
// In order: with Play next, the last one added goes first.
final next = action == HandoffAction.playNext;
for (final p in next ? paths.reversed : paths) {
final error = await SessionState.instance.coordinator.queue.add(FileMedia(p), next: next);
if (error != null) {
SessionState.instance.coordinator.showToast(error);
break;
}
}
case (HandoffLink(:final url), HandoffAction.queue || HandoffAction.playNext):
// In the Link page's mode, probed and refused like there.
final coord = SessionState.instance.coordinator;
final error = await coord.queue
.add(LinkMedia(url, relay: _picker.linkRelay), next: action == HandoffAction.playNext);
if (error != null) coord.showToast(error);
case (HandoffVideos(:final paths), HandoffAction.addFolder):
await _addFolder(File(paths.first).parent.path);
case (HandoffFolder(:final path), _):
@@ -680,6 +697,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
ShellTab.schedule => SchedulePage(
solo: alone,
onPutUp: _putUp,
onAddToQueue: online ? coord.queue.add : null,
onToast: coord.showToast,
),
ShellTab.settings => SettingsTab(
+16 -1
View File
@@ -105,6 +105,10 @@ enum HandoffAction {
addFolder,
/// Open Library → Link with the URL filled.
openLink,
/// Add the videos (or the link, in the Link page's mode) to Up next.
queue,
/// The same, right after what plays.
playNext,
}
/// The card asking how to open a [Handoff] (videos, a folder or a link);
@@ -113,7 +117,9 @@ class HandoffCard extends StatelessWidget {
final Handoff item;
/// Nobody else watches (offline or solo): videos play, no relay talk.
final bool solo;
const HandoffCard({super.key, required this.item, required this.solo});
/// In a server: videos and links can go to Up next.
final bool queue;
const HandoffCard({super.key, required this.item, required this.solo, this.queue = false});
@override
Widget build(BuildContext context) {
@@ -124,6 +130,13 @@ class HandoffCard extends StatelessWidget {
key: ValueKey('handoff-${a.name}'), onPressed: () => pick(a), icon: Icon(icon, size: 18), label: Text(label))
: OutlinedButton.icon(
key: ValueKey('handoff-${a.name}'), onPressed: () => pick(a), icon: Icon(icon, size: 18), label: Text(label));
final many = switch (item) { HandoffVideos(:final paths) => paths.length > 1, _ => false };
final queueButtons = [
if (queue) ...[
button(HandoffAction.queue, Icons.playlist_add_rounded, many ? 'Add all to queue' : 'Add to queue'),
button(HandoffAction.playNext, Icons.queue_play_next_rounded, many ? 'Play all next' : 'Play next'),
],
];
final (IconData icon, String title, String detail, List<Widget> buttons) = switch (item) {
HandoffVideos(:final paths) => (
@@ -132,6 +145,7 @@ class HandoffCard extends StatelessWidget {
paths.length > 1 ? 'and ${paths.length - 1} more · the first one goes up' : 'A video on this computer',
[
button(HandoffAction.putUp, Icons.play_arrow, solo ? 'Play' : 'Put it up', primary: true),
...queueButtons,
button(HandoffAction.addFolder, Icons.create_new_folder_outlined, 'Add its folder to Files'),
],
),
@@ -148,6 +162,7 @@ class HandoffCard extends StatelessWidget {
[
button(HandoffAction.openLink, Icons.link, solo ? 'Open in Link' : 'Put it up as a link…',
primary: true),
...queueButtons,
],
),
HandoffImage() || HandoffNothing() => (Icons.help_outline, '', '', const <Widget>[]),
+7 -22
View File
@@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:ui' show ImageFilter;
import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart';
@@ -7,6 +6,7 @@ import 'package:sync_relay_core/core.dart';
import '../session/health.dart';
import '../session/session_coordinator.dart';
import '../state/session_state.dart';
import '../widgets/app_menu.dart' show AppGlass;
import 'avatar.dart';
import 'motion.dart';
@@ -241,32 +241,17 @@ class _PlayerStatusState extends State<PlayerStatus> {
}
}
/// Frosted dark glass in a pill: the status pill, the unread chip.
/// The player's pill surface (the status pill, the action pills, the
/// unread chip): the app's glass as a stadium ([AppGlass.pill]).
class PlayerGlass extends StatelessWidget {
final Widget child;
const PlayerGlass({super.key, required this.child});
@override
Widget build(BuildContext context) {
final r = BorderRadius.circular(999);
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: ClipRRect(
borderRadius: r,
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12),
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.55),
borderRadius: r,
border: Border.all(color: Colors.white12, width: 0.5),
),
child: child,
),
),
),
);
}
Widget build(BuildContext context) => ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: AppGlass.pill(child: child),
);
}
class _StatusLine extends StatelessWidget {
+164 -120
View File
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart' show ValueListenable;
import 'package:flutter/gestures.dart' show kPrimaryMouseButton;
@@ -105,6 +106,10 @@ class _SessionPlayerState extends State<SessionPlayer> {
// does (shown on mount and on mouse movement, hidden 3 s later or on
// exit), the uosc ones reporting it.
final _controlsShown = ValueNotifier(true);
// The mouse on a floating element, or the Up next popover open, keeps
// the controls up.
final _hold = ControlsHold();
late final void Function() _stopPopoverWatch;
SessionCoordinator get _coord => SessionState.instance.coordinator;
bool get _full => widget.mode == PlayerMode.full;
@@ -124,6 +129,9 @@ class _SessionPlayerState extends State<SessionPlayer> {
_shaderNoticeSeen = n.id;
_toast(n.text);
});
_stopPopoverWatch = effect(() {
_hold.set(#upNextPopover, _coord.queue.popoverOpen.value);
});
}
@override
@@ -131,8 +139,10 @@ class _SessionPlayerState extends State<SessionPlayer> {
HardwareKeyboard.instance.removeHandler(_globalKey);
AppKeys.removeChatToggle(_toggleChat);
_stopShaderWatch();
_stopPopoverWatch();
_noFocus.dispose();
_controlsShown.dispose();
_hold.dispose();
super.dispose();
}
@@ -291,10 +301,9 @@ class _SessionPlayerState extends State<SessionPlayer> {
children: [
video,
if (_full) ...[
_status(),
_progressLine(),
_jellyfinOverlay(),
_upNextChip(),
_blockedCard(),
_actionArea(),
if (widget.fullscreen) ...[if (!SessionState.instance.solo.value) _pausedParty(), _unreadChip()],
] else
_miniOverlay(),
@@ -390,6 +399,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
child: Row(
children: [
const Expanded(child: NowPlayingTitle()),
UpNextButton(queue: coord.queue, onPlayNextEpisode: coord.playNextEpisode, look: UpNextLook.standard),
MaterialDesktopCustomButton(
icon: const Icon(Icons.video_library_outlined),
onPressed: widget.onChangeVideo,
@@ -522,6 +532,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
material: (_) => ControlsHoverProbe(
shown: _controlsShown,
hideAfter: _controlsHide,
hold: _hold,
child: DoubleClickListener(
onDoubleClick: widget.onToggleFullscreen,
// Clicks on the bars are button clicks, not the video.
@@ -553,11 +564,14 @@ class _SessionPlayerState extends State<SessionPlayer> {
});
return UoscControls(
shown: _controlsShown,
holdBy: _hold,
onDoubleTap: widget.onToggleFullscreen,
topLeft: const NowPlayingTitle(),
// Up next is one of the top bar's controls, before chat.
top: Row(
mainAxisSize: MainAxisSize.min,
children: [
UpNextButton(queue: coord.queue, onPlayNextEpisode: coord.playNextEpisode, look: UpNextLook.uosc),
UoscButton(
key: const ValueKey('uosc-chat'),
icon: widget.chatOpen ? Icons.chat_bubble_rounded : Icons.chat_bubble_outline_rounded,
@@ -670,114 +684,129 @@ class _SessionPlayerState extends State<SessionPlayer> {
);
}
/// The one status pill, bottom centre above the controls.
Widget _status() =>
const Positioned(left: 16, right: 16, bottom: 124, child: Center(child: PlayerStatus()));
/// The side inset every floating element keeps (the bars' own), and
/// the gap between them and the controls.
static const _edge = 16.0;
/// Jellyfin: the blocked card, the end-of-episode card (countdown) and
/// the "Next episode" pill; for any media the skip pill (Skip OP / ED /
/// 1:30) above it. Outside the auto-hiding controls; the cards stay up,
/// the pills show only with the controls ([PillGate]).
Widget _jellyfinOverlay() {
final coord = _coord;
return Positioned.fill(
child: SignalBuilder(
builder: (context) {
final blocked = coord.jellyfinBlocked.value;
final next = coord.nextEpisode.value;
final countdown = coord.autoAdvance.value;
// Up next goes before the next episode.
final upNext = coord.queue.countdown.value;
final finished = coord.serverState.value?.transport == 'finished';
final pill = coord.nextPromptVisible.value;
final SkipOffer? skip = coord.skipOffer.value;
final showNext = next != null && pill;
return Stack(
children: [
if (blocked != null)
Center(
child: JellyfinBlockedCard(
blocked: blocked,
onLinkAccount: widget.onOpenIntegrations,
onRetry: coord.retryJellyfin,
),
),
if (upNext != null && finished)
Positioned(
right: 24,
bottom: 138,
child: UpNextCard(queue: coord.queue, countdown: upNext),
)
// "Clear" in Up next hides the next episode for this media.
else if (next != null &&
(finished || countdown != null) &&
coord.queue.shownContinuation.value.isNotEmpty)
Positioned(
right: 24,
bottom: 138,
child: EndOfEpisodeCard(
next: next,
countdown: countdown,
onPlayNow: coord.playNextEpisode,
onCancel: coord.cancelAutoAdvance,
),
)
else if (showNext || skip != null)
Positioned(
right: 24,
bottom: 124,
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
spacing: 10,
children: [
if (skip != null)
PillGate(
key: const ValueKey('skip-gate'),
kind: skip.kind,
// Skip ED: no announcing, only with the controls.
announce: skip.kind != SkipKind.ending,
controlsShown: _controlsShown,
child: SkipPill(offer: skip, onPressed: () => coord.skip(skip)),
),
if (showNext)
PillGate(
key: const ValueKey('next-gate'),
kind: next,
controlsShown: _controlsShown,
child: NextEpisodePill(episode: next, onPressed: coord.playNextEpisode),
),
],
),
),
],
);
},
),
);
}
/// The bottom controls' height in the design in use.
static double _bottomControlsHeight(bool uosc) => uosc
? UoscControls.buttonRowHeight + UoscControls.rowBottomPadding + UoscTimeline.height
: _barHeight + ChapterSeekBar.height;
/// "Up next · <title>" top right, with the controls (and while its
/// popover is open); under the party in fullscreen.
Widget _upNextChip() {
final queue = _coord.queue;
return Positioned(
right: 16,
top: widget.fullscreen ? _barHeight + 60 : _topBarHeight + 8,
child: SignalBuilder(
builder: (context) => PillGate(
key: const ValueKey('up-next-gate'),
kind: 'up-next',
controlsShown: queue.popoverOpen.value ? const _AlwaysOn() : _controlsShown,
child: UpNextChip(queue: queue, onPlayNextEpisode: _coord.playNextEpisode),
/// The action area: one band above the bottom controls for everything
/// that floats there. The status pill (the ready prompt among it)
/// centred; at the right edge the end cards, else the action pills
/// (Skip OP / ED / 1:30 over Next episode). It rides on the controls:
/// just above them while they are up, down near the edge while they are
/// hidden. The mouse on any of it counts as the mouse on the controls
/// ([ControlsHold]).
Widget _actionArea() {
return SignalBuilder(builder: (context) {
final controls = _bottomControlsHeight(AppSettings.instance.mpvControls.value);
return ValueListenableBuilder<bool>(
valueListenable: _controlsShown,
builder: (context, shown, child) => AnimatedPositioned(
duration: easeTime,
curve: ease,
left: _edge,
right: _edge,
top: 0,
bottom: shown ? controls + _edge : _edge,
child: child!,
),
),
child: CustomMultiChildLayout(
delegate: _ActionAreaLayout(gap: _edge),
children: [
LayoutId(id: _ActionSlot.status, child: ControlsHoldRegion(hold: _hold, child: const PlayerStatus())),
LayoutId(id: _ActionSlot.side, child: _actionSide()),
],
),
);
});
}
/// The action area's right end: the end-of-media card (Up next, else the
/// next episode; it stays up), else the pills, shown only with the
/// controls ([PillGate]).
Widget _actionSide() {
final coord = _coord;
return SignalBuilder(
builder: (context) {
final next = coord.nextEpisode.value;
final countdown = coord.autoAdvance.value;
// Up next goes before the next episode.
final upNext = coord.queue.countdown.value;
final finished = coord.serverState.value?.transport == 'finished';
final pill = coord.nextPromptVisible.value;
final SkipOffer? skip = coord.skipOffer.value;
final showNext = next != null && pill;
if (upNext != null && finished) {
return ControlsHoldRegion(hold: _hold, child: UpNextCard(queue: coord.queue, countdown: upNext));
}
// "Clear" in Up next hides the next episode for this media.
if (next != null && (finished || countdown != null) && coord.queue.shownContinuation.value.isNotEmpty) {
return ControlsHoldRegion(
hold: _hold,
child: EndOfEpisodeCard(
next: next,
countdown: countdown,
onPlayNow: coord.playNextEpisode,
onCancel: coord.cancelAutoAdvance,
),
);
}
if (!showNext && skip == null) return const SizedBox.shrink();
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
spacing: 8,
children: [
if (skip != null)
PillGate(
key: const ValueKey('skip-gate'),
kind: skip.kind,
// Skip ED: no announcing, only with the controls.
announce: skip.kind != SkipKind.ending,
controlsShown: _controlsShown,
hold: _hold,
child: SkipPill(offer: skip, onPressed: () => coord.skip(skip)),
),
if (showNext)
PillGate(
key: const ValueKey('next-gate'),
kind: next,
controlsShown: _controlsShown,
hold: _hold,
child: NextEpisodePill(episode: next, onPressed: coord.playNextEpisode),
),
],
);
},
);
}
/// Jellyfin refused this viewer: the card in the middle (it stays up).
Widget _blockedCard() => Positioned.fill(
child: SignalBuilder(builder: (context) {
final blocked = _coord.jellyfinBlocked.value;
if (blocked == null) return const SizedBox.shrink();
return Center(
child: ControlsHoldRegion(
hold: _hold,
child: JellyfinBlockedCard(
blocked: blocked,
onLinkAccount: widget.onOpenIntegrations,
onRetry: _coord.retryJellyfin,
),
),
);
}),
);
/// Fullscreen hides the strip: while paused the party stays in a corner.
Widget _pausedParty() => Positioned(
right: 16,
top: _barHeight + 8,
right: _edge,
top: _topBarHeight + 8,
child: PartyWatch(builder: (context) {
final r = _coord.serverState.value;
final paused = r != null && r.transport != 'playing';
@@ -795,8 +824,8 @@ class _SessionPlayerState extends State<SessionPlayer> {
/// glass chip says so top right (while it plays, only the controls' chat
/// button shows the count: nothing sits on the picture).
Widget _unreadChip() => Positioned(
right: 16,
top: 16,
right: _edge,
top: _edge,
child: IgnorePointer(
child: ValueListenableBuilder<bool>(
valueListenable: _controlsShown,
@@ -945,32 +974,24 @@ class _SessionPlayerState extends State<SessionPlayer> {
}
}
/// Always up (a pill whose popover is open).
class _AlwaysOn implements ValueListenable<bool> {
const _AlwaysOn();
@override
bool get value => true;
@override
void addListener(VoidCallback listener) {}
@override
void removeListener(VoidCallback listener) {}
}
/// A floating pill's visibility: only while the controls are up (the
/// mouse moved) or the mouse is on it. With [announce] it is also up for
/// [firstShown] whenever [kind] changes (a new offer: Skip OP, Skip 1:30).
/// Gone with the offer (the parent drops it).
/// Gone with the offer (the parent drops it). The mouse on it holds
/// [hold]: it counts as the mouse on the controls.
class PillGate extends StatefulWidget {
static const firstShown = Duration(seconds: 5);
final Object kind;
final bool announce;
final ValueListenable<bool> controlsShown;
final ControlsHold? hold;
final Widget child;
const PillGate({
super.key,
required this.kind,
this.announce = false,
required this.controlsShown,
this.hold,
required this.child,
});
@@ -1014,8 +1035,8 @@ class _PillGateState extends State<PillGate> {
@override
Widget build(BuildContext context) {
// The pill covers the controls' hover area: on it, they may hide while
// it stays.
// The pill covers the controls' hover area: on it, [hold] keeps them
// up (and it stays while the mouse is on it).
return ValueListenableBuilder<bool>(
valueListenable: widget.controlsShown,
builder: (context, shown, child) {
@@ -1029,11 +1050,34 @@ class _PillGateState extends State<PillGate> {
),
);
},
child: widget.child,
child: ControlsHoldRegion(hold: widget.hold, child: widget.child),
);
}
}
enum _ActionSlot { status, side }
/// The action area's two slots, at its bottom: [_ActionSlot.side] at the
/// right end, [_ActionSlot.status] centred, moved left only as far as it
/// must to keep [gap] from the side (and never wider than what is left).
class _ActionAreaLayout extends MultiChildLayoutDelegate {
final double gap;
_ActionAreaLayout({required this.gap});
@override
void performLayout(Size size) {
final side = layoutChild(_ActionSlot.side, BoxConstraints.loose(size));
positionChild(_ActionSlot.side, Offset(size.width - side.width, size.height - side.height));
final room = side.width > 0 ? size.width - side.width - gap : size.width;
final status = layoutChild(_ActionSlot.status, BoxConstraints.loose(Size(math.max(0, room), size.height)));
final x = math.min((size.width - status.width) / 2, room - status.width);
positionChild(_ActionSlot.status, Offset(math.max(0, x), size.height - status.height));
}
@override
bool shouldRelayout(_ActionAreaLayout old) => old.gap != gap;
}
const _shaderKeys = [
LogicalKeyboardKey.digit0,
LogicalKeyboardKey.digit1,
+67 -32
View File
@@ -22,6 +22,9 @@ abstract final class AppMenuStyle {
static const border = Color(0x1FFFFFFF);
static const hover = Color(0x14FFFFFF);
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 rowRadius = 8.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 (null: the menu has no checks for this row).
class AppMenuItem<T> {
@@ -220,39 +270,24 @@ class _AppMenuPanelState extends State<_AppMenuPanel> {
@override
Widget build(BuildContext context) {
final checks = widget.items.any((i) => i.checked != null);
final r = BorderRadius.circular(AppMenuStyle.radius);
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'),
color: AppMenuStyle.glass,
shape: AppMenuStyle.shape,
child: IntrinsicWidth(
child: SingleChildScrollView(
padding: const EdgeInsets.all(4),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final (i, item) in widget.items.indexed)
_MenuRow(
key: i == _start ? _current : null,
item: item,
checks: checks,
autofocus: i == _start,
onTap: () => widget.onPick(i),
),
],
return AppGlass(
key: const ValueKey('app-menu'),
child: IntrinsicWidth(
child: SingleChildScrollView(
padding: const EdgeInsets.all(4),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final (i, item) in widget.items.indexed)
_MenuRow(
key: i == _start ? _current : null,
item: item,
checks: checks,
autofocus: i == _start,
onTap: () => widget.onPick(i),
),
),
),
],
),
),
),
+10 -2
View File
@@ -149,6 +149,9 @@ class _FakeJellyfin {
res.statusCode = 204;
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);
res.statusCode = 404;
await res.close();
@@ -378,8 +381,13 @@ void main() {
expect(ep1.last, 'Stopped');
expect(ep1.where((k) => k == 'Stopped'), hasLength(1));
final stopped1 = aCalls.lastWhere((s) => s.body?['ItemId'] == _ep1);
expect(stopped1.body!['PositionTicks'], greaterThan(19 * jellyfinTicksPerSecond),
reason: 'played to the end');
// Part of it watched, then a seek to the end: not watched (see
// 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(ep2.first, 'Playing');
expect(ep2.last, 'Stopped', reason: 'leaving reports Stopped');
+40 -2
View File
@@ -41,6 +41,10 @@ FakeJellyfin playbackFake() {
return const JellyfinHttpResponse(204, '');
});
}
fake.on('POST', '/UserPlayedItems/$episodeId', (_, _) {
reports.add((path: '/UserPlayedItems', body: const {}));
return const JellyfinHttpResponse(200, '{"Played":true}');
});
return fake;
}
@@ -261,11 +265,45 @@ void main() {
'ItemId': episodeId,
'MediaSourceId': episodeId,
'PlaySessionId': playSessionId,
'PositionTicks': 3005000000,
});
'PositionTicks': 0,
}, reason: '11 s watched is a peek: the resume point from before (none) stays');
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 {
reporter.start(const PlayerSnapshot(position: 0, paused: false));
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');
});
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', () {
expect(queueHowLabel(item(QueueHow.through), selfId: 'm2'), 'through you');
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 |
|---|---|---|
| `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 |
| `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 |
@@ -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
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
(`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
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
@@ -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
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
(bounded to 1.5 s inside the graceful leave) and at `finished` (position =
duration, so Jellyfin marks it played); seeking back from the end sends
Playing again. Reports carry `PositionTicks`, `IsPaused` and mpv's
`aid`/`sid` as Jellyfin stream indices (`jellyfinIndexForMpvTrack`,
`SubtitleStreamIndex -1` = off). They go out in order on one chain;
failures are logged, one quiet toast per session, playback is never
touched; a 401 marks the account expired and ends reporting.
(bounded to 1.5 s inside the graceful leave) and at `finished`; seeking
back from the end sends Playing again. Reports carry `PositionTicks`
(see "Watched rule"), `IsPaused` and mpv's `aid`/`sid` as Jellyfin
stream indices (`jellyfinIndexForMpvTrack`, `SubtitleStreamIndex -1` =
off). They go out in order on one chain; failures are logged, one quiet
toast per session, playback is never touched; a 401 marks the account
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
`/Shows/{id}/Episodes` call; `episodeBefore` / `episodeAfter`) gives the
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
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
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
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
@@ -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
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.
- **Getting ready** (`queueWarmDue`): in the last 90 s of the current
media, once it is buffered to its end, or at `finished`, the app that
will put the first item up resolves its link (`AppYtDlp.probe`, kept 10
minutes, which the swap then reads), so the swap does not wait for
yt-dlp. A Jellyfin item, a file or a server-fetched link has nothing to
get ready there. Only the first item, only that app, nothing on the
server. Its row shows a dot: hollow waiting, pulsing getting ready,
filled ready, red failed (tried again when it comes up).
- **Getting ready** (`QueueController._warm`): the one item this app
puts up next is kept ready all the time, so a skip, Play now or the
end of the media starts it at once. That is the first Up next item on
the app that puts it up (`queuePerformer`), else, with Up next empty,
the first "Continues with" episode on an app that has it. It is
resolved as soon as it is first there (or this app becomes the one),
again every 30 minutes, and dropped when it stops being first: a link
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:
with an own-account Jellyfin episode playing, the next three episodes
of the series (`/Shows/{id}/Episodes`, cached), greyed under Up next,
"+" adds one. "Clear" hides them for the media playing and keeps its
next-episode countdown from starting.
- **UI**: `UpNextChip` top right under the top bar (under the party in
fullscreen), shown with the controls like the pills (`PillGate`) and
while its popover is open: "Up next · title", a +n count, ▶ plays it
now; without Up next the continuation ("Next · S1E8 …", ▶ = next
- **UI**: `UpNextButton` is one of the player's top-bar controls, before
chat (see "Player overlay" under Desktop UI), in the look of the
design's buttons (`UpNextLook`); it shows and hides with the controls,
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
`LeafPopover` (package `leaf_widgets`, an overlay rather than a route):
frosted glass like the app menus; rows with a 16:9 picture, title, second
line, "length · added by (avatar) name · how", a drag handle
`LeafPopover` (package `leaf_widgets`, an overlay rather than a route)
hanging under it, right edges aligned: the app's glass (`AppGlass`),
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`), ▶
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
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
(`handoffHolds`). "Add to queue" elsewhere: the Link page's button
next to Put it up (in the chosen mode), a right-click menu on recent
links, Jellyfin cards, episodes and details-panel episodes, and Files
videos (Put it up / Add to queue / Play next), and a button in the
Jellyfin details panel (right-click: play next). None of it offline.
(`handoffHolds`). "Add to queue" elsewhere, on every source: the Link
page's button next to Put it up (in the chosen mode), a right-click
`showAppMenu` (Put it up / Add to queue / Play next) on recent links,
Jellyfin cards (home rows, libraries, search), series episodes and
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
@@ -988,7 +1034,10 @@ from `AppMenuStyle.theme`; the uosc controls keep their own flat menus.
by the linked account (`jellyfinEpisodeNumbers`, one request per series
memoised 3 min; `confirmOnJellyfin` is the pure step); absent reads
"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
Shift+wheel scroll the board sideways, the plain wheel scrolls a column.
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
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
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.
- **Sounds** (`sfx/`): the coordinator plays an `SfxCue` for ready,
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,
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
title. One status pill (`player_status.dart`) sits bottom centre above
the controls: `playerStatusFor` (pure) picks the most pressing of
title. One status pill (`player_status.dart`) sits centred in the
action area (see "Player overlay"): `playerStatusFor` (pure) picks the most pressing of
removed, reconnecting (Cancel), a warn/error health message (Retry),
connecting (only after 2 s), finished, someone loading (below
`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
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 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
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
`hwdec`), subtitle and audio track. The rows (`playerSettingRows`) are
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
`WindowButtons` to the top bar while the controls are up (restore
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
(strip button or Tab): chat with avatars; the "Activity" chip (off by
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
off (its double-click pushes a fullscreen route with a second `Video`), the
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
shader button, ⚙ and fullscreen. Its seek bar is off; ours
(`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
the controls up through a guarded dynamic `onHover()` on media_kit's
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
(`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).
Volume (`controls/volume.dart`, both
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
shared `showShaderMenu`), ready, mark everyone ready, reload; 1:30,
chapters, previous / next episode, change video, volume, ⚙, fullscreen; the
now-playing title top left (`UoscControls.topLeft`, with a top gradient), chat
and back-to-library (fullscreen: exit) top right. Menus (`showUoscMenu`: a popup route above the button, the
now-playing title top left (`UoscControls.topLeft`, with a top gradient), Up next, chat
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
actions are the coordinator's (seeks through `player.seek`, the user path;
`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/sync_engine.dart';
export 'src/updater.dart';
export 'src/watch_session.dart';
export 'src/jellyfin/client.dart';
export 'src/jellyfin/client_info.dart';
export 'src/jellyfin/http.dart';
+12 -6
View File
@@ -92,7 +92,9 @@ class JellyfinPlaybackReport {
final String itemId;
final String mediaSourceId;
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;
/// Jellyfin stream index ([JellyfinMediaStream.index]) of the audio track.
final int? audioStreamIndex;
@@ -136,7 +138,7 @@ class JellyfinPlaybackReport {
'ItemId': itemId,
'MediaSourceId': mediaSourceId,
'PlaySessionId': playSessionId,
'PositionTicks': positionTicks,
if (positionTicks != null) 'PositionTicks': positionTicks,
'IsPaused': isPaused,
'CanSeek': canSeek,
'PlayMethod': playMethod,
@@ -149,7 +151,7 @@ class JellyfinPlaybackReport {
'ItemId': itemId,
'MediaSourceId': mediaSourceId,
'PlaySessionId': playSessionId,
'PositionTicks': positionTicks,
if (positionTicks != null) 'PositionTicks': positionTicks,
};
}
@@ -540,19 +542,23 @@ class JellyfinClient {
// ------------------------------------------------------------ progress
/// `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) =>
_json('POST', '/Sessions/Playing', body: r.toProgressJson());
/// `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) =>
_json('POST', '/Sessions/Playing/Progress', body: r.toProgressJson());
/// `POST /Sessions/Playing/Stopped`: ends the session. The server then
/// 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
/// 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) =>
_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);
});
}