feat(player): polish actions and synced lyrics

This commit is contained in:
zarzet
2026-07-29 19:29:59 +07:00
parent 24b9d7ba80
commit d4bed9c643
3 changed files with 220 additions and 93 deletions
+167 -93
View File
@@ -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<NowPlayingScreen> {
icon: const Icon(Icons.queue_music),
onPressed: () => _showQueueSheet(colorScheme),
),
PopupMenuButton<String>(
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<NowPlayingScreen> {
}
}
Future<void> _showMoreActions({
required MediaItem mediaItem,
required String source,
required ColorScheme colorScheme,
}) async {
final action = await showAppBottomSheet<String>(
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<void> _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<Duration>? _positionSubscription;
late List<GlobalKey> _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<GlobalKey>.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<Duration>(
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<void> _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,
),
),
),
),
),
);
},
);
},
),
+23
View File
@@ -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;
}
+30
View File
@@ -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,
);
});
}