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);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets('the next line, the one before and the same again play from '
|
|
|
|
|
'their start; a line can end with a pause', (tester) async {
|
|
|
|
|
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);
|
|
|
|
|
|
|
|
|
|
await tester.tap(find.byTooltip('Repeat line'));
|
|
|
|
|
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));
|
|
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
|
|
|
|
|
await tester.tap(find.byTooltip('Audio and subtitles'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
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): 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);
|
|
|
|
|
});
|
|
|
|
|
}
|