Files
sync_replay_project/apps/desktop/lib/src/shell/player_status.dart
T
foxyzandClaude Opus 5.5 771b961b32 Player overlay as one piece, Up next always warm and on every source, Jellyfin never unmarks a watched episode
- Overlay: one layout for both designs (top bar with Up next as one of
  its buttons; one action band above the controls for the status pill,
  skips, next episode and end cards), one glass recipe (AppGlass) and
  one pill (FloatingPill); hovering any of them, or the open queue,
  holds the controls up.
- Up next: the first item (or the first continuation episode) is kept
  resolved on the app that puts it up, refreshed before it expires;
  Schedule and drop/paste can queue too.
- Jellyfin: WatchSession (core) counts real watched coverage; an
  episode is watched at 80 % or at its ending after half; a played one
  is re-marked after Playing (10.11 unmarks on open) and never gets a
  position; a peek leaves the old resume point; part-way resume points
  stay under 85 %.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:53:42 +03:00

396 lines
14 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
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';
/// What the player's status pill says, most pressing first.
enum StatusKind { reconnecting, problem, connecting, finished, loading, you, cannotPlay, waiting }
/// The pill's button: stop reconnecting, retry (reconnect), reload the
/// video (your player could not open it), or mark yourself ready (the
/// whole pill).
enum StatusAction { cancel, retry, reload, ready }
/// One status line: [text], a muted [detail], a coloured [tone] for
/// problems (warn, error), the members it is about ([who]; [self] for you)
/// and a loading [progress] (0..1).
class StatusView {
final StatusKind kind;
final String text;
final String? detail;
final HealthSeverity? tone;
final List<MemberView> who;
final bool self;
final double? progress;
final StatusAction? action;
/// The mini player's shorter line.
final String short;
const StatusView(
this.kind,
this.text, {
this.detail,
this.tone,
this.who = const [],
this.self = false,
this.progress,
this.action,
String? short,
}) : short = short ?? text;
}
/// "Bob", "Bob and Mika", "Bob, Mika and Cleo", "Bob, Mika and 3 others".
String nameList(List<String> names) {
if (names.length <= 1) return names.join();
if (names.length <= 3) return '${names.sublist(0, names.length - 1).join(', ')} and ${names.last}';
return '${names[0]}, ${names[1]} and ${names.length - 2} others';
}
String _sentence(String s) => s.isEmpty ? s : s[0].toUpperCase() + s.substring(1);
/// The player's status from the session's signals; null when there is
/// nothing to say (playing, connected, everyone ready). [slowConnect]:
/// connecting has taken over 2 s. [playerError]: why your player cannot
/// open the media (the coordinator's `playerError`).
StatusView? playerStatusFor({
required HealthStatus health,
bool reconnecting = false,
StateBroadcast? state,
MemberView? self,
bool slowConnect = false,
String playerError = '',
}) {
if (reconnecting) {
return const StatusView(StatusKind.reconnecting, 'Reconnecting…',
tone: HealthSeverity.warn, action: StatusAction.cancel);
}
if (playerError.isNotEmpty && state?.media != null && !(self?.out ?? false)) {
return StatusView(StatusKind.problem, "Can't play this: $playerError",
tone: HealthSeverity.error, action: StatusAction.reload, short: "Can't play this");
}
if (health.severity == HealthSeverity.warn || health.severity == HealthSeverity.error) {
return StatusView(StatusKind.problem, _sentence(health.message),
tone: health.severity, action: StatusAction.retry);
}
if (slowConnect && health.severity == HealthSeverity.info && health.message.startsWith('connecting')) {
return const StatusView(StatusKind.connecting, 'Connecting…');
}
final r = state;
if (r == null || r.media == null || r.transport == 'playing') return null;
// Sitting out: the join-back card says it; nobody waits for you.
if (self != null && self.out) return null;
if (r.transport == 'finished') {
return const StatusView(StatusKind.finished, 'Finished · seek back or pick the next video', short: 'Finished');
}
// Members whose player cannot open it: the server does not wait for
// them; say why instead of "waiting for".
final failed = [
for (final m in r.members)
if (m.error.isNotEmpty && !m.out && (self == null || m.id != self.id)) m,
];
// 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 < 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: p, detail: '${(p * 100).round()}%');
}
}
if (failed.isNotEmpty) {
final f = failed.first;
final who = nameList([for (final m in failed) m.name]);
final unready = self != null && !self.ready;
return StatusView(StatusKind.cannotPlay, "$who can't play this: ${f.error}",
who: failed,
short: "$who can't play this",
detail: unready ? "Press Space when you're ready" : null,
action: unready ? StatusAction.ready : null);
}
if (self != null && !self.ready) {
return StatusView(StatusKind.you, "Press Space when you're ready",
who: [self], self: true, action: StatusAction.ready, detail: 'or click here', short: 'Press Space when ready');
}
// Members sitting out are never waited for nor counted.
final active = [for (final m in r.members) if (!m.stale && !m.out && m.error.isEmpty) m];
final others = [
for (final m in active)
if ((self == null || m.id != self.id) && !(m.inSet && m.ready)) m,
];
if (others.isEmpty) return null;
final ready = active.where((m) => m.ready).length;
return StatusView(StatusKind.waiting, 'Waiting for ${nameList([for (final m in others) m.name])}',
who: others, detail: '$ready/${active.length} ready');
}
/// [playerStatusFor] from the session (tracked: call inside a builder).
StatusView? playerStatusNow({bool slowConnect = false}) {
final c = SessionState.instance.coordinator;
return playerStatusFor(
health: c.health.value,
reconnecting: c.reconnecting.value,
state: c.serverState.value,
self: c.selfMember.value,
slowConnect: slowConnect,
playerError: c.playerError.value,
);
}
/// The player's one status pill (bottom centre): frosted dark glass with
/// one short line, the members it is about and at most one button. Fades
/// in and out; a different kind of status fades through, the pill resizing
/// around it.
class PlayerStatus extends StatefulWidget {
const PlayerStatus({super.key});
static const connectGrace = Duration(seconds: 2);
@override
State<PlayerStatus> createState() => _PlayerStatusState();
}
class _PlayerStatusState extends State<PlayerStatus> {
Timer? _connectTimer;
bool _slow = false;
StatusView? _last;
@override
void dispose() {
_connectTimer?.cancel();
super.dispose();
}
// "Connecting…" only once it takes a while.
void _trackConnecting(HealthStatus h) {
final connecting = h.severity == HealthSeverity.info && h.message.startsWith('connecting');
if (!connecting) {
_connectTimer?.cancel();
_connectTimer = null;
_slow = false;
} else if (_connectTimer == null && !_slow) {
_connectTimer = Timer(PlayerStatus.connectGrace, () {
if (mounted) setState(() => _slow = true);
});
}
}
void _act(SessionCoordinator coord, StatusAction a) {
switch (a) {
case StatusAction.cancel:
coord.cancelReconnect();
case StatusAction.retry:
coord.retry();
case StatusAction.reload:
unawaited(coord.reloadVideo());
case StatusAction.ready:
coord.toggleReady();
}
}
@override
Widget build(BuildContext context) {
return PartyWatch(builder: (context) {
final coord = SessionState.instance.coordinator;
_trackConnecting(coord.health.value);
final view = playerStatusNow(slowConnect: _slow);
if (view != null) _last = view;
final shown = _last;
final ready = view?.action == StatusAction.ready;
return AnimatedOpacity(
duration: easeTime,
curve: ease,
opacity: view == null ? 0 : 1,
child: IgnorePointer(
ignoring: view == null,
child: shown == null
? const SizedBox.shrink()
: MouseRegion(
cursor: ready ? SystemMouseCursors.click : MouseCursor.defer,
child: GestureDetector(
key: const ValueKey('player-status'),
onTap: ready ? coord.toggleReady : null,
child: PlayerGlass(
child: AnimatedSize(
duration: easeTime,
curve: ease,
child: fadeThrough(
_StatusLine(
key: ValueKey(shown.kind),
view: shown,
coord: coord,
onAction: (a) => _act(coord, a),
),
expand: false,
alignment: Alignment.center,
),
),
),
),
),
),
);
});
}
}
/// 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) => ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: AppGlass.pill(child: child),
);
}
class _StatusLine extends StatelessWidget {
final StatusView view;
final SessionCoordinator coord;
final ValueChanged<StatusAction> onAction;
const _StatusLine({super.key, required this.view, required this.coord, required this.onAction});
static const _avatar = 34.0;
Widget? _lead() {
final v = view;
switch (v.kind) {
case StatusKind.problem:
final error = v.tone == HealthSeverity.error;
return Icon(error ? Icons.error_outline_rounded : Icons.warning_amber_rounded,
size: 20, color: error ? Colors.redAccent.shade100 : Colors.amber);
case StatusKind.reconnecting:
return const _Spinner(color: Colors.amber);
case StatusKind.connecting:
return const _Spinner(color: Colors.white70);
case StatusKind.finished:
return null;
case StatusKind.loading:
case StatusKind.you:
case StatusKind.cannotPlay:
case StatusKind.waiting:
final r = coord.serverState.value;
final infos = [
for (final m in v.who)
v.self ? selfAvatarInfo(ready: m.ready, host: m.host) : memberAvatarInfo(m, r, coord),
];
if (infos.isEmpty) return null;
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,
),
),
],
);
}
}
@override
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 ? 18 : (avatars ? 5 : 14), 5, action == null ? 18 : 6, 5),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (lead != null) ...[
SizedBox(height: _avatar + 6, child: Center(child: lead)),
SizedBox(width: avatars ? 10 : 8),
],
Flexible(
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 (action != null) ...[
const SizedBox(width: 12),
_PillButton(
label: action == StatusAction.cancel ? 'Cancel' : 'Retry',
onPressed: () => onAction(action),
),
],
],
),
);
}
}
class _Spinner extends StatelessWidget {
final Color color;
const _Spinner({required this.color});
@override
Widget build(BuildContext context) =>
SizedBox.square(dimension: 14, child: CircularProgressIndicator(strokeWidth: 2, color: color));
}
class _PillButton extends StatelessWidget {
final String label;
final VoidCallback onPressed;
const _PillButton({required this.label, required this.onPressed});
@override
Widget build(BuildContext context) => TextButton(
key: ValueKey('player-status-$label'),
onPressed: onPressed,
style: TextButton.styleFrom(
foregroundColor: Colors.white,
backgroundColor: Colors.white12,
shape: const StadiumBorder(),
minimumSize: const Size(0, 26),
padding: const EdgeInsets.symmetric(horizontal: 12),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
textStyle: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
),
child: Text(label),
);
}