From cf80bb2167377f65bbd40a23ea80d3e8c975e92f Mon Sep 17 00:00:00 2001 From: foxyz Date: Fri, 9 Oct 2026 07:31:03 +0300 Subject: [PATCH] End of video plays out, no false 'loading' near/after the end; bigger status pill avatars; start card fits the mini Co-Authored-By: Claude Opus 5.5 --- .../lib/src/playback/playback_controller.dart | 11 ++- .../lib/src/session/session_coordinator.dart | 2 +- apps/desktop/lib/src/shell/avatar.dart | 2 +- apps/desktop/lib/src/shell/player_status.dart | 85 ++++++++++++------- apps/desktop/lib/src/shell/start_banner.dart | 14 +-- apps/desktop/lib/src/sync/state_diff.dart | 12 ++- apps/desktop/test/state_diff_test.dart | 11 ++- docs/ARCHITECTURE.md | 17 ++-- packages/core/lib/src/protocol.dart | 11 +++ 9 files changed, 113 insertions(+), 52 deletions(-) diff --git a/apps/desktop/lib/src/playback/playback_controller.dart b/apps/desktop/lib/src/playback/playback_controller.dart index ed5ad9c..12db6ec 100644 --- a/apps/desktop/lib/src/playback/playback_controller.dart +++ b/apps/desktop/lib/src/playback/playback_controller.dart @@ -42,7 +42,7 @@ class PlaybackController { bool _probing = false; final FrameWatchdog _watchdog = FrameWatchdog(); StreamSubscription? _logSub; - final List> _timeSubs = []; + final List> _timeSubs = []; String _chapterJson = ''; // `glsl-shaders` as last set on the current player ('' = none). String _shaders = ''; @@ -430,9 +430,18 @@ class PlaybackController { // track-list observed in mpv (read back as JSON). void _watchMarks(SyncPlayer p) { _timeSubs.add(p.stream.position.listen((d) { + // At the end the bar shows full: mpv's last position is the last + // frame's start (and the 250 ms steps round down), short of duration. + if (p.state.completed && duration.value > Duration.zero) { + position.value = duration.value; + return; + } final ms = d.inMilliseconds; position.value = Duration(milliseconds: ms - ms % 250); })); + _timeSubs.add(p.stream.completed.listen((done) { + if (done && duration.value > Duration.zero) position.value = duration.value; + })); _timeSubs.add(p.stream.buffer.listen((d) { final ms = d.inMilliseconds; buffered.value = Duration(milliseconds: ms - ms % 250); diff --git a/apps/desktop/lib/src/session/session_coordinator.dart b/apps/desktop/lib/src/session/session_coordinator.dart index 381c464..6a55781 100644 --- a/apps/desktop/lib/src/session/session_coordinator.dart +++ b/apps/desktop/lib/src/session/session_coordinator.dart @@ -717,7 +717,7 @@ class SessionCoordinator { return; } for (final m in r.members) { - if (m.inSet && m.buffered < RelayConstants.minBufferSecs) { + if (m.inSet && m.buffered < r.bufferNeed) { starving.value = 'waiting for ${m.name} — ${m.buffered.toStringAsFixed(1)} s buffered'; return; diff --git a/apps/desktop/lib/src/shell/avatar.dart b/apps/desktop/lib/src/shell/avatar.dart index 67c7ae0..93391bf 100644 --- a/apps/desktop/lib/src/shell/avatar.dart +++ b/apps/desktop/lib/src/shell/avatar.dart @@ -100,7 +100,7 @@ AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator color: self ? Profile.instance.color.value : colorForName(m.name), image: memberAvatarImage(m, coord), ready: m.ready, - buffering: !m.stale && m.ready && playing && m.buffered < RelayConstants.minBufferSecs, + buffering: !m.stale && m.ready && playing && m.buffered < r!.bufferNeed, relaying: relay != null && relay.source.isNotEmpty && relay.source == m.id, stale: m.stale, // The state marks the host (`members[].host`); your own app also diff --git a/apps/desktop/lib/src/shell/player_status.dart b/apps/desktop/lib/src/shell/player_status.dart index 1abd973..e1e28ff 100644 --- a/apps/desktop/lib/src/shell/player_status.dart +++ b/apps/desktop/lib/src/shell/player_status.dart @@ -87,16 +87,18 @@ StatusView? playerStatusFor({ } // As the server holds playback: someone in the ready set short of the // minimum buffer. + final need = r.bufferNeed; for (final m in r.members) { - if (m.inSet && m.buffered < RelayConstants.minBufferSecs) { + if (m.inSet && m.buffered < need) { final you = self != null && m.id == self.id; + final p = (m.buffered / need).clamp(0.0, 1.0); return StatusView(StatusKind.loading, you ? 'Loading the video…' : '${m.name} is loading…', - who: [m], self: you, progress: (m.buffered / RelayConstants.minBufferSecs).clamp(0.0, 1.0)); + who: [m], self: you, progress: p, detail: '${(p * 100).round()}%'); } } if (self != null && !self.ready) { return StatusView(StatusKind.you, "Press Space when you're ready", - who: [self], self: true, action: StatusAction.ready, short: 'Press Space when ready'); + who: [self], self: true, action: StatusAction.ready, detail: 'or click here', short: 'Press Space when ready'); } final active = [for (final m in r.members) if (!m.stale) m]; final others = [ @@ -251,7 +253,7 @@ class _StatusLine extends StatelessWidget { final ValueChanged onAction; const _StatusLine({super.key, required this.view, required this.coord, required this.onAction}); - static const _avatar = 24.0; + static const _avatar = 34.0; Widget? _lead() { final v = view; @@ -260,7 +262,7 @@ class _StatusLine extends StatelessWidget { case StatusKind.problem: final error = v.tone == HealthSeverity.error; return Icon(error ? Icons.error_outline_rounded : Icons.warning_amber_rounded, - size: 18, color: error ? Colors.redAccent.shade100 : Colors.amber); + size: 20, color: error ? Colors.redAccent.shade100 : Colors.amber); case StatusKind.reconnecting: return const _Spinner(color: Colors.amber); case StatusKind.connecting: @@ -276,9 +278,26 @@ class _StatusLine extends StatelessWidget { v.self ? selfAvatarInfo(ready: m.ready, host: m.host) : memberAvatarInfo(m, r, coord), ]; if (infos.isEmpty) return null; - return infos.length == 1 + final avatar = infos.length == 1 ? StatusAvatar(info: infos.first, size: _avatar, badges: false) : AvatarStack(members: infos, shown: 4, size: _avatar); + if (v.progress == null) return avatar; + // Loading: the progress runs round the avatar. + return Stack( + alignment: Alignment.center, + children: [ + avatar, + SizedBox.square( + dimension: _avatar + 6, + child: CircularProgressIndicator( + value: v.progress, + strokeWidth: 2.5, + color: Colors.white, + backgroundColor: Colors.white24, + ), + ), + ], + ); } } @@ -286,43 +305,45 @@ class _StatusLine extends StatelessWidget { Widget build(BuildContext context) { final v = view; final lead = _lead(); + final avatars = v.who.isNotEmpty; final action = v.action == StatusAction.ready ? null : v.action; return Padding( - padding: EdgeInsets.fromLTRB(lead == null ? 16 : 6, 6, action == null ? 16 : 6, 6), + padding: EdgeInsets.fromLTRB(lead == null ? 18 : (avatars ? 5 : 14), 5, action == null ? 18 : 6, 5), child: Row( mainAxisSize: MainAxisSize.min, children: [ if (lead != null) ...[ - SizedBox(height: _avatar, child: Center(child: lead)), - const SizedBox(width: 8), + SizedBox(height: _avatar + 6, child: Center(child: lead)), + SizedBox(width: avatars ? 10 : 8), ], Flexible( - child: Text( - v.text, - key: const ValueKey('player-status-text'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600), - ), - ), - if (v.progress != null) ...[ - const SizedBox(width: 8), - SizedBox.square( - dimension: 14, - child: CircularProgressIndicator( - value: v.progress, - strokeWidth: 2, - color: Colors.white, - backgroundColor: Colors.white24, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: _avatar), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + v.text, + key: const ValueKey('player-status-text'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600, height: 1.2), + ), + if (v.detail != null) + Text( + v.detail!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white60, fontSize: 12, fontWeight: FontWeight.w500, height: 1.2), + ), + ], ), ), - ], - if (v.detail != null) ...[ - const SizedBox(width: 8), - Text(v.detail!, style: const TextStyle(color: Colors.white54, fontSize: 13, fontWeight: FontWeight.w500)), - ], + ), if (action != null) ...[ - const SizedBox(width: 10), + const SizedBox(width: 12), _PillButton( label: action == StatusAction.cancel ? 'Cancel' : 'Retry', onPressed: () => onAction(action), diff --git a/apps/desktop/lib/src/shell/start_banner.dart b/apps/desktop/lib/src/shell/start_banner.dart index 85db03c..aa01c9a 100644 --- a/apps/desktop/lib/src/shell/start_banner.dart +++ b/apps/desktop/lib/src/shell/start_banner.dart @@ -26,9 +26,11 @@ class StartCard extends StatelessWidget { final episode = now.episode; return LayoutBuilder(builder: (context, box) { final h = box.maxHeight; + // Small (the mini player): no cover, a smaller title near the bottom. + final small = h < 320; final coverWidth = (h * 0.26).clamp(84.0, 240.0) / 1.5; - final titleSize = (h * 0.05).clamp(22.0, 44.0); - final side = (box.maxWidth * 0.05).clamp(20.0, 72.0); + final titleSize = small ? (h * 0.08).clamp(13.0, 18.0) : (h * 0.05).clamp(22.0, 44.0); + final side = small ? 12.0 : (box.maxWidth * 0.05).clamp(20.0, 72.0); return Stack( fit: StackFit.expand, children: [ @@ -76,12 +78,14 @@ class StartCard extends StatelessWidget { Positioned( left: side, right: side, - bottom: startCardBottom, + bottom: small ? 12 : startCardBottom, child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ - ArtCover(hash: now.posterHash, width: coverWidth), - const SizedBox(width: 24), + if (!small) ...[ + ArtCover(hash: now.posterHash, width: coverWidth), + const SizedBox(width: 24), + ], Expanded( child: Column( mainAxisSize: MainAxisSize.min, diff --git a/apps/desktop/lib/src/sync/state_diff.dart b/apps/desktop/lib/src/sync/state_diff.dart index 1193cb7..11bc8c9 100644 --- a/apps/desktop/lib/src/sync/state_diff.dart +++ b/apps/desktop/lib/src/sync/state_diff.dart @@ -43,6 +43,10 @@ bool localPlayAllowed(StateBroadcast state, String memberId) { return false; } +/// At `finished`, a player at most this far behind the end keeps playing +/// to it. +const finishSlackSecs = 3.0; + class StateDiff { static const debounceWindow = Duration(seconds: 2); @@ -73,7 +77,9 @@ class StateDiff { } // End of media: everyone stops where they are (at or just before the - // end). No seek to the very end and no drift correction. + // end). No seek to the very end and no drift correction. A player a + // little behind the server's clock plays its last moments out (mpv + // stops at the end by itself) instead of being cut short. if (state.transport == 'finished') { final first = _lastEventId == -1; final changed = first || @@ -87,7 +93,9 @@ class StateDiff { if (!changed) return out; out.add(const RateAction(1.0)); if (first) out.add(SeekAction(serverPos > 1 ? serverPos - 1 : 0)); - out.add(const TransportAction(false)); + if (first || state.position - ownPosition > finishSlackSecs) { + out.add(const TransportAction(false)); + } return out; } diff --git a/apps/desktop/test/state_diff_test.dart b/apps/desktop/test/state_diff_test.dart index dd4061b..daf6bae 100644 --- a/apps/desktop/test/state_diff_test.dart +++ b/apps/desktop/test/state_diff_test.dart @@ -158,12 +158,15 @@ void main() { }); group('finished', () { - test('stops in place: pause, no seek to the end, no drift seeks', () { - final diff = StateDiff(); + test('a player just behind plays out; far behind pauses; no seeks', () { final t = DateTime.now().toUtc(); - diff.diff(_state(eventId: 1, transport: 'playing', position: 100), 100, t); final end = _state(eventId: 2, transport: 'finished', position: 120); - final actions = diff.diff(end, 119.4, t); + final near = StateDiff()..diff(_state(eventId: 1, transport: 'playing', position: 100), 100, t); + final nearActions = near.diff(end, 119.4, t); + expect(nearActions.whereType(), isEmpty); + expect(nearActions.whereType(), isEmpty); + final diff = StateDiff()..diff(_state(eventId: 1, transport: 'playing', position: 100), 100, t); + final actions = diff.diff(end, 110, t); expect(actions.whereType(), isEmpty); expect(actions.whereType().single.playing, isFalse); // Far off and later: still nothing. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 879db75..1d8c5d4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -101,7 +101,9 @@ Loosely modeled on Syncplay, with a different readiness design. `server`). Recovery resumes by itself when the gate passes. - **End**: while PLAYING with a known duration, reaching it sets FINISHED (feed `finished` by `server`, position = duration). Clients pause where they - are; FINISHED never auto-resumes. A seek turns it into HELD at the new + are, except one within 3 s of the end (`finishSlackSecs`), which plays + out to mpv's own stop; at the end the position shows the full duration. + FINISHED never auto-resumes. A seek turns it into HELD at the new position (resuming at once if the gate passes); a swap resets to HELD. - **Seek**: allowed from any member in any transport; the server writes the position, bumps `eventId` and records who sent it (`seekBy`, `seekSeq` in @@ -653,13 +655,16 @@ sideways until an end, then the page). under 1 s) `StartBanner` (`start_banner.dart`) lies under the controls: 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. One status pill (`player_status.dart`) sits bottom centre above + 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 removed, reconnecting (Cancel), a warn/error health message (Retry), - connecting (only after 2 s), finished, someone loading (their avatar - and buffer progress), you not ready (a click marks you ready) and who - everyone waits for (avatars, "2/3 ready"); nothing otherwise, info - never. Frosted glass, colour only for problems; it fades through + connecting (only after 2 s), finished, someone loading (below + `StateBroadcast.bufferNeed`, the gate's own need: less near the end, + none at finished; their avatar with the progress round it), you not + ready (a click marks you ready) and who everyone waits for (avatars, + "2/3 ready"); nothing otherwise, info never. 34 px avatars, a main line + and a muted detail line. Frosted 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 diff --git a/packages/core/lib/src/protocol.dart b/packages/core/lib/src/protocol.dart index a6ff032..38e4fdf 100644 --- a/packages/core/lib/src/protocol.dart +++ b/packages/core/lib/src/protocol.dart @@ -556,6 +556,17 @@ class MemberView { } class StateBroadcast { + /// Buffer an in-set member needs before playback can go on, as the + /// server's gate counts it: [RelayConstants.minBufferSecs], less near the + /// end (mpv cannot buffer past it), none once finished. + double get bufferNeed { + if (transport == 'finished') return 0; + final d = (media?['duration'] as num?)?.toDouble() ?? 0; + const full = RelayConstants.minBufferSecs; + if (d <= 0) return full; + return (d - position - RelayConstants.endSlackSecs).clamp(0.0, full).toDouble(); + } + final int eventId; final String transport; final double position;