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