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:
parent
68945b2224
commit
436dc884b6
12 changed files with 266 additions and 10 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Reference in a new issue