feat(jellyfin): From Jellyfin once signed in, the panel in the glow's colour, adding the words listed
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:
parent
c6e7a53dd6
commit
d552f8bea0
14 changed files with 543 additions and 157 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
///
|
///
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue