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:
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]),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 '';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>[]),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in new issue
Block a user