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