feat(jellyfin): From Jellyfin once signed in, the panel in the glow's colour, adding the words listed
All checks were successful
CI / check (push) Successful in 16m0s
CI / deploy (push) Successful in 1m13s

Add material's entry is now From Jellyfin (Aus Jellyfin), with a play
icon rather than a TV, as it plays music too, and shows only once the
device is signed in to a server in Settings, as the assistant shows only
once switched on; Settings says where it then is. The screen it opens is
called Jellyfin, and its search finds music too.

The words' panel takes the glow's colour as the scene changes: each copy
the glow takes (every 3 s) gives its average, no lighter than 35 % so the
text stays readable, as strong as the glow and fading into the next as
it does. In the light theme, which has no glow, the panel is tinted from
it while the pointer is over it. What lights up in the panel takes a
scheme made from the same colour; the episode's thumbnail is no longer
used.

While the studyset holds none of the video's words, the study button
adds the words listed (Add 11 words, as the filter shows them), tagged
as a word added alone is; afterwards it studies them, and its menu adds
the new ones listed.

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 21:03:37 +02:00
commit d552f8bea0
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
14 changed files with 543 additions and 157 deletions

File diff suppressed because one or more lines are too long

View file

@ -1741,8 +1741,8 @@
"planNothing": "Gerade steht nichts an.", "planNothing": "Gerade steht nichts an.",
"planLearningPaths": "Lernpfade", "planLearningPaths": "Lernpfade",
"jellyfinTitle": "Jellyfin", "jellyfinTitle": "Jellyfin",
"jellyfinSubtitle": "Schau Serien und Filme von deinem eigenen Jellyfin-Server und lerne die Wörter ihrer Untertitel.", "jellyfinSubtitle": "Schau Serien und Filme und hör Musik von deinem eigenen Jellyfin-Server und lerne die Wörter ihrer Untertitel und Liedtexte. Angemeldet bietet „Material hinzufügen“ eines Lernsets „Aus Jellyfin“ an.",
"jellyfinPrivacy": "Die Adresse deines Servers und deine Anmeldung bleiben auf diesem Gerät, und die mit einem Lernset verbundenen Serien folgen dir als IDs, nie als Titel. Um die Wörter einer Zeile zu finden, fragt die App die Instanz nach ihrem Text; sie antwortet und speichert ihn nicht.", "jellyfinPrivacy": "Die Adresse deines Servers und deine Anmeldung bleiben auf diesem Gerät, und was du mit einem Lernset verbindest, folgt dir als IDs, nie als Titel. Um die Wörter einer Zeile zu finden, fragt die App die Instanz nach ihrem Text; sie antwortet und speichert ihn nicht. Wörter, die du hinzufügst, bekommen Tags, wo du ihnen begegnet bist, gespeichert mit deinen Karteikarten wie jeder Tag.",
"jellyfinSignedInAs": "{user} auf {server}", "jellyfinSignedInAs": "{user} auf {server}",
"jellyfinSignOut": "Von Jellyfin abmelden", "jellyfinSignOut": "Von Jellyfin abmelden",
"jellyfinAddress": "Serveradresse", "jellyfinAddress": "Serveradresse",
@ -1761,11 +1761,11 @@
"jellyfinWrongCredentials": "Benutzername oder Passwort stimmt nicht.", "jellyfinWrongCredentials": "Benutzername oder Passwort stimmt nicht.",
"jellyfinSignedOut": "Du wurdest von Jellyfin abgemeldet. Melde dich erneut an.", "jellyfinSignedOut": "Du wurdest von Jellyfin abgemeldet. Melde dich erneut an.",
"jellyfinFailed": "Das hat auf dem Jellyfin-Server nicht geklappt. Versuch es noch einmal.", "jellyfinFailed": "Das hat auf dem Jellyfin-Server nicht geklappt. Versuch es noch einmal.",
"studysetWatch": "Von Jellyfin schauen", "studysetWatch": "Aus Jellyfin",
"jellyfinBrowseTitle": "Schauen", "jellyfinBrowseTitle": "Jellyfin",
"jellyfinTied": "Mit diesem Lernset verbunden", "jellyfinTied": "Mit diesem Lernset verbunden",
"jellyfinLibraries": "Mediatheken", "jellyfinLibraries": "Mediatheken",
"jellyfinSearchHint": "Serien und Filme suchen", "jellyfinSearchHint": "Serien, Filme und Musik suchen",
"jellyfinTie": "Mit diesem Lernset verbinden", "jellyfinTie": "Mit diesem Lernset verbinden",
"jellyfinUntie": "Vom Lernset lösen", "jellyfinUntie": "Vom Lernset lösen",
"jellyfinEmpty": "Hier ist nichts", "jellyfinEmpty": "Hier ist nichts",
@ -1802,12 +1802,15 @@
"watchWider": "Längere Wörter hier", "watchWider": "Längere Wörter hier",
"watchWordsTitleSong": "Wörter in diesem Lied", "watchWordsTitleSong": "Wörter in diesem Lied",
"watchStudyWordsSong": "Die Wörter dieses Lieds lernen", "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.", "watchNoLyrics": "Dieses Lied hat keinen Liedtext, es gibt nichts nachzuschlagen.",
"jellyfinNoLyrics": "Kein Liedtext", "jellyfinNoLyrics": "Kein Liedtext",
"watchStudy": "Lernen", "watchStudy": "Lernen",
"watchStudyWords": "Die Wörter dieses Videos lernen", "watchStudyWords": "Die Wörter dieses Videos lernen",
"watchStudyNothing": "Das Lernset enthält noch keins der Wörter dieses Videos", "watchAddListed": "{count, plural, =1{1 Wort hinzufügen} other{{count} Wörter hinzufügen}}",
"watchAddListedHint": "Fügt die aufgelisteten Wörter dem Lernset hinzu, mit Tags, wo sie vorkamen, um sie zu lernen",
"watchAddNothing": "Keine neuen Wörter in der Liste: ändere den Filter",
"watchAddNew": "{count, plural, =1{Das 1 neue Wort der Liste hinzufügen} other{Die {count} neuen Wörter der Liste hinzufügen}}",
"watchAddedWords": "{count, plural, =0{Keine Wörter hinzugefügt} =1{1 Wort hinzugefügt} other{{count} Wörter hinzugefügt}}",
"watchStudyScheduled": "Lernen, nach Plan", "watchStudyScheduled": "Lernen, nach Plan",
"watchStudyCram": "Pauken, jedes einmal", "watchStudyCram": "Pauken, jedes einmal",
"watchFilter": "Wörter filtern", "watchFilter": "Wörter filtern",

View file

@ -2052,8 +2052,8 @@
"planNothing": "Nothing due right now.", "planNothing": "Nothing due right now.",
"planLearningPaths": "Learning paths", "planLearningPaths": "Learning paths",
"jellyfinTitle": "Jellyfin", "jellyfinTitle": "Jellyfin",
"jellyfinSubtitle": "Watch shows and films from your own Jellyfin server, and learn the words of their subtitles.", "jellyfinSubtitle": "Watch shows and films and listen to music from your own Jellyfin server, and learn the words of their subtitles and lyrics. Signed in, a studyset's Add material offers From Jellyfin.",
"jellyfinPrivacy": "Your server's address and your sign-in stay on this device, and the shows tied to a studyset follow you as ids, never titles. To find the words of a line, the app asks the instance about its text, which it answers and doesn't keep.", "jellyfinPrivacy": "Your server's address and your sign-in stay on this device, and what you tie to a studyset follows you as ids, never titles. To find the words of a line, the app asks the instance about its text, which it answers and doesn't keep. Words you add are tagged with where you met them, kept with your flashcards like any tag.",
"jellyfinSignedInAs": "{user} on {server}", "jellyfinSignedInAs": "{user} on {server}",
"@jellyfinSignedInAs": {"placeholders": {"user": {"type": "String"}, "server": {"type": "String"}}}, "@jellyfinSignedInAs": {"placeholders": {"user": {"type": "String"}, "server": {"type": "String"}}},
"jellyfinSignOut": "Sign out of Jellyfin", "jellyfinSignOut": "Sign out of Jellyfin",
@ -2073,11 +2073,11 @@
"jellyfinWrongCredentials": "The user name or password is wrong.", "jellyfinWrongCredentials": "The user name or password is wrong.",
"jellyfinSignedOut": "You were signed out of Jellyfin. Sign in again.", "jellyfinSignedOut": "You were signed out of Jellyfin. Sign in again.",
"jellyfinFailed": "That didn't work on the Jellyfin server. Try again.", "jellyfinFailed": "That didn't work on the Jellyfin server. Try again.",
"studysetWatch": "Watch from Jellyfin", "studysetWatch": "From Jellyfin",
"jellyfinBrowseTitle": "Watch", "jellyfinBrowseTitle": "Jellyfin",
"jellyfinTied": "Tied to this studyset", "jellyfinTied": "Tied to this studyset",
"jellyfinLibraries": "Libraries", "jellyfinLibraries": "Libraries",
"jellyfinSearchHint": "Search shows and films", "jellyfinSearchHint": "Search shows, films and music",
"jellyfinTie": "Tie to this studyset", "jellyfinTie": "Tie to this studyset",
"jellyfinUntie": "Untie from this studyset", "jellyfinUntie": "Untie from this studyset",
"jellyfinEmpty": "Nothing here", "jellyfinEmpty": "Nothing here",
@ -2116,12 +2116,18 @@
"watchWider": "Longer words here", "watchWider": "Longer words here",
"watchWordsTitleSong": "Words in this song", "watchWordsTitleSong": "Words in this song",
"watchStudyWordsSong": "Study this song's words", "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.", "watchNoLyrics": "This song has no lyrics, so there is nothing to look up.",
"jellyfinNoLyrics": "No lyrics", "jellyfinNoLyrics": "No lyrics",
"watchStudy": "Study", "watchStudy": "Study",
"watchStudyWords": "Study this video's words", "watchStudyWords": "Study this video's words",
"watchStudyNothing": "The studyset holds none of this video's words yet", "watchAddListed": "{count, plural, =1{Add 1 word} other{Add {count} words}}",
"@watchAddListed": {"placeholders": {"count": {"type": "int"}}},
"watchAddListedHint": "Adds the words listed to the studyset, tagged with where they were met, to study them",
"watchAddNothing": "No new words listed: change the filter",
"watchAddNew": "{count, plural, =1{Add the 1 new word listed} other{Add the {count} new words listed}}",
"@watchAddNew": {"placeholders": {"count": {"type": "int"}}},
"watchAddedWords": "{count, plural, =0{No words added} =1{1 word added} other{{count} words added}}",
"@watchAddedWords": {"placeholders": {"count": {"type": "int"}}},
"watchStudyScheduled": "Study, on their schedules", "watchStudyScheduled": "Study, on their schedules",
"watchStudyCram": "Cram, each once", "watchStudyCram": "Cram, each once",
"watchFilter": "Filter the words", "watchFilter": "Filter the words",

View file

@ -10290,13 +10290,13 @@ abstract class AppLocalizations {
/// No description provided for @jellyfinSubtitle. /// No description provided for @jellyfinSubtitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Watch shows and films from your own Jellyfin server, and learn the words of their subtitles.'** /// **'Watch shows and films and listen to music from your own Jellyfin server, and learn the words of their subtitles and lyrics. Signed in, a studyset\'s Add material offers From Jellyfin.'**
String get jellyfinSubtitle; String get jellyfinSubtitle;
/// No description provided for @jellyfinPrivacy. /// No description provided for @jellyfinPrivacy.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Your server\'s address and your sign-in stay on this device, and the shows tied to a studyset follow you as ids, never titles. To find the words of a line, the app asks the instance about its text, which it answers and doesn\'t keep.'** /// **'Your server\'s address and your sign-in stay on this device, and what you tie to a studyset follows you as ids, never titles. To find the words of a line, the app asks the instance about its text, which it answers and doesn\'t keep. Words you add are tagged with where you met them, kept with your flashcards like any tag.'**
String get jellyfinPrivacy; String get jellyfinPrivacy;
/// No description provided for @jellyfinSignedInAs. /// No description provided for @jellyfinSignedInAs.
@ -10410,13 +10410,13 @@ abstract class AppLocalizations {
/// No description provided for @studysetWatch. /// No description provided for @studysetWatch.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Watch from Jellyfin'** /// **'From Jellyfin'**
String get studysetWatch; String get studysetWatch;
/// No description provided for @jellyfinBrowseTitle. /// No description provided for @jellyfinBrowseTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Watch'** /// **'Jellyfin'**
String get jellyfinBrowseTitle; String get jellyfinBrowseTitle;
/// No description provided for @jellyfinTied. /// No description provided for @jellyfinTied.
@ -10434,7 +10434,7 @@ abstract class AppLocalizations {
/// No description provided for @jellyfinSearchHint. /// No description provided for @jellyfinSearchHint.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Search shows and films'** /// **'Search shows, films and music'**
String get jellyfinSearchHint; String get jellyfinSearchHint;
/// No description provided for @jellyfinTie. /// No description provided for @jellyfinTie.
@ -10653,12 +10653,6 @@ abstract class AppLocalizations {
/// **'Study this song\'s words'** /// **'Study this song\'s words'**
String get watchStudyWordsSong; 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. /// No description provided for @watchNoLyrics.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -10683,11 +10677,35 @@ abstract class AppLocalizations {
/// **'Study this video\'s words'** /// **'Study this video\'s words'**
String get watchStudyWords; String get watchStudyWords;
/// No description provided for @watchStudyNothing. /// No description provided for @watchAddListed.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'The studyset holds none of this video\'s words yet'** /// **'{count, plural, =1{Add 1 word} other{Add {count} words}}'**
String get watchStudyNothing; String watchAddListed(int count);
/// No description provided for @watchAddListedHint.
///
/// In en, this message translates to:
/// **'Adds the words listed to the studyset, tagged with where they were met, to study them'**
String get watchAddListedHint;
/// No description provided for @watchAddNothing.
///
/// In en, this message translates to:
/// **'No new words listed: change the filter'**
String get watchAddNothing;
/// No description provided for @watchAddNew.
///
/// In en, this message translates to:
/// **'{count, plural, =1{Add the 1 new word listed} other{Add the {count} new words listed}}'**
String watchAddNew(int count);
/// No description provided for @watchAddedWords.
///
/// In en, this message translates to:
/// **'{count, plural, =0{No words added} =1{1 word added} other{{count} words added}}'**
String watchAddedWords(int count);
/// No description provided for @watchStudyScheduled. /// No description provided for @watchStudyScheduled.
/// ///

View file

@ -6492,11 +6492,11 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get jellyfinSubtitle => String get jellyfinSubtitle =>
'Schau Serien und Filme von deinem eigenen Jellyfin-Server und lerne die Wörter ihrer Untertitel.'; 'Schau Serien und Filme und hör Musik von deinem eigenen Jellyfin-Server und lerne die Wörter ihrer Untertitel und Liedtexte. Angemeldet bietet „Material hinzufügen“ eines Lernsets „Aus Jellyfin“ an.';
@override @override
String get jellyfinPrivacy => String get jellyfinPrivacy =>
'Die Adresse deines Servers und deine Anmeldung bleiben auf diesem Gerät, und die mit einem Lernset verbundenen Serien folgen dir als IDs, nie als Titel. Um die Wörter einer Zeile zu finden, fragt die App die Instanz nach ihrem Text; sie antwortet und speichert ihn nicht.'; 'Die Adresse deines Servers und deine Anmeldung bleiben auf diesem Gerät, und was du mit einem Lernset verbindest, folgt dir als IDs, nie als Titel. Um die Wörter einer Zeile zu finden, fragt die App die Instanz nach ihrem Text; sie antwortet und speichert ihn nicht. Wörter, die du hinzufügst, bekommen Tags, wo du ihnen begegnet bist, gespeichert mit deinen Karteikarten wie jeder Tag.';
@override @override
String jellyfinSignedInAs(String user, String server) { String jellyfinSignedInAs(String user, String server) {
@ -6563,10 +6563,10 @@ class AppLocalizationsDe extends AppLocalizations {
'Das hat auf dem Jellyfin-Server nicht geklappt. Versuch es noch einmal.'; 'Das hat auf dem Jellyfin-Server nicht geklappt. Versuch es noch einmal.';
@override @override
String get studysetWatch => 'Von Jellyfin schauen'; String get studysetWatch => 'Aus Jellyfin';
@override @override
String get jellyfinBrowseTitle => 'Schauen'; String get jellyfinBrowseTitle => 'Jellyfin';
@override @override
String get jellyfinTied => 'Mit diesem Lernset verbunden'; String get jellyfinTied => 'Mit diesem Lernset verbunden';
@ -6575,7 +6575,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get jellyfinLibraries => 'Mediatheken'; String get jellyfinLibraries => 'Mediatheken';
@override @override
String get jellyfinSearchHint => 'Serien und Filme suchen'; String get jellyfinSearchHint => 'Serien, Filme und Musik suchen';
@override @override
String get jellyfinTie => 'Mit diesem Lernset verbinden'; String get jellyfinTie => 'Mit diesem Lernset verbinden';
@ -6699,10 +6699,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get watchStudyWordsSong => 'Die Wörter dieses Lieds lernen'; 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 @override
String get watchNoLyrics => String get watchNoLyrics =>
'Dieses Lied hat keinen Liedtext, es gibt nichts nachzuschlagen.'; 'Dieses Lied hat keinen Liedtext, es gibt nichts nachzuschlagen.';
@ -6717,8 +6713,46 @@ class AppLocalizationsDe extends AppLocalizations {
String get watchStudyWords => 'Die Wörter dieses Videos lernen'; String get watchStudyWords => 'Die Wörter dieses Videos lernen';
@override @override
String get watchStudyNothing => String watchAddListed(int count) {
'Das Lernset enthält noch keins der Wörter dieses Videos'; String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Wörter hinzufügen',
one: '1 Wort hinzufügen',
);
return '$_temp0';
}
@override
String get watchAddListedHint =>
'Fügt die aufgelisteten Wörter dem Lernset hinzu, mit Tags, wo sie vorkamen, um sie zu lernen';
@override
String get watchAddNothing =>
'Keine neuen Wörter in der Liste: ändere den Filter';
@override
String watchAddNew(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Die $count neuen Wörter der Liste hinzufügen',
one: 'Das 1 neue Wort der Liste hinzufügen',
);
return '$_temp0';
}
@override
String watchAddedWords(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Wörter hinzugefügt',
one: '1 Wort hinzugefügt',
zero: 'Keine Wörter hinzugefügt',
);
return '$_temp0';
}
@override @override
String get watchStudyScheduled => 'Lernen, nach Plan'; String get watchStudyScheduled => 'Lernen, nach Plan';

View file

@ -6436,11 +6436,11 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get jellyfinSubtitle => String get jellyfinSubtitle =>
'Watch shows and films from your own Jellyfin server, and learn the words of their subtitles.'; 'Watch shows and films and listen to music from your own Jellyfin server, and learn the words of their subtitles and lyrics. Signed in, a studyset\'s Add material offers From Jellyfin.';
@override @override
String get jellyfinPrivacy => String get jellyfinPrivacy =>
'Your server\'s address and your sign-in stay on this device, and the shows tied to a studyset follow you as ids, never titles. To find the words of a line, the app asks the instance about its text, which it answers and doesn\'t keep.'; 'Your server\'s address and your sign-in stay on this device, and what you tie to a studyset follows you as ids, never titles. To find the words of a line, the app asks the instance about its text, which it answers and doesn\'t keep. Words you add are tagged with where you met them, kept with your flashcards like any tag.';
@override @override
String jellyfinSignedInAs(String user, String server) { String jellyfinSignedInAs(String user, String server) {
@ -6505,10 +6505,10 @@ class AppLocalizationsEn extends AppLocalizations {
'That didn\'t work on the Jellyfin server. Try again.'; 'That didn\'t work on the Jellyfin server. Try again.';
@override @override
String get studysetWatch => 'Watch from Jellyfin'; String get studysetWatch => 'From Jellyfin';
@override @override
String get jellyfinBrowseTitle => 'Watch'; String get jellyfinBrowseTitle => 'Jellyfin';
@override @override
String get jellyfinTied => 'Tied to this studyset'; String get jellyfinTied => 'Tied to this studyset';
@ -6517,7 +6517,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get jellyfinLibraries => 'Libraries'; String get jellyfinLibraries => 'Libraries';
@override @override
String get jellyfinSearchHint => 'Search shows and films'; String get jellyfinSearchHint => 'Search shows, films and music';
@override @override
String get jellyfinTie => 'Tie to this studyset'; String get jellyfinTie => 'Tie to this studyset';
@ -6640,10 +6640,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get watchStudyWordsSong => 'Study this song\'s words'; String get watchStudyWordsSong => 'Study this song\'s words';
@override
String get watchStudyNothingSong =>
'The studyset holds none of this song\'s words yet';
@override @override
String get watchNoLyrics => String get watchNoLyrics =>
'This song has no lyrics, so there is nothing to look up.'; 'This song has no lyrics, so there is nothing to look up.';
@ -6658,8 +6654,45 @@ class AppLocalizationsEn extends AppLocalizations {
String get watchStudyWords => 'Study this video\'s words'; String get watchStudyWords => 'Study this video\'s words';
@override @override
String get watchStudyNothing => String watchAddListed(int count) {
'The studyset holds none of this video\'s words yet'; String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Add $count words',
one: 'Add 1 word',
);
return '$_temp0';
}
@override
String get watchAddListedHint =>
'Adds the words listed to the studyset, tagged with where they were met, to study them';
@override
String get watchAddNothing => 'No new words listed: change the filter';
@override
String watchAddNew(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Add the $count new words listed',
one: 'Add the 1 new word listed',
);
return '$_temp0';
}
@override
String watchAddedWords(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count words added',
one: '1 word added',
zero: 'No words added',
);
return '$_temp0';
}
@override @override
String get watchStudyScheduled => 'Study, on their schedules'; String get watchStudyScheduled => 'Study, on their schedules';

View file

@ -116,6 +116,18 @@ final class const WatchState({
null => null, null => null,
}; };
/// The video's words the list shows, as [filter] admits them.
List<VideoWord> get listed => [
for (final word in words ?? const <VideoWord>[])
if (filter.admits(word.entry, held: known.contains(word.entry.ref))) word,
];
/// Of the words [listed], those the studyset lacks.
List<VideoWord> get listedFresh => [
for (final word in listed)
if (!known.contains(word.entry.ref)) word,
];
WatchState copyWith({ WatchState copyWith({
WatchStatus? status, WatchStatus? status,
Object? problem = _same, Object? problem = _same,
@ -202,8 +214,9 @@ const _same = Object();
/// Watches the video [itemId] of the Jellyfin server to learn its words /// Watches the video [itemId] of the Jellyfin server to learn its words
/// into the studyset [studysetId]: opens it where it was left off, follows /// into the studyset [studysetId]: opens it where it was left off, follows
/// it line by line, cuts the lines into the words of [dictionary] (none /// it line by line, cuts the lines into the words of [dictionary] (none
/// without one: the subtitles show as text), and adds a word as a card with /// without one: the subtitles show as text), and adds a word as a card,
/// the line it was met in. Tells the server how far it got as it goes. /// tagged with where it was met. Tells the server how far it got as it
/// goes.
class WatchCubit( class WatchCubit(
final JellyfinRepository _jellyfin, final JellyfinRepository _jellyfin,
final MediaPlayers _players, final MediaPlayers _players,
@ -497,6 +510,32 @@ class WatchCubit(
null => const [], null => const [],
}; };
/// Adds the words [WatchState.listed] that the studyset lacks, in the
/// list's order, tagged as a word added alone is (decided 2026-10-13:
/// what a studyset holding none of the video's words is offered in place
/// of studying); how many became cards.
Future<int> addListed() async {
final fresh = [for (final word in state.listedFresh) word.entry];
if (fresh.isEmpty) {
return 0;
}
final added = await _adder.add(
studysetId,
fresh,
jellyfinTagNames: _tagNames,
);
final progress = await _progress.of(studysetId);
if (!isClosed) {
emit(
state.copyWith(
known: {...state.known, for (final entry in fresh) entry.ref},
progress: progress,
),
);
}
return added.added;
}
/// Whether the studyset holds any word of the video, to study. /// Whether the studyset holds any word of the video, to study.
bool get holdsWords => bool get holdsWords =>
state.words?.any((word) => state.known.contains(word.entry.ref)) ?? false; state.words?.any((word) => state.known.contains(word.entry.ref)) ?? false;

View file

@ -12,9 +12,16 @@ import 'package:material_ui/material_ui.dart';
/// blurred, fading out towards its foot, where the subtitles are read, each /// blurred, fading out towards its foot, where the subtitles are read, each
/// copy fading into the next. In the dark theme only, as YouTube's: a glow /// copy fading into the next. In the dark theme only, as YouTube's: a glow
/// on a light page would wash the text out. /// on a light page would wash the text out.
///
/// Each copy's colour, its pixels' average, goes to [onColour], in either
/// theme: the words' panel beside the picture takes it (decided
/// 2026-10-13).
class const AmbientGlow({ class const AmbientGlow({
required final GlobalKey source, required final GlobalKey source,
required final Widget child, required final Widget child,
/// Told the picture's colour at each copy.
final ValueChanged<Color>? onColour,
super.key, super.key,
}) extends StatefulWidget { }) extends StatefulWidget {
/// How often the picture is copied. /// How often the picture is copied.
@ -38,7 +45,12 @@ class _AmbientGlowState() extends State<AmbientGlow> {
} }
Future<void> _copy() async { Future<void> _copy() async {
if (!mounted || Theme.of(context).brightness != Brightness.dark) { if (!mounted) {
return;
}
final dark = Theme.of(context).brightness == Brightness.dark;
final onColour = widget.onColour;
if (!dark && onColour == null) {
return; return;
} }
final boundary = widget.source.currentContext?.findRenderObject(); final boundary = widget.source.currentContext?.findRenderObject();
@ -52,8 +64,17 @@ class _AmbientGlowState() extends State<AmbientGlow> {
final copy = await boundary.toImage( final copy = await boundary.toImage(
pixelRatio: 32 / boundary.size.width, pixelRatio: 32 / boundary.size.width,
); );
if (!mounted) { final colour = onColour == null ? null : await averageColourOf(copy);
if (!mounted || !dark) {
copy.dispose(); copy.dispose();
}
if (!mounted) {
return;
}
if (colour != null) {
onColour?.call(colour);
}
if (!dark) {
return; return;
} }
setState(() => _copies.add(copy)); setState(() => _copies.add(copy));
@ -128,3 +149,38 @@ class _AmbientGlowState() extends State<AmbientGlow> {
); );
} }
} }
/// The average colour of [image]'s pixels, those wholly clear left out;
/// null when there are none, or they can't be read.
Future<Color?> averageColourOf(ui.Image image) async {
final data = await image.toByteData(
format: ui.ImageByteFormat.rawStraightRgba,
);
if (data == null) {
return null;
}
var red = 0;
var green = 0;
var blue = 0;
var count = 0;
for (var at = 0; at + 3 < data.lengthInBytes; at += 4) {
if (data.getUint8(at + 3) == 0) {
continue;
}
red += data.getUint8(at);
green += data.getUint8(at + 1);
blue += data.getUint8(at + 2);
count++;
}
return count == 0
? null
: Color.fromARGB(255, red ~/ count, green ~/ count, blue ~/ count);
}
/// The colour the words' panel takes from the picture's [colour] in the
/// dark theme: no lighter than 35 %, its hue and saturation kept, so the
/// theme's light text stays readable on it however bright the scene.
Color panelColourOf(Color colour) {
final hsl = HSLColor.fromColor(colour);
return hsl.lightness <= 0.35 ? colour : hsl.withLightness(0.35).toColor();
}

View file

@ -305,7 +305,7 @@ class _JellyfinAccountViewState() extends State<JellyfinAccountView> {
} }
return ListTile( return ListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.tv), leading: const Icon(LucideIcons.squarePlay),
title: Text( title: Text(
l10n.jellyfinSignedInAs(account.userName, account.server.name), l10n.jellyfinSignedInAs(account.userName, account.server.name),
), ),

View file

@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:clock/clock.dart'; import 'package:clock/clock.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@ -90,9 +91,13 @@ class _WatchViewState() extends State<_WatchView> {
/// The page of the player's settings shown, if open. /// The page of the player's settings shown, if open.
final _settings = ValueNotifier<WatchSettingsPage?>(null); final _settings = ValueNotifier<WatchSettingsPage?>(null);
/// The picture's colour now, as the glow last copied it; null until then.
final _colour = ValueNotifier<Color?>(null);
@override @override
void dispose() { void dispose() {
_settings.dispose(); _settings.dispose();
_colour.dispose();
super.dispose(); super.dispose();
} }
@ -194,13 +199,21 @@ class _WatchViewState() extends State<_WatchView> {
children: [ children: [
Expanded( Expanded(
child: song child: song
? _SongStage(wide: true, settings: _settings) ? _SongStage(
: _Stage(wide: true, settings: _settings), wide: true,
settings: _settings,
colour: _colour,
)
: _Stage(
wide: true,
settings: _settings,
colour: _colour,
),
), ),
const VerticalDivider(width: 1), const VerticalDivider(width: 1),
SizedBox( SizedBox(
width: 380, width: 380,
child: _Panel(dictionary: dictionary), child: _Panel(dictionary: dictionary, colour: _colour),
), ),
], ],
) )
@ -208,11 +221,21 @@ class _WatchViewState() extends State<_WatchView> {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
if (song) if (song)
_SongStage(wide: false, settings: _settings) _SongStage(
wide: false,
settings: _settings,
colour: _colour,
)
else else
_Stage(wide: false, settings: _settings), _Stage(
wide: false,
settings: _settings,
colour: _colour,
),
const Divider(height: 1), const Divider(height: 1),
Expanded(child: _Panel(dictionary: dictionary)), Expanded(
child: _Panel(dictionary: dictionary, colour: _colour),
),
], ],
), ),
), ),
@ -235,6 +258,9 @@ class _WatchViewState() extends State<_WatchView> {
class const _Stage({ class const _Stage({
required final bool wide, required final bool wide,
required final ValueNotifier<WatchSettingsPage?> settings, required final ValueNotifier<WatchSettingsPage?> settings,
/// Told the picture's colour as the glow copies it.
required final ValueNotifier<Color?> colour,
}) extends StatefulWidget { }) extends StatefulWidget {
@override @override
State<_Stage> createState() => _StageState(); State<_Stage> createState() => _StageState();
@ -257,6 +283,7 @@ class _StageState() extends State<_Stage> {
); );
return AmbientGlow( return AmbientGlow(
source: _picture, source: _picture,
onColour: (colour) => widget.colour.value = colour,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
@ -418,6 +445,9 @@ class const _LineWords({
class const _SongStage({ class const _SongStage({
required final bool wide, required final bool wide,
required final ValueNotifier<WatchSettingsPage?> settings, required final ValueNotifier<WatchSettingsPage?> settings,
/// Told the cover's colour as the glow copies it.
required final ValueNotifier<Color?> colour,
}) extends StatefulWidget { }) extends StatefulWidget {
@override @override
State<_SongStage> createState() => _SongStageState(); State<_SongStage> createState() => _SongStageState();
@ -460,6 +490,7 @@ class _SongStageState() extends State<_SongStage> {
); );
return AmbientGlow( return AmbientGlow(
source: _picture, source: _picture,
onColour: (colour) => widget.colour.value = colour,
child: widget.wide child: widget.wide
? Row( ? Row(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
@ -646,8 +677,12 @@ class const _Word({
/// Beside the video: the word chosen, or the video's words; the sources /// Beside the video: the word chosen, or the video's words; the sources
/// of the words pinned in its lower right corner (decided 2026-10-13). /// of the words pinned in its lower right corner (decided 2026-10-13).
class const _Panel({required final Dictionary? dictionary}) class const _Panel({
extends StatelessWidget { required final Dictionary? dictionary,
/// The picture's colour now, which the panel takes.
required final ValueListenable<Color?> colour,
}) extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
@ -662,6 +697,7 @@ class const _Panel({required final Dictionary? dictionary})
(cubit) => cubit.state.selectedPart != null, (cubit) => cubit.state.selectedPart != null,
); );
return _VideoTint( return _VideoTint(
colour: colour,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
@ -686,72 +722,71 @@ class const _Panel({required final Dictionary? dictionary})
} }
} }
/// The panel taking the video's colour while the pointer is over it, as /// The panel in the video's colour (decided 2026-10-13): in the dark theme
/// YouTube's description does (decided 2026-10-13): a Material colour /// always, the colour of the glow beside it as it changes with the scene
/// scheme made from the episode's picture on the server, which, as /// ([AmbientGlow]'s copy, no lighter than text stays readable on, as strong
/// YouTube's thumbnails, stays one for the whole video. /// as the glow and fading into the next as it does); in the light theme,
class const _VideoTint({required final Widget child}) extends StatefulWidget { /// which has no glow, a tint while the pointer is over it. What lights up
/// in it takes a colour scheme made from the same colour.
class const _VideoTint({
required final ValueListenable<Color?> colour,
required final Widget child,
}) extends StatefulWidget {
@override @override
State<_VideoTint> createState() => _VideoTintState(); State<_VideoTint> createState() => _VideoTintState();
} }
class _VideoTintState() extends State<_VideoTint> { class _VideoTintState() extends State<_VideoTint> {
ColorScheme? _scheme;
String? _of;
Brightness? _brightness;
bool _over = false; bool _over = false;
/// Makes the scheme of [item]'s picture, unless made already. /// The scheme made last, and what from.
void _schemeOf(JellyfinItem? item, Brightness brightness) { ColorScheme? _scheme;
if (item == null || (Color, Brightness)? _of;
!item.hasImage ||
(item.id == _of && brightness == _brightness)) {
return;
}
_of = item.id;
_brightness = brightness;
final image = DependenciesScope.of(
context,
).jellyfin.imageOf(item, height: 120);
if (image != null) {
unawaited(_make(NetworkImage('${image.url}', headers: image.headers)));
}
}
Future<void> _make(ImageProvider picture) async { ColorScheme? _schemeOf(Color? colour, Brightness brightness) {
try { if (colour == null) {
final scheme = await ColorScheme.fromImageProvider( return null;
provider: picture,
brightness: _brightness ?? Brightness.dark,
);
if (mounted) {
setState(() => _scheme = scheme);
}
} on Object {
// No picture to take colours from: the panel stays as it is.
} }
if (_of != (colour, brightness)) {
_of = (colour, brightness);
_scheme = ColorScheme.fromSeed(
seedColor: colour,
brightness: brightness,
);
}
return _scheme;
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
_schemeOf( final brightness = Theme.of(context).brightness;
context.select<WatchCubit, JellyfinItem?>((cubit) => cubit.state.item), final dark = brightness == Brightness.dark;
Theme.of(context).brightness, return ValueListenableBuilder(
); valueListenable: widget.colour,
return _VideoColour( builder: (context, colour, child) {
scheme: _scheme, final scheme = _schemeOf(colour, brightness);
child: MouseRegion( return _VideoColour(
onEnter: (_) => setState(() => _over = true), scheme: scheme,
onExit: (_) => setState(() => _over = false), child: MouseRegion(
child: AnimatedContainer( onEnter: (_) => setState(() => _over = true),
duration: const Duration(milliseconds: 250), onExit: (_) => setState(() => _over = false),
color: _over child: AnimatedContainer(
? _scheme?.secondaryContainer.withValues(alpha: 0.6) ?? // As the glow fades from one copy into the next.
Colors.transparent duration: Duration(milliseconds: dark ? 1500 : 250),
: Colors.transparent, color: switch ((colour, scheme)) {
child: widget.child, (final colour?, _) when dark => panelColourOf(
), colour,
), ).withValues(alpha: 0.45),
(_, final scheme?) when _over =>
scheme.secondaryContainer.withValues(alpha: 0.6),
_ => Colors.transparent,
},
child: child,
),
),
);
},
child: widget.child,
); );
} }
} }
@ -1133,14 +1168,7 @@ class const _VideoWords({required final Dictionary dictionary})
WatchCubit cubit() => context.read<WatchCubit>(); WatchCubit cubit() => context.read<WatchCubit>();
final state = context.watch<WatchCubit>().state; final state = context.watch<WatchCubit>().state;
final words = state.words; final words = state.words;
final shown = [ final shown = state.listed;
for (final word in words ?? const <VideoWord>[])
if (state.filter.admits(
word.entry,
held: state.known.contains(word.entry.ref),
))
word,
];
return CustomScrollView( return CustomScrollView(
slivers: [ slivers: [
SliverPadding( SliverPadding(
@ -1271,7 +1299,9 @@ class const _VideoWords({required final Dictionary dictionary})
/// Studying the words of this video (decided 2026-10-13), as a learning /// Studying the words of this video (decided 2026-10-13), as a learning
/// path's study button studies a studyset: Study, on their schedules, or /// path's study button studies a studyset: Study, on their schedules, or
/// Cram, every one once; the studyset's cards of its words, those added /// Cram, every one once; the studyset's cards of its words, those added
/// before it was watched too, which get its own tag for it. /// before it was watched too, which get its own tag for it. While the
/// studyset holds none of them, it adds the words listed instead; then its
/// menu still adds the new ones listed.
class const _StudyWords() extends StatelessWidget { class const _StudyWords() extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -1282,6 +1312,29 @@ class const _StudyWords() extends StatelessWidget {
final song = context.select<WatchCubit, bool>( final song = context.select<WatchCubit, bool>(
(cubit) => cubit.state.song, (cubit) => cubit.state.song,
); );
final fresh = context.select<WatchCubit, int>(
(cubit) => cubit.state.listedFresh.length,
);
Future<void> add() async {
final messenger = ScaffoldMessenger.of(context);
final added = await context.read<WatchCubit>().addListed();
messenger.showSnackBar(
SnackBar(content: Text(l10n.watchAddedWords(added))),
);
}
if (!holds) {
return Tooltip(
message: fresh > 0 ? l10n.watchAddListedHint : l10n.watchAddNothing,
child: FilledButton.tonalIcon(
style: FilledButton.styleFrom(visualDensity: VisualDensity.compact),
icon: const Icon(LucideIcons.plus, size: 18),
label: Text(l10n.watchAddListed(fresh)),
onPressed: fresh > 0 ? add : null,
),
);
}
Future<void> study({required bool cram}) async { Future<void> study({required bool cram}) async {
final router = GoRouter.of(context); final router = GoRouter.of(context);
final cubit = context.read<WatchCubit>(); final cubit = context.read<WatchCubit>();
@ -1305,14 +1358,17 @@ class const _StudyWords() extends StatelessWidget {
onPressed: () => study(cram: true), onPressed: () => study(cram: true),
child: Text(l10n.watchStudyCram), child: Text(l10n.watchStudyCram),
), ),
if (fresh > 0) ...[
const Divider(height: 1),
MenuItemButton(
leadingIcon: const Icon(LucideIcons.plus),
onPressed: add,
child: Text(l10n.watchAddNew(fresh)),
),
],
], ],
builder: (context, controller, _) => Tooltip( builder: (context, controller, _) => Tooltip(
message: switch ((holds, song)) { message: song ? l10n.watchStudyWordsSong : l10n.watchStudyWords,
(true, false) => l10n.watchStudyWords,
(true, true) => l10n.watchStudyWordsSong,
(false, false) => l10n.watchStudyNothing,
(false, true) => l10n.watchStudyNothingSong,
},
child: FilledButton.tonalIcon( child: FilledButton.tonalIcon(
style: FilledButton.styleFrom(visualDensity: VisualDensity.compact), style: FilledButton.styleFrom(visualDensity: VisualDensity.compact),
icon: const Icon(LucideIcons.graduationCap, size: 18), icon: const Icon(LucideIcons.graduationCap, size: 18),
@ -1323,9 +1379,8 @@ class const _StudyWords() extends StatelessWidget {
const Icon(LucideIcons.chevronDown, size: 16), const Icon(LucideIcons.chevronDown, size: 16),
], ],
), ),
onPressed: holds onPressed: () =>
? () => controller.isOpen ? controller.close() : controller.open() controller.isOpen ? controller.close() : controller.open(),
: null,
), ),
), ),
); );

View file

@ -1272,23 +1272,49 @@ class const _StudyPill({
} }
/// What can be added to the studyset, behind [builder]'s trigger: a /// What can be added to the studyset, behind [builder]'s trigger: a
/// learning path, flashcards, a note, a PDF, and words from the dictionary /// learning path, flashcards, a note, a PDF, words from a video or song of
/// of its language where the instance has one. What is added shows in its /// the person's Jellyfin server once this device is signed in to one (in
/// own tab ([ContentTabCubit]): what is written there once the person comes /// Settings, decided 2026-10-13: as the assistant shows only once switched
/// back, what is uploaded once it is in. Imports are under the options. /// on), and words from the dictionary of its language where the instance
/// has one. What is added shows in its own tab ([ContentTabCubit]): what is
/// written there once the person comes back, what is uploaded once it is
/// in. Imports are under the options.
class const _AddMaterial({ class const _AddMaterial({
required final String studysetId, required final String studysetId,
required final MenuController controller, required final MenuController controller,
required final Widget Function(BuildContext context, MenuController menu) required final Widget Function(BuildContext context, MenuController menu)
builder, builder,
}) extends StatelessWidget { }) extends StatefulWidget {
@override @override
Widget build(BuildContext context) => StudysetDictionary(builder: _menu); State<_AddMaterial> createState() => _AddMaterialState();
}
Widget _menu(BuildContext context, Dictionary? dictionary) { class _AddMaterialState() extends State<_AddMaterial> {
@override
void initState() {
super.initState();
// What the device keeps of Jellyfin, read once; no server is asked.
unawaited(DependenciesScope.of(context).jellyfin.open());
}
@override
Widget build(BuildContext context) => ValueListenableBuilder(
valueListenable: DependenciesScope.of(context).jellyfin.state,
builder: (context, jellyfin, _) => StudysetDictionary(
builder: (context, dictionary) =>
_menu(context, dictionary, jellyfin: jellyfin?.account != null),
),
);
Widget _menu(
BuildContext context,
Dictionary? dictionary, {
required bool jellyfin,
}) {
final studysetId = widget.studysetId;
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
return MenuAnchor( return MenuAnchor(
controller: controller, controller: widget.controller,
menuChildren: [ menuChildren: [
MenuItemButton( MenuItemButton(
leadingIcon: const Icon(LucideIcons.route), leadingIcon: const Icon(LucideIcons.route),
@ -1323,11 +1349,12 @@ class const _AddMaterial({
onPressed: () => _uploadPdf(context), onPressed: () => _uploadPdf(context),
child: Text(l10n.pdfUploadMenu), child: Text(l10n.pdfUploadMenu),
), ),
MenuItemButton( if (jellyfin)
leadingIcon: const Icon(LucideIcons.tv), MenuItemButton(
onPressed: () => context.push(RoutePaths.jellyfin(studysetId)), leadingIcon: const Icon(LucideIcons.squarePlay),
child: Text(l10n.studysetWatch), onPressed: () => context.push(RoutePaths.jellyfin(studysetId)),
), child: Text(l10n.studysetWatch),
),
// Only for a language the instance has a dictionary of. // Only for a language the instance has a dictionary of.
if (dictionary != null) if (dictionary != null)
MenuItemButton( MenuItemButton(
@ -1339,7 +1366,7 @@ class const _AddMaterial({
child: Text(l10n.studysetFromDictionary), child: Text(l10n.studysetFromDictionary),
), ),
], ],
builder: (context, menu, _) => builder(context, menu), builder: (context, menu, _) => widget.builder(context, menu),
); );
} }

View file

@ -0,0 +1,62 @@
import 'dart:async';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/jellyfin/widget/ambient_glow.dart';
import 'package:material_ui/material_ui.dart';
/// An image one pixel high, of [colors] in turn.
Future<ui.Image> pixels(List<Color> colors) {
final bytes = Uint8List.fromList([
for (final color in colors) ...[
(color.r * 255).round(),
(color.g * 255).round(),
(color.b * 255).round(),
(color.a * 255).round(),
],
]);
final done = Completer<ui.Image>();
ui.decodeImageFromPixels(
bytes,
colors.length,
1,
ui.PixelFormat.rgba8888,
done.complete,
);
return done.future;
}
void main() {
testWidgets("a picture's colour is its pixels' average, the clear ones "
'left out', (tester) async {
final colour = await tester.runAsync(() async {
final image = await pixels(const [
Color(0xFFFF0000),
Color(0xFF0000FF),
Color(0x00FFFFFF),
]);
try {
return await averageColourOf(image);
} finally {
image.dispose();
}
});
expect(colour, const Color(0xFF7F007F));
});
test('the panel takes the colour no lighter than text stays readable '
'on, keeping its hue', () {
final snow = panelColourOf(const Color(0xFFF4F8FF));
final night = panelColourOf(const Color(0xFF101830));
expect(HSLColor.fromColor(snow).lightness, closeTo(0.35, 0.01));
expect(
HSLColor.fromColor(snow).hue,
closeTo(HSLColor.fromColor(const Color(0xFFF4F8FF)).hue, 1),
);
// Dark enough already: as it is.
expect(night, const Color(0xFF101830));
});
}

View file

@ -205,16 +205,27 @@ void main() {
expect(find.text('Libraries'), findsNothing); expect(find.text('Libraries'), findsNothing);
}); });
testWidgets("the studyset's Add material offers watching from Jellyfin", ( testWidgets("the studyset's Add material offers Jellyfin only once this "
tester, 'device is signed in to a server (in Settings), as the assistant '
) async { 'shows only once switched on', (tester) async {
await show(tester, RoutePaths.studyset('japanese')); await show(tester, RoutePaths.studyset('japanese'));
await tester.tap(find.text('Add material')); await tester.tap(find.text('Add material'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('Watch from Jellyfin'));
expect(find.text('Create a path'), findsOneWidget);
expect(find.text('From Jellyfin'), findsNothing);
});
testWidgets('signed in, Add material › From Jellyfin opens its '
'libraries', (tester) async {
store = signedInJellyfin();
await show(tester, RoutePaths.studyset('japanese'));
await tester.tap(find.text('Add material'));
await tester.pumpAndSettle();
await tester.tap(find.text('From Jellyfin'));
await tester.pump(); await tester.pump();
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.widgetWithText(TextField, 'Server address'), findsOneWidget); expect(find.text('Libraries'), findsOneWidget);
}); });
} }

View file

@ -196,12 +196,21 @@ void main() {
// 駅 and 友達 come twice; で, に, を, けど and だ are grammar. // 駅 and 友達 come twice; で, に, を, けど and だ are grammar.
expect(words.take(2).toSet(), {'駅', '友達'}); expect(words.take(2).toSet(), {'駅', '友達'});
expect(words, isNot(contains('で'))); expect(words, isNot(contains('で')));
expect(words, contains('待つ'));
final wait = find.textContaining('待つ', findRichText: true).first; // Further down the list.
await tester.ensureVisible(wait); final wait = find.textContaining('待つ', findRichText: true);
await tester.scrollUntilVisible(
wait,
100,
scrollable: find
.descendant(
of: find.byType(CustomScrollView),
matching: find.byType(Scrollable),
)
.first,
);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(wait); await tester.tap(wait.first);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(players.last!.state.value.position, const Duration(seconds: 16)); expect(players.last!.state.value.position, const Duration(seconds: 16));
expect(find.text('A form of 待つ'), findsOneWidget); expect(find.text('A form of 待つ'), findsOneWidget);
@ -602,6 +611,11 @@ void main() {
await tester.tap(find.text('Study')); await tester.tap(find.text('Study'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// The words listed it lacks can be added from here too.
expect(
find.textContaining(RegExp(r'^Add the \d+ new words listed$')),
findsOneWidget,
);
await tester.tap(find.text('Cram, each once')); await tester.tap(find.text('Cram, each once'));
await tester.pump(); await tester.pump();
await tester.pump(const Duration(seconds: 1)); await tester.pump(const Duration(seconds: 1));
@ -627,6 +641,34 @@ void main() {
); );
}); });
testWidgets("with none of the video's words held, the button adds the "
'words listed, tagged from Jellyfin, and then studies them', (
tester,
) async {
await watch(tester);
final add = find.textContaining(RegExp(r'^Add \d+ words$'));
expect(add, findsOneWidget);
final listed = int.parse(
RegExp(r'\d+').firstMatch(tester.widget<Text>(add).data!)![0]!,
);
expect(listed, greaterThan(1));
await tester.tap(add);
await tester.pumpAndSettle();
expect(find.text('$listed words added'), findsOneWidget);
final added = await cards.cardsOf('japanese');
expect(added, hasLength(listed));
final tagging = await tags.read();
for (final card in added) {
expect(
[for (final tag in tagging.of(card.id)) tag.name],
containsAll(['Sample Journey', 'Sample Journey S1 E1']),
);
}
expect(find.text('Study'), findsOneWidget);
});
testWidgets("a song's lyrics move with it, its words looked up as a " 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", ( "video's; a word added is tagged with the artist and the song", (
tester, tester,