mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 12:52:13 +02:00
fix: sync lyric romanization and clear stale selection menus
Preserve romanization and translation metadata through Rust exports and cache, match rounded line times, and retain word endings. Highlight romanization with the main lyric clock in Material and Mornye. Clear selection overlay ownership safely when collection screens are disposed. Add parser, rendering, cache and overlay lifecycle regression coverage.
This commit is contained in:
@@ -2324,7 +2324,13 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
|
||||
style: style,
|
||||
);
|
||||
painter.layout(maxWidth: width);
|
||||
extents.add(painter.height + 32);
|
||||
var height = painter.height + 32;
|
||||
for (final (text, style, _) in _lyricSupplements(context, line)) {
|
||||
painter.text = TextSpan(text: text, style: style);
|
||||
painter.layout(maxWidth: width);
|
||||
height += 6 + painter.height;
|
||||
}
|
||||
extents.add(height);
|
||||
}
|
||||
_lineExtents = extents;
|
||||
painter.dispose();
|
||||
@@ -2470,8 +2476,11 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
|
||||
? '\u00b7\u00b7\u00b7'
|
||||
: line.text;
|
||||
|
||||
final timed =
|
||||
isActive &&
|
||||
(line.hasWordTiming || line.romanizationWords.isNotEmpty);
|
||||
Widget content;
|
||||
if (isActive && line.hasWordTiming) {
|
||||
if (timed) {
|
||||
content = _WordHighlightedLyricLine(
|
||||
line: line,
|
||||
colorScheme: widget.colorScheme,
|
||||
@@ -2495,6 +2504,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
|
||||
color: color,
|
||||
),
|
||||
);
|
||||
content = _withLyricSupplements(context, line, content, color);
|
||||
}
|
||||
content = AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 320),
|
||||
@@ -2502,7 +2512,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
|
||||
switchInCurve: Curves.easeOutCubic,
|
||||
switchOutCurve: Curves.easeInCubic,
|
||||
child: KeyedSubtree(
|
||||
key: ValueKey(isActive && line.hasWordTiming),
|
||||
key: ValueKey(timed),
|
||||
child: mornye
|
||||
? SizedBox(width: double.infinity, child: content)
|
||||
: content,
|
||||
@@ -2560,6 +2570,61 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
|
||||
}
|
||||
}
|
||||
|
||||
Iterable<(String, TextStyle, List<LyricWord>)> _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 ? 15 : 17)
|
||||
: (translation ? 14 : 16),
|
||||
height: 1.35,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
translation ? const <LyricWord>[] : line.romanizationWords,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _withLyricSupplements(
|
||||
BuildContext context,
|
||||
LyricLine line,
|
||||
Widget primary,
|
||||
Color color, {
|
||||
Widget Function(String, List<LyricWord>, TextStyle)? timedText,
|
||||
}) {
|
||||
if (line.romanization == null && line.translation == null) return primary;
|
||||
return Column(
|
||||
crossAxisAlignment: context.isMornye
|
||||
? CrossAxisAlignment.start
|
||||
: CrossAxisAlignment.center,
|
||||
children: [
|
||||
primary,
|
||||
for (final (text, style, words) in _lyricSupplements(context, line)) ...[
|
||||
const SizedBox(height: 6),
|
||||
if (words.isNotEmpty && timedText != null)
|
||||
timedText(text, words, style)
|
||||
else
|
||||
Text(
|
||||
text,
|
||||
textAlign: context.isMornye ? TextAlign.start : TextAlign.center,
|
||||
style: style.copyWith(
|
||||
color: color.withValues(alpha: color.a * 0.8),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class _WordHighlightedLyricLine extends ConsumerStatefulWidget {
|
||||
final LyricLine line;
|
||||
final ColorScheme colorScheme;
|
||||
@@ -2657,10 +2722,12 @@ class _WordHighlightedLyricLineState
|
||||
if (!mounted) return;
|
||||
final predicted = _currentPosition();
|
||||
_anchorAt(
|
||||
reconcileSyncedLyricsPosition(
|
||||
predictedPosition: predicted,
|
||||
reportedPosition: position,
|
||||
),
|
||||
_shouldAnimate
|
||||
? reconcileSyncedLyricsPosition(
|
||||
predictedPosition: predicted,
|
||||
reportedPosition: position,
|
||||
)
|
||||
: position,
|
||||
);
|
||||
setState(() {});
|
||||
}
|
||||
@@ -2670,7 +2737,7 @@ class _WordHighlightedLyricLineState
|
||||
final position = _currentPosition();
|
||||
if (playing != null) _playing = playing;
|
||||
if (loading != null) _loading = loading;
|
||||
_anchorAt(position);
|
||||
_anchorAt(_shouldAnimate ? position : ref.read(playbackPositionProvider));
|
||||
_syncAnimationClock();
|
||||
setState(() {});
|
||||
}
|
||||
@@ -2685,10 +2752,12 @@ class _WordHighlightedLyricLineState
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
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;
|
||||
@@ -2712,6 +2781,32 @@ class _WordHighlightedLyricLineState
|
||||
}
|
||||
|
||||
Widget _buildHighlightedLine(BuildContext context) {
|
||||
final style =
|
||||
(Theme.of(context).textTheme.headlineSmall ?? const TextStyle())
|
||||
.copyWith(
|
||||
fontSize: context.isMornye ? 28 : null,
|
||||
height: context.isMornye ? 1.3 : 1.4,
|
||||
fontWeight: FontWeight.bold,
|
||||
);
|
||||
final primary = widget.line.hasWordTiming
|
||||
? _buildTimedText(widget.line.text, widget.line.words, style)
|
||||
: Text(
|
||||
widget.line.text,
|
||||
textAlign: context.isMornye ? TextAlign.start : TextAlign.center,
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTimedText(String text, List<LyricWord> words, TextStyle style) {
|
||||
final highlightedColor = widget.colorScheme.onSurface;
|
||||
final mornye = context.isMornye;
|
||||
final pendingColor = mornye
|
||||
@@ -2720,11 +2815,11 @@ class _WordHighlightedLyricLineState
|
||||
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];
|
||||
for (var index = 0; index < words.length; index++) {
|
||||
final word = words[index];
|
||||
segments.add(word.text);
|
||||
starts.add(word.time);
|
||||
ends.add(_segmentEnd(index));
|
||||
ends.add(_segmentEnd(words, index));
|
||||
}
|
||||
|
||||
return _SweepingTimedLyricText(
|
||||
@@ -2733,16 +2828,11 @@ class _WordHighlightedLyricLineState
|
||||
ends: ends,
|
||||
currentPosition: _currentPosition,
|
||||
repaint: _animationClock,
|
||||
style: (Theme.of(context).textTheme.headlineSmall ?? const TextStyle())
|
||||
.copyWith(
|
||||
fontSize: mornye ? 28 : null,
|
||||
height: mornye ? 1.3 : 1.4,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
style: style,
|
||||
textAlign: mornye ? TextAlign.start : TextAlign.center,
|
||||
pendingColor: pendingColor,
|
||||
highlightedColor: highlightedColor,
|
||||
semanticsLabel: widget.line.text,
|
||||
semanticsLabel: text,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ final RegExp _lrcDisplayTimestampPattern = RegExp(
|
||||
r'^\[\d{1,3}:\d{1,2}(?:[.:]\d{1,3})?\]',
|
||||
);
|
||||
final RegExp _lrcDisplayMetadataPattern = RegExp(
|
||||
r'^\[[a-zA-Z][a-zA-Z0-9_]*:.*\]$',
|
||||
r'^\[[a-zA-Z][a-zA-Z0-9_-]*:.*\]$',
|
||||
);
|
||||
final RegExp _lrcDisplayInlineTimestampPattern = RegExp(
|
||||
r'<\d{1,3}:\d{1,2}(?:[.:]\d{1,3})?>',
|
||||
|
||||
+182
-27
@@ -1,10 +1,13 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:xml/xml.dart';
|
||||
|
||||
class LyricWord {
|
||||
final Duration time;
|
||||
final Duration? end;
|
||||
final String text;
|
||||
|
||||
const LyricWord({required this.time, required this.text});
|
||||
const LyricWord({required this.time, this.end, required this.text});
|
||||
}
|
||||
|
||||
class LyricLine {
|
||||
@@ -12,12 +15,18 @@ class LyricLine {
|
||||
final Duration? end;
|
||||
final String text;
|
||||
final List<LyricWord> words;
|
||||
final String? romanization;
|
||||
final List<LyricWord> romanizationWords;
|
||||
final String? translation;
|
||||
|
||||
const LyricLine({
|
||||
required this.time,
|
||||
this.end,
|
||||
required this.text,
|
||||
this.words = const [],
|
||||
this.romanization,
|
||||
this.romanizationWords = const [],
|
||||
this.translation,
|
||||
});
|
||||
|
||||
bool get hasWordTiming => words.isNotEmpty;
|
||||
@@ -61,7 +70,11 @@ class LyricsParser {
|
||||
|
||||
// ID tags such as [ti:..], [ar:..], [offset:..].
|
||||
static final RegExp _idTag = RegExp(
|
||||
r'^\[(ti|ar|al|by|offset|length|re|ve|tool|au|la|encoder):.*\]$',
|
||||
r'^\[(ti|ar|al|by|offset|length|re|ve|tool|au|la|encoder|instrumental|x-[a-z0-9_-]+):.*\]$',
|
||||
caseSensitive: false,
|
||||
);
|
||||
static final RegExp _supplementTag = RegExp(
|
||||
r'^\[x-(romaji-words|romaji|translation):(\d+):([^\]]*)\]$',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
@@ -102,6 +115,9 @@ class LyricsParser {
|
||||
final rawLines = text.split(RegExp(r'\r\n|\r|\n'));
|
||||
final parsed = <LyricLine>[];
|
||||
final plainBuffer = <String>[];
|
||||
final romanization = <int, String>{};
|
||||
final romanizationWords = <int, List<LyricWord>>{};
|
||||
final translation = <int, String>{};
|
||||
var sawTimestamp = false;
|
||||
var sawWordTiming = false;
|
||||
var offsetMs = 0;
|
||||
@@ -110,6 +126,29 @@ class LyricsParser {
|
||||
final line = rawLine.trimRight();
|
||||
if (line.trim().isEmpty) continue;
|
||||
|
||||
final supplement = _supplementTag.firstMatch(line.trim());
|
||||
if (supplement != null) {
|
||||
final time = int.tryParse(supplement.group(2)!);
|
||||
try {
|
||||
final value = utf8.decode(base64.decode(supplement.group(3)!)).trim();
|
||||
if (time != null && value.isNotEmpty) {
|
||||
final kind = supplement.group(1)!.toLowerCase();
|
||||
if (kind == 'romaji-words') {
|
||||
final words = _parseRomanizationWords(value);
|
||||
if (words.isNotEmpty) {
|
||||
romanizationWords.putIfAbsent(time, () => words);
|
||||
}
|
||||
} else {
|
||||
final target = kind == 'romaji' ? romanization : translation;
|
||||
target.putIfAbsent(time, () => value);
|
||||
}
|
||||
}
|
||||
} on FormatException {
|
||||
// Optional corrupt metadata must not hide the original lyrics.
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Capture [offset:] for timing correction, drop other ID tags.
|
||||
final idMatch = _idTag.firstMatch(line.trim());
|
||||
if (idMatch != null) {
|
||||
@@ -147,7 +186,14 @@ class LyricsParser {
|
||||
for (final tm in timeMatches) {
|
||||
final d = _toDuration(tm.group(1), tm.group(2), tm.group(3));
|
||||
if (d == null) continue;
|
||||
parsed.add(LyricLine(time: d, text: cleanContent, words: words));
|
||||
parsed.add(
|
||||
LyricLine(
|
||||
time: d,
|
||||
end: words.lastOrNull?.end,
|
||||
text: cleanContent,
|
||||
words: words,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,34 +203,44 @@ class LyricsParser {
|
||||
synced: false,
|
||||
wordSynced: false,
|
||||
lines: const [],
|
||||
plainText: rawLines
|
||||
.map((l) => l.trim())
|
||||
.where((l) => l.isNotEmpty && _idTag.firstMatch(l) == null)
|
||||
.join('\n'),
|
||||
plainText: plainBuffer.where((l) => l.isNotEmpty).join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
parsed.sort((a, b) => a.time.compareTo(b.time));
|
||||
final alignedRomanization = _alignSupplements(parsed, romanization);
|
||||
final alignedRomanizationWords = _alignSupplements(
|
||||
parsed,
|
||||
romanizationWords,
|
||||
);
|
||||
final alignedTranslation = _alignSupplements(parsed, translation);
|
||||
|
||||
final adjusted = offsetMs == 0
|
||||
final adjusted =
|
||||
offsetMs == 0 &&
|
||||
alignedRomanization.isEmpty &&
|
||||
alignedTranslation.isEmpty
|
||||
? parsed
|
||||
: parsed
|
||||
.map(
|
||||
(l) => LyricLine(
|
||||
time: _shift(l.time, offsetMs),
|
||||
end: l.end,
|
||||
text: l.text,
|
||||
words: l.words
|
||||
.map(
|
||||
(w) => LyricWord(
|
||||
time: _shift(w.time, offsetMs),
|
||||
text: w.text,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
: parsed.map((l) {
|
||||
final romanization = alignedRomanization[l.time.inMilliseconds];
|
||||
final words =
|
||||
alignedRomanizationWords[l.time.inMilliseconds] ??
|
||||
const <LyricWord>[];
|
||||
// Keep readable text when optional timings are incomplete or
|
||||
// belong to a different revision of the transliteration.
|
||||
final validWords =
|
||||
words.map((word) => word.text).join() == romanization
|
||||
? words
|
||||
: const <LyricWord>[];
|
||||
return LyricLine(
|
||||
time: _shift(l.time, offsetMs),
|
||||
end: l.end == null ? null : _shift(l.end!, offsetMs),
|
||||
text: l.text,
|
||||
romanization: romanization,
|
||||
romanizationWords: _shiftWords(validWords, offsetMs),
|
||||
translation: alignedTranslation[l.time.inMilliseconds],
|
||||
words: _shiftWords(l.words, offsetMs),
|
||||
);
|
||||
}).toList();
|
||||
|
||||
return ParsedLyrics(
|
||||
synced: true,
|
||||
@@ -194,6 +250,88 @@ class LyricsParser {
|
||||
);
|
||||
}
|
||||
|
||||
/// Older files may have centisecond line times alongside millisecond
|
||||
/// supplement times. Resolve each supplement to one nearest line, keeping
|
||||
/// exact matches when multiple metadata entries compete for that line.
|
||||
static Map<int, T> _alignSupplements<T>(
|
||||
List<LyricLine> lines,
|
||||
Map<int, T> supplements,
|
||||
) {
|
||||
final matched = <int, (int, T)>{};
|
||||
for (final entry in supplements.entries) {
|
||||
var lo = 0;
|
||||
var hi = lines.length;
|
||||
while (lo < hi) {
|
||||
final mid = (lo + hi) >> 1;
|
||||
if (lines[mid].time.inMilliseconds < entry.key) {
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
int? nearest;
|
||||
var distance = 11;
|
||||
for (final index in [lo - 1, lo]) {
|
||||
if (index < 0 || index >= lines.length) continue;
|
||||
final time = lines[index].time.inMilliseconds;
|
||||
final delta = (time - entry.key).abs();
|
||||
if (delta < distance) {
|
||||
nearest = time;
|
||||
distance = delta;
|
||||
}
|
||||
}
|
||||
if (nearest == null) continue;
|
||||
final previous = matched[nearest];
|
||||
if (previous == null || distance < previous.$1) {
|
||||
matched[nearest] = (distance, entry.value);
|
||||
}
|
||||
}
|
||||
return matched.map((time, match) => MapEntry(time, match.$2));
|
||||
}
|
||||
|
||||
static List<LyricWord> _parseRomanizationWords(String raw) {
|
||||
final value = jsonDecode(raw);
|
||||
if (value is! List) return const [];
|
||||
final words = <LyricWord>[];
|
||||
for (final item in value) {
|
||||
if (item case {
|
||||
'text': final String text,
|
||||
'startTimeMs': final int start,
|
||||
'endTimeMs': final int end,
|
||||
}) {
|
||||
if (text.trim().isEmpty ||
|
||||
start < 0 ||
|
||||
end < start ||
|
||||
(words.isNotEmpty && words.last.time.inMilliseconds > start)) {
|
||||
return const [];
|
||||
}
|
||||
words.add(
|
||||
LyricWord(
|
||||
time: Duration(milliseconds: start),
|
||||
end: Duration(milliseconds: end),
|
||||
text: text,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
return words;
|
||||
}
|
||||
|
||||
static List<LyricWord> _shiftWords(List<LyricWord> words, int offsetMs) {
|
||||
if (offsetMs == 0 || words.isEmpty) return words;
|
||||
return words
|
||||
.map(
|
||||
(word) => LyricWord(
|
||||
time: _shift(word.time, offsetMs),
|
||||
end: word.end == null ? null : _shift(word.end!, offsetMs),
|
||||
text: word.text,
|
||||
),
|
||||
)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
static Duration _shift(Duration d, int offsetMs) {
|
||||
// LRC offset: positive value shifts lyrics earlier.
|
||||
final ms = d.inMilliseconds - offsetMs;
|
||||
@@ -214,7 +352,17 @@ class LyricsParser {
|
||||
? matches[i + 1].start
|
||||
: content.length;
|
||||
final word = content.substring(start, end);
|
||||
if (word.trim().isEmpty) continue;
|
||||
if (word.trim().isEmpty) {
|
||||
final previous = words.lastOrNull;
|
||||
if (previous != null && previous.end == null && d >= previous.time) {
|
||||
words[words.length - 1] = LyricWord(
|
||||
time: previous.time,
|
||||
end: d,
|
||||
text: previous.text,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
words.add(LyricWord(time: d, text: word));
|
||||
}
|
||||
return words;
|
||||
@@ -242,7 +390,14 @@ class LyricsParser {
|
||||
final sBegin = _parseClock(span.getAttribute('begin'));
|
||||
final spanText = span.innerText;
|
||||
if (sBegin != null && spanText.trim().isNotEmpty) {
|
||||
words.add(LyricWord(time: sBegin, text: '$spanText '));
|
||||
final sEnd = _parseClock(span.getAttribute('end'));
|
||||
words.add(
|
||||
LyricWord(
|
||||
time: sBegin,
|
||||
end: sEnd != null && sEnd >= sBegin ? sEnd : null,
|
||||
text: '$spanText ',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:spotiflac_android/theme/app_tokens.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
|
||||
@@ -29,21 +30,36 @@ class _SelectionOverlayHostState extends State<SelectionOverlayHost> {
|
||||
_owner == owner && _builder != null;
|
||||
|
||||
void show(SelectionOverlayController owner, WidgetBuilder builder) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_update(() {
|
||||
_owner = owner;
|
||||
_builder = builder;
|
||||
});
|
||||
}
|
||||
|
||||
void hide(SelectionOverlayController owner) {
|
||||
if (!mounted || _owner != owner) return;
|
||||
setState(() {
|
||||
if (_owner != owner) return;
|
||||
_update(() {
|
||||
_owner = null;
|
||||
_builder = null;
|
||||
});
|
||||
}
|
||||
|
||||
/// A screen can remove its bar from dispose while Flutter has locked the
|
||||
/// tree. Clear ownership immediately, then rebuild after that frame so the
|
||||
/// bar cannot outlive the screen or retain its disposed callbacks.
|
||||
void _update(VoidCallback change) {
|
||||
if (!mounted) return;
|
||||
if (SchedulerBinding.instance.schedulerPhase !=
|
||||
SchedulerPhase.persistentCallbacks) {
|
||||
setState(change);
|
||||
return;
|
||||
}
|
||||
change();
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final owner = _owner;
|
||||
|
||||
Reference in New Issue
Block a user