Files
plezy/lib/screens/music/now_playing_screen.dart
T
edde746 7d6a747aa7 feat(music): now-playing, mini-player, queue and lyrics UI
Persistent mini-player over the profile navigator (route-aware
suppression, bottom-bar inset), slide-up now-playing screen with
mobile/desktop/TV layouts (blurred-art background, pause shape morphs,
dpad seek chain), queue sheet with reorder/remove/jump, synced lyrics
view, TV rail now-playing item, sleep timer, and audio playlist
playback. Adds download affordances on music surfaces.
2026-07-05 21:50:11 +02:00

984 lines
35 KiB
Dart

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/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/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/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> {
MusicPlaybackService? _service;
StreamSubscription<Object>? _errorsSub;
bool _showLyrics = false;
final Map<String, Future<Lyrics?>> _lyricsCache = {};
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 _overflowFocused = false;
bool _poppedForIdle = false;
@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();
_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);
/// 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>(
context,
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,
),
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(backgroundColor: tk.bg, body: content),
);
}
// -------------------------------------------------------------------
// Layouts
// -------------------------------------------------------------------
Widget _buildPortraitLayout(MusicPlaybackService service, MediaItem track, MediaServerClient? client) {
return Column(
children: [
_buildTopBar(track, service.playContext?.title),
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)),
Padding(padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), child: _buildSeekBar()),
_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?.title),
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(),
_buildTransportRow(service),
_buildUtilityRow(showQueueButton: false),
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 playContextTitle = service.playContext?.title;
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: playContextTitle == null || playContextTitle.isEmpty
? const SizedBox.shrink()
: Text(
t.music.playingFrom(title: playContextTitle),
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(),
const SizedBox(height: 8),
_buildTransportRow(service),
_buildUtilityRow(showQueueButton: true),
],
),
),
],
),
);
}
// -------------------------------------------------------------------
// Pieces
// -------------------------------------------------------------------
/// [playContextTitle] 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, String? playContextTitle) {
final tk = tokens(context);
return Padding(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 0),
child: Row(
children: [
IconButton(
icon: AppIcon(Symbols.keyboard_arrow_down_rounded, fill: 1, color: tk.text),
tooltip: t.common.close,
onPressed: _pop,
),
Expanded(
child: playContextTitle == null || playContextTitle.isEmpty
? const SizedBox.shrink()
: Text(
t.music.playingFrom(title: playContextTitle),
textAlign: TextAlign.center,
maxLines: 1,
overflow: .ellipsis,
style: TextStyle(fontSize: 13, color: tk.textMuted),
),
),
_buildOverflowButton(track),
],
),
);
}
/// ⋮ — 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);
final button = IconButton(
icon: AppIcon(Symbols.more_vert_rounded, fill: 1, color: tk.text),
onPressed: () => contextMenuKey.currentState?.showContextMenu(context),
);
Widget child = button;
if (focusable) {
final showFocus = _overflowFocused && InputModeTracker.isKeyboardMode(context);
child = Focus(
focusNode: _overflowFocusNode,
descendantsAreFocusable: false,
onFocusChange: (hasFocus) => setState(() => _overflowFocused = hasFocus),
onKeyEvent: (node, event) {
final backResult = handleBackKeyAction(event, _pop);
if (backResult != KeyEventResult.ignored) return backResult;
return dpadKeyHandler(
onSelect: () => contextMenuKey.currentState?.showContextMenu(context),
onDown: _seekFocusNode.requestFocus,
onUp: () {}, // top of the chain — trap
trapHorizontalEdges: true,
)(node, event);
},
child: AnimatedContainer(
duration: FocusTheme.getAnimationDuration(context),
decoration: FocusTheme.textFillFocusDecoration(context, isFocused: showFocus, borderRadius: 20),
child: button,
),
);
}
return MediaContextMenu(
key: contextMenuKey,
item: track,
extraEntries: [
MediaMenuExtraEntry(icon: Symbols.bedtime_rounded, label: t.music.sleepTimer, onSelected: _showSleepTimerSheet),
],
child: child,
);
}
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() {
return _NowPlayingSeekBar(
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(context)),
builder: (context, state) => _transportIcon(
state,
icon: Symbols.queue_music_rounded,
active: false,
tooltip: t.music.queue,
onPressed: () => unawaited(showQueueSheet(context)),
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 FocusNode focusNode;
final VoidCallback? onNavigateUp;
final VoidCallback? onNavigateDown;
final VoidCallback onBack;
const _NowPlayingSeekBar({
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;
Duration? _keySeekTarget;
/// Stepped acceleration tiers, mirroring the video timeline's key-repeat
/// scrubbing.
double _seekMultiplier() {
if (_seekRepeatCount <= 5) return 1.5;
if (_seekRepeatCount <= 15) return 3.0;
if (_seekRepeatCount <= 30) return 6.0;
return 10.0;
}
void _resetSeekState() {
_seekRepeatCount = 0;
_seekDirection = null;
_keySeekTarget = null;
}
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
final key = event.logicalKey;
if (event is KeyUpEvent && (key.isLeftKey || key.isRightKey)) {
_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 ? _seekMultiplier() : 1.0;
final stepMs = (_baseStepMs * multiplier).round();
// Step from the in-flight target during a held burst — the position
// stream lags behind the seeks.
final base = _keySeekTarget ?? service.position;
final targetMs = (base.inMilliseconds + (key.isRightKey ? stepMs : -stepMs)).clamp(0, duration.inMilliseconds);
final target = Duration(milliseconds: targetMs);
_keySeekTarget = target;
unawaited(service.seek(target));
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>(
stream: service.positionStream,
builder: (context, snapshot) {
final duration = service.duration ?? Duration.zero;
final durationMs = duration.inMilliseconds.toDouble();
final hasDuration = durationMs > 0;
final rawPositionMs = _dragValueMs ?? (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) => setState(() => _dragValueMs = value) : null,
onChanged: hasDuration ? (value) => setState(() => _dragValueMs = value) : null,
onChangeEnd: hasDuration
? (value) {
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) => setState(() {
_focused = hasFocus;
if (!hasFocus) _resetSeekState();
}),
child: AnimatedContainer(
duration: FocusTheme.getAnimationDuration(context),
padding: const EdgeInsets.symmetric(vertical: 4),
decoration: FocusTheme.textFillFocusDecoration(context, isFocused: showFocus, borderRadius: tk.radiusLg),
child: bar,
),
);
}
}