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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user