Player: now-playing title with cover, start banner, one status pill; media art for everyone

The swapper sends the media's poster and banner once (`art`, Jellyfin
images or AniList by parsed name); state carries their hashes and every
member fetches them from /i/ like avatars. state.media.swapBy names who
put the media up. uosc and the standard bar show cover, title and
episode; a banner card shows before playback starts. The top-left health
pill and the waiting pill become one frosted status pill in plain words.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-09 01:19:34 +03:00
1 parent d092ea371a
commit b534c8c7ea
29 files changed
+1892 -236

No files matched your search

+7 -2
View File
@@ -15,11 +15,16 @@ const fadeOutPart = 0.35;
/// Fade through (Material motion): the old view is gone after the first
/// 35%, then the new one fades and scales in; they never overlap. Key
/// [child] by what it shows so a change of key runs the transition.
Widget fadeThrough(Widget child, {Key? key}) => AnimatedSwitcher(
/// [expand] false: sized by the views (at [alignment]) instead of filling
/// the box, for unbounded places (a row, a pill).
Widget fadeThrough(Widget child,
{Key? key, bool expand = true, AlignmentGeometry alignment = AlignmentDirectional.topStart}) =>
AnimatedSwitcher(
key: key,
duration: fadeThroughTime,
layoutBuilder: (current, previous) => Stack(
fit: StackFit.expand,
fit: expand ? StackFit.expand : StackFit.loose,
alignment: alignment,
children: [...previous, ?current],
),
transitionBuilder: (c, anim) {
@@ -0,0 +1,171 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import '../session/now_playing.dart';
import '../state/session_state.dart';
import 'motion.dart';
/// Tests: art bytes by hash instead of the session's cache.
@visibleForTesting
Uint8List? Function(String hash)? debugArtBytes;
/// The one lookup for media art (cover, banner) by content hash: null
/// when there is none or it has not arrived yet (listeners of
/// [artChanges] hear when it lands).
Uint8List? artBytes(String hash) {
if (hash.isEmpty) return null;
final debug = debugArtBytes;
if (debug != null) return debug(hash);
return SessionState.instance.coordinator.art.bytesFor(hash);
}
/// Notifies when art arrives.
Listenable artChanges() => SessionState.instance.coordinator.art;
final _images = <String, MemoryImage>{};
/// [hash]'s art as an image, the same provider while the bytes are the
/// same (so a rebuild never decodes again).
ImageProvider? artImage(String hash) {
final bytes = artBytes(hash);
if (bytes == null) return null;
final hit = _images[hash];
if (hit != null && identical(hit.bytes, bytes)) return hit;
if (_images.length > 16) _images.clear();
return _images[hash] = MemoryImage(bytes);
}
/// [image] fading in (ease) once decoded; nothing before.
Widget fadeInImage(ImageProvider image, {BoxFit fit = BoxFit.cover}) => Image(
image: image,
fit: fit,
gaplessPlayback: true,
frameBuilder: (context, child, frame, sync) => sync
? child
: AnimatedOpacity(opacity: frame == null ? 0 : 1, duration: easeTime, curve: ease, child: child),
);
/// The portrait cover (2:3) of the current media, a quiet tile with a
/// film icon until (or unless) its art arrives.
class ArtCover extends StatelessWidget {
final String hash;
final double width;
const ArtCover({super.key, required this.hash, this.width = 40});
@override
Widget build(BuildContext context) {
final image = artImage(hash);
final r = BorderRadius.circular(width >= 80 ? 10 : 6);
return Container(
width: width,
height: width * 1.5,
decoration: BoxDecoration(
borderRadius: r,
boxShadow: const [BoxShadow(color: Color(0x66000000), blurRadius: 10, offset: Offset(0, 3))],
),
child: ClipRRect(
borderRadius: r,
child: Stack(
fit: StackFit.expand,
children: [
DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF2A2D35), Color(0xFF17191E)],
),
),
child: Icon(Icons.movie_outlined, color: Colors.white38, size: width * 0.45),
),
if (image != null) fadeInImage(image),
DecoratedBox(
decoration: BoxDecoration(
borderRadius: r,
border: Border.all(color: Colors.white12),
),
),
],
),
),
);
}
}
const _shadow = [Shadow(color: Color(0x99000000), blurRadius: 8)];
/// Title and episode line of [now] (with its cover unless [cover] is
/// off), ellipsized; cross-fades when the media changes.
class NowPlayingBlock extends StatelessWidget {
final NowPlaying? now;
final bool cover;
/// Smaller type (the mini player).
final bool compact;
const NowPlayingBlock({super.key, required this.now, this.cover = true, this.compact = false});
@override
Widget build(BuildContext context) {
final n = now;
final Widget child;
if (n == null) {
child = const SizedBox.shrink(key: ValueKey('now-playing-none'));
} else {
final episode = n.episode;
child = Row(
key: ValueKey('now-playing:${n.title}\n${episode ?? ''}'),
mainAxisSize: MainAxisSize.min,
children: [
if (cover) ...[ArtCover(hash: n.posterHash), const SizedBox(width: 12)],
Flexible(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
n.title,
key: const ValueKey('now-playing-title'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w700,
fontSize: compact ? 13 : 16,
shadows: _shadow),
),
if (episode != null && episode.isNotEmpty)
Text(
episode,
key: const ValueKey('now-playing-episode'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: Colors.white70, fontSize: compact ? 11 : 13, shadows: _shadow),
),
],
),
),
],
);
}
return fadeThrough(child, expand: false, alignment: AlignmentDirectional.centerStart);
}
}
/// [NowPlayingBlock] of the session's current media.
class NowPlayingTitle extends StatelessWidget {
final bool cover;
final bool compact;
const NowPlayingTitle({super.key, this.cover = true, this.compact = false});
@override
Widget build(BuildContext context) => ListenableBuilder(
listenable: artChanges(),
builder: (context, _) => SignalBuilder(
builder: (context) => NowPlayingBlock(
now: SessionState.instance.coordinator.nowPlaying.value,
cover: cover,
compact: compact,
),
),
);
}
@@ -0,0 +1,366 @@
import 'dart:async';
import 'dart:ui' show ImageFilter;
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 'avatar.dart';
import 'motion.dart';
/// What the player's status pill says, most pressing first.
enum StatusKind { kicked, reconnecting, problem, connecting, finished, loading, you, waiting }
/// The pill's button: stop reconnecting, retry, or mark yourself ready
/// (the whole pill).
enum StatusAction { cancel, retry, 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.
StatusView? playerStatusFor({
required HealthStatus health,
bool reconnecting = false,
String? kickReason,
StateBroadcast? state,
MemberView? self,
bool slowConnect = false,
}) {
if (kickReason != null) {
return StatusView(StatusKind.kicked,
kickReason.isEmpty ? 'You were removed from the server' : 'You were removed: $kickReason',
tone: HealthSeverity.error, short: 'Removed from the server');
}
if (reconnecting) {
return const StatusView(StatusKind.reconnecting, 'Reconnecting…',
tone: HealthSeverity.warn, action: StatusAction.cancel);
}
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;
if (r.transport == 'finished') {
return const StatusView(StatusKind.finished, 'Finished · seek back or pick the next video', short: 'Finished');
}
// As the server holds playback: someone in the ready set short of the
// minimum buffer.
for (final m in r.members) {
if (m.inSet && m.buffered < RelayConstants.minBufferSecs) {
final you = self != null && m.id == self.id;
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));
}
}
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');
}
final active = [for (final m in r.members) if (!m.stale) 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,
kickReason: c.kickReason.value,
state: c.serverState.value,
self: c.selfMember.value,
slowConnect: slowConnect,
);
}
/// 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.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: _Glass(
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,
),
),
),
),
),
),
);
});
}
}
class _Glass extends StatelessWidget {
final Widget child;
const _Glass({required this.child});
@override
Widget build(BuildContext context) {
final r = BorderRadius.circular(999);
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: ClipRRect(
borderRadius: r,
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12),
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.55),
borderRadius: r,
border: Border.all(color: Colors.white12, width: 0.5),
),
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 = 24.0;
Widget? _lead() {
final v = view;
switch (v.kind) {
case StatusKind.kicked:
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);
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.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;
return infos.length == 1
? StatusAvatar(info: infos.first, size: _avatar, badges: false)
: AvatarStack(members: infos, shown: 4, size: _avatar);
}
}
@override
Widget build(BuildContext context) {
final v = view;
final lead = _lead();
final action = v.action == StatusAction.ready ? null : v.action;
return Padding(
padding: EdgeInsets.fromLTRB(lead == null ? 16 : 6, 6, action == null ? 16 : 6, 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (lead != null) ...[
SizedBox(height: _avatar, child: Center(child: lead)),
const SizedBox(width: 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,
),
),
],
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),
_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),
);
}
+22 -182
View File
@@ -13,7 +13,6 @@ import '../controls/uosc/timeline.dart';
import '../playback/media_marks.dart' show SkipOffer, formatClock;
import '../screens/session/jellyfin_widgets.dart';
import '../screens/session/player_controls.dart';
import '../session/health.dart';
import '../session/session_coordinator.dart';
import '../shaders/shader_menu.dart';
import '../shaders/shader_presets.dart';
@@ -24,6 +23,9 @@ import '../widgets/text_focus.dart';
import '../widgets/window_buttons.dart';
import 'avatar.dart';
import 'motion.dart';
import 'now_playing_title.dart';
import 'player_status.dart';
import 'start_banner.dart';
/// Where the session's video is shown: nowhere, the floating mini player,
/// or filling the body (and, with fullscreen, the window).
@@ -36,8 +38,8 @@ typedef VideoViewBuilder = Widget Function(BuildContext context, VideoControlsBu
/// The session's player: the one `Video` of the current player and
/// everything over it. Full: our bars on media_kit's controls or the uosc
/// set (Settings), health / waiting / Jellyfin overlays and the player
/// keys. Mini: hover controls and a progress line. The shell keeps this
/// set (Settings), the start card, the status pill, Jellyfin overlays and
/// the player keys. Mini: hover controls and a progress line. The shell keeps this
/// widget mounted at one place in the tree while the mode changes, so the
/// `Video` is never mounted twice nor rebuilt for a move.
class SessionPlayer extends StatefulWidget {
@@ -277,8 +279,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
child: Stack(fit: StackFit.expand, children: [video, _frameProbe.anchor()]),
),
if (_full) ...[
_healthOverlay(),
_waitingPill(bottom: 124),
_status(),
_progressLine(),
_jellyfinOverlay(),
if (widget.fullscreen) _pausedParty(),
@@ -306,6 +307,8 @@ class _SessionPlayerState extends State<SessionPlayer> {
}
static const _barHeight = 56.0;
/// The standard top bar: room for the cover (2:3, 60 px tall).
static const _topBarHeight = 72.0;
static const _controlsHide = Duration(seconds: 3);
/// Fullscreen hides the strip and its window buttons: the top bar
@@ -346,14 +349,6 @@ class _SessionPlayerState extends State<SessionPlayer> {
),
);
String get _title {
final ref = _coord.currentRef.value;
if (ref != null && ref.title.isNotEmpty) {
return [ref.title, ?ref.subtitle].join(' · ');
}
return (_coord.serverState.value?.media?['name'] as String?) ?? '';
}
MaterialDesktopVideoControlsThemeData _controlsTheme() {
final coord = _coord;
return MaterialDesktopVideoControlsThemeData(
@@ -375,19 +370,10 @@ class _SessionPlayerState extends State<SessionPlayer> {
child: Align(
alignment: Alignment.topCenter,
child: SizedBox(
height: _barHeight,
height: _topBarHeight,
child: Row(
children: [
Expanded(
child: SignalBuilder(
builder: (context) => Text(
_title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600, fontSize: 16),
),
),
),
const Expanded(child: NowPlayingTitle()),
MaterialDesktopCustomButton(
icon: const Icon(Icons.video_library_outlined),
onPressed: widget.onChangeVideo,
@@ -510,7 +496,12 @@ class _SessionPlayerState extends State<SessionPlayer> {
}
Widget _sessionControls(VideoState state) {
// The start card sits under the controls (they stay usable on it).
// The "Player design" setting swaps them live.
return Stack(fit: StackFit.expand, children: [const StartBanner(), _controlsSwitch(state)]);
}
Widget _controlsSwitch(VideoState state) {
return SessionControlsSwitch(
material: (_) => ControlsHoverProbe(
shown: _standardShown,
@@ -518,7 +509,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
child: DoubleClickListener(
onDoubleClick: widget.onToggleFullscreen,
// Clicks on the bars are button clicks, not the video.
skipTop: _barHeight,
skipTop: _topBarHeight,
skipBottom: _barHeight + ChapterSeekBar.height,
child: MaterialDesktopVideoControls(state),
),
@@ -546,6 +537,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
});
return UoscControls(
onDoubleTap: widget.onToggleFullscreen,
topLeft: const NowPlayingTitle(),
top: Row(
mainAxisSize: MainAxisSize.min,
children: [
@@ -655,80 +647,9 @@ class _SessionPlayerState extends State<SessionPlayer> {
);
}
Widget _healthOverlay() {
final coord = _coord;
return Positioned(
top: _barHeight + 8,
left: 12,
right: 12,
child: Align(
alignment: Alignment.topLeft,
child: SignalBuilder(
builder: (context) {
final h = coord.health.value;
final starve = coord.starving.value;
final retrying = coord.reconnecting.value;
final kicked = coord.kickReason.value;
final unwell = h.severity == HealthSeverity.warn ||
h.severity == HealthSeverity.error ||
retrying ||
starve != null;
if (!unwell) return const SizedBox.shrink();
final color = switch (h.severity) {
HealthSeverity.ok => Colors.green,
HealthSeverity.info => Colors.grey,
HealthSeverity.warn => Colors.orange,
HealthSeverity.error => Colors.red,
};
final IconData icon;
if (retrying) {
icon = Icons.sync;
} else if (h.severity == HealthSeverity.error) {
icon = Icons.error_outline;
} else if (h.severity == HealthSeverity.warn) {
icon = Icons.warning_amber_outlined;
} else {
icon = Icons.hourglass_empty;
}
final text = [h.message, ?starve, if (retrying) 'reconnecting…'].join(' · ');
final canRetry = !retrying &&
kicked == null &&
(h.severity == HealthSeverity.error || h.severity == HealthSeverity.warn);
final button = TextButton.styleFrom(
foregroundColor: Colors.white, tapTargetSize: MaterialTapTargetSize.shrinkWrap);
return Container(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.65), borderRadius: BorderRadius.circular(999)),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: color, size: 16),
const SizedBox(width: 8),
Flexible(child: Text(text, style: const TextStyle(color: Colors.white, fontSize: 12))),
if (retrying)
TextButton(
style: button,
onPressed: coord.cancelReconnect,
child: const Text('Cancel', style: TextStyle(fontSize: 12)),
),
if (canRetry)
TextButton(
style: button,
onPressed: coord.retry,
child: const Text('Retry', style: TextStyle(fontSize: 12)),
),
],
),
);
},
),
),
);
}
Widget _waitingPill({required double bottom}) =>
Positioned(left: 0, right: 0, bottom: bottom, child: const Center(child: WaitingPill()));
/// The one status pill, bottom centre above the controls.
Widget _status() =>
const Positioned(left: 16, right: 16, bottom: 124, child: Center(child: PlayerStatus()));
/// Jellyfin: the blocked card, the end-of-episode card (countdown) and
/// the "Next episode" pill; for any media the skip pill (Skip OP / ED /
@@ -807,7 +728,6 @@ class _SessionPlayerState extends State<SessionPlayer> {
Widget _miniOverlay() {
final coord = _coord;
final playback = coord.playback;
const white = TextStyle(color: Colors.white, fontWeight: FontWeight.w600);
return MouseRegion(
onEnter: (_) => setState(() => _miniHover = true),
onExit: (_) => setState(() => _miniHover = false),
@@ -832,12 +752,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
Row(
children: [
const SizedBox(width: 10),
Expanded(
child: SignalBuilder(
builder: (context) =>
Text(_title, maxLines: 1, overflow: TextOverflow.ellipsis, style: white),
),
),
const Expanded(child: NowPlayingTitle(cover: false, compact: true)),
IconButton(
focusNode: _noFocus,
onPressed: widget.onExpand,
@@ -871,7 +786,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
style: const TextStyle(color: Colors.white70, fontSize: 12)),
const Spacer(),
Flexible(
child: Text(waitingText(compact: true),
child: Text(playerStatusNow()?.short ?? '',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.amber, fontSize: 12)),
@@ -904,81 +819,6 @@ const _shaderKeys = [
LogicalKeyboardKey.digit6,
];
/// Who the server waits for: "Waiting for you · press Space when ready",
/// "Waiting for Alex, Mika", or '' while playing or with nothing up.
String waitingText({bool compact = false}) {
final coord = SessionState.instance.coordinator;
final r = coord.serverState.value;
if (r == null || r.media == null || r.transport != 'held') return '';
final self = coord.selfMember.value;
if (self != null && !self.ready) {
return compact ? 'waiting for you' : 'Waiting for you · press Space when ready';
}
final waiting = [for (final m in r.members) if (!m.ready && !m.stale) m.name];
if (waiting.isEmpty) return compact ? '' : 'Paused';
return '${compact ? 'waiting' : 'Waiting'} for ${waiting.join(', ')}';
}
/// While held: who everyone is waiting for, with their avatars. When it
/// is you, a click marks you ready.
class WaitingPill extends StatelessWidget {
const WaitingPill({super.key});
@override
Widget build(BuildContext context) {
return PartyWatch(builder: (context) {
final coord = SessionState.instance.coordinator;
final text = waitingText();
final r = coord.serverState.value;
final self = coord.selfMember.value;
final meWaiting = self != null && !self.ready;
final who = meWaiting
? [selfAvatarInfo()]
: [
for (final m in r?.members ?? const <MemberView>[])
if (!m.ready && !m.stale) memberAvatarInfo(m, r, coord),
];
return AnimatedOpacity(
duration: easeTime,
curve: ease,
opacity: text.isEmpty ? 0 : 1,
child: IgnorePointer(
ignoring: text.isEmpty || !meWaiting,
child: GestureDetector(
key: const ValueKey('waiting-pill'),
onTap: coord.toggleReady,
child: MouseRegion(
cursor: meWaiting ? SystemMouseCursors.click : MouseCursor.defer,
child: Container(
padding: const EdgeInsets.fromLTRB(8, 6, 16, 6),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.72),
borderRadius: BorderRadius.circular(30),
border: Border.all(color: Colors.white12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
for (final m in who.take(4))
Padding(padding: const EdgeInsets.only(right: 4), child: StatusAvatar(info: m, size: 28)),
const SizedBox(width: 6),
Flexible(
child: Text(text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600)),
),
],
),
),
),
),
),
);
});
}
}
/// Mini player progress: a 2 px line, or (hovered) a 4 px bar that seeks.
class _MiniSeekBar extends StatelessWidget {
final bool thin;
@@ -0,0 +1,176 @@
import 'dart:ui' show ImageFilter;
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import '../session/now_playing.dart';
import '../state/session_state.dart';
import 'motion.dart';
import 'now_playing_title.dart';
/// Space kept free at the bottom of the start card for the controls and
/// the status pill.
const startCardBottom = 176.0;
/// The start card of [now]: its banner (cover-fit, darkened), else a soft
/// blur of the cover, else dark; the cover, a big title and the episode
/// line bottom left, above the controls.
class StartCard extends StatelessWidget {
final NowPlaying now;
const StartCard({super.key, required this.now});
@override
Widget build(BuildContext context) {
final banner = artImage(now.bannerHash);
final cover = banner == null ? artImage(now.posterHash) : null;
final episode = now.episode;
return LayoutBuilder(builder: (context, box) {
final h = box.maxHeight;
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);
return Stack(
fit: StackFit.expand,
children: [
const ColoredBox(color: Color(0xFF0C0D11)),
if (banner != null)
fadeInImage(banner)
else if (cover != null)
Opacity(
opacity: 0.5,
child: ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 48, sigmaY: 48, tileMode: TileMode.decal),
child: Transform.scale(scale: 1.3, child: fadeInImage(cover)),
),
)
else
const DecoratedBox(
decoration: BoxDecoration(
gradient: RadialGradient(
center: Alignment(-0.7, 0.5),
radius: 1.2,
colors: [Color(0xFF23262E), Color(0xFF0C0D11)],
),
),
),
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: [0, 0.35, 1],
colors: [Color(0x55000000), Color(0x22000000), Color(0xE6000000)],
),
),
),
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
stops: [0, 0.65],
colors: [Color(0x99000000), Color(0x00000000)],
),
),
),
Positioned(
left: side,
right: side,
bottom: startCardBottom,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
ArtCover(hash: now.posterHash, width: coverWidth),
const SizedBox(width: 24),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
now.title,
key: const ValueKey('start-card-title'),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: titleSize,
fontWeight: FontWeight.w800,
height: 1.1,
shadows: const [Shadow(color: Color(0x99000000), blurRadius: 16)],
),
),
if (episode != null && episode.isNotEmpty) ...[
const SizedBox(height: 6),
Text(
episode,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: Colors.white70, fontSize: titleSize * 0.42),
),
],
],
),
),
],
),
),
],
);
});
}
}
/// [StartCard] over the picture until playback starts on this media
/// (the server plays, or its position passed 1 s); then it fades out and
/// stays gone for that media, whatever happens later. Never takes input.
class StartBanner extends StatefulWidget {
const StartBanner({super.key});
@override
State<StartBanner> createState() => _StartBannerState();
}
class _StartBannerState extends State<StartBanner> {
int? _mediaId;
bool _started = false;
// Faded out: the card leaves the tree (and its images with it).
bool _gone = true;
NowPlaying? _last;
@override
Widget build(BuildContext context) {
final coord = SessionState.instance.coordinator;
return IgnorePointer(
child: ListenableBuilder(
listenable: artChanges(),
builder: (context, _) => SignalBuilder(builder: (context) {
final r = coord.serverState.value;
final now = coord.nowPlaying.value;
if (r != null && r.mediaId != _mediaId) {
_mediaId = r.mediaId;
_started = false;
}
if (r != null && (r.transport == 'playing' || r.position >= 1)) _started = true;
final show = now != null && r?.media != null && !_started;
if (show) {
_last = now;
_gone = false;
}
final last = _last;
if (last == null || _gone) return const SizedBox.shrink();
return AnimatedOpacity(
key: const ValueKey('start-banner'),
opacity: show ? 1 : 0,
duration: easeTime,
curve: ease,
onEnd: () {
if (!show && mounted) setState(() => _gone = true);
},
child: StartCard(now: last),
);
}),
),
);
}
}