feat(jellyfin): any stretch of a line's characters chosen by hand

The cut takes the longest word and knows no grammar, so 今日は reads as
こんにちは where it is 今日 and は. Any stretch of a line's characters can
now be chosen by hand: dragged across with a mouse, Shift+clicked from
the word or stretch chosen, or on a touch screen held on a character and
dragged on. It is looked up as one word, as it stands or as a form of
one, in one small request (the instance has the line's text already);
while it is chosen the line reads it as that word (きょう over 今日), and
what the dictionary lacks as one word is offered as a search on Jisho.

The line's words move into line_words.dart, out of the Watch screen.

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 22:38:29 +02:00
commit 48ac84d92c
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
12 changed files with 918 additions and 209 deletions

View file

@ -1840,7 +1840,10 @@
"watchWordCount": "{count}×",
"watchAdd": "Hinzufügen",
"watchAdded": "{word} hinzugefügt",
"watchTapAWord": "Tipp auf ein Wort der Untertitel, um es nachzuschlagen.",
"watchStretchNone": "„{text}“ steht nicht als ein Wort im Wörterbuch.",
"watchStretchFailed": "Das Nachschlagen hat nicht geklappt. Wähle es noch einmal, um es erneut zu versuchen.",
"watchStretchJisho": "Auf Jisho suchen",
"watchTapAWord": "Tipp auf ein Wort der Untertitel, um es nachzuschlagen, oder zieh über Zeichen, um einen beliebigen Teil einer Zeile nachzuschlagen (auf einem Touchscreen erst gedrückt halten).",
"watchFormOf": "Eine Form von {word}",
"watchFailed": "Das Video konnte nicht abgespielt werden."
}

View file

@ -2158,8 +2158,12 @@
"@watchWordCount": {"placeholders": {"count": {"type": "int"}}},
"watchAdd": "Add",
"watchAdded": "{word} added",
"watchStretchNone": "{text} isn't in the dictionary as one word.",
"@watchStretchNone": {"placeholders": {"text": {"type": "String"}}},
"watchStretchFailed": "Couldn't look it up. Choose it again to try once more.",
"watchStretchJisho": "Search Jisho for it",
"@watchAdded": {"placeholders": {"word": {"type": "String"}}},
"watchTapAWord": "Tap a word of the subtitles to look it up.",
"watchTapAWord": "Tap a word of the subtitles to look it up, or drag across characters to look up any part of a line (on a touch screen, hold first).",
"watchFormOf": "A form of {word}",
"@watchFormOf": {"placeholders": {"word": {"type": "String"}}},
"watchFailed": "The video couldn't be played."

View file

@ -10881,10 +10881,28 @@ abstract class AppLocalizations {
/// **'{word} added'**
String watchAdded(String word);
/// No description provided for @watchStretchNone.
///
/// In en, this message translates to:
/// **'{text} isn\'t in the dictionary as one word.'**
String watchStretchNone(String text);
/// No description provided for @watchStretchFailed.
///
/// In en, this message translates to:
/// **'Couldn\'t look it up. Choose it again to try once more.'**
String get watchStretchFailed;
/// No description provided for @watchStretchJisho.
///
/// In en, this message translates to:
/// **'Search Jisho for it'**
String get watchStretchJisho;
/// No description provided for @watchTapAWord.
///
/// In en, this message translates to:
/// **'Tap a word of the subtitles to look it up.'**
/// **'Tap a word of the subtitles to look it up, or drag across characters to look up any part of a line (on a touch screen, hold first).'**
String get watchTapAWord;
/// No description provided for @watchFormOf.

View file

@ -6846,9 +6846,21 @@ class AppLocalizationsDe extends AppLocalizations {
return '$word hinzugefügt';
}
@override
String watchStretchNone(String text) {
return '„$text“ steht nicht als ein Wort im Wörterbuch.';
}
@override
String get watchStretchFailed =>
'Das Nachschlagen hat nicht geklappt. Wähle es noch einmal, um es erneut zu versuchen.';
@override
String get watchStretchJisho => 'Auf Jisho suchen';
@override
String get watchTapAWord =>
'Tipp auf ein Wort der Untertitel, um es nachzuschlagen.';
'Tipp auf ein Wort der Untertitel, um es nachzuschlagen, oder zieh über Zeichen, um einen beliebigen Teil einer Zeile nachzuschlagen (auf einem Touchscreen erst gedrückt halten).';
@override
String watchFormOf(String word) {

View file

@ -6786,7 +6786,20 @@ class AppLocalizationsEn extends AppLocalizations {
}
@override
String get watchTapAWord => 'Tap a word of the subtitles to look it up.';
String watchStretchNone(String text) {
return '$text isn\'t in the dictionary as one word.';
}
@override
String get watchStretchFailed =>
'Couldn\'t look it up. Choose it again to try once more.';
@override
String get watchStretchJisho => 'Search Jisho for it';
@override
String get watchTapAWord =>
'Tap a word of the subtitles to look it up, or drag across characters to look up any part of a line (on a touch screen, hold first).';
@override
String watchFormOf(String word) {

View file

@ -143,6 +143,38 @@ class DictionaryRepository(final DictionaryGateway _gateway) {
];
}
/// Stretches chosen by hand looked up already, by dictionary and text;
/// the oldest go once there are [_sentencesKept].
final _stretches = <(String, String, String), SentencePart>{};
/// [text], a stretch of a line chosen by hand from [start], looked up in
/// [dictionary] as one word (`stretchPart`): one request for its
/// [stretchKeys], none for one asked before.
Future<SentencePart> wordOfStretch(
Dictionary dictionary,
String text, {
int start = 0,
}) async {
final key = (dictionary.source, dictionary.glossLanguage, text);
if (_stretches[key] case final known?) {
return SentencePart(
text: known.text,
start: start,
entries: known.entries,
steps: known.steps,
);
}
final keys = stretchKeys(text);
final found = keys.isEmpty
? const <String, List<DictionaryItem>>{}
: await _gateway.withKeys(dictionary, keys.toList());
final part = _stretches[key] = stretchPart(text, start, found);
while (_stretches.length > _sentencesKept) {
_stretches.remove(_stretches.keys.first);
}
return part;
}
/// How many entries of [dictionary] carry [label] (`jlpt:n5`).
Future<int> countLabelled(Dictionary dictionary, String label) =>
_gateway.countLabelled(dictionary, label);

View file

@ -201,51 +201,89 @@ List<_Word> _wordsAt(
_inWord(characters[start + longest])) {
longest++;
}
final words = <_Word>[];
for (var length = longest; length > 0; length--) {
final text = String.fromCharCodes(
characters.sublist(start, start + length),
);
final kana = !text.runes.any(_isKanji);
final candidates =
<({DictionaryWord entry, List<String> steps, int order})>[];
final seen = <DictionaryRef>{};
for (final form in deinflect(text)) {
final key = form.text.toLowerCase();
for (final (index, item) in (found[key] ?? const []).indexed) {
if (item is! DictionaryWord ||
!form.fits([for (final sense in item.senses) ...sense.pos]) ||
kana && !_writtenAs(item, key, conjugated: form.steps.isNotEmpty) ||
!seen.add(item.ref)) {
continue;
}
candidates.add((
entry: item,
steps: form.steps,
// As it stands before a form of it, the fewest steps first; a
// word written as the text is before one only read so, kana
// before kanji for kana; then the dictionary's order.
order:
form.steps.length * 10000 +
(item.headword == key ? 0 : 1000) +
(kana && !_usuallyKana(item) ? 500 : 0) +
index,
));
return [
for (var length = longest; length > 0; length--)
?_wordOf(characters, start, length, found),
];
}
/// The word [length] characters long at [start], as [found] has it by its
/// keys, as it stands or as a form of a word whose class fits; null when
/// the dictionary has none.
_Word? _wordOf(
List<int> characters,
int start,
int length,
Map<String, List<DictionaryItem>> found,
) {
final text = String.fromCharCodes(characters.sublist(start, start + length));
final kana = !text.runes.any(_isKanji);
final candidates =
<({DictionaryWord entry, List<String> steps, int order})>[];
final seen = <DictionaryRef>{};
for (final form in deinflect(text)) {
final key = form.text.toLowerCase();
for (final (index, item) in (found[key] ?? const []).indexed) {
if (item is! DictionaryWord ||
!form.fits([for (final sense in item.senses) ...sense.pos]) ||
kana && !_writtenAs(item, key, conjugated: form.steps.isNotEmpty) ||
!seen.add(item.ref)) {
continue;
}
candidates.add((
entry: item,
steps: form.steps,
// As it stands before a form of it, the fewest steps first; a
// word written as the text is before one only read so, kana
// before kanji for kana; then the dictionary's order.
order:
form.steps.length * 10000 +
(item.headword == key ? 0 : 1000) +
(kana && !_usuallyKana(item) ? 500 : 0) +
index,
));
}
candidates.sort((a, b) => a.order.compareTo(b.order));
final entries = [for (final each in candidates) each.entry];
if (entries.isEmpty) {
continue;
}
words.add((
length: length,
entries: entries,
steps: candidates.first.steps,
kana: kana,
));
}
return words;
if (candidates.isEmpty) {
return null;
}
candidates.sort((a, b) => a.order.compareTo(b.order));
return (
length: length,
entries: [for (final each in candidates) each.entry],
steps: candidates.first.steps,
kana: kana,
);
}
/// The keys a dictionary is asked for to look up [text], a stretch of a
/// line chosen by hand, as one word: it as it stands and every word it may
/// be a form of.
Set<String> stretchKeys(String text) => {
if (text.runes.every(_inWord))
for (final form in deinflect(text)) form.text,
};
/// [text], a stretch of a line chosen by hand from [start] (decided
/// 2026-10-13: by characters, so also part of a word the cut took longer,
/// 今日 out of 今日は), as one word, as [found] has it by its
/// [stretchKeys]: the entries it may be, as it stands or as a form of one;
/// none when it is no word the dictionary has, or holds what no word does.
SentencePart stretchPart(
String text,
int start,
Map<String, List<DictionaryItem>> found,
) {
final characters = text.runes.toList();
final word = characters.isNotEmpty && characters.every(_inWord)
? _wordOf(characters, 0, characters.length, found)
: null;
return SentencePart(
text: text,
start: start,
entries: word?.entries ?? const [],
steps: word?.steps ?? const [],
);
}
/// Whether [entry], found by [key] for text written in kana alone, is a

View file

@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:developer';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@ -33,6 +34,11 @@ enum WatchStatus() {
/// A word of the subtitles chosen: the `part` of the `line`.
typedef WatchSelection = ({int line, int part});
/// A stretch of the `line` chosen by hand (decided 2026-10-13: by
/// characters): its `text`, from `start` to before `end`, as offsets into
/// the line as it shows, joined into one.
typedef WatchStretch = ({int line, int start, int end, String text});
/// A word met in a video: the entry it is, the form it was first met in,
/// how often it comes, and the line it first comes in.
typedef VideoWord = ({
@ -82,6 +88,16 @@ final class const WatchState({
final Map<int, List<SentencePart>> parts = const {},
final WatchSelection? selected,
/// The stretch chosen by hand, in place of a word ([selected]).
final WatchStretch? stretch,
/// The [stretch] looked up as one word: no entries when the dictionary
/// has none; null while it is looked up.
final SentencePart? stretchPart,
/// Whether looking the [stretch] up failed.
final bool stretchFailed = false,
/// The dictionary entries cards of the studyset were made from.
final Set<DictionaryRef> known = const {},
@ -110,11 +126,16 @@ final class const WatchState({
String? translationOf(int index) =>
index < lines.length ? textDuring(translations, lines[index]) : null;
/// The part chosen.
SentencePart? get selectedPart => switch (selected) {
(:final line, :final part) => parts[line]?.elementAtOrNull(part),
null => null,
};
/// The part chosen: the word, or the stretch once looked up.
SentencePart? get selectedPart => stretch != null
? stretchPart
: switch (selected) {
(:final line, :final part) => parts[line]?.elementAtOrNull(part),
null => null,
};
/// The line of the word or the stretch chosen.
int? get chosenLine => stretch?.line ?? selected?.line;
/// The video's words the list shows, as [filter] admits them.
List<VideoWord> get listed => [
@ -144,6 +165,9 @@ final class const WatchState({
Object? line = _same,
Map<int, List<SentencePart>>? parts,
Object? selected = _same,
Object? stretch = _same,
Object? stretchPart = _same,
bool? stretchFailed,
Set<DictionaryRef>? known,
Object? words = _same,
bool? pauseAtLineEnd,
@ -173,6 +197,11 @@ final class const WatchState({
line: line == _same ? this.line : line as int?,
parts: parts ?? this.parts,
selected: selected == _same ? this.selected : selected as WatchSelection?,
stretch: stretch == _same ? this.stretch : stretch as WatchStretch?,
stretchPart: stretchPart == _same
? this.stretchPart
: stretchPart as SentencePart?,
stretchFailed: stretchFailed ?? this.stretchFailed,
known: known ?? this.known,
words: words == _same ? this.words : words as List<VideoWord>?,
pauseAtLineEnd: pauseAtLineEnd ?? this.pauseAtLineEnd,
@ -199,6 +228,9 @@ final class const WatchState({
line,
parts,
selected,
stretch,
stretchPart,
stretchFailed,
known,
words,
pauseAtLineEnd,
@ -431,10 +463,40 @@ class WatchCubit(
/// Chooses the [part] of the [line]: its entries show, and the video
/// pauses, as reading a word takes a moment.
void select(int line, int part) {
emit(state.copyWith(selected: (line: line, part: part)));
emit(state.copyWith(selected: (line: line, part: part), stretch: null));
unawaited(_playback?.pause());
}
/// Chooses the characters of the [line] from [start] to before [end],
/// [text], by hand, and looks them up as one word; the video pauses, as
/// for a word.
Future<void> selectStretch(int line, int start, int end, String text) async {
final stretch = (line: line, start: start, end: end, text: text);
emit(
state.copyWith(
selected: null,
stretch: stretch,
stretchPart: null,
stretchFailed: false,
),
);
unawaited(_playback?.pause());
final dictionary = this.dictionary;
try {
final part = dictionary == null
? SentencePart(text: text, start: start)
: await _dictionaries.wordOfStretch(dictionary, text, start: start);
if (!isClosed && state.stretch == stretch) {
emit(state.copyWith(stretchPart: part));
}
} on Exception catch (error) {
log('Looking up a stretch failed', name: 'Watch', error: error);
if (!isClosed && state.stretch == stretch) {
emit(state.copyWith(stretchFailed: true));
}
}
}
/// Goes to the first line [word] comes in, paused, the word chosen.
Future<void> showWord(VideoWord word) async {
final parts = state.parts[word.firstLine] ?? const <SentencePart>[];
@ -449,13 +511,14 @@ class WatchCubit(
state.copyWith(
line: word.firstLine,
selected: (line: word.firstLine, part: part),
stretch: null,
),
);
}
}
/// Chooses no word: the video's words show again.
void unselect() => emit(state.copyWith(selected: null));
void unselect() => emit(state.copyWith(selected: null, stretch: null));
/// Adds [entry] to the studyset as a card, tagged with the show and the
/// episode. Its hints are the dictionary's examples alone, never the line
@ -463,7 +526,7 @@ class WatchCubit(
/// a line may be wrong or mistyped, and every episode's line would pile
/// up on a common word.
Future<void> add(DictionaryWord entry) async {
if (state.selected == null) {
if (state.selectedPart == null) {
return;
}
await _adder.add(studysetId, [entry], jellyfinTagNames: _tagNames);
@ -626,6 +689,7 @@ class WatchCubit(
lines: lines,
parts: const {},
selected: null,
stretch: null,
words: null,
line: lastCueIndex(
lines,

View file

@ -0,0 +1,461 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/furigana.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/sentence_words.dart';
import 'package:halkyon_learn_flutter/src/feature/jellyfin/bloc/watch_cubit.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_language_scope.dart';
import 'package:material_ui/material_ui.dart';
/// The words of the line [line], each tappable, the kanji's readings above
/// them; the line as text while it isn't cut yet. A line broken for showing
/// over a picture is one here, wrapping where the width ends (Japanese and
/// Chinese join as they are, others with a space).
///
/// Any stretch of its characters can be chosen by hand too (decided
/// 2026-10-13: by characters, so also part of a word the cut took longer,
/// 今日 out of 今日は): dragged across with a mouse, from the word or
/// stretch chosen to a character Shift+clicked, or on a touch screen held
/// on a character and dragged on. It is looked up as one word.
class const LineWords({
required final int line,
required final double fontSize,
final WrapAlignment alignment = WrapAlignment.center,
super.key,
}) extends StatefulWidget {
@override
State<LineWords> createState() => _LineWordsState();
}
/// A part of the line as it shows, from `from` in the line so shown.
typedef _Shown = ({int index, SentencePart part, int from});
class _LineWordsState() extends State<LineWords> {
/// The stretch being dragged across, from the character it started on
/// to the one under the pointer; null while none is.
(int, int)? _dragging;
/// The parts as last built, and the line they make.
List<_Shown> _shown = const [];
String _text = '';
/// The character of the line under [global], if any.
int? _characterAt(Offset global) {
final result = HitTestResult();
WidgetsBinding.instance.hitTestInView(
result,
global,
View.of(context).viewId,
);
for (final entry in result.path) {
final target = entry.target;
if (target is _RenderCharacterRun) {
return target.characterAt(global);
}
}
return null;
}
/// The stretch from [a] to [b], whichever comes first, as offsets.
(int, int) _between(int a, int b) => (math.min(a, b), math.max(a, b));
void _choose((int, int) characters) {
final (from, to) = characters;
// To the end of the last character: past a pair of surrogates too.
final end = to < _text.length && _isLead(_text.codeUnitAt(to))
? to + 2
: to + 1;
context.read<WatchCubit>().selectStretch(
widget.line,
from,
end,
_text.substring(from, end),
);
}
void _start(Offset global) {
if (_characterAt(global) case final at?) {
setState(() => _dragging = (at, at));
}
}
void _move(Offset global) {
final dragging = _dragging;
if (dragging == null) {
return;
}
if (_characterAt(global) case final at? when at != dragging.$2) {
setState(() => _dragging = (dragging.$1, at));
}
}
void _end() {
if (_dragging case (final from, final to)) {
_choose(_between(from, to));
}
setState(() => _dragging = null);
}
/// A click with Shift held: from the word or stretch chosen in this line
/// (or the character itself) to the character clicked.
void _shiftClick(PointerUpEvent event) {
if (event.kind != PointerDeviceKind.mouse ||
!HardwareKeyboard.instance.isShiftPressed ||
_dragging != null) {
return;
}
final at = _characterAt(event.position);
if (at == null) {
return;
}
final state = context.read<WatchCubit>().state;
final anchor = switch ((state.stretch, state.selected)) {
(final stretch?, _) when stretch.line == widget.line => stretch.start,
(_, (:final line, :final part)) when line == widget.line =>
_shown.where((shown) => shown.index == part).firstOrNull?.from ?? at,
_ => at,
};
_choose(_between(anchor, at));
}
@override
Widget build(BuildContext context) {
final state = context.watch<WatchCubit>().state;
final line = widget.line;
final parts =
state.parts[line] ??
[SentencePart(text: state.lines[line].text, start: 0)];
final joiner = switch (StudysetLanguageScope.of(
context,
).front?.split('-').first) {
'ja' || 'zh' => '',
_ => ' ',
};
var at = 0;
_shown = [
for (final (index, part) in parts.indexed)
() {
final shown = !part.isWord && part.text.contains('\n')
? SentencePart(
text: part.text.replaceAll('\n', joiner),
start: part.start,
)
: part;
final from = at;
at += shown.text.length;
return (index: index, part: shown, from: from);
}(),
];
_text = [for (final shown in _shown) shown.part.text].join();
// A stretch found as a word reads as that word while chosen: 今日 out
// of 今日は as きょう, not as こんにち.
final drawn = switch ((state.stretch, state.stretchPart, _dragging)) {
(final stretch?, final found?, null)
when stretch.line == line && found.isWord =>
_cut(_shown, stretch, found),
_ => _shown,
};
// What shows as chosen: the stretch being dragged, else the one
// chosen in this line.
final chosen = switch ((_dragging, state.stretch)) {
((final a, final b), _) => () {
final (from, to) = _between(a, b);
return (from, to + 1);
}(),
(_, final stretch?) when stretch.line == line => (
stretch.start,
stretch.end,
),
_ => null,
};
final words = Wrap(
alignment: widget.alignment,
crossAxisAlignment: WrapCrossAlignment.end,
children: [
for (final (:index, :part, :from) in drawn)
if (part.text.isNotEmpty)
_Word(
part: part,
from: from,
fontSize: widget.fontSize,
known: part.entries.any(
(entry) => state.known.contains(entry.ref),
),
selected:
state.selected == (line: line, part: index) && part.isWord,
chosen: switch (chosen) {
(final start, final end)
when start < from + part.text.length && end > from =>
(start - from, end - from),
_ => null,
},
onTap: part.isWord && index >= 0
? () {
// Shift+click chooses a stretch instead.
if (!HardwareKeyboard.instance.isShiftPressed) {
context.read<WatchCubit>().select(line, index);
}
}
: null,
),
],
);
return Listener(
onPointerUp: _shiftClick,
// Touch: held on a character, then dragged on.
child: GestureDetector(
supportedDevices: const {
PointerDeviceKind.touch,
PointerDeviceKind.stylus,
PointerDeviceKind.invertedStylus,
},
onLongPressStart: (details) {
_start(details.globalPosition);
if (_dragging != null) {
unawaited(HapticFeedback.selectionClick());
}
},
onLongPressMoveUpdate: (details) => _move(details.globalPosition),
onLongPressEnd: (_) => _end(),
onLongPressCancel: () => setState(() => _dragging = null),
// A mouse: dragged across.
child: GestureDetector(
supportedDevices: const {PointerDeviceKind.mouse},
// From the character the button went down on.
dragStartBehavior: DragStartBehavior.down,
onPanStart: (details) => _start(details.globalPosition),
onPanUpdate: (details) => _move(details.globalPosition),
onPanEnd: (_) => _end(),
onPanCancel: () => setState(() => _dragging = null),
child: words,
),
),
);
}
}
/// [shown] with the [stretch] chosen, [found] as a word, as one part of
/// its own (not to be tapped: its index is -1), and what it cuts through
/// as text before and after it.
List<_Shown> _cut(
List<_Shown> shown,
WatchStretch stretch,
SentencePart found,
) {
final cut = <_Shown>[];
var placed = false;
for (final each in shown) {
final from = each.from;
final to = from + each.part.text.length;
if (to <= stretch.start || from >= stretch.end) {
cut.add(each);
continue;
}
if (from < stretch.start) {
cut.add((
index: -1,
part: SentencePart(
text: each.part.text.substring(0, stretch.start - from),
start: each.part.start,
),
from: from,
));
}
if (!placed) {
placed = true;
cut.add((
index: -1,
part: SentencePart(
text: stretch.text,
start: stretch.start,
entries: found.entries,
steps: found.steps,
),
from: stretch.start,
));
}
if (to > stretch.end) {
cut.add((
index: -1,
part: SentencePart(
text: each.part.text.substring(stretch.end - from),
start: each.part.start + stretch.end - from,
),
from: stretch.end,
));
}
}
return cut;
}
/// Whether [unit] starts a pair of surrogates.
bool _isLead(int unit) => unit >= 0xD800 && unit <= 0xDBFF;
/// A word of a line: its kanji with their readings above, tappable, marked
/// when the studyset holds it ([known]) or it is chosen ([selected]); or
/// the text between words. Its characters within a stretch chosen by hand
/// ([chosen], from and to within it) are marked one by one.
class const _Word({
required final SentencePart part,
required final int from,
required final double fontSize,
required final bool known,
required final bool selected,
required final (int, int)? chosen,
required final VoidCallback? onTap,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final entry = part.entries.firstOrNull;
final pieces = entry == null
? [(text: part.text, reading: null)]
: furigana(part.text, entry.headword, entry.reading);
final text = TextStyle(fontSize: fontSize, height: 1.2);
final reading = TextStyle(
fontSize: fontSize * 0.45,
height: 1.1,
color: theme.colorScheme.onSurfaceVariant,
);
final marked = theme.colorScheme.primaryContainer;
var at = 0;
final word = Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
for (final piece in pieces)
() {
final start = at;
at += piece.text.length;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(piece.reading ?? '', style: reading),
_CharacterRun(
start: from + start,
child: Text.rich(
TextSpan(
children: _marked(piece.text, start, chosen, marked),
),
style: text,
),
),
],
);
}(),
],
);
if (onTap == null) {
return word;
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 1),
child: Material(
color: selected
? theme.colorScheme.primaryContainer
: Colors.transparent,
borderRadius: BorderRadius.circular(6),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(6),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 2),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
width: 2,
color: known
? theme.colorScheme.primary
: theme.colorScheme.outlineVariant,
),
),
),
child: word,
),
),
),
);
}
/// [text], from [start] in the word, as spans: what lies within [chosen]
/// on [colour], the rest as it is.
static List<TextSpan> _marked(
String text,
int start,
(int, int)? chosen,
Color colour,
) {
if (chosen == null) {
return [TextSpan(text: text)];
}
final (from, to) = chosen;
final inFrom = (from - start).clamp(0, text.length);
final inTo = (to - start).clamp(0, text.length);
return [
if (inFrom > 0) TextSpan(text: text.substring(0, inFrom)),
if (inTo > inFrom)
TextSpan(
text: text.substring(inFrom, inTo),
style: TextStyle(backgroundColor: colour),
),
if (inTo < text.length) TextSpan(text: text.substring(inTo)),
];
}
}
/// Characters of a line from [start] in it, whose paragraph (the [child])
/// says which of them lies under the pointer.
class const _CharacterRun({required final int start, required super.child})
extends SingleChildRenderObjectWidget {
@override
_RenderCharacterRun createRenderObject(BuildContext context) =>
_RenderCharacterRun(start);
@override
void updateRenderObject(
BuildContext context,
_RenderCharacterRun renderObject,
) => renderObject.start = start;
}
class _RenderCharacterRun(var int start) extends RenderProxyBox {
/// The character of the line at [global], within this run: the one
/// whose box holds it, or the nearest across.
int? characterAt(Offset global) {
final paragraph = child;
if (paragraph is! RenderParagraph) {
return null;
}
final local = paragraph.globalToLocal(global);
final text = paragraph.text.toPlainText();
int? nearest;
var distance = double.infinity;
for (var offset = 0; offset < text.length; offset++) {
if (_isTrail(text.codeUnitAt(offset))) {
continue;
}
final length = _isLead(text.codeUnitAt(offset)) ? 2 : 1;
for (final box in paragraph.getBoxesForSelection(
TextSelection(baseOffset: offset, extentOffset: offset + length),
)) {
final away = local.dx < box.left
? box.left - local.dx
: local.dx > box.right
? local.dx - box.right
: 0.0;
if (away < distance) {
distance = away;
nearest = offset;
}
}
}
return nearest == null ? null : start + nearest;
}
}
/// Whether [unit] ends a pair of surrogates.
bool _isTrail(int unit) => unit >= 0xDC00 && unit <= 0xDFFF;

View file

@ -15,8 +15,6 @@ import 'package:halkyon_learn_flutter/src/feature/card/widget/vocabulary_senses.
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_adder.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/entry_progress.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/furigana.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/sentence_words.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/widget/dictionary_word_tile.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/widget/studyset_dictionary.dart';
import 'package:halkyon_learn_flutter/src/feature/jellyfin/bloc/watch_cubit.dart';
@ -25,6 +23,7 @@ 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/jellyfin/widget/ambient_glow.dart';
import 'package:halkyon_learn_flutter/src/feature/jellyfin/widget/jellyfin_connect.dart';
import 'package:halkyon_learn_flutter/src/feature/jellyfin/widget/line_words.dart';
import 'package:halkyon_learn_flutter/src/feature/jellyfin/widget/watch_player.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/card_schedule.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_language_scope.dart';
@ -369,7 +368,7 @@ class const _Line({required final double fontSize}) extends StatelessWidget {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
_LineWords(line: line, fontSize: fontSize),
LineWords(line: line, fontSize: fontSize),
if (translation != null) ...[
const SizedBox(height: 8),
Text(
@ -385,60 +384,6 @@ class const _Line({required final double fontSize}) extends StatelessWidget {
}
}
/// The words of the line [line], each tappable, the kanji's readings above
/// them; the line as text while it isn't cut yet. A line broken for showing
/// over a picture is one here, wrapping where the width ends (Japanese and
/// Chinese join as they are, others with a space).
class const _LineWords({
required final int line,
required final double fontSize,
final WrapAlignment alignment = WrapAlignment.center,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final state = context.watch<WatchCubit>().state;
final parts =
state.parts[line] ??
[SentencePart(text: state.lines[line].text, start: 0)];
final joiner = switch (StudysetLanguageScope.of(
context,
).front?.split('-').first) {
'ja' || 'zh' => '',
_ => ' ',
};
final row = [
for (final (index, part) in parts.indexed)
if (!part.isWord && part.text.contains('\n'))
(
index,
SentencePart(text: part.text.replaceAll('\n', joiner), start: 0),
)
else
(index, part),
];
return Wrap(
alignment: alignment,
crossAxisAlignment: WrapCrossAlignment.end,
children: [
for (final (index, part) in row)
if (part.text.isNotEmpty)
_Word(
part: part,
fontSize: fontSize,
known: part.entries.any(
(entry) => state.known.contains(entry.ref),
),
selected:
state.selected == (line: line, part: index) && part.isWord,
onTap: part.isWord
? () => context.read<WatchCubit>().select(line, index)
: null,
),
],
);
}
}
/// A song: its cover as the player's picture, square, its colours glowing
/// around it in the dark theme, and its lyrics beside it on a desktop
/// (below it on a phone) as a moving banner (decided 2026-10-13).
@ -589,7 +534,7 @@ class _LyricsBannerState() extends State<_LyricsBanner> {
onTap: state.synced
? () => context.read<WatchCubit>().playLine(index)
: null,
child: _LineWords(
child: LineWords(
line: index,
fontSize: sung == index ? big : small,
alignment: widget.wide
@ -605,76 +550,6 @@ class _LyricsBannerState() extends State<_LyricsBanner> {
}
}
/// A word of a line: its kanji with their readings above, tappable, marked
/// when the studyset holds it ([known]) or it is chosen; or the text
/// between words.
class const _Word({
required final SentencePart part,
required final double fontSize,
required final bool known,
required final bool selected,
required final VoidCallback? onTap,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final entry = part.entries.firstOrNull;
final pieces = entry == null
? [(text: part.text, reading: null)]
: furigana(part.text, entry.headword, entry.reading);
final text = TextStyle(fontSize: fontSize, height: 1.2);
final reading = TextStyle(
fontSize: fontSize * 0.45,
height: 1.1,
color: theme.colorScheme.onSurfaceVariant,
);
final word = Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
for (final piece in pieces)
Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(piece.reading ?? '', style: reading),
Text(piece.text, style: text),
],
),
],
);
if (onTap == null) {
return word;
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 1),
child: Material(
color: selected
? theme.colorScheme.primaryContainer
: Colors.transparent,
borderRadius: BorderRadius.circular(6),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(6),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 2),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
width: 2,
color: known
? theme.colorScheme.primary
: theme.colorScheme.outlineVariant,
),
),
),
child: word,
),
),
),
);
}
}
/// 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).
class const _Panel({
@ -694,7 +569,7 @@ class const _Panel({
);
}
final selected = context.select<WatchCubit, bool>(
(cubit) => cubit.state.selectedPart != null,
(cubit) => cubit.state.chosenLine != null,
);
return _VideoTint(
colour: colour,
@ -820,12 +695,71 @@ class const _ChosenWord({required final Dictionary dictionary})
final theme = Theme.of(context);
WatchCubit cubit() => context.read<WatchCubit>();
final state = context.watch<WatchCubit>().state;
final part = state.selectedPart;
final selected = state.selected;
if (part == null || selected == null) {
final line = state.chosenLine;
if (line == null) {
return const SizedBox();
}
final translation = state.translationOf(selected.line);
final part = state.selectedPart;
final translation = state.translationOf(line);
final heading = Row(
children: [
Expanded(
child: Text(
part?.text ?? state.stretch?.text ?? '',
style: theme.textTheme.headlineSmall,
),
),
IconButton(
tooltip: MaterialLocalizations.of(context).closeButtonTooltip,
icon: const Icon(LucideIcons.x),
onPressed: () => cubit().unselect(),
),
],
);
final lineAndTranslation = [
Text(oneLine(state.lines[line].text)),
if (translation != null)
Text(
oneLine(translation),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
];
// A stretch chosen by hand while it is looked up, if that failed, or
// when the dictionary has no such word: Jisho may still know it.
if (part == null || !part.isWord) {
final text = part?.text ?? state.stretch?.text ?? '';
return ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 8, 16),
children: [
heading,
...lineAndTranslation,
const SizedBox(height: 16),
if (part == null && !state.stretchFailed)
const Center(child: AppLoadingIndicator())
else ...[
Text(
state.stretchFailed
? l10n.watchStretchFailed
: l10n.watchStretchNone(text),
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: OutlinedButton.icon(
icon: const Icon(LucideIcons.externalLink, size: 16),
label: Text(l10n.watchStretchJisho),
onPressed: () => launchUrl(
Uri.https('jisho.org', '/search/$text'),
mode: LaunchMode.externalApplication,
),
),
),
],
],
);
}
// On the headword's line, at its end (decided 2026-10-13): Jisho, and
// Add for a word the studyset lacks.
List<Widget> actions(DictionaryWord entry) => [
@ -865,18 +799,7 @@ class const _ChosenWord({required final Dictionary dictionary})
return ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 8, 16),
children: [
Row(
children: [
Expanded(
child: Text(part.text, style: theme.textTheme.headlineSmall),
),
IconButton(
tooltip: MaterialLocalizations.of(context).closeButtonTooltip,
icon: const Icon(LucideIcons.x),
onPressed: () => cubit().unselect(),
),
],
),
heading,
if (part.steps.isNotEmpty)
Text(
l10n.watchFormOf(part.entries.first.headword),
@ -885,14 +808,7 @@ class const _ChosenWord({required final Dictionary dictionary})
),
),
const SizedBox(height: 8),
Text(oneLine(state.lines[selected.line].text)),
if (translation != null)
Text(
oneLine(translation),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
...lineAndTranslation,
const SizedBox(height: 8),
for (final (index, entry) in part.entries.indexed) ...[
if (index > 0) const Divider(height: 1),

View file

@ -212,6 +212,62 @@ void main() {
});
});
group('a stretch chosen by hand', () {
final greetings = [
word('30', '今日', reading: 'きょう', pos: ['n', 'adv']),
word('31', '今日は', reading: 'こんにちは', pos: ['int']),
word('32', 'は', pos: ['prt']),
...words,
];
test('is looked up whole, also inside a word the cut took longer: 今日 '
'out of 今日は', () async {
final repository = DictionaryRepository(
FakeDictionaryGateway(words: greetings),
);
final [line] = await repository.wordsOf(jmdict, ['今日は']);
expect(line.single.entries.first.headword, '今日は');
final chosen = await repository.wordOfStretch(jmdict, '今日');
expect(chosen.text, '今日');
expect(chosen.start, 0);
expect(chosen.entries.single.headword, '今日');
});
test('as it stands or as a form of a word: 会い of 会う', () async {
final repository = DictionaryRepository(
FakeDictionaryGateway(words: greetings),
);
final chosen = await repository.wordOfStretch(jmdict, '会い', start: 8);
// A stem goes back to its word in no step of its own.
expect(chosen.entries.first.headword, '会う');
expect(chosen.start, 8);
});
test('is nothing the dictionary has when it is no word, or holds '
'punctuation', () async {
final repository = DictionaryRepository(
FakeDictionaryGateway(words: greetings),
);
expect((await repository.wordOfStretch(jmdict, '駅で')).isWord, isFalse);
expect((await repository.wordOfStretch(jmdict, '昨日、駅')).isWord, isFalse);
});
test('is asked for once', () async {
final gateway = FakeDictionaryGateway(words: greetings);
final repository = DictionaryRepository(gateway);
await repository.wordOfStretch(jmdict, '今日');
await repository.wordOfStretch(jmdict, '今日');
expect(gateway.keysAsked, hasLength(1));
});
});
group('furigana', () {
test('reads the kanji of a word as its dictionary form gives them', () {
expect(furigana('食べられなかった', '食べる', 'たべる'), [

View file

@ -600,6 +600,98 @@ void main() {
expect(card.content.entry, (source: 'jmdict', id: '37'));
});
group('a stretch chosen by hand (decided 2026-10-13: by characters)', () {
final greetings = [
word('40', '今日', reading: 'きょう', pos: ['n'], glosses: ['today']),
word('41', '今日は', reading: 'こんにちは', pos: ['int'], glosses: ['hello']),
word('42', 'は', pos: ['prt'], labels: ['common']),
word('43', '雨', reading: 'あめ', pos: ['n'], glosses: ['rain']),
word('44', 'だ', pos: ['cop'], labels: ['common']),
];
Future<void> watchGreetings(WidgetTester tester) async {
await watch(
tester,
vtt: {
('episode-1', 2): 'WEBVTT\n\n00:00:01.000 --> 00:00:05.000\n今日は雨だ\n',
('episode-1', 3):
'WEBVTT\n\n00:00:01.000 --> 00:00:05.000\nA grey sky.\n',
},
words: greetings,
);
await playTo(tester, const Duration(seconds: 2));
}
/// The middle of the [index]th character of the base text [text] of
/// the line (four characters to a run at most here, each as wide).
Offset character(WidgetTester tester, String text, int index) {
final rect = tester.getRect(find.text(text, findRichText: true).first);
final width = rect.width / text.length;
return Offset(rect.left + width * (index + 0.5), rect.center.dy);
}
testWidgets('dragged across with a mouse, the characters chosen are '
'looked up as one word, also inside a word the cut took longer: '
'今日 out of 今日は', (tester) async {
await watchGreetings(tester);
await players.last!.play();
expect(find.text('今日', findRichText: true), findsOneWidget);
final drag = await tester.startGesture(
character(tester, '今日', 0),
kind: PointerDeviceKind.mouse,
);
await drag.moveTo(character(tester, '今日', 1));
await drag.up();
await tester.pumpAndSettle();
expect(players.last!.state.value.playing, isFalse);
expect(find.textContaining('today', findRichText: true), findsOneWidget);
expect(find.textContaining('hello', findRichText: true), findsNothing);
// While chosen, the line reads it as the word chosen: きょう, not
// こんにち of こんにちは.
expect(find.text('こんにち'), findsNothing);
expect(find.text('きょう'), findsWidgets);
});
testWidgets('Shift+click chooses from the word chosen to the character '
'clicked; what the dictionary lacks as one word is searched on '
'Jisho', (tester) async {
await watchGreetings(tester);
await tester.tap(find.text('雨', findRichText: true));
await tester.pumpAndSettle();
expect(find.textContaining('rain', findRichText: true), findsOneWidget);
await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
final click = await tester.startGesture(
character(tester, 'だ', 0),
kind: PointerDeviceKind.mouse,
);
await click.up();
await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft);
await tester.pumpAndSettle();
expect(
find.text("雨だ isn't in the dictionary as one word."),
findsOneWidget,
);
expect(find.text('Search Jisho for it'), findsOneWidget);
expect(find.textContaining('rain', findRichText: true), findsNothing);
});
testWidgets('on a phone, holding a character starts a stretch, and '
'moving on extends it', (tester) async {
await watchGreetings(tester);
final hold = await tester.startGesture(character(tester, '今日', 0));
await tester.pump(const Duration(milliseconds: 600));
await hold.moveTo(character(tester, '今日', 1));
await hold.up();
await tester.pumpAndSettle();
expect(find.textContaining('today', findRichText: true), findsOneWidget);
});
});
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 == '駅');