feat(jellyfin): the player as players are, with the settings under a cog
The Watch screen's player takes the shape people know from players,
YouTube's first: the picture set in from every side with rounded
corners, so the glow of its colours shows all around it, and over it
play or pause in the middle with 10 seconds back and on beside it, and
along its foot where the video is (0:05 ──── 23:47) and a cog. The
controls fade out while it plays and the pointer rests, and come back as
it moves, on a tap with a finger, on a jump and while paused. The cog
opens a panel inside the picture, so nothing runs off the window: Pause
after each line, the subtitles, the translation and the sound, each
track named by its language in the app's words, and the keyboard
shortcuts. The buttons for the line before, again and the next sit
beside the subtitles they act on, no longer looking like a player's
skip to the next video. Keys are YouTube's (Space or K, J and L, the
arrows, 0 to 9, Home, End) with Shift and the arrows for lines, R, P and
Esc; ? lists them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-13 16:51:53 +02:00
|
|
|
import 'package:flutter/services.dart';
|
feat(jellyfin): watch from one's own Jellyfin server and learn the words of its subtitles
Animelon's way of learning from video, from the person's own Jellyfin
server: connected per device (Settings, or a studyset's Add material ›
Watch from Jellyfin) with a name and password or a Quick Connect code,
over HTTPS only (plain HTTP only to a server on the same machine); the
token stays in the system's keyring. Shows, films and videos tied to a
studyset follow the person in their settings as the server's and the
item's ids alone (settings schema 9), named by the server when shown.
The Watch screen plays the video (video_player on Android, media_kit's
libmpv on Linux and Windows) with the line in the studyset's front
language cut into the dictionary's words with furigana, and the back
language's line beneath; a word tapped pauses the video and becomes a
card with the line and its translation as its first hint, tagged with
the show. The video's words are listed, most frequent first. The sound is
the track in the studyset's language, whichever the file marks default,
and the menu switches it; the subtitles read are a whole track, never a
forced one (which only covers what the sound doesn't say), and captions
for the hard of hearing only without plain ones. The line and its
translation take the video's width in a box of a fixed height, so the
video keeps its size; in the dark theme the video's colours glow around
it, as YouTube's ambient mode, and the words' panel takes the colour of
the episode's picture under the pointer.
A line is cut into words with one request: DictionaryEndpoint.withKeys
looks up every stretch of it that may be a word, as it stands and as
every word it may be a conjugation of, in the new dictionary_key table
(one row per key, indexed; migration dictionary-keys adds the table and
its index, nothing else), which the loader fills beside the entries.
The instance gets the text of each line looked up and keeps none of it,
as the Watch screen says.
Linux builds need libmpv-dev, and the app links libmpv: the snap stages
Ubuntu's libmpv2, makes the update-alternatives links for BLAS and LAPACK
that ffmpeg's speech recognition needs (staging never runs install
scripts), and removes what the GNOME runtime and core24 already hold, as
older copies of cairo, pango and librsvg hid the runtime's from its GTK.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-13 11:31:37 +02:00
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
|
import 'package:go_router/go_router.dart';
|
|
|
|
|
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
|
|
|
|
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
|
|
|
|
import 'package:halkyon_learn_flutter/src/core/video/media_players.dart';
|
|
|
|
|
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/jellyfin/model/jellyfin.dart';
|
|
|
|
|
import 'package:halkyon_learn_flutter/src/feature/jellyfin/widget/watch_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';
|
|
|
|
|
import 'package:material_ui/material_ui.dart';
|
|
|
|
|
|
|
|
|
|
import '../../support/fake_card_gateway.dart';
|
|
|
|
|
import '../../support/fake_jellyfin.dart';
|
|
|
|
|
import '../../support/fake_studyset_gateway.dart';
|
|
|
|
|
import '../../support/fake_tag_gateway.dart';
|
|
|
|
|
import '../../support/pump_app.dart';
|
|
|
|
|
import '../../support/sample_episode.dart';
|
|
|
|
|
import '../../support/test_dependencies.dart';
|
|
|
|
|
|
|
|
|
|
void main() {
|
|
|
|
|
late FakeCardGateway cardGateway;
|
|
|
|
|
late FakeTagGateway tagGateway;
|
|
|
|
|
late CardRepository cards;
|
|
|
|
|
late TagRepository tags;
|
|
|
|
|
late FakeJellyfinGateway jellyfin;
|
|
|
|
|
late FakeMediaPlayers players;
|
|
|
|
|
|
|
|
|
|
setUp(() {
|
|
|
|
|
cardGateway = FakeCardGateway();
|
|
|
|
|
tagGateway = FakeTagGateway();
|
|
|
|
|
cards = CardRepository(cardGateway);
|
|
|
|
|
tags = TagRepository(tagGateway);
|
|
|
|
|
jellyfin = sampleJellyfin();
|
|
|
|
|
players = FakeMediaPlayers();
|
|
|
|
|
});
|
|
|
|
|
tearDown(() async {
|
|
|
|
|
await cards.dispose();
|
|
|
|
|
await tags.dispose();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/// Opens the sample episode from the Japanese studyset, in a window of
|
|
|
|
|
/// [size].
|
|
|
|
|
Future<void> watch(
|
|
|
|
|
WidgetTester tester, {
|
|
|
|
|
Size size = const Size(1400, 900),
|
|
|
|
|
bool playerAvailable = true,
|
|
|
|
|
JellyfinPlayback? playback,
|
|
|
|
|
Map<(String, int), String>? vtt,
|
|
|
|
|
List<MediaAudioTrack> audio = const [],
|
|
|
|
|
}) async {
|
|
|
|
|
if (playback != null || vtt != null) {
|
|
|
|
|
jellyfin = sampleJellyfin(
|
|
|
|
|
playback: playback ?? sampleEpisodePlayback,
|
|
|
|
|
vtt:
|
|
|
|
|
vtt ??
|
|
|
|
|
{('episode-1', 2): sampleJapanese, ('episode-1', 3): sampleEnglish},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (audio.isNotEmpty) {
|
|
|
|
|
players = FakeMediaPlayers(audio: audio);
|
|
|
|
|
}
|
|
|
|
|
tester.view.physicalSize = size;
|
|
|
|
|
tester.view.devicePixelRatio = 1;
|
|
|
|
|
addTearDown(tester.view.reset);
|
|
|
|
|
final studysets = FakeStudysetGateway()
|
|
|
|
|
..receiveRemoteChange(
|
|
|
|
|
'japanese',
|
|
|
|
|
encodeStudysetPayload(
|
|
|
|
|
StudysetContent(
|
|
|
|
|
title: 'Japanese',
|
|
|
|
|
description: RichContent.empty(),
|
|
|
|
|
frontLanguage: 'ja',
|
|
|
|
|
backLanguage: 'en',
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
if (!playerAvailable) {
|
|
|
|
|
players = FakeMediaPlayers(available: false);
|
|
|
|
|
}
|
|
|
|
|
await pumpApp(
|
|
|
|
|
tester,
|
|
|
|
|
testDependencies(
|
|
|
|
|
studysetGateway: studysets,
|
|
|
|
|
cardGateway: cardGateway,
|
|
|
|
|
tagGateway: tagGateway,
|
|
|
|
|
dictionaryGateway: sampleDictionary(),
|
|
|
|
|
jellyfinGateway: jellyfin,
|
|
|
|
|
jellyfinStore: signedInJellyfin(),
|
|
|
|
|
mediaPlayers: players,
|
|
|
|
|
),
|
|
|
|
|
location: RoutePaths.watch('japanese', 'episode-1'),
|
|
|
|
|
);
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Plays on to [position].
|
|
|
|
|
Future<void> playTo(WidgetTester tester, Duration position) async {
|
|
|
|
|
players.last!.moveTo(position);
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
testWidgets('the line shown is cut into words, their readings above, its '
|
|
|
|
|
'translation beneath; it follows the video', (tester) async {
|
|
|
|
|
await watch(tester);
|
|
|
|
|
expect(players.opened.single.headers['Authorization'], contains('token-1'));
|
|
|
|
|
expect(jellyfin.reports.single.started, isTrue);
|
|
|
|
|
|
|
|
|
|
await playTo(tester, const Duration(seconds: 2));
|
|
|
|
|
expect(find.text('友達'), findsOneWidget);
|
|
|
|
|
expect(find.text('ともだち'), findsOneWidget);
|
|
|
|
|
// 会いました: 会 read あ, its ending as it stands.
|
|
|
|
|
expect(find.text('会'), findsOneWidget);
|
|
|
|
|
expect(find.text('あ'), findsOneWidget);
|
|
|
|
|
expect(find.text('いました'), findsOneWidget);
|
|
|
|
|
expect(find.text('Yesterday I met a friend at the station.'), findsOne);
|
|
|
|
|
|
|
|
|
|
// Between lines the last one stays.
|
|
|
|
|
await playTo(tester, const Duration(milliseconds: 5500));
|
|
|
|
|
expect(find.text('友達'), findsOneWidget);
|
|
|
|
|
|
|
|
|
|
await playTo(tester, const Duration(seconds: 12));
|
|
|
|
|
expect(find.text('旅'), findsOneWidget);
|
|
|
|
|
expect(find.text('友達'), findsNothing);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets('a word tapped pauses the video and shows what it is a form '
|
|
|
|
|
'of; added, it is a flashcard with its line and translation as its '
|
|
|
|
|
'first hint, tagged with the show, and is marked from then on', (
|
|
|
|
|
tester,
|
|
|
|
|
) async {
|
|
|
|
|
await watch(tester);
|
|
|
|
|
await players.last!.play();
|
|
|
|
|
await playTo(tester, const Duration(seconds: 2));
|
|
|
|
|
|
|
|
|
|
await tester.tap(find.text('いました'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
|
|
|
|
|
expect(players.last!.state.value.playing, isFalse);
|
|
|
|
|
expect(find.text('A form of 会う'), findsOneWidget);
|
|
|
|
|
await tester.tap(find.text('Add'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
|
|
|
|
|
expect(find.text('会う added, with this line'), findsOneWidget);
|
|
|
|
|
final card = (await cards.cardsOf('japanese')).single;
|
|
|
|
|
expect(card.content.type, CardType.vocabulary);
|
|
|
|
|
expect(card.content.entry, (source: 'jmdict', id: '6'));
|
|
|
|
|
expect(
|
|
|
|
|
card.content.hints.first.plainText,
|
|
|
|
|
allOf(
|
|
|
|
|
contains('昨日、駅で友達に会いました。'),
|
|
|
|
|
contains('Yesterday I met a friend at the station.'),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
final tagging = await tags.read();
|
|
|
|
|
expect(
|
|
|
|
|
[for (final tag in tagging.of(card.id)) tag.name],
|
|
|
|
|
containsAll(['Sample Journey', 'JLPT-N5']),
|
|
|
|
|
);
|
|
|
|
|
expect(find.byTooltip('In this studyset'), findsOneWidget);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets("the video's words are listed, the most frequent first, "
|
|
|
|
|
'particles left out; one opens its first line', (tester) async {
|
|
|
|
|
await watch(tester);
|
|
|
|
|
|
|
|
|
|
expect(find.text('Words in this video'), findsOneWidget);
|
|
|
|
|
final words = [
|
|
|
|
|
for (final tile in tester.widgetList<ListTile>(
|
|
|
|
|
find.descendant(
|
|
|
|
|
of: find.byType(CustomScrollView),
|
|
|
|
|
matching: find.byType(ListTile),
|
|
|
|
|
),
|
|
|
|
|
))
|
|
|
|
|
(tile.title! as Text).textSpan!.toPlainText().split(' ').first,
|
|
|
|
|
];
|
|
|
|
|
// 駅 and 友達 come twice; で, に, を, けど and だ are grammar.
|
|
|
|
|
expect(words.take(2).toSet(), {'駅', '友達'});
|
|
|
|
|
expect(words, isNot(contains('で')));
|
|
|
|
|
expect(words, contains('待つ'));
|
|
|
|
|
|
|
|
|
|
await tester.tap(find.textContaining('待つ', findRichText: true).first);
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
expect(players.last!.state.value.position, const Duration(seconds: 16));
|
|
|
|
|
expect(find.text('A form of 待つ'), findsOneWidget);
|
|
|
|
|
});
|
|
|
|
|
|
feat(jellyfin): the player as players are, with the settings under a cog
The Watch screen's player takes the shape people know from players,
YouTube's first: the picture set in from every side with rounded
corners, so the glow of its colours shows all around it, and over it
play or pause in the middle with 10 seconds back and on beside it, and
along its foot where the video is (0:05 ──── 23:47) and a cog. The
controls fade out while it plays and the pointer rests, and come back as
it moves, on a tap with a finger, on a jump and while paused. The cog
opens a panel inside the picture, so nothing runs off the window: Pause
after each line, the subtitles, the translation and the sound, each
track named by its language in the app's words, and the keyboard
shortcuts. The buttons for the line before, again and the next sit
beside the subtitles they act on, no longer looking like a player's
skip to the next video. Keys are YouTube's (Space or K, J and L, the
arrows, 0 to 9, Home, End) with Shift and the arrows for lines, R, P and
Esc; ? lists them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-13 16:51:53 +02:00
|
|
|
testWidgets('the next line and the one before play from their start, the '
|
|
|
|
|
'one shown from its start once under way; a line can end with a '
|
|
|
|
|
'pause', (tester) async {
|
feat(jellyfin): watch from one's own Jellyfin server and learn the words of its subtitles
Animelon's way of learning from video, from the person's own Jellyfin
server: connected per device (Settings, or a studyset's Add material ›
Watch from Jellyfin) with a name and password or a Quick Connect code,
over HTTPS only (plain HTTP only to a server on the same machine); the
token stays in the system's keyring. Shows, films and videos tied to a
studyset follow the person in their settings as the server's and the
item's ids alone (settings schema 9), named by the server when shown.
The Watch screen plays the video (video_player on Android, media_kit's
libmpv on Linux and Windows) with the line in the studyset's front
language cut into the dictionary's words with furigana, and the back
language's line beneath; a word tapped pauses the video and becomes a
card with the line and its translation as its first hint, tagged with
the show. The video's words are listed, most frequent first. The sound is
the track in the studyset's language, whichever the file marks default,
and the menu switches it; the subtitles read are a whole track, never a
forced one (which only covers what the sound doesn't say), and captions
for the hard of hearing only without plain ones. The line and its
translation take the video's width in a box of a fixed height, so the
video keeps its size; in the dark theme the video's colours glow around
it, as YouTube's ambient mode, and the words' panel takes the colour of
the episode's picture under the pointer.
A line is cut into words with one request: DictionaryEndpoint.withKeys
looks up every stretch of it that may be a word, as it stands and as
every word it may be a conjugation of, in the new dictionary_key table
(one row per key, indexed; migration dictionary-keys adds the table and
its index, nothing else), which the loader fills beside the entries.
The instance gets the text of each line looked up and keeps none of it,
as the Watch screen says.
Linux builds need libmpv-dev, and the app links libmpv: the snap stages
Ubuntu's libmpv2, makes the update-alternatives links for BLAS and LAPACK
that ffmpeg's speech recognition needs (staging never runs install
scripts), and removes what the GNOME runtime and core24 already hold, as
older copies of cairo, pango and librsvg hid the runtime's from its GTK.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-13 11:31:37 +02:00
|
|
|
await watch(tester);
|
|
|
|
|
await playTo(tester, const Duration(seconds: 7));
|
|
|
|
|
|
|
|
|
|
await tester.tap(find.byTooltip('Next line'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
expect(players.last!.state.value.position, const Duration(seconds: 11));
|
|
|
|
|
expect(players.last!.state.value.playing, isTrue);
|
|
|
|
|
|
feat(jellyfin): the player as players are, with the settings under a cog
The Watch screen's player takes the shape people know from players,
YouTube's first: the picture set in from every side with rounded
corners, so the glow of its colours shows all around it, and over it
play or pause in the middle with 10 seconds back and on beside it, and
along its foot where the video is (0:05 ──── 23:47) and a cog. The
controls fade out while it plays and the pointer rests, and come back as
it moves, on a tap with a finger, on a jump and while paused. The cog
opens a panel inside the picture, so nothing runs off the window: Pause
after each line, the subtitles, the translation and the sound, each
track named by its language in the app's words, and the keyboard
shortcuts. The buttons for the line before, again and the next sit
beside the subtitles they act on, no longer looking like a player's
skip to the next video. Keys are YouTube's (Space or K, J and L, the
arrows, 0 to 9, Home, End) with Shift and the arrows for lines, R, P and
Esc; ? lists them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-13 16:51:53 +02:00
|
|
|
// Under way: the one shown again, from its start.
|
|
|
|
|
await playTo(tester, const Duration(seconds: 14));
|
|
|
|
|
await tester.tap(find.byTooltip('Previous line'));
|
feat(jellyfin): watch from one's own Jellyfin server and learn the words of its subtitles
Animelon's way of learning from video, from the person's own Jellyfin
server: connected per device (Settings, or a studyset's Add material ›
Watch from Jellyfin) with a name and password or a Quick Connect code,
over HTTPS only (plain HTTP only to a server on the same machine); the
token stays in the system's keyring. Shows, films and videos tied to a
studyset follow the person in their settings as the server's and the
item's ids alone (settings schema 9), named by the server when shown.
The Watch screen plays the video (video_player on Android, media_kit's
libmpv on Linux and Windows) with the line in the studyset's front
language cut into the dictionary's words with furigana, and the back
language's line beneath; a word tapped pauses the video and becomes a
card with the line and its translation as its first hint, tagged with
the show. The video's words are listed, most frequent first. The sound is
the track in the studyset's language, whichever the file marks default,
and the menu switches it; the subtitles read are a whole track, never a
forced one (which only covers what the sound doesn't say), and captions
for the hard of hearing only without plain ones. The line and its
translation take the video's width in a box of a fixed height, so the
video keeps its size; in the dark theme the video's colours glow around
it, as YouTube's ambient mode, and the words' panel takes the colour of
the episode's picture under the pointer.
A line is cut into words with one request: DictionaryEndpoint.withKeys
looks up every stretch of it that may be a word, as it stands and as
every word it may be a conjugation of, in the new dictionary_key table
(one row per key, indexed; migration dictionary-keys adds the table and
its index, nothing else), which the loader fills beside the entries.
The instance gets the text of each line looked up and keeps none of it,
as the Watch screen says.
Linux builds need libmpv-dev, and the app links libmpv: the snap stages
Ubuntu's libmpv2, makes the update-alternatives links for BLAS and LAPACK
that ffmpeg's speech recognition needs (staging never runs install
scripts), and removes what the GNOME runtime and core24 already hold, as
older copies of cairo, pango and librsvg hid the runtime's from its GTK.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-13 11:31:37 +02:00
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
expect(players.last!.state.value.position, const Duration(seconds: 11));
|
|
|
|
|
|
|
|
|
|
await tester.tap(find.byTooltip('Previous line'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
expect(players.last!.state.value.position, const Duration(seconds: 6));
|
|
|
|
|
|
feat(jellyfin): the player as players are, with the settings under a cog
The Watch screen's player takes the shape people know from players,
YouTube's first: the picture set in from every side with rounded
corners, so the glow of its colours shows all around it, and over it
play or pause in the middle with 10 seconds back and on beside it, and
along its foot where the video is (0:05 ──── 23:47) and a cog. The
controls fade out while it plays and the pointer rests, and come back as
it moves, on a tap with a finger, on a jump and while paused. The cog
opens a panel inside the picture, so nothing runs off the window: Pause
after each line, the subtitles, the translation and the sound, each
track named by its language in the app's words, and the keyboard
shortcuts. The buttons for the line before, again and the next sit
beside the subtitles they act on, no longer looking like a player's
skip to the next video. Keys are YouTube's (Space or K, J and L, the
arrows, 0 to 9, Home, End) with Shift and the arrows for lines, R, P and
Esc; ? lists them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-13 16:51:53 +02:00
|
|
|
await tester.tap(find.byTooltip('Settings'));
|
|
|
|
|
await tester.pumpAndSettle();
|
feat(jellyfin): watch from one's own Jellyfin server and learn the words of its subtitles
Animelon's way of learning from video, from the person's own Jellyfin
server: connected per device (Settings, or a studyset's Add material ›
Watch from Jellyfin) with a name and password or a Quick Connect code,
over HTTPS only (plain HTTP only to a server on the same machine); the
token stays in the system's keyring. Shows, films and videos tied to a
studyset follow the person in their settings as the server's and the
item's ids alone (settings schema 9), named by the server when shown.
The Watch screen plays the video (video_player on Android, media_kit's
libmpv on Linux and Windows) with the line in the studyset's front
language cut into the dictionary's words with furigana, and the back
language's line beneath; a word tapped pauses the video and becomes a
card with the line and its translation as its first hint, tagged with
the show. The video's words are listed, most frequent first. The sound is
the track in the studyset's language, whichever the file marks default,
and the menu switches it; the subtitles read are a whole track, never a
forced one (which only covers what the sound doesn't say), and captions
for the hard of hearing only without plain ones. The line and its
translation take the video's width in a box of a fixed height, so the
video keeps its size; in the dark theme the video's colours glow around
it, as YouTube's ambient mode, and the words' panel takes the colour of
the episode's picture under the pointer.
A line is cut into words with one request: DictionaryEndpoint.withKeys
looks up every stretch of it that may be a word, as it stands and as
every word it may be a conjugation of, in the new dictionary_key table
(one row per key, indexed; migration dictionary-keys adds the table and
its index, nothing else), which the loader fills beside the entries.
The instance gets the text of each line looked up and keeps none of it,
as the Watch screen says.
Linux builds need libmpv-dev, and the app links libmpv: the snap stages
Ubuntu's libmpv2, makes the update-alternatives links for BLAS and LAPACK
that ffmpeg's speech recognition needs (staging never runs install
scripts), and removes what the GNOME runtime and core24 already hold, as
older copies of cairo, pango and librsvg hid the runtime's from its GTK.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-13 11:31:37 +02:00
|
|
|
await tester.tap(find.text('Pause after each line'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
await playTo(tester, const Duration(milliseconds: 9900));
|
|
|
|
|
expect(players.last!.state.value.playing, isFalse);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets('leaving tells the server where the video stopped', (
|
|
|
|
|
tester,
|
|
|
|
|
) async {
|
|
|
|
|
await watch(tester);
|
|
|
|
|
await playTo(tester, const Duration(seconds: 12));
|
|
|
|
|
|
|
|
|
|
GoRouter.of(tester.element(find.byType(WatchScreen))).go(RoutePaths.home);
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
|
|
|
|
|
expect(jellyfin.reports.last.stopped, isTrue);
|
|
|
|
|
expect(jellyfin.reports.last.position, const Duration(seconds: 12));
|
|
|
|
|
expect(players.last!.disposed, isTrue);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets('without a player the subtitles still show, and the screen '
|
|
|
|
|
'says why the video does not', (tester) async {
|
|
|
|
|
await watch(tester, playerAvailable: false);
|
|
|
|
|
|
|
|
|
|
expect(find.textContaining("Videos can't play on this device"), findsOne);
|
|
|
|
|
expect(find.text('昨日'), findsNothing);
|
|
|
|
|
expect(find.text('Words in this video'), findsOneWidget);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets("the sound is in the studyset's language, whichever track the "
|
|
|
|
|
'file plays first; the menu switches it', (tester) async {
|
|
|
|
|
await watch(
|
|
|
|
|
tester,
|
|
|
|
|
playback: const JellyfinPlayback(
|
|
|
|
|
itemId: 'episode-1',
|
|
|
|
|
mediaSourceId: 'episode-1',
|
|
|
|
|
playSessionId: 'session-1',
|
|
|
|
|
runTime: Duration(minutes: 24),
|
|
|
|
|
subtitles: [
|
|
|
|
|
JellyfinSubtitleTrack(index: 2, title: 'Japanese', language: 'jpn'),
|
|
|
|
|
JellyfinSubtitleTrack(index: 3, title: 'English', language: 'eng'),
|
|
|
|
|
],
|
|
|
|
|
audio: [
|
|
|
|
|
JellyfinAudioTrack(
|
|
|
|
|
index: 4,
|
|
|
|
|
title: 'German',
|
|
|
|
|
language: 'ger',
|
|
|
|
|
isDefault: true,
|
|
|
|
|
),
|
|
|
|
|
JellyfinAudioTrack(
|
|
|
|
|
index: 5,
|
|
|
|
|
title: 'English',
|
|
|
|
|
language: 'eng',
|
|
|
|
|
isDefault: true,
|
|
|
|
|
),
|
|
|
|
|
JellyfinAudioTrack(
|
|
|
|
|
index: 6,
|
|
|
|
|
title: 'Japanese [Original]',
|
|
|
|
|
language: 'jpn',
|
|
|
|
|
isDefault: true,
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
// As Android names them.
|
|
|
|
|
audio: const [
|
|
|
|
|
MediaAudioTrack(id: 'a', language: 'de'),
|
|
|
|
|
MediaAudioTrack(id: 'b', language: 'en'),
|
|
|
|
|
MediaAudioTrack(id: 'c', language: 'ja'),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
expect(players.last!.selectedAudio?.id, 'c');
|
|
|
|
|
|
feat(jellyfin): the player as players are, with the settings under a cog
The Watch screen's player takes the shape people know from players,
YouTube's first: the picture set in from every side with rounded
corners, so the glow of its colours shows all around it, and over it
play or pause in the middle with 10 seconds back and on beside it, and
along its foot where the video is (0:05 ──── 23:47) and a cog. The
controls fade out while it plays and the pointer rests, and come back as
it moves, on a tap with a finger, on a jump and while paused. The cog
opens a panel inside the picture, so nothing runs off the window: Pause
after each line, the subtitles, the translation and the sound, each
track named by its language in the app's words, and the keyboard
shortcuts. The buttons for the line before, again and the next sit
beside the subtitles they act on, no longer looking like a player's
skip to the next video. Keys are YouTube's (Space or K, J and L, the
arrows, 0 to 9, Home, End) with Shift and the arrows for lines, R, P and
Esc; ? lists them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-13 16:51:53 +02:00
|
|
|
await tester.tap(find.byTooltip('Settings'));
|
feat(jellyfin): watch from one's own Jellyfin server and learn the words of its subtitles
Animelon's way of learning from video, from the person's own Jellyfin
server: connected per device (Settings, or a studyset's Add material ›
Watch from Jellyfin) with a name and password or a Quick Connect code,
over HTTPS only (plain HTTP only to a server on the same machine); the
token stays in the system's keyring. Shows, films and videos tied to a
studyset follow the person in their settings as the server's and the
item's ids alone (settings schema 9), named by the server when shown.
The Watch screen plays the video (video_player on Android, media_kit's
libmpv on Linux and Windows) with the line in the studyset's front
language cut into the dictionary's words with furigana, and the back
language's line beneath; a word tapped pauses the video and becomes a
card with the line and its translation as its first hint, tagged with
the show. The video's words are listed, most frequent first. The sound is
the track in the studyset's language, whichever the file marks default,
and the menu switches it; the subtitles read are a whole track, never a
forced one (which only covers what the sound doesn't say), and captions
for the hard of hearing only without plain ones. The line and its
translation take the video's width in a box of a fixed height, so the
video keeps its size; in the dark theme the video's colours glow around
it, as YouTube's ambient mode, and the words' panel takes the colour of
the episode's picture under the pointer.
A line is cut into words with one request: DictionaryEndpoint.withKeys
looks up every stretch of it that may be a word, as it stands and as
every word it may be a conjugation of, in the new dictionary_key table
(one row per key, indexed; migration dictionary-keys adds the table and
its index, nothing else), which the loader fills beside the entries.
The instance gets the text of each line looked up and keeps none of it,
as the Watch screen says.
Linux builds need libmpv-dev, and the app links libmpv: the snap stages
Ubuntu's libmpv2, makes the update-alternatives links for BLAS and LAPACK
that ffmpeg's speech recognition needs (staging never runs install
scripts), and removes what the GNOME runtime and core24 already hold, as
older copies of cairo, pango and librsvg hid the runtime's from its GTK.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-13 11:31:37 +02:00
|
|
|
await tester.pumpAndSettle();
|
feat(jellyfin): the player as players are, with the settings under a cog
The Watch screen's player takes the shape people know from players,
YouTube's first: the picture set in from every side with rounded
corners, so the glow of its colours shows all around it, and over it
play or pause in the middle with 10 seconds back and on beside it, and
along its foot where the video is (0:05 ──── 23:47) and a cog. The
controls fade out while it plays and the pointer rests, and come back as
it moves, on a tap with a finger, on a jump and while paused. The cog
opens a panel inside the picture, so nothing runs off the window: Pause
after each line, the subtitles, the translation and the sound, each
track named by its language in the app's words, and the keyboard
shortcuts. The buttons for the line before, again and the next sit
beside the subtitles they act on, no longer looking like a player's
skip to the next video. Keys are YouTube's (Space or K, J and L, the
arrows, 0 to 9, Home, End) with Shift and the arrows for lines, R, P and
Esc; ? lists them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-13 16:51:53 +02:00
|
|
|
// Tracks are named by their language, plainly.
|
|
|
|
|
expect(find.text('Japanese'), findsWidgets);
|
feat(jellyfin): watch from one's own Jellyfin server and learn the words of its subtitles
Animelon's way of learning from video, from the person's own Jellyfin
server: connected per device (Settings, or a studyset's Add material ›
Watch from Jellyfin) with a name and password or a Quick Connect code,
over HTTPS only (plain HTTP only to a server on the same machine); the
token stays in the system's keyring. Shows, films and videos tied to a
studyset follow the person in their settings as the server's and the
item's ids alone (settings schema 9), named by the server when shown.
The Watch screen plays the video (video_player on Android, media_kit's
libmpv on Linux and Windows) with the line in the studyset's front
language cut into the dictionary's words with furigana, and the back
language's line beneath; a word tapped pauses the video and becomes a
card with the line and its translation as its first hint, tagged with
the show. The video's words are listed, most frequent first. The sound is
the track in the studyset's language, whichever the file marks default,
and the menu switches it; the subtitles read are a whole track, never a
forced one (which only covers what the sound doesn't say), and captions
for the hard of hearing only without plain ones. The line and its
translation take the video's width in a box of a fixed height, so the
video keeps its size; in the dark theme the video's colours glow around
it, as YouTube's ambient mode, and the words' panel takes the colour of
the episode's picture under the pointer.
A line is cut into words with one request: DictionaryEndpoint.withKeys
looks up every stretch of it that may be a word, as it stands and as
every word it may be a conjugation of, in the new dictionary_key table
(one row per key, indexed; migration dictionary-keys adds the table and
its index, nothing else), which the loader fills beside the entries.
The instance gets the text of each line looked up and keeps none of it,
as the Watch screen says.
Linux builds need libmpv-dev, and the app links libmpv: the snap stages
Ubuntu's libmpv2, makes the update-alternatives links for BLAS and LAPACK
that ffmpeg's speech recognition needs (staging never runs install
scripts), and removes what the GNOME runtime and core24 already hold, as
older copies of cairo, pango and librsvg hid the runtime's from its GTK.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-13 11:31:37 +02:00
|
|
|
await tester.tap(find.text('Audio'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
await tester.tap(find.text('German'));
|
|
|
|
|
await tester.pump();
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
expect(players.last!.selectedAudio?.id, 'a');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets("a forced track, which only covers what the sound doesn't "
|
|
|
|
|
'say, is never the one read', (tester) async {
|
|
|
|
|
await watch(
|
|
|
|
|
tester,
|
|
|
|
|
playback: const JellyfinPlayback(
|
|
|
|
|
itemId: 'episode-1',
|
|
|
|
|
mediaSourceId: 'episode-1',
|
|
|
|
|
playSessionId: 'session-1',
|
|
|
|
|
runTime: Duration(minutes: 24),
|
|
|
|
|
subtitles: [
|
|
|
|
|
JellyfinSubtitleTrack(
|
|
|
|
|
index: 7,
|
|
|
|
|
title: 'Japanese (forced)',
|
|
|
|
|
language: 'jpn',
|
|
|
|
|
isDefault: true,
|
|
|
|
|
isForced: true,
|
|
|
|
|
),
|
|
|
|
|
JellyfinSubtitleTrack(index: 2, title: 'Japanese', language: 'jpn'),
|
|
|
|
|
JellyfinSubtitleTrack(index: 3, title: 'English', language: 'eng'),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
await playTo(tester, const Duration(seconds: 2));
|
|
|
|
|
|
|
|
|
|
expect(find.text('友達'), findsOneWidget);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets('a line takes the whole width, its translation on one line '
|
|
|
|
|
'too; the video keeps its size however long the line is', (
|
|
|
|
|
tester,
|
|
|
|
|
) async {
|
|
|
|
|
final long = List.filled(8, '昨日、駅で友達に会いました。').join();
|
|
|
|
|
await watch(
|
|
|
|
|
tester,
|
|
|
|
|
vtt: {
|
|
|
|
|
('episode-1', 2):
|
|
|
|
|
'WEBVTT\n\n00:00:01.000 --> 00:00:05.000\n旅\n\n'
|
|
|
|
|
'00:00:06.000 --> 00:00:10.000\n$long\n',
|
|
|
|
|
('episode-1', 3):
|
|
|
|
|
'WEBVTT\n\n00:00:01.000 --> 00:00:05.000\nA journey.\n\n'
|
|
|
|
|
'00:00:06.000 --> 00:00:10.000\n'
|
|
|
|
|
'So I say, "Here you go!",\nand I lent it to him.\n',
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
await playTo(tester, const Duration(seconds: 2));
|
|
|
|
|
final controls = tester.getTopLeft(find.byTooltip('Previous line'));
|
|
|
|
|
|
|
|
|
|
await playTo(tester, const Duration(seconds: 7));
|
|
|
|
|
expect(
|
|
|
|
|
find.text('So I say, "Here you go!", and I lent it to him.'),
|
|
|
|
|
findsOneWidget,
|
|
|
|
|
);
|
|
|
|
|
expect(tester.getTopLeft(find.byTooltip('Previous line')), controls);
|
|
|
|
|
expect(tester.takeException(), isNull);
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-13 16:35:44 +02:00
|
|
|
testWidgets('resizing the window, across every size class, keeps the '
|
|
|
|
|
'video where it is', (tester) async {
|
|
|
|
|
await watch(tester);
|
|
|
|
|
await playTo(tester, const Duration(minutes: 2));
|
|
|
|
|
final playing = players.last;
|
|
|
|
|
|
|
|
|
|
for (final size in const [
|
|
|
|
|
Size(800, 900),
|
|
|
|
|
Size(500, 900),
|
|
|
|
|
Size(1400, 900),
|
|
|
|
|
]) {
|
|
|
|
|
tester.view.physicalSize = size;
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
expect(players.opened, hasLength(1));
|
|
|
|
|
expect(players.last, same(playing));
|
|
|
|
|
expect(playing!.disposed, isFalse);
|
|
|
|
|
expect(playing.state.value.position, const Duration(minutes: 2));
|
|
|
|
|
});
|
|
|
|
|
|
feat(jellyfin): the player as players are, with the settings under a cog
The Watch screen's player takes the shape people know from players,
YouTube's first: the picture set in from every side with rounded
corners, so the glow of its colours shows all around it, and over it
play or pause in the middle with 10 seconds back and on beside it, and
along its foot where the video is (0:05 ──── 23:47) and a cog. The
controls fade out while it plays and the pointer rests, and come back as
it moves, on a tap with a finger, on a jump and while paused. The cog
opens a panel inside the picture, so nothing runs off the window: Pause
after each line, the subtitles, the translation and the sound, each
track named by its language in the app's words, and the keyboard
shortcuts. The buttons for the line before, again and the next sit
beside the subtitles they act on, no longer looking like a player's
skip to the next video. Keys are YouTube's (Space or K, J and L, the
arrows, 0 to 9, Home, End) with Shift and the arrows for lines, R, P and
Esc; ? lists them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-13 16:51:53 +02:00
|
|
|
testWidgets('the player has play or pause with 10 seconds back and on '
|
|
|
|
|
'beside it, and where it is from 0:00 to its length', (tester) async {
|
|
|
|
|
await watch(tester);
|
|
|
|
|
await playTo(tester, const Duration(minutes: 1));
|
|
|
|
|
expect(find.text('1:00'), findsOneWidget);
|
|
|
|
|
expect(find.text('24:00'), findsOneWidget);
|
|
|
|
|
|
|
|
|
|
await tester.tap(find.byTooltip('10 seconds on'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
expect(players.last!.state.value.position, const Duration(seconds: 70));
|
|
|
|
|
await tester.tap(find.byTooltip('10 seconds back'));
|
|
|
|
|
await tester.tap(find.byTooltip('10 seconds back'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
expect(players.last!.state.value.position, const Duration(seconds: 50));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets("YouTube's keys work, the lines' too, and ? lists them", (
|
|
|
|
|
tester,
|
|
|
|
|
) async {
|
|
|
|
|
await watch(tester);
|
|
|
|
|
await playTo(tester, const Duration(minutes: 1));
|
|
|
|
|
Duration at() => players.last!.state.value.position;
|
|
|
|
|
|
|
|
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.keyL);
|
|
|
|
|
expect(at(), const Duration(seconds: 70));
|
|
|
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.keyJ);
|
|
|
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
|
|
|
|
|
expect(at(), const Duration(seconds: 55));
|
|
|
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.digit5);
|
|
|
|
|
expect(at(), const Duration(minutes: 12));
|
|
|
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.home);
|
|
|
|
|
expect(at(), Duration.zero);
|
|
|
|
|
|
|
|
|
|
await playTo(tester, const Duration(seconds: 7));
|
|
|
|
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
|
|
|
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
|
|
|
|
|
await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft);
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
expect(at(), const Duration(seconds: 11));
|
|
|
|
|
|
|
|
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.keyP);
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
await playTo(tester, const Duration(milliseconds: 14900));
|
|
|
|
|
expect(players.last!.state.value.playing, isFalse);
|
|
|
|
|
|
|
|
|
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.slash, character: '?');
|
|
|
|
|
await tester.sendKeyUpEvent(LogicalKeyboardKey.slash);
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
expect(find.text('Keyboard shortcuts'), findsOneWidget);
|
|
|
|
|
expect(find.text('Previous or next line'), findsOneWidget);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets('the controls fade out while it plays and the pointer rests, '
|
|
|
|
|
'and come back while paused', (tester) async {
|
|
|
|
|
await watch(tester);
|
|
|
|
|
double shown() => tester
|
|
|
|
|
.widget<AnimatedOpacity>(
|
|
|
|
|
find
|
|
|
|
|
.ancestor(
|
|
|
|
|
of: find.byTooltip('10 seconds on'),
|
|
|
|
|
matching: find.byType(AnimatedOpacity),
|
|
|
|
|
)
|
|
|
|
|
.first,
|
|
|
|
|
)
|
|
|
|
|
.opacity;
|
|
|
|
|
expect(shown(), 1);
|
|
|
|
|
|
|
|
|
|
await tester.tap(find.byTooltip('Play'));
|
|
|
|
|
await tester.pump();
|
|
|
|
|
await tester.pump(const Duration(seconds: 4));
|
|
|
|
|
expect(shown(), 0);
|
|
|
|
|
|
|
|
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.space);
|
|
|
|
|
await tester.pump();
|
|
|
|
|
expect(shown(), 1);
|
|
|
|
|
});
|
|
|
|
|
|
feat(jellyfin): watch from one's own Jellyfin server and learn the words of its subtitles
Animelon's way of learning from video, from the person's own Jellyfin
server: connected per device (Settings, or a studyset's Add material ›
Watch from Jellyfin) with a name and password or a Quick Connect code,
over HTTPS only (plain HTTP only to a server on the same machine); the
token stays in the system's keyring. Shows, films and videos tied to a
studyset follow the person in their settings as the server's and the
item's ids alone (settings schema 9), named by the server when shown.
The Watch screen plays the video (video_player on Android, media_kit's
libmpv on Linux and Windows) with the line in the studyset's front
language cut into the dictionary's words with furigana, and the back
language's line beneath; a word tapped pauses the video and becomes a
card with the line and its translation as its first hint, tagged with
the show. The video's words are listed, most frequent first. The sound is
the track in the studyset's language, whichever the file marks default,
and the menu switches it; the subtitles read are a whole track, never a
forced one (which only covers what the sound doesn't say), and captions
for the hard of hearing only without plain ones. The line and its
translation take the video's width in a box of a fixed height, so the
video keeps its size; in the dark theme the video's colours glow around
it, as YouTube's ambient mode, and the words' panel takes the colour of
the episode's picture under the pointer.
A line is cut into words with one request: DictionaryEndpoint.withKeys
looks up every stretch of it that may be a word, as it stands and as
every word it may be a conjugation of, in the new dictionary_key table
(one row per key, indexed; migration dictionary-keys adds the table and
its index, nothing else), which the loader fills beside the entries.
The instance gets the text of each line looked up and keeps none of it,
as the Watch screen says.
Linux builds need libmpv-dev, and the app links libmpv: the snap stages
Ubuntu's libmpv2, makes the update-alternatives links for BLAS and LAPACK
that ffmpeg's speech recognition needs (staging never runs install
scripts), and removes what the GNOME runtime and core24 already hold, as
older copies of cairo, pango and librsvg hid the runtime's from its GTK.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-13 11:31:37 +02:00
|
|
|
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));
|
|
|
|
|
|
|
|
|
|
expect(find.text('友達'), findsOneWidget);
|
|
|
|
|
expect(tester.takeException(), isNull);
|
|
|
|
|
});
|
|
|
|
|
}
|