feat(train): marked playlists kept on the phone, their tempo worked out (ADR 0030, D47)

Music's second part: the marked playlists' tracks are downloaded as
Jellyfin keeps them, on unmetered networks only, while the app is open
or resumes, and deleted once no marked playlist holds them. Each
track's tempo is worked out once on the phone: a BPM tag where the file
has one, else a minute decoded with MediaCodec and its spectral flux
autocorrelated (Tempo.kt, tested on the JVM). The music screen shows
what is kept and deletes it after asking.

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:48:53 +01:00
commit 7f18f001f4
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -1,5 +1,6 @@
import 'dart:convert';
import 'package:halkyon_train_flutter/src/feature/music/data/music_platform.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/music_store.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
@ -21,6 +22,28 @@ class FakeJellyfin({
{'Id': 'p2', 'Name': 'Easy', 'MediaType': 'Audio', 'ChildCount': 1},
];
/// The tracks of each playlist, by its ID; one missing is a 404.
Map<String, List<Map<String, Object?>>> tracks = {
'p1': [track('t1', 'One'), track('t2', 'Two')],
'p2': [track('t2', 'Two'), track('t3', 'Three')],
};
/// The tracks whose download fails with a server error.
final broken = <String>{};
/// The tracks downloaded, in order.
final downloads = <String>[];
/// A track as Jellyfin lists it.
static Map<String, Object?> track(String id, String name) => {
'Id': id,
'Name': name,
'Type': 'Audio',
'AlbumArtist': 'Band',
'Album': 'Album',
'RunTimeTicks': 1800000000,
};
void approve() => _approved = true;
void revoke() => _valid.clear();
@ -75,6 +98,24 @@ class FakeJellyfin({
return _valid.contains(token)
? json({'Items': playlists})
: json(null, 401);
case _ when request.url.path.startsWith('/Playlists/'):
final id = request.url.pathSegments[1];
if (!_valid.contains(token)) {
return json(null, 401);
}
return tracks[id] == null
? json(null, 404)
: json({'Items': tracks[id]});
case _ when request.url.path.startsWith('/Audio/'):
final id = request.url.pathSegments[1];
if (!_valid.contains(token)) {
return json(null, 401);
}
if (broken.contains(id)) {
return http.Response('', 500);
}
downloads.add(id);
return http.Response('audio of $id', 200);
case 'POST /Sessions/Logout':
_valid.remove(token);
return http.Response('', 204);
@ -83,6 +124,23 @@ class FakeJellyfin({
});
}
/// A [MusicPlatform] answering [tempos] by the file's name.
class FakeMusicPlatform() implements MusicPlatform {
bool unmeteredNow = true;
final tempos = <String, double>{'t1': 128, 't2': 172.5};
final analysed = <String>[];
@override
Future<bool> unmetered() async => unmeteredNow;
@override
Future<double?> tempo(String path) async {
final name = path.split('/').last;
analysed.add(name);
return tempos[name];
}
}
/// A [JellyfinTokenStore] in memory, with others' tokens in [others].
class MemoryJellyfinTokenStore() implements JellyfinTokenStore {
String? token;