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
18 changed files with 720 additions and 62 deletions

File diff suppressed because one or more lines are too long

View file

@ -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",

View file

@ -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",

View file

@ -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:

View file

@ -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';

View file

@ -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';

View file

@ -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) {

View file

@ -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,
);
}
}

View file

@ -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(

View file

@ -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?,

View file

@ -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),
);
}

View file

@ -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(

View file

@ -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,6 +207,9 @@ class _WatchViewState() extends State<_WatchView> {
: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
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,11 +389,8 @@ class const _Line({required final double fontSize}) extends StatelessWidget {
else
(index, part),
];
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Wrap(
alignment: WrapAlignment.center,
return Wrap(
alignment: alignment,
crossAxisAlignment: WrapCrossAlignment.end,
children: [
for (final (index, part) in row)
@ -369,25 +402,174 @@ class const _Line({required final double fontSize}) extends StatelessWidget {
(entry) => state.known.contains(entry.ref),
),
selected:
state.selected == (line: line, part: index) &&
part.isWord,
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(
);
}
}
/// 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,
),
),
),
),
],
),
);
}
}
@ -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),

View file

@ -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();

View file

@ -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>[];

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));

View file

@ -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');

View file

@ -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)),
],
},
);