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
+1285
-447
No files matched your search
@@ -84,7 +84,7 @@ bar while its controls are up), Esc leaves it. The controls show the
|
||||
show's cover, title and episode top left (a web video: its thumbnail and
|
||||
name; guests get the art too); before
|
||||
a video first plays, its banner fills the picture until playback starts.
|
||||
One pill above the controls says what playback waits for ("Press Space
|
||||
One pill bottom centre, just above the controls, says what playback waits for ("Press Space
|
||||
when you're ready", "Waiting for Bob and Mika · 2/3 ready", "Bob is
|
||||
loading…") or what went wrong, with Retry or Cancel when it helps. Chat and activity open in a panel on the right (chat button or
|
||||
Tab), ready to type; it shows messages, and its "Activity" toggle adds
|
||||
@@ -127,8 +127,8 @@ disk. Nothing is uploaded or stored anywhere.
|
||||
|
||||
What to watch, from three sources (the switch at the top). Drop a video,
|
||||
a folder or a link anywhere on the window, or press Ctrl+V outside a
|
||||
text field, and a small card asks how to open it (put it up, add the
|
||||
folder to Files, or fill Link).
|
||||
text field, and a small card asks how to open it (put it up, add it to
|
||||
Up next, add the folder to Files, or fill Link).
|
||||
|
||||
- **Jellyfin**: your linked servers (see below); with none linked it
|
||||
points to Files and Link and offers Jellyfin as optional. Library chips beside the
|
||||
@@ -162,14 +162,16 @@ and it plays for you alone ([Watch solo](#getting-started)).
|
||||
|
||||
In a server (solo too), anything can wait its turn instead of going up
|
||||
now: **Add to queue** on the Link page (in the way you picked), or
|
||||
right-click a Jellyfin card or episode, a video in Files or a recent link
|
||||
for **Add to queue** / **Play next**; the Jellyfin details panel has a
|
||||
queue button (right-click it to play next). A link is checked first, like
|
||||
Put it up. Everyone shares one Up next and anyone can change it; what
|
||||
right-click a Jellyfin card or episode, a video in Files, a recent link
|
||||
or an episode on the Schedule for **Add to queue** / **Play next**; the
|
||||
Jellyfin details panel has a queue button (right-click it to play next),
|
||||
and the card for a dropped or pasted video or link has both. A link is
|
||||
checked first, like Put it up. The next item is kept ready on the app
|
||||
that will put it up, so skipping to it starts at once. Everyone shares one Up next and anyone can change it; what
|
||||
you added stays when you leave.
|
||||
|
||||
In the player, **Up next · title** sits top right with the controls: ▶
|
||||
plays it now, the title opens the queue: drag rows to reorder, hover a
|
||||
In the player, **Up next · title** sits in the top bar next to the chat
|
||||
button: ⏭ plays it now, the title opens the queue: drag rows to reorder, hover a
|
||||
row to play it now (▶) or remove it (×), paste a link in the field or
|
||||
drop a link or a video on it to add. When a video ends, a card shows
|
||||
what is up next with a 10 s countdown (Cancel stops it on your screen)
|
||||
@@ -269,10 +271,11 @@ chapter title and the time there. With the controls hidden it stays as a
|
||||
thin line (unless "Progress line when hidden" is off). Above it flat buttons: ≡ menu, Subtitles and Audio (with the
|
||||
track count), the shader badge (the active Anime4K mode, e.g. "A HQ"),
|
||||
ready, chapters, 1:30, previous / next episode, change video, volume, ⚙
|
||||
and fullscreen; the title top left, chat and back-to-library top right. Keys and skip buttons work
|
||||
and fullscreen; the title top left, Up next, chat and back-to-library top right. Keys and skip buttons work
|
||||
the same.
|
||||
|
||||
Skipping: during the opening a "Skip OP" button floats in the corner (it
|
||||
Skipping: during the opening a "Skip OP" button shows bottom right, just
|
||||
above the controls (it
|
||||
jumps to the end of the opening), during the ending "Skip ED" (to the end
|
||||
of the ending only; what comes after is yours to skip). "Skip OP" and
|
||||
"Skip 1:30" show for 5 seconds when they come up, then only while the
|
||||
@@ -324,7 +327,10 @@ Watching:
|
||||
directly with their own sign-in. Jellyfin tracks progress per account:
|
||||
your watched state, Continue watching and resume point follow what you
|
||||
saw (reported on start, every 10 s, on pause, resume, seek and track
|
||||
change, and when it stops).
|
||||
change, and when it stops). Watched means you actually played most of
|
||||
it (or reached the ending after half), not where you stopped: opening
|
||||
a watched episode to check a moment keeps it watched, and a quick peek
|
||||
leaves no resume point.
|
||||
- Friends without an account watch through the server: whoever put the
|
||||
item up passes it on automatically, with their own sign-in, which never
|
||||
leaves their computer (only the current item, only to people in the
|
||||
|
||||
@@ -2,16 +2,92 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// What keeps the player's controls up besides the mouse on them: the
|
||||
/// mouse on a floating element over the picture (a pill, a card, the
|
||||
/// status pill: [ControlsHoldRegion]) or an open popover. While anything
|
||||
/// holds, the controls stay up; on release they hide on their usual
|
||||
/// countdown. Both designs follow it ([ControlsHoverProbe] with
|
||||
/// media_kit's controls, `UoscControls`).
|
||||
class ControlsHold extends ChangeNotifier {
|
||||
final _by = <Object>{};
|
||||
bool _told = false;
|
||||
bool _queued = false;
|
||||
bool _disposed = false;
|
||||
|
||||
bool get held => _told;
|
||||
|
||||
/// [who] holds ([on]) or lets go. Listeners hear it in a microtask, never
|
||||
/// during a build or a dispose.
|
||||
void set(Object who, bool on) {
|
||||
if (on ? !_by.add(who) : !_by.remove(who)) return;
|
||||
if (_queued) return;
|
||||
_queued = true;
|
||||
scheduleMicrotask(() {
|
||||
_queued = false;
|
||||
if (_disposed) return;
|
||||
final now = _by.isNotEmpty;
|
||||
if (now == _told) return;
|
||||
_told = now;
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// The mouse on [child] counts as the mouse on the controls: it holds
|
||||
/// [hold] while it is there.
|
||||
class ControlsHoldRegion extends StatefulWidget {
|
||||
final ControlsHold? hold;
|
||||
final Widget child;
|
||||
const ControlsHoldRegion({super.key, required this.hold, required this.child});
|
||||
|
||||
@override
|
||||
State<ControlsHoldRegion> createState() => _ControlsHoldRegionState();
|
||||
}
|
||||
|
||||
class _ControlsHoldRegionState extends State<ControlsHoldRegion> {
|
||||
@override
|
||||
void didUpdateWidget(ControlsHoldRegion oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.hold != widget.hold) oldWidget.hold?.set(this, false);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.hold?.set(this, false);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hold = widget.hold;
|
||||
if (hold == null) return widget.child;
|
||||
return MouseRegion(
|
||||
onEnter: (_) => hold.set(this, true),
|
||||
onExit: (_) => hold.set(this, false),
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Follows whether media_kit's desktop controls around [child] are up,
|
||||
/// by the same rule they use: up on mount and on any mouse movement,
|
||||
/// down [hideAfter] later or when the mouse leaves. media_kit does not
|
||||
/// expose its visibility; this lets the player draw a progress line while
|
||||
/// they are down. Never takes input.
|
||||
/// they are down. With [hold] held it keeps them (and itself) up. Never
|
||||
/// takes input.
|
||||
class ControlsHoverProbe extends StatefulWidget {
|
||||
final ValueNotifier<bool> shown;
|
||||
final Duration hideAfter;
|
||||
final ControlsHold? hold;
|
||||
final Widget child;
|
||||
const ControlsHoverProbe({super.key, required this.shown, required this.hideAfter, required this.child});
|
||||
const ControlsHoverProbe(
|
||||
{super.key, required this.shown, required this.hideAfter, this.hold, required this.child});
|
||||
|
||||
@override
|
||||
State<ControlsHoverProbe> createState() => _ControlsHoverProbeState();
|
||||
@@ -19,22 +95,67 @@ class ControlsHoverProbe extends StatefulWidget {
|
||||
|
||||
class _ControlsHoverProbeState extends State<ControlsHoverProbe> {
|
||||
Timer? _timer;
|
||||
// While held: media_kit's own countdown is restarted before it ends.
|
||||
Timer? _keep;
|
||||
|
||||
bool get _held => widget.hold?.held ?? false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.hold?.addListener(_holdChanged);
|
||||
// Not during this build: the line listening to [shown] may be built.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _wake();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ControlsHoverProbe oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.hold != widget.hold) {
|
||||
oldWidget.hold?.removeListener(_holdChanged);
|
||||
widget.hold?.addListener(_holdChanged);
|
||||
_holdChanged();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.hold?.removeListener(_holdChanged);
|
||||
_timer?.cancel();
|
||||
_keep?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _holdChanged() {
|
||||
if (!mounted) return;
|
||||
_keep?.cancel();
|
||||
_keep = null;
|
||||
if (_held) {
|
||||
_keep = Timer.periodic(widget.hideAfter * 0.5, (_) => _pokeControls());
|
||||
}
|
||||
_pokeControls();
|
||||
_wake();
|
||||
}
|
||||
|
||||
/// media_kit's controls state is private: a guarded dynamic call to its
|
||||
/// hover handler, as a mouse movement on it would make.
|
||||
void _pokeControls() {
|
||||
if (!mounted) return;
|
||||
void visit(Element e) {
|
||||
if (e is StatefulElement && e.state.runtimeType.toString() == '_MaterialDesktopVideoControlsState') {
|
||||
try {
|
||||
(e.state as dynamic).onHover();
|
||||
} catch (_) {}
|
||||
return;
|
||||
}
|
||||
e.visitChildElements(visit);
|
||||
}
|
||||
|
||||
context.visitChildElements(visit);
|
||||
}
|
||||
|
||||
void _wake() {
|
||||
widget.shown.value = true;
|
||||
_timer?.cancel();
|
||||
@@ -43,7 +164,8 @@ class _ControlsHoverProbeState extends State<ControlsHoverProbe> {
|
||||
|
||||
void _hide() {
|
||||
_timer?.cancel();
|
||||
if (mounted) widget.shown.value = false;
|
||||
if (!mounted || _held) return;
|
||||
widget.shown.value = false;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -52,7 +174,9 @@ class _ControlsHoverProbeState extends State<ControlsHoverProbe> {
|
||||
hitTestBehavior: HitTestBehavior.translucent,
|
||||
onEnter: (_) => _wake(),
|
||||
onHover: (_) => _wake(),
|
||||
onExit: (_) => _hide(),
|
||||
// Held (the mouse went onto a pill or the popover): media_kit's
|
||||
// controls just hid on their own exit; bring them straight back.
|
||||
onExit: (_) => _held ? _pokeControls() : _hide(),
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
@@ -312,19 +312,18 @@ class PlayerSettingsCard extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
// The app's glass, like the Up next popover and the menus.
|
||||
return AppGlass(
|
||||
key: const ValueKey('player-settings-card'),
|
||||
elevation: 12,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: SizedBox(
|
||||
width: 420,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(18, 14, 18, 10),
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 10),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text('Player', style: TextStyle(fontWeight: FontWeight.w700, fontSize: 16)),
|
||||
const Text('Player', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
|
||||
const SizedBox(height: 4),
|
||||
...playerSettingRows(renderMode: renderMode),
|
||||
if (tracks) ...[
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../../state/settings.dart';
|
||||
import '../controls_probe.dart' show ControlsHold;
|
||||
|
||||
// The mpv-style (uosc) controls frame: a full-width timeline at the
|
||||
// bottom with a row of flat buttons above it, the title top left and a
|
||||
@@ -28,6 +29,8 @@ class UoscControls extends StatefulWidget {
|
||||
final Duration hideAfter;
|
||||
/// Follows whether the controls are up (for overlays outside them).
|
||||
final ValueNotifier<bool>? shown;
|
||||
/// Keeps them up while held (the mouse on an overlay, an open popover).
|
||||
final ControlsHold? holdBy;
|
||||
const UoscControls({
|
||||
super.key,
|
||||
required this.timeline,
|
||||
@@ -38,9 +41,12 @@ class UoscControls extends StatefulWidget {
|
||||
this.onDoubleTap,
|
||||
this.hideAfter = const Duration(seconds: 3),
|
||||
this.shown,
|
||||
this.holdBy,
|
||||
});
|
||||
|
||||
static const buttonRowHeight = 40.0;
|
||||
/// Under the button row, above the timeline.
|
||||
static const rowBottomPadding = 4.0;
|
||||
|
||||
/// The controls around [context], for widgets that need them kept up
|
||||
/// (a timeline drag, an open menu).
|
||||
@@ -54,6 +60,7 @@ class UoscControls extends StatefulWidget {
|
||||
class UoscControlsState extends State<UoscControls> {
|
||||
bool _visible = true;
|
||||
int _holds = 0;
|
||||
bool _heldBy = false;
|
||||
Timer? _timer;
|
||||
|
||||
bool get visible => _visible;
|
||||
@@ -61,6 +68,7 @@ class UoscControlsState extends State<UoscControls> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.holdBy?.addListener(_holdByChanged);
|
||||
// Not during this build: an overlay listening to [shown] may be built.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) widget.shown?.value = true;
|
||||
@@ -68,12 +76,30 @@ class UoscControlsState extends State<UoscControls> {
|
||||
_hideLater();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(UoscControls oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.holdBy != widget.holdBy) {
|
||||
oldWidget.holdBy?.removeListener(_holdByChanged);
|
||||
widget.holdBy?.addListener(_holdByChanged);
|
||||
_holdByChanged();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.holdBy?.removeListener(_holdByChanged);
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _holdByChanged() {
|
||||
final on = widget.holdBy?.held ?? false;
|
||||
if (on == _heldBy || !mounted) return;
|
||||
_heldBy = on;
|
||||
hold(on);
|
||||
}
|
||||
|
||||
/// Show the controls and restart the hide countdown.
|
||||
void wake() {
|
||||
if (!_visible && mounted) {
|
||||
@@ -185,8 +211,9 @@ class UoscControlsState extends State<UoscControls> {
|
||||
child: AnimatedOpacity(
|
||||
duration: fade,
|
||||
opacity: visible ? 1 : 0,
|
||||
// The buttons centred on the title, like the standard
|
||||
// design's top bar.
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
@@ -196,7 +223,8 @@ class UoscControlsState extends State<UoscControls> {
|
||||
: Align(alignment: AlignmentDirectional.centerStart, child: widget.topLeft),
|
||||
),
|
||||
),
|
||||
?widget.top,
|
||||
if (widget.top != null)
|
||||
Padding(padding: const EdgeInsets.fromLTRB(0, 12, 6, 0), child: widget.top),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -217,7 +245,7 @@ class UoscControlsState extends State<UoscControls> {
|
||||
duration: fade,
|
||||
opacity: visible ? 1 : 0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(6, 0, 6, 4),
|
||||
padding: const EdgeInsets.fromLTRB(6, 0, 6, UoscControls.rowBottomPadding),
|
||||
child: SizedBox(
|
||||
height: UoscControls.buttonRowHeight,
|
||||
child: Row(children: [...widget.left, const Spacer(), ...widget.right]),
|
||||
|
||||
@@ -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);
|
||||
final until = now.add(keep);
|
||||
if (hit == null) {
|
||||
_probes[url] = (at: now, until: until, f: f);
|
||||
f.catchError((Object _) {
|
||||
_probes.remove(url);
|
||||
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,41 +101,30 @@ 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(
|
||||
final label = MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hoverTitle = true),
|
||||
onExit: (_) => setState(() => _hoverTitle = false),
|
||||
child: GestureDetector(
|
||||
key: const ValueKey('up-next-chip'),
|
||||
key: const ValueKey('up-next-button'),
|
||||
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,
|
||||
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: 17, color: Colors.white70),
|
||||
size: _uosc ? 22 : 24, color: Colors.white),
|
||||
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)),
|
||||
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(
|
||||
@@ -154,43 +132,58 @@ class _UpNextChipState extends State<UpNextChip> {
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: queued ? Colors.white : Colors.white70,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500),
|
||||
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: const Color(0x24FFFFFF), borderRadius: BorderRadius.circular(999)),
|
||||
child: Text('+$more', style: const TextStyle(color: Colors.white70, fontSize: 11)),
|
||||
decoration: BoxDecoration(color: UoscStyle.hover, borderRadius: BorderRadius.circular(999)),
|
||||
child: Text('+$more', style: TextStyle(color: dim, fontSize: 11)),
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 4),
|
||||
const SizedBox(width: 2),
|
||||
AnimatedRotation(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
turns: open ? 0.5 : 0,
|
||||
child: const Icon(Icons.expand_more_rounded, size: 18, color: Colors.white54),
|
||||
child: Icon(Icons.expand_more_rounded, size: 18, color: dim),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
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,13 +864,20 @@ 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,
|
||||
// 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(
|
||||
@@ -878,6 +890,7 @@ class _WarmDotState extends State<_WarmDot> with SingleTickerProviderStateMixin
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -907,7 +920,8 @@ class _ContinuationRowState extends State<_ContinuationRow> {
|
||||
duration: const Duration(milliseconds: 120),
|
||||
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: SizedBox(
|
||||
width: 400,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 12, 12),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 380),
|
||||
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),
|
||||
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,7 +1625,8 @@ class SessionCoordinator {
|
||||
}
|
||||
JellyfinPlan plan;
|
||||
try {
|
||||
plan = await resolveJellyfinRef(jellyfin, ref,
|
||||
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,33 +241,18 @@ class _PlayerStatusState extends State<PlayerStatus> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Frosted dark glass in a pill: the status pill, the unread chip.
|
||||
/// The player's pill surface (the status pill, the action pills, the
|
||||
/// unread chip): the app's glass as a stadium ([AppGlass.pill]).
|
||||
class PlayerGlass extends StatelessWidget {
|
||||
final Widget child;
|
||||
const PlayerGlass({super.key, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final r = BorderRadius.circular(999);
|
||||
return ConstrainedBox(
|
||||
Widget build(BuildContext context) => 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: AppGlass.pill(child: child),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusLine extends StatelessWidget {
|
||||
final StatusView view;
|
||||
|
||||
@@ -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,20 +684,54 @@ 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() {
|
||||
/// The bottom controls' height in the design in use.
|
||||
static double _bottomControlsHeight(bool uosc) => uosc
|
||||
? UoscControls.buttonRowHeight + UoscControls.rowBottomPadding + UoscTimeline.height
|
||||
: _barHeight + ChapterSeekBar.height;
|
||||
|
||||
/// 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 Positioned.fill(
|
||||
child: SignalBuilder(
|
||||
return 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.
|
||||
@@ -692,43 +740,26 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
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),
|
||||
)
|
||||
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.
|
||||
else if (next != null &&
|
||||
(finished || countdown != null) &&
|
||||
coord.queue.shownContinuation.value.isNotEmpty)
|
||||
Positioned(
|
||||
right: 24,
|
||||
bottom: 138,
|
||||
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,
|
||||
),
|
||||
)
|
||||
else if (showNext || skip != null)
|
||||
Positioned(
|
||||
right: 24,
|
||||
bottom: 124,
|
||||
child: Column(
|
||||
);
|
||||
}
|
||||
if (!showNext && skip == null) return const SizedBox.shrink();
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
spacing: 10,
|
||||
spacing: 8,
|
||||
children: [
|
||||
if (skip != null)
|
||||
PillGate(
|
||||
@@ -737,6 +768,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
// 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)
|
||||
@@ -744,40 +776,37 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
key: const ValueKey('next-gate'),
|
||||
kind: next,
|
||||
controlsShown: _controlsShown,
|
||||
hold: _hold,
|
||||
child: NextEpisodePill(episode: next, onPressed: coord.playNextEpisode),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// "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),
|
||||
/// 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,20 +270,8 @@ 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(
|
||||
return AppGlass(
|
||||
key: const ValueKey('app-menu'),
|
||||
color: AppMenuStyle.glass,
|
||||
shape: AppMenuStyle.shape,
|
||||
child: IntrinsicWidth(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(4),
|
||||
@@ -253,9 +291,6 @@ class _AppMenuPanelState extends State<_AppMenuPanel> {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,6 +149,9 @@ class _FakeJellyfin {
|
||||
res.statusCode = 204;
|
||||
return res.close();
|
||||
}
|
||||
if (path.startsWith('/UserPlayedItems/') || path.startsWith('/UserItems/')) {
|
||||
return json({'Played': path.startsWith('/UserPlayedItems/'), 'PlaybackPositionTicks': 0});
|
||||
}
|
||||
if (RegExp(r'^/Videos/\w+/stream$').hasMatch(path)) return _stream(req);
|
||||
res.statusCode = 404;
|
||||
await res.close();
|
||||
@@ -378,8 +381,13 @@ void main() {
|
||||
expect(ep1.last, 'Stopped');
|
||||
expect(ep1.where((k) => k == 'Stopped'), hasLength(1));
|
||||
final stopped1 = aCalls.lastWhere((s) => s.body?['ItemId'] == _ep1);
|
||||
expect(stopped1.body!['PositionTicks'], greaterThan(19 * jellyfinTicksPerSecond),
|
||||
reason: 'played to the end');
|
||||
// Part of it watched, then a seek to the end: not watched (see
|
||||
// WatchSession). A resume point capped at 85 % of the 20 s, so
|
||||
// Jellyfin cannot mark it played on its own.
|
||||
expect(stopped1.body!['PositionTicks'], lessThan(18 * 10000000),
|
||||
reason: 'part way: resume point under 90 %, so not marked played');
|
||||
expect(jelly.calls.any((c) => c.method == 'POST' && c.path == '/UserPlayedItems/$_ep1'), isFalse,
|
||||
reason: 'seeking to the end is not watching');
|
||||
expect(stopped1.body!['PlaySessionId'], 'ps-ua-$_ep1');
|
||||
expect(ep2.first, 'Playing');
|
||||
expect(ep2.last, 'Stopped', reason: 'leaving reports Stopped');
|
||||
|
||||
@@ -41,6 +41,10 @@ FakeJellyfin playbackFake() {
|
||||
return const JellyfinHttpResponse(204, '');
|
||||
});
|
||||
}
|
||||
fake.on('POST', '/UserPlayedItems/$episodeId', (_, _) {
|
||||
reports.add((path: '/UserPlayedItems', body: const {}));
|
||||
return const JellyfinHttpResponse(200, '{"Played":true}');
|
||||
});
|
||||
return fake;
|
||||
}
|
||||
|
||||
@@ -261,11 +265,45 @@ void main() {
|
||||
'ItemId': episodeId,
|
||||
'MediaSourceId': episodeId,
|
||||
'PlaySessionId': playSessionId,
|
||||
'PositionTicks': 3005000000,
|
||||
});
|
||||
'PositionTicks': 0,
|
||||
}, reason: '11 s watched is a peek: the resume point from before (none) stays');
|
||||
expect(errors, isEmpty);
|
||||
});
|
||||
|
||||
test('watched to the end: Stopped without a position, then marked played', () async {
|
||||
reporter.start(const PlayerSnapshot(position: 0, paused: false));
|
||||
for (var i = 1; i <= 1420; i++) {
|
||||
now = DateTime(2026, 1, 1, 20).add(Duration(seconds: i));
|
||||
reporter.update(PlayerSnapshot(position: i.toDouble(), paused: false));
|
||||
}
|
||||
await reporter.stop(const PlayerSnapshot(position: 1420, paused: true));
|
||||
expect(reports.map((r) => r.path.split('/').last).toList().sublist(reports.length - 2),
|
||||
['Stopped', 'UserPlayedItems']);
|
||||
expect(reports[reports.length - 2].body.containsKey('PositionTicks'), isFalse);
|
||||
final nearEnd = [for (final r in reports) if (r.path.endsWith('Progress')) r.body]
|
||||
.where((b) => b.containsKey('PositionTicks') && b['PositionTicks'] >= 1207 * jellyfinTicksPerSecond);
|
||||
expect(nearEnd, isEmpty, reason: 'near the end Progress carries no position: Jellyfin must not decide');
|
||||
});
|
||||
|
||||
test('played before: a peek never unmarks it', () async {
|
||||
final r = JellyfinProgressReporter(
|
||||
client: accounts.clientFor(fixtureServerId)!,
|
||||
itemId: episodeId,
|
||||
source: fixtureSource(),
|
||||
playSessionId: playSessionId,
|
||||
playedBefore: true,
|
||||
clock: () => now,
|
||||
);
|
||||
r.start(const PlayerSnapshot(position: 700, paused: false));
|
||||
now = now.add(const Duration(seconds: 1));
|
||||
r.update(const PlayerSnapshot(position: 701, paused: true));
|
||||
await r.stop(const PlayerSnapshot(position: 701, paused: true));
|
||||
expect(reports.map((r) => r.path.split('/').last),
|
||||
['Playing', 'UserPlayedItems', 'Progress', 'Stopped', 'UserPlayedItems']);
|
||||
expect(reports[2].body.containsKey('PositionTicks'), isFalse);
|
||||
expect(reports[3].body.containsKey('PositionTicks'), isFalse);
|
||||
});
|
||||
|
||||
test('played to the end, then seeked back: a fresh Playing', () async {
|
||||
reporter.start(const PlayerSnapshot(position: 0, paused: false));
|
||||
await reporter.stop(const PlayerSnapshot(position: 1440, paused: true));
|
||||
|
||||
@@ -54,16 +54,6 @@ void main() {
|
||||
expect(queueJanitor([member('m9'), member('m1', stale: true), member('m2')]), 'm2');
|
||||
});
|
||||
|
||||
test('warming starts in the last 90 s, once buffered to the end, or at the end', () {
|
||||
bool due(double pos, {double buffered = 0, String t = 'playing'}) =>
|
||||
queueWarmDue(transport: t, position: pos, duration: 1000, bufferedTo: pos + buffered);
|
||||
expect(due(100), isFalse);
|
||||
expect(due(911), isTrue);
|
||||
expect(due(100, buffered: 900), isTrue);
|
||||
expect(due(0, t: 'finished'), isTrue);
|
||||
expect(queueWarmDue(transport: 'playing', position: 5, duration: 0, bufferedTo: 5), isFalse);
|
||||
});
|
||||
|
||||
test('labels', () {
|
||||
expect(queueHowLabel(item(QueueHow.through), selfId: 'm2'), 'through you');
|
||||
expect(queueHowLabel(item(QueueHow.through), selfId: 'm1'), 'through Bob');
|
||||
|
||||
+121
-41
@@ -7,7 +7,7 @@ Plain overview first: [HOW-IT-WORKS.md](HOW-IT-WORKS.md). Wire format:
|
||||
|
||||
| Path | What | Rule |
|
||||
|---|---|---|
|
||||
| `packages/core` | protocol messages, sync engine (`SyncEngine`), constants, config, Range parsing, logging, Jellyfin client, updater | pure Dart, no Flutter |
|
||||
| `packages/core` | protocol messages, sync engine (`SyncEngine`), constants, config, Range parsing, logging, Jellyfin client, updater, watched rule (`WatchSession`) | pure Dart, no Flutter |
|
||||
| `packages/server` | Relic HTTP + WebSocket server (`SyncServer`, `ServerHub`, `Session`, media endpoint, relay buffer, Jellyfin relay), the `serve()` entrypoint, `bin/server.dart` (dedicated server) | pure Dart, no Flutter |
|
||||
| `apps/desktop` | Flutter app: UI, `SyncClient`, `SessionCoordinator`, `PlaybackController` (media_kit/mpv), `ServerIsolate`, update UI (`UpdateController`) | ships with `watch-together-server` |
|
||||
| `tool` | harnesses: `range_logger`, `shaper`, `fake_viewer`, `e2e_check` | dev only |
|
||||
@@ -688,7 +688,8 @@ all three link modes. Nothing is bundled or installed by hand.
|
||||
reason and the GET's HTTP status. `LinkProbe.problem`: why it will not
|
||||
play, in plain words (`playErrorText` of yt-dlp's reason, else of a 4xx
|
||||
status), null for a file or a page yt-dlp read. The app probes on paste
|
||||
(`AppYtDlp.probe`, remembered 10 minutes; media file names too, not a
|
||||
(`AppYtDlp.probe`, remembered 10 minutes, longer for Up next's first
|
||||
item (see "Up next"); media file names too, not a
|
||||
Jellyfin item link) for the
|
||||
Link preview and the swap: a page is named by its title, never its URL
|
||||
tail, and its thumbnail goes up as its art (see "Media art"). A link with a
|
||||
@@ -754,13 +755,43 @@ Client side (`lib/src/jellyfin/session_media.dart`, `progress.dart`,
|
||||
file is open and mpv has picked its tracks; Progress every 10 s and at
|
||||
once on pause, resume, a seek (position more than 3 s from where playback
|
||||
should be) or a track change; Stopped on swap, leave, sitting out, window close
|
||||
(bounded to 1.5 s inside the graceful leave) and at `finished` (position =
|
||||
duration, so Jellyfin marks it played); seeking back from the end sends
|
||||
Playing again. Reports carry `PositionTicks`, `IsPaused` and mpv's
|
||||
`aid`/`sid` as Jellyfin stream indices (`jellyfinIndexForMpvTrack`,
|
||||
`SubtitleStreamIndex -1` = off). They go out in order on one chain;
|
||||
failures are logged, one quiet toast per session, playback is never
|
||||
touched; a 401 marks the account expired and ends reporting.
|
||||
(bounded to 1.5 s inside the graceful leave) and at `finished`; seeking
|
||||
back from the end sends Playing again. Reports carry `PositionTicks`
|
||||
(see "Watched rule"), `IsPaused` and mpv's `aid`/`sid` as Jellyfin
|
||||
stream indices (`jellyfinIndexForMpvTrack`, `SubtitleStreamIndex -1` =
|
||||
off). They go out in order on one chain; failures are logged, one quiet
|
||||
toast per session, playback is never touched; a 401 marks the account
|
||||
expired and ends reporting.
|
||||
- **Watched rule** (`WatchSession` in `packages/core/lib/src/watch_session.dart`,
|
||||
pure, meant for any tracker): each opening samples the position every
|
||||
tick; only normal playback counts (forward, at most 2x speed + 1.5 s,
|
||||
samples at most 10 s apart; seeks, skips and pauses add nothing). The
|
||||
union of the watched stretches is the coverage (rewatching counts once),
|
||||
alongside the plain watched time, first / last / furthest position and
|
||||
start / end time. At a stop: played before this opening -> keep played;
|
||||
coverage >= 80 % of the runtime, or the ending reached (furthest position
|
||||
past the Outro / ED start, else the real end) with coverage >= 50 % ->
|
||||
played; coverage under max(2 min, 5 %) (at most a quarter of a short
|
||||
item) -> a peek; else part way, resuming at the last position. An
|
||||
unknown runtime never makes it played. Jellyfin decides played from
|
||||
positions alone (10.11): Playing sets a resumable item unplayed, a
|
||||
Progress or Stopped position past "max resume" (90 %) marks it played,
|
||||
one in between keeps a resume point. So `JellyfinProgressReporter`
|
||||
(played before = `UserData.Played` read at open with
|
||||
`/UserItems/{id}/UserData`):
|
||||
- played (before, or by the rule at an earlier stop of this opening):
|
||||
`UserPlayedItems` right after Playing, Progress without
|
||||
`PositionTicks` (leaves the saved position and played state), Stopped
|
||||
without `PositionTicks` (Jellyfin marks it played, position 0), then
|
||||
`UserPlayedItems` again;
|
||||
- peek: Stopped with the resume point from before the opening (0 when
|
||||
none), so the item is as it was;
|
||||
- part way: Stopped at the last position, held under 85 % of the
|
||||
runtime so Jellyfin cannot mark it played;
|
||||
- not played yet, any time: Progress leaves `PositionTicks` out from
|
||||
85 % of the runtime on (and without a runtime), so only the rule
|
||||
marks it played.
|
||||
Members playing through the server report nothing.
|
||||
- **Episodes**: with `OwnPlay` on an episode, `adjacentEpisodes` (one
|
||||
`/Shows/{id}/Episodes` call; `episodeBefore` / `episodeAfter`) gives the
|
||||
previous and next episode. The controls show previous / next buttons
|
||||
@@ -832,7 +863,7 @@ every `state` and feed), the pure rules in `queue_logic.dart`, the UI in
|
||||
puts the first item up does, with a swap naming it (`swap.queueId`), so
|
||||
the server takes it off in the same step and a second swap of it is
|
||||
ignored. Cancel is per screen like the next-episode countdown. "Play
|
||||
now" anywhere (a row, the card, the chip's ▶, `>` / Shift+N) swaps
|
||||
now" anywhere (a row, the card, the Up next button's ⏭, `>` / Shift+N) swaps
|
||||
directly from the app that can, else sends `play` and that app reacts
|
||||
to `state.queuePlay`. With nothing up at all, the first item goes up as
|
||||
soon as it is added. The swap goes through the usual paths
|
||||
@@ -842,37 +873,52 @@ every `state` and feed), the pure rules in `queue_logic.dart`, the UI in
|
||||
with the reason. A swap without `queueId` (a manual one) leaves the
|
||||
queue alone. A member who puts its own link or file up while sitting
|
||||
out comes back first; any other queued swap leaves the swapper out.
|
||||
- **Getting ready** (`queueWarmDue`): in the last 90 s of the current
|
||||
media, once it is buffered to its end, or at `finished`, the app that
|
||||
will put the first item up resolves its link (`AppYtDlp.probe`, kept 10
|
||||
minutes, which the swap then reads), so the swap does not wait for
|
||||
yt-dlp. A Jellyfin item, a file or a server-fetched link has nothing to
|
||||
get ready there. Only the first item, only that app, nothing on the
|
||||
server. Its row shows a dot: hollow waiting, pulsing getting ready,
|
||||
filled ready, red failed (tried again when it comes up).
|
||||
- **Getting ready** (`QueueController._warm`): the one item this app
|
||||
puts up next is kept ready all the time, so a skip, Play now or the
|
||||
end of the media starts it at once. That is the first Up next item on
|
||||
the app that puts it up (`queuePerformer`), else, with Up next empty,
|
||||
the first "Continues with" episode on an app that has it. It is
|
||||
resolved as soon as it is first there (or this app becomes the one),
|
||||
again every 30 minutes, and dropped when it stops being first: a link
|
||||
is probed (`AppYtDlp.probe` with `keep`: the result stays 35 minutes
|
||||
instead of 10 and a refresh replaces it only once it is in; swaps read
|
||||
it from there); a Jellyfin item is resolved with this app's account
|
||||
(`resolveJellyfinRef`: PlaybackInfo and the item) into
|
||||
`SessionCoordinator.warmJellyfin`, which `_playJellyfin` takes instead
|
||||
of resolving when the same ref goes up within those 35 minutes and the
|
||||
account is unchanged. A file or a server-fetched link has nothing to
|
||||
get ready. One item, one app, nothing on the server. Its row shows a
|
||||
dot on its picture's corner (a tooltip says which): hollow waiting, pulsing getting ready, filled ready, red failed
|
||||
(tried again every 2 minutes).
|
||||
- **Continues with**: not queued, computed here like the next episode:
|
||||
with an own-account Jellyfin episode playing, the next three episodes
|
||||
of the series (`/Shows/{id}/Episodes`, cached), greyed under Up next,
|
||||
"+" adds one. "Clear" hides them for the media playing and keeps its
|
||||
next-episode countdown from starting.
|
||||
- **UI**: `UpNextChip` top right under the top bar (under the party in
|
||||
fullscreen), shown with the controls like the pills (`PillGate`) and
|
||||
while its popover is open: "Up next · title", a +n count, ▶ plays it
|
||||
now; without Up next the continuation ("Next · S1E8 …", ▶ = next
|
||||
- **UI**: `UpNextButton` is one of the player's top-bar controls, before
|
||||
chat (see "Player overlay" under Desktop UI), in the look of the
|
||||
design's buttons (`UpNextLook`); it shows and hides with the controls,
|
||||
which an open popover holds up: "Up next · title", a +n count, ⏭ plays
|
||||
it now; without Up next the continuation ("Next · S1E8 …", ⏭ = next
|
||||
episode); nothing when both are empty. Its title opens the queue as a
|
||||
`LeafPopover` (package `leaf_widgets`, an overlay rather than a route):
|
||||
frosted glass like the app menus; rows with a 16:9 picture, title, second
|
||||
line, "length · added by (avatar) name · how", a drag handle
|
||||
`LeafPopover` (package `leaf_widgets`, an overlay rather than a route)
|
||||
hanging under it, right edges aligned: the app's glass (`AppGlass`),
|
||||
the menus' paddings and row hover, 64 px rows with an 80 px 16:9
|
||||
picture, title, second line, "length · added by (avatar) name · how", a drag handle
|
||||
(`ReorderableListView`, reordered here at once and sent as `move`), ▶
|
||||
and × floating in on hover; "Continues with"; a "Paste a link to add"
|
||||
field (focused on open, so the player's keys and Space rest while it
|
||||
is up; Esc closes) and a drop zone for links and video files. Being no
|
||||
route, it holds the shell's own drop and paste handling off while open
|
||||
(`handoffHolds`). "Add to queue" elsewhere: the Link page's button
|
||||
next to Put it up (in the chosen mode), a right-click menu on recent
|
||||
links, Jellyfin cards, episodes and details-panel episodes, and Files
|
||||
videos (Put it up / Add to queue / Play next), and a button in the
|
||||
Jellyfin details panel (right-click: play next). None of it offline.
|
||||
(`handoffHolds`). "Add to queue" elsewhere, on every source: the Link
|
||||
page's button next to Put it up (in the chosen mode), a right-click
|
||||
`showAppMenu` (Put it up / Add to queue / Play next) on recent links,
|
||||
Jellyfin cards (home rows, libraries, search), series episodes and
|
||||
details-panel episodes, Files videos and Schedule cards that can be
|
||||
put up, a button in the Jellyfin details panel (right-click: play
|
||||
next), and Add to queue / Play next on the drop and paste card (videos
|
||||
in order, a link in the Link page's mode). All go through
|
||||
`QueueController.add`. None of it offline.
|
||||
|
||||
## Desktop UI
|
||||
|
||||
@@ -988,7 +1034,10 @@ from `AppMenuStyle.theme`; the uosc controls keep their own flat menus.
|
||||
by the linked account (`jellyfinEpisodeNumbers`, one request per series
|
||||
memoised 3 min; `confirmOnJellyfin` is the pure step); absent reads
|
||||
"aired · not on Jellyfin yet", unknown (no linked account, lookup
|
||||
failed) "aired", neither with Put it up. Requests and the owner's inbox are in
|
||||
failed) "aired", neither with Put it up. Put it up resolves the Jellyfin
|
||||
episode (`resolveJellyfinEpisode`: AnimeFlow's numbering, else the
|
||||
season and offset, else the air day) and goes through the shell's
|
||||
put-up; right-click queues it the same way. Requests and the owner's inbox are in
|
||||
`requests_sheet.dart`. It refreshes every minute; mouse drag and
|
||||
Shift+wheel scroll the board sideways, the plain wheel scrolls a column.
|
||||
Settings (`settings_tab.dart`) has a left nav with icons
|
||||
@@ -1008,7 +1057,8 @@ from `AppMenuStyle.theme`; the uosc controls keep their own flat menus.
|
||||
`HandoffCard` opens as a popover at the pointer: a video goes up through
|
||||
the shell's put-up (Play when alone; offline that starts solo) or adds
|
||||
its folder to Files; a folder is added to Files and opened; a link fills
|
||||
Library → Link (with others the relay mode is picked there). Images are ignored.
|
||||
Library → Link (with others the relay mode is picked there). In a
|
||||
server, videos and links also get Add to queue / Play next. Images are ignored.
|
||||
Nothing happens while a dialog or popover is over the shell.
|
||||
- **Sounds** (`sfx/`): the coordinator plays an `SfxCue` for ready,
|
||||
unready, force-ready, join, leave, swap and others' chat (feed
|
||||
@@ -1059,8 +1109,8 @@ from `AppMenuStyle.theme`; the uosc controls keep their own flat menus.
|
||||
the banner (else a blur of the cover, else dark), darkened, with cover,
|
||||
big title and episode; it fades out on play and stays gone for that
|
||||
media; under 320 px tall (the mini) it drops the cover and shrinks the
|
||||
title. One status pill (`player_status.dart`) sits bottom centre above
|
||||
the controls: `playerStatusFor` (pure) picks the most pressing of
|
||||
title. One status pill (`player_status.dart`) sits centred in the
|
||||
action area (see "Player overlay"): `playerStatusFor` (pure) picks the most pressing of
|
||||
removed, reconnecting (Cancel), a warn/error health message (Retry),
|
||||
connecting (only after 2 s), finished, someone loading (below
|
||||
`StateBroadcast.bufferNeed`, the gate's own need at the current
|
||||
@@ -1073,10 +1123,10 @@ from `AppMenuStyle.theme`; the uosc controls keep their own flat menus.
|
||||
line) and are never in "waiting for". A member's new error also toasts
|
||||
once (the pill is hidden while playing); avatars carry a red error mark
|
||||
and the members card says "can't play this: ...". 34 px avatars, a main line
|
||||
and a muted detail line. Frosted glass, colour only for problems; it fades through
|
||||
and a muted detail line. The pill glass, colour only for problems; it fades through
|
||||
between kinds. The mini shows its short line in its hover bar. ⚙ in both
|
||||
control designs opens the player settings card (`controls/
|
||||
player_settings.dart`, `showPopover`): player design, progress line
|
||||
player_settings.dart`, `showPopover`, on `AppGlass`): player design, progress line
|
||||
when hidden, upscaling shader, hardware decoding (set live on mpv's
|
||||
`hwdec`), subtitle and audio track. The rows (`playerSettingRows`) are
|
||||
the same widgets Settings → Player shows; all apply live. The progress
|
||||
@@ -1086,6 +1136,35 @@ from `AppMenuStyle.theme`; the uosc controls keep their own flat menus.
|
||||
exit), as media_kit does not expose it. Fullscreen adds the
|
||||
`WindowButtons` to the top bar while the controls are up (restore
|
||||
leaves fullscreen).
|
||||
- **Player overlay** (full player, both designs; `session_player.dart`):
|
||||
everything over the picture has a fixed place on one grid (16 px from
|
||||
the sides, the bars' inset), so nothing floats on its own. The top
|
||||
bar (part of the controls): now-playing title left; right the Up next
|
||||
button (`UpNextButton`), chat, back-to-library or exit fullscreen,
|
||||
then the window buttons in fullscreen, all in that design's button
|
||||
look. Under it at the right, in fullscreen while paused, the party's
|
||||
avatars; with the controls down and unread chat, the unread chip top
|
||||
right. The action area: one band just above the bottom controls
|
||||
(`_actionArea`; their height per design, 16 px gap), sliding down to
|
||||
16 px from the bottom while they are hidden (`AnimatedPositioned`,
|
||||
the shell's `ease` / `easeTime`); in it the status pill centred
|
||||
(moved left only as far as it must to clear the right end, never
|
||||
overlapping it) and at the right end the end card (`UpNextCard`, else
|
||||
`EndOfEpisodeCard`, which stay up) or the action pills (Skip OP / ED /
|
||||
1:30 over Next episode, `PillGate`). The Jellyfin blocked card sits in
|
||||
the middle. Two surfaces: cards and popovers use `AppGlass`
|
||||
(`widgets/app_menu.dart`, the menus' recipe: blur, dark tint, hairline
|
||||
border, 12 px radius, shadow); pills use `PlayerGlass`
|
||||
(`AppGlass.pill`: a stadium, lighter tint, no shadow): the status
|
||||
pill, the unread chip and `FloatingPill` (the action pills, 44 px
|
||||
tall like the status pill without avatars). The mouse on any
|
||||
floating element counts as the mouse on the controls: a
|
||||
`ControlsHold` (`controls/controls_probe.dart`) held by
|
||||
`ControlsHoldRegion`s (and by the open Up next popover) keeps either
|
||||
design's controls up (`UoscControls.holdBy`; `ControlsHoverProbe.hold`,
|
||||
which re-wakes media_kit's controls through their private hover
|
||||
handler, the same guarded dynamic call as `keepVideoControlsAwake`),
|
||||
and lets them hide on their usual countdown once released.
|
||||
- **Chat** (`chat_panel.dart`): a 340 px panel sliding in at the right
|
||||
(strip button or Tab): chat with avatars; the "Activity" chip (off by
|
||||
default, `showActivity`, kept for the app's run only) adds the other
|
||||
@@ -1164,7 +1243,7 @@ Session controls (the Standard player design): media_kit's
|
||||
`screens/session/player_controls.dart`); its own double-click and keys are
|
||||
off (its double-click pushes a fullscreen route with a second `Video`), the
|
||||
picture's double-click is a raw pointer observer (`DoubleClickListener`).
|
||||
The top bar holds the now-playing title, Change video, chat and back-to-library
|
||||
The top bar holds the now-playing title, Up next, Change video, chat and back-to-library
|
||||
(fullscreen: exit, then the window buttons); the bottom bar ends with the
|
||||
shader button, ⚙ and fullscreen. Its seek bar is off; ours
|
||||
(`ChapterSeekBar`: chapter diamonds, hover time with the chapter title)
|
||||
@@ -1172,10 +1251,11 @@ sits in the bottom bar, which is taller by the bar's height (the top bar
|
||||
keeps its row at the top). A drag sends no hover events, so the bar keeps
|
||||
the controls up through a guarded dynamic `onHover()` on media_kit's
|
||||
private controls state (`keepVideoControlsAwake`). Pills (Skip OP / ED /
|
||||
1:30, Next episode) and cards live outside the auto-hiding controls. The
|
||||
1:30, Next episode) and cards live outside the auto-hiding controls, in
|
||||
the action area ("Player overlay"). The
|
||||
cards stay up; a pill (`PillGate`) shows only while the controls are up
|
||||
(`ControlsHoverProbe` for this design, `UoscControls.shown` for uosc) or
|
||||
the mouse is on it, and Skip OP / Skip 1:30 also for 5 s when an offer of
|
||||
the mouse is on it (which holds the controls up too), and Skip OP / Skip 1:30 also for 5 s when an offer of
|
||||
a new kind appears (Skip ED and Next episode never without the controls).
|
||||
Volume (`controls/volume.dart`, both
|
||||
designs): mpv's `volume-max` is pinned to `PlaybackController.maxVolume`
|
||||
@@ -1200,8 +1280,8 @@ leave),
|
||||
subtitles and audio with track counts, a shader badge ("A HQ", opening the
|
||||
shared `showShaderMenu`), ready, mark everyone ready, reload; 1:30,
|
||||
chapters, previous / next episode, change video, volume, ⚙, fullscreen; the
|
||||
now-playing title top left (`UoscControls.topLeft`, with a top gradient), chat
|
||||
and back-to-library (fullscreen: exit) top right. Menus (`showUoscMenu`: a popup route above the button, the
|
||||
now-playing title top left (`UoscControls.topLeft`, with a top gradient), Up next, chat
|
||||
and back-to-library (fullscreen: exit) top right, centred on the title. Menus (`showUoscMenu`: a popup route above the button, the
|
||||
current row highlighted) list tracks and chapters with start times. All
|
||||
actions are the coordinator's (seeks through `player.seek`, the user path;
|
||||
`selectTrack`, `skipForward`, `seekTo`); keys and the floating pills are
|
||||
|
||||
@@ -13,6 +13,7 @@ export 'src/cookies.dart';
|
||||
export 'src/media_source.dart';
|
||||
export 'src/sync_engine.dart';
|
||||
export 'src/updater.dart';
|
||||
export 'src/watch_session.dart';
|
||||
export 'src/jellyfin/client.dart';
|
||||
export 'src/jellyfin/client_info.dart';
|
||||
export 'src/jellyfin/http.dart';
|
||||
|
||||
@@ -92,7 +92,9 @@ class JellyfinPlaybackReport {
|
||||
final String itemId;
|
||||
final String mediaSourceId;
|
||||
final String playSessionId;
|
||||
final int positionTicks;
|
||||
/// Null leaves it out: a Progress without it does not touch the saved
|
||||
/// position or played state; a Stopped without it marks the item played.
|
||||
final int? positionTicks;
|
||||
final bool isPaused;
|
||||
/// Jellyfin stream index ([JellyfinMediaStream.index]) of the audio track.
|
||||
final int? audioStreamIndex;
|
||||
@@ -136,7 +138,7 @@ class JellyfinPlaybackReport {
|
||||
'ItemId': itemId,
|
||||
'MediaSourceId': mediaSourceId,
|
||||
'PlaySessionId': playSessionId,
|
||||
'PositionTicks': positionTicks,
|
||||
if (positionTicks != null) 'PositionTicks': positionTicks,
|
||||
'IsPaused': isPaused,
|
||||
'CanSeek': canSeek,
|
||||
'PlayMethod': playMethod,
|
||||
@@ -149,7 +151,7 @@ class JellyfinPlaybackReport {
|
||||
'ItemId': itemId,
|
||||
'MediaSourceId': mediaSourceId,
|
||||
'PlaySessionId': playSessionId,
|
||||
'PositionTicks': positionTicks,
|
||||
if (positionTicks != null) 'PositionTicks': positionTicks,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -540,19 +542,23 @@ class JellyfinClient {
|
||||
// ------------------------------------------------------------ progress
|
||||
|
||||
/// `POST /Sessions/Playing`: playback started (Jellyfin shows the session
|
||||
/// as playing and starts tracking the item).
|
||||
/// as playing and starts tracking the item). Up to 10.11 this also sets
|
||||
/// the item unplayed (resumable items); later servers leave it.
|
||||
Future<void> reportPlaybackStart(JellyfinPlaybackReport r) =>
|
||||
_json('POST', '/Sessions/Playing', body: r.toProgressJson());
|
||||
|
||||
/// `POST /Sessions/Playing/Progress`: position, pause state and tracks.
|
||||
/// Jellyfin saves the resume position from these.
|
||||
/// With a position Jellyfin saves it like a stop does (below
|
||||
/// "min resume" cleared, past "max resume" marked played); without one
|
||||
/// the saved position and played state stay.
|
||||
Future<void> reportPlaybackProgress(JellyfinPlaybackReport r) =>
|
||||
_json('POST', '/Sessions/Playing/Progress', body: r.toProgressJson());
|
||||
|
||||
/// `POST /Sessions/Playing/Stopped`: ends the session. The server then
|
||||
/// marks the item played if the position is past its "max resume" share
|
||||
/// (90% by default), keeps it as a resume point between 5% and 90%, and
|
||||
/// clears the position below 5%.
|
||||
/// clears the position below 5% (the played flag stays as it is in both).
|
||||
/// Without a position it marks the item played and clears the position.
|
||||
Future<void> reportPlaybackStopped(JellyfinPlaybackReport r) =>
|
||||
_json('POST', '/Sessions/Playing/Stopped', body: r.toStoppedJson());
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
// What one viewer actually watched of one opened item, and the rule that
|
||||
// turns it into "watched", "barely looked" or "stopped part way". Pure, so
|
||||
// any tracker (Jellyfin today) applies the same rule. See ARCHITECTURE.md
|
||||
// "Watched rule".
|
||||
|
||||
/// What a [WatchSession] amounts to when it ends.
|
||||
enum WatchOutcome {
|
||||
/// Seen: mark it played and clear the resume point.
|
||||
played,
|
||||
|
||||
/// Already played when opened (or earlier in this session): stays played
|
||||
/// whatever happened, with no resume point.
|
||||
keepPlayed,
|
||||
|
||||
/// Barely watched (a peek, a screenshot): leave the item as it was.
|
||||
peek,
|
||||
|
||||
/// Part way: resume from [WatchVerdict.resumeAt].
|
||||
partial,
|
||||
}
|
||||
|
||||
class WatchVerdict {
|
||||
final WatchOutcome outcome;
|
||||
|
||||
/// Where to resume, seconds ([WatchOutcome.partial] only).
|
||||
final double? resumeAt;
|
||||
const WatchVerdict(this.outcome, {this.resumeAt});
|
||||
|
||||
@override
|
||||
String toString() => 'WatchVerdict(${outcome.name}${resumeAt == null ? '' : ', $resumeAt'})';
|
||||
}
|
||||
|
||||
/// One opening of one item on this client. Fed the player position about
|
||||
/// once a second ([sample]); only normal playback counts as watched: the
|
||||
/// stretch between two samples while playing, when the position moved
|
||||
/// forward no faster than [maxRate] (plus [slack]) and the samples are at
|
||||
/// most [maxGap] apart. Seeks, skips and pauses add nothing. [covered] is the union of the watched stretches,
|
||||
/// so rewatching a part does not count twice; [watched] is the plain sum.
|
||||
class WatchSession {
|
||||
/// Played before this opening (Jellyfin `UserData.Played`).
|
||||
final bool playedBefore;
|
||||
|
||||
/// The resume point before this opening, seconds (0 = none).
|
||||
final double resumeBefore;
|
||||
|
||||
final double maxRate;
|
||||
final double slack;
|
||||
final double maxGap;
|
||||
|
||||
WatchSession(
|
||||
{this.playedBefore = false, this.resumeBefore = 0, this.maxRate = 2, this.slack = 1.5, this.maxGap = 10});
|
||||
|
||||
DateTime? startedAt;
|
||||
DateTime? endedAt;
|
||||
double? firstPosition;
|
||||
double? lastPosition;
|
||||
|
||||
/// The furthest position seen, reached by playing or by a jump.
|
||||
double furthest = 0;
|
||||
|
||||
/// Seconds of normal playback, rewatches included.
|
||||
double watched = 0;
|
||||
|
||||
final _spans = <(double, double)>[];
|
||||
({double pos, bool playing, DateTime at})? _prev;
|
||||
|
||||
/// Seconds of the item covered by normal playback (rewatches once).
|
||||
double get covered => _spans.fold(0.0, (s, e) => s + e.$2 - e.$1);
|
||||
|
||||
/// The watched stretches, sorted and merged.
|
||||
List<(double, double)> get spans => List.unmodifiable(_spans);
|
||||
|
||||
void sample(double position, {required bool playing, required DateTime at}) {
|
||||
final prev = _prev;
|
||||
_prev = (pos: position, playing: playing, at: at);
|
||||
startedAt ??= at;
|
||||
endedAt = at;
|
||||
firstPosition ??= position;
|
||||
lastPosition = position;
|
||||
furthest = math.max(furthest, position);
|
||||
if (prev == null || !prev.playing) return;
|
||||
final dt = at.difference(prev.at).inMilliseconds / 1000;
|
||||
final dp = position - prev.pos;
|
||||
if (dt <= 0 || dt > maxGap || dp <= 0 || dp > dt * maxRate + slack) return;
|
||||
watched += dp;
|
||||
_add(prev.pos, position);
|
||||
}
|
||||
|
||||
void _add(double a, double b) {
|
||||
final out = <(double, double)>[];
|
||||
var (lo, hi) = (a, b);
|
||||
for (final (s, e) in _spans) {
|
||||
if (e < lo || s > hi) {
|
||||
out.add((s, e));
|
||||
} else {
|
||||
lo = math.min(lo, s);
|
||||
hi = math.max(hi, e);
|
||||
}
|
||||
}
|
||||
out
|
||||
..add((lo, hi))
|
||||
..sort((x, y) => x.$1.compareTo(y.$1));
|
||||
_spans
|
||||
..clear()
|
||||
..addAll(out);
|
||||
}
|
||||
|
||||
/// The rule, for an item [runtime] seconds long whose ending starts at
|
||||
/// [endingStart] (an Outro segment or ED chapter; null = the real end):
|
||||
/// - played before: [WatchOutcome.keepPlayed];
|
||||
/// - [covered] >= 80 % of the runtime, or the ending was reached
|
||||
/// ([furthest]) with [covered] >= 50 %: [WatchOutcome.played];
|
||||
/// - [covered] under max(2 min, 5 %) (at most a quarter of a short
|
||||
/// item): [WatchOutcome.peek];
|
||||
/// - else [WatchOutcome.partial] at [lastPosition].
|
||||
/// An unknown runtime (0) never makes it played.
|
||||
WatchVerdict verdict({required double runtime, double? endingStart}) {
|
||||
if (playedBefore) return const WatchVerdict(WatchOutcome.keepPlayed);
|
||||
final c = covered;
|
||||
if (runtime > 0) {
|
||||
final end = endingStart != null && endingStart > 0 && endingStart < runtime ? endingStart : runtime - 2;
|
||||
if (c >= 0.8 * runtime || (furthest >= end && c >= 0.5 * runtime)) {
|
||||
return const WatchVerdict(WatchOutcome.played);
|
||||
}
|
||||
}
|
||||
final peekBelow = runtime > 0 ? math.min(math.max(120.0, 0.05 * runtime), 0.25 * runtime) : 120.0;
|
||||
if (c < peekBelow) return const WatchVerdict(WatchOutcome.peek);
|
||||
return WatchVerdict(WatchOutcome.partial, resumeAt: lastPosition ?? 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
/// Plays [from]..[to] at normal speed, one sample a second, starting at
|
||||
/// wall second [t]; returns the wall second after.
|
||||
int play(WatchSession w, int t, double from, double to) {
|
||||
var pos = from;
|
||||
w.sample(pos, playing: true, at: DateTime(2026, 1, 1).add(Duration(seconds: t)));
|
||||
while (pos < to) {
|
||||
t++;
|
||||
pos = pos + 1 > to ? to : pos + 1;
|
||||
w.sample(pos, playing: true, at: DateTime(2026, 1, 1).add(Duration(seconds: t)));
|
||||
}
|
||||
return t + 1;
|
||||
}
|
||||
|
||||
void main() {
|
||||
const ep = 1440.0; // 24 min
|
||||
const ed = 1350.0; // the ending starts 1.5 min before the end
|
||||
|
||||
test('normal playback counts; seeks, skips and pauses do not', () {
|
||||
final w = WatchSession();
|
||||
var t = play(w, 0, 0, 60);
|
||||
// A jump ahead between two samples: not watched.
|
||||
w.sample(600, playing: true, at: DateTime(2026, 1, 1).add(Duration(seconds: t)));
|
||||
t = play(w, t + 1, 600, 630);
|
||||
// Paused: the position does not move, nothing counts.
|
||||
w.sample(630, playing: false, at: DateTime(2026, 1, 1).add(Duration(seconds: t + 30)));
|
||||
expect(w.covered, closeTo(90, 1));
|
||||
expect(w.spans, hasLength(2));
|
||||
expect(w.firstPosition, 0);
|
||||
expect(w.lastPosition, 630);
|
||||
expect(w.furthest, 630);
|
||||
});
|
||||
|
||||
test('rewatching a part counts once in coverage, twice in watched time', () {
|
||||
final w = WatchSession();
|
||||
var t = play(w, 0, 0, 300);
|
||||
t = play(w, t, 100, 200); // went back for a moment
|
||||
expect(w.covered, closeTo(300, 1));
|
||||
expect(w.watched, closeTo(400, 1));
|
||||
});
|
||||
|
||||
test('played: 80 % covered, or the ending reached after 50 %', () {
|
||||
final full = WatchSession();
|
||||
play(full, 0, 0, 1200); // 83 %
|
||||
expect(full.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.played);
|
||||
|
||||
final skipped = WatchSession();
|
||||
final t = play(skipped, 0, 0, 800); // 56 %, then skipped to the ED
|
||||
play(skipped, t, ed, ed + 5);
|
||||
expect(skipped.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.played);
|
||||
// Without a known ending only the real end counts.
|
||||
expect(skipped.verdict(runtime: ep).outcome, WatchOutcome.partial);
|
||||
|
||||
final jumped = WatchSession();
|
||||
play(jumped, 0, 0, 400); // 28 %, then jumped to the end
|
||||
jumped.sample(ep, playing: false, at: DateTime(2026, 1, 2));
|
||||
expect(jumped.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.partial);
|
||||
});
|
||||
|
||||
test('played before: stays played however little was watched', () {
|
||||
final w = WatchSession(playedBefore: true);
|
||||
play(w, 0, 700, 730); // checking a moment
|
||||
expect(w.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.keepPlayed);
|
||||
});
|
||||
|
||||
test('a peek leaves nothing; part way resumes at the last position', () {
|
||||
final peek = WatchSession(resumeBefore: 300);
|
||||
play(peek, 0, 900, 990); // 90 s
|
||||
expect(peek.verdict(runtime: ep).outcome, WatchOutcome.peek);
|
||||
|
||||
final movie = WatchSession();
|
||||
play(movie, 0, 0, 300); // 5 min of 2 h: under 5 %
|
||||
expect(movie.verdict(runtime: 7200).outcome, WatchOutcome.peek);
|
||||
|
||||
final part = WatchSession();
|
||||
final t = play(part, 0, 0, 500);
|
||||
part.sample(450, playing: false, at: DateTime(2026, 1, 1).add(Duration(seconds: t)));
|
||||
final v = part.verdict(runtime: ep);
|
||||
expect(v.outcome, WatchOutcome.partial);
|
||||
expect(v.resumeAt, 450);
|
||||
|
||||
// A 3 min clip: half of it is not a peek.
|
||||
final clip = WatchSession();
|
||||
play(clip, 0, 0, 90);
|
||||
expect(clip.verdict(runtime: 180).outcome, WatchOutcome.partial);
|
||||
});
|
||||
|
||||
test('an unknown runtime never makes it played', () {
|
||||
final w = WatchSession();
|
||||
play(w, 0, 0, 1000);
|
||||
expect(w.verdict(runtime: 0).outcome, WatchOutcome.partial);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user