fix(lyrics): keep the active line anchored during language transitions

This commit is contained in:
zarzet
2026-09-27 00:20:02 +07:00
parent 3f111b5ea6
commit 3e6bb3bba4
4 changed files with 178 additions and 73 deletions
+87 -54
View File
@@ -34,6 +34,7 @@ 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/aligned_lyric_pronunciation.dart';
import 'package:spotiflac_android/widgets/lyric_supplement_transition.dart';
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
import 'package:spotiflac_android/widgets/audio_output_button.dart';
import 'package:spotiflac_android/widgets/lyric_gap_indicator.dart';
@@ -2850,6 +2851,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
static const double _estimatedLyricExtent = 64;
List<double>? _lineExtents;
List<(double, double, double)> _lineMeasurements = [];
List<LyricPronunciationLayout?> _pronunciationLayouts = [];
Object? _lineLayoutKey;
double? _viewportHeight;
Offset? _layoutVisibility;
@@ -2882,6 +2884,16 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
if (oldWidget.lyrics != widget.lyrics) {
_resetLineKeys();
}
if (oldWidget.showPronunciation != widget.showPronunciation ||
oldWidget.showTranslation != widget.showTranslation) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && !_userScrolling && _scroll.hasClients) {
// Cancel a pending line-scroll once, then let layout preserve the
// current anchor throughout the supplement animation.
_scroll.jumpTo(_scroll.offset);
}
});
}
if (oldWidget.isActive != widget.isActive ||
oldWidget.lyrics != widget.lyrics) {
_syncPositionSubscription();
@@ -3078,9 +3090,11 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
locale: locale,
);
final measurements = <(double, double, double)>[];
final layouts = <LyricPronunciationLayout?>[];
for (final line in _lines) {
if (line.text.isEmpty) {
measurements.add((56, 0, 0));
layouts.add(null);
continue;
}
painter.text = TextSpan(text: line.text, style: style);
@@ -3088,6 +3102,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
var height = painter.height + 32;
var pronunciationHeight = 0.0;
var translationHeight = 0.0;
LyricPronunciationLayout? pronunciationLayout;
for (final (text, style, _, translation) in _lyricSupplements(
context,
line,
@@ -3108,14 +3123,17 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
locale: locale,
);
if (aligned != null) {
pronunciationLayout = aligned;
height = aligned.primaryHeight + 32;
pronunciationHeight = aligned.pronunciationHeight;
}
}
}
measurements.add((height, pronunciationHeight, translationHeight));
layouts.add(pronunciationLayout);
}
_lineMeasurements = measurements;
_pronunciationLayouts = layouts;
painter.dispose();
}
@@ -3210,7 +3228,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
),
duration: MediaQuery.disableAnimationsOf(context)
? Duration.zero
: const Duration(milliseconds: 320),
: const Duration(milliseconds: 420),
curve: Curves.easeInOutCubic,
builder: (context, visibility, _) => _buildLyrics(context, visibility),
);
@@ -3246,6 +3264,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
child: LayoutBuilder(
builder: (context, constraints) {
if (mornye) {
final previousExtents = _lineExtents;
_measureMornyeLines(
(constraints.maxWidth - 48).clamp(0, double.infinity),
);
@@ -3258,11 +3277,32 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
pronunciation * visibility.dx +
translation * visibility.dy,
];
if (previousExtents != null &&
_layoutVisibility != null &&
_layoutVisibility != visibility &&
previousExtents.length == _lineExtents!.length &&
_scroll.hasClients) {
var anchor = _active.clamp(0, previousExtents.length);
if (_userScrolling) {
anchor = 0;
var extent = 0.0;
while (anchor < previousExtents.length &&
extent + previousExtents[anchor] <= _scroll.offset) {
extent += previousExtents[anchor++];
}
}
var correction = 0.0;
for (var i = 0; i < anchor; i++) {
correction += _lineExtents![i] - previousExtents[i];
}
// Correct before the ListView lays out/paints. A post-frame
// jump leaves every painted frame one animation step behind.
if (correction != 0) _scroll.position.correctBy(correction);
}
}
if (_viewportHeight != constraints.maxHeight ||
_layoutVisibility != visibility) {
_layoutVisibility = visibility;
if (_viewportHeight != constraints.maxHeight) {
_viewportHeight = constraints.maxHeight;
_layoutVisibility = visibility;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
unawaited(_maybeAutoScroll(_active, immediate: true));
@@ -3355,6 +3395,9 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
initialPosition: _activeTransitionPosition,
seekPreview: widget.seekPreview,
supplementVisibility: visibility,
pronunciationLayout: mornye
? _pronunciationLayouts[index]
: null,
textAlign: textAlign,
);
} else {
@@ -3380,6 +3423,9 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
content,
color,
visibility: visibility,
pronunciationLayout: mornye
? _pronunciationLayouts[index]
: null,
textAlign: textAlign,
);
}
@@ -3535,6 +3581,7 @@ Widget _withLyricSupplements(
Widget primary,
Color color, {
required Offset visibility,
required LyricPronunciationLayout? pronunciationLayout,
required TextAlign textAlign,
Widget Function(String, List<LyricWord>, TextStyle)? timedText,
Widget Function(String, List<LyricWord>, TextStyle)? timedSupplementText,
@@ -3553,25 +3600,20 @@ Widget _withLyricSupplements(
for (final (text, style, words, translation) in supplements)
if ((translation || !aligned) &&
(translation ? visibility.dy : visibility.dx) > 0)
ClipRect(
child: Align(
alignment: alignment,
heightFactor: translation ? visibility.dy : visibility.dx,
child: Opacity(
opacity: translation ? visibility.dy : visibility.dx,
child: Padding(
padding: const EdgeInsets.only(top: 6),
child: words.isNotEmpty && timedSupplementText != null
? timedSupplementText(text, words, style)
: Text(
text,
textAlign: textAlign,
style: style.copyWith(
color: color.withValues(alpha: color.a * 0.8),
),
),
),
),
LyricSupplementTransition(
alignment: alignment,
visibility: translation ? visibility.dy : visibility.dx,
child: Padding(
padding: const EdgeInsets.only(top: 6),
child: words.isNotEmpty && timedSupplementText != null
? timedSupplementText(text, words, style)
: Text(
text,
textAlign: textAlign,
style: style.copyWith(
color: color.withValues(alpha: color.a * 0.8),
),
),
),
),
],
@@ -3585,37 +3627,25 @@ Widget _withLyricSupplements(
final pronunciationStyle = supplements
.firstWhere((supplement) => !supplement.$4)
.$2;
return LayoutBuilder(
builder: (context, constraints) {
final layout = LyricPronunciationLayout.measure(
line: line,
primaryStyle: primaryStyle,
pronunciationStyle: pronunciationStyle,
maxWidth: constraints.maxWidth,
textScaler: MediaQuery.textScalerOf(context),
textDirection: Directionality.of(context),
locale: Localizations.maybeLocaleOf(context),
);
if (layout == null) return withSupplements(primary);
return withSupplements(
AlignedLyricPronunciation(
layout: layout,
visibility: visibility.dx,
primaryStyle: primaryStyle,
pronunciationStyle: pronunciationStyle,
textAlign: textAlign,
pronunciationBuilder: timedSupplementText,
textBuilder:
timedText ??
(text, words, style) => Text(
text,
textAlign: textAlign,
style: style.copyWith(color: color),
),
),
aligned: true,
);
},
final layout = pronunciationLayout;
if (layout == null) return withSupplements(primary);
return withSupplements(
AlignedLyricPronunciation(
layout: layout,
visibility: visibility.dx,
primaryStyle: primaryStyle,
pronunciationStyle: pronunciationStyle,
textAlign: textAlign,
pronunciationBuilder: timedSupplementText,
textBuilder:
timedText ??
(text, words, style) => Text(
text,
textAlign: textAlign,
style: style.copyWith(color: color),
),
),
aligned: true,
);
}
@@ -3626,6 +3656,7 @@ class _WordHighlightedLyricLine extends ConsumerStatefulWidget {
final Duration initialPosition;
final ValueListenable<Duration?> seekPreview;
final Offset supplementVisibility;
final LyricPronunciationLayout? pronunciationLayout;
final TextAlign textAlign;
const _WordHighlightedLyricLine({
@@ -3635,6 +3666,7 @@ class _WordHighlightedLyricLine extends ConsumerStatefulWidget {
required this.initialPosition,
required this.seekPreview,
required this.supplementVisibility,
required this.pronunciationLayout,
required this.textAlign,
});
@@ -3827,6 +3859,7 @@ class _WordHighlightedLyricLineState
timedSupplementText: (text, words, style) =>
_buildTimedText(text, words, style, lift: false),
visibility: widget.supplementVisibility,
pronunciationLayout: widget.pronunciationLayout,
textAlign: widget.textAlign,
);
}
+14 -19
View File
@@ -1,6 +1,7 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:spotiflac_android/widgets/lyric_supplement_transition.dart';
import 'package:spotiflac_android/utils/lyrics_parser.dart';
/// Keeps each pronunciation phrase under the lyric with the same start time.
@@ -234,25 +235,19 @@ class AlignedLyricPronunciation extends StatelessWidget {
),
),
if (visibility > 0)
ClipRect(
child: Align(
alignment: Alignment.topLeft,
heightFactor: visibility,
child: Opacity(
opacity: visibility,
child: Padding(
padding: const EdgeInsets.only(
top: LyricPronunciationLayout._pronunciationGap,
),
child: SizedBox(
width: double.infinity,
height: row.pronunciationHeight,
child: (pronunciationBuilder ?? textBuilder)(
group.pronunciation.map((word) => word.text).join(),
group.pronunciation,
pronunciationStyle,
),
),
LyricSupplementTransition(
visibility: visibility,
child: Padding(
padding: const EdgeInsets.only(
top: LyricPronunciationLayout._pronunciationGap,
),
child: SizedBox(
width: double.infinity,
height: row.pronunciationHeight,
child: (pronunciationBuilder ?? textBuilder)(
group.pronunciation.map((word) => word.text).join(),
group.pronunciation,
pronunciationStyle,
),
),
),
@@ -0,0 +1,30 @@
import 'package:flutter/widgets.dart';
/// The painted text and its reserved height follow the same progress. Cropping
/// full-size text while only shrinking its container makes toggles look jerky.
class LyricSupplementTransition extends StatelessWidget {
const LyricSupplementTransition({
super.key,
required this.visibility,
required this.child,
this.alignment = Alignment.topLeft,
});
final double visibility;
final Widget child;
final Alignment alignment;
@override
Widget build(BuildContext context) => Align(
alignment: alignment,
heightFactor: visibility,
child: Opacity(
opacity: visibility,
child: Transform.scale(
scale: visibility,
alignment: alignment,
child: child,
),
),
);
}
@@ -2392,6 +2392,53 @@ void main() {
},
);
testWidgets('language toggles keep a later active lyric anchored in every frame', (
tester,
) async {
metadataOverrides['lyrics'] = [
for (var i = 0; i < 12; i++) ...[
'[x-romaji:${(i + 1) * 10000}:${base64.encode(utf8.encode('Pronunciation $i'))}]',
'[x-translation:${(i + 1) * 10000}:${base64.encode(utf8.encode('Translation $i'))}]',
'[${((i + 1) * 10 ~/ 60).toString().padLeft(2, '0')}:${((i + 1) * 10 % 60).toString().padLeft(2, '0')}.000]Original $i',
],
].join('\n');
await pumpNowPlaying(
tester,
theme: MornyeTheme.build(Brightness.dark),
size: const Size(390, 844),
playback: PlaybackState(
processingState: AudioProcessingState.ready,
updatePosition: const Duration(seconds: 45),
),
);
mediaItems.add(item('anchored-supplements'));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(CupertinoIcons.quote_bubble));
await tester.pumpAndSettle();
final original = find.text('Original 3');
final anchor = tester.getTopLeft(original).dy;
for (final action in [
'Hide Pronunciation',
'Show Pronunciation',
'Hide Translation',
'Show Translation',
]) {
await tester.tap(find.byKey(const ValueKey('lyrics-language-options')));
await tester.pumpAndSettle();
await tester.tap(find.text(action));
await tester.pump();
for (var frame = 0; frame < 32; frame++) {
await tester.pump(const Duration(milliseconds: 16));
expect(
tester.getTopLeft(original).dy,
closeTo(anchor, 1),
reason: '$action frame $frame',
);
}
}
expect(tester.takeException(), isNull);
});
for (final supplement in ['none', 'pronunciation', 'translation']) {
testWidgets('language menu only offers available $supplement', (
tester,