feat(train): smaller copies of lossless tracks, transcoded by Jellyfin to Opus (ADR 0030, D52)
Smaller copies, on by default on the music screen, has the music job fetch a file above 256 kbps as Opus at 128 kbps in WebM, but for the lossy codecs (MP3, AAC, Vorbis, Opus) at any rate. Each track keeps which copy its file is; switching fetches the other copy without working out the tempo again, the file kept until the new one is whole. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
409a2519f9
commit
2a7cd927c4
13 changed files with 379 additions and 39 deletions
|
|
@ -508,6 +508,8 @@
|
|||
"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.",
|
||||
"musicSmaller": "Kleinere Kopien",
|
||||
"musicSmallerHint": "Verlustfreie Dateien wie FLAC als Opus mit 128 kbit/s, die dein Server erstellt. MP3 und AAC bleiben, wie sie sind.",
|
||||
"recordMusic": "Musik",
|
||||
"recordMusicOn": "Musik",
|
||||
"recordMetronome": "Metronom",
|
||||
|
|
|
|||
|
|
@ -641,6 +641,8 @@
|
|||
"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.",
|
||||
"musicSmaller": "Smaller copies",
|
||||
"musicSmallerHint": "Lossless files such as FLAC kept as Opus at 128 kbps, which your server makes. MP3 and AAC stay as they are.",
|
||||
"recordMusic": "Music",
|
||||
"recordMusicOn": "Music",
|
||||
"@recordMusicOn": {"description": "The segment playing the kept tracks by cadence."},
|
||||
|
|
|
|||
|
|
@ -3070,6 +3070,18 @@ abstract class AppLocalizations {
|
|||
/// **'Every playlist is unmarked, and its tracks deleted from this phone. They stay on your Jellyfin server.'**
|
||||
String get musicKeptDeleteText;
|
||||
|
||||
/// No description provided for @musicSmaller.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Smaller copies'**
|
||||
String get musicSmaller;
|
||||
|
||||
/// No description provided for @musicSmallerHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Lossless files such as FLAC kept as Opus at 128 kbps, which your server makes. MP3 and AAC stay as they are.'**
|
||||
String get musicSmallerHint;
|
||||
|
||||
/// No description provided for @recordMusic.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -1831,6 +1831,13 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
String get musicKeptDeleteText =>
|
||||
'Alle Playlists werden abgewählt und ihre Titel von diesem Telefon gelöscht. Auf deinem Jellyfin-Server bleiben sie.';
|
||||
|
||||
@override
|
||||
String get musicSmaller => 'Kleinere Kopien';
|
||||
|
||||
@override
|
||||
String get musicSmallerHint =>
|
||||
'Verlustfreie Dateien wie FLAC als Opus mit 128 kbit/s, die dein Server erstellt. MP3 und AAC bleiben, wie sie sind.';
|
||||
|
||||
@override
|
||||
String get recordMusic => 'Musik';
|
||||
|
||||
|
|
|
|||
|
|
@ -1819,6 +1819,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
String get musicKeptDeleteText =>
|
||||
'Every playlist is unmarked, and its tracks deleted from this phone. They stay on your Jellyfin server.';
|
||||
|
||||
@override
|
||||
String get musicSmaller => 'Smaller copies';
|
||||
|
||||
@override
|
||||
String get musicSmallerHint =>
|
||||
'Lossless files such as FLAC kept as Opus at 128 kbps, which your server makes. MP3 and AAC stay as they are.';
|
||||
|
||||
@override
|
||||
String get recordMusic => 'Music';
|
||||
|
||||
|
|
|
|||
|
|
@ -250,6 +250,9 @@ class MusicCubit(
|
|||
void mark(JellyfinPlaylist playlist, {required bool on}) =>
|
||||
_library.mark(playlist, on: on);
|
||||
|
||||
/// Keeps smaller copies of large files, or the files as they are (D52).
|
||||
void keepSmaller({required bool on}) => _library.keepSmaller(on: on);
|
||||
|
||||
/// Unmarks every playlist, which deletes their music on this phone.
|
||||
void unmarkAll() => _library.unmarkAll();
|
||||
|
||||
|
|
|
|||
|
|
@ -236,7 +236,7 @@ class JellyfinClient(
|
|||
address,
|
||||
'/Playlists/$playlist/Items',
|
||||
token: session.token,
|
||||
query: {'userId': session.userId},
|
||||
query: {'userId': session.userId, 'fields': 'MediaSources'},
|
||||
);
|
||||
return [
|
||||
if (answer case {'Items': final List<Object?> items})
|
||||
|
|
@ -260,13 +260,32 @@ class JellyfinClient(
|
|||
final int ticks => Duration(microseconds: ticks ~/ 10),
|
||||
_ => null,
|
||||
},
|
||||
bitrate: switch (item['MediaSources']) {
|
||||
[{'Bitrate': final int bitrate}, ...] => bitrate,
|
||||
_ => null,
|
||||
},
|
||||
codec: switch (item['MediaSources']) {
|
||||
[{'MediaStreams': final List<Object?> streams}, ...] =>
|
||||
streams.whereType<Map<String, Object?>>().firstWhere(
|
||||
(stream) => stream['Type'] == 'Audio',
|
||||
orElse: () => const {},
|
||||
)['Codec']
|
||||
as String?,
|
||||
_ => null,
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// The path and query under the server's address the track [id] is
|
||||
/// downloaded from, as Jellyfin keeps it (D47, D52).
|
||||
static String audioPath(String id) => '/Audio/$id/stream?static=true';
|
||||
/// downloaded from as [copy] (D47, D52): as Jellyfin keeps it, or
|
||||
/// transcoded to Opus at 128 kbps in WebM.
|
||||
static String audioPath(String id, MusicCopy copy) => switch (copy) {
|
||||
MusicCopy.original => '/Audio/$id/stream?static=true',
|
||||
MusicCopy.smaller =>
|
||||
'/Audio/$id/stream.webm'
|
||||
'?audioCodec=opus&audioBitRate=128000&maxAudioChannels=2',
|
||||
};
|
||||
|
||||
/// The `Authorization` header Train asks with, without a token: the
|
||||
/// music job adds the token it keeps (D52).
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ 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/data/music_store.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/music/model/music_track.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
|
|
@ -103,10 +104,12 @@ class MusicDownloads(
|
|||
}
|
||||
|
||||
var _marked = <String>{};
|
||||
bool? _smaller;
|
||||
|
||||
void _marksChanged() {
|
||||
final marked = {..._library.settings.value.playlists.keys};
|
||||
if (!setEquals(marked, _marked)) {
|
||||
final settings = _library.settings.value;
|
||||
if (!setEquals({...settings.playlists.keys}, _marked) ||
|
||||
settings.smaller != _smaller) {
|
||||
unawaited(sync());
|
||||
}
|
||||
}
|
||||
|
|
@ -115,6 +118,7 @@ class MusicDownloads(
|
|||
_takeIn();
|
||||
final settings = _library.settings.value;
|
||||
_marked = {...settings.playlists.keys};
|
||||
_smaller = settings.smaller;
|
||||
// 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) {
|
||||
|
|
@ -147,6 +151,8 @@ class MusicDownloads(
|
|||
artist: () => track.artist,
|
||||
album: () => track.album,
|
||||
length: () => track.length,
|
||||
bitrate: () => track.bitrate,
|
||||
codec: () => track.codec,
|
||||
playlists: {
|
||||
if (listed[track.id] case final seen?) ...seen.playlists,
|
||||
playlist,
|
||||
|
|
@ -179,14 +185,27 @@ class MusicDownloads(
|
|||
_set(kept.value.copyWith(tracks: listed));
|
||||
}
|
||||
|
||||
/// The missing tracks the job is to fetch, or none where no one is
|
||||
/// signed in or nothing is missing.
|
||||
/// What [track]'s file is to be (D52): a smaller copy where the settings
|
||||
/// say so and its file is above [MusicSettings.smallerAbove], but for a
|
||||
/// lossy codec's.
|
||||
MusicCopy _wanted(MusicTrack track) =>
|
||||
_library.settings.value.smaller &&
|
||||
(track.bitrate ?? 0) > MusicSettings.smallerAbove &&
|
||||
!MusicSettings.lossy.contains(track.codec?.toLowerCase())
|
||||
? MusicCopy.smaller
|
||||
: MusicCopy.original;
|
||||
|
||||
/// The tracks the job is to fetch: those missing, without a tempo, or
|
||||
/// kept as another copy than wanted, which stays until the new one is
|
||||
/// whole.
|
||||
List<MusicJobTrack> get _missing => [
|
||||
for (final track in kept.value.tracks.values)
|
||||
if (!track.downloaded || !track.analysed)
|
||||
if (_wanted(track) case final copy
|
||||
when !track.downloaded || !track.analysed || track.copy != copy)
|
||||
(
|
||||
id: track.id,
|
||||
path: JellyfinClient.audioPath(track.id),
|
||||
copy: copy,
|
||||
path: JellyfinClient.audioPath(track.id, copy),
|
||||
analyse: !track.analysed,
|
||||
),
|
||||
];
|
||||
|
|
@ -246,16 +265,20 @@ class MusicDownloads(
|
|||
'bytes': final int bytes,
|
||||
} &&
|
||||
final Map<String, Object?> fetched) {
|
||||
final copy =
|
||||
MusicCopy.values.asNameMap()[fetched['copy']] ??
|
||||
MusicCopy.original;
|
||||
if (kept.value.tracks.containsKey(id)) {
|
||||
_update(
|
||||
id,
|
||||
(track) => fetched['analysed'] == true
|
||||
? track.copyWith(
|
||||
bytes: () => bytes,
|
||||
copy: copy,
|
||||
tempo: () => (fetched['tempo'] as num?)?.toDouble(),
|
||||
analysed: true,
|
||||
)
|
||||
: track.copyWith(bytes: () => bytes),
|
||||
: track.copyWith(bytes: () => bytes, copy: copy),
|
||||
);
|
||||
} else {
|
||||
_deleteFile(id);
|
||||
|
|
|
|||
|
|
@ -106,6 +106,11 @@ class MusicLibrary(
|
|||
void playInRecording(String choice) =>
|
||||
_set(settings.value.copyWith(inRecording: choice));
|
||||
|
||||
/// Keeps smaller copies of files above [MusicSettings.smallerAbove], or
|
||||
/// the files as they are (D52).
|
||||
void keepSmaller({required bool on}) =>
|
||||
_set(settings.value.copyWith(smaller: on));
|
||||
|
||||
/// Unmarks every playlist, which deletes their music on this phone.
|
||||
void unmarkAll() => _set(settings.value.copyWith(playlists: const {}));
|
||||
|
||||
|
|
@ -132,6 +137,7 @@ class MusicLibrary(
|
|||
MusicSettings(
|
||||
deviceId: settings.value.deviceId,
|
||||
inRecording: settings.value.inRecording,
|
||||
smaller: settings.value.smaller,
|
||||
address: server.address,
|
||||
serverName: server.name,
|
||||
serverId: server.id,
|
||||
|
|
|
|||
|
|
@ -2,10 +2,17 @@ import 'dart:convert';
|
|||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/music/model/music_track.dart';
|
||||
|
||||
/// A track for the music job to fetch (ADR 0030, D52): its ID, the path and
|
||||
/// query under the server's address, and whether its tempo is wanted.
|
||||
typedef MusicJobTrack = ({String id, String path, bool analyse});
|
||||
/// A track for the music job to fetch (ADR 0030, D52): its ID, the copy
|
||||
/// wanted and the path and query under the server's address it comes from,
|
||||
/// and whether its tempo is wanted.
|
||||
typedef MusicJobTrack = ({
|
||||
String id,
|
||||
MusicCopy copy,
|
||||
String path,
|
||||
bool analyse,
|
||||
});
|
||||
|
||||
/// What the music job fetches (D52): the person's missing tracks from their
|
||||
/// server into their music folder, signed in with the token Train keeps for
|
||||
|
|
@ -27,7 +34,12 @@ final class const MusicJob({
|
|||
'client': client,
|
||||
'tracks': [
|
||||
for (final track in tracks)
|
||||
{'id': track.id, 'path': track.path, 'analyse': track.analyse},
|
||||
{
|
||||
'id': track.id,
|
||||
'copy': track.copy.name,
|
||||
'path': track.path,
|
||||
'analyse': track.analyse,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -28,18 +28,34 @@ final class const MusicSettings({
|
|||
/// What the Record sheet plays, as last chosen (D48): a `MusicChoice`'s
|
||||
/// name; null before.
|
||||
final String? inRecording,
|
||||
|
||||
/// Whether files above [smallerAbove], but for [lossy] ones, are kept as
|
||||
/// smaller copies (D52).
|
||||
final bool smaller = true,
|
||||
}) with Equatable {
|
||||
/// The bits a second above which a smaller copy is kept of a file whose
|
||||
/// codec isn't one of [lossy] (D52): FLAC, ALAC, WAV and their like.
|
||||
static const smallerAbove = 256000;
|
||||
|
||||
/// The lossy codecs, as Jellyfin names them, whose files are kept as they
|
||||
/// are at any rate: transcoding them loses their quality twice.
|
||||
static const lossy = {'mp3', 'aac', 'vorbis', 'opus'};
|
||||
|
||||
/// 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, inRecording: inRecording);
|
||||
MusicSettings signedOut() => MusicSettings(
|
||||
deviceId: deviceId,
|
||||
inRecording: inRecording,
|
||||
smaller: smaller,
|
||||
);
|
||||
|
||||
MusicSettings copyWith({
|
||||
Map<String, String>? playlists,
|
||||
String? inRecording,
|
||||
bool? smaller,
|
||||
}) => MusicSettings(
|
||||
deviceId: deviceId,
|
||||
address: address,
|
||||
|
|
@ -49,6 +65,7 @@ final class const MusicSettings({
|
|||
userName: userName,
|
||||
playlists: playlists ?? this.playlists,
|
||||
inRecording: inRecording ?? this.inRecording,
|
||||
smaller: smaller ?? this.smaller,
|
||||
);
|
||||
|
||||
@override
|
||||
|
|
@ -61,6 +78,7 @@ final class const MusicSettings({
|
|||
userName,
|
||||
playlists,
|
||||
inRecording,
|
||||
smaller,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -90,6 +108,7 @@ class MusicStore(final File _file) {
|
|||
.cast<String, String>(),
|
||||
},
|
||||
inRecording: json['inRecording'] as String?,
|
||||
smaller: json['smaller'] != false,
|
||||
);
|
||||
}
|
||||
} on Object catch (error) {
|
||||
|
|
@ -113,6 +132,7 @@ class MusicStore(final File _file) {
|
|||
'userName': settings.userName,
|
||||
'playlists': settings.playlists,
|
||||
'inRecording': settings.inRecording,
|
||||
'smaller': settings.smaller,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,14 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// What a track's file on the phone is (ADR 0030, D52).
|
||||
enum MusicCopy() {
|
||||
/// The file as Jellyfin keeps it.
|
||||
original,
|
||||
|
||||
/// Transcoded by Jellyfin to Opus at 128 kbps in WebM.
|
||||
smaller,
|
||||
}
|
||||
|
||||
/// A track of a marked playlist (ADR 0030, D47), as Jellyfin names it and
|
||||
/// as this phone keeps it.
|
||||
final class const MusicTrack({
|
||||
|
|
@ -11,12 +20,21 @@ final class const MusicTrack({
|
|||
/// Its length as Jellyfin gives it.
|
||||
final Duration? length,
|
||||
|
||||
/// Its file's bits a second on the server, as Jellyfin gives them.
|
||||
final int? bitrate,
|
||||
|
||||
/// Its audio's codec on the server, as Jellyfin names it (`flac`, `mp3`).
|
||||
final String? codec,
|
||||
|
||||
/// The marked playlists holding it, by their IDs.
|
||||
final Set<String> playlists = const {},
|
||||
|
||||
/// Its file's size once downloaded; null before.
|
||||
final int? bytes,
|
||||
|
||||
/// What the downloaded file is (D52).
|
||||
final MusicCopy copy = MusicCopy.original,
|
||||
|
||||
/// Its beats per minute; null where none was found or before it was
|
||||
/// worked out ([analysed]).
|
||||
final double? tempo,
|
||||
|
|
@ -31,8 +49,11 @@ final class const MusicTrack({
|
|||
String? Function()? artist,
|
||||
String? Function()? album,
|
||||
Duration? Function()? length,
|
||||
int? Function()? bitrate,
|
||||
String? Function()? codec,
|
||||
Set<String>? playlists,
|
||||
int? Function()? bytes,
|
||||
MusicCopy? copy,
|
||||
double? Function()? tempo,
|
||||
bool? analysed,
|
||||
}) => MusicTrack(
|
||||
|
|
@ -41,8 +62,11 @@ final class const MusicTrack({
|
|||
artist: artist == null ? this.artist : artist(),
|
||||
album: album == null ? this.album : album(),
|
||||
length: length == null ? this.length : length(),
|
||||
bitrate: bitrate == null ? this.bitrate : bitrate(),
|
||||
codec: codec == null ? this.codec : codec(),
|
||||
playlists: playlists ?? this.playlists,
|
||||
bytes: bytes == null ? this.bytes : bytes(),
|
||||
copy: copy ?? this.copy,
|
||||
tempo: tempo == null ? this.tempo : tempo(),
|
||||
analysed: analysed ?? this.analysed,
|
||||
);
|
||||
|
|
@ -53,8 +77,11 @@ final class const MusicTrack({
|
|||
'artist': artist,
|
||||
'album': album,
|
||||
'ms': length?.inMilliseconds,
|
||||
'bitrate': bitrate,
|
||||
'codec': codec,
|
||||
'playlists': [...playlists],
|
||||
'bytes': bytes,
|
||||
'copy': copy.name,
|
||||
'tempo': tempo,
|
||||
'analysed': analysed,
|
||||
};
|
||||
|
|
@ -71,10 +98,14 @@ final class const MusicTrack({
|
|||
final int ms => Duration(milliseconds: ms),
|
||||
_ => null,
|
||||
},
|
||||
bitrate: json['bitrate'] as int?,
|
||||
codec: json['codec'] as String?,
|
||||
playlists: {
|
||||
...(json['playlists'] as List<Object?>? ?? const []).cast<String>(),
|
||||
},
|
||||
bytes: json['bytes'] as int?,
|
||||
// Kept before D52: as Jellyfin keeps it.
|
||||
copy: MusicCopy.values.asNameMap()[json['copy']] ?? MusicCopy.original,
|
||||
tempo: (json['tempo'] as num?)?.toDouble(),
|
||||
analysed: json['analysed'] == true,
|
||||
),
|
||||
|
|
@ -88,8 +119,11 @@ final class const MusicTrack({
|
|||
artist,
|
||||
album,
|
||||
length,
|
||||
bitrate,
|
||||
codec,
|
||||
playlists,
|
||||
bytes,
|
||||
copy,
|
||||
tempo,
|
||||
analysed,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -284,6 +284,13 @@ class const _Playlists({
|
|||
ListTile(subtitle: Text(l10n.musicNoPlaylists)),
|
||||
if (kept case final kept? when settings.playlists.isNotEmpty)
|
||||
_Kept(kept: kept),
|
||||
SwitchListTile(
|
||||
secondary: const Icon(LucideIcons.fileAudio),
|
||||
title: Text(l10n.musicSmaller),
|
||||
subtitle: Text(l10n.musicSmallerHint),
|
||||
value: settings.smaller,
|
||||
onChanged: (on) => cubit().keepSmaller(on: on),
|
||||
),
|
||||
for (final playlist in playlists ?? const <JellyfinPlaylist>[])
|
||||
CheckboxListTile(
|
||||
title: Text(playlist.name),
|
||||
|
|
|
|||
Loading…
Reference in a new issue