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:
zarzet committed 2026-10-18 22:40:10 +07:00
1 parent 237690cd89
commit 77c8639df2
3 files changed
+752 -747

No files matched your search

+3 -747
View File
@@ -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;
}
}
+132
View File
@@ -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,
);
}
+617
View File
@@ -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;
}
}