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;
|
||||
final FrameWatchdog _watchdog = FrameWatchdog();
|
||||
StreamSubscription<PlayerLog>? _logSub;
|
||||
final List<StreamSubscription<Duration>> _timeSubs = [];
|
||||
final List<StreamSubscription<Object?>> _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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<StatusAction> 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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<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<TransportAction>().single.playing, isFalse);
|
||||
// Far off and later: still nothing.
|
||||
|
||||
Reference in new issue
Block a user