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
18 changed files with 720 additions and 62 deletions
File diff suppressed because one or more lines are too long
|
|
@ -1800,6 +1800,11 @@
|
|||
"watchProgressLearned": "Gelernt · {days, plural, =0{heute wieder} =1{morgen wieder} other{in {days} Tagen wieder}}",
|
||||
"watchJisho": "Auf Jisho nachschlagen",
|
||||
"watchWider": "Längere Wörter hier",
|
||||
"watchWordsTitleSong": "Wörter in diesem Lied",
|
||||
"watchStudyWordsSong": "Die Wörter dieses Lieds lernen",
|
||||
"watchStudyNothingSong": "Das Lernset enthält noch keins der Wörter dieses Lieds",
|
||||
"watchNoLyrics": "Dieses Lied hat keinen Liedtext, es gibt nichts nachzuschlagen.",
|
||||
"jellyfinNoLyrics": "Kein Liedtext",
|
||||
"watchStudy": "Lernen",
|
||||
"watchStudyWords": "Die Wörter dieses Videos lernen",
|
||||
"watchStudyNothing": "Das Lernset enthält noch keins der Wörter dieses Videos",
|
||||
|
|
|
|||
|
|
@ -2114,6 +2114,11 @@
|
|||
"@watchProgressLearned": {"placeholders": {"days": {"type": "int"}}},
|
||||
"watchJisho": "Look up on Jisho",
|
||||
"watchWider": "Longer words here",
|
||||
"watchWordsTitleSong": "Words in this song",
|
||||
"watchStudyWordsSong": "Study this song's words",
|
||||
"watchStudyNothingSong": "The studyset holds none of this song's words yet",
|
||||
"watchNoLyrics": "This song has no lyrics, so there is nothing to look up.",
|
||||
"jellyfinNoLyrics": "No lyrics",
|
||||
"watchStudy": "Study",
|
||||
"watchStudyWords": "Study this video's words",
|
||||
"watchStudyNothing": "The studyset holds none of this video's words yet",
|
||||
|
|
|
|||
|
|
@ -10641,6 +10641,36 @@ abstract class AppLocalizations {
|
|||
/// **'Longer words here'**
|
||||
String get watchWider;
|
||||
|
||||
/// No description provided for @watchWordsTitleSong.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Words in this song'**
|
||||
String get watchWordsTitleSong;
|
||||
|
||||
/// No description provided for @watchStudyWordsSong.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Study this song\'s words'**
|
||||
String get watchStudyWordsSong;
|
||||
|
||||
/// No description provided for @watchStudyNothingSong.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'The studyset holds none of this song\'s words yet'**
|
||||
String get watchStudyNothingSong;
|
||||
|
||||
/// No description provided for @watchNoLyrics.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This song has no lyrics, so there is nothing to look up.'**
|
||||
String get watchNoLyrics;
|
||||
|
||||
/// No description provided for @jellyfinNoLyrics.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No lyrics'**
|
||||
String get jellyfinNoLyrics;
|
||||
|
||||
/// No description provided for @watchStudy.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -6691,6 +6691,23 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get watchWider => 'Längere Wörter hier';
|
||||
|
||||
@override
|
||||
String get watchWordsTitleSong => 'Wörter in diesem Lied';
|
||||
|
||||
@override
|
||||
String get watchStudyWordsSong => 'Die Wörter dieses Lieds lernen';
|
||||
|
||||
@override
|
||||
String get watchStudyNothingSong =>
|
||||
'Das Lernset enthält noch keins der Wörter dieses Lieds';
|
||||
|
||||
@override
|
||||
String get watchNoLyrics =>
|
||||
'Dieses Lied hat keinen Liedtext, es gibt nichts nachzuschlagen.';
|
||||
|
||||
@override
|
||||
String get jellyfinNoLyrics => 'Kein Liedtext';
|
||||
|
||||
@override
|
||||
String get watchStudy => 'Lernen';
|
||||
|
||||
|
|
|
|||
|
|
@ -6633,6 +6633,23 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get watchWider => 'Longer words here';
|
||||
|
||||
@override
|
||||
String get watchWordsTitleSong => 'Words in this song';
|
||||
|
||||
@override
|
||||
String get watchStudyWordsSong => 'Study this song\'s words';
|
||||
|
||||
@override
|
||||
String get watchStudyNothingSong =>
|
||||
'The studyset holds none of this song\'s words yet';
|
||||
|
||||
@override
|
||||
String get watchNoLyrics =>
|
||||
'This song has no lyrics, so there is nothing to look up.';
|
||||
|
||||
@override
|
||||
String get jellyfinNoLyrics => 'No lyrics';
|
||||
|
||||
@override
|
||||
String get watchStudy => 'Study';
|
||||
|
||||
|
|
|
|||
|
|
@ -93,6 +93,13 @@ final class const WatchState({
|
|||
/// Which of the video's words its list shows.
|
||||
final WordFilter filter = const WordFilter(),
|
||||
|
||||
/// Whether it is a song, whose lines are its lyrics (decided 2026-10-13).
|
||||
final bool song = false,
|
||||
|
||||
/// Whether the lines are timed: a song's lyrics may be text alone, read
|
||||
/// whole, followed by no line.
|
||||
final bool synced = true,
|
||||
|
||||
/// Where the studyset's cards made from entries stand, by entry.
|
||||
final Map<DictionaryRef, CardSchedule> progress = const {},
|
||||
}) with Equatable {
|
||||
|
|
@ -130,6 +137,8 @@ final class const WatchState({
|
|||
bool? pauseAtLineEnd,
|
||||
WordFilter? filter,
|
||||
Map<DictionaryRef, CardSchedule>? progress,
|
||||
bool? song,
|
||||
bool? synced,
|
||||
}) => WatchState(
|
||||
status: status ?? this.status,
|
||||
problem: problem == _same ? this.problem : problem as JellyfinProblem?,
|
||||
|
|
@ -157,6 +166,8 @@ final class const WatchState({
|
|||
pauseAtLineEnd: pauseAtLineEnd ?? this.pauseAtLineEnd,
|
||||
filter: filter ?? this.filter,
|
||||
progress: progress ?? this.progress,
|
||||
song: song ?? this.song,
|
||||
synced: synced ?? this.synced,
|
||||
);
|
||||
|
||||
@override
|
||||
|
|
@ -181,6 +192,8 @@ final class const WatchState({
|
|||
pauseAtLineEnd,
|
||||
filter,
|
||||
progress,
|
||||
song,
|
||||
synced,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -234,8 +247,15 @@ class WatchCubit(
|
|||
for (final track in text)
|
||||
if (track != study) track,
|
||||
], languages.back ?? 'en');
|
||||
final lines = await _cues(info, study);
|
||||
final translations = await _cues(info, translation);
|
||||
// A song's lines are its lyrics, without a translation.
|
||||
final lyrics = info.audioOnly
|
||||
? await _jellyfin.lyrics(itemId, runTime: info.runTime)
|
||||
: null;
|
||||
final lines = lyrics?.lines ?? await _cues(info, study);
|
||||
final translations = lyrics == null
|
||||
? await _cues(info, translation)
|
||||
: const <SubtitleCue>[];
|
||||
final synced = lyrics?.synced ?? true;
|
||||
final known = await _adder.entriesIn(studysetId);
|
||||
final progress = await _progress.of(studysetId);
|
||||
if (isClosed) {
|
||||
|
|
@ -252,7 +272,9 @@ class WatchCubit(
|
|||
audioTrack: audioTrackIn(info.audio, languages.front),
|
||||
lines: lines,
|
||||
translations: translations,
|
||||
line: lastCueIndex(lines, info.resumeAt),
|
||||
line: synced ? lastCueIndex(lines, info.resumeAt) : null,
|
||||
song: info.audioOnly,
|
||||
synced: synced,
|
||||
known: known,
|
||||
progress: progress,
|
||||
),
|
||||
|
|
@ -318,7 +340,7 @@ class WatchCubit(
|
|||
return;
|
||||
}
|
||||
final now = playback.state.value;
|
||||
final index = lastCueIndex(state.lines, now.position);
|
||||
final index = state.synced ? lastCueIndex(state.lines, now.position) : null;
|
||||
if (index != state.line) {
|
||||
emit(state.copyWith(line: index));
|
||||
if (index != null) {
|
||||
|
|
|
|||
|
|
@ -105,6 +105,13 @@ abstract interface class JellyfinGateway() {
|
|||
JellyfinSubtitleTrack track,
|
||||
);
|
||||
|
||||
/// The lyrics of the song [itemId], line by line, each with when it is
|
||||
/// sung if they are timed; empty without lyrics.
|
||||
Future<List<({String text, Duration? start})>> lyrics(
|
||||
JellyfinAccount account,
|
||||
String itemId,
|
||||
);
|
||||
|
||||
/// The picture of [item], [height] pixels high.
|
||||
Uri imageOf(JellyfinAccount account, JellyfinItem item, {int height});
|
||||
|
||||
|
|
@ -400,6 +407,8 @@ class HttpJellyfinGateway(
|
|||
itemId: itemId,
|
||||
mediaSourceId: sourceId,
|
||||
playSessionId: _string(json, 'PlaySessionId') ?? '',
|
||||
// Sound alone: a song, whose cover is an image, no video.
|
||||
audioOnly: item.kind == JellyfinItemKind.audio,
|
||||
runTime: ticks == null ? item.runTime : _duration(ticks),
|
||||
resumeAt: item.progress == null || item.runTime == null
|
||||
? Duration.zero
|
||||
|
|
@ -459,7 +468,9 @@ class HttpJellyfinGateway(
|
|||
@override
|
||||
Uri streamOf(JellyfinAccount account, JellyfinPlayback playback) => _at(
|
||||
account.server.address,
|
||||
'/Videos/${playback.itemId}/stream',
|
||||
playback.audioOnly
|
||||
? '/Audio/${playback.itemId}/stream'
|
||||
: '/Videos/${playback.itemId}/stream',
|
||||
{
|
||||
'static': 'true',
|
||||
'mediaSourceId': playback.mediaSourceId,
|
||||
|
|
@ -468,6 +479,39 @@ class HttpJellyfinGateway(
|
|||
},
|
||||
);
|
||||
|
||||
@override
|
||||
Future<List<({String text, Duration? start})>> lyrics(
|
||||
JellyfinAccount account,
|
||||
String itemId,
|
||||
) async {
|
||||
final Object? json;
|
||||
try {
|
||||
json = await _send(
|
||||
'GET',
|
||||
_at(account.server.address, '/Audio/$itemId/Lyrics'),
|
||||
token: account.token,
|
||||
);
|
||||
} on JellyfinException catch (error) {
|
||||
// A song without lyrics answers 404: none.
|
||||
if (error.problem == JellyfinProblem.failed) {
|
||||
return const [];
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
final lyrics = _map(json);
|
||||
return [
|
||||
for (final line in _list(lyrics[_key(lyrics, 'Lyrics')]))
|
||||
if (_map(line) case final line)
|
||||
(
|
||||
text: _string(line, 'Text') ?? '',
|
||||
start: switch (_int(line, 'Start')) {
|
||||
final ticks? => _duration(ticks),
|
||||
null => null,
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> subtitles(
|
||||
JellyfinAccount account,
|
||||
|
|
@ -564,14 +608,19 @@ class HttpJellyfinGateway(
|
|||
'Episode' => JellyfinItemKind.episode,
|
||||
'Movie' => JellyfinItemKind.movie,
|
||||
'Video' || 'MusicVideo' => JellyfinItemKind.video,
|
||||
'MusicArtist' => JellyfinItemKind.artist,
|
||||
'MusicAlbum' => JellyfinItemKind.album,
|
||||
'Audio' => JellyfinItemKind.audio,
|
||||
_ => JellyfinItemKind.other,
|
||||
},
|
||||
seriesName: _string(json, 'SeriesName'),
|
||||
seasonNumber:
|
||||
_int(json, 'ParentIndexNumber') ??
|
||||
(_string(json, 'Type') == 'Season'
|
||||
? _int(json, 'IndexNumber')
|
||||
: null),
|
||||
// A song's are its disc's: no season.
|
||||
seasonNumber: switch (_string(json, 'Type')) {
|
||||
'Season' =>
|
||||
_int(json, 'ParentIndexNumber') ?? _int(json, 'IndexNumber'),
|
||||
'Episode' => _int(json, 'ParentIndexNumber'),
|
||||
_ => null,
|
||||
},
|
||||
episodeNumber: _string(json, 'Type') == 'Episode'
|
||||
? _int(json, 'IndexNumber')
|
||||
: null,
|
||||
|
|
@ -582,6 +631,8 @@ class HttpJellyfinGateway(
|
|||
: (position / ticks).clamp(0, 1).toDouble(),
|
||||
played: userData[_key(userData, 'Played')] == true,
|
||||
hasImage: _map(json[_key(json, 'ImageTags')]).isNotEmpty,
|
||||
artist: _string(json, 'AlbumArtist'),
|
||||
hasLyrics: json[_key(json, 'HasLyrics')] == true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -207,6 +207,38 @@ class JellyfinRepository(
|
|||
parseSubtitles(await gateway.subtitles(account, playback, track)),
|
||||
);
|
||||
|
||||
/// The lyrics of the song [itemId] as lines: timed (`synced`) each from
|
||||
/// when it is sung to when the next is, or as text alone, all of them at
|
||||
/// the start; none without lyrics. Empty lines (pauses) are left out.
|
||||
Future<({List<SubtitleCue> lines, bool synced})> lyrics(
|
||||
String itemId, {
|
||||
Duration? runTime,
|
||||
}) => _signedIn((gateway, account) async {
|
||||
final lines = [
|
||||
for (final line in await gateway.lyrics(account, itemId))
|
||||
if (line.text.trim().isNotEmpty) line,
|
||||
];
|
||||
final synced =
|
||||
lines.isNotEmpty && lines.every((line) => line.start != null);
|
||||
return (
|
||||
lines: [
|
||||
for (final (index, line) in lines.indexed)
|
||||
SubtitleCue(
|
||||
text: line.text.trim(),
|
||||
start: synced ? line.start! : Duration.zero,
|
||||
end: !synced
|
||||
? Duration.zero
|
||||
: index + 1 < lines.length
|
||||
? lines[index + 1].start!
|
||||
: (runTime != null && runTime > line.start!
|
||||
? runTime
|
||||
: line.start! + const Duration(seconds: 8)),
|
||||
),
|
||||
],
|
||||
synced: synced,
|
||||
);
|
||||
});
|
||||
|
||||
/// Tells the server how far [playback] went: it keeps where to resume.
|
||||
/// Failures are left unsaid: watching goes on without.
|
||||
Future<void> report(
|
||||
|
|
|
|||
|
|
@ -47,11 +47,23 @@ enum JellyfinItemKind() {
|
|||
/// Any other video.
|
||||
video,
|
||||
|
||||
/// Anything else: music, books, … Not shown.
|
||||
/// An artist of a music library, of albums.
|
||||
artist,
|
||||
|
||||
/// An album, of songs.
|
||||
album,
|
||||
|
||||
/// A song: listened to, its lyrics read (decided 2026-10-13).
|
||||
audio,
|
||||
|
||||
/// Anything else: books, … Not shown.
|
||||
other;
|
||||
|
||||
/// Whether it is watched, rather than opened to see what it holds.
|
||||
bool get isVideo => this == episode || this == movie || this == video;
|
||||
|
||||
/// Whether it is played (watched or listened to) rather than opened.
|
||||
bool get isPlayed => isVideo || this == audio;
|
||||
}
|
||||
|
||||
/// An item of a Jellyfin library: a library, a show, a season, an episode,
|
||||
|
|
@ -85,6 +97,13 @@ final class const JellyfinItem({
|
|||
|
||||
/// Whether it has a picture of its own.
|
||||
final bool hasImage = false,
|
||||
|
||||
/// For a song or an album, who made it.
|
||||
final String? artist,
|
||||
|
||||
/// For a song, whether it has lyrics: without, there is nothing to learn
|
||||
/// from it, and it doesn't open.
|
||||
final bool hasLyrics = false,
|
||||
}) with Equatable {
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
|
|
@ -99,6 +118,8 @@ final class const JellyfinItem({
|
|||
progress,
|
||||
played,
|
||||
hasImage,
|
||||
artist,
|
||||
hasLyrics,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -142,9 +163,10 @@ final class const JellyfinAudioTrack({
|
|||
List<Object?> get props => [index, title, language, isDefault];
|
||||
}
|
||||
|
||||
/// What watching a video takes: the video ([itemId]) in the version chosen
|
||||
/// ([mediaSourceId]), its subtitle and audio tracks, how long it runs and
|
||||
/// where it was left off, and the session Jellyfin is told about.
|
||||
/// What watching a video or listening to a song takes: it ([itemId]) in the
|
||||
/// version chosen ([mediaSourceId]), its subtitle and audio tracks, how
|
||||
/// long it runs and where it was left off, the session Jellyfin is told
|
||||
/// about, and whether it is sound alone ([audioOnly]: a song).
|
||||
final class const JellyfinPlayback({
|
||||
required final String itemId,
|
||||
required final String mediaSourceId,
|
||||
|
|
@ -153,6 +175,7 @@ final class const JellyfinPlayback({
|
|||
final List<JellyfinAudioTrack> audio = const [],
|
||||
final Duration? runTime,
|
||||
final Duration resumeAt = Duration.zero,
|
||||
final bool audioOnly = false,
|
||||
}) with Equatable {
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
|
|
@ -163,6 +186,7 @@ final class const JellyfinPlayback({
|
|||
audio,
|
||||
runTime,
|
||||
resumeAt,
|
||||
audioOnly,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -269,8 +293,14 @@ final class const JellyfinState({
|
|||
/// (or the film's or video's own) name, shown on cards; and the episode's
|
||||
/// own, "あかね噺 S1 E1", hidden on cards (as many would gather on a common
|
||||
/// word), so the episode's words can be studied together.
|
||||
///
|
||||
/// A song gets its artist's, shown, and "宝鐘マリン · 幽霊船戦", hidden.
|
||||
({String? shown, String? hidden}) jellyfinTagsOf(JellyfinItem item) =>
|
||||
switch (item) {
|
||||
JellyfinItem(kind: JellyfinItemKind.audio, artist: final artist?) => (
|
||||
shown: artist,
|
||||
hidden: '$artist · ${item.name}',
|
||||
),
|
||||
JellyfinItem(
|
||||
seriesName: final series?,
|
||||
seasonNumber: final season?,
|
||||
|
|
|
|||
|
|
@ -110,7 +110,11 @@ class _JellyfinBrowserScreenState() extends State<JellyfinBrowserScreen> {
|
|||
}
|
||||
|
||||
void _open(JellyfinItem item) {
|
||||
if (item.kind.isVideo) {
|
||||
// A song without lyrics has nothing to learn from (decided 2026-10-13).
|
||||
if (item.kind == JellyfinItemKind.audio && !item.hasLyrics) {
|
||||
return;
|
||||
}
|
||||
if (item.kind.isPlayed) {
|
||||
unawaited(context.push(RoutePaths.watch(widget.studysetId, item.id)));
|
||||
} else {
|
||||
unawaited(
|
||||
|
|
@ -303,9 +307,13 @@ class const _ItemTile({
|
|||
JellyfinItemKind.season ||
|
||||
JellyfinItemKind.movie ||
|
||||
JellyfinItemKind.video ||
|
||||
JellyfinItemKind.artist ||
|
||||
JellyfinItemKind.album ||
|
||||
JellyfinItemKind.folder => true,
|
||||
_ => false,
|
||||
};
|
||||
// Songs without lyrics are listed, greyed, and don't open.
|
||||
final silent = item.kind == JellyfinItemKind.audio && !item.hasLyrics;
|
||||
final tied = context.select<JellyfinTiesCubit, bool>(
|
||||
(ties) => ties.state.isTied(item.id),
|
||||
);
|
||||
|
|
@ -315,11 +323,19 @@ class const _ItemTile({
|
|||
item.seasonNumber != null &&
|
||||
item.episodeNumber != null)
|
||||
l10n.jellyfinEpisode(item.seasonNumber!, item.episodeNumber!),
|
||||
if (item.kind == JellyfinItemKind.album || silent) ?item.artist,
|
||||
if (silent) l10n.jellyfinNoLyrics,
|
||||
?item.year?.toString(),
|
||||
if (item.runTime case final runTime?) '${runTime.inMinutes} min',
|
||||
if (item.runTime case final runTime?
|
||||
when item.kind == JellyfinItemKind.audio)
|
||||
'${runTime.inMinutes}:'
|
||||
'${(runTime.inSeconds % 60).toString().padLeft(2, '0')}'
|
||||
else if (item.runTime case final runTime?)
|
||||
'${runTime.inMinutes} min',
|
||||
];
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
enabled: !silent,
|
||||
onTap: onTap,
|
||||
leading: SizedBox(
|
||||
width: 72,
|
||||
|
|
@ -344,6 +360,9 @@ class const _ItemTile({
|
|||
JellyfinItemKind.folder => LucideIcons.folder,
|
||||
JellyfinItemKind.series ||
|
||||
JellyfinItemKind.season => LucideIcons.tv,
|
||||
JellyfinItemKind.artist => LucideIcons.micVocal,
|
||||
JellyfinItemKind.album => LucideIcons.disc3,
|
||||
JellyfinItemKind.audio => LucideIcons.music,
|
||||
_ => LucideIcons.film,
|
||||
}, color: theme.colorScheme.onSurfaceVariant),
|
||||
if (item.progress case final progress?)
|
||||
|
|
@ -380,6 +399,8 @@ class const _ItemTile({
|
|||
? context.read<JellyfinTiesCubit>().untie(item.id)
|
||||
: context.read<JellyfinTiesCubit>().tie(item),
|
||||
)
|
||||
: silent
|
||||
? null
|
||||
: const Icon(LucideIcons.chevronRight),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,9 +33,13 @@ enum WatchSettingsPage() {
|
|||
/// which the screen's Esc closes): Pause after each line, the subtitles
|
||||
/// read, the translation, the sound, and the keyboard shortcuts. [frame] is
|
||||
/// around the picture alone, for its colours to glow.
|
||||
///
|
||||
/// A song shows [picture] (its cover) in place of a video, at [aspectRatio].
|
||||
class const WatchPlayer({
|
||||
required final GlobalKey frame,
|
||||
required final ValueNotifier<WatchSettingsPage?> settings,
|
||||
final Widget? picture,
|
||||
final double? aspectRatio,
|
||||
super.key,
|
||||
}) extends StatefulWidget {
|
||||
/// How long the controls stay without the pointer moving (decided
|
||||
|
|
@ -140,7 +144,7 @@ class _WatchPlayerState() extends State<WatchPlayer> {
|
|||
valueListenable: playback.state,
|
||||
builder: (context, media, _) => Center(
|
||||
child: AspectRatio(
|
||||
aspectRatio: media.aspectRatio,
|
||||
aspectRatio: widget.aspectRatio ?? media.aspectRatio,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: ValueListenableBuilder(
|
||||
|
|
@ -166,7 +170,7 @@ class _WatchPlayerState() extends State<WatchPlayer> {
|
|||
},
|
||||
child: RepaintBoundary(
|
||||
key: widget.frame,
|
||||
child: playback.view(),
|
||||
child: widget.picture ?? playback.view(),
|
||||
),
|
||||
),
|
||||
IgnorePointer(
|
||||
|
|
|
|||
|
|
@ -100,6 +100,9 @@ class _WatchViewState() extends State<_WatchView> {
|
|||
Widget build(BuildContext context) {
|
||||
final dictionary = widget.dictionary;
|
||||
WatchCubit cubit() => context.read<WatchCubit>();
|
||||
final song = context.select<WatchCubit, bool>(
|
||||
(cubit) => cubit.state.song,
|
||||
);
|
||||
final item = context.select<WatchCubit, JellyfinItem?>(
|
||||
(cubit) => cubit.state.item,
|
||||
);
|
||||
|
|
@ -190,7 +193,9 @@ class _WatchViewState() extends State<_WatchView> {
|
|||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _Stage(wide: true, settings: _settings),
|
||||
child: song
|
||||
? _SongStage(wide: true, settings: _settings)
|
||||
: _Stage(wide: true, settings: _settings),
|
||||
),
|
||||
const VerticalDivider(width: 1),
|
||||
SizedBox(
|
||||
|
|
@ -202,7 +207,10 @@ class _WatchViewState() extends State<_WatchView> {
|
|||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_Stage(wide: false, settings: _settings),
|
||||
if (song)
|
||||
_SongStage(wide: false, settings: _settings)
|
||||
else
|
||||
_Stage(wide: false, settings: _settings),
|
||||
const Divider(height: 1),
|
||||
Expanded(child: _Panel(dictionary: dictionary)),
|
||||
],
|
||||
|
|
@ -327,16 +335,44 @@ class const _Line({required final double fontSize}) extends StatelessWidget {
|
|||
if (line == null) {
|
||||
return const SizedBox();
|
||||
}
|
||||
final parts =
|
||||
state.lineParts ??
|
||||
[SentencePart(text: state.lines[line].text, start: 0)];
|
||||
final translation = switch (state.translationOf(line)) {
|
||||
final text? => oneLine(text),
|
||||
null => null,
|
||||
};
|
||||
// A line of subtitles may be two, broken for showing over a picture:
|
||||
// here it is one, wrapping where the width ends (Japanese and Chinese
|
||||
// join as they are, others with a space).
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_LineWords(line: line, fontSize: fontSize),
|
||||
if (translation != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
translation,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The words of the line [line], each tappable, the kanji's readings above
|
||||
/// them; the line as text while it isn't cut yet. A line broken for showing
|
||||
/// over a picture is one here, wrapping where the width ends (Japanese and
|
||||
/// Chinese join as they are, others with a space).
|
||||
class const _LineWords({
|
||||
required final int line,
|
||||
required final double fontSize,
|
||||
final WrapAlignment alignment = WrapAlignment.center,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = context.watch<WatchCubit>().state;
|
||||
final parts =
|
||||
state.parts[line] ??
|
||||
[SentencePart(text: state.lines[line].text, start: 0)];
|
||||
final joiner = switch (StudysetLanguageScope.of(
|
||||
context,
|
||||
).front?.split('-').first) {
|
||||
|
|
@ -353,45 +389,191 @@ class const _Line({required final double fontSize}) extends StatelessWidget {
|
|||
else
|
||||
(index, part),
|
||||
];
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
return Wrap(
|
||||
alignment: alignment,
|
||||
crossAxisAlignment: WrapCrossAlignment.end,
|
||||
children: [
|
||||
Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
crossAxisAlignment: WrapCrossAlignment.end,
|
||||
children: [
|
||||
for (final (index, part) in row)
|
||||
if (part.text.isNotEmpty)
|
||||
_Word(
|
||||
part: part,
|
||||
fontSize: fontSize,
|
||||
known: part.entries.any(
|
||||
(entry) => state.known.contains(entry.ref),
|
||||
),
|
||||
selected:
|
||||
state.selected == (line: line, part: index) &&
|
||||
part.isWord,
|
||||
onTap: part.isWord
|
||||
? () => context.read<WatchCubit>().select(line, index)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (translation != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
translation,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
for (final (index, part) in row)
|
||||
if (part.text.isNotEmpty)
|
||||
_Word(
|
||||
part: part,
|
||||
fontSize: fontSize,
|
||||
known: part.entries.any(
|
||||
(entry) => state.known.contains(entry.ref),
|
||||
),
|
||||
selected:
|
||||
state.selected == (line: line, part: index) && part.isWord,
|
||||
onTap: part.isWord
|
||||
? () => context.read<WatchCubit>().select(line, index)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A song: its cover as the player's picture, square, its colours glowing
|
||||
/// around it in the dark theme, and its lyrics beside it on a desktop
|
||||
/// (below it on a phone) as a moving banner (decided 2026-10-13).
|
||||
class const _SongStage({
|
||||
required final bool wide,
|
||||
required final ValueNotifier<WatchSettingsPage?> settings,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
State<_SongStage> createState() => _SongStageState();
|
||||
}
|
||||
|
||||
class _SongStageState() extends State<_SongStage> {
|
||||
/// Around the cover, which the glow copies.
|
||||
final _picture = GlobalKey();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final item = context.select<WatchCubit, JellyfinItem?>(
|
||||
(cubit) => cubit.state.item,
|
||||
);
|
||||
final image = item == null || !item.hasImage
|
||||
? null
|
||||
: DependenciesScope.of(context).jellyfin.imageOf(item, height: 800);
|
||||
final placeholder = ColoredBox(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
LucideIcons.music,
|
||||
size: 64,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
final cover = image == null
|
||||
? placeholder
|
||||
: Image.network(
|
||||
'${image.url}',
|
||||
headers: image.headers,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (imageContext, _, _) => placeholder,
|
||||
);
|
||||
final player = WatchPlayer(
|
||||
frame: _picture,
|
||||
settings: widget.settings,
|
||||
picture: cover,
|
||||
aspectRatio: 1,
|
||||
);
|
||||
return AmbientGlow(
|
||||
source: _picture,
|
||||
child: widget.wide
|
||||
? Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 4,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(child: player),
|
||||
),
|
||||
),
|
||||
const Expanded(flex: 5, child: _LyricsBanner(wide: true)),
|
||||
],
|
||||
)
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: SizedBox(height: 240, child: Center(child: player)),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 220,
|
||||
child: _LyricsBanner(wide: false),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A song's lyrics as a moving banner, as music players show them: every
|
||||
/// line, the one sung large and bright, the others smaller and faded, the
|
||||
/// one sung kept in view as the song goes on; every word tappable as in a
|
||||
/// video's line, and a faded line's start a tap away. Lyrics without times
|
||||
/// show whole and bright, none followed.
|
||||
class const _LyricsBanner({required final bool wide}) extends StatefulWidget {
|
||||
@override
|
||||
State<_LyricsBanner> createState() => _LyricsBannerState();
|
||||
}
|
||||
|
||||
class _LyricsBannerState() extends State<_LyricsBanner> {
|
||||
final _keys = <int, GlobalKey>{};
|
||||
int? _shown;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final state = context.watch<WatchCubit>().state;
|
||||
if (state.status != WatchStatus.ready) {
|
||||
return const SizedBox();
|
||||
}
|
||||
if (state.lines.isEmpty) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(l10n.watchNoLyrics, textAlign: TextAlign.center),
|
||||
),
|
||||
);
|
||||
}
|
||||
final sung = state.synced ? state.line : null;
|
||||
if (sung != null && sung != _shown) {
|
||||
_shown = sung;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final line = _keys[sung]?.currentContext;
|
||||
if (line != null && line.mounted) {
|
||||
Scrollable.ensureVisible(
|
||||
line,
|
||||
alignment: 0.4,
|
||||
duration: const Duration(milliseconds: 400),
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
final big = widget.wide ? 30.0 : 22.0;
|
||||
final small = widget.wide ? 20.0 : 16.0;
|
||||
return SingleChildScrollView(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: widget.wide ? 96 : 24,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: widget.wide
|
||||
? CrossAxisAlignment.start
|
||||
: CrossAxisAlignment.center,
|
||||
children: [
|
||||
for (var index = 0; index < state.lines.length; index++)
|
||||
Padding(
|
||||
key: _keys[index] ??= GlobalKey(),
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
opacity: sung == null || sung == index ? 1 : 0.45,
|
||||
child: GestureDetector(
|
||||
onTap: state.synced
|
||||
? () => context.read<WatchCubit>().playLine(index)
|
||||
: null,
|
||||
child: _LineWords(
|
||||
line: index,
|
||||
fontSize: sung == index ? big : small,
|
||||
alignment: widget.wide
|
||||
? WrapAlignment.start
|
||||
: WrapAlignment.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A word of a line: its kanji with their readings above, tappable, marked
|
||||
/// when the studyset holds it ([known]) or it is chosen; or the text
|
||||
/// between words.
|
||||
|
|
@ -971,7 +1153,9 @@ class const _VideoWords({required final Dictionary dictionary})
|
|||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.watchWordsTitle,
|
||||
state.song
|
||||
? l10n.watchWordsTitleSong
|
||||
: l10n.watchWordsTitle,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
|
|
@ -1092,12 +1276,15 @@ class const _StudyWords() extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final cubit = context.read<WatchCubit>();
|
||||
final holds = context.select<WatchCubit, bool>(
|
||||
(cubit) => cubit.holdsWords,
|
||||
);
|
||||
final song = context.select<WatchCubit, bool>(
|
||||
(cubit) => cubit.state.song,
|
||||
);
|
||||
Future<void> study({required bool cram}) async {
|
||||
final router = GoRouter.of(context);
|
||||
final cubit = context.read<WatchCubit>();
|
||||
final focus = await cubit.studyWords(cram: cram);
|
||||
if (focus != null) {
|
||||
await router.push(
|
||||
|
|
@ -1120,7 +1307,12 @@ class const _StudyWords() extends StatelessWidget {
|
|||
),
|
||||
],
|
||||
builder: (context, controller, _) => Tooltip(
|
||||
message: holds ? l10n.watchStudyWords : l10n.watchStudyNothing,
|
||||
message: switch ((holds, song)) {
|
||||
(true, false) => l10n.watchStudyWords,
|
||||
(true, true) => l10n.watchStudyWordsSong,
|
||||
(false, false) => l10n.watchStudyNothing,
|
||||
(false, true) => l10n.watchStudyNothingSong,
|
||||
},
|
||||
child: FilledButton.tonalIcon(
|
||||
style: FilledButton.styleFrom(visualDensity: VisualDensity.compact),
|
||||
icon: const Icon(LucideIcons.graduationCap, size: 18),
|
||||
|
|
|
|||
|
|
@ -137,6 +137,34 @@ void main() {
|
|||
expect(store.kept?.account?.token, 'token-2');
|
||||
});
|
||||
|
||||
testWidgets('a music library lists its albums and songs; a song without '
|
||||
'lyrics is greyed and opens nothing, one with lyrics plays', (
|
||||
tester,
|
||||
) async {
|
||||
jellyfin = sampleMusic();
|
||||
store = signedInJellyfin();
|
||||
await show(
|
||||
tester,
|
||||
RoutePaths.jellyfin(
|
||||
'japanese',
|
||||
parentId: 'album-1',
|
||||
name: 'Sample Album',
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Sample Song'), findsOneWidget);
|
||||
expect(find.textContaining('No lyrics'), findsOneWidget);
|
||||
await tester.tap(find.text('Sample Song (Instrumental)'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Sample Song (Instrumental)'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Sample Song'));
|
||||
for (var frame = 0; frame < 6; frame++) {
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
}
|
||||
expect(find.text('Words in this song'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a show tied to the studyset comes first next time; an '
|
||||
'episode opens to be watched', (tester) async {
|
||||
store = signedInJellyfin();
|
||||
|
|
|
|||
|
|
@ -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>[];
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@ class FakeJellyfinGateway({
|
|||
final Map<String, List<JellyfinItem>> tree = const {},
|
||||
final Map<String, JellyfinPlayback> playbacks = const {},
|
||||
final Map<(String, int), String> vtt = const {},
|
||||
|
||||
/// Each song's lyrics, by its id.
|
||||
final Map<String, List<({String text, Duration? start})>> lyricsOf = const {},
|
||||
}) implements JellyfinGateway {
|
||||
/// When set, every request after signing in fails with it.
|
||||
JellyfinProblem? fail;
|
||||
|
|
@ -172,6 +175,15 @@ class FakeJellyfinGateway({
|
|||
return vtt[(playback.itemId, track.index)] ?? 'WEBVTT\n';
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<({String text, Duration? start})>> lyrics(
|
||||
JellyfinAccount account,
|
||||
String itemId,
|
||||
) async {
|
||||
_check(account);
|
||||
return lyricsOf[itemId] ?? const [];
|
||||
}
|
||||
|
||||
@override
|
||||
Uri imageOf(JellyfinAccount account, JellyfinItem item, {int height = 360}) =>
|
||||
fakeJellyfinServer.address.replace(path: '/Items/${item.id}/Images');
|
||||
|
|
|
|||
|
|
@ -234,3 +234,63 @@ MemoryJellyfinStore signedInJellyfin() => MemoryJellyfinStore(
|
|||
),
|
||||
),
|
||||
);
|
||||
|
||||
/// A sample song: its album, by Sample Artist, holds it with lyrics timed
|
||||
/// line by line, and an instrumental without.
|
||||
const sampleSong = JellyfinItem(
|
||||
id: 'song-1',
|
||||
name: 'Sample Song',
|
||||
kind: JellyfinItemKind.audio,
|
||||
artist: 'Sample Artist',
|
||||
hasLyrics: true,
|
||||
hasImage: true,
|
||||
runTime: Duration(minutes: 3),
|
||||
);
|
||||
|
||||
/// A music library of one album: [sampleSong] and an instrumental.
|
||||
FakeJellyfinGateway sampleMusic() => FakeJellyfinGateway(
|
||||
tree: {
|
||||
'': [
|
||||
const JellyfinItem(
|
||||
id: 'music-1',
|
||||
name: 'Music',
|
||||
kind: JellyfinItemKind.library,
|
||||
),
|
||||
],
|
||||
'music-1': [
|
||||
const JellyfinItem(
|
||||
id: 'album-1',
|
||||
name: 'Sample Album',
|
||||
kind: JellyfinItemKind.album,
|
||||
artist: 'Sample Artist',
|
||||
),
|
||||
],
|
||||
'album-1': [
|
||||
sampleSong,
|
||||
const JellyfinItem(
|
||||
id: 'song-2',
|
||||
name: 'Sample Song (Instrumental)',
|
||||
kind: JellyfinItemKind.audio,
|
||||
artist: 'Sample Artist',
|
||||
runTime: Duration(minutes: 3),
|
||||
),
|
||||
],
|
||||
},
|
||||
playbacks: {
|
||||
'song-1': const JellyfinPlayback(
|
||||
itemId: 'song-1',
|
||||
mediaSourceId: 'song-1',
|
||||
playSessionId: 'session-2',
|
||||
subtitles: [],
|
||||
runTime: Duration(minutes: 3),
|
||||
audioOnly: true,
|
||||
),
|
||||
},
|
||||
lyricsOf: {
|
||||
'song-1': const [
|
||||
(text: '昨日、駅で友達に会いました。', start: Duration(seconds: 1)),
|
||||
(text: '', start: Duration(seconds: 5)),
|
||||
(text: 'まだ読んでいないけど、面白そうだね。', start: Duration(seconds: 6)),
|
||||
],
|
||||
},
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue