feat(jellyfin): an episode's own tag, and studying an episode's words

A word added from an episode gets the show's tag and the episode's own ("あかね噺 S1 E1"), hidden on cards. The words panel's Study button studies the studyset's cards of the video's words, on their schedules or crammed (each once): it pauses the video and first gives the episode's tag to the cards of its words the studyset held before, then opens the session on that tag. A study focus can now ask for cramming.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-13 19:32:37 +02:00
commit 436dc884b6
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
12 changed files with 266 additions and 10 deletions

View file

@ -1800,6 +1800,11 @@
"watchProgressLearned": "Gelernt · {days, plural, =0{heute wieder} =1{morgen wieder} other{in {days} Tagen wieder}}",
"watchJisho": "Auf Jisho nachschlagen",
"watchWider": "Längere Wörter hier",
"watchStudy": "Lernen",
"watchStudyWords": "Die Wörter dieses Videos lernen",
"watchStudyNothing": "Das Lernset enthält noch keins der Wörter dieses Videos",
"watchStudyScheduled": "Lernen, nach Plan",
"watchStudyCram": "Pauken, jedes einmal",
"watchFilter": "Wörter filtern",
"watchFilterFresh": "Neue Wörter",
"watchFilterHeld": "Wörter im Lernset",

View file

@ -2114,6 +2114,11 @@
"@watchProgressLearned": {"placeholders": {"days": {"type": "int"}}},
"watchJisho": "Look up on Jisho",
"watchWider": "Longer words here",
"watchStudy": "Study",
"watchStudyWords": "Study this video's words",
"watchStudyNothing": "The studyset holds none of this video's words yet",
"watchStudyScheduled": "Study, on their schedules",
"watchStudyCram": "Cram, each once",
"watchFilter": "Filter the words",
"watchFilterFresh": "New words",
"watchFilterHeld": "Words in the studyset",

View file

@ -10641,6 +10641,36 @@ abstract class AppLocalizations {
/// **'Longer words here'**
String get watchWider;
/// No description provided for @watchStudy.
///
/// In en, this message translates to:
/// **'Study'**
String get watchStudy;
/// No description provided for @watchStudyWords.
///
/// In en, this message translates to:
/// **'Study this video\'s words'**
String get watchStudyWords;
/// No description provided for @watchStudyNothing.
///
/// In en, this message translates to:
/// **'The studyset holds none of this video\'s words yet'**
String get watchStudyNothing;
/// No description provided for @watchStudyScheduled.
///
/// In en, this message translates to:
/// **'Study, on their schedules'**
String get watchStudyScheduled;
/// No description provided for @watchStudyCram.
///
/// In en, this message translates to:
/// **'Cram, each once'**
String get watchStudyCram;
/// No description provided for @watchFilter.
///
/// In en, this message translates to:

View file

@ -6691,6 +6691,22 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get watchWider => 'Längere Wörter hier';
@override
String get watchStudy => 'Lernen';
@override
String get watchStudyWords => 'Die Wörter dieses Videos lernen';
@override
String get watchStudyNothing =>
'Das Lernset enthält noch keins der Wörter dieses Videos';
@override
String get watchStudyScheduled => 'Lernen, nach Plan';
@override
String get watchStudyCram => 'Pauken, jedes einmal';
@override
String get watchFilter => 'Wörter filtern';

View file

@ -6633,6 +6633,22 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get watchWider => 'Longer words here';
@override
String get watchStudy => 'Study';
@override
String get watchStudyWords => 'Study this video\'s words';
@override
String get watchStudyNothing =>
'The studyset holds none of this video\'s words yet';
@override
String get watchStudyScheduled => 'Study, on their schedules';
@override
String get watchStudyCram => 'Cram, each once';
@override
String get watchFilter => 'Filter the words';

View file

@ -3,6 +3,7 @@ import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
/// What adding entries came to: how many became cards, and how many the
/// studyset held already.
@ -34,6 +35,23 @@ class DictionaryAdder(final CardRepository _cards, final TagRepository _tags) {
?card.content.entry,
};
/// Gives every card of [studysetId] made from one of [entries] the tag
/// [tagName] (made hidden on cards if new): the words of an episode the
/// studyset held before it was watched too. The tag as something refers
/// to it.
Future<TagRef> tagHeld(
String studysetId,
Set<DictionaryRef> entries,
String tagName,
) => _queue.run(() async {
final tagId = await _tags.create(tagName, hidden: true);
await _tags.addTags({
for (final card in await _cards.cardsOf(studysetId))
if (entries.contains(card.content.entry)) card.id: {tagId},
});
return (id: tagId, name: tidyTagName(tagName));
});
/// Adds [words] to [studysetId], at its end, in their order; each card
/// also tagged [tagNames] (the show a word was met in) and
/// [hiddenTagNames], tags made hidden on cards if they are new (the

View file

@ -15,6 +15,7 @@ import 'package:halkyon_learn_flutter/src/feature/jellyfin/data/jellyfin_reposit
import 'package:halkyon_learn_flutter/src/feature/jellyfin/model/jellyfin.dart';
import 'package:halkyon_learn_flutter/src/feature/jellyfin/model/word_filter.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/card_schedule.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/study_focus.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
/// How far opening a video got.
@ -432,7 +433,6 @@ class WatchCubit(
return;
}
final line = state.lines[selected.line].text;
final item = state.item;
await _adder.add(
studysetId,
[
@ -445,7 +445,8 @@ class WatchCubit(
},
)),
],
tagNames: [?(item?.seriesName ?? item?.name)],
tagNames: [?_tags?.shown],
hiddenTagNames: [?_tags?.hidden],
);
final progress = await _progress.of(studysetId);
if (!isClosed) {
@ -482,6 +483,35 @@ class WatchCubit(
/// Plays the line after the one shown.
Future<void> nextLine() => playLine((state.line ?? -1) + 1);
/// The tags a card from this video gets.
({String? shown, String? hidden})? get _tags => switch (state.item) {
final item? => jellyfinTagsOf(item),
null => null,
};
/// Whether the studyset holds any word of the video, to study.
bool get holdsWords =>
state.words?.any((word) => state.known.contains(word.entry.ref)) ?? false;
/// Studying the words of this video (decided 2026-10-13): pauses it,
/// gives the studyset's cards of its words its own tag (those held before
/// it was watched too), and gives the focus studying that tag's cards,
/// [cram] or on their schedules; null without the video's words yet.
Future<StudyFocus?> studyWords({required bool cram}) async {
final item = state.item;
final words = state.words;
if (item == null || words == null) {
return null;
}
if (_playback case final playback? when playback.state.value.playing) {
await playback.pause();
}
final tag = await _adder.tagHeld(studysetId, {
for (final word in words) word.entry.ref,
}, jellyfinTagsOf(item).hidden ?? item.name);
return StudyFocus(tag: tag, cram: cram);
}
/// Plays, or pauses.
Future<void> playOrPause() async {
final playback = _playback;

View file

@ -265,6 +265,25 @@ final class const JellyfinState({
List<Object?> get props => [deviceId, account];
}
/// The tags a card made from [item] gets (decided 2026-10-13): the show's
/// (or the film's or video's own) name, shown on cards; and the episode's
/// own, "あかね噺 S1 E1", hidden on cards (as many would gather on a common
/// word), so the episode's words can be studied together.
({String? shown, String? hidden}) jellyfinTagsOf(JellyfinItem item) =>
switch (item) {
JellyfinItem(
seriesName: final series?,
seasonNumber: final season?,
episodeNumber: final episode?,
) =>
(shown: series, hidden: '$series S$season E$episode'),
JellyfinItem(seriesName: final series?) => (
shown: series,
hidden: '$series · ${item.name}',
),
_ => (shown: item.name, hidden: null),
};
/// Jellyfin's languages (ISO 639-2, both the bibliographic and the
/// terminological codes) for a language as studysets name it (BCP 47), so
/// a studyset's front language finds its subtitles: `ja` → `jpn`.

View file

@ -3,9 +3,11 @@ import 'dart:async';
import 'package:clock/clock.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/theme/theme_extras.dart';
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/vocabulary_senses.dart';
@ -973,6 +975,7 @@ class const _VideoWords({required final Dictionary dictionary})
style: theme.textTheme.titleMedium,
),
),
const _StudyWords(),
const _WordsFilter(),
],
),
@ -1081,6 +1084,62 @@ class const _VideoWords({required final Dictionary dictionary})
}
}
/// 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
/// 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.
class const _StudyWords() extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final cubit = context.read<WatchCubit>();
final holds = context.select<WatchCubit, bool>(
(cubit) => cubit.holdsWords,
);
Future<void> study({required bool cram}) async {
final router = GoRouter.of(context);
final focus = await cubit.studyWords(cram: cram);
if (focus != null) {
await router.push(
RoutePaths.study(cubit.studysetId, query: focus.toQuery()),
);
}
}
return MenuAnchor(
menuChildren: [
MenuItemButton(
leadingIcon: const Icon(LucideIcons.graduationCap),
onPressed: () => study(cram: false),
child: Text(l10n.watchStudyScheduled),
),
MenuItemButton(
leadingIcon: const Icon(LucideIcons.zap),
onPressed: () => study(cram: true),
child: Text(l10n.watchStudyCram),
),
],
builder: (context, controller, _) => Tooltip(
message: holds ? l10n.watchStudyWords : l10n.watchStudyNothing,
child: FilledButton.tonalIcon(
style: FilledButton.styleFrom(visualDensity: VisualDensity.compact),
icon: const Icon(LucideIcons.graduationCap, size: 18),
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(l10n.watchStudy),
const Icon(LucideIcons.chevronDown, size: 16),
],
),
onPressed: holds
? () => controller.isOpen ? controller.close() : controller.open()
: null,
),
),
);
}
}
/// The words' filter (decided 2026-10-13): a filter button, dotted while
/// the list is narrowed, opening a menu of whether the studyset holds them
/// (new, held, all), their JLPT levels and their word classes, several of

View file

@ -200,7 +200,10 @@ class StudySessionBloc(
emit(StudySessionStateLoading(backFirst: _options.backFirst));
_options = (await _settings.read()).study;
if (_focus case final focus?) {
_options = _options.copyWith(backFirst: focus.backFirst);
_options = _options.copyWith(
backFirst: focus.backFirst,
mode: focus.cram ? LearnMode.cram : null,
);
}
_roundNumber++;
_ratings.clear();

View file

@ -3,8 +3,9 @@ import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
/// What a study button asks of one session, beside the person's settings:
/// the cards with its [tag] in place of the tags the settings choose, the
/// studyset with its [topics] or alone, and whether cards ask their back
/// first. Nothing of it is saved.
/// studyset with its [topics] or alone, whether cards ask their back first,
/// and whether to cram them, outside their schedules (the Watch screen's
/// Cram, 2026-10-13). Nothing of it is saved.
final class const StudyFocus({
/// The tag whose cards alone are studied; null for the settings' choice.
final TagRef? tag,
@ -15,6 +16,10 @@ final class const StudyFocus({
/// Whether front/back and vocabulary cards ask their back; null for as
/// the settings have it.
final bool? backFirst,
/// Whether every card is gone through once, ratings moving none; else as
/// the settings have it.
final bool cram = false,
}) with Equatable {
/// The focus [toQuery] wrote; null for a query without one, a session as
/// the settings have it.
@ -32,6 +37,7 @@ final class const StudyFocus({
'0' => false,
_ => null,
},
cram: query[_cramKey] == '1',
);
}
@ -41,14 +47,19 @@ final class const StudyFocus({
if (tag case final tag?) ...{_tagKey: tag.id, _tagNameKey: tag.name},
_topicsKey: topics ? '1' : '0',
if (backFirst case final backFirst?) _backFirstKey: backFirst ? '1' : '0',
if (cram) _cramKey: '1',
};
/// This focus asking the other side first.
StudyFocus turned() =>
StudyFocus(tag: tag, topics: topics, backFirst: !(backFirst ?? false));
StudyFocus turned() => StudyFocus(
tag: tag,
topics: topics,
backFirst: !(backFirst ?? false),
cram: cram,
);
@override
List<Object?> get props => [tag, topics, backFirst];
List<Object?> get props => [tag, topics, backFirst, cram];
}
const _focusKey = 'focus';
@ -56,3 +67,4 @@ const _tagKey = 'tag';
const _tagNameKey = 'tagName';
const _topicsKey = 'topics';
const _backFirstKey = 'back';
const _cramKey = 'cram';

View file

@ -12,6 +12,7 @@ import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_add
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
import 'package:halkyon_learn_flutter/src/feature/jellyfin/model/jellyfin.dart';
import 'package:halkyon_learn_flutter/src/feature/jellyfin/widget/watch_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
@ -169,8 +170,11 @@ void main() {
final tagging = await tags.read();
expect(
[for (final tag in tagging.of(card.id)) tag.name],
containsAll(['Sample Journey', 'JLPT-N5']),
containsAll(['Sample Journey', 'Sample Journey S1 E1', 'JLPT-N5']),
);
// The episode's own tag is hidden on cards; the show's is not.
expect(tagging.named('Sample Journey S1 E1')?.hidden, isTrue);
expect(tagging.named('Sample Journey')?.hidden, isFalse);
// How far it is learnt: not yet.
expect(find.text('New'), findsOneWidget);
});
@ -194,7 +198,10 @@ void main() {
expect(words, isNot(contains('で')));
expect(words, contains('待つ'));
await tester.tap(find.textContaining('待つ', findRichText: true).first);
final wait = find.textContaining('待つ', findRichText: true).first;
await tester.ensureVisible(wait);
await tester.pumpAndSettle();
await tester.tap(wait);
await tester.pumpAndSettle();
expect(players.last!.state.value.position, const Duration(seconds: 16));
expect(find.text('A form of 待つ'), findsOneWidget);
@ -584,6 +591,42 @@ void main() {
expect(card.content.entry, (source: 'jmdict', id: '37'));
});
testWidgets("the episode's words can be studied or crammed: the cards of "
'its words, held before it was watched too, get its tag', (tester) async {
final station = sampleWords.firstWhere((word) => word.headword == '駅');
await DictionaryAdder(cards, tags).add('japanese', [station]);
final card = (await cards.cardsOf('japanese')).single;
await watch(tester);
await tester.tap(find.byTooltip('Play'));
await tester.pump();
await tester.tap(find.text('Study'));
await tester.pumpAndSettle();
await tester.tap(find.text('Cram, each once'));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
final tagging = await tags.read();
expect(
[for (final tag in tagging.of(card.id)) tag.name],
contains('Sample Journey S1 E1'),
);
expect(players.last!.state.value.playing, isFalse);
// The session, over the Watch screen, on the episode's tag, crammed (a
// spinner turns there: frames, not settling).
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
final location = GoRouterState.of(
tester.element(find.byType(StudyScreen)),
).uri;
expect(location.path, '/study/japanese');
expect(location.queryParameters, containsPair('cram', '1'));
expect(
location.queryParameters,
containsPair('tagName', 'Sample Journey S1 E1'),
);
});
testWidgets('on a phone the words are below the video', (tester) async {
await watch(tester, size: const Size(412, 915));
await playTo(tester, const Duration(seconds: 2));