Files
sync_replay_project/apps/desktop/lib/src/shell/session_player.dart
T
foxyzandClaude Opus 5.5 b534c8c7ea 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>
2026-10-09 01:19:34 +03:00

942 lines
34 KiB
Dart

import 'package:flutter/gestures.dart' show kPrimaryMouseButton;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:media_kit_video/media_kit_video.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart';
import '../controls/controls_probe.dart';
import '../controls/player_settings.dart';
import '../controls/uosc/buttons.dart';
import '../controls/uosc/controls.dart';
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/session_coordinator.dart';
import '../shaders/shader_menu.dart';
import '../shaders/shader_presets.dart';
import '../state/session_state.dart';
import '../state/settings.dart';
import '../widgets/shown_frame_probe.dart';
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).
enum PlayerMode { none, mini, full }
/// Builds the one video view of the current player around [controls]
/// (null: none, the mini player). Tests pass a stand-in: media_kit's
/// `Video` hangs flutter_test.
typedef VideoViewBuilder = Widget Function(BuildContext context, VideoControlsBuilder? controls);
/// 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), 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 {
final PlayerMode mode;
final bool fullscreen;
final bool chatOpen;
final int unread;
final VoidCallback onExpand;
final VoidCallback onCollapse;
final VoidCallback onClose;
final VoidCallback onToggleFullscreen;
final VoidCallback onToggleChat;
final VoidCallback onChangeVideo;
final VoidCallback onOpenIntegrations;
/// Asks before leaving the server.
final VoidCallback onLeave;
final VideoViewBuilder? videoBuilder;
/// Window buttons in the fullscreen top bar (off in tests: they need
/// window_manager).
final bool windowControls;
const SessionPlayer({
super.key,
required this.mode,
required this.fullscreen,
required this.onExpand,
required this.onCollapse,
required this.onClose,
required this.onToggleFullscreen,
required this.onToggleChat,
required this.onChangeVideo,
required this.onOpenIntegrations,
required this.onLeave,
this.chatOpen = false,
this.unread = 0,
this.videoBuilder,
this.windowControls = true,
});
@override
State<SessionPlayer> createState() => _SessionPlayerState();
}
class _SessionPlayerState extends State<SessionPlayer> {
// One key per player generation: a reloaded player needs a fresh
// `Video` (it never swaps controllers in place).
var _videoGen = -1;
var _videoKey = GlobalKey<VideoState>();
final _frameProbe = ShownFrameProbe();
final _noFocus = FocusNode(skipTraversal: true, canRequestFocus: false);
DateTime? _lastKeyAt;
late final void Function() _stopShaderWatch;
bool _shaderMenuOpen = false;
// Own toast ids, negative: the coordinator counts up from 1.
int _toastSeq = 0;
int _shaderNoticeSeen = 0;
bool _miniHover = false;
// Whether the standard controls are up (tracked like media_kit does:
// shown on mount and on mouse movement, hidden 3 s later or on exit).
final _standardShown = ValueNotifier(true);
SessionCoordinator get _coord => SessionState.instance.coordinator;
bool get _full => widget.mode == PlayerMode.full;
@override
void initState() {
super.initState();
_coord.jellyfin.ensureLoaded();
_coord.playback.shownFrame = _frameProbe.sample;
HardwareKeyboard.instance.addHandler(_globalKey);
// Shader messages (preset changes, load failures) become toasts.
_shaderNoticeSeen = _coord.playback.shaderNotice.peek()?.id ?? 0;
_stopShaderWatch = effect(() {
final n = _coord.playback.shaderNotice.value;
if (n == null || n.id == _shaderNoticeSeen) return;
_shaderNoticeSeen = n.id;
_toast(n.text);
});
}
@override
void dispose() {
HardwareKeyboard.instance.removeHandler(_globalKey);
final playback = _coord.playback;
if (playback.shownFrame == _frameProbe.sample) playback.shownFrame = null;
_stopShaderWatch();
_noFocus.dispose();
_standardShown.dispose();
super.dispose();
}
void _reloadVideo() {
_coord.markInput();
_coord.reloadVideo();
}
void _toast(String text) {
_coord.toasts.value = [
..._coord.toasts.peek(),
ToastItem(id: --_toastSeq, text: text, at: DateTime.now().toUtc()),
];
}
/// Menu, Ctrl+0..6: saved as the choice (also used on the next join)
/// and applied live. A null quality keeps the saved one.
void _pickShader(ShaderPreset preset, ShaderQuality? quality) => applyShaderChoice(preset, quality);
void _toggleChat() {
_coord.markInput();
widget.onToggleChat();
}
/// The player keys, while the player fills the body and nothing (a
/// dialog, a card, a text field) is in front of it.
bool _globalKey(KeyEvent event) {
if (event is! KeyDownEvent || !mounted || !_full) return false;
if (SessionState.instance.route.peek() != AppRoute.session) return false;
final route = ModalRoute.of(context);
if (route != null && !route.isCurrent) {
// A menu of ours is open: Esc closes it.
if (_shaderMenuOpen && event.logicalKey == LogicalKeyboardKey.escape) {
Navigator.of(context, rootNavigator: true).maybePop();
return true;
}
return false;
}
if (textInputFocused()) return false;
final now = DateTime.now().toUtc();
if (_lastKeyAt != null && now.difference(_lastKeyAt!).inMilliseconds < 350) {
return false;
}
final coord = _coord;
final key = event.logicalKey;
// Ctrl+0..6: Anime4K's own mpv keys (off, modes A..C+A).
final digit = _shaderKeys.indexOf(key);
if (digit >= 0 && HardwareKeyboard.instance.isControlPressed) {
_lastKeyAt = now;
_pickShader(digit == 0 ? ShaderPreset.off : ShaderPreset.modes[digit - 1], null);
return true;
}
if (key == LogicalKeyboardKey.keyR && HardwareKeyboard.instance.isControlPressed) {
_lastKeyAt = now;
_reloadVideo();
return true;
}
if (key == LogicalKeyboardKey.space) {
_lastKeyAt = now;
coord.toggleReady();
return true;
}
// mpv's playlist keys (< >) and Shift+P / Shift+N: Jellyfin episodes.
final shift = HardwareKeyboard.instance.isShiftPressed;
if (event.character == '>' || (shift && key == LogicalKeyboardKey.keyN)) {
if (coord.nextEpisode.peek() == null) return false;
_lastKeyAt = now;
coord.playNextEpisode();
return true;
}
if (event.character == '<' || (shift && key == LogicalKeyboardKey.keyP)) {
if (coord.previousEpisode.peek() == null) return false;
_lastKeyAt = now;
coord.playPreviousEpisode();
return true;
}
// mpv's chapter keys.
if (key == LogicalKeyboardKey.pageDown || key == LogicalKeyboardKey.pageUp) {
_lastKeyAt = now;
return coord.stepChapter(key == LogicalKeyboardKey.pageDown ? 1 : -1);
}
if (shift && key == LogicalKeyboardKey.arrowRight) {
_lastKeyAt = now;
coord.skipForward();
return true;
}
if (key == LogicalKeyboardKey.tab) {
_lastKeyAt = now;
_toggleChat();
return true;
}
if (key == LogicalKeyboardKey.arrowRight) {
_lastKeyAt = now;
coord.nudge(10);
return true;
}
if (key == LogicalKeyboardKey.arrowLeft) {
_lastKeyAt = now;
coord.nudge(-10);
return true;
}
if (key == LogicalKeyboardKey.escape) {
_lastKeyAt = now;
if (widget.chatOpen) {
widget.onToggleChat();
} else if (widget.fullscreen) {
widget.onToggleFullscreen();
} else {
widget.onLeave();
}
return true;
}
if (key == LogicalKeyboardKey.keyF) {
_lastKeyAt = now;
widget.onToggleFullscreen();
return true;
}
return false;
}
@override
Widget build(BuildContext context) {
return FocusTraversalGroup(
descendantsAreTraversable: false,
child: ColoredBox(
color: Colors.black,
// Player lifetime = session (and each "Reload video"): no live
// player ⇒ no `Video`, so no texture outlives its player.
child: SignalBuilder(builder: (context) {
final playback = _coord.playback;
final gen = playback.generation.value;
if (!playback.alive.value) {
return Center(
child: Text('player starting…',
style: TextStyle(color: Colors.white70, fontSize: _full ? 14 : 12)),
);
}
if (gen != _videoGen) {
_videoGen = gen;
_videoKey = GlobalKey<VideoState>();
}
final VideoControlsBuilder? controls = _full ? _sessionControls : null;
final video = widget.videoBuilder?.call(context, controls) ?? _video(controls);
return Stack(
fit: StackFit.expand,
children: [
// The frozen-picture watchdog captures this boundary: the
// video (and its controls), not the window around it.
RepaintBoundary(
child: Stack(fit: StackFit.expand, children: [video, _frameProbe.anchor()]),
),
if (_full) ...[
_status(),
_progressLine(),
_jellyfinOverlay(),
if (widget.fullscreen) _pausedParty(),
] else
_miniOverlay(),
],
);
}),
),
);
}
Widget _video(VideoControlsBuilder? controls) {
final theme = _controlsTheme();
return MaterialDesktopVideoControlsTheme(
normal: theme,
fullscreen: theme,
child: Video(
key: _videoKey,
controller: _coord.playback.video,
subtitleViewConfiguration: const SubtitleViewConfiguration(visible: false),
controls: controls,
),
);
}
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
/// carries them while the controls are up (restore leaves fullscreen).
Widget? _fullscreenWindowButtons() {
if (!widget.fullscreen || !widget.windowControls) return null;
return Padding(
key: const ValueKey('fullscreen-window-buttons'),
padding: const EdgeInsets.only(left: 8),
child: IconTheme.merge(
data: const IconThemeData(color: Colors.white),
child: WindowButtons(
focusNode: _noFocus,
fullscreen: true,
onToggleFullscreen: widget.onToggleFullscreen,
),
),
);
}
/// The standard design's progress line while its controls are hidden
/// (the uosc timeline collapses into its own).
Widget _progressLine() => Positioned(
left: 0,
right: 0,
bottom: 0,
child: IgnorePointer(
child: SignalBuilder(builder: (context) {
final s = AppSettings.instance;
if (s.mpvControls.value || !s.progressLine.value) return const SizedBox.shrink();
return ValueListenableBuilder<bool>(
valueListenable: _standardShown,
builder: (context, shown, _) => shown
? const SizedBox.shrink()
: const _MiniSeekBar(key: ValueKey('progress-line-standard'), thin: true),
);
}),
),
);
MaterialDesktopVideoControlsThemeData _controlsTheme() {
final coord = _coord;
return MaterialDesktopVideoControlsThemeData(
playAndPauseOnTap: false,
modifyVolumeOnScroll: false,
// media_kit's double-click pushes its own fullscreen route (a second
// `Video`); ours toggles the window instead (see [DoubleClickListener]).
toggleFullscreenOnDoublePress: false,
keyboardShortcuts: {},
visibleOnMount: true,
hideMouseOnControlsRemoval: true,
controlsHoverDuration: _controlsHide,
// Our seek bar (chapter marks, hover title) sits in the bottom bar,
// which grows by its height; the top bar keeps its row at the top.
displaySeekBar: false,
buttonBarHeight: _barHeight + ChapterSeekBar.height,
topButtonBar: [
Expanded(
child: Align(
alignment: Alignment.topCenter,
child: SizedBox(
height: _topBarHeight,
child: Row(
children: [
const Expanded(child: NowPlayingTitle()),
MaterialDesktopCustomButton(
icon: const Icon(Icons.video_library_outlined),
onPressed: widget.onChangeVideo,
),
MaterialDesktopCustomButton(
icon: Badge(
isLabelVisible: widget.unread > 0,
label: Text('${widget.unread}'),
child: Icon(widget.chatOpen ? Icons.chat_bubble : Icons.chat_bubble_outline),
),
onPressed: _toggleChat,
),
MaterialDesktopCustomButton(
icon: Icon(widget.fullscreen ? Icons.fullscreen_exit : Icons.close_fullscreen),
onPressed: widget.fullscreen ? widget.onToggleFullscreen : widget.onCollapse,
),
?_fullscreenWindowButtons(),
],
),
),
),
),
],
bottomButtonBar: [
Expanded(
child: Column(
children: [
SignalBuilder(
builder: (context) => PlayerSeekBar(
player: coord.playback.player,
chapters: coord.playback.chapters.value,
),
),
Expanded(child: Row(children: _bottomButtons())),
],
),
),
],
);
}
List<Widget> _bottomButtons() {
final coord = _coord;
final playback = coord.playback;
void menuOpen(bool open) => _shaderMenuOpen = open;
void pickTrack(TrackPick p) => coord.selectTrack(aid: p.aid, sid: p.sid, subtitlesOff: p.subtitlesOff);
return [
SignalBuilder(
builder: (context) => EpisodeSkipButton(
focusNode: _noFocus,
episode: coord.previousEpisode.value,
next: false,
onPressed: coord.playPreviousEpisode,
),
),
SignalBuilder(
builder: (context) => EpisodeSkipButton(
focusNode: _noFocus,
episode: coord.nextEpisode.value,
next: true,
onPressed: coord.playNextEpisode,
),
),
const MaterialDesktopVolumeButton(),
_ReadyControlButton(focusNode: _noFocus),
_BarTextButton(
focusNode: _noFocus,
label: 'Mark everyone ready',
onPressed: coord.forcePlayAll,
),
_BarTextButton(
focusNode: _noFocus,
icon: Icons.refresh,
label: 'Reload video',
tooltip: 'Reload video (Ctrl+R) — fixes a frozen picture',
onPressed: _reloadVideo,
),
const MaterialDesktopPositionIndicator(),
const Spacer(),
SkipForwardButton(focusNode: _noFocus, onPressed: coord.skipForward),
SignalBuilder(
builder: (context) => ChaptersControlButton(
focusNode: _noFocus,
chapters: playback.chapters.value,
current: playback.currentChapter.value,
onPick: (c) => coord.seekTo(c.start.inMicroseconds / 1e6),
onMenuOpen: menuOpen,
),
),
SignalBuilder(
builder: (context) => TrackMenuButton(
focusNode: _noFocus,
type: JellyfinStreamType.audio,
tracks: playback.tracks.value,
onPick: pickTrack,
onMenuOpen: menuOpen,
),
),
SignalBuilder(
builder: (context) => TrackMenuButton(
focusNode: _noFocus,
type: JellyfinStreamType.subtitle,
tracks: playback.tracks.value,
onPick: pickTrack,
onMenuOpen: menuOpen,
),
),
ShaderControlButton(
focusNode: _noFocus,
renderMode: playback.renderMode,
onPick: _pickShader,
onMenuOpen: menuOpen,
),
PlayerSettingsButton(focusNode: _noFocus, renderMode: playback.renderMode),
MaterialDesktopCustomButton(
icon: Icon(widget.fullscreen ? Icons.fullscreen_exit : Icons.fullscreen),
onPressed: widget.onToggleFullscreen,
),
];
}
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,
hideAfter: _controlsHide,
child: DoubleClickListener(
onDoubleClick: widget.onToggleFullscreen,
// Clicks on the bars are button clicks, not the video.
skipTop: _topBarHeight,
skipBottom: _barHeight + ChapterSeekBar.height,
child: MaterialDesktopVideoControls(state),
),
),
mpv: (_) => _mpvControls(),
);
}
/// The uosc controls: same actions as the bars above,
/// all through the coordinator (seeks via `player.seek`, the user path).
Widget _mpvControls() {
final coord = _coord;
final playback = coord.playback;
void menuOpen(bool open) => _shaderMenuOpen = open;
void pickTrack(TrackPick p) => coord.selectTrack(aid: p.aid, sid: p.sid, subtitlesOff: p.subtitlesOff);
Widget episode(bool next) => SignalBuilder(builder: (context) {
final e = next ? coord.nextEpisode.value : coord.previousEpisode.value;
if (e == null) return const SizedBox.shrink();
return UoscButton(
key: ValueKey(next ? 'episode-next' : 'episode-previous'),
icon: next ? Icons.skip_next_rounded : Icons.skip_previous_rounded,
tooltip: '${next ? 'Next' : 'Previous'}: ${e.label} (${next ? '> or Shift+N' : '< or Shift+P'})',
onPressed: next ? coord.playNextEpisode : coord.playPreviousEpisode,
);
});
return UoscControls(
onDoubleTap: widget.onToggleFullscreen,
topLeft: const NowPlayingTitle(),
top: Row(
mainAxisSize: MainAxisSize.min,
children: [
UoscButton(icon: Icons.chat_bubble_outline_rounded, tooltip: 'Chat & activity (Tab)', onPressed: _toggleChat),
UoscButton(
icon: widget.fullscreen ? Icons.fullscreen_exit_rounded : Icons.close_fullscreen_rounded,
tooltip: widget.fullscreen ? 'Exit fullscreen (Esc)' : 'Back to the library (keeps playing)',
onPressed: widget.fullscreen ? widget.onToggleFullscreen : widget.onCollapse,
),
?_fullscreenWindowButtons(),
],
),
timeline: (context, expanded) => SignalBuilder(
builder: (context) => UoscTimeline(
position: playback.position.value,
duration: playback.duration.value,
buffered: playback.buffered.value,
chapters: playback.chapters.value,
expanded: expanded,
onSeek: (d) => playback.player.seek(d),
),
),
left: [
UoscMainMenuButton(
onMenuOpen: menuOpen,
actions: [
(title: 'Change video', hint: null, run: widget.onChangeVideo),
(title: 'Chat & activity', hint: 'Tab', run: _toggleChat),
(title: 'Reload video', hint: 'Ctrl+R', run: _reloadVideo),
(title: 'Mark everyone ready', hint: null, run: coord.forcePlayAll),
(title: 'Fullscreen', hint: 'F', run: widget.onToggleFullscreen),
(title: 'Leave server', hint: 'Esc', run: widget.onLeave),
],
),
SignalBuilder(
builder: (context) => UoscTracksButton(
type: JellyfinStreamType.subtitle,
tracks: playback.tracks.value,
onPick: pickTrack,
onMenuOpen: menuOpen,
),
),
SignalBuilder(
builder: (context) => UoscTracksButton(
type: JellyfinStreamType.audio,
tracks: playback.tracks.value,
onPick: pickTrack,
onMenuOpen: menuOpen,
),
),
UoscShaderButton(renderMode: playback.renderMode, onPick: _pickShader, onMenuOpen: menuOpen),
const SizedBox(width: 12),
SignalBuilder(builder: (context) {
final ready = coord.selfMember.value?.ready ?? false;
return UoscButton(
key: const ValueKey('uosc-ready'),
icon: ready ? Icons.check_circle_rounded : Icons.radio_button_unchecked_rounded,
label: ready ? 'Ready' : 'Not ready',
color: ready ? Colors.greenAccent : null,
tooltip: 'Ready / not ready (Space)',
onPressed: coord.toggleReady,
);
}),
UoscButton(icon: Icons.done_all_rounded, tooltip: 'Mark everyone ready', onPressed: coord.forcePlayAll),
UoscButton(
icon: Icons.refresh_rounded,
tooltip: 'Reload video (Ctrl+R) — fixes a frozen picture',
onPressed: _reloadVideo,
),
],
right: [
UoscButton(
key: const ValueKey('uosc-skip-forward'),
icon: Icons.fast_forward_rounded,
label: '1:30',
tooltip: 'Skip 1:30 for everyone (Shift+Right)',
onPressed: coord.skipForward,
),
SignalBuilder(
builder: (context) => UoscChaptersButton(
chapters: playback.chapters.value,
current: playback.currentChapter.value,
duration: playback.duration.value,
onPick: (c) => coord.seekTo(c.start.inMicroseconds / 1e6),
onMenuOpen: menuOpen,
),
),
episode(false),
episode(true),
const SizedBox(width: 12),
UoscButton(icon: Icons.video_library_outlined, tooltip: 'Change video', onPressed: widget.onChangeVideo),
StreamBuilder<double>(
stream: playback.player.stream.volume,
initialData: playback.player.state.volume,
builder: (context, v) => UoscVolumeButton(
volume: v.data ?? 100,
onChanged: (x) => playback.player.setVolume(x),
),
),
UoscPlayerSettingsButton(renderMode: playback.renderMode),
UoscButton(
icon: widget.fullscreen ? Icons.fullscreen_exit_rounded : Icons.fullscreen_rounded,
tooltip: 'Fullscreen (F)',
onPressed: widget.onToggleFullscreen,
),
],
);
}
/// 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 /
/// 1:30) above it. Outside the auto-hiding controls, so they stay up.
Widget _jellyfinOverlay() {
final coord = _coord;
return Positioned.fill(
child: SignalBuilder(
builder: (context) {
final blocked = coord.jellyfinBlocked.value;
final next = coord.nextEpisode.value;
final countdown = coord.autoAdvance.value;
final finished = coord.serverState.value?.transport == 'finished';
final pill = coord.nextPromptVisible.value;
final SkipOffer? skip = coord.skipOffer.value;
final showNext = next != null && pill;
return Stack(
children: [
if (blocked != null)
Center(
child: JellyfinBlockedCard(
blocked: blocked,
onLinkAccount: widget.onOpenIntegrations,
onRetry: coord.retryJellyfin,
),
),
if (next != null && (finished || countdown != null))
Positioned(
right: 24,
bottom: 138,
child: EndOfEpisodeCard(
next: next,
countdown: countdown,
onPlayNow: coord.playNextEpisode,
onCancel: coord.cancelAutoAdvance,
),
)
else if (showNext || skip != null)
Positioned(
right: 24,
bottom: 124,
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
spacing: 10,
children: [
if (skip != null) SkipPill(offer: skip, onPressed: () => coord.skip(skip)),
if (showNext) NextEpisodePill(episode: next, onPressed: coord.playNextEpisode),
],
),
),
],
);
},
),
);
}
/// Fullscreen hides the strip: while paused the party stays in a corner.
Widget _pausedParty() => Positioned(
right: 16,
top: _barHeight + 8,
child: PartyWatch(builder: (context) {
final r = _coord.serverState.value;
final paused = r != null && r.transport != 'playing';
return AnimatedOpacity(
duration: easeTime,
curve: ease,
opacity: paused ? 1 : 0,
child: IgnorePointer(child: AvatarStack(members: partyAvatars(_coord))),
);
}),
);
/// Mini: title, expand and close on hover; ready, time and a seekable
/// bar at the bottom. A thin progress line stays while not hovered.
Widget _miniOverlay() {
final coord = _coord;
final playback = coord.playback;
return MouseRegion(
onEnter: (_) => setState(() => _miniHover = true),
onExit: (_) => setState(() => _miniHover = false),
child: Stack(
fit: StackFit.expand,
children: [
AnimatedOpacity(
duration: const Duration(milliseconds: 160),
opacity: _miniHover ? 1 : 0,
child: IgnorePointer(
ignoring: !_miniHover,
child: DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.black54, Colors.transparent, Colors.black87],
),
),
child: Column(
children: [
Row(
children: [
const SizedBox(width: 10),
const Expanded(child: NowPlayingTitle(cover: false, compact: true)),
IconButton(
focusNode: _noFocus,
onPressed: widget.onExpand,
tooltip: 'Expand',
icon: const Icon(Icons.open_in_full, color: Colors.white, size: 18),
),
IconButton(
focusNode: _noFocus,
onPressed: widget.onClose,
tooltip: 'Stop watching (stay in the server)',
icon: const Icon(Icons.close, color: Colors.white, size: 18),
),
],
),
const Spacer(),
SignalBuilder(builder: (context) {
final ready = coord.selfMember.value?.ready ?? false;
final pos = playback.position.value, dur = playback.duration.value;
return Row(
children: [
IconButton(
focusNode: _noFocus,
tooltip: ready ? 'Ready (click: not ready)' : 'Not ready (click: ready)',
onPressed: coord.toggleReady,
icon: Icon(
ready ? Icons.check_circle : Icons.radio_button_unchecked,
color: ready ? Colors.greenAccent : Colors.white,
),
),
Text('${formatClock(pos)} / ${formatClock(dur)}',
style: const TextStyle(color: Colors.white70, fontSize: 12)),
const Spacer(),
Flexible(
child: Text(playerStatusNow()?.short ?? '',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.amber, fontSize: 12)),
),
const SizedBox(width: 10),
],
);
}),
_MiniSeekBar(onSeek: (d) => playback.player.seek(d)),
],
),
),
),
),
if (!_miniHover)
const Positioned(left: 0, right: 0, bottom: 0, child: _MiniSeekBar(thin: true)),
],
),
);
}
}
const _shaderKeys = [
LogicalKeyboardKey.digit0,
LogicalKeyboardKey.digit1,
LogicalKeyboardKey.digit2,
LogicalKeyboardKey.digit3,
LogicalKeyboardKey.digit4,
LogicalKeyboardKey.digit5,
LogicalKeyboardKey.digit6,
];
/// Mini player progress: a 2 px line, or (hovered) a 4 px bar that seeks.
class _MiniSeekBar extends StatelessWidget {
final bool thin;
final ValueChanged<Duration>? onSeek;
const _MiniSeekBar({super.key, this.thin = false, this.onSeek});
@override
Widget build(BuildContext context) {
final playback = SessionState.instance.coordinator.playback;
return SignalBuilder(builder: (context) {
final d = playback.duration.value;
final p = playback.position.value;
final v = d.inMilliseconds == 0 ? 0.0 : (p.inMilliseconds / d.inMilliseconds).clamp(0.0, 1.0);
final bar = LinearProgressIndicator(
value: v,
minHeight: thin ? 2 : 4,
backgroundColor: thin ? Colors.transparent : Colors.white24,
);
if (thin || onSeek == null || d == Duration.zero) return bar;
void seekAt(double x, double width) => onSeek!(d * (x / width).clamp(0.0, 1.0));
return LayoutBuilder(
builder: (context, box) => GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (e) => seekAt(e.localPosition.dx, box.maxWidth),
onHorizontalDragEnd: null,
onHorizontalDragUpdate: (e) => seekAt(e.localPosition.dx, box.maxWidth),
child: Padding(padding: const EdgeInsets.symmetric(vertical: 6), child: bar),
),
);
});
}
}
/// Double-click on the picture (not on the bars) without joining the
/// gesture arena, so single clicks on buttons stay instant.
class DoubleClickListener extends StatefulWidget {
final VoidCallback onDoubleClick;
final double skipTop, skipBottom;
final Widget child;
const DoubleClickListener(
{super.key, required this.onDoubleClick, this.skipTop = 0, this.skipBottom = 0, required this.child});
@override
State<DoubleClickListener> createState() => _DoubleClickListenerState();
}
class _DoubleClickListenerState extends State<DoubleClickListener> {
DateTime? _last;
Offset? _lastAt;
void _down(PointerDownEvent e) {
if (e.buttons != kPrimaryMouseButton) return;
final h = context.size?.height ?? 0;
final y = e.localPosition.dy;
if (y < widget.skipTop || y > h - widget.skipBottom) {
_last = null;
return;
}
final now = DateTime.now();
final last = _last, at = _lastAt;
if (last != null &&
at != null &&
now.difference(last) < const Duration(milliseconds: 400) &&
(e.localPosition - at).distance < 24) {
_last = null;
widget.onDoubleClick();
return;
}
_last = now;
_lastAt = e.localPosition;
}
@override
Widget build(BuildContext context) =>
Listener(behavior: HitTestBehavior.translucent, onPointerDown: _down, child: widget.child);
}
class _ReadyControlButton extends StatelessWidget {
final FocusNode focusNode;
const _ReadyControlButton({required this.focusNode});
@override
Widget build(BuildContext context) {
return SignalBuilder(
builder: (context) {
final ready = SessionState.instance.coordinator.selfMember.value?.ready ?? false;
return _BarTextButton(
focusNode: focusNode,
label: ready ? 'Ready ✓' : 'Not ready',
onPressed: SessionState.instance.coordinator.toggleReady,
);
},
);
}
}
class _BarTextButton extends StatelessWidget {
final FocusNode focusNode;
final String label;
final IconData? icon;
final String? tooltip;
final VoidCallback onPressed;
const _BarTextButton(
{required this.focusNode, required this.label, required this.onPressed, this.icon, this.tooltip});
@override
Widget build(BuildContext context) {
final style = TextButton.styleFrom(
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 8),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
);
final text = Text(label, style: const TextStyle(fontSize: 12));
final button = icon == null
? TextButton(focusNode: focusNode, style: style, onPressed: onPressed, child: text)
: TextButton.icon(
focusNode: focusNode, style: style, onPressed: onPressed, icon: Icon(icon, size: 16), label: text);
return tooltip == null ? button : Tooltip(message: tooltip, child: button);
}
}