mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-22 07:18:38 +02:00
refactor(lyrics): split player_lyrics.dart into line-style and word-sweep parts
The word-sweep painter and the line style helpers are self-contained, so they move into part files of the same library. No behavior change.
This commit is contained in:
1 parent
237690cd89
commit
77c8639df2
3 files changed
+752
-747
No files matched your search
@@ -23,6 +23,9 @@ import 'package:spotiflac_android/widgets/lyric_gap_indicator.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_player_slider.dart';
|
||||
import 'package:spotiflac_android/widgets/lyrics_text_scale.dart';
|
||||
|
||||
part 'player_lyrics_line_style.dart';
|
||||
part 'player_lyrics_word_sweep.dart';
|
||||
|
||||
const _mornyeLyricFontSize = 34.0;
|
||||
const mornyeLyricsContentInset = 28.0 + MornyePlayerSlider.horizontalInset;
|
||||
|
||||
@@ -1045,750 +1048,3 @@ class LyricsCredits extends StatelessWidget {
|
||||
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;
|
||||
List<String>? _cachedSegments;
|
||||
List<List<Rect>>? _cachedSegmentBoxes;
|
||||
double? _cachedMinWidth;
|
||||
double? _cachedMaxWidth;
|
||||
|
||||
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();
|
||||
_cachedSegmentBoxes = null;
|
||||
_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;
|
||||
}
|
||||
|
||||
List<List<Rect>> _segmentBoxes(double minWidth, double maxWidth) {
|
||||
if (_cachedSegmentBoxes != null &&
|
||||
_cachedMinWidth == minWidth &&
|
||||
_cachedMaxWidth == maxWidth &&
|
||||
listEquals(_cachedSegments, widget.segments)) {
|
||||
return _cachedSegmentBoxes!;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
// Snapshot contents: callers can rebuild equal lists or mutate boundaries
|
||||
// without changing the joined text. Timing is supplied separately below.
|
||||
_cachedSegments = List<String>.of(widget.segments);
|
||||
_cachedMinWidth = minWidth;
|
||||
_cachedMaxWidth = maxWidth;
|
||||
return _cachedSegmentBoxes = segmentBoxes;
|
||||
}
|
||||
|
||||
@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 minWidth = constraints.hasBoundedWidth ? maxWidth : 0.0;
|
||||
final pendingPainter = _pendingPainter!
|
||||
..layout(minWidth: minWidth, maxWidth: maxWidth);
|
||||
final highlightedPainter = _highlightedPainter!
|
||||
..layout(minWidth: minWidth, maxWidth: maxWidth);
|
||||
final width = constraints.hasBoundedWidth
|
||||
? constraints.maxWidth
|
||||
: pendingPainter.width;
|
||||
final height = pendingPainter.height;
|
||||
final segmentBoxes = _segmentBoxes(minWidth, maxWidth);
|
||||
|
||||
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,
|
||||
glowSigma: widget.liftEnabled
|
||||
? textScaler.scale(widget.style.fontSize ?? 24) * 0.2
|
||||
: 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;
|
||||
|
||||
/// Blur radius of the glow behind held words; 0 disables it.
|
||||
final double glowSigma;
|
||||
|
||||
_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,
|
||||
required this.glowSigma,
|
||||
}) : 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 glows = <(Path, 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;
|
||||
final glow = glowSigma > 0 && timed && value > 0
|
||||
? syncedLyricSegmentGlow(
|
||||
position: position,
|
||||
start: starts[index],
|
||||
end: ends[index],
|
||||
)
|
||||
: 0.0;
|
||||
final glowPath = glow > 0.01 ? Path() : null;
|
||||
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;
|
||||
// A wide, soft leading edge reads as a smooth flow, not a wipe.
|
||||
final feather = ((highlightLift > 0 ? width : box.width) * 0.3).clamp(
|
||||
4.0,
|
||||
16.0,
|
||||
);
|
||||
// Only the sung part glows, so unsung letters never light up early.
|
||||
if (glowPath != null && revealWidth > 0) {
|
||||
glowPath.addRect(
|
||||
Rect.fromLTRB(
|
||||
box.left,
|
||||
box.top,
|
||||
box.left + revealWidth.clamp(0.0, box.width),
|
||||
box.bottom,
|
||||
),
|
||||
);
|
||||
}
|
||||
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;
|
||||
}
|
||||
if (glowPath != null) glows.add((glowPath, lift, glow));
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// A blurred copy of the sung letters behind them. Usually at most one
|
||||
// held word glows, so the extra layer stays cheap.
|
||||
for (final (clip, lift, glow) in glows) {
|
||||
final bounds = clip.getBounds().inflate(glowSigma * 3);
|
||||
canvas.save();
|
||||
canvas.translate(0, -lift);
|
||||
canvas.saveLayer(
|
||||
bounds,
|
||||
Paint()
|
||||
..color = Color.fromRGBO(0, 0, 0, 0.55 * glow)
|
||||
..imageFilter = ImageFilter.blur(
|
||||
sigmaX: glowSigma,
|
||||
sigmaY: glowSigma,
|
||||
),
|
||||
);
|
||||
canvas.clipPath(clip, doAntiAlias: false);
|
||||
highlightedPainter.paint(canvas, Offset.zero);
|
||||
canvas.restore();
|
||||
canvas.restore();
|
||||
}
|
||||
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.glowSigma != glowSigma ||
|
||||
oldDelegate.pendingPainter != pendingPainter ||
|
||||
oldDelegate.highlightedPainter != highlightedPainter;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
part of 'player_lyrics.dart';
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,617 @@
|
||||
part of 'player_lyrics.dart';
|
||||
|
||||
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;
|
||||
List<String>? _cachedSegments;
|
||||
List<List<Rect>>? _cachedSegmentBoxes;
|
||||
double? _cachedMinWidth;
|
||||
double? _cachedMaxWidth;
|
||||
|
||||
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();
|
||||
_cachedSegmentBoxes = null;
|
||||
_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;
|
||||
}
|
||||
|
||||
List<List<Rect>> _segmentBoxes(double minWidth, double maxWidth) {
|
||||
if (_cachedSegmentBoxes != null &&
|
||||
_cachedMinWidth == minWidth &&
|
||||
_cachedMaxWidth == maxWidth &&
|
||||
listEquals(_cachedSegments, widget.segments)) {
|
||||
return _cachedSegmentBoxes!;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
// Snapshot contents: callers can rebuild equal lists or mutate boundaries
|
||||
// without changing the joined text. Timing is supplied separately below.
|
||||
_cachedSegments = List<String>.of(widget.segments);
|
||||
_cachedMinWidth = minWidth;
|
||||
_cachedMaxWidth = maxWidth;
|
||||
return _cachedSegmentBoxes = segmentBoxes;
|
||||
}
|
||||
|
||||
@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 minWidth = constraints.hasBoundedWidth ? maxWidth : 0.0;
|
||||
final pendingPainter = _pendingPainter!
|
||||
..layout(minWidth: minWidth, maxWidth: maxWidth);
|
||||
final highlightedPainter = _highlightedPainter!
|
||||
..layout(minWidth: minWidth, maxWidth: maxWidth);
|
||||
final width = constraints.hasBoundedWidth
|
||||
? constraints.maxWidth
|
||||
: pendingPainter.width;
|
||||
final height = pendingPainter.height;
|
||||
final segmentBoxes = _segmentBoxes(minWidth, maxWidth);
|
||||
|
||||
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,
|
||||
glowSigma: widget.liftEnabled
|
||||
? textScaler.scale(widget.style.fontSize ?? 24) * 0.2
|
||||
: 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;
|
||||
|
||||
/// Blur radius of the glow behind held words; 0 disables it.
|
||||
final double glowSigma;
|
||||
|
||||
_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,
|
||||
required this.glowSigma,
|
||||
}) : 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 glows = <(Path, 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;
|
||||
final glow = glowSigma > 0 && timed && value > 0
|
||||
? syncedLyricSegmentGlow(
|
||||
position: position,
|
||||
start: starts[index],
|
||||
end: ends[index],
|
||||
)
|
||||
: 0.0;
|
||||
final glowPath = glow > 0.01 ? Path() : null;
|
||||
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;
|
||||
// A wide, soft leading edge reads as a smooth flow, not a wipe.
|
||||
final feather = ((highlightLift > 0 ? width : box.width) * 0.3).clamp(
|
||||
4.0,
|
||||
16.0,
|
||||
);
|
||||
// Only the sung part glows, so unsung letters never light up early.
|
||||
if (glowPath != null && revealWidth > 0) {
|
||||
glowPath.addRect(
|
||||
Rect.fromLTRB(
|
||||
box.left,
|
||||
box.top,
|
||||
box.left + revealWidth.clamp(0.0, box.width),
|
||||
box.bottom,
|
||||
),
|
||||
);
|
||||
}
|
||||
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;
|
||||
}
|
||||
if (glowPath != null) glows.add((glowPath, lift, glow));
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// A blurred copy of the sung letters behind them. Usually at most one
|
||||
// held word glows, so the extra layer stays cheap.
|
||||
for (final (clip, lift, glow) in glows) {
|
||||
final bounds = clip.getBounds().inflate(glowSigma * 3);
|
||||
canvas.save();
|
||||
canvas.translate(0, -lift);
|
||||
canvas.saveLayer(
|
||||
bounds,
|
||||
Paint()
|
||||
..color = Color.fromRGBO(0, 0, 0, 0.55 * glow)
|
||||
..imageFilter = ImageFilter.blur(
|
||||
sigmaX: glowSigma,
|
||||
sigmaY: glowSigma,
|
||||
),
|
||||
);
|
||||
canvas.clipPath(clip, doAntiAlias: false);
|
||||
highlightedPainter.paint(canvas, Offset.zero);
|
||||
canvas.restore();
|
||||
canvas.restore();
|
||||
}
|
||||
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.glowSigma != glowSigma ||
|
||||
oldDelegate.pendingPainter != pendingPainter ||
|
||||
oldDelegate.highlightedPainter != highlightedPainter;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user