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:
1 parent
d092ea371a
commit
b534c8c7ea
29 files changed
+1892
-236
No files matched your search
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user