feat(train): signing in to Jellyfin and marking playlists (ADR 0030, D47)

Music's decisions as D47, and its first part: the music screen from the
account sheet, on Android, signs in to the person's Jellyfin with Quick
Connect, else a password, and lists their playlists of music to mark
for running. The token is in the keyring, the server and the marks in
the person's music.json, nothing on Train's server.

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-29 12:36:32 +01:00
commit 3336fe4eca
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
19 changed files with 2364 additions and 3 deletions

File diff suppressed because one or more lines are too long

View file

@ -468,5 +468,31 @@
"recordingMapHint": "Wischen für die Karte", "recordingMapHint": "Wischen für die Karte",
"recordingFiguresHint": "Zurückwischen für die Werte", "recordingFiguresHint": "Zurückwischen für die Werte",
"recordingNorthUp": "Norden oben", "recordingNorthUp": "Norden oben",
"recordingHeadingUp": "Bewegungsrichtung oben" "recordingHeadingUp": "Bewegungsrichtung oben",
"musicTitle": "Musik",
"musicHint": "Jellyfin-Playlists zum Laufen",
"musicIntro": "Train spielt Musik von deinem Jellyfin-Server und wählt Titel, deren Takt zu deinen Schritten passt. Dein Konto und deine Musik bleiben auf diesem Telefon.",
"musicServer": "Jellyfin-Server",
"musicConnect": "Verbinden",
"musicBadAddress": "Gib die Adresse des Servers ein, etwa jellyfin.example.org.",
"musicUnreachable": "Der Server ist nicht erreichbar. Prüfe die Adresse und die Verbindung.",
"musicNotJellyfin": "Unter dieser Adresse ist kein Jellyfin-Server.",
"musicRefused": "Jellyfin hat abgelehnt. Prüfe Benutzername und Passwort.",
"musicCodeExpired": "Der Code ist abgelaufen.",
"musicFailed": "Jellyfin hat mit einem Fehler geantwortet. Versuch es später noch einmal.",
"musicQuickConnect": "Um dich bei {server} anzumelden, gib diesen Code in Jellyfin auf einem anderen Gerät ein, unter Einstellungen, Quick Connect.",
"musicNewCode": "Neuer Code",
"musicBack": "Zurück",
"musicUsePassword": "Mit Passwort",
"musicPasswordIntro": "Melde dich mit deinem Jellyfin-Benutzernamen und Passwort bei {server} an.",
"musicUserName": "Benutzername",
"musicPassword": "Passwort",
"musicSignIn": "Anmelden",
"musicSignedInAs": "Angemeldet als {name}",
"musicSignOut": "Abmelden",
"musicPlaylists": "Playlists zum Laufen",
"musicPlaylistsHint": "Train wählt aus den Titeln der Playlists, die du markierst, und behält sie auf diesem Telefon.",
"musicNoPlaylists": "Auf diesem Server gibt es noch keine Musik-Playlists.",
"musicRetry": "Erneut versuchen",
"musicTracks": "{count, plural, =1{1 Titel} other{{count} Titel}}"
} }

View file

@ -588,5 +588,38 @@
"recordingMapHint": "Swipe for the map", "recordingMapHint": "Swipe for the map",
"recordingFiguresHint": "Swipe back for the figures", "recordingFiguresHint": "Swipe back for the figures",
"recordingNorthUp": "North up", "recordingNorthUp": "North up",
"recordingHeadingUp": "Heading up" "recordingHeadingUp": "Heading up",
"musicTitle": "Music",
"@musicTitle": {"description": "The music screen's title, and its tile in the account sheet."},
"musicHint": "Jellyfin playlists to run to",
"@musicHint": {"description": "Under Music in the account sheet."},
"musicIntro": "Train plays music from your Jellyfin server, choosing tracks whose beat fits your steps. Your account and music stay on this phone.",
"musicServer": "Jellyfin server",
"@musicServer": {"description": "The label of the server's address."},
"musicConnect": "Connect",
"musicBadAddress": "Enter the server's address, such as jellyfin.example.org.",
"musicUnreachable": "The server can't be reached. Check the address and the connection.",
"musicNotJellyfin": "There is no Jellyfin server at this address.",
"musicRefused": "Jellyfin refused. Check the user name and password.",
"musicCodeExpired": "The code has expired.",
"musicFailed": "Jellyfin answered with an error. Try again later.",
"musicQuickConnect": "To sign in to {server}, enter this code in Jellyfin on another device, under Settings, Quick Connect.",
"@musicQuickConnect": {"placeholders": {"server": {"type": "String"}}},
"musicNewCode": "New code",
"musicBack": "Back",
"musicUsePassword": "Use a password",
"musicPasswordIntro": "Sign in to {server} with your Jellyfin user name and password.",
"@musicPasswordIntro": {"placeholders": {"server": {"type": "String"}}},
"musicUserName": "User name",
"musicPassword": "Password",
"musicSignIn": "Sign in",
"musicSignedInAs": "Signed in as {name}",
"@musicSignedInAs": {"placeholders": {"name": {"type": "String"}}},
"musicSignOut": "Sign out",
"musicPlaylists": "Playlists for running",
"musicPlaylistsHint": "Train chooses from the tracks of the playlists you mark, and keeps them on this phone.",
"musicNoPlaylists": "There are no music playlists on this server yet.",
"musicRetry": "Try again",
"musicTracks": "{count, plural, =1{1 track} other{{count} tracks}}",
"@musicTracks": {"placeholders": {"count": {"type": "int"}}}
} }

View file

@ -2835,6 +2835,162 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Heading up'** /// **'Heading up'**
String get recordingHeadingUp; String get recordingHeadingUp;
/// The music screen's title, and its tile in the account sheet.
///
/// In en, this message translates to:
/// **'Music'**
String get musicTitle;
/// Under Music in the account sheet.
///
/// In en, this message translates to:
/// **'Jellyfin playlists to run to'**
String get musicHint;
/// No description provided for @musicIntro.
///
/// In en, this message translates to:
/// **'Train plays music from your Jellyfin server, choosing tracks whose beat fits your steps. Your account and music stay on this phone.'**
String get musicIntro;
/// The label of the server's address.
///
/// In en, this message translates to:
/// **'Jellyfin server'**
String get musicServer;
/// No description provided for @musicConnect.
///
/// In en, this message translates to:
/// **'Connect'**
String get musicConnect;
/// No description provided for @musicBadAddress.
///
/// In en, this message translates to:
/// **'Enter the server\'s address, such as jellyfin.example.org.'**
String get musicBadAddress;
/// No description provided for @musicUnreachable.
///
/// In en, this message translates to:
/// **'The server can\'t be reached. Check the address and the connection.'**
String get musicUnreachable;
/// No description provided for @musicNotJellyfin.
///
/// In en, this message translates to:
/// **'There is no Jellyfin server at this address.'**
String get musicNotJellyfin;
/// No description provided for @musicRefused.
///
/// In en, this message translates to:
/// **'Jellyfin refused. Check the user name and password.'**
String get musicRefused;
/// No description provided for @musicCodeExpired.
///
/// In en, this message translates to:
/// **'The code has expired.'**
String get musicCodeExpired;
/// No description provided for @musicFailed.
///
/// In en, this message translates to:
/// **'Jellyfin answered with an error. Try again later.'**
String get musicFailed;
/// No description provided for @musicQuickConnect.
///
/// In en, this message translates to:
/// **'To sign in to {server}, enter this code in Jellyfin on another device, under Settings, Quick Connect.'**
String musicQuickConnect(String server);
/// No description provided for @musicNewCode.
///
/// In en, this message translates to:
/// **'New code'**
String get musicNewCode;
/// No description provided for @musicBack.
///
/// In en, this message translates to:
/// **'Back'**
String get musicBack;
/// No description provided for @musicUsePassword.
///
/// In en, this message translates to:
/// **'Use a password'**
String get musicUsePassword;
/// No description provided for @musicPasswordIntro.
///
/// In en, this message translates to:
/// **'Sign in to {server} with your Jellyfin user name and password.'**
String musicPasswordIntro(String server);
/// No description provided for @musicUserName.
///
/// In en, this message translates to:
/// **'User name'**
String get musicUserName;
/// No description provided for @musicPassword.
///
/// In en, this message translates to:
/// **'Password'**
String get musicPassword;
/// No description provided for @musicSignIn.
///
/// In en, this message translates to:
/// **'Sign in'**
String get musicSignIn;
/// No description provided for @musicSignedInAs.
///
/// In en, this message translates to:
/// **'Signed in as {name}'**
String musicSignedInAs(String name);
/// No description provided for @musicSignOut.
///
/// In en, this message translates to:
/// **'Sign out'**
String get musicSignOut;
/// No description provided for @musicPlaylists.
///
/// In en, this message translates to:
/// **'Playlists for running'**
String get musicPlaylists;
/// No description provided for @musicPlaylistsHint.
///
/// In en, this message translates to:
/// **'Train chooses from the tracks of the playlists you mark, and keeps them on this phone.'**
String get musicPlaylistsHint;
/// No description provided for @musicNoPlaylists.
///
/// In en, this message translates to:
/// **'There are no music playlists on this server yet.'**
String get musicNoPlaylists;
/// No description provided for @musicRetry.
///
/// In en, this message translates to:
/// **'Try again'**
String get musicRetry;
/// No description provided for @musicTracks.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 track} other{{count} tracks}}'**
String musicTracks(int count);
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate

View file

@ -1681,4 +1681,104 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get recordingHeadingUp => 'Bewegungsrichtung oben'; String get recordingHeadingUp => 'Bewegungsrichtung oben';
@override
String get musicTitle => 'Musik';
@override
String get musicHint => 'Jellyfin-Playlists zum Laufen';
@override
String get musicIntro =>
'Train spielt Musik von deinem Jellyfin-Server und wählt Titel, deren Takt zu deinen Schritten passt. Dein Konto und deine Musik bleiben auf diesem Telefon.';
@override
String get musicServer => 'Jellyfin-Server';
@override
String get musicConnect => 'Verbinden';
@override
String get musicBadAddress =>
'Gib die Adresse des Servers ein, etwa jellyfin.example.org.';
@override
String get musicUnreachable =>
'Der Server ist nicht erreichbar. Prüfe die Adresse und die Verbindung.';
@override
String get musicNotJellyfin =>
'Unter dieser Adresse ist kein Jellyfin-Server.';
@override
String get musicRefused =>
'Jellyfin hat abgelehnt. Prüfe Benutzername und Passwort.';
@override
String get musicCodeExpired => 'Der Code ist abgelaufen.';
@override
String get musicFailed =>
'Jellyfin hat mit einem Fehler geantwortet. Versuch es später noch einmal.';
@override
String musicQuickConnect(String server) {
return 'Um dich bei $server anzumelden, gib diesen Code in Jellyfin auf einem anderen Gerät ein, unter Einstellungen, Quick Connect.';
}
@override
String get musicNewCode => 'Neuer Code';
@override
String get musicBack => 'Zurück';
@override
String get musicUsePassword => 'Mit Passwort';
@override
String musicPasswordIntro(String server) {
return 'Melde dich mit deinem Jellyfin-Benutzernamen und Passwort bei $server an.';
}
@override
String get musicUserName => 'Benutzername';
@override
String get musicPassword => 'Passwort';
@override
String get musicSignIn => 'Anmelden';
@override
String musicSignedInAs(String name) {
return 'Angemeldet als $name';
}
@override
String get musicSignOut => 'Abmelden';
@override
String get musicPlaylists => 'Playlists zum Laufen';
@override
String get musicPlaylistsHint =>
'Train wählt aus den Titeln der Playlists, die du markierst, und behält sie auf diesem Telefon.';
@override
String get musicNoPlaylists =>
'Auf diesem Server gibt es noch keine Musik-Playlists.';
@override
String get musicRetry => 'Erneut versuchen';
@override
String musicTracks(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Titel',
one: '1 Titel',
);
return '$_temp0';
}
} }

View file

@ -1671,6 +1671,104 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get recordingHeadingUp => 'Heading up'; String get recordingHeadingUp => 'Heading up';
@override
String get musicTitle => 'Music';
@override
String get musicHint => 'Jellyfin playlists to run to';
@override
String get musicIntro =>
'Train plays music from your Jellyfin server, choosing tracks whose beat fits your steps. Your account and music stay on this phone.';
@override
String get musicServer => 'Jellyfin server';
@override
String get musicConnect => 'Connect';
@override
String get musicBadAddress =>
'Enter the server\'s address, such as jellyfin.example.org.';
@override
String get musicUnreachable =>
'The server can\'t be reached. Check the address and the connection.';
@override
String get musicNotJellyfin => 'There is no Jellyfin server at this address.';
@override
String get musicRefused =>
'Jellyfin refused. Check the user name and password.';
@override
String get musicCodeExpired => 'The code has expired.';
@override
String get musicFailed => 'Jellyfin answered with an error. Try again later.';
@override
String musicQuickConnect(String server) {
return 'To sign in to $server, enter this code in Jellyfin on another device, under Settings, Quick Connect.';
}
@override
String get musicNewCode => 'New code';
@override
String get musicBack => 'Back';
@override
String get musicUsePassword => 'Use a password';
@override
String musicPasswordIntro(String server) {
return 'Sign in to $server with your Jellyfin user name and password.';
}
@override
String get musicUserName => 'User name';
@override
String get musicPassword => 'Password';
@override
String get musicSignIn => 'Sign in';
@override
String musicSignedInAs(String name) {
return 'Signed in as $name';
}
@override
String get musicSignOut => 'Sign out';
@override
String get musicPlaylists => 'Playlists for running';
@override
String get musicPlaylistsHint =>
'Train chooses from the tracks of the playlists you mark, and keeps them on this phone.';
@override
String get musicNoPlaylists =>
'There are no music playlists on this server yet.';
@override
String get musicRetry => 'Try again';
@override
String musicTracks(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
);
return '$_temp0';
}
} }
/// The translations for English, as used in the United Kingdom (`en_GB`). /// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -4,6 +4,7 @@ import 'package:halkyon_train_flutter/src/app/train_dependencies.dart';
import 'package:halkyon_train_flutter/src/app/train_frame.dart'; import 'package:halkyon_train_flutter/src/app/train_frame.dart';
import 'package:halkyon_train_flutter/src/feature/health/widget/health_screen.dart'; import 'package:halkyon_train_flutter/src/feature/health/widget/health_screen.dart';
import 'package:halkyon_train_flutter/src/feature/loop/widget/loop_screen.dart'; import 'package:halkyon_train_flutter/src/feature/loop/widget/loop_screen.dart';
import 'package:halkyon_train_flutter/src/feature/music/widget/music_screen.dart';
import 'package:halkyon_train_flutter/src/feature/profile/widget/profile_screen.dart'; import 'package:halkyon_train_flutter/src/feature/profile/widget/profile_screen.dart';
import 'package:halkyon_ui/halkyon_ui.dart'; import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
@ -70,6 +71,14 @@ class const _AccountSheet() extends StatelessWidget {
subtitle: Text(l10n.loopHint), subtitle: Text(l10n.loopHint),
onTap: () => openLoopScreen(context), onTap: () => openLoopScreen(context),
), ),
// Music from Jellyfin on this phone (D47).
if (PersonScope.of<TrainPerson>(context).music != null)
ListTile(
leading: const Icon(LucideIcons.music),
title: Text(l10n.musicTitle),
subtitle: Text(l10n.musicHint),
onTap: () => openMusicScreen(context),
),
ListTile( ListTile(
leading: const Icon(LucideIcons.info), leading: const Icon(LucideIcons.info),
title: Text(l10n.aboutTitle), title: Text(l10n.aboutTitle),

View file

@ -20,6 +20,8 @@ import 'package:halkyon_train_flutter/src/feature/loop/data/polar_loop.dart';
import 'package:halkyon_train_flutter/src/feature/map/data/map_source_repository.dart'; import 'package:halkyon_train_flutter/src/feature/map/data/map_source_repository.dart';
import 'package:halkyon_train_flutter/src/feature/map/data/offline_maps.dart'; import 'package:halkyon_train_flutter/src/feature/map/data/offline_maps.dart';
import 'package:halkyon_train_flutter/src/feature/map/widget/map_tiles.dart'; import 'package:halkyon_train_flutter/src/feature/map/widget/map_tiles.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/music_library.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/music_store.dart';
import 'package:halkyon_train_flutter/src/feature/plan/data/plan_repository.dart'; import 'package:halkyon_train_flutter/src/feature/plan/data/plan_repository.dart';
import 'package:halkyon_train_flutter/src/feature/plan/data/sync_plan_gateway.dart'; import 'package:halkyon_train_flutter/src/feature/plan/data/sync_plan_gateway.dart';
import 'package:halkyon_train_flutter/src/feature/profile/data/profile_repository.dart'; import 'package:halkyon_train_flutter/src/feature/profile/data/profile_repository.dart';
@ -104,6 +106,10 @@ class TrainPerson(
/// Their Polar Loop on this phone (D34); null where Train can't hold one /// Their Polar Loop on this phone (D34); null where Train can't hold one
/// (iOS for now, the desktop). /// (iOS for now, the desktop).
final PolarLoop? loop, final PolarLoop? loop,
/// Their music from Jellyfin on this phone (D47); null where none plays
/// (iOS for now, the desktop).
final MusicLibrary? music,
final Future<void> Function()? _onDispose, final Future<void> Function()? _onDispose,
}) implements Disposable { }) implements Disposable {
/// Workouts deleted, to be undone for a moment (D24); dropped with the /// Workouts deleted, to be undone for a moment (D24); dropped with the
@ -127,6 +133,7 @@ class TrainPerson(
await routeDeletions.dispose(); await routeDeletions.dispose();
await offlineMaps?.dispose(); await offlineMaps?.dispose();
await health?.dispose(); await health?.dispose();
music?.dispose();
await loop?.dispose(); await loop?.dispose();
await _onDispose?.call(); await _onDispose?.call();
} }
@ -289,6 +296,7 @@ class TrainDependencies({
person, person,
// One person's folder on the device at a time, as Learn keeps it. // One person's folder on the device at a time, as Learn keeps it.
await folders.openOnly(person.authUserId), await folders.openOnly(person.authUserId),
appVersion: device.appVersion,
accountKeys: () => accountKeys.read(person.authUserId), accountKeys: () => accountKeys.read(person.authUserId),
onClosed: closed.complete, onClosed: closed.complete,
); );
@ -325,6 +333,7 @@ Future<TrainPerson> _openPersonData(
AppCompatibility compatibility, AppCompatibility compatibility,
SignedIn person, SignedIn person,
Directory folder, { Directory folder, {
required String appVersion,
required Future<UnlockedAccountKeys?> Function() accountKeys, required Future<UnlockedAccountKeys?> Function() accountKeys,
required void Function() onClosed, required void Function() onClosed,
}) async { }) async {
@ -421,6 +430,14 @@ Future<TrainPerson> _openPersonData(
: null, : null,
health: health, health: health,
loop: loop, loop: loop,
// Jellyfin's account and the marked playlists, on this phone alone.
music: Platform.isAndroid
? MusicLibrary(
MusicStore(File(p.join(folder.path, 'music.json'))),
SecureJellyfinTokenStore(person.authUserId),
version: appVersion,
)
: null,
onDispose: () async { onDispose: () async {
try { try {
await workouts.dispose(); await workouts.dispose();
@ -451,6 +468,10 @@ class TrainDevice({
/// This build's number (after the `+` in the pubspec's version), which the /// This build's number (after the `+` in the pubspec's version), which the
/// server knows apps by (`AppCompatibility`); null where it has none. /// server knows apps by (`AppCompatibility`); null where it has none.
required final int? appBuild, required final int? appBuild,
/// This build's version (before the `+`), which Jellyfin lists Train's
/// sign-ins by (D47).
final String appVersion = '',
}) { }) {
/// Makes them, [builtIn] being the instance the build names. /// Makes them, [builtIn] being the instance the build names.
static Future<TrainDevice> create({required Instance builtIn}) async { static Future<TrainDevice> create({required Instance builtIn}) async {
@ -460,6 +481,7 @@ class TrainDevice({
support: support, support: support,
storage: InstanceStorage(support, builtIn: builtIn), storage: InstanceStorage(support, builtIn: builtIn),
appBuild: int.tryParse(package.buildNumber), appBuild: int.tryParse(package.buildNumber),
appVersion: package.version,
); );
} }
} }

View file

@ -0,0 +1,338 @@
import 'dart:async';
import 'package:clock/clock.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/jellyfin_client.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/music_library.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/music_store.dart';
/// Where signing in to Jellyfin stands (ADR 0030, D47).
enum MusicStep() {
/// Reading what the phone keeps.
opening,
/// Asking for the server's address.
address,
/// Asking the server what it is.
finding,
/// Showing a Quick Connect code until it is approved.
quickConnect,
/// Asking for the user name and password.
password,
/// Signing in.
signingIn,
/// Signed in: the playlists.
signedIn,
}
/// Why something on the music screen failed: Jellyfin's failures, and
/// Train's own.
enum MusicFailure() {
badAddress,
unreachable,
notJellyfin,
refused,
expired,
failed;
static MusicFailure of(JellyfinException error) => switch (error.failure) {
JellyfinFailure.unreachable => unreachable,
JellyfinFailure.notJellyfin => notJellyfin,
JellyfinFailure.refused => refused,
JellyfinFailure.quickConnectOff || JellyfinFailure.failed => failed,
};
}
/// What the music screen shows.
final class const MusicState({
required final MusicSettings settings,
final MusicStep step = MusicStep.opening,
/// The server being signed in to.
final JellyfinServer? server,
/// The Quick Connect code to approve.
final String? code,
/// What just failed.
final MusicFailure? failure,
/// The person's playlists of music; null until read.
final List<JellyfinPlaylist>? playlists,
}) with Equatable {
MusicState copyWith({
MusicSettings? settings,
MusicStep? step,
JellyfinServer? Function()? server,
String? Function()? code,
MusicFailure? Function()? failure,
List<JellyfinPlaylist>? Function()? playlists,
}) => MusicState(
settings: settings ?? this.settings,
step: step ?? this.step,
server: server == null ? this.server : server(),
code: code == null ? this.code : code(),
failure: failure == null ? this.failure : failure(),
playlists: playlists == null ? this.playlists : playlists(),
);
@override
List<Object?> get props => [settings, step, server, code, failure, playlists];
}
/// The music screen: signing in to Jellyfin and marking playlists.
class MusicCubit(
final MusicLibrary _library, {
/// How often a Quick Connect code is asked about.
final Duration poll = const Duration(seconds: 3),
/// How long a code is waited for: Jellyfin forgets requests after ten
/// minutes.
final Duration expiry = const Duration(minutes: 9),
}) extends Cubit<MusicState> {
this : super(MusicState(settings: _library.settings.value));
Timer? _polling;
String? _secret;
DateTime? _since;
bool _asking = false;
Future<void> open() async {
_library.settings.addListener(_settingsChanged);
await _library.open();
if (isClosed) {
return;
}
if (_library.settings.value.signedIn) {
emit(state.copyWith(step: MusicStep.signedIn));
await loadPlaylists();
} else {
emit(state.copyWith(step: MusicStep.address));
}
}
/// Finds the server at [typed], then signs in with Quick Connect where
/// it has it, else asks for the password.
Future<void> find(String typed) async {
final address = jellyfinAddress(typed);
if (address == null) {
emit(state.copyWith(failure: () => MusicFailure.badAddress));
return;
}
emit(state.copyWith(step: MusicStep.finding, failure: () => null));
try {
final server = await _library.find(address);
if (isClosed) {
return;
}
emit(state.copyWith(server: () => server));
if (server.quickConnect) {
await newCode();
} else {
emit(state.copyWith(step: MusicStep.password));
}
} on JellyfinException catch (error) {
if (!isClosed) {
emit(
state.copyWith(
step: MusicStep.address,
failure: () => MusicFailure.of(error),
),
);
}
}
}
/// Asks the server for a Quick Connect code and waits for its approval;
/// a server that refuses asks for the password.
Future<void> newCode() async {
final server = state.server!;
_stopPolling();
emit(
state.copyWith(
step: MusicStep.finding,
failure: () => null,
code: () => null,
),
);
try {
final code = await _library.jellyfin.initiateQuickConnect(
server.address,
);
if (isClosed) {
return;
}
_secret = code.secret;
_since = clock.now();
emit(state.copyWith(step: MusicStep.quickConnect, code: () => code.code));
_polling = Timer.periodic(poll, (_) => unawaited(_ask()));
} on JellyfinException catch (error) {
if (!isClosed) {
emit(
error.failure == JellyfinFailure.quickConnectOff
? state.copyWith(step: MusicStep.password)
: state.copyWith(
step: MusicStep.address,
failure: () => MusicFailure.of(error),
),
);
}
}
}
/// Signs in with a password instead of the code.
void usePassword() {
_stopPolling();
emit(state.copyWith(step: MusicStep.password, failure: () => null));
}
/// Back to the address.
void back() {
_stopPolling();
emit(
state.copyWith(
step: MusicStep.address,
server: () => null,
code: () => null,
failure: () => null,
),
);
}
Future<void> signIn(String name, String password) async {
emit(state.copyWith(step: MusicStep.signingIn, failure: () => null));
try {
await _library.signInWithPassword(state.server!, name, password);
await _signedIn();
} on JellyfinException catch (error) {
if (!isClosed) {
emit(
state.copyWith(
step: MusicStep.password,
failure: () => MusicFailure.of(error),
),
);
}
}
}
/// Reads the person's playlists again.
Future<void> loadPlaylists() async {
emit(state.copyWith(failure: () => null));
try {
final playlists = await _library.playlists();
if (!isClosed) {
emit(state.copyWith(playlists: () => playlists));
}
} on JellyfinException catch (error) {
if (isClosed) {
return;
}
emit(
_library.settings.value.signedIn
? state.copyWith(failure: () => MusicFailure.of(error))
: MusicState(
settings: _library.settings.value,
step: MusicStep.address,
failure: MusicFailure.of(error),
),
);
}
}
void mark(JellyfinPlaylist playlist, {required bool on}) =>
_library.mark(playlist, on: on);
Future<void> signOut() async {
await _library.signOut();
if (!isClosed) {
emit(
MusicState(settings: _library.settings.value, step: MusicStep.address),
);
}
}
Future<void> _ask() async {
final server = state.server;
final secret = _secret;
if (server == null || secret == null || _asking) {
return;
}
if (clock.now().difference(_since!) > expiry) {
_stopPolling();
emit(
state.copyWith(code: () => null, failure: () => MusicFailure.expired),
);
return;
}
try {
_asking = true;
final approved = await _library.jellyfin.quickConnectApproved(
server.address,
secret,
);
if (!approved || _secret != secret || isClosed) {
return;
}
_stopPolling();
emit(state.copyWith(step: MusicStep.signingIn));
await _library.signInWithQuickConnect(server, secret);
await _signedIn();
} on JellyfinException catch (error) {
// A request the server forgot can't be approved any more; a failure to
// reach it is asked again at the next tick.
if (!isClosed && error.failure != JellyfinFailure.unreachable) {
_stopPolling();
emit(
state.copyWith(
step: MusicStep.quickConnect,
code: () => null,
failure: () => MusicFailure.of(error),
),
);
}
} finally {
_asking = false;
}
}
Future<void> _signedIn() async {
if (isClosed) {
return;
}
emit(
state.copyWith(
step: MusicStep.signedIn,
code: () => null,
playlists: () => null,
),
);
await loadPlaylists();
}
void _settingsChanged() {
if (!isClosed) {
emit(state.copyWith(settings: _library.settings.value));
}
}
void _stopPolling() {
_polling?.cancel();
_polling = null;
_secret = null;
}
@override
Future<void> close() {
_stopPolling();
_library.settings.removeListener(_settingsChanged);
return super.close();
}
}

View file

@ -0,0 +1,303 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:equatable/equatable.dart';
import 'package:http/http.dart' as http;
/// Why Jellyfin couldn't be asked (ADR 0030, D47).
enum JellyfinFailure() {
/// Nothing answered at the address.
unreachable,
/// Something answered, but not Jellyfin.
notJellyfin,
/// Jellyfin refused: a wrong password, a token taken back.
refused,
/// The server has Quick Connect switched off.
quickConnectOff,
/// Jellyfin answered with an error of its own.
failed,
}
/// Jellyfin couldn't be asked, for [failure].
final class const JellyfinException(final JellyfinFailure failure)
implements Exception {
@override
String toString() => 'JellyfinException(${failure.name})';
}
/// A Jellyfin server as it describes itself.
final class const JellyfinServer({
required final Uri address,
required final String name,
required final String id,
/// Whether it signs in with Quick Connect.
final bool quickConnect = false,
}) with Equatable {
@override
List<Object?> get props => [address, name, id, quickConnect];
}
/// A Quick Connect request: the code the person approves, the secret
/// Train keeps.
typedef QuickConnectCode = ({String code, String secret});
/// What signing in gives: Train's own token, and whose it is.
typedef JellyfinSignIn = ({String token, String userId, String userName});
/// What asking as the person takes.
typedef JellyfinSession = ({String token, String userId});
/// A playlist of the person's on Jellyfin.
final class const JellyfinPlaylist({
required final String id,
required final String name,
/// How many items Jellyfin counts in it.
final int tracks = 0,
}) with Equatable {
@override
List<Object?> get props => [id, name, tracks];
}
/// [typed] as a Jellyfin server's address: https where it names no scheme,
/// its path kept (a server under `/jellyfin`), no trailing slash; null where
/// it names no host or another scheme than http and https.
Uri? jellyfinAddress(String typed) {
final trimmed = typed.trim();
final withScheme = trimmed.contains('://') ? trimmed : 'https://$trimmed';
final uri = Uri.tryParse(withScheme);
if (uri == null ||
uri.host.isEmpty ||
!(uri.scheme == 'https' || uri.scheme == 'http')) {
return null;
}
return Uri(
scheme: uri.scheme,
host: uri.host,
port: uri.hasPort ? uri.port : null,
path: uri.path.replaceAll(RegExp(r'/+$'), ''),
);
}
/// Jellyfin's API as Train asks it (D47), over plain HTTP: as the client
/// "Halkyon Train" on the device `deviceId`, to which Jellyfin ties the
/// tokens it gives.
class JellyfinClient(
final http.Client _http, {
required final String deviceId,
required final String version,
final String device = 'Android',
}) {
static const _timeout = Duration(seconds: 15);
/// What [address] is; [JellyfinFailure.notJellyfin] where it isn't
/// Jellyfin.
Future<JellyfinServer> server(Uri address) async {
final Object? info;
try {
info = await _send('GET', address, '/System/Info/Public');
} on JellyfinException catch (error) {
if (error.failure == JellyfinFailure.unreachable) {
rethrow;
}
throw const JellyfinException(JellyfinFailure.notJellyfin);
}
if (info case {
'Id': final String id,
'ServerName': final String name,
'Version': String _,
}) {
var quickConnect = false;
try {
quickConnect =
await _send('GET', address, '/QuickConnect/Enabled') == true;
} on JellyfinException {
// A server that doesn't say has it off.
}
return JellyfinServer(
address: address,
name: name,
id: id,
quickConnect: quickConnect,
);
}
throw const JellyfinException(JellyfinFailure.notJellyfin);
}
/// Starts a Quick Connect request (Jellyfin 10.9 and later).
Future<QuickConnectCode> initiateQuickConnect(Uri address) async {
try {
if (await _send('POST', address, '/QuickConnect/Initiate') case {
'Code': final String code,
'Secret': final String secret,
}) {
return (code: code, secret: secret);
}
} on JellyfinException catch (error) {
if (error.failure == JellyfinFailure.refused) {
throw const JellyfinException(JellyfinFailure.quickConnectOff);
}
rethrow;
}
throw const JellyfinException(JellyfinFailure.failed);
}
/// Whether the person approved the request [secret] belongs to.
Future<bool> quickConnectApproved(Uri address, String secret) async =>
switch (await _send(
'GET',
address,
'/QuickConnect/Connect',
query: {'Secret': secret},
)) {
{'Authenticated': true} => true,
_ => false,
};
/// Signs in with the approved request [secret] belongs to.
Future<JellyfinSignIn> authenticateWithQuickConnect(
Uri address,
String secret,
) async => _signedIn(
await _send(
'POST',
address,
'/Users/AuthenticateWithQuickConnect',
body: {'Secret': secret},
),
);
/// Signs in with [name] and [password].
Future<JellyfinSignIn> authenticateByName(
Uri address,
String name,
String password,
) async => _signedIn(
await _send(
'POST',
address,
'/Users/AuthenticateByName',
body: {'Username': name, 'Pw': password},
),
);
/// The person's playlists of music, by name; a playlist that names no
/// type counts as music, one of videos doesn't.
Future<List<JellyfinPlaylist>> playlists(
Uri address,
JellyfinSession session,
) async {
final answer = await _send(
'GET',
address,
'/Items',
token: session.token,
query: {
'userId': session.userId,
'includeItemTypes': 'Playlist',
'recursive': 'true',
'sortBy': 'SortName',
'fields': 'ChildCount',
},
);
return [
if (answer case {'Items': final List<Object?> items})
for (final item in items)
if (item
case {
'Id': final String id,
'Name': final String name,
}
when item['MediaType'] == null || item['MediaType'] == 'Audio')
JellyfinPlaylist(
id: id,
name: name,
tracks: item['ChildCount'] as int? ?? 0,
),
];
}
/// Ends [session]'s token on the server; what fails is left, as the
/// device forgets the token anyway.
Future<void> signOut(Uri address, JellyfinSession session) async {
try {
await _send('POST', address, '/Sessions/Logout', token: session.token);
} on JellyfinException {
// The server keeps it until its operator ends it.
}
}
JellyfinSignIn _signedIn(Object? answer) {
if (answer case {
'AccessToken': final String token,
'User': {'Id': final String userId, 'Name': final String userName},
}) {
return (token: token, userId: userId, userName: userName);
}
throw const JellyfinException(JellyfinFailure.failed);
}
String _authorization(String? token) =>
'MediaBrowser Client="Halkyon Train", Device="$device", '
'DeviceId="$deviceId", Version="$version"'
'${token == null ? '' : ', Token="$token"'}';
Future<Object?> _send(
String method,
Uri address,
String path, {
String? token,
Map<String, String>? query,
Map<String, Object?>? body,
}) async {
final request =
http.Request(
method,
address.replace(
path: '${address.path}$path',
queryParameters: query,
),
)
..headers['authorization'] = _authorization(token)
..headers['accept'] = 'application/json';
if (body != null) {
request
..headers['content-type'] = 'application/json'
..body = jsonEncode(body);
}
final http.Response response;
try {
response = await http.Response.fromStream(
await _http.send(request).timeout(_timeout),
).timeout(_timeout);
} on Object catch (error) {
if (error is http.ClientException ||
error is SocketException ||
error is TimeoutException ||
error is HandshakeException) {
throw const JellyfinException(JellyfinFailure.unreachable);
}
rethrow;
}
if (response.statusCode == 401 || response.statusCode == 403) {
throw const JellyfinException(JellyfinFailure.refused);
}
if (response.statusCode >= 300) {
throw const JellyfinException(JellyfinFailure.failed);
}
if (response.body.isEmpty) {
return null;
}
try {
return jsonDecode(response.body);
} on FormatException {
throw const JellyfinException(JellyfinFailure.notJellyfin);
}
}
}

View file

@ -0,0 +1,129 @@
import 'package:flutter/foundation.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/jellyfin_client.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/music_store.dart';
import 'package:http/http.dart' as http;
/// The person's music on Jellyfin, on this phone (ADR 0030, D47): the
/// server signed in to, with Quick Connect or a password, and the
/// playlists marked for running.
class MusicLibrary(
final MusicStore _store,
final JellyfinTokenStore _tokens, {
required final String version,
final http.Client? client,
}) {
late final ValueNotifier<MusicSettings> settings = ValueNotifier(
_store.read(),
);
late final jellyfin = JellyfinClient(
client ?? http.Client(),
deviceId: settings.value.deviceId,
version: version,
);
/// Forgets the tokens of people the device no longer keeps, and who is
/// signed in where their token is gone.
Future<void> open() async {
await _tokens.forgetOthers();
if (settings.value.signedIn && await _tokens.read() == null) {
_set(settings.value.signedOut());
}
}
/// What the server at [address] is.
Future<JellyfinServer> find(Uri address) => jellyfin.server(address);
/// Signs in to [server] with the Quick Connect request [secret] belongs
/// to, once approved.
Future<void> signInWithQuickConnect(
JellyfinServer server,
String secret,
) async {
await _signedIn(
server,
await jellyfin.authenticateWithQuickConnect(server.address, secret),
);
}
/// Signs in to [server] with [name] and [password].
Future<void> signInWithPassword(
JellyfinServer server,
String name,
String password,
) async {
await _signedIn(
server,
await jellyfin.authenticateByName(server.address, name, password),
);
}
/// The person's playlists of music; a token Jellyfin took back signs the
/// person out here too.
Future<List<JellyfinPlaylist>> playlists() async {
final current = settings.value;
final token = await _tokens.read();
if (!current.signedIn || token == null) {
throw const JellyfinException(JellyfinFailure.refused);
}
try {
return await jellyfin.playlists(current.address!, (
token: token,
userId: current.userId!,
));
} on JellyfinException catch (error) {
if (error.failure == JellyfinFailure.refused) {
await _tokens.keep(null);
_set(settings.value.signedOut());
}
rethrow;
}
}
/// Marks [playlist] for running, or no longer.
void mark(JellyfinPlaylist playlist, {required bool on}) {
final playlists = {...settings.value.playlists};
if (on) {
playlists[playlist.id] = playlist.name;
} else {
playlists.remove(playlist.id);
}
_set(settings.value.copyWith(playlists: playlists));
}
/// Signs out: the token ended on the server where it can be, forgotten
/// here, and the marked playlists with it.
Future<void> signOut() async {
final current = settings.value;
final token = await _tokens.read();
if (current.signedIn && token != null) {
await jellyfin.signOut(current.address!, (
token: token,
userId: current.userId!,
));
}
await _tokens.keep(null);
_set(current.signedOut());
}
void dispose() => settings.dispose();
Future<void> _signedIn(JellyfinServer server, JellyfinSignIn signedIn) async {
await _tokens.keep(signedIn.token);
_set(
MusicSettings(
deviceId: settings.value.deviceId,
address: server.address,
serverName: server.name,
serverId: server.id,
userId: signedIn.userId,
userName: signedIn.userName,
),
);
}
void _set(MusicSettings value) {
_store.save(value);
settings.value = value;
}
}

View file

@ -0,0 +1,157 @@
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import 'dart:math' show Random;
import 'package:equatable/equatable.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// Music from Jellyfin on this phone, for one person (ADR 0030, D47): never
/// synced, nothing of it on Train's server.
final class const MusicSettings({
/// The ID Jellyfin knows this phone's Train by, made once.
required final String deviceId,
/// The server signed in to; null before.
final Uri? address,
final String? serverName,
final String? serverId,
/// Who is signed in there; null before.
final String? userId,
final String? userName,
/// The playlists marked for running: their names by their IDs.
final Map<String, String> playlists = const {},
}) with Equatable {
/// Whether someone is signed in (their token in the keyring).
bool get signedIn => address != null && userId != null;
/// Signed out of the server: the device's ID kept, as Jellyfin lists
/// devices by it.
MusicSettings signedOut() => MusicSettings(deviceId: deviceId);
MusicSettings copyWith({Map<String, String>? playlists}) => MusicSettings(
deviceId: deviceId,
address: address,
serverName: serverName,
serverId: serverId,
userId: userId,
userName: userName,
playlists: playlists ?? this.playlists,
);
@override
List<Object?> get props => [
deviceId,
address,
serverName,
serverId,
userId,
userName,
playlists,
];
}
/// Keeps [MusicSettings] in a file of the person's folder (`music.json`); a
/// file it can't read is none, so the person signs in again.
class MusicStore(final File _file) {
MusicSettings read() {
if (_file.existsSync()) {
try {
if (jsonDecode(_file.readAsStringSync())
case {
'deviceId': final String deviceId,
} &&
final Map<String, Object?> json) {
return MusicSettings(
deviceId: deviceId,
address: switch (json['address']) {
final String address => Uri.tryParse(address),
_ => null,
},
serverName: json['serverName'] as String?,
serverId: json['serverId'] as String?,
userId: json['userId'] as String?,
userName: json['userName'] as String?,
playlists: {
...(json['playlists'] as Map<String, Object?>? ?? const {})
.cast<String, String>(),
},
);
}
} on Object catch (error) {
log('Music settings not read: ${error.runtimeType}', name: 'Music');
}
}
final settings = MusicSettings(deviceId: _newDeviceId());
save(settings);
return settings;
}
void save(MusicSettings settings) {
_file.parent.createSync(recursive: true);
_file.writeAsStringSync(
jsonEncode({
'deviceId': settings.deviceId,
'address': settings.address?.toString(),
'serverName': settings.serverName,
'serverId': settings.serverId,
'userId': settings.userId,
'userName': settings.userName,
'playlists': settings.playlists,
}),
);
}
static String _newDeviceId() {
final random = Random.secure();
return [
for (var i = 0; i < 16; i++)
random.nextInt(256).toRadixString(16).padLeft(2, '0'),
].join();
}
}
/// Where the person's Jellyfin token is kept on this phone.
abstract interface class JellyfinTokenStore() {
Future<String?> read();
/// Keeps [token]; null forgets it.
Future<void> keep(String? token);
/// Forgets the tokens of everyone else, whose folders the device no
/// longer keeps (one person at a time, as `PersonFolders.openOnly`).
Future<void> forgetOthers();
}
/// [JellyfinTokenStore] in the system's keyring (Android's Keystore), under
/// `train_jellyfin:` and the person's ID.
class SecureJellyfinTokenStore(
final String _person, {
final FlutterSecureStorage _storage = const FlutterSecureStorage(),
}) implements JellyfinTokenStore {
static const _prefix = 'train_jellyfin:';
String get _key => '$_prefix$_person';
@override
Future<String?> read() => _storage.read(key: _key);
@override
Future<void> keep(String? token) async {
await _storage.delete(key: _key);
if (token != null) {
await _storage.write(key: _key, value: token);
}
}
@override
Future<void> forgetOthers() async {
for (final key in (await _storage.readAll()).keys) {
if (key.startsWith(_prefix) && key != _key) {
await _storage.delete(key: key);
}
}
}
}

View file

@ -0,0 +1,287 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/app/train_dependencies.dart';
import 'package:halkyon_train_flutter/src/feature/music/bloc/music_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/jellyfin_client.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// Opens the music screen over [context]'s.
Future<void> openMusicScreen(BuildContext context) {
final music = PersonScope.of<TrainPerson>(context).music!;
return Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (context) => BlocProvider(
create: (context) => MusicCubit(music)..open(),
child: const MusicScreen(),
),
),
);
}
/// Music from Jellyfin on this phone (ADR 0030, D47): signing in to the
/// person's server, with Quick Connect where it has it, else a password;
/// then their playlists, marked for running.
class const MusicScreen({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return BlocBuilder<MusicCubit, MusicState>(
builder: (context, state) => Scaffold(
appBar: AppBar(title: Text(l10n.musicTitle)),
body: SafeArea(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: readableContentMaxWidth,
),
child: switch (state.step) {
MusicStep.opening ||
MusicStep.finding ||
MusicStep.signingIn => const AppLoadingIndicator(),
MusicStep.address => _Address(failure: state.failure),
MusicStep.quickConnect => _QuickConnect(state: state),
MusicStep.password => _Password(state: state),
MusicStep.signedIn => _Playlists(state: state),
},
),
),
),
),
);
}
}
String _failureText(AppLocalizations l10n, MusicFailure failure) =>
switch (failure) {
MusicFailure.badAddress => l10n.musicBadAddress,
MusicFailure.unreachable => l10n.musicUnreachable,
MusicFailure.notJellyfin => l10n.musicNotJellyfin,
MusicFailure.refused => l10n.musicRefused,
MusicFailure.expired => l10n.musicCodeExpired,
MusicFailure.failed => l10n.musicFailed,
};
class const _Address({final MusicFailure? failure}) extends StatefulWidget {
@override
State<_Address> createState() => _AddressState();
}
class _AddressState() extends State<_Address> {
final _address = TextEditingController();
@override
void dispose() {
_address.dispose();
super.dispose();
}
void _find() => context.read<MusicCubit>().find(_address.text);
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final failure = widget.failure;
return ListView(
padding: const EdgeInsets.all(16),
children: [
Text(l10n.musicIntro),
const SizedBox(height: 16),
TextField(
controller: _address,
keyboardType: TextInputType.url,
autocorrect: false,
textInputAction: TextInputAction.go,
onSubmitted: (_) => _find(),
decoration: InputDecoration(
labelText: l10n.musicServer,
hintText: 'jellyfin.example.org',
errorText: failure == null ? null : _failureText(l10n, failure),
errorMaxLines: 3,
),
),
const SizedBox(height: 16),
Align(
alignment: AlignmentDirectional.centerEnd,
child: FilledButton(onPressed: _find, child: Text(l10n.musicConnect)),
),
],
);
}
}
class const _QuickConnect({required final MusicState state})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final code = state.code;
final failure = state.failure;
MusicCubit cubit() => context.read<MusicCubit>();
return ListView(
padding: const EdgeInsets.all(16),
children: [
Text(l10n.musicQuickConnect(state.server!.name)),
const SizedBox(height: 24),
if (code != null) ...[
Center(
child: SelectableText(
code,
style: Theme.of(context).textTheme.displaySmall,
),
),
const SizedBox(height: 16),
const LinearProgressIndicator(),
] else if (failure != null) ...[
Text(_failureText(l10n, failure)),
const SizedBox(height: 16),
Align(
alignment: AlignmentDirectional.centerEnd,
child: FilledButton(
onPressed: () => cubit().newCode(),
child: Text(l10n.musicNewCode),
),
),
],
const SizedBox(height: 24),
OverflowBar(
alignment: MainAxisAlignment.end,
spacing: 8,
children: [
TextButton(
onPressed: () => cubit().back(),
child: Text(l10n.musicBack),
),
TextButton(
onPressed: () => cubit().usePassword(),
child: Text(l10n.musicUsePassword),
),
],
),
],
);
}
}
class const _Password({required final MusicState state})
extends StatefulWidget {
@override
State<_Password> createState() => _PasswordState();
}
class _PasswordState() extends State<_Password> {
final _name = TextEditingController();
final _password = TextEditingController();
@override
void dispose() {
_name.dispose();
_password.dispose();
super.dispose();
}
void _signIn() =>
context.read<MusicCubit>().signIn(_name.text.trim(), _password.text);
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final failure = widget.state.failure;
return ListView(
padding: const EdgeInsets.all(16),
children: [
Text(l10n.musicPasswordIntro(widget.state.server!.name)),
const SizedBox(height: 16),
TextField(
controller: _name,
autocorrect: false,
textInputAction: TextInputAction.next,
autofillHints: const [AutofillHints.username],
decoration: InputDecoration(labelText: l10n.musicUserName),
),
const SizedBox(height: 8),
TextField(
controller: _password,
obscureText: true,
textInputAction: TextInputAction.go,
autofillHints: const [AutofillHints.password],
onSubmitted: (_) => _signIn(),
decoration: InputDecoration(
labelText: l10n.musicPassword,
errorText: failure == null ? null : _failureText(l10n, failure),
errorMaxLines: 3,
),
),
const SizedBox(height: 16),
OverflowBar(
alignment: MainAxisAlignment.end,
spacing: 8,
children: [
TextButton(
onPressed: () => context.read<MusicCubit>().back(),
child: Text(l10n.musicBack),
),
FilledButton(onPressed: _signIn, child: Text(l10n.musicSignIn)),
],
),
],
);
}
}
class const _Playlists({required final MusicState state})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final settings = state.settings;
final playlists = state.playlists;
final failure = state.failure;
MusicCubit cubit() => context.read<MusicCubit>();
return ListView(
children: [
ListTile(
leading: const Icon(LucideIcons.server),
title: Text(settings.serverName ?? '${settings.address}'),
subtitle: Text(l10n.musicSignedInAs(settings.userName ?? '')),
trailing: TextButton(
onPressed: () => cubit().signOut(),
child: Text(l10n.musicSignOut),
),
),
const Divider(),
ListTile(
title: Text(
l10n.musicPlaylists,
style: Theme.of(context).textTheme.titleSmall,
),
subtitle: Text(l10n.musicPlaylistsHint),
),
if (failure != null)
ListTile(
title: Text(_failureText(l10n, failure)),
trailing: TextButton(
onPressed: () => cubit().loadPlaylists(),
child: Text(l10n.musicRetry),
),
)
else if (playlists == null)
const Padding(
padding: EdgeInsets.all(16),
child: AppLoadingIndicator(),
)
else if (playlists.isEmpty)
ListTile(subtitle: Text(l10n.musicNoPlaylists)),
for (final playlist in playlists ?? const <JellyfinPlaylist>[])
CheckboxListTile(
title: Text(playlist.name),
subtitle: Text(l10n.musicTracks(playlist.tracks)),
value: settings.playlists.containsKey(playlist.id),
onChanged: (on) => cubit().mark(playlist, on: on ?? false),
),
],
);
}
}

View file

@ -30,6 +30,9 @@ dependencies:
# vector tiles of the instance's source (OpenFreeMap without a setting, D22), # vector tiles of the instance's source (OpenFreeMap without a setting, D22),
# and its coordinates. # and its coordinates.
flutter_map: ^8.3.2 flutter_map: ^8.3.2
# The Jellyfin token in the keyring (ADR 0030, D47), as halkyon_app keeps
# the account's keys.
flutter_secure_storage: ^10.0.0
halkyon_app: halkyon_app:
path: ../../../packages/halkyon_app path: ../../../packages/halkyon_app
# The device's half of signing in with OpenID Connect (ADR 0023): the # The device's half of signing in with OpenID Connect (ADR 0023): the

View file

@ -0,0 +1,263 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/jellyfin_client.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
/// Jellyfin as Train asks it (ADR 0030, D47): what the server is, signing in
/// with Quick Connect or a password, and the person's playlists.
void main() {
final address = Uri.parse('https://music.example.org/jellyfin');
/// A Jellyfin server answering [routes] by path, as JSON; every request it
/// saw in [seen].
JellyfinClient serving(
Map<String, (int, Object?)> routes, {
List<http.Request>? seen,
}) => JellyfinClient(
MockClient((request) async {
seen?.add(request);
final route = routes['${request.method} ${request.url.path}'];
if (route == null) {
return http.Response('', 404);
}
return http.Response(
jsonEncode(route.$2),
route.$1,
headers: {'content-type': 'application/json'},
);
}),
deviceId: 'device-1',
version: '0.1.0',
);
group('an address as typed', () {
test('becomes https without a scheme, keeps its path, drops a trailing '
'slash', () {
expect(
jellyfinAddress(' music.example.org/jellyfin/ '),
Uri.parse('https://music.example.org/jellyfin'),
);
expect(
jellyfinAddress('http://192.168.1.4:8096'),
Uri.parse('http://192.168.1.4:8096'),
);
});
test('is none without a host or with another scheme', () {
expect(jellyfinAddress(''), isNull);
expect(jellyfinAddress('ftp://music.example.org'), isNull);
expect(jellyfinAddress('https://'), isNull);
});
});
test('a server says its name and whether Quick Connect is on', () async {
final seen = <http.Request>[];
final client = serving({
'GET /jellyfin/System/Info/Public': (
200,
{'ServerName': 'Home', 'Id': 'server-1', 'Version': '10.10.7'},
),
'GET /jellyfin/QuickConnect/Enabled': (200, true),
}, seen: seen);
final server = await client.server(address);
expect(server.name, 'Home');
expect(server.id, 'server-1');
expect(server.quickConnect, isTrue);
expect(
seen.first.headers['authorization'],
'MediaBrowser Client="Halkyon Train", Device="Android", '
'DeviceId="device-1", Version="0.1.0"',
);
});
test("what answers without Jellyfin's basics is not Jellyfin, and what "
"doesn't answer is unreachable", () async {
await expectLater(
serving({
'GET /jellyfin/System/Info/Public': (200, {'hello': 'world'}),
}).server(address),
throwsA(
isA<JellyfinException>().having(
(e) => e.failure,
'failure',
JellyfinFailure.notJellyfin,
),
),
);
await expectLater(
serving({}).server(address),
throwsA(
isA<JellyfinException>().having(
(e) => e.failure,
'failure',
JellyfinFailure.notJellyfin,
),
),
);
final down = JellyfinClient(
MockClient((request) => throw http.ClientException('down')),
deviceId: 'device-1',
version: '0.1.0',
);
await expectLater(
down.server(address),
throwsA(
isA<JellyfinException>().having(
(e) => e.failure,
'failure',
JellyfinFailure.unreachable,
),
),
);
});
test(
'Quick Connect: a code to show, approved, then a token of its own',
() async {
final seen = <http.Request>[];
var approved = false;
final client = JellyfinClient(
MockClient((request) async {
seen.add(request);
final body = switch ('${request.method} ${request.url.path}') {
'POST /jellyfin/QuickConnect/Initiate' => {
'Secret': 'secret-1',
'Code': '123456',
},
'GET /jellyfin/QuickConnect/Connect' => {'Authenticated': approved},
'POST /jellyfin/Users/AuthenticateWithQuickConnect' => {
'AccessToken': 'token-1',
'User': {'Id': 'user-1', 'Name': 'Ada'},
},
_ => null,
};
return http.Response(jsonEncode(body), body == null ? 404 : 200);
}),
deviceId: 'device-1',
version: '0.1.0',
);
final code = await client.initiateQuickConnect(address);
expect(code.code, '123456');
expect(await client.quickConnectApproved(address, code.secret), isFalse);
approved = true;
expect(await client.quickConnectApproved(address, code.secret), isTrue);
final signedIn = await client.authenticateWithQuickConnect(
address,
code.secret,
);
expect(signedIn, (token: 'token-1', userId: 'user-1', userName: 'Ada'));
expect(seen[1].url.queryParameters, {'Secret': 'secret-1'});
expect(jsonDecode(seen.last.body), {'Secret': 'secret-1'});
},
);
test('Quick Connect switched off is said so', () async {
await expectLater(
serving({
'POST /jellyfin/QuickConnect/Initiate': (401, null),
}).initiateQuickConnect(address),
throwsA(
isA<JellyfinException>().having(
(e) => e.failure,
'failure',
JellyfinFailure.quickConnectOff,
),
),
);
});
test('a password signs in; a wrong one is refused', () async {
final seen = <http.Request>[];
final client = serving({
'POST /jellyfin/Users/AuthenticateByName': (
200,
{
'AccessToken': 'token-2',
'User': {'Id': 'user-2', 'Name': 'Grace'},
},
),
}, seen: seen);
expect(
await client.authenticateByName(address, 'Grace', 'pw'),
(token: 'token-2', userId: 'user-2', userName: 'Grace'),
);
expect(jsonDecode(seen.single.body), {'Username': 'Grace', 'Pw': 'pw'});
await expectLater(
serving({
'POST /jellyfin/Users/AuthenticateByName': (401, null),
}).authenticateByName(address, 'Grace', 'wrong'),
throwsA(
isA<JellyfinException>().having(
(e) => e.failure,
'failure',
JellyfinFailure.refused,
),
),
);
});
test(
"the person's playlists of music, with the token; videos' left out",
() async {
final seen = <http.Request>[];
final client = serving({
'GET /jellyfin/Items': (
200,
{
'Items': [
{
'Id': 'p1',
'Name': 'Tempo',
'MediaType': 'Audio',
'ChildCount': 42,
},
{'Id': 'p2', 'Name': 'Films', 'MediaType': 'Video'},
{'Id': 'p3', 'Name': 'Old'},
],
},
),
}, seen: seen);
const session = (token: 'token-1', userId: 'user-1');
final playlists = await client.playlists(address, session);
expect(playlists, const [
JellyfinPlaylist(id: 'p1', name: 'Tempo', tracks: 42),
JellyfinPlaylist(id: 'p3', name: 'Old'),
]);
expect(seen.single.url.queryParameters, {
'userId': 'user-1',
'includeItemTypes': 'Playlist',
'recursive': 'true',
'sortBy': 'SortName',
'fields': 'ChildCount',
});
expect(
seen.single.headers['authorization'],
endsWith(', Token="token-1"'),
);
},
);
test('a token Jellyfin no longer takes is refused', () async {
await expectLater(
serving({
'GET /jellyfin/Items': (401, null),
}).playlists(address, (token: 'old', userId: 'user-1')),
throwsA(
isA<JellyfinException>().having(
(e) => e.failure,
'failure',
JellyfinFailure.refused,
),
),
);
});
}

View file

@ -0,0 +1,126 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/jellyfin_client.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/music_library.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/music_store.dart';
import '../../support/fake_jellyfin.dart';
/// The person's Jellyfin on this phone (ADR 0030, D47): signed in, the
/// token in the keyring and nothing else of it in the file, the playlists
/// marked, signed out.
void main() {
late Directory folder;
late File file;
late FakeJellyfin jellyfin;
late MemoryJellyfinTokenStore tokens;
final address = Uri.parse('https://music.example.org');
setUp(() {
folder = Directory.systemTemp.createTempSync('music_');
file = File('${folder.path}/music.json');
jellyfin = FakeJellyfin();
tokens = MemoryJellyfinTokenStore();
});
tearDown(() => folder.deleteSync(recursive: true));
MusicLibrary open() => MusicLibrary(
MusicStore(file),
tokens,
version: '0.1.0',
client: jellyfin.client,
);
test('a device ID is made once and kept, and Jellyfin is told it', () async {
final first = open();
final id = first.settings.value.deviceId;
expect(id, hasLength(32));
expect(open().settings.value.deviceId, id);
await first.find(address);
expect(
jellyfin.requests.first.headers['authorization'],
contains('DeviceId="$id"'),
);
});
test('signed in with a password: the token in the keyring, never in the '
'file; playlists marked and kept', () async {
final library = open();
final server = await library.find(address);
await library.signInWithPassword(server, 'Ada', 'pw');
expect(tokens.token, 'token-1');
expect(file.readAsStringSync(), isNot(contains('token-1')));
expect(library.settings.value.signedIn, isTrue);
expect(library.settings.value.userName, 'Ada');
expect(library.settings.value.serverName, 'Home');
final playlists = await library.playlists();
library.mark(playlists.first, on: true);
expect(open().settings.value.playlists, {'p1': 'Tempo'});
library.mark(playlists.first, on: false);
expect(open().settings.value.playlists, isEmpty);
});
test('a wrong password is refused, and nothing is kept', () async {
final library = open();
final server = await library.find(address);
await expectLater(
library.signInWithPassword(server, 'Ada', 'wrong'),
throwsA(isA<JellyfinException>()),
);
expect(tokens.token, isNull);
expect(library.settings.value.signedIn, isFalse);
});
test('signing out ends the token on the server and forgets it and the '
'playlists here', () async {
final library = open();
await library.signInWithPassword(await library.find(address), 'Ada', 'pw');
library.mark(const JellyfinPlaylist(id: 'p1', name: 'Tempo'), on: true);
await library.signOut();
expect(jellyfin.valid, isEmpty);
expect(tokens.token, isNull);
expect(library.settings.value.signedIn, isFalse);
expect(library.settings.value.playlists, isEmpty);
});
test('a token Jellyfin took back signs the person out here', () async {
final library = open();
await library.signInWithPassword(await library.find(address), 'Ada', 'pw');
jellyfin.revoke();
await expectLater(library.playlists(), throwsA(isA<JellyfinException>()));
expect(tokens.token, isNull);
expect(library.settings.value.signedIn, isFalse);
});
test(
"opening forgets others' tokens, and a sign-in whose token is gone",
() async {
final library = open();
await library.signInWithPassword(
await library.find(address),
'Ada',
'pw',
);
tokens.token = null;
final again = open();
await again.open();
expect(tokens.others, isEmpty);
expect(again.settings.value.signedIn, isFalse);
},
);
test("a file that can't be read is none", () {
file.writeAsStringSync('{not json');
expect(open().settings.value.signedIn, isFalse);
});
}

View file

@ -0,0 +1,159 @@
import 'dart:io';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/feature/music/bloc/music_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/music_library.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/music_store.dart';
import 'package:halkyon_train_flutter/src/feature/music/widget/music_screen.dart';
import 'package:material_ui/material_ui.dart';
import '../../support/fake_jellyfin.dart';
/// The music screen (ADR 0030, D47): the server's address, Quick Connect's
/// code until it is approved, else the password; then the playlists marked.
void main() {
late Directory folder;
late MemoryJellyfinTokenStore tokens;
setUp(() {
folder = Directory.systemTemp.createTempSync('music_');
tokens = MemoryJellyfinTokenStore();
});
tearDown(() => folder.deleteSync(recursive: true));
Future<MusicLibrary> show(WidgetTester tester, FakeJellyfin jellyfin) async {
final library = MusicLibrary(
MusicStore(File('${folder.path}/music.json')),
tokens,
version: '0.1.0',
client: jellyfin.client,
);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('en'),
home: BlocProvider(
create: (context) => MusicCubit(
library,
poll: const Duration(milliseconds: 100),
)..open(),
child: const MusicScreen(),
),
),
);
await tester.runAsync(() => Future<void>.delayed(Duration.zero));
await tester.pumpAndSettle();
return library;
}
/// Lets the fake server's answers and the screen's timers run.
Future<void> settle(WidgetTester tester) async {
for (var i = 0; i < 5; i++) {
await tester.runAsync(() => Future<void>.delayed(Duration.zero));
await tester.pump(const Duration(milliseconds: 150));
}
}
testWidgets('Quick Connect: the code shown until approved, then the '
"person's playlists, one marked", (tester) async {
final jellyfin = FakeJellyfin();
final library = await show(tester, jellyfin);
await tester.enterText(find.byType(TextField), 'music.example.org');
await tester.tap(find.text('Connect'));
await settle(tester);
expect(find.text('123456'), findsOneWidget);
expect(find.textContaining('To sign in to Home'), findsOneWidget);
jellyfin.approve();
await settle(tester);
expect(find.text('Signed in as Ada'), findsOneWidget);
expect(find.text('Tempo'), findsOneWidget);
expect(find.text('42 tracks'), findsOneWidget);
await tester.tap(find.text('Tempo'));
await tester.pump();
expect(library.settings.value.playlists, {'p1': 'Tempo'});
await tester.tap(find.text('Sign out'));
await settle(tester);
expect(find.text('Jellyfin server'), findsOneWidget);
expect(tokens.token, isNull);
});
testWidgets('without Quick Connect, the password; a wrong one said so', (
tester,
) async {
await show(tester, FakeJellyfin(quickConnect: false));
await tester.enterText(find.byType(TextField), 'music.example.org');
await tester.tap(find.text('Connect'));
await settle(tester);
expect(find.text('User name'), findsOneWidget);
await tester.enterText(find.byType(TextField).first, 'Ada');
await tester.enterText(find.byType(TextField).last, 'wrong');
await tester.tap(find.text('Sign in'));
await settle(tester);
expect(find.textContaining('Jellyfin refused'), findsOneWidget);
await tester.enterText(find.byType(TextField).last, 'pw');
await tester.tap(find.text('Sign in'));
await settle(tester);
expect(find.text('Signed in as Ada'), findsOneWidget);
});
testWidgets('an address that names no server is said so, and nothing is '
'asked', (tester) async {
final jellyfin = FakeJellyfin();
await show(tester, jellyfin);
await tester.enterText(find.byType(TextField), 'ftp://x');
await tester.tap(find.text('Connect'));
await tester.pump();
expect(find.textContaining("Enter the server's address"), findsOneWidget);
expect(jellyfin.requests, isEmpty);
});
testWidgets('a code not approved in time expires, and a new one is asked '
'for', (tester) async {
final jellyfin = FakeJellyfin();
final library = MusicLibrary(
MusicStore(File('${folder.path}/music.json')),
tokens,
version: '0.1.0',
client: jellyfin.client,
);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('en'),
home: BlocProvider(
create: (context) => MusicCubit(
library,
poll: const Duration(milliseconds: 100),
expiry: Duration.zero,
)..open(),
child: const MusicScreen(),
),
),
);
await settle(tester);
await tester.enterText(find.byType(TextField), 'music.example.org');
await tester.tap(find.text('Connect'));
await settle(tester);
expect(find.text('The code has expired.'), findsOneWidget);
await tester.tap(find.text('New code'));
await tester.runAsync(() => Future<void>.delayed(Duration.zero));
await tester.pump();
expect(find.text('123456'), findsOneWidget);
});
}

View file

@ -0,0 +1,99 @@
import 'dart:convert';
import 'package:halkyon_train_flutter/src/feature/music/data/music_store.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
/// A Jellyfin server for tests (ADR 0030, D47), at
/// `https://music.example.org`: Ada signs in with the password `pw` or with
/// Quick Connect once [approve]d; her token `token-<n>` holds until
/// [revoke]d.
class FakeJellyfin({
/// Whether it signs in with Quick Connect.
final bool quickConnect = true,
}) {
final requests = <http.Request>[];
var _approved = false;
var _tokens = 0;
final _valid = <String>{};
List<Map<String, Object?>> playlists = [
{'Id': 'p1', 'Name': 'Tempo', 'MediaType': 'Audio', 'ChildCount': 42},
{'Id': 'p2', 'Name': 'Easy', 'MediaType': 'Audio', 'ChildCount': 1},
];
void approve() => _approved = true;
void revoke() => _valid.clear();
/// Tokens given and still valid.
Set<String> get valid => {..._valid};
late final http.Client client = MockClient((request) async {
requests.add(request);
final token = RegExp(
'Token="([^"]+)"',
).firstMatch(request.headers['authorization'] ?? '')?.group(1);
http.Response json(Object? body, [int status = 200]) =>
http.Response(jsonEncode(body), status);
String signIn() {
final token = 'token-${++_tokens}';
_valid.add(token);
return token;
}
switch ('${request.method} ${request.url.path}') {
case 'GET /System/Info/Public':
return json({
'ServerName': 'Home',
'Id': 'server-1',
'Version': '10.10.7',
});
case 'GET /QuickConnect/Enabled':
return json(quickConnect);
case 'POST /QuickConnect/Initiate':
return quickConnect
? json({'Code': '123456', 'Secret': 'secret-1'})
: json(null, 401);
case 'GET /QuickConnect/Connect':
return json({'Authenticated': _approved});
case 'POST /Users/AuthenticateWithQuickConnect':
return _approved
? json({
'AccessToken': signIn(),
'User': {'Id': 'user-1', 'Name': 'Ada'},
})
: json(null, 401);
case 'POST /Users/AuthenticateByName':
final body = jsonDecode(request.body) as Map<String, Object?>;
return body['Username'] == 'Ada' && body['Pw'] == 'pw'
? json({
'AccessToken': signIn(),
'User': {'Id': 'user-1', 'Name': 'Ada'},
})
: json(null, 401);
case 'GET /Items':
return _valid.contains(token)
? json({'Items': playlists})
: json(null, 401);
case 'POST /Sessions/Logout':
_valid.remove(token);
return http.Response('', 204);
}
return http.Response('', 404);
});
}
/// A [JellyfinTokenStore] in memory, with others' tokens in [others].
class MemoryJellyfinTokenStore() implements JellyfinTokenStore {
String? token;
final others = <String>{'someone-else'};
@override
Future<String?> read() async => token;
@override
Future<void> keep(String? token) async => this.token = token;
@override
Future<void> forgetOthers() async => others.clear();
}

View file

@ -986,6 +986,59 @@ Strava. What was found, 27 Oct:
read Train's database; a role of its own for it is left for later. restic now forgets read Train's database; a role of its own for it is left for later. restic now forgets
snapshots grouped by host alone, so that the snapshots before Train's dump joined snapshots grouped by host alone, so that the snapshots before Train's dump joined
them aren't kept for good as a group of their own. them aren't kept for good as a group of their own.
- **D47 Music from Jellyfin** (recommended, the developer's; 29 Oct; D4's how):
- **Signing in**: with Jellyfin's Quick Connect (Train shows a code, the person
approves it in another Jellyfin client, Train gets a token of its own), else with
the user name and password where the server has Quick Connect off (over the
password alone, which always passes through Train, and over an API key, which only
an administrator makes and which carries an administrator's rights).
- **Which tracks**: those of the Jellyfin playlists the person marks for running in
Train's settings (over the whole library, hours of downloads and analysis on a large
one, and over genres, only as good as the library's tags).
- **Kept on this phone only**: the token in secure storage, the server's address, the
marked playlists and each track's tempo in a file of the person's on the device,
nothing on Train's server (over sealed, synced rows, a synced model and its
migration so that a second phone skips the analysis; music plays on phones only,
D5). A second phone signs in and analyses again.
- **Offline**: the marked playlists' tracks are downloaded on unmetered networks only
and kept up with the playlists; the settings show their size and delete them (over
fetching what a run needs as it starts, which makes the start wait on the network
and a free run guess its length, and over streaming, which D4 ruled out).
- **Tempo by Train's own analysis, in Kotlin** (over aubio, GPL-3 C, which needs a
native build per ABI; TarsosDSP, GPL-3 Java, unmaintained since 2021; Essentia,
AGPL-3 C++, tens of MB and a heavy build): Android's decoder reads a minute of the
downloaded track, an onset envelope and its autocorrelation give the beats per
minute, tested on the JVM with beats of known tempo. A BPM tag the file carries is
taken first.
- **Played by Media3's ExoPlayer in the recording service** (Apache-2.0, AndroidX;
over just_audio and audio_service in Flutter, whose engine may stop with the
activity and whose player would talk to the service's cues and plan runner across
the channel): beside the cues, which duck it, and the plan runner, it plays on with
the screen off and picks the next track there.
- **Cadence followed**: the one the step detector measures, smoothed over 30 s,
without a plan and for a step aiming at heart rate or the like; for a step aiming
at a pace, the cadence the person's own past runs show at that pace (steps against
speed), the measured one until three runs show it (over the measured cadence alone,
which never pulls the person towards the step's pace, and over a cadence set by
hand). A track fits at its tempo, its half or its double, sped up or slowed down by
at most **6 %** with the pitch kept (over 10 %, where voices start to sound off, and
over none, which needs a large playlist). A metronome at the cadence plays where no
library is set (D4).
- In parts: 10a signing in to Jellyfin and marking playlists in the settings; 10b
downloading the marked tracks and working out their tempo; 10c playing them in a
recording, by cadence, and the metronome.
Built: 10a. *Music* in the account sheet, on Android: the server's address (https
where none is typed; a path kept, as for a server under `/jellyfin`), then Quick
Connect's code, asked about every 3 s and given up after 9 minutes (Jellyfin forgets
a request after ten), or *Use a password*, which a server with Quick Connect off
asks for at once; Quick Connect as Jellyfin 10.9 and later have it. Train signs in
as the client "Halkyon Train" with a device ID of its own, made once and kept, so
that the server lists it as a device of the person's. The token is in the keyring
under the person's ID, and the tokens of people the device no longer keeps are
forgotten as the library opens; a token Jellyfin takes back signs the person out
on the phone too. Signing out ends the token on the server where it answers. The
person's playlists of music are listed (a playlist naming no type counts as one,
one of videos doesn't) and marked, the marked ones by ID and name in `music.json`.
## Consequences ## Consequences