Files
SpotiFLAC-Mobile/lib/widgets/player_lyrics.dart
T

1688 lines
58 KiB
Dart

import 'dart:async';
import 'dart:ui' show BoxHeightStyle, ImageFilter;
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show ValueListenable, listEquals;
import 'package:flutter/rendering.dart'
show OverflowBoxFit, RenderAbstractViewport, ScrollDirection;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/music_player_provider.dart';
import 'package:spotiflac_android/providers/runtime_profile_provider.dart';
import 'package:spotiflac_android/theme/app_tokens.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/utils/lyrics_parser.dart';
import 'package:spotiflac_android/utils/lyrics_timeline.dart';
import 'package:spotiflac_android/utils/synced_lyrics_scroll.dart';
import 'package:spotiflac_android/widgets/aligned_lyric_pronunciation.dart';
import 'package:spotiflac_android/widgets/lyric_supplement_transition.dart';
import 'package:spotiflac_android/widgets/lyric_scroll_motion.dart';
import 'package:spotiflac_android/widgets/lyric_gap_indicator.dart';
import 'package:spotiflac_android/widgets/mornye_player_slider.dart';
const _mornyeLyricFontSize = 34.0;
const mornyeLyricsContentInset = 28.0 + MornyePlayerSlider.horizontalInset;
class SyncedLyricsView extends ConsumerStatefulWidget {
final ParsedLyrics lyrics;
final ValueListenable<Duration?> seekPreview;
final LyricsCredits? credits;
final ColorScheme colorScheme;
final bool isActive;
final bool showPronunciation;
final bool showTranslation;
const SyncedLyricsView({
super.key,
required this.lyrics,
required this.seekPreview,
this.credits,
required this.colorScheme,
required this.isActive,
required this.showPronunciation,
required this.showTranslation,
});
@override
ConsumerState<SyncedLyricsView> createState() => _SyncedLyricsViewState();
}
class _SyncedLyricsViewState extends ConsumerState<SyncedLyricsView>
with TickerProviderStateMixin {
final ScrollController _scroll = ScrollController();
ProviderSubscription<Duration>? _positionSubscription;
ProviderSubscription<bool>? _playingSubscription;
ProviderSubscription<bool>? _loadingSubscription;
Timer? _lineBoundaryTimer;
Timer? _userScrollIdleTimer;
late List<LyricLine> _lines;
late LyricDisplayLayout _displayLayout;
late List<GlobalKey> _lineKeys;
int _active = -1;
Set<int> _activeLines = {};
Duration _activeTransitionPosition = Duration.zero;
bool _playing = false;
bool _loading = false;
bool _hasStarted = false;
bool _userScrolling = false;
static const double _estimatedLyricExtent = 64;
List<double>? _lineExtents;
List<(double, double, double)> _lineMeasurements = [];
List<LyricPronunciationLayout?> _pronunciationLayouts = [];
Object? _lineLayoutKey;
double? _viewportHeight;
Offset? _layoutVisibility;
late final AnimationController _rowReveal = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 420),
)..addListener(_revealTick);
List<double> _revealFrom = [];
List<double> _revealTo = [];
List<double>? _layoutRowVisibility;
List<double>? _targetLineExtents;
bool _preserveRevealAnchor = false;
late final AnimationController _scrollMotion = AnimationController(
vsync: this,
duration: LyricScrollMotion.duration,
value: 1,
);
double _scrollMotionDistance = 0;
int _scrollMotionFocus = 0;
void _stopScrollMotion() {
_scrollMotion.stop();
_scrollMotion.value = 1;
}
void _startScrollMotion(int row, double distance) {
if (distance.abs() < 0.5 ||
widget.seekPreview.value != null ||
MediaQuery.disableAnimationsOf(context)) {
_stopScrollMotion();
return;
}
setState(() {
_scrollMotionDistance = distance;
_scrollMotionFocus = row;
});
_scrollMotion.forward(from: 0);
}
void _revealTick() {
if (mounted) setState(() {});
}
List<double> get _rowVisibility {
final progress = Curves.easeInOutCubic.transform(_rowReveal.value);
return List.generate(
_lines.length,
(index) =>
_revealFrom[index] +
(_revealTo[index] - _revealFrom[index]) * progress,
);
}
void _updateRowVisibility({bool immediate = false, bool sameFocus = false}) {
_preserveRevealAnchor = sameFocus;
final target = List.generate(_lines.length, (index) {
final line = _lines[index];
if (line.text.isEmpty) return _activeLines.contains(index) ? 1.0 : 0.0;
if (line.isBackground) return index <= _active ? 1.0 : 0.0;
return 1.0;
});
if (immediate || MediaQuery.disableAnimationsOf(context)) {
_rowReveal.stop();
_revealFrom = target;
_revealTo = target;
return;
}
if (listEquals(target, _revealTo)) return;
_revealFrom = _rowVisibility;
_revealTo = target;
_rowReveal.forward(from: 0);
}
// The header stays fixed when controls collapse. Anchor lyrics to it, not
// to a fraction of the growing viewport, including during the transition.
double get _focusInset =>
(MediaQuery.sizeOf(context).height * 0.06).clamp(16.0, 48.0);
@override
void initState() {
super.initState();
_resetLineKeys();
widget.seekPreview.addListener(_previewChanged);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Appearance can rebuild when transport controls fade (for example with
// custom glass clarity). Keep the existing playback subscription and the
// user's scroll ownership; only new lyrics/page activation should reset it.
if (_positionSubscription == null && widget.isActive) {
_syncPositionSubscription();
}
}
@override
void didUpdateWidget(covariant SyncedLyricsView oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.seekPreview != widget.seekPreview) {
oldWidget.seekPreview.removeListener(_previewChanged);
widget.seekPreview.addListener(_previewChanged);
}
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();
}
}
void _resetLineKeys() {
_stopScrollMotion();
_hasStarted = false;
_lineExtents = null;
_lineLayoutKey = null;
_lines = lyricsTimelineWithGaps(widget.lyrics.lines);
_rowReveal.stop();
_revealFrom = _revealTo = [
for (final line in _lines)
if (line.isBackground || line.text.isEmpty) 0.0 else 1.0,
];
_layoutRowVisibility = null;
_targetLineExtents = null;
_displayLayout = LyricDisplayLayout(_lines);
_lineKeys = List<GlobalKey>.generate(
_lines.length,
(index) => GlobalKey(debugLabel: 'lyric-line-$index'),
growable: false,
);
}
void _syncPositionSubscription() {
_positionSubscription?.close();
_playingSubscription?.close();
_loadingSubscription?.close();
_lineBoundaryTimer?.cancel();
_positionSubscription = null;
_playingSubscription = null;
_loadingSubscription = null;
if (!widget.isActive) {
_stopScrollMotion();
return;
}
_userScrollIdleTimer?.cancel();
_userScrolling = false;
final position = _displayPosition;
_playing = ref.read(playbackPlayingProvider);
_loading = ref.read(playbackLoadingProvider);
_active = _activeIndexAt(position);
_activeLines = activeLyricIndices(_lines, position, _active);
_updateRowVisibility(immediate: true);
_activeTransitionPosition = position;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) unawaited(_maybeAutoScroll(_active, immediate: true));
});
_scheduleNextLine(position);
_positionSubscription = ref.listenManual<Duration>(
playbackPositionProvider,
(previous, next) {
if (widget.seekPreview.value != null) return;
final active = _activeIndexAt(next);
_setActiveLine(active, position: next);
_scheduleNextLine(next);
},
);
_playingSubscription = ref.listenManual<bool>(playbackPlayingProvider, (
previous,
next,
) {
_playing = next;
final position = _displayPosition;
_setActiveLine(_activeIndexAt(position), position: position);
_scheduleNextLine(position);
});
_loadingSubscription = ref.listenManual<bool>(playbackLoadingProvider, (
previous,
next,
) {
_loading = next;
final position = _displayPosition;
_setActiveLine(_activeIndexAt(position), position: position);
_scheduleNextLine(position);
});
}
Duration get _displayPosition =>
widget.seekPreview.value ?? ref.read(playbackPositionProvider);
void _previewChanged() {
if (!mounted || !widget.isActive) return;
final position = _displayPosition;
final wasUserScrolling = _userScrolling;
_userScrolling = false;
_userScrollIdleTimer?.cancel();
final active = _activeIndexAt(position);
if (active == _active && (wasUserScrolling || active < 0)) {
unawaited(_maybeAutoScroll(active));
}
_setActiveLine(active, position: position);
_scheduleNextLine(position);
}
int _activeIndexAt(Duration position) {
// Scrubbing is an explicit preview, even at zero or while audio buffers.
if (widget.seekPreview.value != null) {
return LyricsParser.activeIndex(_lines, position);
}
if (context.isMornye) {
// Read one transport snapshot: playing/loading derived providers can
// notify separately during the same playback event.
final playback = ref.read(playbackStateProvider).value;
if (playback?.processingState == AudioProcessingState.loading ||
playback?.processingState == AudioProcessingState.buffering) {
return -1;
}
_hasStarted =
_hasStarted || playback?.playing == true || position > Duration.zero;
if (!_hasStarted) return -1;
}
return LyricsParser.activeIndex(_lines, position);
}
void _setActiveLine(int active, {required Duration position}) {
if (!mounted) return;
final activeLines = activeLyricIndices(_lines, position, active);
final indexChanged = active != _active;
final previousFocus = _active < 0
? -1
: _displayLayout.focusForLine[_active];
if (!indexChanged &&
activeLines.length == _activeLines.length &&
activeLines.containsAll(_activeLines)) {
return;
}
setState(() {
_active = active;
_activeLines = activeLines;
_activeTransitionPosition = position;
_updateRowVisibility(
sameFocus:
active >= 0 && _displayLayout.focusForLine[active] == previousFocus,
);
});
if (!indexChanged ||
(active >= 0 && _displayLayout.focusForLine[active] == previousFocus)) {
return;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && active == _active) unawaited(_maybeAutoScroll(active));
});
}
void _scheduleNextLine(Duration position) {
_lineBoundaryTimer?.cancel();
if (!widget.isActive ||
!_playing ||
_loading ||
widget.seekPreview.value != null) {
return;
}
final lines = _lines;
final dueIndex = syncedLyricsDueLineIndex(
lineStarts: lines.map((line) => line.time).toList(growable: false),
currentIndex: _active,
position: position,
);
_setActiveLine(dueIndex, position: position);
final nextIndex = dueIndex + 1;
Duration? nextBoundary = nextIndex < lines.length
? lines[nextIndex].time
: null;
for (final index in _activeLines) {
final end = lines[index].end;
if ((index != dueIndex ||
lines[index].voice != null ||
lines[index].isBackground) &&
end != null &&
end > position &&
(nextBoundary == null || end < nextBoundary)) {
nextBoundary = end;
}
}
if (nextBoundary == null) return;
final boundary = nextBoundary;
_lineBoundaryTimer = Timer(boundary - position, () {
if (!mounted || !widget.isActive || !_playing || _loading) return;
_scheduleNextLine(boundary);
});
}
@override
void dispose() {
widget.seekPreview.removeListener(_previewChanged);
_positionSubscription?.close();
_playingSubscription?.close();
_loadingSubscription?.close();
_lineBoundaryTimer?.cancel();
_userScrollIdleTimer?.cancel();
_scroll.dispose();
_rowReveal.dispose();
_scrollMotion.dispose();
super.dispose();
}
void _measureMornyeLines(double width) {
final style = _mornyeLyricStyle(context);
final scaler = MediaQuery.textScalerOf(context);
final direction = Directionality.of(context);
final locale = Localizations.maybeLocaleOf(context);
final key = (width, style, scaler, direction, locale);
if (_lineLayoutKey == key) return;
_lineLayoutKey = key;
final painter = TextPainter(
textDirection: direction,
textScaler: scaler,
locale: locale,
);
final measurements = <(double, double, double)>[];
final layouts = List<LyricPronunciationLayout?>.filled(_lines.length, null);
for (final index in _displayLayout.lineOrder) {
final line = _lines[index];
if (line.text.isEmpty) {
measurements.add((56, 0, 0));
continue;
}
painter.text = TextSpan(
text: line.text,
style: _mornyeLyricStyle(context, background: line.isBackground),
);
painter.layout(maxWidth: width);
final padding = _linePadding(index).vertical;
var height = painter.height + padding;
var pronunciationHeight = 0.0;
var translationHeight = 0.0;
LyricPronunciationLayout? pronunciationLayout;
for (final (text, style, _, translation) in _lyricSupplements(
context,
line,
)) {
painter.text = TextSpan(text: text, style: style);
painter.layout(maxWidth: width);
if (translation) {
translationHeight = 6 + painter.height;
} else {
pronunciationHeight = 6 + painter.height;
final aligned = LyricPronunciationLayout.measure(
line: line,
primaryStyle: _mornyeLyricStyle(
context,
background: line.isBackground,
),
pronunciationStyle: style,
maxWidth: width,
textScaler: scaler,
textDirection: direction,
locale: locale,
);
if (aligned != null) {
pronunciationLayout = aligned;
height = aligned.primaryHeight + padding;
pronunciationHeight = aligned.pronunciationHeight;
}
}
}
measurements.add((height, pronunciationHeight, translationHeight));
layouts[index] = pronunciationLayout;
}
_lineMeasurements = measurements;
_pronunciationLayouts = layouts;
painter.dispose();
}
Future<void> _maybeAutoScroll(int index, {bool immediate = false}) async {
// A short intro may have no countdown row. Still return to the first
// upcoming lyric when the user scrubs back before any vocals.
if (index < 0 && widget.seekPreview.value != null) index = 0;
if (_userScrolling ||
index < 0 ||
index >= _lines.length ||
!_scroll.hasClients) {
return;
}
index = _displayLayout.focusForLine[index];
final row = _displayLayout.rowForLine[index];
final duration = immediate || MediaQuery.disableAnimationsOf(context)
? Duration.zero
: Duration(milliseconds: widget.seekPreview.value != null ? 220 : 380);
if (duration == Duration.zero) _stopScrollMotion();
final extents = _targetLineExtents;
if (context.isMornye && extents != null && row < extents.length) {
final position = _scroll.position;
final target = extents.take(row).fold(0.0, (sum, extent) => sum + extent);
final offset = target.clamp(
position.minScrollExtent,
position.maxScrollExtent,
);
if (duration == Duration.zero) {
_scroll.jumpTo(offset);
} else {
_startScrollMotion(row, offset - position.pixels);
await _scroll.animateTo(
offset,
duration: duration,
curve: Curves.easeOutCubic,
);
}
return;
}
if (index < _lineKeys.length) {
final lineContext = _lineKeys[index].currentContext;
if (lineContext != null) {
final object = lineContext.findRenderObject();
final viewport = object == null
? null
: RenderAbstractViewport.maybeOf(object);
if (duration != Duration.zero && viewport != null) {
_startScrollMotion(
row,
viewport.getOffsetToReveal(object!, 0.5).offset - _scroll.offset,
);
}
await Scrollable.ensureVisible(
lineContext,
alignment: 0.5,
alignmentPolicy: ScrollPositionAlignmentPolicy.explicit,
duration: duration,
curve: Curves.easeOutCubic,
);
return;
}
}
final position = _scroll.position;
final target = syncedLyricsEstimatedOffset(
index: row,
estimatedLineExtent: _estimatedLyricExtent,
);
final clamped = target.clamp(
position.minScrollExtent,
position.maxScrollExtent,
);
if (duration == Duration.zero) {
_scroll.jumpTo(clamped.toDouble());
} else {
_startScrollMotion(row, clamped - position.pixels);
await _scroll.animateTo(
clamped.toDouble(),
duration: duration,
curve: Curves.easeOutCubic,
);
}
if (!mounted ||
_userScrolling ||
(_active >= 0 && index != _displayLayout.focusForLine[_active]) ||
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: duration == Duration.zero
? Duration.zero
: const Duration(milliseconds: 180),
curve: Curves.easeOut,
);
}
}
@override
Widget build(BuildContext context) => TweenAnimationBuilder<Offset>(
tween: Tween(
end: Offset(
widget.showPronunciation ? 1 : 0,
widget.showTranslation ? 1 : 0,
),
),
duration: MediaQuery.disableAnimationsOf(context)
? Duration.zero
: const Duration(milliseconds: 420),
curve: Curves.easeInOutCubic,
builder: (context, visibility, _) => _buildLyrics(context, visibility),
);
Widget _buildLyrics(BuildContext context, Offset visibility) {
final lines = _lines;
final active = _active;
final rowVisibility = _rowVisibility;
final loading = ref.watch(playbackLoadingProvider);
final mornye = context.isMornye;
final highContrast = MediaQuery.highContrastOf(context);
final blurLyrics =
mornye &&
!highContrast &&
// Every defocused line is filtered again on each playback frame.
ref.watch(mornyeLiquidGlassProvider);
final motion = MediaQuery.disableAnimationsOf(context)
? Duration.zero
: const Duration(milliseconds: 280);
return NotificationListener<ScrollNotification>(
onNotification: (notification) {
if (notification.depth != 0 ||
notification.metrics.axis != Axis.vertical) {
return false;
}
final dragging =
(notification is ScrollStartNotification &&
notification.dragDetails != null) ||
(notification is ScrollUpdateNotification &&
notification.dragDetails != null) ||
(notification is UserScrollNotification &&
notification.direction != ScrollDirection.idle);
if (dragging) {
_stopScrollMotion();
_userScrolling = true;
_userScrollIdleTimer?.cancel();
} else if (notification is ScrollEndNotification && _userScrolling) {
// Start the return-to-playback timeout only after the drag and its
// momentum finish. A direction notification is not an idle event:
// a long drag can keep that direction while controls fade in/out.
_userScrollIdleTimer?.cancel();
_userScrollIdleTimer = Timer(const Duration(seconds: 4), () {
if (!mounted || !widget.isActive) return;
_userScrolling = false;
unawaited(_maybeAutoScroll(_active));
});
}
return false;
},
child: LayoutBuilder(
builder: (context, constraints) {
final inset = mornye ? mornyeLyricsContentInset : 24.0;
final contentWidth = (constraints.maxWidth - inset * 2).clamp(
0.0,
double.infinity,
);
// Lay out at the existing lyric width, then fit the whole line to
// the timeline. Scaling text, phrase gaps and supplements together
// preserves wrapping instead of pushing extra words onto a new row.
final layoutWidth = (constraints.maxWidth - 48).clamp(
0.0,
double.infinity,
);
final lyricScale = mornye && layoutWidth > 0
? contentWidth / layoutWidth
: 1.0;
if (mornye) {
final previousExtents = _lineExtents;
_measureMornyeLines(layoutWidth);
// Reuse text measurements; only interpolate row heights as the
// supplements fade. Scrolling follows the same animation clock.
final fullExtents = List.generate(_lineMeasurements.length, (row) {
final (primary, pronunciation, translation) =
_lineMeasurements[row];
final index = _displayLayout.lineOrder[row];
if (_lines[index].text.isEmpty) return primary;
final padding = _linePadding(index).vertical;
return padding +
(primary -
padding +
pronunciation * visibility.dx +
translation * visibility.dy) *
lyricScale;
});
_lineExtents = List.generate(fullExtents.length, (row) {
return fullExtents[row] *
rowVisibility[_displayLayout.lineOrder[row]];
});
_targetLineExtents = List.generate(fullExtents.length, (row) {
return fullExtents[row] *
_revealTo[_displayLayout.lineOrder[row]];
});
if (previousExtents != null &&
((_layoutVisibility != null &&
_layoutVisibility != visibility) ||
(_preserveRevealAnchor &&
_layoutRowVisibility != null &&
!listEquals(_layoutRowVisibility, rowVisibility))) &&
previousExtents.length == _lineExtents!.length &&
_scroll.hasClients) {
var anchor = _active < 0
? 0
: _displayLayout.rowForLine[_displayLayout
.focusForLine[_active]];
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);
}
}
_layoutVisibility = visibility;
_layoutRowVisibility = rowVisibility;
if (_viewportHeight != constraints.maxHeight) {
_viewportHeight = constraints.maxHeight;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
unawaited(_maybeAutoScroll(_active, immediate: true));
}
});
}
final topPadding = mornye
? _focusInset
: syncedLyricsCenterPadding(
viewportDimension: constraints.maxHeight,
estimatedLineExtent: _estimatedLyricExtent,
);
// Leave enough trailing space for the final line to reach the same
// upper focus position as every other line.
final bottomPadding = mornye && _lineExtents!.isNotEmpty
? (constraints.maxHeight - _lineExtents!.last - topPadding).clamp(
topPadding,
double.infinity,
)
: topPadding;
return ListView.builder(
controller: _scroll,
itemExtentBuilder: mornye
? (index, _) => index < lines.length
? _lineExtents![index]
: widget.credits!.heightFor(context, contentWidth)
: null,
padding: EdgeInsets.fromLTRB(
inset,
topPadding,
inset,
bottomPadding,
),
itemCount: lines.length + (widget.credits == null ? 0 : 1),
itemBuilder: (context, row) {
if (row == lines.length) return widget.credits!;
final index = _displayLayout.lineOrder[row];
final line = lines[index];
final leadIndex = _displayLayout.leadForLine[index];
final textAlign = _lyricTextAlign(context, lines[leadIndex]);
final reveal = rowVisibility[index];
if (reveal == 0) return SizedBox.shrink(key: _lineKeys[index]);
final isActive = _activeLines.contains(index);
final isPast = index < active;
final color = mornye
? Colors.white
: isActive
? widget.colorScheme.onSurface
: isPast
? widget.colorScheme.onSurfaceVariant.withValues(alpha: 0.5)
: widget.colorScheme.onSurfaceVariant.withValues(alpha: 0.8);
if (line.text.isEmpty) {
return _revealRow(
index,
reveal,
textAlign,
Padding(
padding: EdgeInsets.symmetric(
vertical: context.tokens.lyricsLinePaddingV,
),
child: SizedBox(
height: 24,
child: Align(
alignment: mornye
? Alignment.centerLeft
: Alignment.center,
child:
isActive &&
widget.isActive &&
(!loading || widget.seekPreview.value != null)
? ValueListenableBuilder<Duration?>(
valueListenable: widget.seekPreview,
builder: (context, preview, _) =>
LyricGapIndicator(
key: ValueKey(line.time),
start: line.time,
end: line.end!,
color: color,
position: preview,
),
)
: null,
),
),
),
);
}
final timed =
isActive &&
(line.hasWordTiming || line.romanizationWords.isNotEmpty);
Widget content;
if (timed) {
content = _WordHighlightedLyricLine(
line: line,
colorScheme: widget.colorScheme,
animate: widget.isActive,
initialPosition: _activeTransitionPosition,
seekPreview: widget.seekPreview,
supplementVisibility: visibility,
pronunciationLayout: mornye
? _pronunciationLayouts[index]
: null,
textAlign: textAlign,
);
} else {
content = Text(
line.text,
textAlign: textAlign,
style:
(mornye || isActive
? Theme.of(context).textTheme.headlineSmall
: Theme.of(context).textTheme.titleLarge)
?.copyWith(
height: context.tokens.lyricsLineHeight,
fontSize: line.isBackground
? (mornye ? _mornyeLyricFontSize * 0.68 : 18)
: mornye
? _mornyeLyricFontSize
: null,
fontWeight: mornye || isActive
? FontWeight.bold
: FontWeight.w500,
color: color,
),
);
content = _withLyricSupplements(
context,
line,
content,
color,
visibility: visibility,
pronunciationLayout: mornye
? _pronunciationLayouts[index]
: null,
textAlign: textAlign,
);
}
content = AnimatedSwitcher(
duration: const Duration(milliseconds: 320),
reverseDuration: const Duration(milliseconds: 260),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
child: KeyedSubtree(
key: ValueKey(timed),
child: SizedBox(width: double.infinity, child: content),
),
);
if (mornye) {
final activeLead = active < 0
? -1
: _displayLayout.focusForLine[active];
final distance = activeLead < 0
? row + 1
: (_displayLayout.rowForLine[leadIndex] -
_displayLayout.rowForLine[activeLead])
.abs();
// Nearby lines need visible defocus at the larger lyric size;
// progressively soften lines further from the current one.
final sigma = !blurLyrics || isActive || leadIndex == activeLead
? 0.0
: active < 0
? 4.8
: distance == 1
? 2.4
: distance == 2
? 3.6
: 4.8;
content = TweenAnimationBuilder<double>(
tween: Tween(end: sigma),
duration: motion,
child: RepaintBoundary(child: content),
builder: (context, value, child) => ImageFiltered(
enabled: value > 0,
imageFilter: ImageFilter.blur(sigmaX: value, sigmaY: value),
child: child,
),
);
content = FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.topLeft,
child: SizedBox(width: layoutWidth, child: content),
);
}
content = LyricScrollMotion(
progress: _scrollMotion,
distance: _scrollMotionDistance,
rowsAfterFocus: row - _scrollMotionFocus,
enabled: !isActive,
child: content,
);
return _revealRow(
index,
reveal,
textAlign,
Padding(
padding: _linePadding(index),
child: GestureDetector(
onTap: () =>
ref.read(musicPlayerControllerProvider).seek(line.time),
child: AnimatedScale(
scale: mornye || isActive ? 1.0 : 0.96,
alignment: Alignment.center,
duration: const Duration(milliseconds: 280),
curve: Curves.easeOutCubic,
child: AnimatedOpacity(
opacity: mornye
? (isActive || highContrast ? 1 : 0.48)
: (isActive ? 1.0 : (isPast ? 0.55 : 0.85)),
duration: const Duration(milliseconds: 280),
child: content,
),
),
),
),
);
},
);
},
),
);
}
Widget _revealRow(
int index,
double visibility,
TextAlign align,
Widget child,
) {
return KeyedSubtree(
key: _lineKeys[index],
child: !_lines[index].isBackground && _lines[index].text.isNotEmpty
? child
: IgnorePointer(
ignoring: visibility < 1,
child: LyricSupplementTransition(
visibility: visibility,
alignment: align == TextAlign.right
? Alignment.topRight
: align == TextAlign.center
? Alignment.topCenter
: Alignment.topLeft,
// A fixed-extent row is already shrinking. Measure its
// contents at full height so fitting does not scale it twice.
child: OverflowBox(
minHeight: 0,
maxHeight: double.infinity,
fit: OverflowBoxFit.deferToChild,
alignment: Alignment.topCenter,
child: child,
),
),
),
);
}
EdgeInsets _linePadding(int index) {
final row = _displayLayout.rowForLine[index];
final order = _displayLayout.lineOrder;
final hasBackingBelow =
row + 1 < order.length &&
_lines[order[row + 1]].isBackground &&
_displayLayout.leadForLine[order[row + 1]] ==
_displayLayout.leadForLine[index];
return EdgeInsets.only(
top: _lines[index].isBackground ? 6 : context.tokens.lyricsLinePaddingV,
bottom: hasBackingBelow ? 0 : context.tokens.lyricsLinePaddingV,
);
}
}
class LyricsCredits extends StatelessWidget {
const LyricsCredits({super.key, this.writers, this.provider});
final String? writers;
final String? provider;
String _text(BuildContext context) => [
if (writers != null) context.l10n.nowPlayingWrittenBy(writers!),
if (provider != null) context.l10n.nowPlayingLyricsProvider(provider!),
].join('\n');
TextStyle _style(BuildContext context) =>
Theme.of(context).textTheme.bodyMedium!.copyWith(
fontSize: context.isMornye ? 17 : 14,
fontWeight: FontWeight.w600,
height: 1.4,
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.48),
);
double heightFor(BuildContext context, double width) {
final painter = TextPainter(
text: TextSpan(text: _text(context), style: _style(context)),
textDirection: Directionality.of(context),
textScaler: MediaQuery.textScalerOf(context),
)..layout(maxWidth: width.clamp(0, double.infinity));
final height = painter.height + 32;
painter.dispose();
return height;
}
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Text(_text(context), style: _style(context)),
);
}
Iterable<(String, TextStyle, List<LyricWord>, bool)> _lyricSupplements(
BuildContext context,
LyricLine line,
) sync* {
final base = Theme.of(context).textTheme.bodyMedium ?? const TextStyle();
for (final (text, translation) in [
(line.romanization, false),
(line.translation, true),
]) {
if (text == null || text.trim().isEmpty) continue;
yield (
text,
base.copyWith(
fontSize:
(context.isMornye
? (translation ? 18.0 : 22.0)
: (translation ? 14.0 : 16.0)) *
(line.isBackground ? 0.8 : 1),
height: 1.35,
fontWeight: context.isMornye
? (translation ? FontWeight.w600 : FontWeight.bold)
: FontWeight.w500,
),
translation ? const <LyricWord>[] : line.romanizationWords,
translation,
);
}
}
TextStyle _mornyeLyricStyle(BuildContext context, {bool background = false}) =>
(Theme.of(context).textTheme.headlineSmall ?? const TextStyle()).copyWith(
fontSize: _mornyeLyricFontSize * (background ? 0.68 : 1),
height: context.tokens.lyricsLineHeight,
fontWeight: FontWeight.bold,
);
TextAlign _lyricTextAlign(BuildContext context, LyricLine line) {
final voice = line.voice;
if (voice == null) {
return context.isMornye ? TextAlign.start : TextAlign.center;
}
if (voice.isGroup) return TextAlign.left;
// Provider declarations can be missing or ordered by first appearance.
// The standard V labels retain their meaning regardless of that order.
switch (voice.id.toLowerCase()) {
case 'v1':
case 'v3':
return TextAlign.left;
case 'v2':
return TextAlign.right;
}
return voice.index.isEven ? TextAlign.left : TextAlign.right;
}
Widget _withLyricSupplements(
BuildContext context,
LyricLine line,
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,
}) {
if (line.romanization == null && line.translation == null) return primary;
final alignment = switch (textAlign) {
TextAlign.right => Alignment.topRight,
TextAlign.center => Alignment.topCenter,
_ => Alignment.topLeft,
};
final supplements = _lyricSupplements(context, line).toList();
Widget withSupplements(Widget primary, {bool aligned = false}) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
primary,
for (final (text, style, words, translation) in supplements)
if ((translation || !aligned) &&
(translation ? visibility.dy : visibility.dx) > 0)
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),
),
),
),
),
],
);
if (!context.isMornye ||
line.romanizationWords.isEmpty ||
line.romanization?.trim().isNotEmpty != true) {
return withSupplements(primary);
}
final primaryStyle = _mornyeLyricStyle(
context,
background: line.isBackground,
);
final pronunciationStyle = supplements
.firstWhere((supplement) => !supplement.$4)
.$2;
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,
);
}
class _WordHighlightedLyricLine extends ConsumerStatefulWidget {
final LyricLine line;
final ColorScheme colorScheme;
final bool animate;
final Duration initialPosition;
final ValueListenable<Duration?> seekPreview;
final Offset supplementVisibility;
final LyricPronunciationLayout? pronunciationLayout;
final TextAlign textAlign;
const _WordHighlightedLyricLine({
required this.line,
required this.colorScheme,
required this.animate,
required this.initialPosition,
required this.seekPreview,
required this.supplementVisibility,
required this.pronunciationLayout,
required this.textAlign,
});
@override
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 &&
widget.seekPreview.value == null;
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),
);
widget.seekPreview.addListener(_previewChanged);
_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.seekPreview != widget.seekPreview) {
oldWidget.seekPreview.removeListener(_previewChanged);
widget.seekPreview.addListener(_previewChanged);
_previewChanged();
}
if (oldWidget.line != widget.line ||
oldWidget.initialPosition != widget.initialPosition) {
_anchorAt(widget.initialPosition);
}
if (oldWidget.animate != widget.animate) {
_anchorAt(
_positionAt(advance: oldWidget.animate && _playing && !_loading),
);
_syncAnimationClock();
}
}
Duration _currentPosition() =>
widget.seekPreview.value ?? _positionAt(advance: _shouldAnimate);
void _previewChanged() {
if (!mounted) return;
_anchorAt(widget.seekPreview.value ?? ref.read(playbackPositionProvider));
_syncAnimationClock();
setState(() {});
}
void _anchorAt(Duration position) {
_anchorPosition = position;
_anchorElapsed = _elapsedClock.elapsed;
}
void _updateReportedPosition(Duration position) {
if (!mounted || widget.seekPreview.value != null) return;
final predicted = _currentPosition();
_anchorAt(
_shouldAnimate
? reconcileSyncedLyricsPosition(
predictedPosition: predicted,
reportedPosition: position,
)
: position,
);
if (!_shouldAnimate) setState(() {});
}
void _updateTransportState({bool? playing, bool? loading}) {
if (!mounted) return;
final position = _currentPosition();
if (playing != null) _playing = playing;
if (loading != null) _loading = loading;
_anchorAt(
widget.seekPreview.value ??
(_shouldAnimate ? position : ref.read(playbackPositionProvider)),
);
_syncAnimationClock();
setState(() {});
}
void _syncAnimationClock() {
if (_shouldAnimate) {
if (!_animationClock.isAnimating) {
_animationClock.repeat();
}
} else {
_animationClock.stop();
}
}
Duration _segmentEnd(List<LyricWord> words, int index) {
final word = words[index];
final start = word.time;
if (word.end != null && word.end! >= start) return word.end!;
if (index + 1 < words.length) {
final next = 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() {
widget.seekPreview.removeListener(_previewChanged);
_positionSubscription?.close();
_playingSubscription?.close();
_loadingSubscription?.close();
_animationClock.dispose();
_elapsedClock.stop();
super.dispose();
}
@override
Widget build(BuildContext context) {
final style = context.isMornye
? _mornyeLyricStyle(context, background: widget.line.isBackground)
: (Theme.of(context).textTheme.headlineSmall ?? const TextStyle())
.copyWith(
fontSize: widget.line.isBackground ? 18 : null,
height: context.tokens.lyricsLineHeight,
fontWeight: FontWeight.bold,
);
final primary = widget.line.hasWordTiming
? _buildTimedText(widget.line.text, widget.line.words, style)
: Text(
widget.line.text,
textAlign: widget.textAlign,
style: style.copyWith(color: widget.colorScheme.onSurface),
);
// Both scripts share this state's position interpolation and animation
// clock, including pause, seek and track changes.
return _withLyricSupplements(
context,
widget.line,
primary,
widget.colorScheme.onSurface,
timedText: _buildTimedText,
timedSupplementText: (text, words, style) =>
_buildTimedText(text, words, style, lift: false),
visibility: widget.supplementVisibility,
pronunciationLayout: widget.pronunciationLayout,
textAlign: widget.textAlign,
);
}
Widget _buildTimedText(
String text,
List<LyricWord> words,
TextStyle style, {
bool lift = true,
}) {
final highlightedColor = widget.colorScheme.onSurface;
final mornye = context.isMornye;
final pendingColor = mornye
? Colors.white.withValues(alpha: 0.4)
: widget.colorScheme.onSurfaceVariant.withValues(alpha: 0.6);
return _SweepingTimedLyricText(
segments: [for (final word in words) word.text],
starts: [for (final word in words) word.time],
ends: [
for (var index = 0; index < words.length; index++)
_segmentEnd(words, index),
],
currentPosition: _currentPosition,
repaint: _animationClock,
style: style,
textAlign: widget.textAlign,
pendingColor: pendingColor,
highlightedColor: highlightedColor,
semanticsLabel: text,
liftEnabled: lift && mornye && !MediaQuery.disableAnimationsOf(context),
);
}
}
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 TextAlign textAlign;
final Color pendingColor;
final Color highlightedColor;
final String semanticsLabel;
final bool liftEnabled;
const _SweepingTimedLyricText({
required this.segments,
required this.starts,
required this.ends,
required this.currentPosition,
required this.repaint,
required this.style,
required this.textAlign,
required this.pendingColor,
required this.highlightedColor,
required this.semanticsLabel,
required this.liftEnabled,
});
@override
State<_SweepingTimedLyricText> createState() =>
_SweepingTimedLyricTextState();
}
class _SweepingTimedLyricTextState extends State<_SweepingTimedLyricText> {
TextPainter? _pendingPainter;
TextPainter? _highlightedPainter;
String? _cachedText;
TextStyle? _cachedStyle;
TextAlign? _cachedAlignment;
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 &&
_cachedAlignment == widget.textAlign &&
_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: widget.textAlign,
textDirection: textDirection,
textScaler: textScaler,
locale: locale,
);
_highlightedPainter = TextPainter(
text: TextSpan(
text: text,
style: widget.style.copyWith(color: widget.highlightedColor),
),
textAlign: widget.textAlign,
textDirection: textDirection,
textScaler: textScaler,
locale: locale,
);
_cachedText = text;
_cachedStyle = widget.style;
_cachedAlignment = widget.textAlign;
_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;
final segmentBoxes = <List<Rect>>[];
var segmentOffset = 0;
for (final segment in widget.segments) {
final segmentEnd = segmentOffset + segment.length;
// Select the whole timed word, keeping paragraph shaping and wrapping
// intact. All of its text runs share the same vertical movement.
final boxes = highlightedPainter
.getBoxesForSelection(
TextSelection(
baseOffset: segmentOffset,
extentOffset: segmentEnd,
),
boxHeightStyle: BoxHeightStyle.max,
)
.map((box) => box.toRect())
.toList();
boxes.sort((a, b) {
final row = a.top.compareTo(b.top);
return row == 0 ? a.left.compareTo(b.left) : row;
});
segmentBoxes.add(boxes);
segmentOffset = segmentEnd;
}
return Semantics(
label: widget.semanticsLabel,
child: CustomPaint(
size: Size(width, height),
painter: _TimedLyricSweepPainter(
segmentBoxes: segmentBoxes,
starts: widget.starts,
ends: widget.ends,
currentPosition: widget.currentPosition,
repaint: widget.repaint,
pendingPainter: pendingPainter,
highlightedPainter: highlightedPainter,
highlightLift: widget.liftEnabled
? (textScaler.scale(widget.style.fontSize ?? 24) * 0.05)
.clamp(0.0, 2.0)
: 0,
),
),
);
},
);
}
}
class _TimedLyricSweepPainter extends CustomPainter {
final List<List<Rect>> segmentBoxes;
final List<Duration> starts;
final List<Duration> ends;
final Duration Function() currentPosition;
final TextPainter pendingPainter;
final TextPainter highlightedPainter;
final double highlightLift;
_TimedLyricSweepPainter({
required this.segmentBoxes,
required this.starts,
required this.ends,
required this.currentPosition,
required Listenable repaint,
required this.pendingPainter,
required this.highlightedPainter,
required this.highlightLift,
}) : super(repaint: repaint);
@override
void paint(Canvas canvas, Size size) {
final pendingPaths = <double, Path>{};
final completedPaths = <double, Path>{};
final partialBoxes = <(Rect, double, double, double)>[];
final position = currentPosition();
for (var index = 0; index < segmentBoxes.length; index++) {
final timed = index < starts.length && index < ends.length;
final value = timed
? syncedLyricSegmentProgress(
position: position,
start: starts[index],
end: ends[index],
)
: 0.0;
final boxes = segmentBoxes[index];
final width = boxes.fold<double>(0, (sum, box) => sum + box.width);
final lift = highlightLift > 0 && timed
? highlightLift *
syncedLyricSegmentLift(
position: position,
start: starts[index],
end: ends[index],
)
: 0.0;
var consumed = 0.0;
for (final box in boxes) {
if (box.width <= 0) continue;
if (highlightLift > 0) {
pendingPaths.putIfAbsent(lift, Path.new).addRect(box);
}
// Keep the color sweep continuous across wrapping and font fallback,
// independently of the movement shared by the whole word.
final revealWidth = width * value - consumed;
final feather = ((highlightLift > 0 ? width : box.width) * 0.18).clamp(
3.0,
10.0,
);
if (value > 0 && revealWidth >= box.width) {
completedPaths.putIfAbsent(lift, Path.new).addRect(box);
} else if (value > 0 && revealWidth > -feather) {
partialBoxes.add((box, revealWidth / box.width, lift, feather));
}
consumed += box.width;
}
}
// Move both colors together, so a raised highlight never leaves a dim
// duplicate behind. Most words share the resting or completed position.
if (highlightLift > 0) {
for (final entry in pendingPaths.entries) {
_paintLiftedText(canvas, pendingPainter, entry.value, entry.key);
}
} else {
pendingPainter.paint(canvas, Offset.zero);
}
for (final entry in completedPaths.entries) {
_paintLiftedText(canvas, highlightedPainter, entry.value, entry.key);
}
for (final (box, value, lift, feather) in partialBoxes) {
// Feather the leading edge so the highlight flows through each letter
// while the word rises as a single unit.
final boundary = box.left + box.width * value;
final revealRight = (boundary + feather).clamp(box.left, box.right);
final revealRect = Rect.fromLTRB(
box.left,
box.top,
revealRight,
box.bottom,
);
canvas.save();
canvas.translate(0, -lift);
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(boundary, box.top, boundary + feather, box.bottom),
);
canvas.drawRect(revealRect, mask);
canvas.restore();
canvas.restore();
}
}
void _paintLiftedText(
Canvas canvas,
TextPainter painter,
Path clip,
double lift,
) {
canvas.save();
canvas.translate(0, -lift);
canvas.clipPath(clip, doAntiAlias: false);
painter.paint(canvas, Offset.zero);
canvas.restore();
}
@override
bool shouldRepaint(covariant _TimedLyricSweepPainter oldDelegate) {
return oldDelegate.segmentBoxes != segmentBoxes ||
oldDelegate.starts != starts ||
oldDelegate.ends != ends ||
oldDelegate.currentPosition != currentPosition ||
oldDelegate.highlightLift != highlightLift ||
oldDelegate.pendingPainter != pendingPainter ||
oldDelegate.highlightedPainter != highlightedPainter;
}
}