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:
parent
436dc884b6
commit
22f5a4fcd1
1 changed files with 720 additions and 62 deletions
|
|
@ -2,6 +2,7 @@ import 'dart:convert';
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/video/subtitles.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/jellyfin/bloc/watch_cubit.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/jellyfin/data/jellyfin_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/jellyfin/data/jellyfin_repository.dart';
|
||||
|
|
@ -56,10 +57,35 @@ MockClient jellyfin12(List<http.Request> requests) => MockClient((
|
|||
'CollectionType': 'tvshows',
|
||||
'ImageTags': <String, String>{},
|
||||
},
|
||||
{'Name': 'Music', 'Id': 'lib-2', 'Type': 'MusicAlbum'},
|
||||
{
|
||||
'Name': 'Music',
|
||||
'Id': 'lib-2',
|
||||
'Type': 'CollectionFolder',
|
||||
'CollectionType': 'music',
|
||||
},
|
||||
{'Name': 'A book', 'Id': 'book-1', 'Type': 'Book'},
|
||||
],
|
||||
'TotalRecordCount': 2,
|
||||
}),
|
||||
('GET', '/Items/song-1') => json({
|
||||
'Name': '幽霊船戦',
|
||||
'Id': 'song-1',
|
||||
'Type': 'Audio',
|
||||
'AlbumArtist': '宝鐘マリン',
|
||||
'Album': 'Ahoy!!',
|
||||
'HasLyrics': true,
|
||||
'IndexNumber': 3,
|
||||
'ParentIndexNumber': 1,
|
||||
'RunTimeTicks': 2000000000,
|
||||
'ImageTags': {'Primary': 'a1'},
|
||||
}),
|
||||
('GET', '/Audio/song-1/Lyrics') => json({
|
||||
'Metadata': <String, Object?>{},
|
||||
'Lyrics': [
|
||||
{'Text': '海賊なら Yo-Ho!', 'Start': 68200000, 'Cues': <Object?>[]},
|
||||
{'Text': 'さぁ今旗のもとへ', 'Start': 95800000, 'Cues': <Object?>[]},
|
||||
],
|
||||
}),
|
||||
('GET', '/Items/ep-1') => json({
|
||||
'Name': 'Sample Journey S01E01',
|
||||
'Id': 'ep-1',
|
||||
|
|
@ -204,7 +230,8 @@ void main() {
|
|||
},
|
||||
);
|
||||
|
||||
test('lists libraries, leaving out what is no video', () async {
|
||||
test('lists libraries of videos and of music, leaving out what is '
|
||||
'neither', () async {
|
||||
final account = await signIn();
|
||||
|
||||
final libraries = await gateway.libraries(account);
|
||||
|
|
@ -215,6 +242,11 @@ void main() {
|
|||
name: 'Anime',
|
||||
kind: JellyfinItemKind.library,
|
||||
),
|
||||
const JellyfinItem(
|
||||
id: 'lib-2',
|
||||
name: 'Music',
|
||||
kind: JellyfinItemKind.library,
|
||||
),
|
||||
]);
|
||||
expect(requests.last.headers['Authorization'], contains('Token="t-1"'));
|
||||
expect(requests.last.url.queryParameters['userId'], 'u-1');
|
||||
|
|
@ -286,6 +318,38 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
test('a song is one, with its artist and whether it has lyrics, its '
|
||||
'lyrics timed line by line, none for a song without; a song streams '
|
||||
'as sound', () async {
|
||||
final account = await signIn();
|
||||
|
||||
final song = await gateway.item(account, 'song-1');
|
||||
expect(song.kind, JellyfinItemKind.audio);
|
||||
expect(song.artist, '宝鐘マリン');
|
||||
expect(song.hasLyrics, isTrue);
|
||||
expect(song.seasonNumber, isNull);
|
||||
expect(await gateway.lyrics(account, 'song-1'), [
|
||||
(text: '海賊なら Yo-Ho!', start: const Duration(milliseconds: 6820)),
|
||||
(text: 'さぁ今旗のもとへ', start: const Duration(milliseconds: 9580)),
|
||||
]);
|
||||
expect(await gateway.lyrics(account, 'song-2'), isEmpty);
|
||||
expect(
|
||||
gateway
|
||||
.streamOf(
|
||||
account,
|
||||
const JellyfinPlayback(
|
||||
itemId: 'song-1',
|
||||
mediaSourceId: 'song-1',
|
||||
playSessionId: 'ps-2',
|
||||
subtitles: [],
|
||||
audioOnly: true,
|
||||
),
|
||||
)
|
||||
.path,
|
||||
'/Audio/song-1/stream',
|
||||
);
|
||||
});
|
||||
|
||||
test('a token the server no longer takes is signed out; nothing '
|
||||
'answering is unreachable', () async {
|
||||
final server = await gateway.server(
|
||||
|
|
@ -342,6 +406,44 @@ void main() {
|
|||
});
|
||||
|
||||
group('JellyfinRepository', () {
|
||||
test("a song's lyrics are lines, each until the next, the last until "
|
||||
'the song ends; empty ones left out; untimed lyrics are text '
|
||||
'alone', () async {
|
||||
JellyfinRepository with_(List<({String text, Duration? start})> lines) =>
|
||||
JellyfinRepository(
|
||||
(_) => FakeJellyfinGateway(lyricsOf: {'song-1': lines}),
|
||||
signedInJellyfin(),
|
||||
deviceName: 'Test',
|
||||
appVersion: '1',
|
||||
);
|
||||
|
||||
final timed = await with_(const [
|
||||
(text: '一', start: Duration(seconds: 1)),
|
||||
(text: ' ', start: Duration(seconds: 4)),
|
||||
(text: '二', start: Duration(seconds: 6)),
|
||||
]).lyrics('song-1', runTime: const Duration(minutes: 3));
|
||||
expect(timed.synced, isTrue);
|
||||
expect(timed.lines, const [
|
||||
SubtitleCue(
|
||||
start: Duration(seconds: 1),
|
||||
end: Duration(seconds: 6),
|
||||
text: '一',
|
||||
),
|
||||
SubtitleCue(
|
||||
start: Duration(seconds: 6),
|
||||
end: Duration(minutes: 3),
|
||||
text: '二',
|
||||
),
|
||||
]);
|
||||
|
||||
final untimed = await with_(const [
|
||||
(text: '一', start: null),
|
||||
(text: '二', start: null),
|
||||
]).lyrics('song-1');
|
||||
expect(untimed.synced, isFalse);
|
||||
expect([for (final line in untimed.lines) line.text], ['一', '二']);
|
||||
});
|
||||
|
||||
test('an address is reached over HTTPS only, plain HTTP refused '
|
||||
'before anything is sent', () async {
|
||||
final asked = <Uri>[];
|
||||
|
|
|
|||
Loading…
Reference in a new issue