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:
1 parent
8effa573a0
commit
cf80bb2167
9 files changed
+113
-52
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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
v.text,
|
constraints: const BoxConstraints(minHeight: _avatar),
|
||||||
key: const ValueKey('player-status-text'),
|
child: Column(
|
||||||
maxLines: 1,
|
mainAxisSize: MainAxisSize.min,
|
||||||
overflow: TextOverflow.ellipsis,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
style: const TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
),
|
children: [
|
||||||
),
|
Text(
|
||||||
if (v.progress != null) ...[
|
v.text,
|
||||||
const SizedBox(width: 8),
|
key: const ValueKey('player-status-text'),
|
||||||
SizedBox.square(
|
maxLines: 1,
|
||||||
dimension: 14,
|
overflow: TextOverflow.ellipsis,
|
||||||
child: CircularProgressIndicator(
|
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600, height: 1.2),
|
||||||
value: v.progress,
|
),
|
||||||
strokeWidth: 2,
|
if (v.detail != null)
|
||||||
color: Colors.white,
|
Text(
|
||||||
backgroundColor: Colors.white24,
|
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),
|
||||||
|
|||||||
@@ -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: [
|
||||||
ArtCover(hash: now.posterHash, width: coverWidth),
|
if (!small) ...[
|
||||||
const SizedBox(width: 24),
|
ArtCover(hash: now.posterHash, width: coverWidth),
|
||||||
|
const SizedBox(width: 24),
|
||||||
|
],
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
|||||||
@@ -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));
|
||||||
out.add(const TransportAction(false));
|
if (first || state.position - ownPosition > finishSlackSecs) {
|
||||||
|
out.add(const TransportAction(false));
|
||||||
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in new issue
Block a user