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 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-09 07:31:03 +03:00
1 parent 8effa573a0
commit cf80bb2167
9 files changed
+105 -44

No files matched your search

@@ -42,7 +42,7 @@ class PlaybackController {
bool _probing = false; bool _probing = false;
final FrameWatchdog _watchdog = FrameWatchdog(); final FrameWatchdog _watchdog = FrameWatchdog();
StreamSubscription<PlayerLog>? _logSub; StreamSubscription<PlayerLog>? _logSub;
final List<StreamSubscription<Duration>> _timeSubs = []; final List<StreamSubscription<Object?>> _timeSubs = [];
String _chapterJson = ''; String _chapterJson = '';
// `glsl-shaders` as last set on the current player ('' = none). // `glsl-shaders` as last set on the current player ('' = none).
String _shaders = ''; String _shaders = '';
@@ -430,9 +430,18 @@ class PlaybackController {
// track-list observed in mpv (read back as JSON). // track-list observed in mpv (read back as JSON).
void _watchMarks(SyncPlayer p) { void _watchMarks(SyncPlayer p) {
_timeSubs.add(p.stream.position.listen((d) { _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; final ms = d.inMilliseconds;
position.value = Duration(milliseconds: ms - ms % 250); 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) { _timeSubs.add(p.stream.buffer.listen((d) {
final ms = d.inMilliseconds; final ms = d.inMilliseconds;
buffered.value = Duration(milliseconds: ms - ms % 250); buffered.value = Duration(milliseconds: ms - ms % 250);
@@ -717,7 +717,7 @@ class SessionCoordinator {
return; return;
} }
for (final m in r.members) { for (final m in r.members) {
if (m.inSet && m.buffered < RelayConstants.minBufferSecs) { if (m.inSet && m.buffered < r.bufferNeed) {
starving.value = starving.value =
'waiting for ${m.name} — ${m.buffered.toStringAsFixed(1)} s buffered'; 'waiting for ${m.name} — ${m.buffered.toStringAsFixed(1)} s buffered';
return; return;
+1 -1
View File
@@ -100,7 +100,7 @@ AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator
color: self ? Profile.instance.color.value : colorForName(m.name), color: self ? Profile.instance.color.value : colorForName(m.name),
image: memberAvatarImage(m, coord), image: memberAvatarImage(m, coord),
ready: m.ready, 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, relaying: relay != null && relay.source.isNotEmpty && relay.source == m.id,
stale: m.stale, stale: m.stale,
// The state marks the host (`members[].host`); your own app also // The state marks the host (`members[].host`); your own app also
+48 -27
View File
@@ -87,16 +87,18 @@ StatusView? playerStatusFor({
} }
// As the server holds playback: someone in the ready set short of the // As the server holds playback: someone in the ready set short of the
// minimum buffer. // minimum buffer.
final need = r.bufferNeed;
for (final m in r.members) { 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 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…', 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) { if (self != null && !self.ready) {
return StatusView(StatusKind.you, "Press Space when you're 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 active = [for (final m in r.members) if (!m.stale) m];
final others = [ final others = [
@@ -251,7 +253,7 @@ class _StatusLine extends StatelessWidget {
final ValueChanged<StatusAction> onAction; final ValueChanged<StatusAction> onAction;
const _StatusLine({super.key, required this.view, required this.coord, required this.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() { Widget? _lead() {
final v = view; final v = view;
@@ -260,7 +262,7 @@ class _StatusLine extends StatelessWidget {
case StatusKind.problem: case StatusKind.problem:
final error = v.tone == HealthSeverity.error; final error = v.tone == HealthSeverity.error;
return Icon(error ? Icons.error_outline_rounded : Icons.warning_amber_rounded, 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: case StatusKind.reconnecting:
return const _Spinner(color: Colors.amber); return const _Spinner(color: Colors.amber);
case StatusKind.connecting: case StatusKind.connecting:
@@ -276,9 +278,26 @@ class _StatusLine extends StatelessWidget {
v.self ? selfAvatarInfo(ready: m.ready, host: m.host) : memberAvatarInfo(m, r, coord), v.self ? selfAvatarInfo(ready: m.ready, host: m.host) : memberAvatarInfo(m, r, coord),
]; ];
if (infos.isEmpty) return null; if (infos.isEmpty) return null;
return infos.length == 1 final avatar = infos.length == 1
? StatusAvatar(info: infos.first, size: _avatar, badges: false) ? StatusAvatar(info: infos.first, size: _avatar, badges: false)
: AvatarStack(members: infos, shown: 4, size: _avatar); : 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) { Widget build(BuildContext context) {
final v = view; final v = view;
final lead = _lead(); final lead = _lead();
final avatars = v.who.isNotEmpty;
final action = v.action == StatusAction.ready ? null : v.action; final action = v.action == StatusAction.ready ? null : v.action;
return Padding( 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( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (lead != null) ...[ if (lead != null) ...[
SizedBox(height: _avatar, child: Center(child: lead)), SizedBox(height: _avatar + 6, child: Center(child: lead)),
const SizedBox(width: 8), SizedBox(width: avatars ? 10 : 8),
], ],
Flexible( Flexible(
child: Text( child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: _avatar),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
v.text, v.text,
key: const ValueKey('player-status-text'), key: const ValueKey('player-status-text'),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600), style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600, height: 1.2),
),
),
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,
), ),
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) ...[ if (action != null) ...[
const SizedBox(width: 10), const SizedBox(width: 12),
_PillButton( _PillButton(
label: action == StatusAction.cancel ? 'Cancel' : 'Retry', label: action == StatusAction.cancel ? 'Cancel' : 'Retry',
onPressed: () => onAction(action), onPressed: () => onAction(action),
+7 -3
View File
@@ -26,9 +26,11 @@ class StartCard extends StatelessWidget {
final episode = now.episode; final episode = now.episode;
return LayoutBuilder(builder: (context, box) { return LayoutBuilder(builder: (context, box) {
final h = box.maxHeight; 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 coverWidth = (h * 0.26).clamp(84.0, 240.0) / 1.5;
final titleSize = (h * 0.05).clamp(22.0, 44.0); final titleSize = small ? (h * 0.08).clamp(13.0, 18.0) : (h * 0.05).clamp(22.0, 44.0);
final side = (box.maxWidth * 0.05).clamp(20.0, 72.0); final side = small ? 12.0 : (box.maxWidth * 0.05).clamp(20.0, 72.0);
return Stack( return Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
@@ -76,12 +78,14 @@ class StartCard extends StatelessWidget {
Positioned( Positioned(
left: side, left: side,
right: side, right: side,
bottom: startCardBottom, bottom: small ? 12 : startCardBottom,
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
if (!small) ...[
ArtCover(hash: now.posterHash, width: coverWidth), ArtCover(hash: now.posterHash, width: coverWidth),
const SizedBox(width: 24), const SizedBox(width: 24),
],
Expanded( Expanded(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
+9 -1
View File
@@ -43,6 +43,10 @@ bool localPlayAllowed(StateBroadcast state, String memberId) {
return false; return false;
} }
/// At `finished`, a player at most this far behind the end keeps playing
/// to it.
const finishSlackSecs = 3.0;
class StateDiff { class StateDiff {
static const debounceWindow = Duration(seconds: 2); 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 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') { if (state.transport == 'finished') {
final first = _lastEventId == -1; final first = _lastEventId == -1;
final changed = first || final changed = first ||
@@ -87,7 +93,9 @@ class StateDiff {
if (!changed) return out; if (!changed) return out;
out.add(const RateAction(1.0)); out.add(const RateAction(1.0));
if (first) out.add(SeekAction(serverPos > 1 ? serverPos - 1 : 0)); if (first) out.add(SeekAction(serverPos > 1 ? serverPos - 1 : 0));
if (first || state.position - ownPosition > finishSlackSecs) {
out.add(const TransportAction(false)); out.add(const TransportAction(false));
}
return out; return out;
} }
+7 -4
View File
@@ -158,12 +158,15 @@ void main() {
}); });
group('finished', () { group('finished', () {
test('stops in place: pause, no seek to the end, no drift seeks', () { test('a player just behind plays out; far behind pauses; no seeks', () {
final diff = StateDiff();
final t = DateTime.now().toUtc(); 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 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<SeekAction>(), isEmpty);
expect(nearActions.whereType<TransportAction>(), isEmpty);
final diff = StateDiff()..diff(_state(eventId: 1, transport: 'playing', position: 100), 100, t);
final actions = diff.diff(end, 110, t);
expect(actions.whereType<SeekAction>(), isEmpty); expect(actions.whereType<SeekAction>(), isEmpty);
expect(actions.whereType<TransportAction>().single.playing, isFalse); expect(actions.whereType<TransportAction>().single.playing, isFalse);
// Far off and later: still nothing. // Far off and later: still nothing.
+11 -6
View File
@@ -101,7 +101,9 @@ Loosely modeled on Syncplay, with a different readiness design.
`server`). Recovery resumes by itself when the gate passes. `server`). Recovery resumes by itself when the gate passes.
- **End**: while PLAYING with a known duration, reaching it sets FINISHED - **End**: while PLAYING with a known duration, reaching it sets FINISHED
(feed `finished` by `server`, position = duration). Clients pause where they (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. 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 - **Seek**: allowed from any member in any transport; the server writes the
position, bumps `eventId` and records who sent it (`seekBy`, `seekSeq` in 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: under 1 s) `StartBanner` (`start_banner.dart`) lies under the controls:
the banner (else a blur of the cover, else dark), darkened, with cover, 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 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 the controls: `playerStatusFor` (pure) picks the most pressing of
removed, reconnecting (Cancel), a warn/error health message (Retry), removed, reconnecting (Cancel), a warn/error health message (Retry),
connecting (only after 2 s), finished, someone loading (their avatar connecting (only after 2 s), finished, someone loading (below
and buffer progress), you not ready (a click marks you ready) and who `StateBroadcast.bufferNeed`, the gate's own need: less near the end,
everyone waits for (avatars, "2/3 ready"); nothing otherwise, info none at finished; their avatar with the progress round it), you not
never. Frosted glass, colour only for problems; it fades through 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 between kinds. The mini shows its short line in its hover bar. ⚙ in both
control designs opens the player settings card (`controls/ control designs opens the player settings card (`controls/
player_settings.dart`, `showPopover`): player design, progress line player_settings.dart`, `showPopover`): player design, progress line
+11
View File
@@ -556,6 +556,17 @@ class MemberView {
} }
class StateBroadcast { 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 int eventId;
final String transport; final String transport;
final double position; final double position;