forked from Foxyz/sync_replay_project
- Status: computed from connection, presence, hold, relay, player facts; no setters (health writes and HealthStatus gone); pill, strip and source line read it; transitions go to the Notifier. - Notifier: noticeFor(event, context) with self presence, alone, window activity, playing and prefs; toasts for joins/leaves/returns/swaps/ Up next adds (with picture)/problems/chat, the rest logged; while sitting out only chat, nudges, starting, Up next (+ joins if chosen); alone no social notices; replayed lines logged; owns its toast timer. - System notifications (flutter_local_notifications: D-Bus on Linux, WinRT on Windows) when the window isn't focused; a click focuses it. - Toasts sit above the control bar and move with it. - Versions: each member's in the members card and avatar tooltip, the server's in the card header; the strip no longer shows it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
813 lines
29 KiB
Dart
813 lines
29 KiB
Dart
import 'dart:async';
|
|
import 'dart:math' as math;
|
|
|
|
import 'package:flutter/foundation.dart' show ValueListenable;
|
|
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 '../controls/controls_probe.dart';
|
|
import '../controls/model/controls_model.dart';
|
|
import '../controls/session_controls.dart';
|
|
import '../controls/volume.dart';
|
|
import '../queue/queue_ui.dart';
|
|
import '../playback/media_marks.dart' show SkipKind, SkipOffer, formatClock;
|
|
import '../screens/session/jellyfin_widgets.dart';
|
|
import '../screens/session/player_controls.dart';
|
|
import '../session/app_events.dart' show ShaderMessage;
|
|
import '../session/session_coordinator.dart';
|
|
import '../shaders/shader_presets.dart';
|
|
import '../state/session_state.dart';
|
|
import '../state/settings.dart';
|
|
import '../widgets/app_keys.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 'sit_out_card.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: the controls (one model, the skin picked in
|
|
/// 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;
|
|
/// Follows whether the full player's controls are up (the shell lifts
|
|
/// its toasts above them); the player keeps its own when null.
|
|
final ValueNotifier<bool>? controlsShown;
|
|
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,
|
|
this.controlsShown,
|
|
});
|
|
|
|
@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 _noFocus = FocusNode(skipTraversal: true, canRequestFocus: false);
|
|
DateTime? _lastKeyAt;
|
|
late final void Function() _stopShaderWatch;
|
|
int _shaderNoticeSeen = 0;
|
|
bool _miniHover = false;
|
|
// Whether the controls are up (their frame reports it).
|
|
final _ownShown = ValueNotifier(true);
|
|
ValueNotifier<bool> get _controlsShown => widget.controlsShown ?? _ownShown;
|
|
// The mouse on a floating element, or the Up next popover open, keeps
|
|
// the controls up.
|
|
final _hold = ControlsHold();
|
|
late final void Function() _stopPopoverWatch;
|
|
// The shell's state the controls show, as signals for the model.
|
|
late final _fullscreen = signal(widget.fullscreen);
|
|
late final _chatOpen = signal(widget.chatOpen);
|
|
late final _unread = signal(widget.unread);
|
|
late final PlayerControlsModel _model = PlayerControlsModel(
|
|
coord: _coord,
|
|
fullscreen: _fullscreen,
|
|
chatOpen: _chatOpen,
|
|
unread: _unread,
|
|
onToggleFullscreen: () => widget.onToggleFullscreen(),
|
|
onToggleChat: () => widget.onToggleChat(),
|
|
onChangeVideo: () => widget.onChangeVideo(),
|
|
onCollapse: () => widget.onCollapse(),
|
|
onLeave: () => widget.onLeave(),
|
|
);
|
|
|
|
SessionCoordinator get _coord => SessionState.instance.coordinator;
|
|
bool get _full => widget.mode == PlayerMode.full;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_coord.jellyfin.ensureLoaded();
|
|
HardwareKeyboard.instance.addHandler(_globalKey);
|
|
// Tab (AppKeys) reaches the chat through here in fullscreen.
|
|
AppKeys.addChatToggle(_toggleChat);
|
|
// 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;
|
|
_coord.notify(ShaderMessage(n.text));
|
|
});
|
|
_stopPopoverWatch = effect(() {
|
|
_hold.set(#upNextPopover, _coord.queue.popoverOpen.value);
|
|
});
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(SessionPlayer oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
_fullscreen.value = widget.fullscreen;
|
|
_chatOpen.value = widget.chatOpen;
|
|
_unread.value = widget.unread;
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
HardwareKeyboard.instance.removeHandler(_globalKey);
|
|
AppKeys.removeChatToggle(_toggleChat);
|
|
_stopShaderWatch();
|
|
_stopPopoverWatch();
|
|
_noFocus.dispose();
|
|
_ownShown.dispose();
|
|
_hold.dispose();
|
|
_model.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _toggleChat() => _model.toggleChat();
|
|
|
|
/// The player keys, while the player fills the body and nothing (a
|
|
/// dialog, a card, a text field) is in front of it. Space and Tab are
|
|
/// app-wide ([AppKeys]).
|
|
bool _globalKey(KeyEvent event) {
|
|
if (event is! KeyDownEvent || !mounted) return false;
|
|
if (SessionState.instance.route.peek() != AppRoute.session) return false;
|
|
// Mini: Esc is the last step out, asking to leave the server.
|
|
if (!_full) {
|
|
if (event.logicalKey != LogicalKeyboardKey.escape) return false;
|
|
if (ModalRoute.of(context)?.isCurrent == false || textInputFocused()) return false;
|
|
widget.onLeave();
|
|
return true;
|
|
}
|
|
final route = ModalRoute.of(context);
|
|
if (route != null && !route.isCurrent) {
|
|
// A menu of ours is open: Esc closes it.
|
|
if (_model.menusOpen > 0 && 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 model = _model;
|
|
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;
|
|
model.pickShader(digit == 0 ? ShaderPreset.off : ShaderPreset.modes[digit - 1], null);
|
|
return true;
|
|
}
|
|
if (key == LogicalKeyboardKey.keyR && HardwareKeyboard.instance.isControlPressed) {
|
|
_lastKeyAt = now;
|
|
model.reload();
|
|
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)) {
|
|
// Up next first, then the next episode.
|
|
if (!model.next()) return false;
|
|
_lastKeyAt = now;
|
|
return true;
|
|
}
|
|
if (event.character == '<' || (shift && key == LogicalKeyboardKey.keyP)) {
|
|
if (!model.previous()) return false;
|
|
_lastKeyAt = now;
|
|
return true;
|
|
}
|
|
// mpv's chapter keys.
|
|
if (key == LogicalKeyboardKey.pageDown || key == LogicalKeyboardKey.pageUp) {
|
|
_lastKeyAt = now;
|
|
return model.stepChapter(key == LogicalKeyboardKey.pageDown ? 1 : -1);
|
|
}
|
|
if (shift && key == LogicalKeyboardKey.arrowRight) {
|
|
_lastKeyAt = now;
|
|
model.skipForward();
|
|
return true;
|
|
}
|
|
if (key == LogicalKeyboardKey.arrowUp || key == LogicalKeyboardKey.arrowDown) {
|
|
model.stepVolume(key == LogicalKeyboardKey.arrowUp ? 1 : -1);
|
|
return true;
|
|
}
|
|
if (key == LogicalKeyboardKey.arrowRight) {
|
|
_lastKeyAt = now;
|
|
model.nudge(10);
|
|
return true;
|
|
}
|
|
if (key == LogicalKeyboardKey.arrowLeft) {
|
|
_lastKeyAt = now;
|
|
model.nudge(-10);
|
|
return true;
|
|
}
|
|
if (key == LogicalKeyboardKey.escape) {
|
|
_lastKeyAt = now;
|
|
if (widget.chatOpen) {
|
|
widget.onToggleChat();
|
|
} else if (widget.fullscreen) {
|
|
widget.onToggleFullscreen();
|
|
} else {
|
|
// One step at a time: big player → mini, then (from mini) leave.
|
|
widget.onCollapse();
|
|
}
|
|
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);
|
|
// The wheel anywhere on the full player steps the volume
|
|
// (lists, menus and popovers under the mouse scroll instead).
|
|
return VolumeWheel(
|
|
enabled: _full,
|
|
onStep: _model.stepVolume,
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
video,
|
|
if (_full) ...[
|
|
_blockedCard(),
|
|
_actionArea(),
|
|
if (widget.fullscreen) ...[if (!SessionState.instance.solo.value) _pausedParty(), _unreadChip()],
|
|
_volumeReadout(),
|
|
] else
|
|
_miniOverlay(),
|
|
// Sitting out: nothing is loaded; the card covers the
|
|
// empty player (which stays mounted for joining back).
|
|
if (_coord.selfAway.value) _sitOut(),
|
|
],
|
|
),
|
|
);
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _video(VideoControlsBuilder? controls) => Video(
|
|
key: _videoKey,
|
|
controller: _coord.playback.video,
|
|
subtitleViewConfiguration: const SubtitleViewConfiguration(visible: false),
|
|
controls: controls,
|
|
);
|
|
|
|
/// 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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _sessionControls(VideoState state) {
|
|
// The start card sits under the controls (they stay usable on it).
|
|
return Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
const StartBanner(),
|
|
SessionControls(
|
|
model: _model,
|
|
shown: _controlsShown,
|
|
hold: _hold,
|
|
onDoubleClick: widget.onToggleFullscreen,
|
|
windowButtons: _fullscreenWindowButtons(),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// The side inset every floating element keeps (the bars' own), and
|
|
/// the gap between them and the controls.
|
|
static const _edge = 16.0;
|
|
|
|
/// The bottom controls' height in the skin and size in use.
|
|
static double _bottomControlsHeight() {
|
|
final s = AppSettings.instance;
|
|
return controlsBottomHeight(s.controlsSkin.value, s.controlsSize.value);
|
|
}
|
|
|
|
static double get _topBarHeight => controlsTopHeight(AppSettings.instance.controlsSkin.peek());
|
|
|
|
/// The volume flash after a wheel or key step: a pill under the top bar.
|
|
Widget _volumeReadout() => Positioned(
|
|
left: 0,
|
|
right: 0,
|
|
top: _topBarHeight + 8,
|
|
child: Center(
|
|
child: VolumeFlash(
|
|
trigger: _model.volumeFlash,
|
|
builder: (context) => SignalBuilder(builder: (context) => VolumeReadout(volume: _model.volume.value)),
|
|
),
|
|
),
|
|
);
|
|
|
|
/// The action area: one band above the bottom controls for everything
|
|
/// that floats there. The status pill (the ready prompt among it)
|
|
/// centred; at the right edge the end cards, else the action pills
|
|
/// (Skip OP / ED / 1:30 over Next episode). It rides on the controls:
|
|
/// just above them while they are up, down near the edge while they are
|
|
/// hidden. The mouse on any of it counts as the mouse on the controls
|
|
/// ([ControlsHold]).
|
|
Widget _actionArea() {
|
|
return SignalBuilder(builder: (context) {
|
|
final controls = _bottomControlsHeight();
|
|
return ValueListenableBuilder<bool>(
|
|
valueListenable: _controlsShown,
|
|
builder: (context, shown, child) => AnimatedPositioned(
|
|
duration: easeTime,
|
|
curve: ease,
|
|
left: _edge,
|
|
right: _edge,
|
|
top: 0,
|
|
bottom: shown ? controls + _edge : _edge,
|
|
child: child!,
|
|
),
|
|
child: CustomMultiChildLayout(
|
|
delegate: _ActionAreaLayout(gap: _edge),
|
|
children: [
|
|
LayoutId(id: _ActionSlot.status, child: ControlsHoldRegion(hold: _hold, child: const PlayerStatus())),
|
|
LayoutId(id: _ActionSlot.side, child: _actionSide()),
|
|
],
|
|
),
|
|
);
|
|
});
|
|
}
|
|
|
|
/// The action area's right end: the end-of-media card (Up next, else the
|
|
/// next episode; it stays up), else the pills, shown only with the
|
|
/// controls ([PillGate]).
|
|
Widget _actionSide() {
|
|
final coord = _coord;
|
|
return SignalBuilder(
|
|
builder: (context) {
|
|
final next = coord.nextEpisode.value;
|
|
final countdown = coord.autoAdvance.value;
|
|
// Up next goes before the next episode.
|
|
final upNext = coord.queue.countdown.value;
|
|
final finished = coord.serverState.value?.transport == 'finished';
|
|
final pill = coord.nextPromptVisible.value;
|
|
final SkipOffer? skip = coord.skipOffer.value;
|
|
final showNext = next != null && pill;
|
|
if (upNext != null && finished) {
|
|
return ControlsHoldRegion(hold: _hold, child: UpNextCard(queue: coord.queue, countdown: upNext));
|
|
}
|
|
// "Clear" in Up next hides the next episode for this media.
|
|
if (next != null && (finished || countdown != null) && coord.queue.shownContinuation.value.isNotEmpty) {
|
|
return ControlsHoldRegion(
|
|
hold: _hold,
|
|
child: EndOfEpisodeCard(
|
|
next: next,
|
|
countdown: countdown,
|
|
onPlayNow: coord.playNextEpisode,
|
|
onCancel: coord.cancelAutoAdvance,
|
|
),
|
|
);
|
|
}
|
|
if (!showNext && skip == null) return const SizedBox.shrink();
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
spacing: 8,
|
|
children: [
|
|
if (skip != null)
|
|
PillGate(
|
|
key: const ValueKey('skip-gate'),
|
|
kind: skip.kind,
|
|
// Skip ED: no announcing, only with the controls.
|
|
announce: skip.kind != SkipKind.ending,
|
|
controlsShown: _controlsShown,
|
|
hold: _hold,
|
|
child: SkipPill(offer: skip, onPressed: () => _model.skip(skip)),
|
|
),
|
|
if (showNext)
|
|
PillGate(
|
|
key: const ValueKey('next-gate'),
|
|
kind: next,
|
|
controlsShown: _controlsShown,
|
|
hold: _hold,
|
|
child: NextEpisodePill(episode: next, onPressed: coord.playNextEpisode),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Jellyfin refused this viewer: the card in the middle (it stays up).
|
|
Widget _blockedCard() => Positioned.fill(
|
|
child: SignalBuilder(builder: (context) {
|
|
final blocked = _coord.jellyfinBlocked.value;
|
|
if (blocked == null) return const SizedBox.shrink();
|
|
return Center(
|
|
child: ControlsHoldRegion(
|
|
hold: _hold,
|
|
child: JellyfinBlockedCard(
|
|
blocked: blocked,
|
|
onLinkAccount: widget.onOpenIntegrations,
|
|
onRetry: _coord.retryJellyfin,
|
|
),
|
|
),
|
|
);
|
|
}),
|
|
);
|
|
|
|
/// Fullscreen hides the strip: while paused the party stays in a corner.
|
|
Widget _pausedParty() => Positioned(
|
|
right: _edge,
|
|
top: _topBarHeight + 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))),
|
|
);
|
|
}),
|
|
);
|
|
|
|
/// Fullscreen hides the strip and its chat badge: while there is unread
|
|
/// chat, the controls are down and the video is not playing, a small
|
|
/// glass chip says so top right (while it plays, only the controls' chat
|
|
/// button shows the count: nothing sits on the picture).
|
|
Widget _unreadChip() => Positioned(
|
|
right: _edge,
|
|
top: _edge,
|
|
child: IgnorePointer(
|
|
child: ValueListenableBuilder<bool>(
|
|
valueListenable: _controlsShown,
|
|
builder: (context, shown, _) => SignalBuilder(builder: (context) {
|
|
final playing = _coord.serverState.value?.transport == 'playing';
|
|
final on = widget.unread > 0 && !widget.chatOpen && !shown && !playing;
|
|
return AnimatedOpacity(
|
|
duration: easeTime,
|
|
curve: ease,
|
|
opacity: on ? 1 : 0,
|
|
child: PlayerGlass(
|
|
key: const ValueKey('unread-chip'),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Icon(Icons.chat_bubble_rounded, size: 14, color: Colors.white70),
|
|
const SizedBox(width: 6),
|
|
Text('${widget.unread}', style: const TextStyle(color: Colors.white, fontSize: 12)),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}),
|
|
),
|
|
),
|
|
);
|
|
|
|
/// [SitOutCard] with the mini's (or the full player's) expand /
|
|
/// collapse and close.
|
|
Widget _sitOut() => Positioned.fill(
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
SitOutCard(key: const ValueKey('sit-out-card'), compact: !_full),
|
|
Positioned(
|
|
top: 4,
|
|
right: 4,
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
IconButton(
|
|
focusNode: _noFocus,
|
|
onPressed: _full ? widget.onCollapse : widget.onExpand,
|
|
tooltip: _full ? 'Small player' : 'Expand',
|
|
icon: Icon(_full ? Icons.close_fullscreen : Icons.open_in_full, color: Colors.white, size: 18),
|
|
),
|
|
IconButton(
|
|
focusNode: _noFocus,
|
|
onPressed: widget.onClose,
|
|
tooltip: 'Close the player',
|
|
icon: const Icon(Icons.close, color: Colors.white, size: 18),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
/// 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: 'Sit out (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)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A floating pill's visibility: only while the controls are up (the
|
|
/// mouse moved) or the mouse is on it. With [announce] it is also up for
|
|
/// [firstShown] whenever [kind] changes (a new offer: Skip OP, Skip 1:30).
|
|
/// Gone with the offer (the parent drops it). The mouse on it holds
|
|
/// [hold]: it counts as the mouse on the controls.
|
|
class PillGate extends StatefulWidget {
|
|
static const firstShown = Duration(seconds: 5);
|
|
final Object kind;
|
|
final bool announce;
|
|
final ValueListenable<bool> controlsShown;
|
|
final ControlsHold? hold;
|
|
final Widget child;
|
|
const PillGate({
|
|
super.key,
|
|
required this.kind,
|
|
this.announce = false,
|
|
required this.controlsShown,
|
|
this.hold,
|
|
required this.child,
|
|
});
|
|
|
|
@override
|
|
State<PillGate> createState() => _PillGateState();
|
|
}
|
|
|
|
class _PillGateState extends State<PillGate> {
|
|
Object? _kind;
|
|
bool _fresh = false;
|
|
bool _hover = false;
|
|
Timer? _timer;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_arm();
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(PillGate old) {
|
|
super.didUpdateWidget(old);
|
|
if (widget.kind != _kind) _arm();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_timer?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
void _arm() {
|
|
_kind = widget.kind;
|
|
_timer?.cancel();
|
|
_fresh = widget.announce;
|
|
if (!_fresh) return;
|
|
_timer = Timer(PillGate.firstShown, () {
|
|
if (mounted) setState(() => _fresh = false);
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// The pill covers the controls' hover area: on it, [hold] keeps them
|
|
// up (and it stays while the mouse is on it).
|
|
return ValueListenableBuilder<bool>(
|
|
valueListenable: widget.controlsShown,
|
|
builder: (context, shown, child) {
|
|
final on = _fresh || shown || _hover;
|
|
return IgnorePointer(
|
|
ignoring: !on,
|
|
child: MouseRegion(
|
|
onEnter: (_) => setState(() => _hover = true),
|
|
onExit: (_) => setState(() => _hover = false),
|
|
child: AnimatedOpacity(duration: easeTime, curve: ease, opacity: on ? 1 : 0, child: child),
|
|
),
|
|
);
|
|
},
|
|
child: ControlsHoldRegion(hold: widget.hold, child: widget.child),
|
|
);
|
|
}
|
|
}
|
|
|
|
enum _ActionSlot { status, side }
|
|
|
|
/// The action area's two slots, at its bottom: [_ActionSlot.side] at the
|
|
/// right end, [_ActionSlot.status] centred, moved left only as far as it
|
|
/// must to keep [gap] from the side (and never wider than what is left).
|
|
class _ActionAreaLayout extends MultiChildLayoutDelegate {
|
|
final double gap;
|
|
_ActionAreaLayout({required this.gap});
|
|
|
|
@override
|
|
void performLayout(Size size) {
|
|
final side = layoutChild(_ActionSlot.side, BoxConstraints.loose(size));
|
|
positionChild(_ActionSlot.side, Offset(size.width - side.width, size.height - side.height));
|
|
final room = side.width > 0 ? size.width - side.width - gap : size.width;
|
|
final status = layoutChild(_ActionSlot.status, BoxConstraints.loose(Size(math.max(0, room), size.height)));
|
|
final x = math.min((size.width - status.width) / 2, room - status.width);
|
|
positionChild(_ActionSlot.status, Offset(math.max(0, x), size.height - status.height));
|
|
}
|
|
|
|
@override
|
|
bool shouldRelayout(_ActionAreaLayout old) => old.gap != gap;
|
|
}
|
|
|
|
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({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),
|
|
),
|
|
);
|
|
});
|
|
}
|
|
}
|