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>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-13 11:31:37 +02:00
commit da51ef73cb
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
78 changed files with 25034 additions and 28 deletions

File diff suppressed because one or more lines are too long

View file

@ -39,13 +39,15 @@ Targets: Linux, Windows, macOS, Android and iOS. There is no web build.
[iOS](https://docs.flutter.dev/platform-integration/ios/setup).
- **The Serverpod CLI v4.0.4**: `dart install serverpod_cli@4.0.4`. Pass `--no-analytics`
to every call; otherwise it reports the commands run to Serverpod.
- **Linux**, on top of Flutter's packages, the libraries of three plugins: WebKitGTK
(pulled in by the sign-in package), libsecret (keeps the session) and GStreamer
(plays sounds). On Debian or Ubuntu, Flutter's and these together:
- **Linux**, on top of Flutter's packages, the libraries of four plugins: WebKitGTK
(pulled in by the sign-in package), libsecret (keeps the session), GStreamer (plays
sounds) and libmpv (plays videos from Jellyfin; the app doesn't start without it).
On Debian or Ubuntu, Flutter's and these together:
```sh
sudo apt-get install clang cmake ninja-build pkg-config libgtk-3-dev libstdc++-12-dev \
libwebkit2gtk-4.1-dev libsecret-1-dev libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
libwebkit2gtk-4.1-dev libsecret-1-dev libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev \
libmpv-dev
```
- **Windows** needs Developer Mode (Settings › System › For developers): building makes

View file

@ -22,6 +22,8 @@ import 'package:halkyon_learn_client/src/protocol/auth/device.dart'
as _ieqh4yr9;
import 'package:halkyon_learn_client/src/protocol/dictionary/dictionary_hit.dart'
as _iqbqiphu;
import 'package:halkyon_learn_client/src/protocol/dictionary/dictionary_key_hit.dart'
as _ii0vb6vg;
import 'package:halkyon_learn_client/src/protocol/dictionary/dictionary_source.dart'
as _i8vj64sp;
import 'package:halkyon_learn_client/src/protocol/explore/explore_entry.dart'
@ -320,6 +322,28 @@ class EndpointDictionary extends _isc.EndpointRef {
},
);
/// The entries of the dictionary of [source] with meanings in
/// [glossLanguage] that have a key that is one of [keys] (at most
/// [maxKeys]), each with that key; for each key its first [maxPerKey]
/// entries in the dictionary's order. The app asks for every part of a
/// sentence that may be a word (`食べられなかった`, and `食べる`, which
/// that is a form of), to cut it into words with one request. Keys are
/// compared as written, without the variants a search tries: they come
/// from text, not from typing; a key with a space is never one.
_ida.Future<List<_ii0vb6vg.DictionaryKeyHit>> withKeys(
String source,
String glossLanguage,
List<String> keys,
) => caller.callServerEndpoint<List<_ii0vb6vg.DictionaryKeyHit>>(
'dictionary',
'withKeys',
{
'source': source,
'glossLanguage': glossLanguage,
'keys': keys,
},
);
/// How many entries of the dictionary of [source] with meanings in
/// [glossLanguage] carry [label].
_ida.Future<int> countLabelled(

View file

@ -0,0 +1,154 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod_client/serverpod_client.dart' as _isc;
/// A key of a dictionary entry, one row each, for finding words as a text
/// writes them (`食べる`, `たべる`): `DictionaryEndpoint.withKeys` asks for
/// hundreds at once, which the entries' barred `searchKeys` could only
/// answer by reading the whole dictionary (1.2 s for JMdict); an index on
/// these answers at once. Written by the loader beside the entries, keys
/// with a space left out: text is looked up a word at a time.
abstract class DictionaryKey
implements _isc.SerializableModel, _isc.ProtocolSerialization {
DictionaryKey._({
this.id,
required this.source,
required this.glossLanguage,
required this.key,
required this.sourceId,
required this.rank,
});
factory DictionaryKey({
int? id,
required String source,
required String glossLanguage,
required String key,
required String sourceId,
required int rank,
}) = _DictionaryKeyImpl;
factory DictionaryKey.fromJson(Map<String, dynamic> jsonSerialization) {
return DictionaryKey(
id: jsonSerialization['id'] as int?,
source: jsonSerialization['source'] as String,
glossLanguage: jsonSerialization['glossLanguage'] as String,
key: jsonSerialization['key'] as String,
sourceId: jsonSerialization['sourceId'] as String,
rank: jsonSerialization['rank'] as int,
);
}
/// The database id, set if the object has been inserted into the
/// database or if it has been fetched from the database. Otherwise,
/// the id will be null.
int? id;
/// The entry's source and meaning language, as on the entry.
String source;
String glossLanguage;
/// The key, in lower case, as the entry's `searchKeys` hold it.
String key;
/// The entry's id in its source, which with the two above finds it.
String sourceId;
/// The entry's rank, so the best entries of a key come first.
int rank;
/// Returns a shallow copy of this [DictionaryKey]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
DictionaryKey copyWith({
int? id,
String? source,
String? glossLanguage,
String? key,
String? sourceId,
int? rank,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'DictionaryKey',
if (id != null) 'id': id,
'source': source,
'glossLanguage': glossLanguage,
'key': key,
'sourceId': sourceId,
'rank': rank,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'DictionaryKey',
if (id != null) 'id': id,
'source': source,
'glossLanguage': glossLanguage,
'key': key,
'sourceId': sourceId,
'rank': rank,
};
}
@override
String toString() {
return _isc.SerializationManager.encode(this);
}
}
class _Undefined {}
class _DictionaryKeyImpl extends DictionaryKey {
_DictionaryKeyImpl({
int? id,
required String source,
required String glossLanguage,
required String key,
required String sourceId,
required int rank,
}) : super._(
id: id,
source: source,
glossLanguage: glossLanguage,
key: key,
sourceId: sourceId,
rank: rank,
);
/// Returns a shallow copy of this [DictionaryKey]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
DictionaryKey copyWith({
Object? id = _Undefined,
String? source,
String? glossLanguage,
String? key,
String? sourceId,
int? rank,
}) {
return DictionaryKey(
id: id is int? ? id : this.id,
source: source ?? this.source,
glossLanguage: glossLanguage ?? this.glossLanguage,
key: key ?? this.key,
sourceId: sourceId ?? this.sourceId,
rank: rank ?? this.rank,
);
}
}

View file

@ -0,0 +1,98 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:halkyon_learn_client/src/protocol/protocol.dart' as _itx5j3f5;
import 'package:serverpod_client/serverpod_client.dart' as _isc;
import '../dictionary/dictionary_hit.dart' as _in6hgebk;
/// A dictionary entry with one of its keys: what a text's words were found
/// by, the app looking up every way a sentence may be cut into words.
abstract class DictionaryKeyHit
implements _isc.SerializableModel, _isc.ProtocolSerialization {
DictionaryKeyHit._({
required this.key,
required this.hit,
});
factory DictionaryKeyHit({
required String key,
required _in6hgebk.DictionaryHit hit,
}) = _DictionaryKeyHitImpl;
factory DictionaryKeyHit.fromJson(Map<String, dynamic> jsonSerialization) {
return DictionaryKeyHit(
key: jsonSerialization['key'] as String,
hit: _itx5j3f5.Protocol().deserialize<_in6hgebk.DictionaryHit>(
jsonSerialization['hit'],
),
);
}
/// The key asked for that the entry has (`食べる`).
String key;
_in6hgebk.DictionaryHit hit;
/// Returns a shallow copy of this [DictionaryKeyHit]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
DictionaryKeyHit copyWith({
String? key,
_in6hgebk.DictionaryHit? hit,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'DictionaryKeyHit',
'key': key,
'hit': hit.toJson(),
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'DictionaryKeyHit',
'key': key,
'hit': hit.toJsonForProtocol(),
};
}
@override
String toString() {
return _isc.SerializationManager.encode(this);
}
}
class _DictionaryKeyHitImpl extends DictionaryKeyHit {
_DictionaryKeyHitImpl({
required String key,
required _in6hgebk.DictionaryHit hit,
}) : super._(
key: key,
hit: hit,
);
/// Returns a shallow copy of this [DictionaryKeyHit]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
DictionaryKeyHit copyWith({
String? key,
_in6hgebk.DictionaryHit? hit,
}) {
return DictionaryKeyHit(
key: key ?? this.key,
hit: hit ?? this.hit.copyWith(),
);
}
}

View file

@ -17,6 +17,8 @@ import 'package:halkyon_learn_client/src/protocol/auth/device.dart'
as _ieqh4yr9;
import 'package:halkyon_learn_client/src/protocol/dictionary/dictionary_hit.dart'
as _iqbqiphu;
import 'package:halkyon_learn_client/src/protocol/dictionary/dictionary_key_hit.dart'
as _ii0vb6vg;
import 'package:halkyon_learn_client/src/protocol/dictionary/dictionary_source.dart'
as _i8vj64sp;
import 'package:halkyon_learn_client/src/protocol/explore/explore_entry.dart'
@ -46,6 +48,8 @@ import 'common/not_found_exception.dart' as _i8oee73b;
import 'dictionary/dictionary_credit.dart' as _ii4hopsr;
import 'dictionary/dictionary_entry.dart' as _i4ipxv77;
import 'dictionary/dictionary_hit.dart' as _ivbh47kc;
import 'dictionary/dictionary_key.dart' as _i9gd3xbn;
import 'dictionary/dictionary_key_hit.dart' as _i6b9unou;
import 'dictionary/dictionary_source.dart' as _ie12eb65;
import 'explore/explore_entry.dart' as _iaxdli27;
import 'explore/explore_query.dart' as _ibl9h9o9;
@ -95,6 +99,8 @@ export 'common/not_found_exception.dart';
export 'dictionary/dictionary_credit.dart';
export 'dictionary/dictionary_entry.dart';
export 'dictionary/dictionary_hit.dart';
export 'dictionary/dictionary_key.dart';
export 'dictionary/dictionary_key_hit.dart';
export 'dictionary/dictionary_source.dart';
export 'explore/explore_entry.dart';
export 'explore/explore_query.dart';
@ -2322,6 +2328,12 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (t == _ivbh47kc.DictionaryHit) {
return _ivbh47kc.DictionaryHit.fromJson(data) as T;
}
if (t == _i9gd3xbn.DictionaryKey) {
return _i9gd3xbn.DictionaryKey.fromJson(data) as T;
}
if (t == _i6b9unou.DictionaryKeyHit) {
return _i6b9unou.DictionaryKeyHit.fromJson(data) as T;
}
if (t == _ie12eb65.DictionarySource) {
return _ie12eb65.DictionarySource.fromJson(data) as T;
}
@ -2487,6 +2499,14 @@ class Protocol extends _isd.DatabaseSerializationManager {
return (data != null ? _ivbh47kc.DictionaryHit.fromJson(data) : null)
as T;
}
if (t == _isc.getType<_i9gd3xbn.DictionaryKey?>()) {
return (data != null ? _i9gd3xbn.DictionaryKey.fromJson(data) : null)
as T;
}
if (t == _isc.getType<_i6b9unou.DictionaryKeyHit?>()) {
return (data != null ? _i6b9unou.DictionaryKeyHit.fromJson(data) : null)
as T;
}
if (t == _isc.getType<_ie12eb65.DictionarySource?>()) {
return (data != null ? _ie12eb65.DictionarySource.fromJson(data) : null)
as T;
@ -2668,15 +2688,21 @@ class Protocol extends _isd.DatabaseSerializationManager {
.toList()
as T;
}
if (t == List<_ii0vb6vg.DictionaryKeyHit>) {
return (data as List)
.map((e) => deserialize<_ii0vb6vg.DictionaryKeyHit>(e))
.toList()
as T;
}
if (t == List<String>) {
return (data as List).map((e) => deserialize<String>(e)).toList() as T;
}
if (t == Map<String, String>) {
return (data as Map).map(
(k, v) => MapEntry(deserialize<String>(k), deserialize<String>(v)),
)
as T;
}
if (t == List<String>) {
return (data as List).map((e) => deserialize<String>(e)).toList() as T;
}
if (t == List<_iyigb6vc.ExploreEntry>) {
return (data as List)
.map((e) => deserialize<_iyigb6vc.ExploreEntry>(e))
@ -2722,6 +2748,8 @@ class Protocol extends _isd.DatabaseSerializationManager {
_ii4hopsr.DictionaryCredit => 'DictionaryCredit',
_i4ipxv77.DictionaryEntry => 'DictionaryEntry',
_ivbh47kc.DictionaryHit => 'DictionaryHit',
_i9gd3xbn.DictionaryKey => 'DictionaryKey',
_i6b9unou.DictionaryKeyHit => 'DictionaryKeyHit',
_ie12eb65.DictionarySource => 'DictionarySource',
_iaxdli27.ExploreEntry => 'ExploreEntry',
_ibl9h9o9.ExploreQuery => 'ExploreQuery',
@ -2803,6 +2831,10 @@ class Protocol extends _isd.DatabaseSerializationManager {
return 'DictionaryEntry';
case _ivbh47kc.DictionaryHit():
return 'DictionaryHit';
case _i9gd3xbn.DictionaryKey():
return 'DictionaryKey';
case _i6b9unou.DictionaryKeyHit():
return 'DictionaryKeyHit';
case _ie12eb65.DictionarySource():
return 'DictionarySource';
case _iaxdli27.ExploreEntry():
@ -2944,6 +2976,12 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (dataClassName == 'DictionaryHit') {
return deserialize<_ivbh47kc.DictionaryHit>(data['data']);
}
if (dataClassName == 'DictionaryKey') {
return deserialize<_i9gd3xbn.DictionaryKey>(data['data']);
}
if (dataClassName == 'DictionaryKeyHit') {
return deserialize<_i6b9unou.DictionaryKeyHit>(data['data']);
}
if (dataClassName == 'DictionarySource') {
return deserialize<_ie12eb65.DictionarySource>(data['data']);
}

View file

@ -0,0 +1,133 @@
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/video/media_players.dart';
import 'package:halkyon_learn_flutter/src/core/video/subtitles.dart';
import 'package:halkyon_learn_flutter/src/feature/jellyfin/data/jellyfin_gateway.dart';
import 'package:http/http.dart' as http;
import 'package:integration_test/integration_test.dart';
import 'package:material_ui/material_ui.dart';
// The video player for real (ExoPlayer on Android, libmpv on Linux and
// Windows), streaming from a Jellyfin server, which `flutter test` can't:
// on a device or the desktop,
//
// flutter test integration_test/jellyfin_player_test.dart -d <device> \
// --dart-define=JELLYFIN_URL=https://jellyfin.example.org \
// --dart-define=JELLYFIN_USER=<name> \
// --dart-define=JELLYFIN_PASSWORD=<password>
//
// with a server holding at least one episode, film or video with text
// subtitles, reached over HTTPS as the app reaches it (Android refuses plain
// HTTP to it). A Linux build needs libmpv-dev.
const _url = String.fromEnvironment('JELLYFIN_URL');
const _user = String.fromEnvironment('JELLYFIN_USER');
const _password = String.fromEnvironment('JELLYFIN_PASSWORD');
void main() {
// Frames as the app draws them, as the picture is checked.
IntegrationTestWidgetsFlutterBinding.ensureInitialized().framePolicy =
LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
testWidgets('streams a video with subtitles from the server and plays it', (
tester,
) async {
final client = http.Client();
addTearDown(client.close);
final gateway = HttpJellyfinGateway(
client,
device: (id: 'device-check', name: 'Device check', version: '0'),
);
final found = await tester.runAsync(() async {
final server = await gateway.server(Uri.parse(_url));
final account = await gateway.signIn(server, _user, _password);
final videos = await gateway.search(account, '');
final libraries = await gateway.libraries(account);
// Down the first library to its first video.
var items = [...videos, ...libraries];
for (var depth = 0; depth < 4; depth++) {
final video = items.where((item) => item.kind.isVideo).firstOrNull;
if (video != null) {
final playback = await gateway.playback(account, video.id);
final track = playback.subtitles
.where((track) => track.isText)
.firstOrNull;
final cues = track == null
? const <SubtitleCue>[]
: parseSubtitles(
await gateway.subtitles(account, playback, track),
);
return (account: account, playback: playback, cues: cues);
}
items = items.isEmpty
? items
: await gateway.children(account, items.first.id);
}
return null;
});
expect(found, isNotNull, reason: 'No video found on $_url');
final (:account, :playback, :cues) = found!;
expect(cues, isNotEmpty, reason: 'The video has no text subtitles');
final players = PlatformMediaPlayers();
expect(players.available, isTrue);
// From the middle, where a picture is unlikely to be one colour.
final video = (await tester.runAsync(
() => players.open(
gateway.streamOf(account, playback),
headers: gateway.headersOf(account),
start: (playback.runTime ?? Duration.zero) ~/ 2,
),
))!;
addTearDown(() => tester.runAsync(video.dispose));
final picture = GlobalKey();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Center(
child: RepaintBoundary(
key: picture,
child: SizedBox(width: 640, height: 360, child: video.view()),
),
),
),
),
);
await tester.runAsync(() async {
await video.play();
await Future<void>.delayed(const Duration(seconds: 4));
});
await tester.pump();
final state = video.state.value;
expect(state.error, isNull);
expect(state.duration, greaterThan(Duration.zero));
expect(state.position, greaterThan(const Duration(seconds: 1)));
// The picture is drawn, not only the sound played: a frame isn't one
// flat colour.
final boundary =
picture.currentContext!.findRenderObject()! as RenderRepaintBoundary;
final frame = (await tester.runAsync(() async {
final image = await boundary.toImage();
return (await image.toByteData())!;
}))!;
final lightness = [
for (var i = 0; i < frame.lengthInBytes; i += 4)
frame.getUint8(i) + frame.getUint8(i + 1) + frame.getUint8(i + 2),
];
final mean = lightness.reduce((a, b) => a + b) / lightness.length;
final spread =
lightness.map((value) => (value - mean).abs()).reduce((a, b) => a + b) /
lightness.length;
expect(spread, greaterThan(4), reason: 'The picture is one flat colour');
await tester.runAsync(() => video.seekTo(cues.first.start));
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 500)),
);
expect(
cueAt(cues, video.state.value.position + const Duration(milliseconds: 1)),
isNotNull,
);
});
}

View file

@ -1737,5 +1737,58 @@
"planReview": "{count, plural, =1{1 Karteikarte lernen} other{{count} Karteikarten lernen}}",
"planContinue": "„{title}“ fortsetzen",
"planNothing": "Gerade steht nichts an.",
"planLearningPaths": "Lernpfade"
"planLearningPaths": "Lernpfade",
"jellyfinTitle": "Jellyfin",
"jellyfinSubtitle": "Schau Serien und Filme von deinem eigenen Jellyfin-Server und lerne die Wörter ihrer Untertitel.",
"jellyfinPrivacy": "Die Adresse deines Servers und deine Anmeldung bleiben auf diesem Gerät, und die mit einem Lernset verbundenen Serien folgen dir als IDs, nie als Titel. Um die Wörter einer Zeile zu finden, fragt die App die Instanz nach ihrem Text; sie antwortet und speichert ihn nicht.",
"jellyfinSignedInAs": "{user} auf {server}",
"jellyfinSignOut": "Von Jellyfin abmelden",
"jellyfinAddress": "Serveradresse",
"jellyfinAddressHint": "jellyfin.example.org",
"jellyfinFind": "Weiter",
"jellyfinUser": "Benutzername",
"jellyfinPassword": "Passwort",
"jellyfinSignIn": "Anmelden",
"jellyfinQuickConnect": "Mit einem Code anmelden",
"jellyfinQuickConnectCode": "Gib diesen Code unter Quick Connect in einer Jellyfin-App ein, in der du angemeldet bist:",
"jellyfinQuickConnectWaiting": "Warte, bis der Code bestätigt ist …",
"jellyfinOtherServer": "Anderer Server",
"jellyfinUnreachable": "Unter dieser Adresse antwortet kein Server über HTTPS.",
"jellyfinNotEncrypted": "Diese Adresse ist nicht verschlüsselt, und dein Passwort ginge im Klartext. Nutze die HTTPS-Adresse des Servers.",
"jellyfinNotJellyfin": "Unter dieser Adresse ist kein Jellyfin-Server.",
"jellyfinWrongCredentials": "Benutzername oder Passwort stimmt nicht.",
"jellyfinSignedOut": "Du wurdest von Jellyfin abgemeldet. Melde dich erneut an.",
"jellyfinFailed": "Das hat auf dem Jellyfin-Server nicht geklappt. Versuch es noch einmal.",
"studysetWatch": "Von Jellyfin schauen",
"jellyfinBrowseTitle": "Schauen",
"jellyfinTied": "Mit diesem Lernset verbunden",
"jellyfinLibraries": "Mediatheken",
"jellyfinSearchHint": "Serien und Filme suchen",
"jellyfinTie": "Mit diesem Lernset verbinden",
"jellyfinUntie": "Vom Lernset lösen",
"jellyfinEmpty": "Hier ist nichts",
"jellyfinEpisode": "S{season} F{episode}",
"jellyfinNoPlayer": "Auf diesem Gerät können keine Videos abgespielt werden.",
"jellyfinNoPlayerLinux": "Auf diesem Gerät können keine Videos abgespielt werden: Dafür braucht es libmpv (das Paket libmpv2).",
"watchNoSubtitles": "Dieses Video hat keine Untertitel in der Sprache des Lernsets als Text, es gibt also nichts nachzuschlagen.",
"watchSubtitles": "Untertitel",
"watchTranslation": "Übersetzung",
"watchAudio": "Audio",
"watchTracks": "Audio und Untertitel",
"watchNone": "Keine",
"watchPlay": "Abspielen",
"watchPause": "Pause",
"watchPreviousLine": "Vorherige Zeile",
"watchNextLine": "Nächste Zeile",
"watchRepeatLine": "Zeile wiederholen",
"watchPauseAtLineEnd": "Nach jeder Zeile anhalten",
"watchWordsTitle": "Wörter in diesem Video",
"watchWordsNewOnly": "Nur neue",
"watchWordsFinding": "Wörter werden gesucht …",
"watchWordCount": "{count}×",
"watchAdd": "Hinzufügen",
"watchAdded": "{word} hinzugefügt, mit dieser Zeile",
"watchTapAWord": "Tipp auf ein Wort der Untertitel, um es nachzuschlagen.",
"watchFormOf": "Eine Form von {word}",
"watchFailed": "Das Video konnte nicht abgespielt werden."
}

View file

@ -2048,5 +2048,63 @@
"planContinue": "Continue “{title}”",
"@planContinue": {"placeholders": {"title": {"type": "String"}}},
"planNothing": "Nothing due right now.",
"planLearningPaths": "Learning paths"
"planLearningPaths": "Learning paths",
"jellyfinTitle": "Jellyfin",
"jellyfinSubtitle": "Watch shows and films from your own Jellyfin server, and learn the words of their subtitles.",
"jellyfinPrivacy": "Your server's address and your sign-in stay on this device, and the shows tied to a studyset follow you as ids, never titles. To find the words of a line, the app asks the instance about its text, which it answers and doesn't keep.",
"jellyfinSignedInAs": "{user} on {server}",
"@jellyfinSignedInAs": {"placeholders": {"user": {"type": "String"}, "server": {"type": "String"}}},
"jellyfinSignOut": "Sign out of Jellyfin",
"jellyfinAddress": "Server address",
"jellyfinAddressHint": "jellyfin.example.org",
"jellyfinFind": "Continue",
"jellyfinUser": "User name",
"jellyfinPassword": "Password",
"jellyfinSignIn": "Sign in",
"jellyfinQuickConnect": "Sign in with a code",
"jellyfinQuickConnectCode": "Enter this code under Quick Connect in a Jellyfin app you're signed in to:",
"jellyfinQuickConnectWaiting": "Waiting for the code to be approved…",
"jellyfinOtherServer": "Another server",
"jellyfinUnreachable": "No server answered at that address over HTTPS.",
"jellyfinNotEncrypted": "That address isn't encrypted, and your password would travel in the clear. Use the server's HTTPS address.",
"jellyfinNotJellyfin": "That address isn't a Jellyfin server.",
"jellyfinWrongCredentials": "The user name or password is wrong.",
"jellyfinSignedOut": "You were signed out of Jellyfin. Sign in again.",
"jellyfinFailed": "That didn't work on the Jellyfin server. Try again.",
"studysetWatch": "Watch from Jellyfin",
"jellyfinBrowseTitle": "Watch",
"jellyfinTied": "Tied to this studyset",
"jellyfinLibraries": "Libraries",
"jellyfinSearchHint": "Search shows and films",
"jellyfinTie": "Tie to this studyset",
"jellyfinUntie": "Untie from this studyset",
"jellyfinEmpty": "Nothing here",
"jellyfinEpisode": "S{season} E{episode}",
"@jellyfinEpisode": {"placeholders": {"season": {"type": "int"}, "episode": {"type": "int"}}},
"jellyfinNoPlayer": "Videos can't play on this device.",
"jellyfinNoPlayerLinux": "Videos can't play on this device: playing them needs libmpv (the package libmpv2).",
"watchNoSubtitles": "This video has no subtitles in the studyset's language as text, so there is nothing to look up.",
"watchSubtitles": "Subtitles",
"watchTranslation": "Translation",
"watchAudio": "Audio",
"watchTracks": "Audio and subtitles",
"watchNone": "None",
"watchPlay": "Play",
"watchPause": "Pause",
"watchPreviousLine": "Previous line",
"watchNextLine": "Next line",
"watchRepeatLine": "Repeat line",
"watchPauseAtLineEnd": "Pause after each line",
"watchWordsTitle": "Words in this video",
"watchWordsNewOnly": "New only",
"watchWordsFinding": "Finding the words…",
"watchWordCount": "{count}×",
"@watchWordCount": {"placeholders": {"count": {"type": "int"}}},
"watchAdd": "Add",
"watchAdded": "{word} added, with this line",
"@watchAdded": {"placeholders": {"word": {"type": "String"}}},
"watchTapAWord": "Tap a word of the subtitles to look it up.",
"watchFormOf": "A form of {word}",
"@watchFormOf": {"placeholders": {"word": {"type": "String"}}},
"watchFailed": "The video couldn't be played."
}

View file

@ -10268,6 +10268,324 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Learning paths'**
String get planLearningPaths;
/// No description provided for @jellyfinTitle.
///
/// In en, this message translates to:
/// **'Jellyfin'**
String get jellyfinTitle;
/// No description provided for @jellyfinSubtitle.
///
/// In en, this message translates to:
/// **'Watch shows and films from your own Jellyfin server, and learn the words of their subtitles.'**
String get jellyfinSubtitle;
/// No description provided for @jellyfinPrivacy.
///
/// In en, this message translates to:
/// **'Your server\'s address and your sign-in stay on this device, and the shows tied to a studyset follow you as ids, never titles. To find the words of a line, the app asks the instance about its text, which it answers and doesn\'t keep.'**
String get jellyfinPrivacy;
/// No description provided for @jellyfinSignedInAs.
///
/// In en, this message translates to:
/// **'{user} on {server}'**
String jellyfinSignedInAs(String user, String server);
/// No description provided for @jellyfinSignOut.
///
/// In en, this message translates to:
/// **'Sign out of Jellyfin'**
String get jellyfinSignOut;
/// No description provided for @jellyfinAddress.
///
/// In en, this message translates to:
/// **'Server address'**
String get jellyfinAddress;
/// No description provided for @jellyfinAddressHint.
///
/// In en, this message translates to:
/// **'jellyfin.example.org'**
String get jellyfinAddressHint;
/// No description provided for @jellyfinFind.
///
/// In en, this message translates to:
/// **'Continue'**
String get jellyfinFind;
/// No description provided for @jellyfinUser.
///
/// In en, this message translates to:
/// **'User name'**
String get jellyfinUser;
/// No description provided for @jellyfinPassword.
///
/// In en, this message translates to:
/// **'Password'**
String get jellyfinPassword;
/// No description provided for @jellyfinSignIn.
///
/// In en, this message translates to:
/// **'Sign in'**
String get jellyfinSignIn;
/// No description provided for @jellyfinQuickConnect.
///
/// In en, this message translates to:
/// **'Sign in with a code'**
String get jellyfinQuickConnect;
/// No description provided for @jellyfinQuickConnectCode.
///
/// In en, this message translates to:
/// **'Enter this code under Quick Connect in a Jellyfin app you\'re signed in to:'**
String get jellyfinQuickConnectCode;
/// No description provided for @jellyfinQuickConnectWaiting.
///
/// In en, this message translates to:
/// **'Waiting for the code to be approved…'**
String get jellyfinQuickConnectWaiting;
/// No description provided for @jellyfinOtherServer.
///
/// In en, this message translates to:
/// **'Another server'**
String get jellyfinOtherServer;
/// No description provided for @jellyfinUnreachable.
///
/// In en, this message translates to:
/// **'No server answered at that address over HTTPS.'**
String get jellyfinUnreachable;
/// No description provided for @jellyfinNotEncrypted.
///
/// In en, this message translates to:
/// **'That address isn\'t encrypted, and your password would travel in the clear. Use the server\'s HTTPS address.'**
String get jellyfinNotEncrypted;
/// No description provided for @jellyfinNotJellyfin.
///
/// In en, this message translates to:
/// **'That address isn\'t a Jellyfin server.'**
String get jellyfinNotJellyfin;
/// No description provided for @jellyfinWrongCredentials.
///
/// In en, this message translates to:
/// **'The user name or password is wrong.'**
String get jellyfinWrongCredentials;
/// No description provided for @jellyfinSignedOut.
///
/// In en, this message translates to:
/// **'You were signed out of Jellyfin. Sign in again.'**
String get jellyfinSignedOut;
/// No description provided for @jellyfinFailed.
///
/// In en, this message translates to:
/// **'That didn\'t work on the Jellyfin server. Try again.'**
String get jellyfinFailed;
/// No description provided for @studysetWatch.
///
/// In en, this message translates to:
/// **'Watch from Jellyfin'**
String get studysetWatch;
/// No description provided for @jellyfinBrowseTitle.
///
/// In en, this message translates to:
/// **'Watch'**
String get jellyfinBrowseTitle;
/// No description provided for @jellyfinTied.
///
/// In en, this message translates to:
/// **'Tied to this studyset'**
String get jellyfinTied;
/// No description provided for @jellyfinLibraries.
///
/// In en, this message translates to:
/// **'Libraries'**
String get jellyfinLibraries;
/// No description provided for @jellyfinSearchHint.
///
/// In en, this message translates to:
/// **'Search shows and films'**
String get jellyfinSearchHint;
/// No description provided for @jellyfinTie.
///
/// In en, this message translates to:
/// **'Tie to this studyset'**
String get jellyfinTie;
/// No description provided for @jellyfinUntie.
///
/// In en, this message translates to:
/// **'Untie from this studyset'**
String get jellyfinUntie;
/// No description provided for @jellyfinEmpty.
///
/// In en, this message translates to:
/// **'Nothing here'**
String get jellyfinEmpty;
/// No description provided for @jellyfinEpisode.
///
/// In en, this message translates to:
/// **'S{season} E{episode}'**
String jellyfinEpisode(int season, int episode);
/// No description provided for @jellyfinNoPlayer.
///
/// In en, this message translates to:
/// **'Videos can\'t play on this device.'**
String get jellyfinNoPlayer;
/// No description provided for @jellyfinNoPlayerLinux.
///
/// In en, this message translates to:
/// **'Videos can\'t play on this device: playing them needs libmpv (the package libmpv2).'**
String get jellyfinNoPlayerLinux;
/// No description provided for @watchNoSubtitles.
///
/// In en, this message translates to:
/// **'This video has no subtitles in the studyset\'s language as text, so there is nothing to look up.'**
String get watchNoSubtitles;
/// No description provided for @watchSubtitles.
///
/// In en, this message translates to:
/// **'Subtitles'**
String get watchSubtitles;
/// No description provided for @watchTranslation.
///
/// In en, this message translates to:
/// **'Translation'**
String get watchTranslation;
/// No description provided for @watchAudio.
///
/// In en, this message translates to:
/// **'Audio'**
String get watchAudio;
/// No description provided for @watchTracks.
///
/// In en, this message translates to:
/// **'Audio and subtitles'**
String get watchTracks;
/// No description provided for @watchNone.
///
/// In en, this message translates to:
/// **'None'**
String get watchNone;
/// No description provided for @watchPlay.
///
/// In en, this message translates to:
/// **'Play'**
String get watchPlay;
/// No description provided for @watchPause.
///
/// In en, this message translates to:
/// **'Pause'**
String get watchPause;
/// No description provided for @watchPreviousLine.
///
/// In en, this message translates to:
/// **'Previous line'**
String get watchPreviousLine;
/// No description provided for @watchNextLine.
///
/// In en, this message translates to:
/// **'Next line'**
String get watchNextLine;
/// No description provided for @watchRepeatLine.
///
/// In en, this message translates to:
/// **'Repeat line'**
String get watchRepeatLine;
/// No description provided for @watchPauseAtLineEnd.
///
/// In en, this message translates to:
/// **'Pause after each line'**
String get watchPauseAtLineEnd;
/// No description provided for @watchWordsTitle.
///
/// In en, this message translates to:
/// **'Words in this video'**
String get watchWordsTitle;
/// No description provided for @watchWordsNewOnly.
///
/// In en, this message translates to:
/// **'New only'**
String get watchWordsNewOnly;
/// No description provided for @watchWordsFinding.
///
/// In en, this message translates to:
/// **'Finding the words…'**
String get watchWordsFinding;
/// No description provided for @watchWordCount.
///
/// In en, this message translates to:
/// **'{count}×'**
String watchWordCount(int count);
/// No description provided for @watchAdd.
///
/// In en, this message translates to:
/// **'Add'**
String get watchAdd;
/// No description provided for @watchAdded.
///
/// In en, this message translates to:
/// **'{word} added, with this line'**
String watchAdded(String word);
/// No description provided for @watchTapAWord.
///
/// In en, this message translates to:
/// **'Tap a word of the subtitles to look it up.'**
String get watchTapAWord;
/// No description provided for @watchFormOf.
///
/// In en, this message translates to:
/// **'A form of {word}'**
String watchFormOf(String word);
/// No description provided for @watchFailed.
///
/// In en, this message translates to:
/// **'The video couldn\'t be played.'**
String get watchFailed;
}
class _AppLocalizationsDelegate

View file

@ -6478,4 +6478,187 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get planLearningPaths => 'Lernpfade';
@override
String get jellyfinTitle => 'Jellyfin';
@override
String get jellyfinSubtitle =>
'Schau Serien und Filme von deinem eigenen Jellyfin-Server und lerne die Wörter ihrer Untertitel.';
@override
String get jellyfinPrivacy =>
'Die Adresse deines Servers und deine Anmeldung bleiben auf diesem Gerät, und die mit einem Lernset verbundenen Serien folgen dir als IDs, nie als Titel. Um die Wörter einer Zeile zu finden, fragt die App die Instanz nach ihrem Text; sie antwortet und speichert ihn nicht.';
@override
String jellyfinSignedInAs(String user, String server) {
return '$user auf $server';
}
@override
String get jellyfinSignOut => 'Von Jellyfin abmelden';
@override
String get jellyfinAddress => 'Serveradresse';
@override
String get jellyfinAddressHint => 'jellyfin.example.org';
@override
String get jellyfinFind => 'Weiter';
@override
String get jellyfinUser => 'Benutzername';
@override
String get jellyfinPassword => 'Passwort';
@override
String get jellyfinSignIn => 'Anmelden';
@override
String get jellyfinQuickConnect => 'Mit einem Code anmelden';
@override
String get jellyfinQuickConnectCode =>
'Gib diesen Code unter Quick Connect in einer Jellyfin-App ein, in der du angemeldet bist:';
@override
String get jellyfinQuickConnectWaiting =>
'Warte, bis der Code bestätigt ist …';
@override
String get jellyfinOtherServer => 'Anderer Server';
@override
String get jellyfinUnreachable =>
'Unter dieser Adresse antwortet kein Server über HTTPS.';
@override
String get jellyfinNotEncrypted =>
'Diese Adresse ist nicht verschlüsselt, und dein Passwort ginge im Klartext. Nutze die HTTPS-Adresse des Servers.';
@override
String get jellyfinNotJellyfin =>
'Unter dieser Adresse ist kein Jellyfin-Server.';
@override
String get jellyfinWrongCredentials =>
'Benutzername oder Passwort stimmt nicht.';
@override
String get jellyfinSignedOut =>
'Du wurdest von Jellyfin abgemeldet. Melde dich erneut an.';
@override
String get jellyfinFailed =>
'Das hat auf dem Jellyfin-Server nicht geklappt. Versuch es noch einmal.';
@override
String get studysetWatch => 'Von Jellyfin schauen';
@override
String get jellyfinBrowseTitle => 'Schauen';
@override
String get jellyfinTied => 'Mit diesem Lernset verbunden';
@override
String get jellyfinLibraries => 'Mediatheken';
@override
String get jellyfinSearchHint => 'Serien und Filme suchen';
@override
String get jellyfinTie => 'Mit diesem Lernset verbinden';
@override
String get jellyfinUntie => 'Vom Lernset lösen';
@override
String get jellyfinEmpty => 'Hier ist nichts';
@override
String jellyfinEpisode(int season, int episode) {
return 'S$season F$episode';
}
@override
String get jellyfinNoPlayer =>
'Auf diesem Gerät können keine Videos abgespielt werden.';
@override
String get jellyfinNoPlayerLinux =>
'Auf diesem Gerät können keine Videos abgespielt werden: Dafür braucht es libmpv (das Paket libmpv2).';
@override
String get watchNoSubtitles =>
'Dieses Video hat keine Untertitel in der Sprache des Lernsets als Text, es gibt also nichts nachzuschlagen.';
@override
String get watchSubtitles => 'Untertitel';
@override
String get watchTranslation => 'Übersetzung';
@override
String get watchAudio => 'Audio';
@override
String get watchTracks => 'Audio und Untertitel';
@override
String get watchNone => 'Keine';
@override
String get watchPlay => 'Abspielen';
@override
String get watchPause => 'Pause';
@override
String get watchPreviousLine => 'Vorherige Zeile';
@override
String get watchNextLine => 'Nächste Zeile';
@override
String get watchRepeatLine => 'Zeile wiederholen';
@override
String get watchPauseAtLineEnd => 'Nach jeder Zeile anhalten';
@override
String get watchWordsTitle => 'Wörter in diesem Video';
@override
String get watchWordsNewOnly => 'Nur neue';
@override
String get watchWordsFinding => 'Wörter werden gesucht …';
@override
String watchWordCount(int count) {
return '$count×';
}
@override
String get watchAdd => 'Hinzufügen';
@override
String watchAdded(String word) {
return '$word hinzugefügt, mit dieser Zeile';
}
@override
String get watchTapAWord =>
'Tipp auf ein Wort der Untertitel, um es nachzuschlagen.';
@override
String watchFormOf(String word) {
return 'Eine Form von $word';
}
@override
String get watchFailed => 'Das Video konnte nicht abgespielt werden.';
}

View file

@ -6423,6 +6423,185 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get planLearningPaths => 'Learning paths';
@override
String get jellyfinTitle => 'Jellyfin';
@override
String get jellyfinSubtitle =>
'Watch shows and films from your own Jellyfin server, and learn the words of their subtitles.';
@override
String get jellyfinPrivacy =>
'Your server\'s address and your sign-in stay on this device, and the shows tied to a studyset follow you as ids, never titles. To find the words of a line, the app asks the instance about its text, which it answers and doesn\'t keep.';
@override
String jellyfinSignedInAs(String user, String server) {
return '$user on $server';
}
@override
String get jellyfinSignOut => 'Sign out of Jellyfin';
@override
String get jellyfinAddress => 'Server address';
@override
String get jellyfinAddressHint => 'jellyfin.example.org';
@override
String get jellyfinFind => 'Continue';
@override
String get jellyfinUser => 'User name';
@override
String get jellyfinPassword => 'Password';
@override
String get jellyfinSignIn => 'Sign in';
@override
String get jellyfinQuickConnect => 'Sign in with a code';
@override
String get jellyfinQuickConnectCode =>
'Enter this code under Quick Connect in a Jellyfin app you\'re signed in to:';
@override
String get jellyfinQuickConnectWaiting =>
'Waiting for the code to be approved…';
@override
String get jellyfinOtherServer => 'Another server';
@override
String get jellyfinUnreachable =>
'No server answered at that address over HTTPS.';
@override
String get jellyfinNotEncrypted =>
'That address isn\'t encrypted, and your password would travel in the clear. Use the server\'s HTTPS address.';
@override
String get jellyfinNotJellyfin => 'That address isn\'t a Jellyfin server.';
@override
String get jellyfinWrongCredentials => 'The user name or password is wrong.';
@override
String get jellyfinSignedOut =>
'You were signed out of Jellyfin. Sign in again.';
@override
String get jellyfinFailed =>
'That didn\'t work on the Jellyfin server. Try again.';
@override
String get studysetWatch => 'Watch from Jellyfin';
@override
String get jellyfinBrowseTitle => 'Watch';
@override
String get jellyfinTied => 'Tied to this studyset';
@override
String get jellyfinLibraries => 'Libraries';
@override
String get jellyfinSearchHint => 'Search shows and films';
@override
String get jellyfinTie => 'Tie to this studyset';
@override
String get jellyfinUntie => 'Untie from this studyset';
@override
String get jellyfinEmpty => 'Nothing here';
@override
String jellyfinEpisode(int season, int episode) {
return 'S$season E$episode';
}
@override
String get jellyfinNoPlayer => 'Videos can\'t play on this device.';
@override
String get jellyfinNoPlayerLinux =>
'Videos can\'t play on this device: playing them needs libmpv (the package libmpv2).';
@override
String get watchNoSubtitles =>
'This video has no subtitles in the studyset\'s language as text, so there is nothing to look up.';
@override
String get watchSubtitles => 'Subtitles';
@override
String get watchTranslation => 'Translation';
@override
String get watchAudio => 'Audio';
@override
String get watchTracks => 'Audio and subtitles';
@override
String get watchNone => 'None';
@override
String get watchPlay => 'Play';
@override
String get watchPause => 'Pause';
@override
String get watchPreviousLine => 'Previous line';
@override
String get watchNextLine => 'Next line';
@override
String get watchRepeatLine => 'Repeat line';
@override
String get watchPauseAtLineEnd => 'Pause after each line';
@override
String get watchWordsTitle => 'Words in this video';
@override
String get watchWordsNewOnly => 'New only';
@override
String get watchWordsFinding => 'Finding the words…';
@override
String watchWordCount(int count) {
return '$count×';
}
@override
String get watchAdd => 'Add';
@override
String watchAdded(String word) {
return '$word added, with this line';
}
@override
String get watchTapAWord => 'Tap a word of the subtitles to look it up.';
@override
String watchFormOf(String word) {
return 'A form of $word';
}
@override
String get watchFailed => 'The video couldn\'t be played.';
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -19,6 +19,8 @@ import 'package:halkyon_learn_flutter/src/feature/dictionary/widget/dictionary_s
import 'package:halkyon_learn_flutter/src/feature/explore/widget/explore_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/bloc/inbox_count_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/home/widget/home_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/jellyfin/widget/jellyfin_browser_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/jellyfin/widget/watch_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/bloc/path_editor_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/bloc/path_runner_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/bloc/step_editor_bloc.dart';
@ -127,6 +129,24 @@ GoRouter buildRouter({String initialLocation = RoutePaths.home}) => GoRouter(
);
},
),
// Watching from Jellyfin for a studyset: the libraries, or what
// an item of them holds.
GoRoute(
path: '/studysets/:studysetId/jellyfin',
builder: (context, state) {
final studysetId = state.pathParameters['studysetId']!;
return _inStudyset(
context,
studysetId,
(_, _) => JellyfinBrowserScreen(
key: ValueKey(state.uri.queryParameters['in']),
studysetId: studysetId,
parentId: state.uri.queryParameters['in'],
name: state.uri.queryParameters['name'],
),
);
},
),
// A learning path: worked through, written, and one of its steps
// written.
GoRoute(
@ -240,6 +260,22 @@ GoRouter buildRouter({String initialLocation = RoutePaths.home}) => GoRouter(
path: RoutePaths.readingTimeline,
builder: (context, state) => const ReadingTimelineScreen(),
),
// A video of the Jellyfin server, watched in the whole window to learn
// the words of its subtitles into a studyset.
GoRoute(
path: '/watch/:studysetId/:itemId',
builder: (context, state) {
final studysetId = state.pathParameters['studysetId']!;
return _inStudyset(
context,
studysetId,
(_, _) => WatchScreen(
studysetId: studysetId,
itemId: state.pathParameters['itemId']!,
),
);
},
),
// A PDF, read in the whole window; at a page when asked.
GoRoute(
path: '/pdfs/:pdfId',

View file

@ -20,6 +20,7 @@ import 'package:halkyon_learn_flutter/src/core/sync/app_compatibility.dart';
import 'package:halkyon_learn_flutter/src/core/sync/pending_changes_store.dart';
import 'package:halkyon_learn_flutter/src/core/sync/sync_service.dart';
import 'package:halkyon_learn_flutter/src/core/version/app_version.dart';
import 'package:halkyon_learn_flutter/src/core/video/media_players.dart';
import 'package:halkyon_learn_flutter/src/core/video/youtube_videos.dart';
import 'package:halkyon_learn_flutter/src/feature/about/data/about_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/about/data/serverpod_about_gateway.dart';
@ -46,6 +47,9 @@ import 'package:halkyon_learn_flutter/src/feature/feedback/data/feedback_reposit
import 'package:halkyon_learn_flutter/src/feature/feedback/data/serverpod_feedback_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/data/sync_feedback_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/geo_map/data/geo_map_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/jellyfin/data/jellyfin_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/jellyfin/data/jellyfin_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/jellyfin/data/jellyfin_store.dart';
import 'package:halkyon_learn_flutter/src/feature/kanji/data/stroke_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
@ -78,6 +82,7 @@ import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_tabs.da
import 'package:halkyon_learn_flutter/src/feature/studyset/data/sync_studyset_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/sync_tag_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
import 'package:http/http.dart' as http;
import 'package:package_info_plus/package_info_plus.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
@ -160,6 +165,13 @@ class Dependencies({
/// Connects assistants to the account on the instance (ADR 0006).
required final AssistantRepository assistant,
/// The person's own Jellyfin server, to watch and learn from: the
/// device's, kept across instances.
required final JellyfinRepository jellyfin,
/// Plays videos streamed from a server.
required final MediaPlayers mediaPlayers,
final Future<void> Function()? _onDispose,
}) {
/// Builds the production graph on [instance], around one Serverpod
@ -218,6 +230,8 @@ class Dependencies({
claude: ClaudeHandOff(),
webSearch: WebSearch(),
assistant: AssistantRepository(ServerpodAssistantGateway(client)),
jellyfin: device.jellyfin,
mediaPlayers: PlatformMediaPlayers(),
openPerson: (person) async {
await previousClosed.timeout(
const Duration(seconds: 10),
@ -273,6 +287,9 @@ class DeviceParts({
/// Keeps the device's own settings, such as its appearance.
required final DeviceSettingsStore deviceSettingsStore,
/// The device's way to the person's Jellyfin server.
required final JellyfinRepository jellyfin,
}) {
/// Makes them, [builtIn] being the instance the build names.
static Future<DeviceParts> create({required Instance builtIn}) async {
@ -291,6 +308,19 @@ class DeviceParts({
deviceSettingsStore: FileDeviceSettingsStore(
File(p.join(support, 'device_settings.json')),
),
jellyfin: JellyfinRepository(
(device) => HttpJellyfinGateway(http.Client(), device: device),
SecureJellyfinStore(),
deviceName: switch (Platform.operatingSystem) {
'linux' => 'Linux',
'windows' => 'Windows',
'macos' => 'macOS',
'android' => 'Android',
'ios' => 'iOS',
final other => other,
},
appVersion: package.version,
),
);
}
}

View file

@ -68,6 +68,21 @@ abstract final class RoutePaths() {
static String dictionary(String studysetId) =>
'/studysets/$studysetId/dictionary';
/// Watching from Jellyfin for a studyset: what of the server it is tied
/// to and the libraries, or what [parentId] ([name]) holds.
static String jellyfin(String studysetId, {String? parentId, String? name}) =>
Uri(
path: '/studysets/$studysetId/jellyfin',
queryParameters: parentId == null
? null
: {'in': parentId, 'name': ?name},
).toString();
/// A video of the Jellyfin server, watched to learn the words of its
/// subtitles into a studyset, over the tabs.
static String watch(String studysetId, String itemId) =>
'/watch/$studysetId/$itemId';
/// A PDF, to read, over the tabs; at [page] when given, else where reading
/// left off. Cards and screenshots tied to a page name only the PDF, which
/// knows its studyset.

View file

@ -0,0 +1,371 @@
import 'dart:async';
import 'dart:io';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:material_ui/material_ui.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
import 'package:video_player/video_player.dart';
/// Where a playing video is: its [position] of its [duration], whether it
/// is [playing] or waiting for data ([buffering]), its shape, and why it
/// can't play ([error]).
final class const MediaState({
final Duration position = Duration.zero,
final Duration duration = Duration.zero,
final bool playing = false,
final bool buffering = false,
final double aspectRatio = 16 / 9,
final String? error,
}) with Equatable {
@override
List<Object?> get props => [
position,
duration,
playing,
buffering,
aspectRatio,
error,
];
}
/// A video being played: its [state] as it changes, the controls, and the
/// picture to show ([view]).
abstract interface class MediaPlayback() {
/// Where it is, as it changes: at least ten times a second while it
/// plays.
ValueListenable<MediaState> get state;
Future<void> play();
Future<void> pause();
Future<void> seekTo(Duration position);
/// The picture, filling the box it is given at the video's shape.
Widget view();
/// The video's audio tracks, in the file's order, once it is loaded;
/// empty where the player can't tell them apart or choose, which then
/// plays the file's own choice.
Future<List<MediaAudioTrack>> audioTracks();
/// Plays [track]'s sound, one of [audioTracks].
Future<void> selectAudio(MediaAudioTrack track);
/// Stops it, and lets go of what it held.
Future<void> dispose();
}
/// An audio track of the video playing, as its player knows it: its [id]
/// there, and its [language] as the file or the player names it (`jpn` in
/// libmpv, `ja` on Android).
final class const MediaAudioTrack({
required final String id,
final String? language,
}) with Equatable {
@override
List<Object?> get props => [id, language];
}
/// Of a player's [tracks], the one the file holds at [position] among its
/// audio tracks, in one of [languages]: there if the player lists it there
/// in such a language (or names none), else the first it names so. Null
/// when the player names none so.
MediaAudioTrack? audioTrackAt(
List<MediaAudioTrack> tracks, {
required int position,
required Set<String> languages,
}) {
bool inLanguage(MediaAudioTrack track) =>
languages.contains(track.language?.toLowerCase());
if (position >= 0 && position < tracks.length) {
final there = tracks[position];
if (there.language == null || inLanguage(there)) {
return there;
}
}
return tracks.where(inLanguage).firstOrNull;
}
/// Plays videos streamed from a server, in the app.
abstract interface class MediaPlayers() {
/// Whether this device plays them; else there is nothing to watch with.
bool get available;
/// [url] opened, sending [headers] with each request, paused at [start].
/// Throws when it can't be opened.
Future<MediaPlayback> open(
Uri url, {
Map<String, String> headers = const {},
Duration start = Duration.zero,
});
}
/// [MediaPlayers] of each platform: on Linux and Windows media_kit's
/// libmpv (on Linux the system's, which a build needs as libmpv-dev and the
/// snap brings along; media_kit_video links it, so the app doesn't start
/// without it; set up as the first video opens, which says so should that
/// still fail); elsewhere flutter.dev's
/// `video_player` (ExoPlayer on Android, AVPlayer on iOS and macOS).
/// media_kit is used directly, not behind `video_player`, as libmpv would
/// draw a video's own subtitles over it, which `video_player` can't turn
/// off: the app shows them itself.
class PlatformMediaPlayers() implements MediaPlayers {
static bool? _mediaKitReady;
bool get _usesMediaKit => Platform.isLinux || Platform.isWindows;
@override
bool get available => !_usesMediaKit || _setUpMediaKit();
static bool _setUpMediaKit() {
if (_mediaKitReady case final ready?) {
return ready;
}
try {
MediaKit.ensureInitialized();
return _mediaKitReady = true;
} on Object catch (error) {
debugPrint("Videos can't play here: $error");
return _mediaKitReady = false;
}
}
@override
Future<MediaPlayback> open(
Uri url, {
Map<String, String> headers = const {},
Duration start = Duration.zero,
}) async {
if (!available) {
throw UnsupportedError('No video player on ${Platform.operatingSystem}');
}
return _usesMediaKit
? await _MediaKitPlayback.open(url, headers: headers, start: start)
: await _VideoPlayerPlayback.open(url, headers: headers, start: start);
}
}
class _MediaKitPlayback(final Player _player, final VideoController _video)
implements MediaPlayback {
final _state = ValueNotifier(const MediaState());
final _subscriptions = <StreamSubscription<Object?>>[];
String? _error;
/// The picture's output is attached before the file opens: libmpv opens
/// a file without one as sound alone, and the picture then stays black.
static Future<MediaPlayback> open(
Uri url, {
required Map<String, String> headers,
required Duration start,
}) async {
final player = Player();
final playback = _MediaKitPlayback(player, VideoController(player))
..listen();
try {
await playback._player.open(
Media(
'$url',
httpHeaders: headers,
start: start > Duration.zero ? start : null,
),
play: false,
);
await playback._player.setSubtitleTrack(SubtitleTrack.no());
} on Object {
await playback.dispose();
rethrow;
}
return playback;
}
@override
ValueListenable<MediaState> get state => _state;
/// Follows the player from here on; keeps its own subtitles off, as
/// libmpv chooses a track by itself once the video loaded.
void listen() {
final stream = _player.stream;
_subscriptions.addAll([
stream.position.listen((_) => _update()),
stream.duration.listen((_) => _update()),
stream.playing.listen((_) => _update()),
stream.buffering.listen((_) => _update()),
stream.width.listen((_) => _update()),
stream.height.listen((_) => _update()),
stream.error.listen((error) {
_error = error;
_update();
}),
stream.track.listen((track) {
if (track.subtitle.id != 'no') {
unawaited(_player.setSubtitleTrack(SubtitleTrack.no()));
}
}),
]);
}
void _update() {
final state = _player.state;
final width = state.width ?? 0;
final height = state.height ?? 0;
_state.value = MediaState(
position: state.position,
duration: state.duration,
playing: state.playing,
buffering: state.buffering,
aspectRatio: width > 0 && height > 0 ? width / height : 16 / 9,
error: _error,
);
}
@override
Future<void> play() => _player.play();
@override
Future<void> pause() => _player.pause();
@override
Future<void> seekTo(Duration position) => _player.seek(position);
@override
Future<List<MediaAudioTrack>> audioTracks() async {
bool real(AudioTrack track) => track.id != 'auto' && track.id != 'no';
var tracks = _player.state.tracks.audio.where(real);
if (tracks.isEmpty) {
// libmpv lists them once the file is read.
final read = await _player.stream.tracks
.firstWhere((tracks) => tracks.audio.any(real))
.timeout(const Duration(seconds: 5), onTimeout: Tracks.new);
tracks = read.audio.where(real);
}
return [
for (final track in tracks)
MediaAudioTrack(id: track.id, language: track.language),
];
}
@override
Future<void> selectAudio(MediaAudioTrack track) async {
final chosen = _player.state.tracks.audio
.where((audio) => audio.id == track.id)
.firstOrNull;
if (chosen != null) {
await _player.setAudioTrack(chosen);
}
}
/// The picture alone, at its shape, without bars of its own around it:
/// what is beside it is the screen's.
@override
Widget view() => ValueListenableBuilder(
valueListenable: _state,
builder: (context, state, _) => AspectRatio(
aspectRatio: state.aspectRatio,
child: Video(
controller: _video,
controls: null,
fill: Colors.transparent,
subtitleViewConfiguration: const SubtitleViewConfiguration(
visible: false,
),
),
),
);
@override
Future<void> dispose() async {
for (final subscription in _subscriptions) {
await subscription.cancel();
}
await _player.dispose();
_state.dispose();
}
}
class _VideoPlayerPlayback(final VideoPlayerController _controller)
implements MediaPlayback {
late final _state = ValueNotifier(_read());
static Future<MediaPlayback> open(
Uri url, {
required Map<String, String> headers,
required Duration start,
}) async {
final controller = VideoPlayerController.networkUrl(
url,
httpHeaders: headers,
);
try {
await controller.initialize();
if (start > Duration.zero) {
await controller.seekTo(start);
}
} on Object {
await controller.dispose();
rethrow;
}
return _VideoPlayerPlayback(controller)..listen();
}
@override
ValueListenable<MediaState> get state => _state;
/// Follows the controller from here on.
void listen() => _controller.addListener(_update);
void _update() => _state.value = _read();
MediaState _read() {
final value = _controller.value;
return MediaState(
position: value.position,
duration: value.duration,
playing: value.isPlaying,
buffering: value.isBuffering,
aspectRatio: value.isInitialized && value.aspectRatio > 0
? value.aspectRatio
: 16 / 9,
error: value.hasError ? value.errorDescription ?? '' : null,
);
}
@override
Future<void> play() => _controller.play();
@override
Future<void> pause() => _controller.pause();
@override
Future<void> seekTo(Duration position) => _controller.seekTo(position);
@override
Widget view() => AspectRatio(
aspectRatio: _state.value.aspectRatio,
child: VideoPlayer(_controller),
);
@override
Future<List<MediaAudioTrack>> audioTracks() async {
if (!_controller.isAudioTrackSupportAvailable()) {
return const [];
}
return [
for (final track in await _controller.getAudioTracks())
MediaAudioTrack(id: track.id, language: track.language),
];
}
@override
Future<void> selectAudio(MediaAudioTrack track) =>
_controller.selectAudioTrack(track.id);
@override
Future<void> dispose() async {
_controller.removeListener(_update);
await _controller.dispose();
_state.dispose();
}
}

View file

@ -0,0 +1,140 @@
import 'package:equatable/equatable.dart';
/// A line of subtitles: its [text] (lines joined by a newline), shown from
/// [start] until [end].
final class const SubtitleCue({
required final Duration start,
required final Duration end,
required final String text,
}) with Equatable {
@override
List<Object?> get props => [start, end, text];
}
/// The cues of a WebVTT or SubRip file, in the order they start; cues
/// without text left out. Markup goes: tags (`<i>`, `<c.yellow>`,
/// `<00:00:01.000>`), and what an ASS file converted to either keeps of its
/// overrides (`{\an8}`).
List<SubtitleCue> parseSubtitles(String file) {
final cues = <SubtitleCue>[];
final blocks = file
.replaceAll('\r\n', '\n')
.replaceAll('\r', '\n')
.replaceFirst('', '')
.split(RegExp(r'\n{2,}'));
for (final block in blocks) {
final lines = block.split('\n');
final at = lines.indexWhere((line) => line.contains('-->'));
if (at < 0) {
continue;
}
final times = _times.firstMatch(lines[at]);
if (times == null) {
continue;
}
final text = [
for (final line in lines.skip(at + 1))
if (_clean(line) case final clean when clean.isNotEmpty) clean,
].join('\n');
if (text.isEmpty) {
continue;
}
cues.add(
SubtitleCue(
start: _duration(times[1]!),
end: _duration(times[2]!),
text: text,
),
);
}
cues.sort((a, b) => a.start.compareTo(b.start));
return cues;
}
/// The cue shown at [position] in [cues] (as [parseSubtitles] gives them);
/// of cues that overlap, the one that started last. Null between cues.
SubtitleCue? cueAt(List<SubtitleCue> cues, Duration position) {
// The last cue starting at or before the position, then back through
// those still running.
var low = 0;
var high = cues.length;
while (low < high) {
final middle = (low + high) ~/ 2;
if (cues[middle].start <= position) {
low = middle + 1;
} else {
high = middle;
}
}
for (var index = low - 1; index >= 0 && index >= low - 8; index--) {
if (cues[index].end > position) {
return cues[index];
}
}
return null;
}
/// The index of the last cue of [cues] (as [parseSubtitles] gives them)
/// that started at or before [position]; null before the first. Between
/// cues, the one shown last, as a reader keeps a line in view until the
/// next.
int? lastCueIndex(List<SubtitleCue> cues, Duration position) {
var low = 0;
var high = cues.length;
while (low < high) {
final middle = (low + high) ~/ 2;
if (cues[middle].start <= position) {
low = middle + 1;
} else {
high = middle;
}
}
return low == 0 ? null : low - 1;
}
/// The cues of [cues] that run while [cue] does, joined: the translation of
/// a line, from the other language's subtitles.
String? textDuring(List<SubtitleCue> cues, SubtitleCue cue) {
final middle = cue.start + (cue.end - cue.start) ~/ 2;
final during = [
for (final other in cues)
if (other.start < cue.end && other.end > cue.start) other,
];
if (during.isEmpty) {
return null;
}
// When several overlap, those running at the line's middle; else all.
final atMiddle = [
for (final other in during)
if (other.start <= middle && other.end > middle) other,
];
return [
for (final other in atMiddle.isEmpty ? during : atMiddle) other.text,
].join('\n');
}
final _times = RegExp(
r'((?:\d+:)?\d{1,2}:\d{2}[.,]\d{1,3})\s*-->\s*((?:\d+:)?\d{1,2}:\d{2}[.,]\d{1,3})',
);
Duration _duration(String time) {
final parts = time.replaceAll(',', '.').split(':');
final seconds = double.parse(parts.last);
final minutes = int.parse(parts[parts.length - 2]);
final hours = parts.length > 2 ? int.parse(parts.first) : 0;
return Duration(
hours: hours,
minutes: minutes,
milliseconds: (seconds * 1000).round(),
);
}
String _clean(String line) => line
.replaceAll(RegExp('<[^>]*>'), '')
.replaceAll(RegExp(r'\{\\[^}]*\}'), '')
.replaceAll(r'\N', ' ')
.replaceAll('&amp;', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&nbsp;', ' ')
.trim();

View file

@ -34,13 +34,18 @@ class DictionaryAdder(final CardRepository _cards, final TagRepository _tags) {
?card.content.entry,
};
/// Adds [words] to [studysetId], at its end, in their order.
Future<DictionaryAdded> add(String studysetId, List<DictionaryItem> words) =>
_queue.run(() => _add(studysetId, words));
/// Adds [words] to [studysetId], at its end, in their order; each card
/// also tagged [tagNames] (the show a word was met in).
Future<DictionaryAdded> add(
String studysetId,
List<DictionaryItem> words, {
List<String> tagNames = const [],
}) => _queue.run(() => _add(studysetId, words, tagNames));
Future<DictionaryAdded> _add(
String studysetId,
List<DictionaryItem> words,
List<String> extraTagNames,
) async {
final there = await _entriesIn(studysetId);
final fresh = [
@ -62,7 +67,7 @@ class DictionaryAdder(final CardRepository _cards, final TagRepository _tags) {
final tagIdOf = <String, String>{};
final tagsByCard = <String, Set<String>>{};
for (final (index, word) in fresh.indexed) {
for (final name in word.tagNames) {
for (final name in {...word.tagNames, ...extraTagNames}) {
final tagId = tagIdOf[name] ??= await _tags.create(name);
(tagsByCard[ids[index]] ??= {}).add(tagId);
}

View file

@ -10,6 +10,13 @@ abstract interface class DictionaryGateway() {
/// characters, as it holds.
Future<List<DictionaryItem>> search(Dictionary dictionary, String query);
/// The entries of [dictionary] with a key that is one of [keys], by
/// that key, each key's best first; keys without entries left out.
Future<Map<String, List<DictionaryItem>>> withKeys(
Dictionary dictionary,
List<String> keys,
);
/// How many entries of [dictionary] carry [label].
Future<int> countLabelled(Dictionary dictionary, String label);

View file

@ -2,6 +2,7 @@ import 'dart:math' as math;
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/sentence_words.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
/// The instance's dictionaries: which there are, words looked up in one,
@ -88,6 +89,60 @@ class DictionaryRepository(final DictionaryGateway _gateway) {
),
];
/// The most keys one request asks for, when sentences are cut into
/// words: the instance takes up to 4,000.
static const keysAtOnce = 3000;
/// Sentences cut into words already, by dictionary and sentence; the
/// oldest go once there are [_sentencesKept].
final _parts = <(String, String, String), List<SentencePart>>{};
static const _sentencesKept = 5000;
/// [sentences] each cut into the words of [dictionary] (`sentenceParts`),
/// those cut before as they were; the rest asked for in as few requests
/// as [keysAtOnce] allows, one sentence never split over two.
Future<List<List<SentencePart>>> wordsOf(
Dictionary dictionary,
List<String> sentences,
) async {
(String, String, String) keyOf(String sentence) =>
(dictionary.source, dictionary.glossLanguage, sentence);
final asking = <String, Set<String>>{
for (final sentence in sentences.toSet())
if (!_parts.containsKey(keyOf(sentence)))
sentence: sentenceKeys(sentence),
};
final batch = <String>[];
final keys = <String>{};
Future<void> ask() async {
if (batch.isEmpty) {
return;
}
final found = await _gateway.withKeys(dictionary, keys.toList());
for (final sentence in batch) {
_parts[keyOf(sentence)] = sentenceParts(sentence, found);
}
batch.clear();
keys.clear();
}
for (final MapEntry(key: sentence, value: ofSentence) in asking.entries) {
if (keys.length + ofSentence.length > keysAtOnce) {
await ask();
}
batch.add(sentence);
keys.addAll(ofSentence.take(keysAtOnce));
}
await ask();
while (_parts.length > _sentencesKept) {
_parts.remove(_parts.keys.first);
}
return [
for (final sentence in sentences)
_parts[keyOf(sentence)] ?? const <SentencePart>[],
];
}
/// How many entries of [dictionary] carry [label] (`jlpt:n5`).
Future<int> countLabelled(Dictionary dictionary, String label) =>
_gateway.countLabelled(dictionary, label);

Some files were not shown because too many files have changed in this diff Show more