fix(player): smooth timed lyrics highlighting

This commit is contained in:
zarzet
2026-08-23 15:06:46 +07:00
parent c6437d8428
commit 73051a5feb
4 changed files with 692 additions and 36 deletions
+492 -33
View File
@@ -1310,8 +1310,14 @@ class _SyncedLyricsView extends ConsumerStatefulWidget {
class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
final ScrollController _scroll = ScrollController();
ProviderSubscription<Duration>? _positionSubscription;
ProviderSubscription<bool>? _playingSubscription;
ProviderSubscription<bool>? _loadingSubscription;
Timer? _lineBoundaryTimer;
late List<GlobalKey> _lineKeys;
int _active = -1;
Duration _activeTransitionPosition = Duration.zero;
bool _playing = false;
bool _loading = false;
bool _userScrolling = false;
static const double _estimatedLyricExtent = 64;
@@ -1344,32 +1350,87 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
void _syncPositionSubscription() {
_positionSubscription?.close();
_playingSubscription?.close();
_loadingSubscription?.close();
_lineBoundaryTimer?.cancel();
_positionSubscription = null;
_playingSubscription = null;
_loadingSubscription = null;
if (!widget.isActive) return;
_active = LyricsParser.activeIndex(
widget.lyrics.lines,
ref.read(playbackPositionProvider),
);
final position = ref.read(playbackPositionProvider);
_playing = ref.read(playbackPlayingProvider);
_loading = ref.read(playbackLoadingProvider);
_active = LyricsParser.activeIndex(widget.lyrics.lines, position);
_activeTransitionPosition = position;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) unawaited(_maybeAutoScroll(_active));
});
_scheduleNextLine(position);
_positionSubscription = ref.listenManual<Duration>(
playbackPositionProvider,
(previous, next) {
final active = LyricsParser.activeIndex(widget.lyrics.lines, next);
if (active == _active || !mounted) return;
setState(() => _active = active);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) unawaited(_maybeAutoScroll(active));
});
if (active != _active) _setActiveLine(active, position: next);
_scheduleNextLine(next);
},
);
_playingSubscription = ref.listenManual<bool>(playbackPlayingProvider, (
previous,
next,
) {
_playing = next;
_scheduleNextLine(ref.read(playbackPositionProvider));
});
_loadingSubscription = ref.listenManual<bool>(playbackLoadingProvider, (
previous,
next,
) {
_loading = next;
_scheduleNextLine(ref.read(playbackPositionProvider));
});
}
void _setActiveLine(int active, {required Duration position}) {
if (!mounted || active == _active) return;
setState(() {
_active = active;
_activeTransitionPosition = position;
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) unawaited(_maybeAutoScroll(active));
});
}
void _scheduleNextLine(Duration position) {
_lineBoundaryTimer?.cancel();
if (!widget.isActive || !_playing || _loading) return;
final lines = widget.lyrics.lines;
final dueIndex = syncedLyricsDueLineIndex(
lineStarts: lines.map((line) => line.time).toList(growable: false),
currentIndex: _active,
position: position,
);
if (dueIndex != _active) {
_setActiveLine(dueIndex, position: position);
}
final nextIndex = dueIndex + 1;
if (nextIndex >= lines.length) return;
final boundary = lines[nextIndex].time;
_lineBoundaryTimer = Timer(boundary - position, () {
if (!mounted || !widget.isActive || !_playing || _loading) return;
_scheduleNextLine(boundary);
});
}
@override
void dispose() {
_positionSubscription?.close();
_playingSubscription?.close();
_loadingSubscription?.close();
_lineBoundaryTimer?.cancel();
_scroll.dispose();
super.dispose();
}
@@ -1462,6 +1523,8 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
content = _WordHighlightedLyricLine(
line: line,
colorScheme: widget.colorScheme,
animate: widget.isActive,
initialPosition: _activeTransitionPosition,
);
} else {
content = Text(
@@ -1480,6 +1543,16 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
),
);
}
content = AnimatedSwitcher(
duration: const Duration(milliseconds: 320),
reverseDuration: const Duration(milliseconds: 260),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
child: KeyedSubtree(
key: ValueKey(isActive && line.hasWordTiming),
child: content,
),
);
return Padding(
key: _lineKeys[index],
@@ -1508,42 +1581,428 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
}
}
class _WordHighlightedLyricLine extends ConsumerWidget {
class _WordHighlightedLyricLine extends ConsumerStatefulWidget {
final LyricLine line;
final ColorScheme colorScheme;
final bool animate;
final Duration initialPosition;
const _WordHighlightedLyricLine({
required this.line,
required this.colorScheme,
required this.animate,
required this.initialPosition,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final position = ref.watch(playbackPositionProvider);
final spans = <TextSpan>[];
for (final word in line.words) {
final sung = position >= word.time;
spans.add(
TextSpan(
text: word.text,
style: TextStyle(
color: sung
? colorScheme.onSurface
: colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
),
),
);
ConsumerState<_WordHighlightedLyricLine> createState() =>
_WordHighlightedLyricLineState();
}
class _WordHighlightedLyricLineState
extends ConsumerState<_WordHighlightedLyricLine>
with SingleTickerProviderStateMixin {
late final AnimationController _animationClock;
final Stopwatch _elapsedClock = Stopwatch();
ProviderSubscription<Duration>? _positionSubscription;
ProviderSubscription<bool>? _playingSubscription;
ProviderSubscription<bool>? _loadingSubscription;
late Duration _anchorPosition;
Duration _anchorElapsed = Duration.zero;
bool _playing = false;
bool _loading = false;
bool get _shouldAnimate => widget.animate && _playing && !_loading;
Duration _positionAt({required bool advance}) {
return interpolatedSyncedLyricsPosition(
anchorPosition: _anchorPosition,
elapsedSinceAnchor: _elapsedClock.elapsed - _anchorElapsed,
isPlaying: advance,
);
}
@override
void initState() {
super.initState();
_elapsedClock.start();
_anchorElapsed = _elapsedClock.elapsed;
_anchorPosition = widget.initialPosition;
_playing = ref.read(playbackPlayingProvider);
_loading = ref.read(playbackLoadingProvider);
_animationClock = AnimationController(
vsync: this,
duration: const Duration(seconds: 1),
);
_positionSubscription = ref.listenManual<Duration>(
playbackPositionProvider,
(previous, next) => _updateReportedPosition(next),
);
_playingSubscription = ref.listenManual<bool>(
playbackPlayingProvider,
(previous, next) => _updateTransportState(playing: next),
);
_loadingSubscription = ref.listenManual<bool>(
playbackLoadingProvider,
(previous, next) => _updateTransportState(loading: next),
);
_syncAnimationClock();
}
@override
void didUpdateWidget(covariant _WordHighlightedLyricLine oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.line != widget.line ||
oldWidget.initialPosition != widget.initialPosition) {
_anchorAt(widget.initialPosition);
}
return RichText(
textAlign: TextAlign.center,
text: TextSpan(
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
height: 1.4,
fontWeight: FontWeight.bold,
),
children: spans,
if (oldWidget.animate != widget.animate) {
_anchorAt(
_positionAt(advance: oldWidget.animate && _playing && !_loading),
);
_syncAnimationClock();
}
}
Duration _currentPosition() {
return _positionAt(advance: _shouldAnimate);
}
void _anchorAt(Duration position) {
_anchorPosition = position;
_anchorElapsed = _elapsedClock.elapsed;
}
void _updateReportedPosition(Duration position) {
if (!mounted) return;
final predicted = _currentPosition();
_anchorAt(
reconcileSyncedLyricsPosition(
predictedPosition: predicted,
reportedPosition: position,
),
);
setState(() {});
}
void _updateTransportState({bool? playing, bool? loading}) {
if (!mounted) return;
final position = _currentPosition();
if (playing != null) _playing = playing;
if (loading != null) _loading = loading;
_anchorAt(position);
_syncAnimationClock();
setState(() {});
}
void _syncAnimationClock() {
if (_shouldAnimate) {
if (!_animationClock.isAnimating) {
_animationClock.repeat();
}
} else {
_animationClock.stop();
}
}
Duration _segmentEnd(int index) {
final start = widget.line.words[index].time;
if (index + 1 < widget.line.words.length) {
final next = widget.line.words[index + 1].time;
if (next > start) return next;
}
final lineEnd = widget.line.end;
if (lineEnd != null && lineEnd > start) return lineEnd;
return start + const Duration(milliseconds: 650);
}
@override
void dispose() {
_positionSubscription?.close();
_playingSubscription?.close();
_loadingSubscription?.close();
_animationClock.dispose();
_elapsedClock.stop();
super.dispose();
}
@override
Widget build(BuildContext context) {
return _buildHighlightedLine(context);
}
Widget _buildHighlightedLine(BuildContext context) {
final highlightedColor = widget.colorScheme.onSurface;
final pendingColor = widget.colorScheme.onSurfaceVariant.withValues(
alpha: 0.6,
);
final segments = <String>[];
final starts = <Duration>[];
final ends = <Duration>[];
for (var index = 0; index < widget.line.words.length; index++) {
final word = widget.line.words[index];
segments.add(word.text);
starts.add(word.time);
ends.add(_segmentEnd(index));
}
return _SweepingTimedLyricText(
segments: segments,
starts: starts,
ends: ends,
currentPosition: _currentPosition,
repaint: _animationClock,
style: (Theme.of(context).textTheme.headlineSmall ?? const TextStyle())
.copyWith(height: 1.4, fontWeight: FontWeight.bold),
pendingColor: pendingColor,
highlightedColor: highlightedColor,
semanticsLabel: widget.line.text,
);
}
}
class _SweepingTimedLyricText extends StatefulWidget {
final List<String> segments;
final List<Duration> starts;
final List<Duration> ends;
final Duration Function() currentPosition;
final Listenable repaint;
final TextStyle style;
final Color pendingColor;
final Color highlightedColor;
final String semanticsLabel;
const _SweepingTimedLyricText({
required this.segments,
required this.starts,
required this.ends,
required this.currentPosition,
required this.repaint,
required this.style,
required this.pendingColor,
required this.highlightedColor,
required this.semanticsLabel,
});
@override
State<_SweepingTimedLyricText> createState() =>
_SweepingTimedLyricTextState();
}
class _SweepingTimedLyricTextState extends State<_SweepingTimedLyricText> {
TextPainter? _pendingPainter;
TextPainter? _highlightedPainter;
String? _cachedText;
TextStyle? _cachedStyle;
TextDirection? _cachedDirection;
TextScaler? _cachedScaler;
Locale? _cachedLocale;
Color? _cachedPendingColor;
Color? _cachedHighlightedColor;
void _ensurePainters(
String text,
TextDirection textDirection,
TextScaler textScaler,
Locale? locale,
) {
if (_cachedText == text &&
_cachedStyle == widget.style &&
_cachedDirection == textDirection &&
_cachedScaler == textScaler &&
_cachedLocale == locale &&
_cachedPendingColor == widget.pendingColor &&
_cachedHighlightedColor == widget.highlightedColor) {
return;
}
_pendingPainter?.dispose();
_highlightedPainter?.dispose();
_pendingPainter = TextPainter(
text: TextSpan(
text: text,
style: widget.style.copyWith(color: widget.pendingColor),
),
textAlign: TextAlign.center,
textDirection: textDirection,
textScaler: textScaler,
locale: locale,
);
_highlightedPainter = TextPainter(
text: TextSpan(
text: text,
style: widget.style.copyWith(color: widget.highlightedColor),
),
textAlign: TextAlign.center,
textDirection: textDirection,
textScaler: textScaler,
locale: locale,
);
_cachedText = text;
_cachedStyle = widget.style;
_cachedDirection = textDirection;
_cachedScaler = textScaler;
_cachedLocale = locale;
_cachedPendingColor = widget.pendingColor;
_cachedHighlightedColor = widget.highlightedColor;
}
@override
void dispose() {
_pendingPainter?.dispose();
_highlightedPainter?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final textDirection = Directionality.of(context);
final textScaler = MediaQuery.textScalerOf(context);
final locale = Localizations.maybeLocaleOf(context);
final text = widget.segments.join();
_ensurePainters(text, textDirection, textScaler, locale);
return LayoutBuilder(
builder: (context, constraints) {
final maxWidth = constraints.hasBoundedWidth
? constraints.maxWidth
: double.infinity;
final pendingPainter = _pendingPainter!
..layout(
minWidth: constraints.hasBoundedWidth ? maxWidth : 0,
maxWidth: maxWidth,
);
final highlightedPainter = _highlightedPainter!
..layout(
minWidth: constraints.hasBoundedWidth ? maxWidth : 0,
maxWidth: maxWidth,
);
final width = constraints.hasBoundedWidth
? constraints.maxWidth
: pendingPainter.width;
final height = pendingPainter.height;
return Semantics(
label: widget.semanticsLabel,
child: CustomPaint(
size: Size(width, height),
painter: _TimedLyricSweepPainter(
segments: widget.segments,
starts: widget.starts,
ends: widget.ends,
currentPosition: widget.currentPosition,
repaint: widget.repaint,
pendingPainter: pendingPainter,
highlightedPainter: highlightedPainter,
),
),
);
},
);
}
}
class _TimedLyricSweepPainter extends CustomPainter {
final List<String> segments;
final List<Duration> starts;
final List<Duration> ends;
final Duration Function() currentPosition;
final TextPainter pendingPainter;
final TextPainter highlightedPainter;
_TimedLyricSweepPainter({
required this.segments,
required this.starts,
required this.ends,
required this.currentPosition,
required Listenable repaint,
required this.pendingPainter,
required this.highlightedPainter,
}) : super(repaint: repaint);
@override
void paint(Canvas canvas, Size size) {
pendingPainter.paint(canvas, Offset.zero);
final completedPath = Path();
final partialBoxes = <(Rect, double)>[];
final position = currentPosition();
var offset = 0;
for (var index = 0; index < segments.length; index++) {
final end = offset + segments[index].length;
final value = index < starts.length && index < ends.length
? syncedLyricSegmentProgress(
position: position,
start: starts[index],
end: ends[index],
)
: 0.0;
if (value > 0 && end > offset) {
final boxes = highlightedPainter.getBoxesForSelection(
TextSelection(baseOffset: offset, extentOffset: end),
);
for (final box in boxes) {
final rect = box.toRect();
if (value >= 1) {
completedPath.addRect(rect);
} else {
partialBoxes.add((rect, value));
}
}
}
offset = end;
}
if (!completedPath.getBounds().isEmpty) {
canvas.save();
canvas.clipPath(completedPath);
highlightedPainter.paint(canvas, Offset.zero);
canvas.restore();
}
for (final (box, value) in partialBoxes) {
final boundary = syncedLyricsLeftToRightBoundary(
left: box.left,
right: box.right,
progress: value,
);
final feather = (box.width * 0.18).clamp(3.0, 10.0);
final revealRight = (boundary + feather).clamp(box.left, box.right);
final revealRect = Rect.fromLTRB(
box.left,
box.top,
revealRight,
box.bottom,
);
final gradientStart = boundary.clamp(box.left, revealRight - 0.01);
canvas.save();
canvas.clipRect(revealRect);
canvas.saveLayer(revealRect, Paint());
highlightedPainter.paint(canvas, Offset.zero);
final mask = Paint()
..blendMode = BlendMode.dstIn
..shader =
LinearGradient(
colors: const [Colors.white, Colors.transparent],
).createShader(
Rect.fromLTRB(gradientStart, box.top, revealRight, box.bottom),
);
canvas.drawRect(revealRect, mask);
canvas.restore();
canvas.restore();
}
}
@override
bool shouldRepaint(covariant _TimedLyricSweepPainter oldDelegate) {
return oldDelegate.segments != segments ||
oldDelegate.starts != starts ||
oldDelegate.ends != ends ||
oldDelegate.currentPosition != currentPosition ||
oldDelegate.pendingPainter != pendingPainter ||
oldDelegate.highlightedPainter != highlightedPainter;
}
}
+70
View File
@@ -21,3 +21,73 @@ double syncedLyricsEstimatedOffset({
if (index <= 0) return 0;
return index * estimatedLineExtent;
}
/// Advances to the final lyric line whose timestamp is already due.
int syncedLyricsDueLineIndex({
required List<Duration> lineStarts,
required int currentIndex,
required Duration position,
}) {
if (lineStarts.isEmpty) return -1;
var dueIndex = currentIndex;
if (dueIndex < -1) dueIndex = -1;
if (dueIndex >= lineStarts.length) dueIndex = lineStarts.length - 1;
while (dueIndex + 1 < lineStarts.length &&
lineStarts[dueIndex + 1] <= position) {
dueIndex++;
}
return dueIndex;
}
/// Extrapolates the latest player position for the lyrics animation only.
///
/// The global playback state intentionally updates at a lower frequency to
/// avoid rebuilding unrelated UI on every frame. The active lyric line uses
/// this value to animate between those updates.
Duration interpolatedSyncedLyricsPosition({
required Duration anchorPosition,
required Duration elapsedSinceAnchor,
required bool isPlaying,
}) {
if (!isPlaying || elapsedSinceAnchor.isNegative) return anchorPosition;
return anchorPosition + elapsedSinceAnchor;
}
/// Keeps small timing corrections from making the lyric highlight jump while
/// still applying a seek or another large position change immediately.
Duration reconcileSyncedLyricsPosition({
required Duration predictedPosition,
required Duration reportedPosition,
Duration seekThreshold = const Duration(milliseconds: 750),
}) {
final drift = (reportedPosition - predictedPosition).abs();
if (drift >= seekThreshold) return reportedPosition;
const correctionFraction = 0.25;
final correction =
((reportedPosition - predictedPosition).inMicroseconds *
correctionFraction)
.round();
return predictedPosition + Duration(microseconds: correction);
}
/// Continuous progress for one timed TTML or enhanced LRC segment.
double syncedLyricSegmentProgress({
required Duration position,
required Duration start,
required Duration end,
}) {
if (position <= start) return 0;
if (position >= end || end <= start) return 1;
return (position - start).inMicroseconds / (end - start).inMicroseconds;
}
/// Horizontal leading edge for a highlight that fills left to right.
double syncedLyricsLeftToRightBoundary({
required double left,
required double right,
required double progress,
}) {
final value = progress.clamp(0.0, 1.0);
return left + ((right - left) * value);
}
+21 -3
View File
@@ -24,11 +24,14 @@ void main() {
}
final arguments = (call.arguments as Map).cast<String, dynamic>();
final path = arguments['file_path']?.toString() ?? '';
final lyrics = path.endsWith('/timed.flac')
? '''<tt xmlns="http://www.w3.org/ns/ttml"><body><div><p begin="00:00.000" end="00:02.000"><span begin="00:00.000">Short</span></p></div></body></tt>'''
: path.endsWith('/second.flac')
? '[00:01.00]Second lyric'
: '[00:01.00]First lyric';
return jsonEncode({
'title': path.endsWith('/second.flac') ? 'Second' : 'First',
'lyrics': path.endsWith('/second.flac')
? '[00:01.00]Second lyric'
: '[00:01.00]First lyric',
'lyrics': lyrics,
});
});
});
@@ -89,6 +92,21 @@ void main() {
},
);
testWidgets('short timed lyric remains horizontally centered', (
tester,
) async {
await pumpNowPlaying(tester);
mediaItems.add(item('timed'));
await tester.pumpAndSettle();
await tester.drag(find.byType(PageView), const Offset(-700, 0));
await tester.pumpAndSettle();
final lyric = find.bySemanticsLabel('Short');
expect(lyric, findsOneWidget);
expect(tester.getCenter(lyric).dx, closeTo(540, 1));
});
testWidgets('Now Playing menu exposes Go to Album when album is known', (
tester,
) async {
+109
View File
@@ -27,4 +27,113 @@ void main() {
24,
);
});
test('advances at the exact next line boundary', () {
const starts = [
Duration(seconds: 1),
Duration(seconds: 3),
Duration(seconds: 3),
Duration(seconds: 6),
];
expect(
syncedLyricsDueLineIndex(
lineStarts: starts,
currentIndex: 0,
position: const Duration(milliseconds: 2999),
),
0,
);
expect(
syncedLyricsDueLineIndex(
lineStarts: starts,
currentIndex: 0,
position: const Duration(seconds: 3),
),
2,
);
});
group('smooth timed lyric highlight', () {
test('interpolates position only while playback is advancing', () {
expect(
interpolatedSyncedLyricsPosition(
anchorPosition: const Duration(seconds: 10),
elapsedSinceAnchor: const Duration(milliseconds: 250),
isPlaying: true,
),
const Duration(milliseconds: 10250),
);
expect(
interpolatedSyncedLyricsPosition(
anchorPosition: const Duration(seconds: 10),
elapsedSinceAnchor: const Duration(milliseconds: 250),
isPlaying: false,
),
const Duration(seconds: 10),
);
});
test('blends small clock corrections and applies seeks immediately', () {
expect(
reconcileSyncedLyricsPosition(
predictedPosition: const Duration(milliseconds: 1000),
reportedPosition: const Duration(milliseconds: 1100),
),
const Duration(milliseconds: 1025),
);
expect(
reconcileSyncedLyricsPosition(
predictedPosition: const Duration(seconds: 1),
reportedPosition: const Duration(seconds: 5),
),
const Duration(seconds: 5),
);
});
test('calculates continuous progress inside a timed segment', () {
const start = Duration(seconds: 2);
const end = Duration(seconds: 3);
expect(
syncedLyricSegmentProgress(
position: const Duration(milliseconds: 1500),
start: start,
end: end,
),
0,
);
expect(
syncedLyricSegmentProgress(
position: const Duration(milliseconds: 2250),
start: start,
end: end,
),
0.25,
);
expect(
syncedLyricSegmentProgress(
position: const Duration(milliseconds: 3500),
start: start,
end: end,
),
1,
);
});
test('moves the reveal boundary from left to right', () {
expect(
syncedLyricsLeftToRightBoundary(left: 10, right: 110, progress: 0),
10,
);
expect(
syncedLyricsLeftToRightBoundary(left: 10, right: 110, progress: 0.5),
60,
);
expect(
syncedLyricsLeftToRightBoundary(left: 10, right: 110, progress: 1),
110,
);
});
});
}