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:
parent
3336fe4eca
commit
7f18f001f4
13 changed files with 1676 additions and 24 deletions
|
|
@ -494,5 +494,12 @@
|
|||
"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}}"
|
||||
"musicTracks": "{count, plural, =1{1 Titel} other{{count} Titel}}",
|
||||
"musicKept": "Auf diesem Telefon",
|
||||
"musicKeptWaiting": "{done} von {total} Titeln: der Rest wartet auf WLAN",
|
||||
"musicKeptLoading": "Lädt: {done} von {total} Titeln",
|
||||
"musicKeptDone": "{count} Titel, {size} MB, {tempo} mit Tempo",
|
||||
"musicKeptDelete": "Löschen",
|
||||
"musicKeptDeleteTitle": "Die Musik auf diesem Telefon löschen?",
|
||||
"musicKeptDeleteText": "Alle Playlists werden abgewählt und ihre Titel von diesem Telefon gelöscht. Auf deinem Jellyfin-Server bleiben sie."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -621,5 +621,15 @@
|
|||
"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"}}}
|
||||
"@musicTracks": {"placeholders": {"count": {"type": "int"}}},
|
||||
"musicKept": "On this phone",
|
||||
"musicKeptWaiting": "{done} of {total} tracks: the rest waits for Wi-Fi",
|
||||
"@musicKeptWaiting": {"placeholders": {"done": {"type": "int"}, "total": {"type": "int"}}},
|
||||
"musicKeptLoading": "Downloading: {done} of {total} tracks",
|
||||
"@musicKeptLoading": {"placeholders": {"done": {"type": "int"}, "total": {"type": "int"}}},
|
||||
"musicKeptDone": "{count} tracks, {size} MB, {tempo} with a tempo",
|
||||
"@musicKeptDone": {"placeholders": {"count": {"type": "int"}, "size": {"type": "String", "example": "230.4"}, "tempo": {"type": "int"}}},
|
||||
"musicKeptDelete": "Delete",
|
||||
"musicKeptDeleteTitle": "Delete the music on this phone?",
|
||||
"musicKeptDeleteText": "Every playlist is unmarked, and its tracks deleted from this phone. They stay on your Jellyfin server."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2991,6 +2991,48 @@ abstract class AppLocalizations {
|
|||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{1 track} other{{count} tracks}}'**
|
||||
String musicTracks(int count);
|
||||
|
||||
/// No description provided for @musicKept.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'On this phone'**
|
||||
String get musicKept;
|
||||
|
||||
/// No description provided for @musicKeptWaiting.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{done} of {total} tracks: the rest waits for Wi-Fi'**
|
||||
String musicKeptWaiting(int done, int total);
|
||||
|
||||
/// No description provided for @musicKeptLoading.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Downloading: {done} of {total} tracks'**
|
||||
String musicKeptLoading(int done, int total);
|
||||
|
||||
/// No description provided for @musicKeptDone.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count} tracks, {size} MB, {tempo} with a tempo'**
|
||||
String musicKeptDone(int count, String size, int tempo);
|
||||
|
||||
/// No description provided for @musicKeptDelete.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete'**
|
||||
String get musicKeptDelete;
|
||||
|
||||
/// No description provided for @musicKeptDeleteTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete the music on this phone?'**
|
||||
String get musicKeptDeleteTitle;
|
||||
|
||||
/// No description provided for @musicKeptDeleteText.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Every playlist is unmarked, and its tracks deleted from this phone. They stay on your Jellyfin server.'**
|
||||
String get musicKeptDeleteText;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
|
|
|||
|
|
@ -1781,4 +1781,32 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get musicKept => 'Auf diesem Telefon';
|
||||
|
||||
@override
|
||||
String musicKeptWaiting(int done, int total) {
|
||||
return '$done von $total Titeln: der Rest wartet auf WLAN';
|
||||
}
|
||||
|
||||
@override
|
||||
String musicKeptLoading(int done, int total) {
|
||||
return 'Lädt: $done von $total Titeln';
|
||||
}
|
||||
|
||||
@override
|
||||
String musicKeptDone(int count, String size, int tempo) {
|
||||
return '$count Titel, $size MB, $tempo mit Tempo';
|
||||
}
|
||||
|
||||
@override
|
||||
String get musicKeptDelete => 'Löschen';
|
||||
|
||||
@override
|
||||
String get musicKeptDeleteTitle => 'Die Musik auf diesem Telefon löschen?';
|
||||
|
||||
@override
|
||||
String get musicKeptDeleteText =>
|
||||
'Alle Playlists werden abgewählt und ihre Titel von diesem Telefon gelöscht. Auf deinem Jellyfin-Server bleiben sie.';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1769,6 +1769,34 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get musicKept => 'On this phone';
|
||||
|
||||
@override
|
||||
String musicKeptWaiting(int done, int total) {
|
||||
return '$done of $total tracks: the rest waits for Wi-Fi';
|
||||
}
|
||||
|
||||
@override
|
||||
String musicKeptLoading(int done, int total) {
|
||||
return 'Downloading: $done of $total tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String musicKeptDone(int count, String size, int tempo) {
|
||||
return '$count tracks, $size MB, $tempo with a tempo';
|
||||
}
|
||||
|
||||
@override
|
||||
String get musicKeptDelete => 'Delete';
|
||||
|
||||
@override
|
||||
String get musicKeptDeleteTitle => 'Delete the music on this phone?';
|
||||
|
||||
@override
|
||||
String get musicKeptDeleteText =>
|
||||
'Every playlist is unmarked, and its tracks deleted from this phone. They stay on your Jellyfin server.';
|
||||
}
|
||||
|
||||
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
||||
|
|
|
|||
|
|
@ -20,7 +20,9 @@ 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/offline_maps.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/map/widget/map_tiles.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/music/data/music_downloads.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/music/data/music_library.dart';
|
||||
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:halkyon_train_flutter/src/feature/plan/data/plan_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/data/sync_plan_gateway.dart';
|
||||
|
|
@ -110,6 +112,10 @@ class TrainPerson(
|
|||
/// Their music from Jellyfin on this phone (D47); null where none plays
|
||||
/// (iOS for now, the desktop).
|
||||
final MusicLibrary? music,
|
||||
|
||||
/// What this phone keeps of their marked playlists (D47); null where
|
||||
/// [music] is.
|
||||
final MusicDownloads? keptMusic,
|
||||
final Future<void> Function()? _onDispose,
|
||||
}) implements Disposable {
|
||||
/// Workouts deleted, to be undone for a moment (D24); dropped with the
|
||||
|
|
@ -133,6 +139,7 @@ class TrainPerson(
|
|||
await routeDeletions.dispose();
|
||||
await offlineMaps?.dispose();
|
||||
await health?.dispose();
|
||||
await keptMusic?.dispose();
|
||||
music?.dispose();
|
||||
await loop?.dispose();
|
||||
await _onDispose?.call();
|
||||
|
|
@ -408,6 +415,25 @@ Future<TrainPerson> _openPersonData(
|
|||
backgroundLines: File(p.join(folder.path, 'loop_days.txt')),
|
||||
relay: ServerpodFirmwareRelayGateway(client),
|
||||
)..start());
|
||||
// Jellyfin's account and the marked playlists, on this phone alone; their
|
||||
// tracks kept up as the app opens and resumes (D47).
|
||||
final music = Platform.isAndroid
|
||||
? MusicLibrary(
|
||||
MusicStore(File(p.join(folder.path, 'music.json'))),
|
||||
SecureJellyfinTokenStore(person.authUserId),
|
||||
version: appVersion,
|
||||
)
|
||||
: null;
|
||||
final keptMusic = music == null
|
||||
? null
|
||||
: (MusicDownloads(
|
||||
music,
|
||||
Directory(p.join(folder.path, 'music')),
|
||||
AndroidMusicPlatform(),
|
||||
)..open());
|
||||
final resumed = keptMusic == null
|
||||
? null
|
||||
: AppLifecycleListener(onResume: () => unawaited(keptMusic.sync()));
|
||||
return TrainPerson(
|
||||
person,
|
||||
workouts: workouts,
|
||||
|
|
@ -430,15 +456,10 @@ Future<TrainPerson> _openPersonData(
|
|||
: null,
|
||||
health: health,
|
||||
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,
|
||||
music: music,
|
||||
keptMusic: keptMusic,
|
||||
onDispose: () async {
|
||||
resumed?.dispose();
|
||||
try {
|
||||
await workouts.dispose();
|
||||
await profile.dispose();
|
||||
|
|
|
|||
|
|
@ -250,6 +250,9 @@ class MusicCubit(
|
|||
void mark(JellyfinPlaylist playlist, {required bool on}) =>
|
||||
_library.mark(playlist, on: on);
|
||||
|
||||
/// Unmarks every playlist, which deletes their music on this phone.
|
||||
void unmarkAll() => _library.unmarkAll();
|
||||
|
||||
Future<void> signOut() async {
|
||||
await _library.signOut();
|
||||
if (!isClosed) {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'dart:convert';
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/music/model/music_track.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// Why Jellyfin couldn't be asked (ADR 0030, D47).
|
||||
|
|
@ -223,6 +224,91 @@ class JellyfinClient(
|
|||
];
|
||||
}
|
||||
|
||||
/// The tracks of the playlist [playlist], in its order; what isn't
|
||||
/// audio left out.
|
||||
Future<List<MusicTrack>> tracks(
|
||||
Uri address,
|
||||
JellyfinSession session,
|
||||
String playlist,
|
||||
) async {
|
||||
final answer = await _send(
|
||||
'GET',
|
||||
address,
|
||||
'/Playlists/$playlist/Items',
|
||||
token: session.token,
|
||||
query: {'userId': session.userId},
|
||||
);
|
||||
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,
|
||||
'Type': 'Audio',
|
||||
})
|
||||
MusicTrack(
|
||||
id: id,
|
||||
name: name,
|
||||
artist: switch (item) {
|
||||
{'AlbumArtist': final String artist} => artist,
|
||||
{'Artists': [final String artist, ...]} => artist,
|
||||
_ => null,
|
||||
},
|
||||
album: item['Album'] as String?,
|
||||
// Jellyfin counts in ticks of 100 ns.
|
||||
length: switch (item['RunTimeTicks']) {
|
||||
final int ticks => Duration(microseconds: ticks ~/ 10),
|
||||
_ => null,
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// Downloads the track [id] as Jellyfin keeps it into [file], through a
|
||||
/// part file renamed once whole; its size.
|
||||
Future<int> download(
|
||||
Uri address,
|
||||
JellyfinSession session,
|
||||
String id,
|
||||
File file,
|
||||
) async {
|
||||
final request = http.Request(
|
||||
'GET',
|
||||
address.replace(
|
||||
path: '${address.path}/Audio/$id/stream',
|
||||
queryParameters: {'static': 'true'},
|
||||
),
|
||||
)..headers['authorization'] = _authorization(session.token);
|
||||
final part = File('${file.path}.part');
|
||||
try {
|
||||
final response = await _http.send(request).timeout(_timeout);
|
||||
if (response.statusCode == 401 || response.statusCode == 403) {
|
||||
throw const JellyfinException(JellyfinFailure.refused);
|
||||
}
|
||||
if (response.statusCode >= 300) {
|
||||
throw const JellyfinException(JellyfinFailure.failed);
|
||||
}
|
||||
file.parent.createSync(recursive: true);
|
||||
final sink = part.openWrite();
|
||||
try {
|
||||
// A connection that stalls for the timeout gives up.
|
||||
await sink.addStream(response.stream.timeout(_timeout));
|
||||
} finally {
|
||||
await sink.close();
|
||||
}
|
||||
part.renameSync(file.path);
|
||||
return file.lengthSync();
|
||||
} on Object catch (error) {
|
||||
if (part.existsSync()) {
|
||||
part.deleteSync();
|
||||
}
|
||||
if (_unreachable(error)) {
|
||||
throw const JellyfinException(JellyfinFailure.unreachable);
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
|
|
@ -233,6 +319,12 @@ class JellyfinClient(
|
|||
}
|
||||
}
|
||||
|
||||
static bool _unreachable(Object error) =>
|
||||
error is http.ClientException ||
|
||||
error is SocketException ||
|
||||
error is TimeoutException ||
|
||||
error is HandshakeException;
|
||||
|
||||
JellyfinSignIn _signedIn(Object? answer) {
|
||||
if (answer case {
|
||||
'AccessToken': final String token,
|
||||
|
|
@ -277,10 +369,7 @@ class JellyfinClient(
|
|||
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) {
|
||||
if (_unreachable(error)) {
|
||||
throw const JellyfinException(JellyfinFailure.unreachable);
|
||||
}
|
||||
rethrow;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,278 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:halkyon_core/halkyon_core.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_platform.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/music/model/music_track.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// What the phone keeps of the marked playlists (ADR 0030, D47).
|
||||
final class const KeptMusic({
|
||||
/// Every track of the marked playlists, by its ID, downloaded or not.
|
||||
final Map<String, MusicTrack> tracks = const {},
|
||||
|
||||
/// Whether a download is under way.
|
||||
final bool loading = false,
|
||||
|
||||
/// Whether downloads wait for an unmetered network.
|
||||
final bool waiting = false,
|
||||
}) with Equatable {
|
||||
int get downloaded => tracks.values.where((track) => track.downloaded).length;
|
||||
|
||||
int get bytes =>
|
||||
tracks.values.fold(0, (sum, track) => sum + (track.bytes ?? 0));
|
||||
|
||||
/// The tracks with a tempo, ready to be chosen by it.
|
||||
int get withTempo => tracks.values
|
||||
.where((track) => track.downloaded && track.tempo != null)
|
||||
.length;
|
||||
|
||||
KeptMusic copyWith({
|
||||
Map<String, MusicTrack>? tracks,
|
||||
bool? loading,
|
||||
bool? waiting,
|
||||
}) => KeptMusic(
|
||||
tracks: tracks ?? this.tracks,
|
||||
loading: loading ?? this.loading,
|
||||
waiting: waiting ?? this.waiting,
|
||||
);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [tracks, loading, waiting];
|
||||
}
|
||||
|
||||
/// Keeps the marked playlists' tracks on this phone (D47): their list read
|
||||
/// from Jellyfin, each track downloaded as Jellyfin keeps it on unmetered
|
||||
/// networks only, its tempo worked out once ([MusicPlatform.tempo]); the
|
||||
/// tracks no marked playlist holds any more deleted, offline too. Files in
|
||||
/// [_folder] by their IDs, the list in its `tracks.json`.
|
||||
class MusicDownloads(
|
||||
final MusicLibrary _library,
|
||||
final Directory _folder,
|
||||
final MusicPlatform _platform,
|
||||
) {
|
||||
late final ValueNotifier<KeptMusic> kept = ValueNotifier(
|
||||
KeptMusic(tracks: _read()),
|
||||
);
|
||||
|
||||
late final _runner = CoalescingRunner(
|
||||
_sync,
|
||||
onError: (error, _) =>
|
||||
log('Music not kept: ${error.runtimeType}', name: 'Music'),
|
||||
);
|
||||
|
||||
var _closed = false;
|
||||
|
||||
File get _list => File(p.join(_folder.path, 'tracks.json'));
|
||||
|
||||
/// The file of the track [id]; null where the ID isn't one Train keeps.
|
||||
File? fileOf(String id) => RegExp(r'^[A-Za-z0-9-]+$').hasMatch(id)
|
||||
? File(p.join(_folder.path, id))
|
||||
: null;
|
||||
|
||||
/// Follows the marks and keeps the music up with them; [sync] again
|
||||
/// whenever a network may have come (the app resumed).
|
||||
void open() {
|
||||
_library.settings.addListener(_marksChanged);
|
||||
unawaited(sync());
|
||||
}
|
||||
|
||||
/// Keeps the music up with the marked playlists now.
|
||||
Future<void> sync() => _runner.run();
|
||||
|
||||
Future<void> dispose() async {
|
||||
_closed = true;
|
||||
_library.settings.removeListener(_marksChanged);
|
||||
kept.dispose();
|
||||
}
|
||||
|
||||
var _marked = <String>{};
|
||||
|
||||
void _marksChanged() {
|
||||
final marked = {..._library.settings.value.playlists.keys};
|
||||
if (!setEquals(marked, _marked)) {
|
||||
unawaited(sync());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _sync() async {
|
||||
final settings = _library.settings.value;
|
||||
_marked = {...settings.playlists.keys};
|
||||
// What no marked playlist holds goes first, with or without a network.
|
||||
_keepOnly((track) => track.playlists.any(_marked.contains));
|
||||
if (!settings.signedIn || _marked.isEmpty || _closed) {
|
||||
return;
|
||||
}
|
||||
if (!await _platform.unmetered()) {
|
||||
_set(kept.value.copyWith(waiting: true));
|
||||
return;
|
||||
}
|
||||
_set(kept.value.copyWith(waiting: false, loading: true));
|
||||
try {
|
||||
final listed = <String, MusicTrack>{};
|
||||
final unread = <String>{};
|
||||
for (final playlist in _marked) {
|
||||
try {
|
||||
for (final track in await _library.tracks(playlist)) {
|
||||
final known = listed[track.id] ?? kept.value.tracks[track.id];
|
||||
listed[track.id] = (known ?? track).copyWith(
|
||||
name: track.name,
|
||||
artist: () => track.artist,
|
||||
album: () => track.album,
|
||||
length: () => track.length,
|
||||
playlists: {
|
||||
if (listed[track.id] case final seen?) ...seen.playlists,
|
||||
playlist,
|
||||
},
|
||||
);
|
||||
}
|
||||
} on JellyfinException catch (error) {
|
||||
if (error.failure == JellyfinFailure.unreachable ||
|
||||
error.failure == JellyfinFailure.refused) {
|
||||
return;
|
||||
}
|
||||
// A playlist that can't be read keeps what it had.
|
||||
unread.add(playlist);
|
||||
}
|
||||
}
|
||||
for (final track in kept.value.tracks.values) {
|
||||
final holding = track.playlists.where(unread.contains);
|
||||
if (holding.isNotEmpty) {
|
||||
listed[track.id] = switch (listed[track.id]) {
|
||||
final read? => read.copyWith(
|
||||
playlists: {...read.playlists, ...holding},
|
||||
),
|
||||
null => track,
|
||||
};
|
||||
}
|
||||
}
|
||||
// The files of what the playlists no longer hold go before the list
|
||||
// forgets them.
|
||||
_keepOnly((track) => listed.containsKey(track.id));
|
||||
_set(kept.value.copyWith(tracks: listed));
|
||||
await _download();
|
||||
} finally {
|
||||
if (!_closed) {
|
||||
_set(kept.value.copyWith(loading: false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _download() async {
|
||||
for (final id in [...kept.value.tracks.keys]) {
|
||||
final track = kept.value.tracks[id];
|
||||
final file = fileOf(id);
|
||||
if (track == null || file == null || _closed) {
|
||||
continue;
|
||||
}
|
||||
if (!track.downloaded) {
|
||||
if (!await _platform.unmetered()) {
|
||||
_set(kept.value.copyWith(waiting: true));
|
||||
return;
|
||||
}
|
||||
final int bytes;
|
||||
try {
|
||||
bytes = await _library.download(id, file);
|
||||
} on JellyfinException catch (error) {
|
||||
if (error.failure == JellyfinFailure.failed) {
|
||||
// This track alone: the next sync tries it again.
|
||||
continue;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (_closed) {
|
||||
return;
|
||||
}
|
||||
_update(id, (track) => track.copyWith(bytes: () => bytes));
|
||||
}
|
||||
if (!track.analysed) {
|
||||
double? tempo;
|
||||
try {
|
||||
tempo = await _platform.tempo(file.path);
|
||||
} on PlatformException catch (error) {
|
||||
log('Tempo not worked out: ${error.code}', name: 'Music');
|
||||
}
|
||||
if (_closed) {
|
||||
return;
|
||||
}
|
||||
_update(
|
||||
id,
|
||||
(track) => track.copyWith(tempo: () => tempo, analysed: true),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _update(String id, MusicTrack Function(MusicTrack) change) {
|
||||
if (kept.value.tracks[id] case final track?) {
|
||||
_set(
|
||||
kept.value.copyWith(tracks: {...kept.value.tracks, id: change(track)}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes the tracks [keep] doesn't keep, and their files.
|
||||
void _keepOnly(bool Function(MusicTrack) keep) {
|
||||
final tracks = kept.value.tracks;
|
||||
final gone = [
|
||||
for (final track in tracks.values)
|
||||
if (!keep(track)) track.id,
|
||||
];
|
||||
if (gone.isEmpty) {
|
||||
return;
|
||||
}
|
||||
for (final id in gone) {
|
||||
try {
|
||||
fileOf(id)?.deleteSync();
|
||||
} on PathNotFoundException {
|
||||
// Never downloaded.
|
||||
}
|
||||
}
|
||||
_set(
|
||||
kept.value.copyWith(
|
||||
tracks: {
|
||||
for (final MapEntry(:key, :value) in tracks.entries)
|
||||
if (!gone.contains(key)) key: value,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, MusicTrack> _read() {
|
||||
try {
|
||||
final json = jsonDecode(_list.readAsStringSync());
|
||||
return {
|
||||
if (json case final List<Object?> tracks)
|
||||
for (final track in tracks.map(MusicTrack.fromJson).nonNulls)
|
||||
track.id: track,
|
||||
};
|
||||
} on PathNotFoundException {
|
||||
return const {};
|
||||
} on Object catch (error) {
|
||||
log('Kept music not read: ${error.runtimeType}', name: 'Music');
|
||||
return const {};
|
||||
}
|
||||
}
|
||||
|
||||
void _set(KeptMusic value) {
|
||||
if (_closed) {
|
||||
return;
|
||||
}
|
||||
if (!mapEquals(value.tracks, kept.value.tracks)) {
|
||||
_folder.createSync(recursive: true);
|
||||
File('${_list.path}.part')
|
||||
..writeAsStringSync(
|
||||
jsonEncode([for (final track in value.tracks.values) track.toJson()]),
|
||||
)
|
||||
..renameSync(_list.path);
|
||||
}
|
||||
kept.value = value;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
import 'dart:io';
|
||||
|
||||
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:halkyon_train_flutter/src/feature/music/model/music_track.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// The person's music on Jellyfin, on this phone (ADR 0030, D47): the
|
||||
|
|
@ -60,14 +63,29 @@ class MusicLibrary(
|
|||
|
||||
/// The person's playlists of music; a token Jellyfin took back signs the
|
||||
/// person out here too.
|
||||
Future<List<JellyfinPlaylist>> playlists() async {
|
||||
Future<List<JellyfinPlaylist>> playlists() => _asking(jellyfin.playlists);
|
||||
|
||||
/// The tracks of the playlist [playlist].
|
||||
Future<List<MusicTrack>> tracks(String playlist) => _asking(
|
||||
(address, session) => jellyfin.tracks(address, session, playlist),
|
||||
);
|
||||
|
||||
/// Downloads the track [id] into [file]; its size.
|
||||
Future<int> download(String id, File file) => _asking(
|
||||
(address, session) => jellyfin.download(address, session, id, file),
|
||||
);
|
||||
|
||||
/// What [ask] answers as the person signed in.
|
||||
Future<T> _asking<T>(
|
||||
Future<T> Function(Uri address, JellyfinSession session) ask,
|
||||
) 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!, (
|
||||
return await ask(current.address!, (
|
||||
token: token,
|
||||
userId: current.userId!,
|
||||
));
|
||||
|
|
@ -91,6 +109,9 @@ class MusicLibrary(
|
|||
_set(settings.value.copyWith(playlists: playlists));
|
||||
}
|
||||
|
||||
/// Unmarks every playlist, which deletes their music on this phone.
|
||||
void unmarkAll() => _set(settings.value.copyWith(playlists: const {}));
|
||||
|
||||
/// Signs out: the token ended on the server where it can be, forgotten
|
||||
/// here, and the marked playlists with it.
|
||||
Future<void> signOut() async {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
import 'package:flutter/services.dart';
|
||||
|
||||
/// The phone's side of music (ADR 0030, D47).
|
||||
abstract interface class MusicPlatform() {
|
||||
/// Whether the network is unmetered, as downloads wait for one.
|
||||
Future<bool> unmetered();
|
||||
|
||||
/// The beats per minute of the track in the file at [path]; null where
|
||||
/// neither its tag nor its beat says one.
|
||||
Future<double?> tempo(String path);
|
||||
}
|
||||
|
||||
/// [MusicPlatform] on Android, over `app.halkyon.train/music`
|
||||
/// (`MusicBridge.kt`): the tag `BpmTag.kt` reads, else `Tempo.kt`'s own
|
||||
/// analysis of a minute from the track's middle.
|
||||
class AndroidMusicPlatform({
|
||||
final MethodChannel _channel = const MethodChannel('app.halkyon.train/music'),
|
||||
}) implements MusicPlatform {
|
||||
@override
|
||||
Future<bool> unmetered() async =>
|
||||
await _channel.invokeMethod<bool>('unmetered') ?? false;
|
||||
|
||||
@override
|
||||
Future<double?> tempo(String path) =>
|
||||
_channel.invokeMethod<double>('tempo', {'path': path});
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// A track of a marked playlist (ADR 0030, D47), as Jellyfin names it and
|
||||
/// as this phone keeps it.
|
||||
final class const MusicTrack({
|
||||
required final String id,
|
||||
required final String name,
|
||||
final String? artist,
|
||||
final String? album,
|
||||
|
||||
/// Its length as Jellyfin gives it.
|
||||
final Duration? length,
|
||||
|
||||
/// The marked playlists holding it, by their IDs.
|
||||
final Set<String> playlists = const {},
|
||||
|
||||
/// Its file's size once downloaded; null before.
|
||||
final int? bytes,
|
||||
|
||||
/// Its beats per minute; null where none was found or before it was
|
||||
/// worked out ([analysed]).
|
||||
final double? tempo,
|
||||
|
||||
/// Whether its tempo was worked out (found or not).
|
||||
final bool analysed = false,
|
||||
}) with Equatable {
|
||||
bool get downloaded => bytes != null;
|
||||
|
||||
MusicTrack copyWith({
|
||||
String? name,
|
||||
String? Function()? artist,
|
||||
String? Function()? album,
|
||||
Duration? Function()? length,
|
||||
Set<String>? playlists,
|
||||
int? Function()? bytes,
|
||||
double? Function()? tempo,
|
||||
bool? analysed,
|
||||
}) => MusicTrack(
|
||||
id: id,
|
||||
name: name ?? this.name,
|
||||
artist: artist == null ? this.artist : artist(),
|
||||
album: album == null ? this.album : album(),
|
||||
length: length == null ? this.length : length(),
|
||||
playlists: playlists ?? this.playlists,
|
||||
bytes: bytes == null ? this.bytes : bytes(),
|
||||
tempo: tempo == null ? this.tempo : tempo(),
|
||||
analysed: analysed ?? this.analysed,
|
||||
);
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'artist': artist,
|
||||
'album': album,
|
||||
'ms': length?.inMilliseconds,
|
||||
'playlists': [...playlists],
|
||||
'bytes': bytes,
|
||||
'tempo': tempo,
|
||||
'analysed': analysed,
|
||||
};
|
||||
|
||||
static MusicTrack? fromJson(Object? json) => switch (json) {
|
||||
{'id': final String id, 'name': final String name} &&
|
||||
final Map<String, Object?> json =>
|
||||
MusicTrack(
|
||||
id: id,
|
||||
name: name,
|
||||
artist: json['artist'] as String?,
|
||||
album: json['album'] as String?,
|
||||
length: switch (json['ms']) {
|
||||
final int ms => Duration(milliseconds: ms),
|
||||
_ => null,
|
||||
},
|
||||
playlists: {
|
||||
...(json['playlists'] as List<Object?>? ?? const []).cast<String>(),
|
||||
},
|
||||
bytes: json['bytes'] as int?,
|
||||
tempo: (json['tempo'] as num?)?.toDouble(),
|
||||
analysed: json['analysed'] == true,
|
||||
),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
name,
|
||||
artist,
|
||||
album,
|
||||
length,
|
||||
playlists,
|
||||
bytes,
|
||||
tempo,
|
||||
analysed,
|
||||
];
|
||||
}
|
||||
|
|
@ -4,18 +4,20 @@ 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_train_flutter/src/feature/music/data/music_downloads.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:intl/intl.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!;
|
||||
final person = PersonScope.of<TrainPerson>(context);
|
||||
return Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (context) => BlocProvider(
|
||||
create: (context) => MusicCubit(music)..open(),
|
||||
child: const MusicScreen(),
|
||||
create: (context) => MusicCubit(person.music!)..open(),
|
||||
child: MusicScreen(kept: person.keptMusic),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
@ -24,7 +26,11 @@ Future<void> openMusicScreen(BuildContext context) {
|
|||
/// 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 {
|
||||
class const MusicScreen({
|
||||
/// What the phone keeps of the marked playlists.
|
||||
final MusicDownloads? kept,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
|
@ -44,7 +50,7 @@ class const MusicScreen({super.key}) extends StatelessWidget {
|
|||
MusicStep.address => _Address(failure: state.failure),
|
||||
MusicStep.quickConnect => _QuickConnect(state: state),
|
||||
MusicStep.password => _Password(state: state),
|
||||
MusicStep.signedIn => _Playlists(state: state),
|
||||
MusicStep.signedIn => _Playlists(state: state, kept: kept),
|
||||
},
|
||||
),
|
||||
),
|
||||
|
|
@ -231,8 +237,10 @@ class _PasswordState() extends State<_Password> {
|
|||
}
|
||||
}
|
||||
|
||||
class const _Playlists({required final MusicState state})
|
||||
extends StatelessWidget {
|
||||
class const _Playlists({
|
||||
required final MusicState state,
|
||||
final MusicDownloads? kept,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
|
@ -274,6 +282,8 @@ class const _Playlists({required final MusicState state})
|
|||
)
|
||||
else if (playlists.isEmpty)
|
||||
ListTile(subtitle: Text(l10n.musicNoPlaylists)),
|
||||
if (kept case final kept? when settings.playlists.isNotEmpty)
|
||||
_Kept(kept: kept),
|
||||
for (final playlist in playlists ?? const <JellyfinPlaylist>[])
|
||||
CheckboxListTile(
|
||||
title: Text(playlist.name),
|
||||
|
|
@ -285,3 +295,63 @@ class const _Playlists({required final MusicState state})
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// What the phone keeps of the marked playlists, and deleting it.
|
||||
class const _Kept({required final MusicDownloads kept})
|
||||
extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final locale = Localizations.localeOf(context).toString();
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: kept.kept,
|
||||
builder: (context, music, _) => ListTile(
|
||||
leading: const Icon(LucideIcons.hardDriveDownload),
|
||||
title: Text(l10n.musicKept),
|
||||
subtitle: Text(switch (music) {
|
||||
KeptMusic(waiting: true) => l10n.musicKeptWaiting(
|
||||
music.downloaded,
|
||||
music.tracks.length,
|
||||
),
|
||||
KeptMusic(loading: true) => l10n.musicKeptLoading(
|
||||
music.downloaded,
|
||||
music.tracks.length,
|
||||
),
|
||||
_ => l10n.musicKeptDone(
|
||||
music.downloaded,
|
||||
NumberFormat('0.0', locale).format(music.bytes / 1000000),
|
||||
music.withTempo,
|
||||
),
|
||||
}),
|
||||
trailing: TextButton(
|
||||
onPressed: () => _delete(context, l10n),
|
||||
child: Text(l10n.musicKeptDelete),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _delete(BuildContext context, AppLocalizations l10n) async {
|
||||
final cubit = context.read<MusicCubit>();
|
||||
final sure = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(l10n.musicKeptDeleteTitle),
|
||||
content: Text(l10n.musicKeptDeleteText),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: Text(l10n.musicKeptDelete),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (sure ?? false) {
|
||||
cubit.unmarkAll();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue