Files
plezy/lib/screens/music/now_playing_screen.dart
T

1205 lines
43 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'dart:math' as math;
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../../focus/dpad_navigator.dart';
import '../../focus/focus_theme.dart';
import '../../focus/focusable_action_bar.dart';
import '../../focus/focusable_button.dart';
import '../../focus/focusable_slider.dart';
import '../../focus/input_mode_tracker.dart';
import '../../focus/key_event_utils.dart';
import '../../i18n/strings.g.dart';
import '../../media/ids.dart';
import '../../media/lyrics.dart';
import '../../media/media_item.dart';
import '../../media/stepped_seek.dart';
import '../../media/media_server_client.dart';
import '../../mixins/context_menu_tap_mixin.dart';
import '../../services/device_performance.dart';
import '../../services/music/music_playback_service.dart';
import '../../theme/mono_motion.dart';
import '../../theme/mono_tokens.dart';
import '../../utils/app_logger.dart';
import '../../utils/formatters.dart';
import '../../utils/desktop_window_padding.dart';
import '../../utils/media_image_helper.dart';
import '../../utils/music_navigation.dart';
import '../../utils/platform_detector.dart';
import '../../utils/provider_extensions.dart';
import '../../utils/snackbar_helper.dart';
import '../../widgets/app_icon.dart';
import '../../widgets/app_menu.dart';
import '../../widgets/media_context_menu.dart';
import '../../widgets/music/lyrics_view.dart';
import '../../widgets/music/repeat_mode.dart';
import '../../widgets/optimized_media_image.dart';
import '../../widgets/overlay_sheet.dart';
import 'queue_sheet.dart';
/// Full-screen music player. Pushed via [openNowPlaying]; popping never
/// touches playback — audio continues under the mini-player.
///
/// Layouts: mobile portrait (big art / transport stack), desktop & landscape
/// (two-pane with an inline queue panel), and TV (full-bleed art with a
/// d-pad transport chain).
///
/// D-pad chain (TV): transport row autofocuses play/pause (LEFT/RIGHT rove,
/// edges trapped) · UP → seek bar (LEFT/RIGHT = ±10s with key-repeat
/// acceleration) · seek UP → ⋮ overflow · transport DOWN → utility row
/// (Lyrics, Queue) · Lyrics toggle focuses the lyrics pane (BACK/DOWN-out
/// returns to transport) · BACK pops. Focus is always a text-based
/// background fill.
class NowPlayingScreen extends StatefulWidget {
const NowPlayingScreen({super.key});
@override
State<NowPlayingScreen> createState() => _NowPlayingScreenState();
}
class _NowPlayingScreenState extends State<NowPlayingScreen>
with ContextMenuTapMixin<NowPlayingScreen>, SingleTickerProviderStateMixin {
MusicPlaybackService? _service;
StreamSubscription<Object>? _errorsSub;
bool _showLyrics = false;
final Map<String, Future<Lyrics?>> _lyricsCache = {};
/// Key for getting a context below this screen's own OverlaySheetHost —
/// the State's context sits ABOVE it, so sheet calls made with `context`
/// would miss the host and fall back to a hostless modal sheet.
final GlobalKey _overlayChildKey = GlobalKey();
BuildContext get _sheetContext => _overlayChildKey.currentContext ?? context;
/// Swipe-down-to-dismiss (mobile portrait). The offset feeds a
/// ValueListenableBuilder-wrapped Transform so drag frames never rebuild
/// the screen.
final ValueNotifier<double> _dismissDrag = ValueNotifier<double>(0);
late final AnimationController _dismissSettle;
double _dismissSettleFrom = 0;
final FocusNode _seekFocusNode = FocusNode(debugLabel: 'now_playing_seek');
final FocusNode _overflowFocusNode = FocusNode(debugLabel: 'now_playing_overflow');
final FocusNode _playPauseFocusNode = FocusNode(debugLabel: 'now_playing_play_pause');
final FocusNode _lyricsPaneFocusNode = FocusNode(debugLabel: 'now_playing_lyrics_pane');
final GlobalKey<FocusableActionBarState> _utilityBarKey = GlobalKey<FocusableActionBarState>();
bool _poppedForIdle = false;
@override
void initState() {
super.initState();
_dismissSettle = AnimationController(vsync: this, duration: const Duration(milliseconds: 200))
..addListener(_onDismissSettleTick);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final service = context.read<MusicPlaybackService>();
if (service != _service) {
_errorsSub?.cancel();
_service = service;
// Surface playback failures while the screen is open — the service
// already recovers (skip / stop) by itself.
_errorsSub = service.errors.listen((error) {
if (mounted) showErrorSnackBar(context, t.messages.errorLoading(error: error.toString()));
});
}
}
@override
void dispose() {
_errorsSub?.cancel();
_dismissSettle.dispose();
_dismissDrag.dispose();
_seekFocusNode.dispose();
_overflowFocusNode.dispose();
_playPauseFocusNode.dispose();
_lyricsPaneFocusNode.dispose();
super.dispose();
}
// -------------------------------------------------------------------
// Actions
// -------------------------------------------------------------------
Future<Lyrics?> _lyricsFutureFor(MediaItem track) =>
_lyricsCache.putIfAbsent(track.globalKey, () => context.read<MusicPlaybackService>().fetchLyrics(track));
void _toggleLyrics() {
setState(() => _showLyrics = !_showLyrics);
if (_showLyrics && InputModeTracker.isKeyboardMode(context)) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _showLyrics) _lyricsPaneFocusNode.requestFocus();
});
}
}
void _focusTransport() => _playPauseFocusNode.requestFocus();
void _pop() => Navigator.pop(context);
// Swipe-down anywhere above the transport controls closes the screen —
// the standard music-player gesture. The content follows the finger;
// releasing past the threshold pops (the route's reverse slide+fade
// continues the motion from where the finger left off), otherwise the
// content settles back.
void _onDismissSettleTick() {
_dismissDrag.value = _dismissSettleFrom * (1 - Curves.easeOutCubic.transform(_dismissSettle.value));
}
void _onDismissDragStart(DragStartDetails details) => _dismissSettle.stop();
void _onDismissDragUpdate(DragUpdateDetails details) {
_dismissDrag.value = math.max(0, _dismissDrag.value + details.delta.dy);
}
void _onDismissDragEnd(DragEndDetails details) {
final offset = _dismissDrag.value;
if (offset <= 0) return;
// Same thresholds as the overlay sheet system's drag-to-dismiss.
if (offset > MediaQuery.sizeOf(context).height * 0.25 || (details.primaryVelocity ?? 0) > 500) {
_pop();
} else {
_onDismissDragCancel();
}
}
void _onDismissDragCancel() {
if (_dismissDrag.value <= 0) return;
_dismissSettleFrom = _dismissDrag.value;
_dismissSettle.forward(from: 0);
}
/// Artist line tap — the track's grandparent is the artist. Mirrors the
/// album screen's artist link (fetch, then navigate; soft-fail).
Future<void> _openArtist(MediaItem track) async {
final artistId = track.grandparentId;
final client = context.getMediaClientForItemOrNull(track);
if (artistId == null || client == null) return;
MediaItem? artist;
try {
artist = await client.fetchItem(artistId);
} catch (e) {
appLogger.w('Failed to fetch artist $artistId for track ${track.id}', error: e);
}
if (artist == null || !mounted) return;
await navigateToArtist(context, artist);
}
Future<void> _showSleepTimerSheet() async {
final service = context.read<MusicPlaybackService>();
final timed = service.sleepTimerActive && !service.sleepTimerEndOfTrack;
final selected = await OverlaySheetController.showAdaptive<String>(
_sheetContext,
showDragHandle: true,
builder: (context) => AppMenuSheet<String>(
title: t.music.sleepTimer,
entries: [
AppMenuItem(
value: 'off',
icon: Symbols.timer_off_rounded,
label: t.common.off,
selected: !service.sleepTimerActive,
),
for (final minutes in const [15, 30, 60])
AppMenuItem(
value: '$minutes',
icon: Symbols.timer_rounded,
label: t.music.sleepTimerMinutes(n: minutes),
selected: timed && service.sleepTimerDuration == Duration(minutes: minutes),
),
AppMenuItem(
value: 'end_of_track',
icon: Symbols.music_note_rounded,
label: t.music.sleepTimerEndOfTrack,
selected: service.sleepTimerEndOfTrack,
),
],
),
);
if (!mounted || selected == null) return;
switch (selected) {
case 'off':
service.setSleepTimer(null);
case 'end_of_track':
service.setSleepTimer(null, endOfTrack: true);
default:
final minutes = int.tryParse(selected);
if (minutes != null) service.setSleepTimer(Duration(minutes: minutes));
}
}
// -------------------------------------------------------------------
// Build
// -------------------------------------------------------------------
@override
Widget build(BuildContext context) {
final tk = tokens(context);
final service = context.watch<MusicPlaybackService>();
final track = service.currentTrack;
if (track == null) {
// Session ended elsewhere (stop / error / video claimed audio) — an
// empty player is a dead end, leave the screen.
if (!_poppedForIdle) {
_poppedForIdle = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && Navigator.canPop(context)) Navigator.pop(context);
});
}
return Scaffold(backgroundColor: tk.bg, body: const SizedBox.expand());
}
final client = context.tryGetMediaClientWithFallback(serverIdOrNull(track.serverId));
final isTV = PlatformDetector.isTV();
Widget content = Stack(
fit: StackFit.expand,
children: [
_Background(track: track, client: client, heavyScrim: isTV),
SafeArea(
child: LayoutBuilder(
builder: (context, constraints) {
if (isTV) return _buildTvLayout(service, track, client);
final twoPane = constraints.maxWidth >= 800 && constraints.maxWidth > constraints.maxHeight;
return twoPane ? _buildWideLayout(service, track, client) : _buildPortraitLayout(service, track, client);
},
),
),
],
);
if (PlatformDetector.isDesktopOS() && !isTV) {
content = CallbackShortcuts(
bindings: {const SingleActivator(LogicalKeyboardKey.space): () => unawaited(service.togglePlayPause())},
child: FocusScope(child: Focus(autofocus: true, skipTraversal: true, child: content)),
);
}
// Own OverlaySheetHost so the queue / sleep-timer sheets have a host on
// TV; the host also owns system back (a back with a sheet open closes
// the sheet, otherwise the route pops natively — audio continues).
return OverlaySheetHost(
canPop: true,
child: Scaffold(
key: _overlayChildKey,
backgroundColor: tk.bg,
body: ValueListenableBuilder<double>(
valueListenable: _dismissDrag,
builder: (context, offset, child) => Transform.translate(offset: Offset(0, offset), child: child),
child: content,
),
),
);
}
// -------------------------------------------------------------------
// Layouts
// -------------------------------------------------------------------
Widget _buildPortraitLayout(MusicPlaybackService service, MediaItem track, MediaServerClient? client) {
Widget upper = Column(
children: [
_buildTopBar(track, service.playContext),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
child: _buildArtworkOrLyrics(service, track, client),
),
),
Padding(padding: const EdgeInsets.fromLTRB(32, 8, 32, 0), child: _buildTrackInfo(track, centered: true)),
],
);
// Touch only — desktop keeps mouse drags for text selection and the
// lyrics pane. A drag starting on the lyrics scrollable still scrolls
// (the descendant recognizer wins the arena).
if (!PlatformDetector.isDesktopOS()) {
upper = GestureDetector(
behavior: HitTestBehavior.opaque,
onVerticalDragStart: _onDismissDragStart,
onVerticalDragUpdate: _onDismissDragUpdate,
onVerticalDragEnd: _onDismissDragEnd,
onVerticalDragCancel: _onDismissDragCancel,
child: upper,
);
}
return Column(
children: [
Expanded(child: upper),
Padding(padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), child: _buildSeekBar(track)),
_buildTransportRow(service),
_buildUtilityRow(showQueueButton: true),
const SizedBox(height: 8),
],
);
}
Widget _buildWideLayout(MusicPlaybackService service, MediaItem track, MediaServerClient? client) {
final tk = tokens(context);
return Column(
children: [
_buildTopBar(track, service.playContext),
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 20),
child: Row(
crossAxisAlignment: .stretch,
children: [
Expanded(flex: 9, child: _buildArtworkOrLyrics(service, track, client)),
const SizedBox(width: 32),
Expanded(
flex: 11,
child: Column(
crossAxisAlignment: .stretch,
children: [
_buildTrackInfo(track, centered: false),
const SizedBox(height: 8),
_buildSeekBar(track),
_buildWideControlBand(service),
const SizedBox(height: 12),
// Inline queue panel — same widget the queue sheet uses.
Expanded(
child: Material(
color: tk.bg.withValues(alpha: 0.55),
borderRadius: BorderRadius.circular(tk.radiusLg),
clipBehavior: Clip.antiAlias,
child: const QueueList(),
),
),
],
),
),
],
),
),
),
],
);
}
Widget _buildTvLayout(MusicPlaybackService service, MediaItem track, MediaServerClient? client) {
final tk = tokens(context);
final textTheme = Theme.of(context).textTheme;
final sourceTitle = _playingFromTitle(track, service.playContext);
final artist = track.trackArtistTitle;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 56, vertical: 40),
child: Row(
children: [
Expanded(flex: 4, child: _buildArtworkOrLyrics(service, track, client, tvArt: true)),
const SizedBox(width: 48),
Expanded(
flex: 5,
child: Column(
mainAxisAlignment: .center,
crossAxisAlignment: .stretch,
children: [
Row(
children: [
Expanded(
child: sourceTitle == null
? const SizedBox.shrink()
: Text(
t.music.playingFrom(title: sourceTitle),
maxLines: 1,
overflow: .ellipsis,
style: TextStyle(fontSize: 14, color: tk.textMuted),
),
),
_buildOverflowButton(track, focusable: true),
],
),
const SizedBox(height: 8),
Text(
track.title ?? '',
maxLines: 2,
overflow: .ellipsis,
style: textTheme.headlineMedium?.copyWith(fontWeight: .w600, color: tk.text),
),
if (artist != null && artist.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
artist,
maxLines: 1,
overflow: .ellipsis,
style: textTheme.bodyLarge?.copyWith(color: tk.textMuted),
),
],
const SizedBox(height: 28),
_buildSeekBar(track),
const SizedBox(height: 8),
_buildTransportRow(service),
_buildUtilityRow(showQueueButton: true),
],
),
),
],
),
);
}
// -------------------------------------------------------------------
// Pieces
// -------------------------------------------------------------------
/// [playContext] is passed in (not selected) because this builds
/// inside the layout-phase LayoutBuilder, where `context.select` on the
/// screen's element asserts; the screen already watches the service.
Widget _buildTopBar(MediaItem track, MusicPlayContext? playContext) {
final tk = tokens(context);
final sourceTitle = _playingFromTitle(track, playContext);
// macOS pins the traffic lights at y=21 (16pt buttons → center 29, see
// WindowUtilsPlugin.customButtonPositions); a fixed 58px row centers the
// close button on that line regardless of the platform visual density
// shrinking the IconButton box.
final isMacOS = Theme.of(context).platform == TargetPlatform.macOS;
return Padding(
padding: EdgeInsets.fromLTRB(8, isMacOS ? 0 : 4, 8, 0),
child: SizedBox(
height: isMacOS ? 58 : null,
child: Row(
children: [
// Inset past the macOS traffic lights — this screen is a fullscreen
// route, so the close button would otherwise sit underneath them.
DesktopAppBarHelper.buildAdjustedLeading(
FocusableButton(
onPressed: _pop,
child: IconButton(
icon: AppIcon(Symbols.keyboard_arrow_down_rounded, fill: 1, color: tk.text),
tooltip: t.common.close,
onPressed: _pop,
),
),
context: context,
)!,
Expanded(
child: sourceTitle == null
? const SizedBox.shrink()
: Text(
t.music.playingFrom(title: sourceTitle),
textAlign: TextAlign.center,
maxLines: 1,
overflow: .ellipsis,
style: TextStyle(fontSize: 13, color: tk.textMuted),
),
),
_buildOverflowButton(track),
],
),
),
);
}
/// Album and ad-hoc queues follow the active track's album as the queue
/// crosses album boundaries. Artist/playlist/mix contexts remain stable
/// provenance labels for the session they created.
String? _playingFromTitle(MediaItem track, MusicPlayContext? playContext) {
final followsCurrentAlbum =
playContext == null ||
playContext.kind == MusicPlayContextKind.album ||
playContext.kind == MusicPlayContextKind.tracks;
final title = (followsCurrentAlbum ? track.albumTitle : playContext.title)?.trim();
if (title == null || title.isEmpty) return null;
return toBulletedString([title, if (followsCurrentAlbum && track.albumYear != null) '${track.albumYear}']);
}
/// Wide-layout control band: the transport row stays exactly centered
/// (equal-width flanks), with lyrics + a desktop volume slider as a
/// right-aligned cluster that scales down instead of overflowing when the
/// pane is narrow.
Widget _buildWideControlBand(MusicPlaybackService service) {
final tk = tokens(context);
final colorScheme = Theme.of(context).colorScheme;
final cluster = Row(
mainAxisSize: .min,
children: [
FocusableButton(
onPressed: _toggleLyrics,
child: IconButton(
icon: AppIcon(
Symbols.lyrics_rounded,
fill: 1,
size: 22,
color: _showLyrics ? colorScheme.primary : tk.textMuted,
),
tooltip: t.music.lyrics,
onPressed: _toggleLyrics,
),
),
if (PlatformDetector.isDesktop(context)) ...[const SizedBox(width: 4), _buildVolumeCluster(service)],
],
);
return Row(
children: [
const Expanded(child: SizedBox()),
_buildTransportRow(service),
Expanded(
child: Align(
alignment: .centerRight,
child: FittedBox(fit: BoxFit.scaleDown, child: cluster),
),
),
],
);
}
/// Desktop volume control (0100), persisted by the service across
/// sessions. Mono styling matches the seek bar: text-colored active track
/// on outline.
Widget _buildVolumeCluster(MusicPlaybackService service) {
return ValueListenableBuilder<double>(
valueListenable: service.volumeListenable,
builder: (context, volume, _) {
final tk = tokens(context);
final icon = volume <= 0
? Symbols.volume_off_rounded
: volume < 50
? Symbols.volume_down_rounded
: Symbols.volume_up_rounded;
return Row(
mainAxisSize: .min,
children: [
AppIcon(icon, fill: 1, size: 20, color: tk.textMuted),
SizedBox(
width: 140,
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 3,
activeTrackColor: tk.text,
inactiveTrackColor: tk.outline,
thumbColor: tk.text,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 12),
),
child: FocusableSlider(
value: volume.clamp(0.0, 100.0),
max: 100,
onChanged: (value) => unawaited(service.setVolume(value, persist: false)),
onChangeEnd: (value) => unawaited(service.setVolume(value)),
),
),
),
],
);
},
);
}
/// ⋮ — the current track's standard context menu plus the Sleep timer
/// entry. On TV ([focusable]) it joins the d-pad chain above the seek bar.
Widget _buildOverflowButton(MediaItem track, {bool focusable = false}) {
final tk = tokens(context);
void showMenu() => contextMenuKey.currentState?.showContextMenu(context);
return MediaContextMenu(
key: contextMenuKey,
item: track,
extraEntries: [
MediaMenuExtraEntry(icon: Symbols.bedtime_rounded, label: t.music.sleepTimer, onSelected: _showSleepTimerSheet),
],
child: FocusableButton(
focusNode: focusable ? _overflowFocusNode : null,
onPressed: showMenu,
onNavigateDown: focusable ? _seekFocusNode.requestFocus : null,
onNavigateUp: focusable ? () {} : null,
onNavigateLeft: focusable ? () {} : null,
onNavigateRight: focusable ? () {} : null,
onBack: focusable ? _pop : null,
useBackgroundFocus: focusable,
child: IconButton(
icon: AppIcon(Symbols.more_vert_rounded, fill: 1, color: tk.text),
onPressed: showMenu,
tooltip: MaterialLocalizations.of(context).showMenuTooltip,
),
),
);
}
Widget _buildTrackInfo(MediaItem track, {required bool centered}) {
final tk = tokens(context);
final textTheme = Theme.of(context).textTheme;
final artist = track.trackArtistTitle;
return Column(
crossAxisAlignment: centered ? CrossAxisAlignment.center : CrossAxisAlignment.start,
children: [
Text(
track.title ?? '',
maxLines: 1,
overflow: .ellipsis,
textAlign: centered ? TextAlign.center : TextAlign.start,
style: textTheme.headlineSmall?.copyWith(fontWeight: .w600, color: tk.text),
),
if (artist != null && artist.isNotEmpty)
MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () => unawaited(_openArtist(track)),
child: Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
artist,
maxLines: 1,
overflow: .ellipsis,
textAlign: centered ? TextAlign.center : TextAlign.start,
style: textTheme.bodyMedium?.copyWith(color: tk.textMuted),
),
),
),
),
],
);
}
Widget _buildArtworkOrLyrics(
MusicPlaybackService service,
MediaItem track,
MediaServerClient? client, {
bool tvArt = false,
}) {
return AnimatedSwitcher(
duration: MonoMotion.fill(context),
child: _showLyrics
? KeyedSubtree(
key: const ValueKey('now_playing_lyrics'),
child: LyricsView(
lyricsFuture: _lyricsFutureFor(track),
focusNode: _lyricsPaneFocusNode,
onExit: _focusTransport,
),
)
: KeyedSubtree(
key: const ValueKey('now_playing_art'),
child: _Artwork(track: track, client: client, tvSized: tvArt),
),
);
}
Widget _buildSeekBar(MediaItem track) {
return _NowPlayingSeekBar(
trackKey: track.globalKey,
focusNode: _seekFocusNode,
onNavigateUp: PlatformDetector.isTV() ? _overflowFocusNode.requestFocus : null,
onNavigateDown: _focusTransport,
onBack: _pop,
);
}
/// One transport icon with the shared text-fill focus pill.
Widget _transportIcon(
FocusableActionBuildState state, {
required IconData icon,
required VoidCallback onPressed,
bool active = true,
String? tooltip,
double size = 26,
}) {
final tk = tokens(context);
return AnimatedContainer(
duration: state.animationDuration,
decoration: FocusTheme.textFillFocusDecoration(
context,
isFocused: state.showFocus,
borderRadius: MonoTokens.radiusFull,
),
child: IconButton(
icon: AppIcon(icon, fill: 1, size: size, color: active ? tk.text : tk.textMuted),
tooltip: tooltip,
onPressed: onPressed,
),
);
}
Widget _buildTransportRow(MusicPlaybackService service) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Center(
// Scale down instead of overflowing when the hosting column is
// narrower than the row's intrinsic width (e.g. TV layout on a
// narrow display or a small desktop window).
child: FittedBox(
fit: BoxFit.scaleDown,
child: FocusableActionBar(
spacing: 8,
onNavigateUp: _seekFocusNode.requestFocus,
onNavigateDown: () => _utilityBarKey.currentState?.requestFocusOnFirst(),
onBack: _pop,
actions: [
FocusableAction(
debugLabel: 'np_shuffle',
onPressed: service.toggleShuffle,
builder: (context, state) => _transportIcon(
state,
icon: Symbols.shuffle_rounded,
active: service.shuffled,
tooltip: t.common.shuffle,
onPressed: service.toggleShuffle,
size: 22,
),
),
FocusableAction(
debugLabel: 'np_previous',
onPressed: () => unawaited(service.previous()),
builder: (context, state) => _transportIcon(
state,
icon: Symbols.skip_previous_rounded,
tooltip: t.music.previousTrack,
onPressed: () => unawaited(service.previous()),
size: 32,
),
),
FocusableAction(
debugLabel: 'np_play_pause',
focusNode: _playPauseFocusNode,
autofocus: PlatformDetector.isTV(),
onPressed: () => unawaited(service.togglePlayPause()),
builder: (context, state) =>
_PlayPauseButton(state: state, onPressed: () => unawaited(service.togglePlayPause())),
),
FocusableAction(
debugLabel: 'np_next',
onPressed: () => unawaited(service.next()),
builder: (context, state) => _transportIcon(
state,
icon: Symbols.skip_next_rounded,
tooltip: t.music.nextTrack,
onPressed: () => unawaited(service.next()),
size: 32,
),
),
FocusableAction(
debugLabel: 'np_repeat',
onPressed: () => service.setRepeatMode(nextRepeatMode(service.repeatMode)),
builder: (context, state) => _transportIcon(
state,
icon: repeatModeIcon(service.repeatMode),
active: service.repeatMode != MusicRepeatMode.off,
tooltip: repeatModeLabel(service.repeatMode),
onPressed: () => service.setRepeatMode(nextRepeatMode(service.repeatMode)),
size: 22,
),
),
],
),
),
),
);
}
Widget _buildUtilityRow({required bool showQueueButton}) {
return Center(
child: FocusableActionBar(
key: _utilityBarKey,
spacing: 16,
onNavigateUp: _focusTransport,
// Bottom of the chain — trap DOWN so focus can't escape the screen.
// ignore: no-empty-block
onNavigateDown: () {},
onBack: _pop,
actions: [
FocusableAction(
debugLabel: 'np_lyrics',
onPressed: _toggleLyrics,
builder: (context, state) => _transportIcon(
state,
icon: Symbols.lyrics_rounded,
active: _showLyrics,
tooltip: t.music.lyrics,
onPressed: _toggleLyrics,
size: 22,
),
),
if (showQueueButton)
FocusableAction(
debugLabel: 'np_queue',
onPressed: () => unawaited(showQueueSheet(_sheetContext)),
builder: (context, state) => _transportIcon(
state,
icon: Symbols.queue_music_rounded,
active: false,
tooltip: t.music.queue,
onPressed: () => unawaited(showQueueSheet(_sheetContext)),
size: 22,
),
),
],
),
);
}
}
// ---------------------------------------------------------------------
// Artwork
// ---------------------------------------------------------------------
/// Square artwork with the M3E pause shape-morph: large-radius while
/// playing, ~28 when paused.
class _Artwork extends StatelessWidget {
final MediaItem track;
final MediaServerClient? client;
/// TV sizes the art off the screen height instead of hugging width.
final bool tvSized;
const _Artwork({required this.track, required this.client, required this.tvSized});
@override
Widget build(BuildContext context) {
final tk = tokens(context);
final isPlaying = context.select<MusicPlaybackService, bool>((s) => s.isPlaying);
return LayoutBuilder(
builder: (context, constraints) {
final side = tvSized
? math.min(constraints.maxWidth, constraints.maxHeight * 0.62)
: math.min(constraints.maxWidth * 0.85, constraints.maxHeight);
if (side <= 0 || !side.isFinite) return const SizedBox.shrink();
return Center(
child: AnimatedContainer(
duration: MonoMotion.shape(context),
curve: MonoMotion.emphasized,
width: side,
height: side,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(borderRadius: BorderRadius.circular(isPlaying ? tk.radiusLg : 28)),
child: OptimizedMediaImage(
client: client,
imagePath: track.thumbPath,
imageType: ImageType.square,
width: side,
height: side,
fallbackIcon: Symbols.music_note_rounded,
),
),
);
},
);
}
}
// ---------------------------------------------------------------------
// Background
// ---------------------------------------------------------------------
/// Blurred album art at low opacity under a bg scrim. Skipped entirely on
/// the reduced performance tier (plain background).
class _Background extends StatelessWidget {
final MediaItem track;
final MediaServerClient? client;
final bool heavyScrim;
const _Background({required this.track, required this.client, required this.heavyScrim});
@override
Widget build(BuildContext context) {
final tk = tokens(context);
if (DevicePerformance.isReduced || track.thumbPath == null) {
return ColoredBox(color: tk.bg);
}
return Stack(
fit: StackFit.expand,
children: [
ColoredBox(color: tk.bg),
Opacity(
opacity: heavyScrim ? 0.22 : 0.3,
child: ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 60, sigmaY: 60, tileMode: TileMode.mirror),
child: OptimizedMediaImage(
client: client,
imagePath: track.thumbPath,
imageType: ImageType.square,
fit: BoxFit.cover,
),
),
),
],
);
}
}
// ---------------------------------------------------------------------
// Play / pause morph button
// ---------------------------------------------------------------------
/// 72px inverse-surface button: circle while paused, rounded square while
/// playing (M3E shape morph). Focus reads through the action bar's dimming
/// plus a small scale.
class _PlayPauseButton extends StatelessWidget {
final FocusableActionBuildState state;
final VoidCallback onPressed;
const _PlayPauseButton({required this.state, required this.onPressed});
@override
Widget build(BuildContext context) {
final tk = tokens(context);
final colorScheme = Theme.of(context).colorScheme;
final isPlaying = context.select<MusicPlaybackService, bool>((s) => s.isPlaying);
final isLoading = context.select<MusicPlaybackService, bool>((s) => s.status == MusicPlaybackStatus.loading);
return AnimatedScale(
scale: state.showFocus ? 1.08 : 1.0,
duration: state.animationDuration,
child: AnimatedContainer(
duration: MonoMotion.shape(context),
curve: MonoMotion.emphasized,
width: 72,
height: 72,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: colorScheme.inverseSurface,
borderRadius: BorderRadius.circular(isPlaying ? tk.radiusLg + 4 : 36),
),
child: Material(
type: MaterialType.transparency,
child: InkWell(
mouseCursor: SystemMouseCursors.click,
onTap: onPressed,
child: Center(
child: isLoading
? SizedBox(
width: 26,
height: 26,
child: CircularProgressIndicator(strokeWidth: 2.5, color: colorScheme.onInverseSurface),
)
: AppIcon(
isPlaying ? Symbols.pause_rounded : Symbols.play_arrow_rounded,
fill: 1,
size: 36,
color: colorScheme.onInverseSurface,
),
),
),
),
),
);
}
}
// ---------------------------------------------------------------------
// Seek bar
// ---------------------------------------------------------------------
/// Thin mono slider driven by the position stream — an isolated widget so
/// per-second ticks never rebuild the screen. The thumb only appears while
/// dragging. As a d-pad region, LEFT/RIGHT seek ±10s with the video
/// timeline's stepped key-repeat acceleration; focus renders as a
/// text-based background pill behind the bar.
class _NowPlayingSeekBar extends StatefulWidget {
final String trackKey;
final FocusNode focusNode;
final VoidCallback? onNavigateUp;
final VoidCallback? onNavigateDown;
final VoidCallback onBack;
const _NowPlayingSeekBar({
required this.trackKey,
required this.focusNode,
required this.onNavigateUp,
required this.onNavigateDown,
required this.onBack,
});
@override
State<_NowPlayingSeekBar> createState() => _NowPlayingSeekBarState();
}
class _NowPlayingSeekBarState extends State<_NowPlayingSeekBar> {
static const int _baseStepMs = 10000;
double? _dragValueMs;
bool _focused = false;
int _seekRepeatCount = 0;
LogicalKeyboardKey? _seekDirection;
late final DebouncedSeekAccumulator _keySeek;
@override
void initState() {
super.initState();
_keySeek = DebouncedSeekAccumulator(
currentPosition: () => context.read<MusicPlaybackService>().position,
duration: () => context.read<MusicPlaybackService>().duration ?? Duration.zero,
seek: (target) {
final service = context.read<MusicPlaybackService>();
if (service.currentTrack?.globalKey == widget.trackKey) {
unawaited(service.seek(target));
}
},
onChanged: () {
if (mounted) setState(() {});
},
);
}
@override
void didUpdateWidget(covariant _NowPlayingSeekBar oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.trackKey == widget.trackKey) return;
_keySeek.cancel();
_dragValueMs = null;
_resetSeekState();
}
@override
void dispose() {
_keySeek.dispose();
super.dispose();
}
void _resetSeekState() {
_seekRepeatCount = 0;
_seekDirection = null;
}
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
final key = event.logicalKey;
if (event is KeyUpEvent && (key.isLeftKey || key.isRightKey)) {
_keySeek.flush();
_resetSeekState();
return KeyEventResult.handled;
}
final backResult = handleBackKeyAction(event, widget.onBack);
if (backResult != KeyEventResult.ignored) return backResult;
if (!event.isActionable) return KeyEventResult.ignored;
if (key.isUpKey) {
if (widget.onNavigateUp == null) return KeyEventResult.handled; // trap
widget.onNavigateUp!();
return KeyEventResult.handled;
}
if (key.isDownKey) {
widget.onNavigateDown?.call();
return KeyEventResult.handled;
}
if (key.isLeftKey || key.isRightKey) {
final service = context.read<MusicPlaybackService>();
final duration = service.duration;
if (duration == null || duration.inMilliseconds <= 0) return KeyEventResult.handled;
if (_seekDirection != key) {
_seekDirection = key;
_seekRepeatCount = 0;
}
if (event is KeyRepeatEvent) _seekRepeatCount++;
final multiplier = event is KeyRepeatEvent ? steppedSeekMultiplier(_seekRepeatCount) : 1.0;
final stepMs = (_baseStepMs * multiplier).round();
final step = Duration(milliseconds: stepMs);
_keySeek.seekBy(key.isRightKey ? step : -step);
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
@override
Widget build(BuildContext context) {
final tk = tokens(context);
final service = context.read<MusicPlaybackService>();
final showFocus = _focused && InputModeTracker.isKeyboardMode(context);
final bar = StreamBuilder<Duration>(
key: ValueKey(widget.trackKey),
stream: service.positionStream,
builder: (context, snapshot) {
final duration = service.duration ?? Duration.zero;
final durationMs = duration.inMilliseconds.toDouble();
final hasDuration = durationMs > 0;
final rawPositionMs =
_dragValueMs ??
_keySeek.pendingPosition?.inMilliseconds.toDouble() ??
(snapshot.data ?? service.position).inMilliseconds.toDouble();
final positionMs = hasDuration ? rawPositionMs.clamp(0.0, durationMs) : 0.0;
final dragging = _dragValueMs != null;
return Column(
mainAxisSize: .min,
children: [
SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 4,
activeTrackColor: tk.text,
inactiveTrackColor: tk.outline,
thumbColor: tk.text,
trackGap: dragging ? 4 : 0,
thumbSize: const WidgetStatePropertyAll(Size(4, 18)),
thumbShape: dragging ? null : SliderComponentShape.noThumb,
overlayShape: SliderComponentShape.noOverlay,
),
child: Slider(
max: hasDuration ? durationMs : 1,
value: positionMs,
onChangeStart: hasDuration
? (value) {
_keySeek.cancel();
setState(() => _dragValueMs = value);
}
: null,
onChanged: hasDuration ? (value) => setState(() => _dragValueMs = value) : null,
onChangeEnd: hasDuration
? (value) {
if (service.currentTrack?.globalKey == widget.trackKey) {
unawaited(service.seek(Duration(milliseconds: value.round())));
}
setState(() => _dragValueMs = null);
}
: null,
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 2, 24, 0),
child: Row(
children: [
Text(
formatDurationTimestamp(Duration(milliseconds: positionMs.round())),
style: TextStyle(fontSize: 12, color: tk.textMuted),
),
const Spacer(),
Text(formatDurationTimestamp(duration), style: TextStyle(fontSize: 12, color: tk.textMuted)),
],
),
),
],
);
},
);
return Focus(
focusNode: widget.focusNode,
descendantsAreFocusable: false,
onKeyEvent: _handleKeyEvent,
onFocusChange: (hasFocus) {
if (!hasFocus) {
_keySeek.flush();
_resetSeekState();
}
setState(() => _focused = hasFocus);
},
child: AnimatedContainer(
duration: FocusTheme.getAnimationDuration(context),
padding: const EdgeInsets.symmetric(vertical: 4),
decoration: FocusTheme.textFillFocusDecoration(context, isFocused: showFocus, borderRadius: tk.radiusLg),
child: bar,
),
);
}
}