feat(jellyfin): songs and their lyrics, read as subtitles are

A music library's artists and albums open as shows and seasons do, and
can be tied to a studyset. A song plays as sound alone (`/Audio/{id}/
stream`, as stored) with its lyrics as Jellyfin keeps them (`/Audio/{id}/
Lyrics`: an LRC file beside it, or its tags), cut into the dictionary's
words as a video's subtitles are, with no translation. Timed lyrics
follow the song: the line sung large and bright among the others faded,
scrolled to as it comes, a line tapped played from its start; untimed
ones show whole. A song without lyrics shows greyed and doesn't open
(`HasLyrics`), as there is nothing in it to read. Its cover stands where
the picture would, glowing as a video does, and its words are tagged
with the artist, shown, and "artist · title", hidden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-13 19:46:54 +02:00
commit 22f5a4fcd1
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -60,7 +60,12 @@ void main() {
List<MediaAudioTrack> audio = const [],
List<DictionaryWord>? words,
FakeReviewGateway? reviews,
FakeJellyfinGateway? gateway,
String itemId = 'episode-1',
}) async {
if (gateway != null) {
jellyfin = gateway;
}
if (playback != null || vtt != null) {
jellyfin = sampleJellyfin(
playback: playback ?? sampleEpisodePlayback,
@ -104,7 +109,7 @@ void main() {
jellyfinStore: signedInJellyfin(),
mediaPlayers: players,
),
location: RoutePaths.watch('japanese', 'episode-1'),
location: RoutePaths.watch('japanese', itemId),
);
await tester.pumpAndSettle();
}
@ -627,6 +632,31 @@ void main() {
);
});
testWidgets("a song's lyrics move with it, its words looked up as a "
"video's; a word added is tagged with the artist and the song", (
tester,
) async {
await watch(tester, gateway: sampleMusic(), itemId: 'song-1');
expect(find.text('Words in this song'), findsOneWidget);
// Every line shows, cut into words; the empty one is left out.
expect(find.text('友達'), findsOneWidget);
expect(find.text('読'), findsOneWidget);
await playTo(tester, const Duration(seconds: 2));
await tester.tap(find.text('駅'));
await tester.pumpAndSettle();
await tester.tap(find.text('Add').first);
await tester.pumpAndSettle();
final card = (await cards.cardsOf('japanese')).single;
final tagging = await tags.read();
expect(
[for (final tag in tagging.of(card.id)) tag.name],
containsAll(['Sample Artist', 'Sample Artist · Sample Song']),
);
expect(tagging.named('Sample Artist · Sample Song')?.hidden, isTrue);
});
testWidgets('on a phone the words are below the video', (tester) async {
await watch(tester, size: const Size(412, 915));
await playTo(tester, const Duration(seconds: 2));