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

@@ -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');