mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 05:12:00 +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:
@@ -0,0 +1,172 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/utils/lyrics_parser.dart';
|
||||
|
||||
String _tag(String kind, int time, String text) =>
|
||||
'[x-$kind:$time:${base64.encode(utf8.encode(text))}]';
|
||||
|
||||
void main() {
|
||||
test('retains all three texts, word ends and millisecond timing', () {
|
||||
final lyrics = LyricsParser.parse('''
|
||||
${_tag('romaji', 1009, 'Romanized')}
|
||||
${_tag('translation', 1009, 'English')}
|
||||
[00:01.009]<00:01.009>First <00:01.307><00:02.003>second<00:02.497>
|
||||
''');
|
||||
final line = lyrics.lines.single;
|
||||
expect(lyrics.wordSynced, isTrue);
|
||||
expect(line.text, 'First second');
|
||||
expect(line.romanization, 'Romanized');
|
||||
expect(line.translation, 'English');
|
||||
expect(line.time.inMilliseconds, 1009);
|
||||
expect(line.end?.inMilliseconds, 2497);
|
||||
expect(line.words.map((word) => word.end?.inMilliseconds), [1307, 2497]);
|
||||
expect(lyrics.plainText, 'First second');
|
||||
});
|
||||
|
||||
test('matches legacy and rounded eLRC tags within ten milliseconds', () {
|
||||
final lyrics = LyricsParser.parse('''
|
||||
${_tag('romaji', 1009, 'Rounded up')}
|
||||
${_tag('translation', 2009, 'Truncated')}
|
||||
${_tag('romaji', 2990, 'Boundary')}
|
||||
${_tag('translation', 4011, 'Too far')}
|
||||
[00:01.01]A
|
||||
[00:02.00]B
|
||||
[00:03.00]C
|
||||
[00:04.00]D
|
||||
''');
|
||||
expect(lyrics.lines[0].romanization, 'Rounded up');
|
||||
expect(lyrics.lines[1].translation, 'Truncated');
|
||||
expect(lyrics.lines[2].romanization, 'Boundary');
|
||||
expect(lyrics.lines[3].translation, isNull);
|
||||
});
|
||||
|
||||
test(
|
||||
'romanization timing aligns with rounded lines and applies the offset',
|
||||
() {
|
||||
final words = [
|
||||
{'text': 'Ro', 'startTimeMs': 1009, 'endTimeMs': 1107},
|
||||
{'text': 'ma ', 'startTimeMs': 1107, 'endTimeMs': 1307},
|
||||
{'text': 'nized', 'startTimeMs': 2003, 'endTimeMs': 2497},
|
||||
];
|
||||
final line = LyricsParser.parse('''
|
||||
[offset:109]
|
||||
${_tag('romaji-words', 1009, jsonEncode(words))}
|
||||
${_tag('romaji', 1009, 'Roma nized')}
|
||||
[00:01.01]Original
|
||||
''').lines.single;
|
||||
expect(line.romanization, 'Roma nized');
|
||||
expect(line.romanizationWords.map((word) => word.text), [
|
||||
'Ro',
|
||||
'ma ',
|
||||
'nized',
|
||||
]);
|
||||
expect(line.romanizationWords.first.time.inMilliseconds, 900);
|
||||
expect(line.romanizationWords[1].end?.inMilliseconds, 1198);
|
||||
expect(line.romanizationWords.last.time.inMilliseconds, 1894);
|
||||
expect(line.romanizationWords.last.end?.inMilliseconds, 2388);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'missing or corrupt romanization timings preserve plain romanization',
|
||||
() {
|
||||
for (final timing in [
|
||||
'bad json',
|
||||
'{}',
|
||||
'[null]',
|
||||
'[{"text":"Other","startTimeMs":1000,"endTimeMs":1300}]',
|
||||
'[{"text":"Romanized","startTimeMs":1000,"endTimeMs":900}]',
|
||||
]) {
|
||||
final line = LyricsParser.parse('''
|
||||
${_tag('romaji', 1000, 'Romanized')}
|
||||
${_tag('romaji-words', 1000, timing)}
|
||||
[00:01.00]Original
|
||||
''').lines.single;
|
||||
expect(line.romanization, 'Romanized');
|
||||
expect(line.romanizationWords, isEmpty);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test('exact matches win collisions and a tie goes to one earlier line', () {
|
||||
final lyrics = LyricsParser.parse('''
|
||||
${_tag('romaji', 1009, 'Near')}
|
||||
${_tag('romaji', 1010, 'Exact')}
|
||||
${_tag('romaji', 1001, 'Further')}
|
||||
${_tag('translation', 2005, 'Tie')}
|
||||
[00:01.010]A
|
||||
[00:02.000]B
|
||||
[00:02.010]C
|
||||
''');
|
||||
expect(lyrics.lines[0].romanization, 'Exact');
|
||||
expect(lyrics.lines[1].translation, 'Tie');
|
||||
expect(lyrics.lines[2].translation, isNull);
|
||||
});
|
||||
|
||||
test('offset moves word ends and line end after supplement matching', () {
|
||||
final lyrics = LyricsParser.parse('''
|
||||
[offset:109]
|
||||
${_tag('romaji', 1009, 'Romanized')}
|
||||
[00:01.009]<00:01.009>Word<00:01.307>
|
||||
''');
|
||||
final line = lyrics.lines.single;
|
||||
expect(line.romanization, 'Romanized');
|
||||
expect(line.time.inMilliseconds, 900);
|
||||
expect(line.end?.inMilliseconds, 1198);
|
||||
expect(line.words.single.time.inMilliseconds, 900);
|
||||
expect(line.words.single.end?.inMilliseconds, 1198);
|
||||
});
|
||||
|
||||
test('invalid optional tags do not appear or hide the original text', () {
|
||||
const tags = '''
|
||||
[x-romaji:1000:%%%]
|
||||
[x-romaji:1000:/w==]
|
||||
[x-translation:bad:YQ==]
|
||||
[x-translation:1000:]
|
||||
''';
|
||||
expect(LyricsParser.parse(tags).isEmpty, isTrue);
|
||||
final lyrics = LyricsParser.parse('$tags[00:01.00]Original');
|
||||
expect(lyrics.lines.single.text, 'Original');
|
||||
expect(lyrics.lines.single.romanization, isNull);
|
||||
expect(lyrics.lines.single.translation, isNull);
|
||||
expect(lyrics.plainText, 'Original');
|
||||
expect(LyricsParser.parse('${tags}Plain text').plainText, 'Plain text');
|
||||
});
|
||||
|
||||
test('supplement text safely retains Unicode and embedded newlines', () {
|
||||
const text = '日本語 ]\nSecond line';
|
||||
final lyrics = LyricsParser.parse(
|
||||
'${_tag('translation', 1000, text)}\n[00:01.00]Original',
|
||||
);
|
||||
expect(lyrics.lines.single.translation, text);
|
||||
});
|
||||
|
||||
test('start-only eLRC retains its existing fallback timing', () {
|
||||
final line = LyricsParser.parse(
|
||||
'[00:01.00]<00:01.00>First <00:02.00>second',
|
||||
).lines.single;
|
||||
expect(line.words.map((word) => word.end), [null, null]);
|
||||
expect(line.end, isNull);
|
||||
});
|
||||
|
||||
test('empty tags retain the first valid end and reject backward ends', () {
|
||||
final line = LyricsParser.parse(
|
||||
'[00:01.00]<00:01.00>First <00:00.90><00:01.30><00:01.40>'
|
||||
'<00:02.00>second<00:02.00>',
|
||||
).lines.single;
|
||||
expect(line.words.map((word) => word.end?.inMilliseconds), [1300, 2000]);
|
||||
});
|
||||
|
||||
test('TTML span ends survive independently of the paragraph end', () {
|
||||
final line = LyricsParser.parse('''
|
||||
<tt xmlns="http://www.w3.org/ns/ttml"><body><div>
|
||||
<p begin="00:01.009" end="00:05.000">
|
||||
<span begin="00:01.009" end="00:01.307">First</span>
|
||||
<span begin="00:02.003" end="00:02.497">second</span>
|
||||
</p></div></body></tt>
|
||||
''').lines.single;
|
||||
expect(line.end?.inMilliseconds, 5000);
|
||||
expect(line.words.map((word) => word.end?.inMilliseconds), [1307, 2497]);
|
||||
});
|
||||
}
|
||||
@@ -1165,6 +1165,127 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
for (final mornye in [false, true]) {
|
||||
testWidgets('player shows original, romanization and English ($mornye)', (
|
||||
tester,
|
||||
) async {
|
||||
metadataOverrides['lyrics'] =
|
||||
'[x-romaji:1009:${base64.encode(utf8.encode('Romanized text'))}]\n'
|
||||
'[x-translation:1009:${base64.encode(utf8.encode('English text'))}]\n'
|
||||
'[00:01.01]Original text';
|
||||
await pumpNowPlaying(
|
||||
tester,
|
||||
theme: mornye ? MornyeTheme.build(Brightness.dark) : null,
|
||||
size: const Size(390, 844),
|
||||
);
|
||||
mediaItems.add(item('first'));
|
||||
await tester.pumpAndSettle();
|
||||
if (mornye) {
|
||||
await tester.tap(find.byIcon(CupertinoIcons.quote_bubble));
|
||||
} else {
|
||||
await tester.drag(find.byType(PageView), const Offset(-350, 0));
|
||||
}
|
||||
await tester.pumpAndSettle();
|
||||
for (final text in ['Original text', 'Romanized text', 'English text']) {
|
||||
expect(find.text(text), findsOneWidget);
|
||||
}
|
||||
expect(
|
||||
tester.getTopLeft(find.text('Romanized text')).dy,
|
||||
greaterThan(tester.getBottomLeft(find.text('Original text')).dy),
|
||||
);
|
||||
expect(
|
||||
tester.getTopLeft(find.text('English text')).dy,
|
||||
greaterThan(tester.getBottomLeft(find.text('Romanized text')).dy),
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'original and romanization follow word timing and pauses ($mornye)',
|
||||
(tester) async {
|
||||
final romanizationWords = base64.encode(
|
||||
utf8.encode(
|
||||
jsonEncode([
|
||||
{'text': 'Firsu ', 'startTimeMs': 1009, 'endTimeMs': 1307},
|
||||
{'text': 'secondu', 'startTimeMs': 2003, 'endTimeMs': 2497},
|
||||
]),
|
||||
),
|
||||
);
|
||||
metadataOverrides['lyrics'] =
|
||||
'[x-romaji:1009:${base64.encode(utf8.encode('Firsu secondu'))}]\n'
|
||||
'[x-romaji-words:1009:$romanizationWords]\n'
|
||||
'[00:01.009]<00:01.009>First <00:01.307>'
|
||||
'<00:02.003>second<00:02.497>';
|
||||
final playback = StreamController<PlaybackState>.broadcast();
|
||||
addTearDown(playback.close);
|
||||
await pumpNowPlaying(
|
||||
tester,
|
||||
theme: mornye ? MornyeTheme.build(Brightness.dark) : null,
|
||||
size: const Size(390, 844),
|
||||
playbackEvents: playback.stream,
|
||||
);
|
||||
mediaItems.add(item('first'));
|
||||
await tester.pumpAndSettle();
|
||||
if (mornye) {
|
||||
await tester.tap(find.byIcon(CupertinoIcons.quote_bubble));
|
||||
} else {
|
||||
await tester.drag(find.byType(PageView), const Offset(-350, 0));
|
||||
}
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
Future<List<int>> pixelsAt(int milliseconds, String text) async {
|
||||
playback.add(
|
||||
PlaybackState(
|
||||
processingState: AudioProcessingState.ready,
|
||||
playing: false,
|
||||
updatePosition: Duration(milliseconds: milliseconds),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final paint = find.descendant(
|
||||
of: find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget is Semantics && widget.properties.label == text,
|
||||
),
|
||||
matching: find.byType(CustomPaint),
|
||||
);
|
||||
expect(paint, findsOneWidget, reason: '$text at $milliseconds ms');
|
||||
final painter = tester.widget<CustomPaint>(paint).painter!;
|
||||
final size = tester.getSize(paint);
|
||||
return (await tester.runAsync(() async {
|
||||
final recorder = ui.PictureRecorder();
|
||||
painter.paint(Canvas(recorder), size);
|
||||
final picture = recorder.endRecording();
|
||||
final image = await picture.toImage(
|
||||
size.width.ceil(),
|
||||
size.height.ceil(),
|
||||
);
|
||||
final bytes = (await image.toByteData(
|
||||
format: ui.ImageByteFormat.rawRgba,
|
||||
))!;
|
||||
final pixels = bytes.buffer.asUint8List().toList();
|
||||
image.dispose();
|
||||
picture.dispose();
|
||||
return pixels;
|
||||
}))!;
|
||||
}
|
||||
|
||||
for (final text in ['First second', 'Firsu secondu']) {
|
||||
final singingFirst = await pixelsAt(1100, text);
|
||||
final firstEnded = await pixelsAt(1307, text);
|
||||
expect(firstEnded, isNot(orderedEquals(singingFirst)));
|
||||
expect(await pixelsAt(1800, text), orderedEquals(firstEnded));
|
||||
final singingLast = await pixelsAt(2150, text);
|
||||
final lastEnded = await pixelsAt(2497, text);
|
||||
expect(lastEnded, isNot(orderedEquals(singingLast)));
|
||||
expect(await pixelsAt(2900, text), orderedEquals(lastEnded));
|
||||
expect(await pixelsAt(1100, text), orderedEquals(singingFirst));
|
||||
}
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('timed lyric fills text fragments in reading order', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/theme/app_theme.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/widgets/collection_scaffold.dart';
|
||||
import 'package:spotiflac_android/widgets/selection_bottom_bar.dart';
|
||||
|
||||
Widget _app(ThemeData theme, GlobalKey<NavigatorState> navigator) {
|
||||
return ProviderScope(
|
||||
child: MaterialApp(
|
||||
theme: theme,
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: SelectionOverlayHost(
|
||||
child: Navigator(
|
||||
key: navigator,
|
||||
onGenerateRoute: (_) => MaterialPageRoute<void>(
|
||||
builder: (_) => const Scaffold(body: Text('Library')),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
for (final mornye in [false, true]) {
|
||||
final name = mornye ? 'Mornye' : 'Material';
|
||||
final theme = mornye
|
||||
? MornyeTheme.build(Brightness.light)
|
||||
: AppTheme.light();
|
||||
|
||||
testWidgets('$name: leaving a collection removes its selection bar', (
|
||||
tester,
|
||||
) async {
|
||||
final navigator = GlobalKey<NavigatorState>();
|
||||
final scroll = ScrollController();
|
||||
addTearDown(scroll.dispose);
|
||||
await tester.pumpWidget(_app(theme, navigator));
|
||||
navigator.currentState!.push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => CollectionScaffold(
|
||||
scrollController: scroll,
|
||||
isSelectionMode: true,
|
||||
onExitSelectionMode: () {},
|
||||
appBar: const SliverAppBar(title: Text('Album')),
|
||||
slivers: const [],
|
||||
selectionBar: const Text('Delete 1 track'),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Delete 1 track'), findsOneWidget);
|
||||
|
||||
navigator.currentState!.pop();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(find.text('Library'), findsOneWidget);
|
||||
expect(find.text('Delete 1 track'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('$name: an empty album removes its old selection bar', (
|
||||
tester,
|
||||
) async {
|
||||
final navigator = GlobalKey<NavigatorState>();
|
||||
final scroll = ScrollController();
|
||||
final hasTracks = ValueNotifier(true);
|
||||
addTearDown(scroll.dispose);
|
||||
addTearDown(hasTracks.dispose);
|
||||
await tester.pumpWidget(_app(theme, navigator));
|
||||
navigator.currentState!.push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ValueListenableBuilder<bool>(
|
||||
valueListenable: hasTracks,
|
||||
builder: (_, hasTracks, _) => hasTracks
|
||||
? CollectionScaffold(
|
||||
scrollController: scroll,
|
||||
isSelectionMode: true,
|
||||
onExitSelectionMode: () {},
|
||||
appBar: const SliverAppBar(title: Text('Album')),
|
||||
slivers: const [],
|
||||
selectionBar: const Text('Delete 1 track'),
|
||||
)
|
||||
: const Scaffold(body: Text('No tracks found for this album')),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Delete 1 track'), findsOneWidget);
|
||||
|
||||
hasTracks.value = false;
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(find.text('No tracks found for this album'), findsOneWidget);
|
||||
expect(find.text('Delete 1 track'), findsNothing);
|
||||
navigator.currentState!.pop();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Library'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'$name: leaving Songs clears the bar and allows selecting again',
|
||||
(tester) async {
|
||||
final navigator = GlobalKey<NavigatorState>();
|
||||
await tester.pumpWidget(_app(theme, navigator));
|
||||
for (var visit = 0; visit < 2; visit++) {
|
||||
navigator.currentState!.push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => const _SongsSelectionPage(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.longPress(find.text('Song'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(SelectionBottomBar), findsOneWidget);
|
||||
|
||||
if (visit == 1) {
|
||||
await tester.tap(find.byTooltip('Close'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(SelectionBottomBar), findsNothing);
|
||||
}
|
||||
await tester.tap(find.byTooltip('Back'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(find.text('Library'), findsOneWidget);
|
||||
expect(find.byType(SelectionBottomBar), findsNothing);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Songs owns a controller directly and hides it in dispose, whereas album
|
||||
// screens delegate that lifecycle to CollectionScaffold.
|
||||
class _SongsSelectionPage extends StatefulWidget {
|
||||
const _SongsSelectionPage();
|
||||
|
||||
@override
|
||||
State<_SongsSelectionPage> createState() => _SongsSelectionPageState();
|
||||
}
|
||||
|
||||
class _SongsSelectionPageState extends State<_SongsSelectionPage> {
|
||||
final _selectionOverlay = SelectionOverlayController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_selectionOverlay.hide();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Songs'),
|
||||
leading: BackButton(onPressed: () => Navigator.pop(context)),
|
||||
),
|
||||
body: ListTile(
|
||||
title: const Text('Song'),
|
||||
onLongPress: () => _selectionOverlay.show(
|
||||
context,
|
||||
(_) => SelectionBottomBar(
|
||||
selectedCount: 1,
|
||||
allSelected: true,
|
||||
onClose: _selectionOverlay.hide,
|
||||
onToggleSelectAll: _selectionOverlay.hide,
|
||||
bottomPadding: 0,
|
||||
children: const [Text('Delete 1 track')],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user