From d4bed9c64315a531c5f7344f7208eb1b919f8f6a Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 29 Jul 2026 19:29:59 +0700 Subject: [PATCH] feat(player): polish actions and synced lyrics --- lib/screens/now_playing_screen.dart | 260 ++++++++++++++++++---------- lib/utils/synced_lyrics_scroll.dart | 23 +++ test/synced_lyrics_scroll_test.dart | 30 ++++ 3 files changed, 220 insertions(+), 93 deletions(-) create mode 100644 lib/utils/synced_lyrics_scroll.dart create mode 100644 test/synced_lyrics_scroll_test.dart diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index ce3f09af..7fcf7b6c 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -14,6 +14,8 @@ import 'package:spotiflac_android/utils/int_utils.dart'; import 'package:spotiflac_android/utils/lyrics_parser.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; +import 'package:spotiflac_android/utils/synced_lyrics_scroll.dart'; +import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; import 'package:spotiflac_android/widgets/player_artwork.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; @@ -352,38 +354,14 @@ class _NowPlayingScreenState extends ConsumerState { icon: const Icon(Icons.queue_music), onPressed: () => _showQueueSheet(colorScheme), ), - PopupMenuButton( + IconButton( + tooltip: MaterialLocalizations.of(context).moreButtonTooltip, icon: const Icon(Icons.more_vert), - onSelected: (value) { - switch (value) { - case 'details': - _showDetailsSheet(colorScheme); - break; - case 'external': - _openExternally(source); - break; - } - }, - itemBuilder: (menuContext) => [ - PopupMenuItem( - value: 'details', - child: ListTile( - leading: const Icon(Icons.info_outline), - title: Text(menuContext.l10n.nowPlayingDetails), - contentPadding: EdgeInsets.zero, - ), - ), - PopupMenuItem( - value: 'external', - child: ListTile( - leading: const Icon(Icons.open_in_new), - title: Text( - menuContext.l10n.nowPlayingOpenInExternalPlayer, - ), - contentPadding: EdgeInsets.zero, - ), - ), - ], + onPressed: () => _showMoreActions( + mediaItem: mediaItem, + source: source, + colorScheme: colorScheme, + ), ), ], ), @@ -710,6 +688,52 @@ class _NowPlayingScreenState extends ConsumerState { } } + Future _showMoreActions({ + required MediaItem mediaItem, + required String source, + required ColorScheme colorScheme, + }) async { + final action = await showAppBottomSheet( + context: context, + useRootNavigator: true, + backgroundColor: colorScheme.surfaceContainerHigh, + title: mediaItem.title, + subtitle: mediaItem.artist, + builder: (sheetContext) => Padding( + padding: const EdgeInsets.only(bottom: 16), + child: SettingsGroup( + children: [ + SettingsItem( + icon: Icons.info_outline, + title: sheetContext.l10n.nowPlayingDetails, + onTap: () => Navigator.of(sheetContext).pop('details'), + ), + SettingsItem( + icon: Icons.open_in_new, + title: sheetContext.l10n.nowPlayingOpenInExternalPlayer, + trailing: Icon( + Icons.open_in_new, + size: 18, + color: Theme.of(sheetContext).colorScheme.onSurfaceVariant, + ), + showDivider: false, + onTap: () => Navigator.of(sheetContext).pop('external'), + ), + ], + ), + ), + ); + if (!mounted) return; + switch (action) { + case 'details': + _showDetailsSheet(colorScheme); + break; + case 'external': + await _openExternally(source); + break; + } + } + Future _shuffleLibrary(MusicPlayerController controller) async { try { final rows = await LibraryDatabase.instance.getAll(); @@ -1149,6 +1173,7 @@ class _SyncedLyricsView extends ConsumerStatefulWidget { class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { final ScrollController _scroll = ScrollController(); ProviderSubscription? _positionSubscription; + late List _lineKeys; int _active = -1; bool _userScrolling = false; static const double _estimatedLyricExtent = 64; @@ -1156,18 +1181,30 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { @override void initState() { super.initState(); + _resetLineKeys(); _syncPositionSubscription(); } @override void didUpdateWidget(covariant _SyncedLyricsView oldWidget) { super.didUpdateWidget(oldWidget); + if (oldWidget.lyrics != widget.lyrics) { + _resetLineKeys(); + } if (oldWidget.isActive != widget.isActive || oldWidget.lyrics != widget.lyrics) { _syncPositionSubscription(); } } + void _resetLineKeys() { + _lineKeys = List.generate( + widget.lyrics.lines.length, + (index) => GlobalKey(debugLabel: 'lyric-line-$index'), + growable: false, + ); + } + void _syncPositionSubscription() { _positionSubscription?.close(); _positionSubscription = null; @@ -1177,6 +1214,9 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { widget.lyrics.lines, ref.read(playbackPositionProvider), ); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) unawaited(_maybeAutoScroll(_active)); + }); _positionSubscription = ref.listenManual( playbackPositionProvider, (previous, next) { @@ -1184,7 +1224,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { if (active == _active || !mounted) return; setState(() => _active = active); WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _maybeAutoScroll(active); + if (mounted) unawaited(_maybeAutoScroll(active)); }); }, ); @@ -1197,22 +1237,47 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { super.dispose(); } - void _maybeAutoScroll(int index) { + Future _maybeAutoScroll(int index) async { if (_userScrolling || index < 0 || !_scroll.hasClients) return; + if (index < _lineKeys.length) { + final lineContext = _lineKeys[index].currentContext; + if (lineContext != null) { + await Scrollable.ensureVisible( + lineContext, + alignment: 0.5, + alignmentPolicy: ScrollPositionAlignmentPolicy.explicit, + duration: const Duration(milliseconds: 380), + curve: Curves.easeOutCubic, + ); + return; + } + } + final position = _scroll.position; - final target = - (index * _estimatedLyricExtent) - - (position.viewportDimension * 0.35) + - 24; + final target = syncedLyricsEstimatedOffset( + index: index, + estimatedLineExtent: _estimatedLyricExtent, + ); final clamped = target.clamp( position.minScrollExtent, position.maxScrollExtent, ); - _scroll.animateTo( + await _scroll.animateTo( clamped.toDouble(), duration: const Duration(milliseconds: 380), curve: Curves.easeOutCubic, ); + if (!mounted || _userScrolling || index >= _lineKeys.length) return; + final lineContext = _lineKeys[index].currentContext; + if (lineContext != null && lineContext.mounted) { + await Scrollable.ensureVisible( + lineContext, + alignment: 0.5, + alignmentPolicy: ScrollPositionAlignmentPolicy.explicit, + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + ); + } } @override @@ -1230,66 +1295,75 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { } return false; }, - child: ListView.builder( - controller: _scroll, - padding: const EdgeInsets.fromLTRB(24, 24, 24, 80), - itemCount: lines.length, - itemBuilder: (context, index) { - final line = lines[index]; - final isActive = index == active; - final isPast = index < active; + child: LayoutBuilder( + builder: (context, constraints) { + final centerPadding = syncedLyricsCenterPadding( + viewportDimension: constraints.maxHeight, + estimatedLineExtent: _estimatedLyricExtent, + ); + return ListView.builder( + controller: _scroll, + padding: EdgeInsets.fromLTRB(24, centerPadding, 24, centerPadding), + itemCount: lines.length, + itemBuilder: (context, index) { + final line = lines[index]; + final isActive = index == active; + final isPast = index < active; - final color = isActive - ? widget.colorScheme.onSurface - : isPast - ? widget.colorScheme.onSurfaceVariant.withValues(alpha: 0.5) - : widget.colorScheme.onSurfaceVariant.withValues(alpha: 0.8); + final color = isActive + ? widget.colorScheme.onSurface + : isPast + ? widget.colorScheme.onSurfaceVariant.withValues(alpha: 0.5) + : widget.colorScheme.onSurfaceVariant.withValues(alpha: 0.8); - final text = line.text.trim().isEmpty - ? '\u00b7\u00b7\u00b7' - : line.text; + final text = line.text.trim().isEmpty + ? '\u00b7\u00b7\u00b7' + : line.text; - Widget content; - if (isActive && line.hasWordTiming) { - content = _WordHighlightedLyricLine( - line: line, - colorScheme: widget.colorScheme, - ); - } else { - content = Text( - text, - textAlign: TextAlign.center, - style: - (isActive - ? Theme.of(context).textTheme.headlineSmall - : Theme.of(context).textTheme.titleLarge) - ?.copyWith( - height: 1.4, - fontWeight: isActive - ? FontWeight.bold - : FontWeight.w500, - color: color, - ), - ); - } + Widget content; + if (isActive && line.hasWordTiming) { + content = _WordHighlightedLyricLine( + line: line, + colorScheme: widget.colorScheme, + ); + } else { + content = Text( + text, + textAlign: TextAlign.center, + style: + (isActive + ? Theme.of(context).textTheme.headlineSmall + : Theme.of(context).textTheme.titleLarge) + ?.copyWith( + height: 1.4, + fontWeight: isActive + ? FontWeight.bold + : FontWeight.w500, + color: color, + ), + ); + } - return Padding( - padding: const EdgeInsets.symmetric(vertical: 10), - child: GestureDetector( - onTap: () => - ref.read(musicPlayerControllerProvider).seek(line.time), - child: AnimatedScale( - scale: isActive ? 1.0 : 0.96, - alignment: Alignment.center, - duration: const Duration(milliseconds: 280), - curve: Curves.easeOutCubic, - child: AnimatedOpacity( - opacity: isActive ? 1.0 : (isPast ? 0.55 : 0.85), - duration: const Duration(milliseconds: 280), - child: content, + return Padding( + key: _lineKeys[index], + padding: const EdgeInsets.symmetric(vertical: 10), + child: GestureDetector( + onTap: () => + ref.read(musicPlayerControllerProvider).seek(line.time), + child: AnimatedScale( + scale: isActive ? 1.0 : 0.96, + alignment: Alignment.center, + duration: const Duration(milliseconds: 280), + curve: Curves.easeOutCubic, + child: AnimatedOpacity( + opacity: isActive ? 1.0 : (isPast ? 0.55 : 0.85), + duration: const Duration(milliseconds: 280), + child: content, + ), + ), ), - ), - ), + ); + }, ); }, ), diff --git a/lib/utils/synced_lyrics_scroll.dart b/lib/utils/synced_lyrics_scroll.dart new file mode 100644 index 00000000..95cf323c --- /dev/null +++ b/lib/utils/synced_lyrics_scroll.dart @@ -0,0 +1,23 @@ +import 'dart:math' as math; + +/// Symmetric list padding that lets the first and last lyric lines reach the +/// vertical center instead of getting pinned to a viewport edge. +double syncedLyricsCenterPadding({ + required double viewportDimension, + required double estimatedLineExtent, + double minimumPadding = 24, +}) { + return math.max( + minimumPadding, + (viewportDimension - estimatedLineExtent) / 2, + ); +} + +/// Estimated scroll offset when symmetric center padding is applied. +double syncedLyricsEstimatedOffset({ + required int index, + required double estimatedLineExtent, +}) { + if (index <= 0) return 0; + return index * estimatedLineExtent; +} diff --git a/test/synced_lyrics_scroll_test.dart b/test/synced_lyrics_scroll_test.dart new file mode 100644 index 00000000..939aa797 --- /dev/null +++ b/test/synced_lyrics_scroll_test.dart @@ -0,0 +1,30 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/utils/synced_lyrics_scroll.dart'; + +void main() { + test('symmetric padding lets the final lyric line reach center', () { + const viewport = 500.0; + const lineExtent = 64.0; + const lineCount = 20; + final padding = syncedLyricsCenterPadding( + viewportDimension: viewport, + estimatedLineExtent: lineExtent, + ); + final contentExtent = (padding * 2) + (lineCount * lineExtent); + final maxScrollExtent = contentExtent - viewport; + final lastLineOffset = syncedLyricsEstimatedOffset( + index: lineCount - 1, + estimatedLineExtent: lineExtent, + ); + + expect(padding, 218); + expect(lastLineOffset, maxScrollExtent); + }); + + test('small viewports retain minimum breathing room', () { + expect( + syncedLyricsCenterPadding(viewportDimension: 80, estimatedLineExtent: 64), + 24, + ); + }); +}