diff --git a/README.md b/README.md index 02d728d..cb9a6ea 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/apps/desktop/lib/src/controls/controls_probe.dart b/apps/desktop/lib/src/controls/controls_probe.dart index 57e951b..e2115ab 100644 --- a/apps/desktop/lib/src/controls/controls_probe.dart +++ b/apps/desktop/lib/src/controls/controls_probe.dart @@ -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 = {}; + 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 createState() => _ControlsHoldRegionState(); +} + +class _ControlsHoldRegionState extends State { + @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 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 createState() => _ControlsHoverProbeState(); @@ -19,22 +95,67 @@ class ControlsHoverProbe extends StatefulWidget { class _ControlsHoverProbeState extends State { 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 { 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 { 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, ); } diff --git a/apps/desktop/lib/src/controls/player_settings.dart b/apps/desktop/lib/src/controls/player_settings.dart index 165ebea..294069b 100644 --- a/apps/desktop/lib/src/controls/player_settings.dart +++ b/apps/desktop/lib/src/controls/player_settings.dart @@ -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) ...[ diff --git a/apps/desktop/lib/src/controls/uosc/controls.dart b/apps/desktop/lib/src/controls/uosc/controls.dart index a8b6b1c..bcdc688 100644 --- a/apps/desktop/lib/src/controls/uosc/controls.dart +++ b/apps/desktop/lib/src/controls/uosc/controls.dart @@ -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? 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 { bool _visible = true; int _holds = 0; + bool _heldBy = false; Timer? _timer; bool get visible => _visible; @@ -61,6 +68,7 @@ class UoscControlsState extends State { @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 { _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 { 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 { : 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 { 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]), diff --git a/apps/desktop/lib/src/jellyfin/progress.dart b/apps/desktop/lib/src/jellyfin/progress.dart index cd75971..0695e5a 100644 --- a/apps/desktop/lib/src/jellyfin/progress.dart +++ b/apps/desktop/lib/src/jellyfin/progress.dart @@ -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 _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 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; } diff --git a/apps/desktop/lib/src/playback/yt_dlp_app.dart b/apps/desktop/lib/src/playback/yt_dlp_app.dart index 3316e81..837edf1 100644 --- a/apps/desktop/lib/src/playback/yt_dlp_app.dart +++ b/apps/desktop/lib/src/playback/yt_dlp_app.dart @@ -46,20 +46,44 @@ class AppYtDlp { } } - final _probes = )>{}; + final _probes = f})>{}; static const _probeTtl = Duration(minutes: 10); - Future 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 probe(String url, {Duration keep = _probeTtl, bool fresh = false}) { final now = DateTime.now(); - _probes.removeWhere((_, v) => now.difference(v.$1) > _probeTtl); + _probes.removeWhere((_, v) => now.isAfter(v.until)); final hit = _probes[url]; - if (hit != null) return hit.$2; + if (hit != null && !fresh) { + final longer = hit.at.add(keep); + if (longer.isAfter(hit.until)) _probes[url] = (at: hit.at, until: longer, f: hit.f); + return hit.f; + } final f = ytDlp.then((y) => probeLink(Uri.parse(url), ytDlp: y)); - _probes[url] = (now, f); - f.catchError((Object _) { - _probes.remove(url); - return LinkProbe.file(Uri(), -1); - }); + final until = now.add(keep); + if (hit == null) { + _probes[url] = (at: now, until: until, f: f); + f.catchError((Object _) { + if (_probes[url]?.f == f) _probes.remove(url); + return LinkProbe.file(Uri(), -1); + }); + } else { + f.then((_) { + _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); + } } diff --git a/apps/desktop/lib/src/queue/queue_controller.dart b/apps/desktop/lib/src/queue/queue_controller.dart index b63ef3a..3b84421 100644 --- a/apps/desktop/lib/src/queue/queue_controller.dart +++ b/apps/desktop/lib/src/queue/queue_controller.dart @@ -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 diff --git a/apps/desktop/lib/src/queue/queue_logic.dart b/apps/desktop/lib/src/queue/queue_logic.dart index bb97d4e..eb6f4b0 100644 --- a/apps/desktop/lib/src/queue/queue_logic.dart +++ b/apps/desktop/lib/src/queue/queue_logic.dart @@ -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 ''; diff --git a/apps/desktop/lib/src/queue/queue_ui.dart b/apps/desktop/lib/src/queue/queue_ui.dart index 0d98ee6..5ebdc65 100644 --- a/apps/desktop/lib/src/queue/queue_ui.dart +++ b/apps/desktop/lib/src/queue/queue_ui.dart @@ -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 · " with ▶ (play it now), top right while the controls -/// are up; the title opens the queue. Without Up next, the continuation -/// ("Next · S1E8 …", ▶ plays it). Nothing when both are empty. -class UpNextChip extends StatefulWidget { +/// "Up next · <title>" and ▶ (play it now) in the player's top bar, one +/// flat control among its buttons; the title opens the queue. Without Up +/// next, the continuation ("Next · S1E8 …", ▶ plays it). Nothing when +/// both are empty. +class UpNextButton extends StatefulWidget { final QueueController queue; /// Plays the continuation's first episode (the next episode). final VoidCallback onPlayNextEpisode; - const UpNextChip({super.key, required this.queue, required this.onPlayNextEpisode}); + final UpNextLook look; + const UpNextButton({super.key, required this.queue, required this.onPlayNextEpisode, required this.look}); @override - State<UpNextChip> createState() => _UpNextChipState(); + State<UpNextButton> createState() => _UpNextButtonState(); } -class _UpNextChipState extends State<UpNextChip> { +class _UpNextButtonState extends State<UpNextButton> { final _popover = LeafPopoverController(); bool _hoverTitle = false; QueueController get _q => widget.queue; + bool get _uosc => widget.look == UpNextLook.uosc; @override void dispose() { @@ -102,8 +80,19 @@ class _UpNextChipState extends State<UpNextChip> { handoffHolds = math.max(0, handoffHolds - 1); } + Widget _tip(String message, Widget child) => _uosc + ? UoscTooltip(message: message, child: child) + : Tooltip(message: message, waitDuration: const Duration(milliseconds: 500), child: child); + @override Widget build(BuildContext context) { + // The bar's own metrics: uosc's flat 40 px boxes, or media_kit's + // round 44 px icon buttons. + final height = _uosc ? UoscControls.buttonRowHeight : 44.0; + final radius = _uosc ? UoscStyle.radius : height / 2; + final hover = _uosc ? UoscStyle.hover : Colors.white10; + final fontSize = _uosc ? UoscStyle.fontSize : 14.0; + final dim = _uosc ? UoscStyle.dim : Colors.white70; return SignalBuilder(builder: (context) { final items = _q.items.value; final cont = _q.shownContinuation.value; @@ -112,85 +101,89 @@ class _UpNextChipState extends State<UpNextChip> { final queued = items.isNotEmpty; final title = queued ? items.first.title : (cont.isNotEmpty ? cont.first.label : 'Nothing yet'); final more = items.length - 1; - return _Glass( - radius: 999, - color: const Color(0xB3141418), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - LeafPopover( - controller: _popover, - popoverAnchor: Alignment.topRight, - childAnchor: Alignment.bottomRight, - spacing: 8, - onDismissed: _closed, - popoverBuilder: (context, c) => QueuePanel(queue: _q, onClose: c.hide), - child: MouseRegion( - cursor: SystemMouseCursors.click, - onEnter: (_) => setState(() => _hoverTitle = true), - onExit: (_) => setState(() => _hoverTitle = false), - child: GestureDetector( - key: const ValueKey('up-next-chip'), - behavior: HitTestBehavior.opaque, - onTap: _toggle, - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - height: 36, - padding: const EdgeInsets.only(left: 12, right: 10), - color: _hoverTitle || open ? const Color(0x14FFFFFF) : Colors.transparent, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(queued ? Icons.queue_play_next_rounded : Icons.playlist_play_rounded, - size: 17, color: Colors.white70), - const SizedBox(width: 8), - Text(queued ? 'Up next' : 'Next', - style: const TextStyle(color: Colors.white60, fontSize: 12.5)), - const Text(' · ', style: TextStyle(color: Colors.white38, fontSize: 12.5)), - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 240), - child: Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: queued ? Colors.white : Colors.white70, - fontSize: 13, - fontWeight: FontWeight.w500), - ), - ), - if (more > 0) ...[ - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), - decoration: BoxDecoration( - color: const Color(0x24FFFFFF), borderRadius: BorderRadius.circular(999)), - child: Text('+$more', style: const TextStyle(color: Colors.white70, fontSize: 11)), - ), - ], - const SizedBox(width: 4), - AnimatedRotation( - duration: easeTime, - curve: ease, - turns: open ? 0.5 : 0, - child: const Icon(Icons.expand_more_rounded, size: 18, color: Colors.white54), - ), - ], - ), + final label = MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hoverTitle = true), + onExit: (_) => setState(() => _hoverTitle = false), + child: GestureDetector( + key: const ValueKey('up-next-button'), + behavior: HitTestBehavior.opaque, + onTap: _toggle, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + height: height, + padding: const EdgeInsets.only(left: 10, right: 6), + decoration: BoxDecoration( + color: _hoverTitle || open ? hover : Colors.transparent, + borderRadius: BorderRadius.circular(radius), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(queued ? Icons.queue_play_next_rounded : Icons.playlist_play_rounded, + size: _uosc ? 22 : 24, color: Colors.white), + const SizedBox(width: 8), + Text(queued ? 'Up next' : 'Next', style: TextStyle(color: dim, fontSize: fontSize)), + Text(' · ', style: TextStyle(color: dim, fontSize: fontSize)), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 240), + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: queued ? Colors.white : dim, fontSize: fontSize, fontWeight: FontWeight.w500), ), ), - ), + if (more > 0) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration(color: UoscStyle.hover, borderRadius: BorderRadius.circular(999)), + child: Text('+$more', style: TextStyle(color: dim, fontSize: 11)), + ), + ], + const SizedBox(width: 2), + AnimatedRotation( + duration: easeTime, + curve: ease, + turns: open ? 0.5 : 0, + child: Icon(Icons.expand_more_rounded, size: 18, color: dim), + ), + ], ), - if (queued || cont.isNotEmpty) ...[ - Container(width: 0.5, height: 20, color: Colors.white24), - _RoundIcon( + ), + ), + ); + final playTip = queued ? 'Play ${items.first.title} now' : 'Play ${cont.isEmpty ? '' : cont.first.label} now'; + final VoidCallback? play = queued ? _q.skip : (cont.isNotEmpty ? widget.onPlayNextEpisode : null); + final playButton = _uosc + ? UoscButton(key: const ValueKey('up-next-play'), icon: Icons.skip_next_rounded, tooltip: playTip, onPressed: play) + : _tip( + playTip, + IconButton( key: const ValueKey('up-next-play'), - icon: Icons.play_arrow_rounded, - tooltip: queued ? 'Play ${items.first.title} now' : 'Play ${cont.first.label} now', - onPressed: queued ? _q.skip : widget.onPlayNextEpisode, + iconSize: 28, + color: Colors.white, + onPressed: play, + icon: const Icon(Icons.skip_next_rounded), ), - ], - ], + ); + // The popover hangs under the whole control, right edges aligned. + return LeafPopover( + controller: _popover, + popoverAnchor: Alignment.topRight, + childAnchor: Alignment.bottomRight, + spacing: 8, + onDismissed: _closed, + popoverBuilder: (context, c) => QueuePanel(queue: _q, onClose: c.hide), + // A little apart from the bar's other buttons: its own group. + child: Padding( + padding: const EdgeInsets.only(right: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [label, if (play != null) playButton], + ), ), ); }); @@ -378,7 +371,7 @@ class _QueuePanelState extends State<QueuePanel> { width: 424, child: ConstrainedBox( constraints: BoxConstraints(maxHeight: math.max(260, math.min(600, height - 140))), - child: _Glass( + child: AppGlass( child: AnimatedContainer( duration: easeTime, curve: ease, @@ -403,13 +396,13 @@ class _QueuePanelState extends State<QueuePanel> { ? _empty() : ListView( shrinkWrap: true, - padding: const EdgeInsets.fromLTRB(6, 0, 6, 6), + padding: const EdgeInsets.fromLTRB(4, 0, 4, 4), children: [ if (items.isEmpty) const Padding( - padding: EdgeInsets.fromLTRB(10, 2, 10, 8), + padding: EdgeInsets.fromLTRB(12, 2, 12, 8), child: Text('Nothing added yet. What plays next is below.', - style: TextStyle(color: Colors.white54, fontSize: 12.5)), + style: TextStyle(color: Colors.white54, fontSize: 12)), ) else ReorderableListView.builder( @@ -458,17 +451,18 @@ class _QueuePanelState extends State<QueuePanel> { ); } + // Text lines up with the rows' (4 panel + 12 row padding). Widget _header(int count) => Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 8, 8), + padding: const EdgeInsets.fromLTRB(16, 8, 6, 4), child: Row( children: [ - const Text('Up next', style: TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600)), + const Text('Up next', style: TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600)), if (count > 0) ...[ const SizedBox(width: 8), Container( padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 1), - decoration: BoxDecoration(color: const Color(0x24FFFFFF), borderRadius: BorderRadius.circular(999)), - child: Text('$count', style: const TextStyle(color: Colors.white70, fontSize: 11.5)), + decoration: BoxDecoration(color: AppMenuStyle.focus, borderRadius: BorderRadius.circular(999)), + child: Text('$count', style: const TextStyle(color: Colors.white70, fontSize: 11)), ), ], const Spacer(), @@ -484,13 +478,13 @@ class _QueuePanelState extends State<QueuePanel> { children: [ Icon(Icons.queue_play_next_rounded, size: 34, color: Colors.white30), SizedBox(height: 10), - Text('Nothing up next', style: TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500)), + Text('Nothing up next', style: TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600)), SizedBox(height: 4), Text( 'Paste a link below, drop a video or a link here, or pick "Add to queue" in the library. ' 'It plays when this one ends.', textAlign: TextAlign.center, - style: TextStyle(color: Colors.white54, fontSize: 12.5, height: 1.35), + style: TextStyle(color: Colors.white54, fontSize: 12, height: 1.35), ), ], ), @@ -498,11 +492,11 @@ class _QueuePanelState extends State<QueuePanel> { List<Widget> _continuation(List<EpisodeLink> cont, bool alone) => [ Padding( - padding: EdgeInsets.fromLTRB(10, alone ? 0 : 10, 2, 2), + padding: EdgeInsets.fromLTRB(12, alone ? 0 : 8, 2, 0), child: Row( children: [ const Text('CONTINUES WITH', - style: TextStyle(color: Colors.white38, fontSize: 10.5, letterSpacing: 0.8, fontWeight: FontWeight.w600)), + style: TextStyle(color: Colors.white38, fontSize: 11, letterSpacing: 0.8, fontWeight: FontWeight.w600)), const Spacer(), TextButton( key: const ValueKey('queue-clear-continuation'), @@ -518,7 +512,7 @@ class _QueuePanelState extends State<QueuePanel> { Widget _footer() => Container( decoration: const BoxDecoration(border: Border(top: BorderSide(color: AppMenuStyle.border, width: 0.5))), - padding: const EdgeInsets.fromLTRB(10, 8, 10, 10), + padding: const EdgeInsets.all(8), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, @@ -530,14 +524,16 @@ class _QueuePanelState extends State<QueuePanel> { // Read-only while adding, not disabled: focus stays here, so // Space and the arrows do not reach the player. readOnly: _busy, - style: const TextStyle(fontSize: 13.5), + style: const TextStyle(fontSize: 14), onSubmitted: (_) => _submit(), + // The app's field look (as `AppSelect`): a faint fill, a + // hairline, the accent while focused. decoration: InputDecoration( isDense: true, filled: true, - fillColor: const Color(0x14FFFFFF), + fillColor: const Color(0x0DFFFFFF), hintText: _dragging ? 'Drop to add' : 'Paste a link to add', - hintStyle: const TextStyle(color: Colors.white38, fontSize: 13.5), + hintStyle: const TextStyle(color: Colors.white38, fontSize: 14), prefixIcon: const Icon(Icons.add_link_rounded, size: 18, color: Colors.white54), prefixIconConstraints: const BoxConstraints(minWidth: 38), suffixIcon: _busy @@ -547,7 +543,14 @@ class _QueuePanelState extends State<QueuePanel> { ) : null, contentPadding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide.none), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppMenuStyle.rowRadius), + borderSide: const BorderSide(color: AppMenuStyle.border, width: 0.5), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppMenuStyle.rowRadius), + borderSide: BorderSide(color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.6), width: 0.5), + ), ), ), AnimatedSize( @@ -557,7 +560,7 @@ class _QueuePanelState extends State<QueuePanel> { child: _error == null ? const SizedBox(width: double.infinity) : Padding( - padding: const EdgeInsets.fromLTRB(4, 6, 4, 0), + padding: const EdgeInsets.fromLTRB(8, 6, 8, 0), child: Text(_error!, style: TextStyle(color: Colors.orange.shade300, fontSize: 12)), ), ), @@ -629,7 +632,7 @@ class _MetaLine extends StatelessWidget { /// Wraps onto a second line instead of cutting the end off. final bool wrap; const _MetaLine( - {required this.item, required this.members, required this.selfId, this.fontSize = 11.5, this.wrap = false}); + {required this.item, required this.members, required this.selfId, this.fontSize = 12, this.wrap = false}); @override Widget build(BuildContext context) { @@ -637,7 +640,7 @@ class _MetaLine extends StatelessWidget { final dur = queueClock(item.duration); final mine = item.by == selfId && selfId.isNotEmpty; final name = mine ? 'you' : (item.byName.isEmpty ? '?' : item.byName); - const dot = Text(' · ', style: TextStyle(color: Colors.white24, fontSize: 11)); + final dot = Text(' · ', style: TextStyle(color: Colors.white24, fontSize: fontSize)); if (wrap) { final how = queueHowLabel(item, selfId: selfId); return Wrap( @@ -681,6 +684,15 @@ class _MetaLine extends StatelessWidget { } } +/// Every row of the popover (queued and continuation): one height, the +/// drag handle's width before the picture (a spacer where there is none), +/// one picture size and one type scale. +const queueRowHeight = 64.0; +const _lead = 22.0; +const _thumb = 80.0; +const _titleStyle = TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500); +const _subStyle = TextStyle(color: Colors.white60, fontSize: 12); + class _QueueRow extends StatefulWidget { final int index; final QueueItem item; @@ -711,8 +723,8 @@ class _QueueRowState extends State<_QueueRow> { final item = widget.item; final row = AnimatedContainer( duration: const Duration(milliseconds: 120), - margin: const EdgeInsets.symmetric(vertical: 1), - padding: const EdgeInsets.fromLTRB(2, 6, 8, 6), + height: queueRowHeight, + padding: const EdgeInsets.only(right: 8), decoration: BoxDecoration( color: _hover ? AppMenuStyle.hover : Colors.transparent, borderRadius: BorderRadius.circular(AppMenuStyle.rowRadius), @@ -724,8 +736,8 @@ class _QueueRowState extends State<_QueueRow> { child: MouseRegion( cursor: SystemMouseCursors.grab, child: SizedBox( - width: 22, - height: 40, + width: _lead, + height: queueRowHeight, child: AnimatedOpacity( duration: const Duration(milliseconds: 120), opacity: _hover ? 0.8 : 0.25, @@ -734,29 +746,22 @@ class _QueueRowState extends State<_QueueRow> { ), ), ), - QueueThumb(queue: widget.queue, item: item), - const SizedBox(width: 10), + // Its readiness rides on the picture's corner. + Stack( + children: [ + QueueThumb(queue: widget.queue, item: item, width: _thumb), + if (widget.warm != null) Positioned(top: 3, right: 3, child: _WarmDot(state: widget.warm!)), + ], + ), + const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Row( - children: [ - Flexible( - child: Text(item.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: Colors.white, fontSize: 13.5, fontWeight: FontWeight.w500)), - ), - if (widget.warm != null) ...[const SizedBox(width: 6), _WarmDot(state: widget.warm!)], - ], - ), + Text(item.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: _titleStyle), if (item.sub.isNotEmpty) - Text(item.sub, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: Colors.white60, fontSize: 11.5)), + Text(item.sub, maxLines: 1, overflow: TextOverflow.ellipsis, style: _subStyle), const SizedBox(height: 2), _MetaLine(item: item, members: widget.members, selfId: widget.selfId), ], @@ -859,22 +864,30 @@ class _WarmDotState extends State<_WarmDot> with SingleTickerProviderStateMixin Widget build(BuildContext context) { final green = Colors.greenAccent.shade400; final (color, filled, tip) = switch (widget.state) { - WarmState.idle => (Colors.white54, false, 'Gets ready near the end of this one'), + WarmState.idle => (Colors.white54, false, 'Waiting to get ready'), WarmState.warming => (green, true, 'Getting it ready…'), WarmState.ready => (green, true, 'Ready to start'), - WarmState.failed => (Colors.redAccent, true, "Couldn't get it ready; it is tried again when it comes up"), + WarmState.failed => (Colors.redAccent, true, "Couldn't get it ready; trying again shortly"), }; return Tooltip( message: tip, - child: FadeTransition( - opacity: Tween(begin: 0.35, end: 1.0).animate(_pulse), - child: Container( - width: 8, - height: 8, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: filled ? color : Colors.transparent, - border: Border.all(color: color, width: 1.4), + // On a dark disc: it sits on the picture's corner. + child: Container( + key: const ValueKey('queue-warm-dot'), + width: 14, + height: 14, + alignment: Alignment.center, + decoration: const BoxDecoration(shape: BoxShape.circle, color: Color(0xB3000000)), + child: FadeTransition( + opacity: Tween(begin: 0.35, end: 1.0).animate(_pulse), + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: filled ? color : Colors.transparent, + border: Border.all(color: color, width: 1.4), + ), ), ), ), @@ -907,7 +920,8 @@ class _ContinuationRowState extends State<_ContinuationRow> { duration: const Duration(milliseconds: 120), opacity: _hover ? 0.9 : 0.55, child: Container( - padding: const EdgeInsets.fromLTRB(24, 5, 6, 5), + height: queueRowHeight, + padding: const EdgeInsets.only(left: _lead, right: 8), decoration: BoxDecoration( color: _hover ? AppMenuStyle.hover : Colors.transparent, borderRadius: BorderRadius.circular(AppMenuStyle.rowRadius), @@ -917,30 +931,27 @@ class _ContinuationRowState extends State<_ContinuationRow> { ClipRRect( borderRadius: BorderRadius.circular(6), child: SizedBox( - width: 72, - height: 40.5, + width: _thumb, + height: _thumb * 9 / 16, child: client == null ? const ColoredBox(color: Color(0x1FFFFFFF)) : JellyfinImage(client: client, image: image, icon: Icons.tv_rounded), ), ), - const SizedBox(width: 10), + const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Text(e.label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: Colors.white, fontSize: 13)), + Text(e.label, maxLines: 1, overflow: TextOverflow.ellipsis, style: _titleStyle), Text( [if (runtime != null) queueClock(runtime.inSeconds.toDouble()), e.item.seriesName ?? ''] .where((s) => s.isNotEmpty) .join(' · '), maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle(color: Colors.white54, fontSize: 11.5), + style: _subStyle, ), ], ), @@ -976,7 +987,7 @@ class UpNextCard extends StatelessWidget { final r = queue.coord.serverState.value; final selfId = queue.coord.sync.memberId; final waiting = left == null ? _waitingFor(item, r) : null; - return _Glass( + return AppGlass( key: const ValueKey('up-next-card'), child: SizedBox( width: 400, diff --git a/apps/desktop/lib/src/schedule/ui/schedule_card.dart b/apps/desktop/lib/src/schedule/ui/schedule_card.dart index 8e4d12b..0f5302e 100644 --- a/apps/desktop/lib/src/schedule/ui/schedule_card.dart +++ b/apps/desktop/lib/src/schedule/ui/schedule_card.dart @@ -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( diff --git a/apps/desktop/lib/src/schedule/ui/schedule_page.dart b/apps/desktop/lib/src/schedule/ui/schedule_page.dart index f38665e..d3e13b4 100644 --- a/apps/desktop/lib/src/schedule/ui/schedule_page.dart +++ b/apps/desktop/lib/src/schedule/ui/schedule_page.dart @@ -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, ); } diff --git a/apps/desktop/lib/src/screens/session/jellyfin_widgets.dart b/apps/desktop/lib/src/screens/session/jellyfin_widgets.dart index 787c87a..be92fe2 100644 --- a/apps/desktop/lib/src/screens/session/jellyfin_widgets.dart +++ b/apps/desktop/lib/src/screens/session/jellyfin_widgets.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../../jellyfin/session_media.dart'; import '../../session/session_coordinator.dart' show AutoAdvance, autoAdvanceSecs; +import '../../widgets/app_menu.dart' show AppGlass; import 'player_controls.dart' show FloatingPill; // Jellyfin pieces of the session player. Plain widgets fed by the @@ -69,27 +70,36 @@ class EndOfEpisodeCard extends StatelessWidget { @override Widget build(BuildContext context) { - final t = Theme.of(context).textTheme; final c = countdown; - return Card( + // The same card as Up next's (`UpNextCard`): the app's glass, 400 wide. + return AppGlass( key: const ValueKey('end-of-episode'), - color: Colors.black.withValues(alpha: 0.85), - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 12, 12), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 380), + child: SizedBox( + width: 400, + child: Padding( + padding: const EdgeInsets.all(12), child: Column( mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text(c == null ? 'Up next' : 'Next episode in ${c.secondsLeft} s', - style: t.labelLarge?.copyWith(color: Colors.white70)), - const SizedBox(height: 4), + style: const TextStyle(color: Colors.white60, fontSize: 12)), + const SizedBox(height: 2), Text('Next: ${next.label}', - maxLines: 2, overflow: TextOverflow.ellipsis, style: t.titleMedium), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600, height: 1.25)), if (c != null) ...[ - const SizedBox(height: 8), - LinearProgressIndicator(value: c.secondsLeft / autoAdvanceSecs), + const SizedBox(height: 10), + ClipRRect( + borderRadius: BorderRadius.circular(2), + child: TweenAnimationBuilder<double>( + tween: Tween(end: c.secondsLeft / autoAdvanceSecs), + duration: const Duration(seconds: 1), + builder: (context, v, _) => + LinearProgressIndicator(value: v, minHeight: 3, backgroundColor: const Color(0x1FFFFFFF)), + ), + ), ], const SizedBox(height: 10), Row( diff --git a/apps/desktop/lib/src/screens/session/player_controls.dart b/apps/desktop/lib/src/screens/session/player_controls.dart index 2afe829..77e0f06 100644 --- a/apps/desktop/lib/src/screens/session/player_controls.dart +++ b/apps/desktop/lib/src/screens/session/player_controls.dart @@ -7,6 +7,7 @@ import 'package:sync_relay_core/core.dart' show JellyfinStreamType; import '../../jellyfin/track_prefs.dart' show MpvTrack; import '../../playback/media_marks.dart'; +import '../../widgets/app_menu.dart' show AppGlass; // Session control-bar pieces built on the playback signals (chapters, // tracks, skip offers): the seek bar with chapter marks, the chapter and @@ -525,10 +526,13 @@ class SkipForwardButton extends StatelessWidget { } } -/// Floating pill over the video (Next episode, Skip OP / ED / 1:30): -/// faint at rest, lit on hover. Lives outside the auto-hiding controls -/// (also in fullscreen); `PillGate` shows it with them. +/// The player's action pill (Next episode, Skip OP / ED / 1:30): the +/// status pill's glass ([AppGlass.pill]) at [height], faint at rest, lit +/// on hover. Lives in the player's action area, outside the auto-hiding +/// controls (also in fullscreen); `PillGate` shows it with them. class FloatingPill extends StatefulWidget { + /// Every action pill's height: the status pill's without avatars. + static const height = 44.0; final String label; final String tooltip; final IconData icon; @@ -540,7 +544,7 @@ class FloatingPill extends StatefulWidget { required this.label, required this.tooltip, required this.onPressed, - this.icon = Icons.chevron_right, + this.icon = Icons.chevron_right_rounded, this.tapKey, }); @@ -553,38 +557,31 @@ class _FloatingPillState extends State<FloatingPill> { @override Widget build(BuildContext context) { - final primary = Theme.of(context).colorScheme.primary; + const fade = Duration(milliseconds: 150); return MouseRegion( cursor: SystemMouseCursors.click, onEnter: (_) => setState(() => _hover = true), onExit: (_) => setState(() => _hover = false), child: Tooltip( message: widget.tooltip, - child: AnimatedScale( - duration: const Duration(milliseconds: 150), - scale: _hover ? 1.06 : 1, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 150), - opacity: _hover ? 1 : 0.45, - child: GestureDetector( - key: widget.tapKey, - onTap: widget.onPressed, + child: AnimatedOpacity( + duration: fade, + opacity: _hover ? 1 : 0.45, + child: GestureDetector( + key: widget.tapKey, + onTap: widget.onPressed, + child: AppGlass.pill( child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 9), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.75), - borderRadius: BorderRadius.circular(999), - border: Border.all(color: _hover ? primary : Colors.white24), - boxShadow: _hover - ? [BoxShadow(color: primary.withValues(alpha: 0.45), blurRadius: 16)] - : const [], - ), + duration: fade, + height: FloatingPill.height, + padding: const EdgeInsets.only(left: 16, right: 12), + color: _hover ? Colors.white12 : Colors.transparent, child: Row( mainAxisSize: MainAxisSize.min, children: [ - Text(widget.label, style: const TextStyle(color: Colors.white, fontSize: 13)), - const SizedBox(width: 4), + Text(widget.label, + style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600)), + const SizedBox(width: 6), Icon(widget.icon, color: Colors.white, size: 18), ], ), diff --git a/apps/desktop/lib/src/session/session_coordinator.dart b/apps/desktop/lib/src/session/session_coordinator.dart index 008a676..e0f64da 100644 --- a/apps/desktop/lib/src/session/session_coordinator.dart +++ b/apps/desktop/lib/src/session/session_coordinator.dart @@ -204,6 +204,11 @@ class SessionCoordinator { /// Set when this viewer cannot play the Jellyfin media: no account and /// no relay. The session shows it with a way to link an account. final jellyfinBlocked = signal<BlockedPlay?>(null); + /// An item this app is about to put up, resolved ahead with its own + /// account (Up next's first item, the next episode; see + /// `QueueController`): opening it then skips PlaybackInfo. Used once, + /// until [until]. + ({JellyfinRef ref, OwnPlay plan, DateTime until})? warmJellyfin; /// Neighbouring episodes this viewer can put up (own account only). final previousEpisode = signal<EpisodeLink?>(null); final nextEpisode = signal<EpisodeLink?>(null); @@ -1620,8 +1625,9 @@ class SessionCoordinator { } JellyfinPlan plan; try { - plan = await resolveJellyfinRef(jellyfin, ref, - relay: r.relay, relayUrl: _relayUrlFor(ref), memberRelayUrl: memberRelayUrl); + plan = _takeWarmJellyfin(ref) ?? + await resolveJellyfinRef(jellyfin, ref, + relay: r.relay, relayUrl: _relayUrlFor(ref), memberRelayUrl: memberRelayUrl); } finally { if (gen == _jfGen) _opening = false; } @@ -1656,11 +1662,27 @@ class SessionCoordinator { case OwnPlay(:final url, :final headers): jellyfinBlocked.value = null; if (!await _openMedia(url, headers: headers) || gen != _jfGen) return; + // Played before and the resume point, fresh (a warmed plan's item + // is older): the watched rule never unmarks a played item. + final known = plan.item?.userData; + final userDataF = plan.client + .userData(ref.itemId) + .timeout(const Duration(seconds: 5)) + .then<JellyfinUserData?>((d) => d, onError: (_) => known); + unawaited(_findEpisodes(plan, gen)); + unawaited(_loadSegments(plan, ref, gen)); + // Playing carries the tracks: wait (bounded) until the file is in. + await playback.waitForTracks(); + if (gen != _jfGen) return; + final before = await userDataF; + if (gen != _jfGen) return; final reporter = JellyfinProgressReporter( client: plan.client, itemId: ref.itemId, source: plan.source, playSessionId: plan.info.playSessionId, + playedBefore: before?.played ?? false, + resumeTicksBefore: before?.playbackPositionTicks ?? 0, onUnauthorized: () => jellyfin.markExpired(ref.serverId), onError: (e) { if (_jfToastShown) return; @@ -1668,11 +1690,6 @@ class SessionCoordinator { _pushToast('Jellyfin progress not saved: ${e is JellyfinException && e.isUnauthorized ? 'sign-in expired' : 'server unreachable'}'); }, ); - unawaited(_findEpisodes(plan, gen)); - unawaited(_loadSegments(plan, ref, gen)); - // Playing carries the tracks: wait (bounded) until the file is in. - await playback.waitForTracks(); - if (gen != _jfGen) return; final snap = await _snapshot(); if (gen != _jfGen || snap == null) return; // Installed once started: the per-state restart (after the end) @@ -1682,6 +1699,18 @@ class SessionCoordinator { await _applyTrackChoice(plan, gen); } + /// [warmJellyfin] when it is [ref], still fresh and its account + /// unchanged. + OwnPlay? _takeWarmJellyfin(JellyfinRef ref) { + final w = warmJellyfin; + if (w == null || !sameJellyfinItem(w.ref, ref)) return null; + warmJellyfin = null; + final fresh = DateTime.now().isBefore(w.until) && + jellyfin.account(ref.serverId)?.token == w.plan.account.token && + jellyfin.statusOf(ref.serverId) != JellyfinAccountStatus.expired; + return fresh ? w.plan : null; + } + Future<void> _findEpisodes(OwnPlay plan, int gen) async { final around = await findAdjacentEpisodes(plan); if (gen != _jfGen) return; @@ -1734,7 +1763,10 @@ class SessionCoordinator { final snap = await _snapshot(); if (snap == null) return; if (reporter != null && identical(reporter, _reporter) && r.transport != 'finished') { - reporter.update(snap); + final ending = playback.ending.peek()?.start; + reporter + ..timeline(duration: playback.durationSecs, endingStart: ending == null ? null : ending.inMilliseconds / 1000) + ..update(snap); } final next = nextEpisode.peek(); final duration = playback.durationSecs; diff --git a/apps/desktop/lib/src/shell/app_shell.dart b/apps/desktop/lib/src/shell/app_shell.dart index ab49944..6be3545 100644 --- a/apps/desktop/lib/src/shell/app_shell.dart +++ b/apps/desktop/lib/src/shell/app_shell.dart @@ -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( diff --git a/apps/desktop/lib/src/shell/handoff.dart b/apps/desktop/lib/src/shell/handoff.dart index 5767c24..a6edb2a 100644 --- a/apps/desktop/lib/src/shell/handoff.dart +++ b/apps/desktop/lib/src/shell/handoff.dart @@ -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>[]), diff --git a/apps/desktop/lib/src/shell/player_status.dart b/apps/desktop/lib/src/shell/player_status.dart index 70f8c54..8d579ae 100644 --- a/apps/desktop/lib/src/shell/player_status.dart +++ b/apps/desktop/lib/src/shell/player_status.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:ui' show ImageFilter; import 'package:flutter/material.dart'; import 'package:sync_relay_core/core.dart'; @@ -7,6 +6,7 @@ import 'package:sync_relay_core/core.dart'; import '../session/health.dart'; import '../session/session_coordinator.dart'; import '../state/session_state.dart'; +import '../widgets/app_menu.dart' show AppGlass; import 'avatar.dart'; import 'motion.dart'; @@ -241,32 +241,17 @@ class _PlayerStatusState extends State<PlayerStatus> { } } -/// Frosted dark glass in a pill: the status pill, the unread chip. +/// The player's pill surface (the status pill, the action pills, the +/// unread chip): the app's glass as a stadium ([AppGlass.pill]). class PlayerGlass extends StatelessWidget { final Widget child; const PlayerGlass({super.key, required this.child}); @override - Widget build(BuildContext context) { - final r = BorderRadius.circular(999); - return ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 560), - child: ClipRRect( - borderRadius: r, - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), - child: DecoratedBox( - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.55), - borderRadius: r, - border: Border.all(color: Colors.white12, width: 0.5), - ), - child: child, - ), - ), - ), - ); - } + Widget build(BuildContext context) => ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: AppGlass.pill(child: child), + ); } class _StatusLine extends StatelessWidget { diff --git a/apps/desktop/lib/src/shell/session_player.dart b/apps/desktop/lib/src/shell/session_player.dart index fffbe10..6fa30a8 100644 --- a/apps/desktop/lib/src/shell/session_player.dart +++ b/apps/desktop/lib/src/shell/session_player.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math' as math; import 'package:flutter/foundation.dart' show ValueListenable; import 'package:flutter/gestures.dart' show kPrimaryMouseButton; @@ -105,6 +106,10 @@ class _SessionPlayerState extends State<SessionPlayer> { // does (shown on mount and on mouse movement, hidden 3 s later or on // exit), the uosc ones reporting it. final _controlsShown = ValueNotifier(true); + // The mouse on a floating element, or the Up next popover open, keeps + // the controls up. + final _hold = ControlsHold(); + late final void Function() _stopPopoverWatch; SessionCoordinator get _coord => SessionState.instance.coordinator; bool get _full => widget.mode == PlayerMode.full; @@ -124,6 +129,9 @@ class _SessionPlayerState extends State<SessionPlayer> { _shaderNoticeSeen = n.id; _toast(n.text); }); + _stopPopoverWatch = effect(() { + _hold.set(#upNextPopover, _coord.queue.popoverOpen.value); + }); } @override @@ -131,8 +139,10 @@ class _SessionPlayerState extends State<SessionPlayer> { HardwareKeyboard.instance.removeHandler(_globalKey); AppKeys.removeChatToggle(_toggleChat); _stopShaderWatch(); + _stopPopoverWatch(); _noFocus.dispose(); _controlsShown.dispose(); + _hold.dispose(); super.dispose(); } @@ -291,10 +301,9 @@ class _SessionPlayerState extends State<SessionPlayer> { children: [ video, if (_full) ...[ - _status(), _progressLine(), - _jellyfinOverlay(), - _upNextChip(), + _blockedCard(), + _actionArea(), if (widget.fullscreen) ...[if (!SessionState.instance.solo.value) _pausedParty(), _unreadChip()], ] else _miniOverlay(), @@ -390,6 +399,7 @@ class _SessionPlayerState extends State<SessionPlayer> { child: Row( children: [ const Expanded(child: NowPlayingTitle()), + UpNextButton(queue: coord.queue, onPlayNextEpisode: coord.playNextEpisode, look: UpNextLook.standard), MaterialDesktopCustomButton( icon: const Icon(Icons.video_library_outlined), onPressed: widget.onChangeVideo, @@ -522,6 +532,7 @@ class _SessionPlayerState extends State<SessionPlayer> { material: (_) => ControlsHoverProbe( shown: _controlsShown, hideAfter: _controlsHide, + hold: _hold, child: DoubleClickListener( onDoubleClick: widget.onToggleFullscreen, // Clicks on the bars are button clicks, not the video. @@ -553,11 +564,14 @@ class _SessionPlayerState extends State<SessionPlayer> { }); return UoscControls( shown: _controlsShown, + holdBy: _hold, onDoubleTap: widget.onToggleFullscreen, topLeft: const NowPlayingTitle(), + // Up next is one of the top bar's controls, before chat. top: Row( mainAxisSize: MainAxisSize.min, children: [ + UpNextButton(queue: coord.queue, onPlayNextEpisode: coord.playNextEpisode, look: UpNextLook.uosc), UoscButton( key: const ValueKey('uosc-chat'), icon: widget.chatOpen ? Icons.chat_bubble_rounded : Icons.chat_bubble_outline_rounded, @@ -670,114 +684,129 @@ class _SessionPlayerState extends State<SessionPlayer> { ); } - /// The one status pill, bottom centre above the controls. - Widget _status() => - const Positioned(left: 16, right: 16, bottom: 124, child: Center(child: PlayerStatus())); + /// The side inset every floating element keeps (the bars' own), and + /// the gap between them and the controls. + static const _edge = 16.0; - /// Jellyfin: the blocked card, the end-of-episode card (countdown) and - /// the "Next episode" pill; for any media the skip pill (Skip OP / ED / - /// 1:30) above it. Outside the auto-hiding controls; the cards stay up, - /// the pills show only with the controls ([PillGate]). - Widget _jellyfinOverlay() { - final coord = _coord; - return Positioned.fill( - child: SignalBuilder( - builder: (context) { - final blocked = coord.jellyfinBlocked.value; - final next = coord.nextEpisode.value; - final countdown = coord.autoAdvance.value; - // Up next goes before the next episode. - final upNext = coord.queue.countdown.value; - final finished = coord.serverState.value?.transport == 'finished'; - final pill = coord.nextPromptVisible.value; - final SkipOffer? skip = coord.skipOffer.value; - final showNext = next != null && pill; - return Stack( - children: [ - if (blocked != null) - Center( - child: JellyfinBlockedCard( - blocked: blocked, - onLinkAccount: widget.onOpenIntegrations, - onRetry: coord.retryJellyfin, - ), - ), - if (upNext != null && finished) - Positioned( - right: 24, - bottom: 138, - child: UpNextCard(queue: coord.queue, countdown: upNext), - ) - // "Clear" in Up next hides the next episode for this media. - else if (next != null && - (finished || countdown != null) && - coord.queue.shownContinuation.value.isNotEmpty) - Positioned( - right: 24, - bottom: 138, - child: EndOfEpisodeCard( - next: next, - countdown: countdown, - onPlayNow: coord.playNextEpisode, - onCancel: coord.cancelAutoAdvance, - ), - ) - else if (showNext || skip != null) - Positioned( - right: 24, - bottom: 124, - child: Column( - crossAxisAlignment: CrossAxisAlignment.end, - spacing: 10, - children: [ - if (skip != null) - PillGate( - key: const ValueKey('skip-gate'), - kind: skip.kind, - // Skip ED: no announcing, only with the controls. - announce: skip.kind != SkipKind.ending, - controlsShown: _controlsShown, - child: SkipPill(offer: skip, onPressed: () => coord.skip(skip)), - ), - if (showNext) - PillGate( - key: const ValueKey('next-gate'), - kind: next, - controlsShown: _controlsShown, - child: NextEpisodePill(episode: next, onPressed: coord.playNextEpisode), - ), - ], - ), - ), - ], - ); - }, - ), - ); - } + /// The bottom controls' height in the design in use. + static double _bottomControlsHeight(bool uosc) => uosc + ? UoscControls.buttonRowHeight + UoscControls.rowBottomPadding + UoscTimeline.height + : _barHeight + ChapterSeekBar.height; - /// "Up next · <title>" top right, with the controls (and while its - /// popover is open); under the party in fullscreen. - Widget _upNextChip() { - final queue = _coord.queue; - return Positioned( - right: 16, - top: widget.fullscreen ? _barHeight + 60 : _topBarHeight + 8, - child: SignalBuilder( - builder: (context) => PillGate( - key: const ValueKey('up-next-gate'), - kind: 'up-next', - controlsShown: queue.popoverOpen.value ? const _AlwaysOn() : _controlsShown, - child: UpNextChip(queue: queue, onPlayNextEpisode: _coord.playNextEpisode), + /// The action area: one band above the bottom controls for everything + /// that floats there. The status pill (the ready prompt among it) + /// centred; at the right edge the end cards, else the action pills + /// (Skip OP / ED / 1:30 over Next episode). It rides on the controls: + /// just above them while they are up, down near the edge while they are + /// hidden. The mouse on any of it counts as the mouse on the controls + /// ([ControlsHold]). + Widget _actionArea() { + return SignalBuilder(builder: (context) { + final controls = _bottomControlsHeight(AppSettings.instance.mpvControls.value); + return ValueListenableBuilder<bool>( + valueListenable: _controlsShown, + builder: (context, shown, child) => AnimatedPositioned( + duration: easeTime, + curve: ease, + left: _edge, + right: _edge, + top: 0, + bottom: shown ? controls + _edge : _edge, + child: child!, ), - ), + child: CustomMultiChildLayout( + delegate: _ActionAreaLayout(gap: _edge), + children: [ + LayoutId(id: _ActionSlot.status, child: ControlsHoldRegion(hold: _hold, child: const PlayerStatus())), + LayoutId(id: _ActionSlot.side, child: _actionSide()), + ], + ), + ); + }); + } + + /// The action area's right end: the end-of-media card (Up next, else the + /// next episode; it stays up), else the pills, shown only with the + /// controls ([PillGate]). + Widget _actionSide() { + final coord = _coord; + return SignalBuilder( + builder: (context) { + final next = coord.nextEpisode.value; + final countdown = coord.autoAdvance.value; + // Up next goes before the next episode. + final upNext = coord.queue.countdown.value; + final finished = coord.serverState.value?.transport == 'finished'; + final pill = coord.nextPromptVisible.value; + final SkipOffer? skip = coord.skipOffer.value; + final showNext = next != null && pill; + if (upNext != null && finished) { + return ControlsHoldRegion(hold: _hold, child: UpNextCard(queue: coord.queue, countdown: upNext)); + } + // "Clear" in Up next hides the next episode for this media. + if (next != null && (finished || countdown != null) && coord.queue.shownContinuation.value.isNotEmpty) { + return ControlsHoldRegion( + hold: _hold, + child: EndOfEpisodeCard( + next: next, + countdown: countdown, + onPlayNow: coord.playNextEpisode, + onCancel: coord.cancelAutoAdvance, + ), + ); + } + if (!showNext && skip == null) return const SizedBox.shrink(); + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + spacing: 8, + children: [ + if (skip != null) + PillGate( + key: const ValueKey('skip-gate'), + kind: skip.kind, + // Skip ED: no announcing, only with the controls. + announce: skip.kind != SkipKind.ending, + controlsShown: _controlsShown, + hold: _hold, + child: SkipPill(offer: skip, onPressed: () => coord.skip(skip)), + ), + if (showNext) + PillGate( + key: const ValueKey('next-gate'), + kind: next, + controlsShown: _controlsShown, + hold: _hold, + child: NextEpisodePill(episode: next, onPressed: coord.playNextEpisode), + ), + ], + ); + }, ); } + /// Jellyfin refused this viewer: the card in the middle (it stays up). + Widget _blockedCard() => Positioned.fill( + child: SignalBuilder(builder: (context) { + final blocked = _coord.jellyfinBlocked.value; + if (blocked == null) return const SizedBox.shrink(); + return Center( + child: ControlsHoldRegion( + hold: _hold, + child: JellyfinBlockedCard( + blocked: blocked, + onLinkAccount: widget.onOpenIntegrations, + onRetry: _coord.retryJellyfin, + ), + ), + ); + }), + ); + /// Fullscreen hides the strip: while paused the party stays in a corner. Widget _pausedParty() => Positioned( - right: 16, - top: _barHeight + 8, + right: _edge, + top: _topBarHeight + 8, child: PartyWatch(builder: (context) { final r = _coord.serverState.value; final paused = r != null && r.transport != 'playing'; @@ -795,8 +824,8 @@ class _SessionPlayerState extends State<SessionPlayer> { /// glass chip says so top right (while it plays, only the controls' chat /// button shows the count: nothing sits on the picture). Widget _unreadChip() => Positioned( - right: 16, - top: 16, + right: _edge, + top: _edge, child: IgnorePointer( child: ValueListenableBuilder<bool>( valueListenable: _controlsShown, @@ -945,32 +974,24 @@ class _SessionPlayerState extends State<SessionPlayer> { } } -/// Always up (a pill whose popover is open). -class _AlwaysOn implements ValueListenable<bool> { - const _AlwaysOn(); - @override - bool get value => true; - @override - void addListener(VoidCallback listener) {} - @override - void removeListener(VoidCallback listener) {} -} - /// A floating pill's visibility: only while the controls are up (the /// mouse moved) or the mouse is on it. With [announce] it is also up for /// [firstShown] whenever [kind] changes (a new offer: Skip OP, Skip 1:30). -/// Gone with the offer (the parent drops it). +/// Gone with the offer (the parent drops it). The mouse on it holds +/// [hold]: it counts as the mouse on the controls. class PillGate extends StatefulWidget { static const firstShown = Duration(seconds: 5); final Object kind; final bool announce; final ValueListenable<bool> controlsShown; + final ControlsHold? hold; final Widget child; const PillGate({ super.key, required this.kind, this.announce = false, required this.controlsShown, + this.hold, required this.child, }); @@ -1014,8 +1035,8 @@ class _PillGateState extends State<PillGate> { @override Widget build(BuildContext context) { - // The pill covers the controls' hover area: on it, they may hide while - // it stays. + // The pill covers the controls' hover area: on it, [hold] keeps them + // up (and it stays while the mouse is on it). return ValueListenableBuilder<bool>( valueListenable: widget.controlsShown, builder: (context, shown, child) { @@ -1029,11 +1050,34 @@ class _PillGateState extends State<PillGate> { ), ); }, - child: widget.child, + child: ControlsHoldRegion(hold: widget.hold, child: widget.child), ); } } +enum _ActionSlot { status, side } + +/// The action area's two slots, at its bottom: [_ActionSlot.side] at the +/// right end, [_ActionSlot.status] centred, moved left only as far as it +/// must to keep [gap] from the side (and never wider than what is left). +class _ActionAreaLayout extends MultiChildLayoutDelegate { + final double gap; + _ActionAreaLayout({required this.gap}); + + @override + void performLayout(Size size) { + final side = layoutChild(_ActionSlot.side, BoxConstraints.loose(size)); + positionChild(_ActionSlot.side, Offset(size.width - side.width, size.height - side.height)); + final room = side.width > 0 ? size.width - side.width - gap : size.width; + final status = layoutChild(_ActionSlot.status, BoxConstraints.loose(Size(math.max(0, room), size.height))); + final x = math.min((size.width - status.width) / 2, room - status.width); + positionChild(_ActionSlot.status, Offset(math.max(0, x), size.height - status.height)); + } + + @override + bool shouldRelayout(_ActionAreaLayout old) => old.gap != gap; +} + const _shaderKeys = [ LogicalKeyboardKey.digit0, LogicalKeyboardKey.digit1, diff --git a/apps/desktop/lib/src/widgets/app_menu.dart b/apps/desktop/lib/src/widgets/app_menu.dart index 61d0411..73f2ec2 100644 --- a/apps/desktop/lib/src/widgets/app_menu.dart +++ b/apps/desktop/lib/src/widgets/app_menu.dart @@ -22,6 +22,9 @@ abstract final class AppMenuStyle { static const border = Color(0x1FFFFFFF); static const hover = Color(0x14FFFFFF); static const focus = Color(0x24FFFFFF); + /// The player's pills over the picture: lighter, so less of it is hidden. + static const pillGlass = Color(0x8C000000); + static const blur = 16.0; static const radius = 12.0; static const rowRadius = 8.0; static const rowHeight = 36.0; @@ -53,6 +56,53 @@ abstract final class AppMenuStyle { ); } +/// The app's one frosted surface: a blur under a dark tint, a hairline +/// border and (cards, menus) a soft shadow. Menus, popovers and the +/// player's cards use it as is; the player's pills with [pill]. +class AppGlass extends StatelessWidget { + final Widget child; + final double radius; + final Color color; + final bool shadow; + const AppGlass({ + super.key, + required this.child, + this.radius = AppMenuStyle.radius, + this.color = AppMenuStyle.glass, + this.shadow = true, + }); + + /// A stadium over the picture: the lighter tint, no shadow. + const AppGlass.pill({super.key, required this.child}) + : radius = 999, + color = AppMenuStyle.pillGlass, + shadow = false; + + @override + Widget build(BuildContext context) { + final r = BorderRadius.circular(radius); + final glass = ClipRRect( + borderRadius: r, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: AppMenuStyle.blur, sigmaY: AppMenuStyle.blur), + child: Material( + color: color, + shape: RoundedRectangleBorder(borderRadius: r, side: const BorderSide(color: AppMenuStyle.border, width: 0.5)), + child: child, + ), + ), + ); + if (!shadow) return glass; + return DecoratedBox( + decoration: BoxDecoration( + borderRadius: r, + boxShadow: const [BoxShadow(color: Color(0x66000000), blurRadius: 24, offset: Offset(0, 8))], + ), + child: glass, + ); + } +} + /// One row: [label], a dim [hint] at the right, [checked] = the current /// one (null: the menu has no checks for this row). class AppMenuItem<T> { @@ -220,39 +270,24 @@ class _AppMenuPanelState extends State<_AppMenuPanel> { @override Widget build(BuildContext context) { final checks = widget.items.any((i) => i.checked != null); - final r = BorderRadius.circular(AppMenuStyle.radius); - return DecoratedBox( - decoration: BoxDecoration( - borderRadius: r, - boxShadow: const [BoxShadow(color: Color(0x66000000), blurRadius: 24, offset: Offset(0, 8))], - ), - child: ClipRRect( - borderRadius: r, - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), - child: Material( - key: const ValueKey('app-menu'), - color: AppMenuStyle.glass, - shape: AppMenuStyle.shape, - child: IntrinsicWidth( - child: SingleChildScrollView( - padding: const EdgeInsets.all(4), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - for (final (i, item) in widget.items.indexed) - _MenuRow( - key: i == _start ? _current : null, - item: item, - checks: checks, - autofocus: i == _start, - onTap: () => widget.onPick(i), - ), - ], + return AppGlass( + key: const ValueKey('app-menu'), + child: IntrinsicWidth( + child: SingleChildScrollView( + padding: const EdgeInsets.all(4), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final (i, item) in widget.items.indexed) + _MenuRow( + key: i == _start ? _current : null, + item: item, + checks: checks, + autofocus: i == _start, + onTap: () => widget.onPick(i), ), - ), - ), + ], ), ), ), diff --git a/apps/desktop/test/jellyfin_e2e_test.dart b/apps/desktop/test/jellyfin_e2e_test.dart index 3471390..50592a8 100644 --- a/apps/desktop/test/jellyfin_e2e_test.dart +++ b/apps/desktop/test/jellyfin_e2e_test.dart @@ -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'); diff --git a/apps/desktop/test/jellyfin_session_test.dart b/apps/desktop/test/jellyfin_session_test.dart index b54e78a..ee62aba 100644 --- a/apps/desktop/test/jellyfin_session_test.dart +++ b/apps/desktop/test/jellyfin_session_test.dart @@ -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)); diff --git a/apps/desktop/test/queue_logic_test.dart b/apps/desktop/test/queue_logic_test.dart index eb2dc63..6b3a955 100644 --- a/apps/desktop/test/queue_logic_test.dart +++ b/apps/desktop/test/queue_logic_test.dart @@ -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'); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5f07303..e78a485 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 diff --git a/packages/core/lib/core.dart b/packages/core/lib/core.dart index 61142a1..500041f 100644 --- a/packages/core/lib/core.dart +++ b/packages/core/lib/core.dart @@ -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'; diff --git a/packages/core/lib/src/jellyfin/client.dart b/packages/core/lib/src/jellyfin/client.dart index 29c188f..78c25a9 100644 --- a/packages/core/lib/src/jellyfin/client.dart +++ b/packages/core/lib/src/jellyfin/client.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()); diff --git a/packages/core/lib/src/watch_session.dart b/packages/core/lib/src/watch_session.dart new file mode 100644 index 0000000..b3a9ac7 --- /dev/null +++ b/packages/core/lib/src/watch_session.dart @@ -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); + } +} diff --git a/packages/core/test/watch_session_test.dart b/packages/core/test/watch_session_test.dart new file mode 100644 index 0000000..8f369dc --- /dev/null +++ b/packages/core/test/watch_session_test.dart @@ -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); + }); +}