feat(jellyfin): the instance suggests a Jellyfin server, signed in to with a tap
An instance now names a Jellyfin server for its people to learn from (instance.info().jellyfinUrl): its operator's JELLYFIN_URL (`none`: none), else https://jellyfin. on the domain registered for its public host, as learn.halkyon.eu suggests jellyfin.halkyon.eu, once that answers as a Jellyfin server. The registered domain comes from Mozilla's Public Suffix List, kept in the server's data folder, so an instance at example.co.uk never names someone else's jellyfin.co.uk. The instance looks as it starts and again hourly when asked; the app never reaches for a guessed address. In the app, Add material offers From Jellyfin as soon as the instance suggests a server, before anyone signed in to one. The sign-in fills the server in and finds it by itself, and lists the people the server shows on its login screen: one without a password, as a demo's, signs in with a tap; one with a password is named in the form, which asks for it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
93b0c0a6b0
commit
85506ac7eb
32 changed files with 17321 additions and 17 deletions
File diff suppressed because one or more lines are too long
|
|
@ -29,6 +29,12 @@ GITHUB_CLIENT_SECRET=
|
||||||
# Running modified code, set it to where your modified source is published.
|
# Running modified code, set it to where your modified source is published.
|
||||||
SOURCE_URL=
|
SOURCE_URL=
|
||||||
|
|
||||||
|
# The Jellyfin server the app suggests to this instance's people, who watch and listen
|
||||||
|
# to learn from it (Halkyon Learn's Watch screen). Empty: `https://jellyfin.` on the
|
||||||
|
# domain registered for this instance (learn.example.org → jellyfin.example.org), if a
|
||||||
|
# Jellyfin server answers there; `none`: no suggestion.
|
||||||
|
JELLYFIN_URL=
|
||||||
|
|
||||||
# How much room people's images and sounds may take on this instance, in MiB. Empty: the
|
# How much room people's images and sounds may take on this instance, in MiB. Empty: the
|
||||||
# defaults, 100 for an anonymous person (anonymous accounts are meant to become identified
|
# defaults, 100 for an anonymous person (anonymous accounts are meant to become identified
|
||||||
# ones), 500 for one signed in with GitHub, and 15000 for everyone together (0: no limit
|
# ones), 500 for one signed in with GitHub, and 15000 for everyone together (0: no limit
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,9 @@ services:
|
||||||
SERVERPOD_PASSWORD_githubClientSecret: ${GITHUB_CLIENT_SECRET:-}
|
SERVERPOD_PASSWORD_githubClientSecret: ${GITHUB_CLIENT_SECRET:-}
|
||||||
# Where the source of what this instance runs is; empty: the project's.
|
# Where the source of what this instance runs is; empty: the project's.
|
||||||
SOURCE_URL: ${SOURCE_URL:-}
|
SOURCE_URL: ${SOURCE_URL:-}
|
||||||
|
# The Jellyfin server suggested to people; empty: jellyfin. on the
|
||||||
|
# instance's own domain, if one answers there; `none`: none.
|
||||||
|
JELLYFIN_URL: ${JELLYFIN_URL:-}
|
||||||
# How much room people's files may take, in MiB; empty: the defaults.
|
# How much room people's files may take, in MiB; empty: the defaults.
|
||||||
FILE_LIMIT_ANONYMOUS_MB: ${FILE_LIMIT_ANONYMOUS_MB:-}
|
FILE_LIMIT_ANONYMOUS_MB: ${FILE_LIMIT_ANONYMOUS_MB:-}
|
||||||
FILE_LIMIT_IDENTIFIED_MB: ${FILE_LIMIT_IDENTIFIED_MB:-}
|
FILE_LIMIT_IDENTIFIED_MB: ${FILE_LIMIT_IDENTIFIED_MB:-}
|
||||||
|
|
|
||||||
|
|
@ -597,7 +597,8 @@ class EndpointInstance extends _isc.EndpointRef {
|
||||||
|
|
||||||
/// This instance's offer: GitHub sign-in only with the operator's OAuth
|
/// This instance's offer: GitHub sign-in only with the operator's OAuth
|
||||||
/// app, where its source is, the oldest app build it works with, the
|
/// app, where its source is, the oldest app build it works with, the
|
||||||
/// domain its people's handles are on, and where assistants connect.
|
/// domain its people's handles are on, where assistants connect, and the
|
||||||
|
/// Jellyfin server it suggests.
|
||||||
_ida.Future<_i26u8vl4.InstanceInfo> info() =>
|
_ida.Future<_i26u8vl4.InstanceInfo> info() =>
|
||||||
caller.callServerEndpoint<_i26u8vl4.InstanceInfo>(
|
caller.callServerEndpoint<_i26u8vl4.InstanceInfo>(
|
||||||
'instance',
|
'instance',
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ abstract class InstanceInfo
|
||||||
this.minimumAppBuild,
|
this.minimumAppBuild,
|
||||||
this.accountDomain,
|
this.accountDomain,
|
||||||
this.assistantConnectorUrl,
|
this.assistantConnectorUrl,
|
||||||
|
this.jellyfinUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory InstanceInfo({
|
factory InstanceInfo({
|
||||||
|
|
@ -30,6 +31,7 @@ abstract class InstanceInfo
|
||||||
int? minimumAppBuild,
|
int? minimumAppBuild,
|
||||||
String? accountDomain,
|
String? accountDomain,
|
||||||
String? assistantConnectorUrl,
|
String? assistantConnectorUrl,
|
||||||
|
String? jellyfinUrl,
|
||||||
}) = _InstanceInfoImpl;
|
}) = _InstanceInfoImpl;
|
||||||
|
|
||||||
factory InstanceInfo.fromJson(Map<String, dynamic> jsonSerialization) {
|
factory InstanceInfo.fromJson(Map<String, dynamic> jsonSerialization) {
|
||||||
|
|
@ -40,6 +42,7 @@ abstract class InstanceInfo
|
||||||
accountDomain: jsonSerialization['accountDomain'] as String?,
|
accountDomain: jsonSerialization['accountDomain'] as String?,
|
||||||
assistantConnectorUrl:
|
assistantConnectorUrl:
|
||||||
jsonSerialization['assistantConnectorUrl'] as String?,
|
jsonSerialization['assistantConnectorUrl'] as String?,
|
||||||
|
jellyfinUrl: jsonSerialization['jellyfinUrl'] as String?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -66,6 +69,13 @@ abstract class InstanceInfo
|
||||||
/// the instance offers none, and from servers that predate it.
|
/// the instance offers none, and from servers that predate it.
|
||||||
String? assistantConnectorUrl;
|
String? assistantConnectorUrl;
|
||||||
|
|
||||||
|
/// The Jellyfin server this instance suggests to its people, which the
|
||||||
|
/// app offers to sign in to (decided 2026-10-13): the operator's
|
||||||
|
/// JELLYFIN_URL, else jellyfin. on the domain registered for the
|
||||||
|
/// instance, once a Jellyfin server answers there. Null where there is
|
||||||
|
/// none, and from servers that predate it.
|
||||||
|
String? jellyfinUrl;
|
||||||
|
|
||||||
/// Returns a shallow copy of this [InstanceInfo]
|
/// Returns a shallow copy of this [InstanceInfo]
|
||||||
/// with some or all fields replaced by the given arguments.
|
/// with some or all fields replaced by the given arguments.
|
||||||
@_isc.useResult
|
@_isc.useResult
|
||||||
|
|
@ -75,6 +85,7 @@ abstract class InstanceInfo
|
||||||
int? minimumAppBuild,
|
int? minimumAppBuild,
|
||||||
String? accountDomain,
|
String? accountDomain,
|
||||||
String? assistantConnectorUrl,
|
String? assistantConnectorUrl,
|
||||||
|
String? jellyfinUrl,
|
||||||
});
|
});
|
||||||
@override
|
@override
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
|
|
@ -86,6 +97,7 @@ abstract class InstanceInfo
|
||||||
if (accountDomain != null) 'accountDomain': accountDomain,
|
if (accountDomain != null) 'accountDomain': accountDomain,
|
||||||
if (assistantConnectorUrl != null)
|
if (assistantConnectorUrl != null)
|
||||||
'assistantConnectorUrl': assistantConnectorUrl,
|
'assistantConnectorUrl': assistantConnectorUrl,
|
||||||
|
if (jellyfinUrl != null) 'jellyfinUrl': jellyfinUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -99,6 +111,7 @@ abstract class InstanceInfo
|
||||||
if (accountDomain != null) 'accountDomain': accountDomain,
|
if (accountDomain != null) 'accountDomain': accountDomain,
|
||||||
if (assistantConnectorUrl != null)
|
if (assistantConnectorUrl != null)
|
||||||
'assistantConnectorUrl': assistantConnectorUrl,
|
'assistantConnectorUrl': assistantConnectorUrl,
|
||||||
|
if (jellyfinUrl != null) 'jellyfinUrl': jellyfinUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -117,12 +130,14 @@ class _InstanceInfoImpl extends InstanceInfo {
|
||||||
int? minimumAppBuild,
|
int? minimumAppBuild,
|
||||||
String? accountDomain,
|
String? accountDomain,
|
||||||
String? assistantConnectorUrl,
|
String? assistantConnectorUrl,
|
||||||
|
String? jellyfinUrl,
|
||||||
}) : super._(
|
}) : super._(
|
||||||
gitHubClientId: gitHubClientId,
|
gitHubClientId: gitHubClientId,
|
||||||
sourceCodeUrl: sourceCodeUrl,
|
sourceCodeUrl: sourceCodeUrl,
|
||||||
minimumAppBuild: minimumAppBuild,
|
minimumAppBuild: minimumAppBuild,
|
||||||
accountDomain: accountDomain,
|
accountDomain: accountDomain,
|
||||||
assistantConnectorUrl: assistantConnectorUrl,
|
assistantConnectorUrl: assistantConnectorUrl,
|
||||||
|
jellyfinUrl: jellyfinUrl,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Returns a shallow copy of this [InstanceInfo]
|
/// Returns a shallow copy of this [InstanceInfo]
|
||||||
|
|
@ -135,6 +150,7 @@ class _InstanceInfoImpl extends InstanceInfo {
|
||||||
Object? minimumAppBuild = _Undefined,
|
Object? minimumAppBuild = _Undefined,
|
||||||
Object? accountDomain = _Undefined,
|
Object? accountDomain = _Undefined,
|
||||||
Object? assistantConnectorUrl = _Undefined,
|
Object? assistantConnectorUrl = _Undefined,
|
||||||
|
Object? jellyfinUrl = _Undefined,
|
||||||
}) {
|
}) {
|
||||||
return InstanceInfo(
|
return InstanceInfo(
|
||||||
gitHubClientId: gitHubClientId is String?
|
gitHubClientId: gitHubClientId is String?
|
||||||
|
|
@ -150,6 +166,7 @@ class _InstanceInfoImpl extends InstanceInfo {
|
||||||
assistantConnectorUrl: assistantConnectorUrl is String?
|
assistantConnectorUrl: assistantConnectorUrl is String?
|
||||||
? assistantConnectorUrl
|
? assistantConnectorUrl
|
||||||
: this.assistantConnectorUrl,
|
: this.assistantConnectorUrl,
|
||||||
|
jellyfinUrl: jellyfinUrl is String? ? jellyfinUrl : this.jellyfinUrl,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1749,6 +1749,9 @@
|
||||||
"jellyfinAddressHint": "jellyfin.example.org",
|
"jellyfinAddressHint": "jellyfin.example.org",
|
||||||
"jellyfinFind": "Weiter",
|
"jellyfinFind": "Weiter",
|
||||||
"jellyfinUser": "Benutzername",
|
"jellyfinUser": "Benutzername",
|
||||||
|
"jellyfinPublicUsers": "Auf diesem Server",
|
||||||
|
"jellyfinWithoutPassword": "Ohne Passwort: ein Tippen meldet an",
|
||||||
|
"jellyfinWithPassword": "Mit Passwort",
|
||||||
"jellyfinPassword": "Passwort",
|
"jellyfinPassword": "Passwort",
|
||||||
"jellyfinSignIn": "Anmelden",
|
"jellyfinSignIn": "Anmelden",
|
||||||
"jellyfinQuickConnect": "Mit einem Code anmelden",
|
"jellyfinQuickConnect": "Mit einem Code anmelden",
|
||||||
|
|
|
||||||
|
|
@ -2061,6 +2061,9 @@
|
||||||
"jellyfinAddressHint": "jellyfin.example.org",
|
"jellyfinAddressHint": "jellyfin.example.org",
|
||||||
"jellyfinFind": "Continue",
|
"jellyfinFind": "Continue",
|
||||||
"jellyfinUser": "User name",
|
"jellyfinUser": "User name",
|
||||||
|
"jellyfinPublicUsers": "On this server",
|
||||||
|
"jellyfinWithoutPassword": "No password: one tap signs in",
|
||||||
|
"jellyfinWithPassword": "With a password",
|
||||||
"jellyfinPassword": "Password",
|
"jellyfinPassword": "Password",
|
||||||
"jellyfinSignIn": "Sign in",
|
"jellyfinSignIn": "Sign in",
|
||||||
"jellyfinQuickConnect": "Sign in with a code",
|
"jellyfinQuickConnect": "Sign in with a code",
|
||||||
|
|
|
||||||
|
|
@ -10335,6 +10335,24 @@ abstract class AppLocalizations {
|
||||||
/// **'User name'**
|
/// **'User name'**
|
||||||
String get jellyfinUser;
|
String get jellyfinUser;
|
||||||
|
|
||||||
|
/// No description provided for @jellyfinPublicUsers.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'On this server'**
|
||||||
|
String get jellyfinPublicUsers;
|
||||||
|
|
||||||
|
/// No description provided for @jellyfinWithoutPassword.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No password: one tap signs in'**
|
||||||
|
String get jellyfinWithoutPassword;
|
||||||
|
|
||||||
|
/// No description provided for @jellyfinWithPassword.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'With a password'**
|
||||||
|
String get jellyfinWithPassword;
|
||||||
|
|
||||||
/// No description provided for @jellyfinPassword.
|
/// No description provided for @jellyfinPassword.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -6518,6 +6518,15 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get jellyfinUser => 'Benutzername';
|
String get jellyfinUser => 'Benutzername';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get jellyfinPublicUsers => 'Auf diesem Server';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get jellyfinWithoutPassword => 'Ohne Passwort: ein Tippen meldet an';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get jellyfinWithPassword => 'Mit Passwort';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get jellyfinPassword => 'Passwort';
|
String get jellyfinPassword => 'Passwort';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6462,6 +6462,15 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get jellyfinUser => 'User name';
|
String get jellyfinUser => 'User name';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get jellyfinPublicUsers => 'On this server';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get jellyfinWithoutPassword => 'No password: one tap signs in';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get jellyfinWithPassword => 'With a password';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get jellyfinPassword => 'Password';
|
String get jellyfinPassword => 'Password';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ import 'package:halkyon_learn_flutter/src/feature/geo_map/data/geo_map_repositor
|
||||||
import 'package:halkyon_learn_flutter/src/feature/jellyfin/data/jellyfin_gateway.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_repository.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/jellyfin/data/jellyfin_store.dart';
|
import 'package:halkyon_learn_flutter/src/feature/jellyfin/data/jellyfin_store.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/jellyfin/data/jellyfin_suggestion.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/kanji/data/stroke_repository.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_deletions.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
|
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
|
||||||
|
|
@ -170,6 +171,9 @@ class Dependencies({
|
||||||
/// device's, kept across instances.
|
/// device's, kept across instances.
|
||||||
required final JellyfinRepository jellyfin,
|
required final JellyfinRepository jellyfin,
|
||||||
|
|
||||||
|
/// The Jellyfin server the instance suggests, if any.
|
||||||
|
required final JellyfinSuggestion jellyfinSuggestion,
|
||||||
|
|
||||||
/// Plays videos streamed from a server.
|
/// Plays videos streamed from a server.
|
||||||
required final MediaPlayers mediaPlayers,
|
required final MediaPlayers mediaPlayers,
|
||||||
final Future<void> Function()? _onDispose,
|
final Future<void> Function()? _onDispose,
|
||||||
|
|
@ -231,6 +235,9 @@ class Dependencies({
|
||||||
webSearch: WebSearch(),
|
webSearch: WebSearch(),
|
||||||
assistant: AssistantRepository(ServerpodAssistantGateway(client)),
|
assistant: AssistantRepository(ServerpodAssistantGateway(client)),
|
||||||
jellyfin: device.jellyfin,
|
jellyfin: device.jellyfin,
|
||||||
|
jellyfinSuggestion: JellyfinSuggestion(
|
||||||
|
() async => (await client.instance.info()).jellyfinUrl,
|
||||||
|
),
|
||||||
mediaPlayers: PlatformMediaPlayers(),
|
mediaPlayers: PlatformMediaPlayers(),
|
||||||
openPerson: (person) async {
|
openPerson: (person) async {
|
||||||
await previousClosed.timeout(
|
await previousClosed.timeout(
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,9 @@ abstract interface class JellyfinGateway() {
|
||||||
/// Whether [server] lets devices sign in with a code (Quick Connect).
|
/// Whether [server] lets devices sign in with a code (Quick Connect).
|
||||||
Future<bool> quickConnectEnabled(JellyfinServer server);
|
Future<bool> quickConnectEnabled(JellyfinServer server);
|
||||||
|
|
||||||
|
/// The people [server] shows on its login screen.
|
||||||
|
Future<List<JellyfinPublicUser>> publicUsers(JellyfinServer server);
|
||||||
|
|
||||||
/// Signs in to [server] as [user] with [password].
|
/// Signs in to [server] as [user] with [password].
|
||||||
Future<JellyfinAccount> signIn(
|
Future<JellyfinAccount> signIn(
|
||||||
JellyfinServer server,
|
JellyfinServer server,
|
||||||
|
|
@ -229,6 +232,23 @@ class HttpJellyfinGateway(
|
||||||
Future<bool> quickConnectEnabled(JellyfinServer server) async =>
|
Future<bool> quickConnectEnabled(JellyfinServer server) async =>
|
||||||
await _send('GET', _at(server.address, '/QuickConnect/Enabled')) == true;
|
await _send('GET', _at(server.address, '/QuickConnect/Enabled')) == true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<JellyfinPublicUser>> publicUsers(JellyfinServer server) async => [
|
||||||
|
for (final user in _list(
|
||||||
|
await _send('GET', _at(server.address, '/Users/Public')),
|
||||||
|
))
|
||||||
|
if (user case final Map<String, Object?> json)
|
||||||
|
if ((_string(json, 'Id'), _string(json, 'Name')) case (
|
||||||
|
final id?,
|
||||||
|
final name?,
|
||||||
|
))
|
||||||
|
JellyfinPublicUser(
|
||||||
|
id: id,
|
||||||
|
name: name,
|
||||||
|
hasPassword: json[_key(json, 'HasPassword')] != false,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<JellyfinAccount> signIn(
|
Future<JellyfinAccount> signIn(
|
||||||
JellyfinServer server,
|
JellyfinServer server,
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,16 @@ class JellyfinRepository(
|
||||||
await _keep(_state.value!.withAccount(account));
|
await _keep(_state.value!.withAccount(account));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The people [server] shows on its login screen; none where it can't
|
||||||
|
/// say.
|
||||||
|
Future<List<JellyfinPublicUser>> publicUsers(JellyfinServer server) async {
|
||||||
|
try {
|
||||||
|
return await (await _ready()).publicUsers(server);
|
||||||
|
} on JellyfinException {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether [server] lets devices sign in with a code.
|
/// Whether [server] lets devices sign in with a code.
|
||||||
Future<bool> quickConnectEnabled(JellyfinServer server) async =>
|
Future<bool> quickConnectEnabled(JellyfinServer server) async =>
|
||||||
await (await _ready()).quickConnectEnabled(server);
|
await (await _ready()).quickConnectEnabled(server);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
import 'dart:developer';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
/// The Jellyfin server the instance suggests (decided 2026-10-13): its
|
||||||
|
/// operator's, or `jellyfin.` on its own domain, which the instance has
|
||||||
|
/// found to answer as one. The app never looks for a server itself; it asks
|
||||||
|
/// the instance once, as the suggestion is first needed, and again only
|
||||||
|
/// after asking failed. Null while unknown, or where there is none.
|
||||||
|
class JellyfinSuggestion(final Future<String?> Function() _ask) {
|
||||||
|
final _server = ValueNotifier<Uri?>(null);
|
||||||
|
Future<void>? _asking;
|
||||||
|
|
||||||
|
/// The server suggested, once known.
|
||||||
|
ValueListenable<Uri?> get server => _server;
|
||||||
|
|
||||||
|
/// Asks the instance, unless asked already.
|
||||||
|
Future<void> look() => _asking ??= () async {
|
||||||
|
try {
|
||||||
|
_server.value = switch (await _ask()) {
|
||||||
|
final url? => Uri.tryParse(url),
|
||||||
|
null => null,
|
||||||
|
};
|
||||||
|
} on Object catch (error) {
|
||||||
|
log(
|
||||||
|
'Asking for the suggested server failed',
|
||||||
|
name: 'Jellyfin',
|
||||||
|
error: error,
|
||||||
|
);
|
||||||
|
_asking = null;
|
||||||
|
}
|
||||||
|
}();
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,17 @@ final class const JellyfinServer({
|
||||||
List<Object?> get props => [address, id, name, version];
|
List<Object?> get props => [address, id, name, version];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Someone a Jellyfin server shows on its login screen, to sign in as
|
||||||
|
/// with a tap: a demo's account has no password (decided 2026-10-13).
|
||||||
|
final class const JellyfinPublicUser({
|
||||||
|
required final String id,
|
||||||
|
required final String name,
|
||||||
|
required final bool hasPassword,
|
||||||
|
}) with Equatable {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [id, name, hasPassword];
|
||||||
|
}
|
||||||
|
|
||||||
/// Signed in to a Jellyfin server: as whom, and the token every request
|
/// Signed in to a Jellyfin server: as whom, and the token every request
|
||||||
/// carries, which the device keeps to itself.
|
/// carries, which the device keeps to itself.
|
||||||
final class const JellyfinAccount({
|
final class const JellyfinAccount({
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,11 @@ String jellyfinProblemText(AppLocalizations l10n, JellyfinProblem problem) =>
|
||||||
/// their Jellyfin apps (Quick Connect), where the server allows it. Signed
|
/// their Jellyfin apps (Quick Connect), where the server allows it. Signed
|
||||||
/// in, the repository's state says so, and whatever shows this form shows
|
/// in, the repository's state says so, and whatever shows this form shows
|
||||||
/// the account instead.
|
/// the account instead.
|
||||||
|
///
|
||||||
|
/// A server the instance suggests is filled in and found at once, and the
|
||||||
|
/// people a server shows on its login screen are listed (decided
|
||||||
|
/// 2026-10-13): one without a password, as a demo's, signs in with a tap,
|
||||||
|
/// one with a password is named in the form.
|
||||||
class const JellyfinConnectForm({super.key}) extends StatefulWidget {
|
class const JellyfinConnectForm({super.key}) extends StatefulWidget {
|
||||||
@override
|
@override
|
||||||
State<JellyfinConnectForm> createState() => _JellyfinConnectFormState();
|
State<JellyfinConnectForm> createState() => _JellyfinConnectFormState();
|
||||||
|
|
@ -34,19 +39,40 @@ class _JellyfinConnectFormState() extends State<JellyfinConnectForm> {
|
||||||
final _address = TextEditingController();
|
final _address = TextEditingController();
|
||||||
final _user = TextEditingController();
|
final _user = TextEditingController();
|
||||||
final _password = TextEditingController();
|
final _password = TextEditingController();
|
||||||
|
final _passwordFocus = FocusNode();
|
||||||
JellyfinServer? _server;
|
JellyfinServer? _server;
|
||||||
|
List<JellyfinPublicUser> _publicUsers = const [];
|
||||||
bool _quickConnectEnabled = false;
|
bool _quickConnectEnabled = false;
|
||||||
({String code, String secret})? _quickConnect;
|
({String code, String secret})? _quickConnect;
|
||||||
Timer? _polling;
|
Timer? _polling;
|
||||||
var _busy = false;
|
var _busy = false;
|
||||||
JellyfinProblem? _problem;
|
JellyfinProblem? _problem;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final suggestion = DependenciesScope.of(context).jellyfinSuggestion;
|
||||||
|
unawaited(
|
||||||
|
suggestion.look().then((_) {
|
||||||
|
final suggested = suggestion.server.value;
|
||||||
|
if (mounted &&
|
||||||
|
suggested != null &&
|
||||||
|
_server == null &&
|
||||||
|
_address.text.isEmpty) {
|
||||||
|
_address.text = '$suggested';
|
||||||
|
unawaited(_find());
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_polling?.cancel();
|
_polling?.cancel();
|
||||||
_address.dispose();
|
_address.dispose();
|
||||||
_user.dispose();
|
_user.dispose();
|
||||||
_password.dispose();
|
_password.dispose();
|
||||||
|
_passwordFocus.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -72,10 +98,12 @@ class _JellyfinConnectFormState() extends State<JellyfinConnectForm> {
|
||||||
final jellyfin = DependenciesScope.of(context).jellyfin;
|
final jellyfin = DependenciesScope.of(context).jellyfin;
|
||||||
final server = await jellyfin.find(_address.text);
|
final server = await jellyfin.find(_address.text);
|
||||||
final quickConnect = await jellyfin.quickConnectEnabled(server);
|
final quickConnect = await jellyfin.quickConnectEnabled(server);
|
||||||
|
final publicUsers = await jellyfin.publicUsers(server);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_server = server;
|
_server = server;
|
||||||
_quickConnectEnabled = quickConnect;
|
_quickConnectEnabled = quickConnect;
|
||||||
|
_publicUsers = publicUsers;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -86,6 +114,18 @@ class _JellyfinConnectFormState() extends State<JellyfinConnectForm> {
|
||||||
).jellyfin.signIn(_server!, _user.text.trim(), _password.text),
|
).jellyfin.signIn(_server!, _user.text.trim(), _password.text),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// One of the people the server shows: signed in at once without a
|
||||||
|
/// password, else named in the form, which asks for the password.
|
||||||
|
void _choose(JellyfinPublicUser user) {
|
||||||
|
_user.text = user.name;
|
||||||
|
_password.clear();
|
||||||
|
if (user.hasPassword) {
|
||||||
|
_passwordFocus.requestFocus();
|
||||||
|
} else {
|
||||||
|
unawaited(_signIn());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _startQuickConnect() => _run(() async {
|
Future<void> _startQuickConnect() => _run(() async {
|
||||||
final jellyfin = DependenciesScope.of(context).jellyfin;
|
final jellyfin = DependenciesScope.of(context).jellyfin;
|
||||||
final server = _server!;
|
final server = _server!;
|
||||||
|
|
@ -116,6 +156,7 @@ class _JellyfinConnectFormState() extends State<JellyfinConnectForm> {
|
||||||
_polling?.cancel();
|
_polling?.cancel();
|
||||||
setState(() {
|
setState(() {
|
||||||
_server = null;
|
_server = null;
|
||||||
|
_publicUsers = const [];
|
||||||
_quickConnect = null;
|
_quickConnect = null;
|
||||||
_problem = null;
|
_problem = null;
|
||||||
});
|
});
|
||||||
|
|
@ -212,6 +253,32 @@ class _JellyfinConnectFormState() extends State<JellyfinConnectForm> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
] else ...[
|
] else ...[
|
||||||
|
if (_publicUsers.isNotEmpty) ...[
|
||||||
|
Text(
|
||||||
|
l10n.jellyfinPublicUsers,
|
||||||
|
style: theme.textTheme.titleSmall,
|
||||||
|
),
|
||||||
|
for (final user in _publicUsers)
|
||||||
|
ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: CircleAvatar(
|
||||||
|
child: Text(user.name.characters.first.toUpperCase()),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
user.name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
user.hasPassword
|
||||||
|
? l10n.jellyfinWithPassword
|
||||||
|
: l10n.jellyfinWithoutPassword,
|
||||||
|
),
|
||||||
|
enabled: !_busy,
|
||||||
|
onTap: () => _choose(user),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
TextField(
|
TextField(
|
||||||
controller: _user,
|
controller: _user,
|
||||||
maxLength: TextLimits.name,
|
maxLength: TextLimits.name,
|
||||||
|
|
@ -227,6 +294,7 @@ class _JellyfinConnectFormState() extends State<JellyfinConnectForm> {
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
TextField(
|
TextField(
|
||||||
controller: _password,
|
controller: _password,
|
||||||
|
focusNode: _passwordFocus,
|
||||||
maxLength: TextLimits.phrase,
|
maxLength: TextLimits.phrase,
|
||||||
buildCounter: nearLimitCounter,
|
buildCounter: nearLimitCounter,
|
||||||
obscureText: true,
|
obscureText: true,
|
||||||
|
|
|
||||||
|
|
@ -1275,8 +1275,8 @@ class const _StudyPill({
|
||||||
/// learning path, flashcards, a note, a PDF, words from a video or song of
|
/// learning path, flashcards, a note, a PDF, words from a video or song of
|
||||||
/// the person's Jellyfin server once this device is signed in to one (in
|
/// the person's Jellyfin server once this device is signed in to one (in
|
||||||
/// Settings, decided 2026-10-13: as the assistant shows only once switched
|
/// Settings, decided 2026-10-13: as the assistant shows only once switched
|
||||||
/// on), and words from the dictionary of its language where the instance
|
/// on) or the instance suggests one, and words from the dictionary of its
|
||||||
/// has one. What is added shows in its own tab ([ContentTabCubit]): what is
|
/// language where the instance has one. What is added shows in its own tab ([ContentTabCubit]): what is
|
||||||
/// written there once the person comes back, what is uploaded once it is
|
/// written there once the person comes back, what is uploaded once it is
|
||||||
/// in. Imports are under the options.
|
/// in. Imports are under the options.
|
||||||
class const _AddMaterial({
|
class const _AddMaterial({
|
||||||
|
|
@ -1293,18 +1293,31 @@ class _AddMaterialState() extends State<_AddMaterial> {
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
final dependencies = DependenciesScope.of(context);
|
||||||
// What the device keeps of Jellyfin, read once; no server is asked.
|
// What the device keeps of Jellyfin, read once; no server is asked.
|
||||||
unawaited(DependenciesScope.of(context).jellyfin.open());
|
unawaited(dependencies.jellyfin.open());
|
||||||
|
// Whether the instance suggests a server (it asks the instance alone).
|
||||||
|
unawaited(dependencies.jellyfinSuggestion.look());
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => ValueListenableBuilder(
|
Widget build(BuildContext context) {
|
||||||
valueListenable: DependenciesScope.of(context).jellyfin.state,
|
final dependencies = DependenciesScope.of(context);
|
||||||
builder: (context, jellyfin, _) => StudysetDictionary(
|
return ValueListenableBuilder(
|
||||||
builder: (context, dictionary) =>
|
valueListenable: dependencies.jellyfin.state,
|
||||||
_menu(context, dictionary, jellyfin: jellyfin?.account != null),
|
builder: (context, jellyfin, _) => ValueListenableBuilder(
|
||||||
),
|
valueListenable: dependencies.jellyfinSuggestion.server,
|
||||||
);
|
builder: (context, suggested, _) => StudysetDictionary(
|
||||||
|
builder: (context, dictionary) => _menu(
|
||||||
|
context,
|
||||||
|
dictionary,
|
||||||
|
// Signed in, or a server the instance suggests to sign in to.
|
||||||
|
jellyfin: jellyfin?.account != null || suggested != null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _menu(
|
Widget _menu(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.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/router/route_paths.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/jellyfin/data/jellyfin_suggestion.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/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/data/studyset_payload.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||||
|
|
@ -21,7 +23,11 @@ void main() {
|
||||||
store = MemoryJellyfinStore();
|
store = MemoryJellyfinStore();
|
||||||
});
|
});
|
||||||
|
|
||||||
Future<void> show(WidgetTester tester, String location) async {
|
Future<void> show(
|
||||||
|
WidgetTester tester,
|
||||||
|
String location, {
|
||||||
|
String? suggested,
|
||||||
|
}) async {
|
||||||
tester.view.physicalSize = const Size(1200, 1400);
|
tester.view.physicalSize = const Size(1200, 1400);
|
||||||
tester.view.devicePixelRatio = 1;
|
tester.view.devicePixelRatio = 1;
|
||||||
addTearDown(tester.view.reset);
|
addTearDown(tester.view.reset);
|
||||||
|
|
@ -44,6 +50,7 @@ void main() {
|
||||||
dictionaryGateway: sampleDictionary(),
|
dictionaryGateway: sampleDictionary(),
|
||||||
jellyfinGateway: jellyfin,
|
jellyfinGateway: jellyfin,
|
||||||
jellyfinStore: store,
|
jellyfinStore: store,
|
||||||
|
jellyfinSuggestion: JellyfinSuggestion(() async => suggested),
|
||||||
),
|
),
|
||||||
location: location,
|
location: location,
|
||||||
);
|
);
|
||||||
|
|
@ -228,4 +235,57 @@ void main() {
|
||||||
|
|
||||||
expect(find.text('Libraries'), findsOneWidget);
|
expect(find.text('Libraries'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('a Jellyfin server the instance suggests (decided 2026-10-13)', () {
|
||||||
|
setUp(() {
|
||||||
|
jellyfin.shownUsers = const [
|
||||||
|
JellyfinPublicUser(id: 'user-2', name: 'Demo', hasPassword: false),
|
||||||
|
JellyfinPublicUser(id: 'user-1', name: 'mika', hasPassword: true),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Add material offers it before signing in; the sign-in '
|
||||||
|
"finds it by itself and lists the server's public people, one "
|
||||||
|
'without a password signed in with a tap', (tester) async {
|
||||||
|
await show(
|
||||||
|
tester,
|
||||||
|
RoutePaths.studyset('japanese'),
|
||||||
|
suggested: 'https://jellyfin.local:8920',
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('Add material'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('From Jellyfin'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Living room'), findsOneWidget);
|
||||||
|
expect(find.text('Demo'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('Demo'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Libraries'), findsOneWidget);
|
||||||
|
expect(store.kept?.account?.userName, 'Demo');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('one with a password is named in the form, which asks for '
|
||||||
|
'it', (tester) async {
|
||||||
|
await show(
|
||||||
|
tester,
|
||||||
|
RoutePaths.jellyfin('japanese'),
|
||||||
|
suggested: 'https://jellyfin.local:8920',
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.text('mika'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<TextField>(find.widgetWithText(TextField, 'User name'))
|
||||||
|
.controller!
|
||||||
|
.text,
|
||||||
|
'mika',
|
||||||
|
);
|
||||||
|
expect(store.kept?.account, isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,12 @@ MockClient jellyfin12(List<http.Request> requests) => MockClient((
|
||||||
'id': 'e39b17f2',
|
'id': 'e39b17f2',
|
||||||
'startupWizardCompleted': true,
|
'startupWizardCompleted': true,
|
||||||
}),
|
}),
|
||||||
|
// The people the server shows on its login screen, one without a
|
||||||
|
// password (a demo's), in either case of field names.
|
||||||
|
('GET', '/Users/Public') => json([
|
||||||
|
{'Name': 'Demo', 'Id': 'u-2', 'HasPassword': false},
|
||||||
|
{'name': 'mika', 'id': 'u-1', 'hasPassword': true},
|
||||||
|
]),
|
||||||
('POST', '/Users/AuthenticateByName') =>
|
('POST', '/Users/AuthenticateByName') =>
|
||||||
(jsonDecode(request.body) as Map<String, Object?>)['Pw'] == 'secret'
|
(jsonDecode(request.body) as Map<String, Object?>)['Pw'] == 'secret'
|
||||||
? json({
|
? json({
|
||||||
|
|
@ -230,6 +236,18 @@ void main() {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test('lists the people a server shows on its login screen, and which '
|
||||||
|
'need a password', () async {
|
||||||
|
final server = await gateway.server(
|
||||||
|
Uri.parse('http://jellyfin.local:8096'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await gateway.publicUsers(server), const [
|
||||||
|
JellyfinPublicUser(id: 'u-2', name: 'Demo', hasPassword: false),
|
||||||
|
JellyfinPublicUser(id: 'u-1', name: 'mika', hasPassword: true),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test('lists libraries of videos and of music, leaving out what is '
|
test('lists libraries of videos and of music, leaving out what is '
|
||||||
'neither', () async {
|
'neither', () async {
|
||||||
final account = await signIn();
|
final account = await signIn();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/jellyfin/data/jellyfin_suggestion.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('asks the instance once, as it is first needed', () async {
|
||||||
|
var asked = 0;
|
||||||
|
final suggestion = JellyfinSuggestion(() async {
|
||||||
|
asked++;
|
||||||
|
return 'https://jellyfin.halkyon.eu/';
|
||||||
|
});
|
||||||
|
expect(suggestion.server.value, isNull);
|
||||||
|
|
||||||
|
await suggestion.look();
|
||||||
|
await suggestion.look();
|
||||||
|
|
||||||
|
expect(asked, 1);
|
||||||
|
expect(suggestion.server.value, Uri.parse('https://jellyfin.halkyon.eu/'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('asks again only after asking failed; none named is none', () async {
|
||||||
|
var asked = 0;
|
||||||
|
final suggestion = JellyfinSuggestion(() async {
|
||||||
|
asked++;
|
||||||
|
if (asked == 1) {
|
||||||
|
throw Exception('offline');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
await suggestion.look();
|
||||||
|
expect(suggestion.server.value, isNull);
|
||||||
|
await suggestion.look();
|
||||||
|
await suggestion.look();
|
||||||
|
|
||||||
|
expect(asked, 2);
|
||||||
|
expect(suggestion.server.value, isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue