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.
984 lines
35 KiB
Dart
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,
|
|
),
|
|
);
|
|
}
|
|
}
|