feat(playback): add uncompressed flac playback support

This commit is contained in:
Kingkor Roy Tirtho 2025-09-19 11:53:36 +06:00
parent e8a54d3209
commit cecb687592
42 changed files with 232 additions and 50 deletions

View File

@ -39,6 +39,11 @@ class InstallDependenciesCommand extends Command {
switch (argResults!.option("platform")) { switch (argResults!.option("platform")) {
case "windows": case "windows":
await shell.run(
"""
choco install innosetup -y
""",
);
break; break;
case "linux": case "linux":
await shell.run( await shell.run(

View File

@ -462,5 +462,6 @@
"configure_your_own_metadata_plugin": "Configure your own playlist/album/artist/feed metadata provider", "configure_your_own_metadata_plugin": "Configure your own playlist/album/artist/feed metadata provider",
"audio_scrobblers": "Audio Scrobblers", "audio_scrobblers": "Audio Scrobblers",
"scrobbling": "Scrobbling", "scrobbling": "Scrobbling",
"source": "Source: " "source": "Source: ",
"uncompressed": "Uncompressed"
} }

View File

@ -2936,6 +2936,12 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Source: '** /// **'Source: '**
String get source; String get source;
/// No description provided for @uncompressed.
///
/// In en, this message translates to:
/// **'Uncompressed'**
String get uncompressed;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate

View File

@ -1540,4 +1540,7 @@ class AppLocalizationsAr extends AppLocalizations {
@override @override
String get source => 'Source: '; String get source => 'Source: ';
@override
String get uncompressed => 'Uncompressed';
} }

View File

@ -1541,4 +1541,7 @@ class AppLocalizationsBn extends AppLocalizations {
@override @override
String get source => 'Source: '; String get source => 'Source: ';
@override
String get uncompressed => 'Uncompressed';
} }

View File

@ -1551,4 +1551,7 @@ class AppLocalizationsCa extends AppLocalizations {
@override @override
String get source => 'Source: '; String get source => 'Source: ';
@override
String get uncompressed => 'Uncompressed';
} }

View File

@ -1541,4 +1541,7 @@ class AppLocalizationsCs extends AppLocalizations {
@override @override
String get source => 'Source: '; String get source => 'Source: ';
@override
String get uncompressed => 'Uncompressed';
} }

View File

@ -1553,4 +1553,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get source => 'Source: '; String get source => 'Source: ';
@override
String get uncompressed => 'Uncompressed';
} }

View File

@ -1539,4 +1539,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get source => 'Source: '; String get source => 'Source: ';
@override
String get uncompressed => 'Uncompressed';
} }

View File

@ -1554,4 +1554,7 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get source => 'Source: '; String get source => 'Source: ';
@override
String get uncompressed => 'Uncompressed';
} }

View File

@ -1551,4 +1551,7 @@ class AppLocalizationsEu extends AppLocalizations {
@override @override
String get source => 'Source: '; String get source => 'Source: ';
@override
String get uncompressed => 'Uncompressed';
} }

View File

@ -1539,4 +1539,7 @@ class AppLocalizationsFa extends AppLocalizations {
@override @override
String get source => 'Source: '; String get source => 'Source: ';
@override
String get uncompressed => 'Uncompressed';
} }

View File

@ -1539,4 +1539,7 @@ class AppLocalizationsFi extends AppLocalizations {
@override @override
String get source => 'Source: '; String get source => 'Source: ';
@override
String get uncompressed => 'Uncompressed';
} }

View File

@ -1559,4 +1559,7 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get source => 'Source: '; String get source => 'Source: ';
@override
String get uncompressed => 'Uncompressed';
} }

View File

@ -1545,4 +1545,7 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get source => 'Source: '; String get source => 'Source: ';
@override
String get uncompressed => 'Uncompressed';
} }

View File

@ -1547,4 +1547,7 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get source => 'Source: '; String get source => 'Source: ';
@override
String get uncompressed => 'Uncompressed';
} }

View File

@ -1546,4 +1546,7 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get source => 'Source: '; String get source => 'Source: ';
@override
String get uncompressed => 'Uncompressed';
} }

View File

@ -1510,4 +1510,7 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get source => 'Source: '; String get source => 'Source: ';
@override
String get uncompressed => 'Uncompressed';
} }

View File

@ -1548,4 +1548,7 @@ class AppLocalizationsKa extends AppLocalizations {
@override @override
String get source => 'Source: '; String get source => 'Source: ';
@override
String get uncompressed => 'Uncompressed';
} }

View File

@ -1514,4 +1514,7 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get source => 'Source: '; String get source => 'Source: ';
@override
String get uncompressed => 'Uncompressed';
} }

View File

@ -1551,4 +1551,7 @@ class AppLocalizationsNe extends AppLocalizations {
@override @override
String get source => 'Source: '; String get source => 'Source: ';
@override
String get uncompressed => 'Uncompressed';
} }

View File

@ -1545,4 +1545,7 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get source => 'Source: '; String get source => 'Source: ';
@override
String get uncompressed => 'Uncompressed';
} }

View File

@ -1547,4 +1547,7 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get source => 'Source: '; String get source => 'Source: ';
@override
String get uncompressed => 'Uncompressed';
} }

View File

@ -1544,4 +1544,7 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get source => 'Source: '; String get source => 'Source: ';
@override
String get uncompressed => 'Uncompressed';
} }

View File

@ -1547,4 +1547,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get source => 'Source: '; String get source => 'Source: ';
@override
String get uncompressed => 'Uncompressed';
} }

View File

@ -1553,4 +1553,7 @@ class AppLocalizationsTa extends AppLocalizations {
@override @override
String get source => 'Source: '; String get source => 'Source: ';
@override
String get uncompressed => 'Uncompressed';
} }

View File

@ -1536,4 +1536,7 @@ class AppLocalizationsTh extends AppLocalizations {
@override @override
String get source => 'Source: '; String get source => 'Source: ';
@override
String get uncompressed => 'Uncompressed';
} }

View File

@ -1554,4 +1554,7 @@ class AppLocalizationsTl extends AppLocalizations {
@override @override
String get source => 'Source: '; String get source => 'Source: ';
@override
String get uncompressed => 'Uncompressed';
} }

View File

@ -1547,4 +1547,7 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get source => 'Source: '; String get source => 'Source: ';
@override
String get uncompressed => 'Uncompressed';
} }

View File

@ -1543,4 +1543,7 @@ class AppLocalizationsUk extends AppLocalizations {
@override @override
String get source => 'Source: '; String get source => 'Source: ';
@override
String get uncompressed => 'Uncompressed';
} }

View File

@ -1549,4 +1549,7 @@ class AppLocalizationsVi extends AppLocalizations {
@override @override
String get source => 'Source: '; String get source => 'Source: ';
@override
String get uncompressed => 'Uncompressed';
} }

View File

@ -1503,6 +1503,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get source => 'Source: '; String get source => 'Source: ';
@override
String get uncompressed => 'Uncompressed';
} }
/// The translations for Chinese, as used in Taiwan (`zh_TW`). /// The translations for Chinese, as used in Taiwan (`zh_TW`).

View File

@ -41,14 +41,6 @@ enum YoutubeClientEngine {
} }
} }
enum MusicCodec {
m4a._("M4a (Best for downloaded music)"),
weba._("WebA (Best for streamed music)\nDoesn't support audio metadata");
final String label;
const MusicCodec._(this.label);
}
enum SearchMode { enum SearchMode {
youtube._("YouTube"), youtube._("YouTube"),
youtubeMusic._("YouTube Music"); youtubeMusic._("YouTube Music");

View File

@ -44,6 +44,11 @@ class SettingsPlaybackSection extends HookConsumerWidget {
title: Text(context.l10n.audio_quality), title: Text(context.l10n.audio_quality),
value: preferences.audioQuality, value: preferences.audioQuality,
options: [ options: [
if (preferences.audioSource == AudioSource.dabMusic)
SelectItemButton(
value: SourceQualities.uncompressed,
child: Text(context.l10n.uncompressed),
),
SelectItemButton( SelectItemButton(
value: SourceQualities.high, value: SourceQualities.high,
child: Text(context.l10n.high), child: Text(context.l10n.high),

View File

@ -101,10 +101,7 @@ class ServerPlaybackRoutes {
); );
final contentLengthRes = await Future<dio_lib.Response?>.value( final contentLengthRes = await Future<dio_lib.Response?>.value(
dio.head( dio.head(url, options: options),
url,
options: options,
),
).catchError((e, stack) async { ).catchError((e, stack) async {
AppLogger.reportError(e, stack); AppLogger.reportError(e, stack);
@ -135,6 +132,21 @@ class ServerPlaybackRoutes {
); );
} }
if (headers["range"] == "bytes=0-") {
final bufferSize =
userPreferences.audioQuality == SourceQualities.uncompressed
? 6 * 1024 * 1024
: 4 * 1024 * 1024;
final endRange = min(bufferSize,
int.parse(contentLengthRes?.headers.value("content-length") ?? "0"));
options = options.copyWith(
headers: {
...options.headers ?? {},
"range": "bytes=0-$endRange",
},
);
}
final res = await dio.get<Uint8List>(url, options: options); final res = await dio.get<Uint8List>(url, options: options);
final bytes = res.data; final bytes = res.data;
@ -166,7 +178,7 @@ class ServerPlaybackRoutes {
await trackPartialCacheFile.rename(trackCacheFile.path); await trackPartialCacheFile.rename(trackCacheFile.path);
} }
if (contentRange.total == fileLength && track.codec == SourceCodecs.m4a) { if (contentRange.total == fileLength && track.codec != SourceCodecs.weba) {
final playlistTrack = playlist.tracks.firstWhereOrNull( final playlistTrack = playlist.tracks.firstWhereOrNull(
(element) => element.id == track.query.id, (element) => element.id == track.query.id,
); );

View File

@ -54,6 +54,7 @@ class UserPreferencesNotifier extends Notifier<PreferencesTableData> {
} }
await audioPlayer.setAudioNormalization(state.normalizeAudio); await audioPlayer.setAudioNormalization(state.normalizeAudio);
await _updatePlayerBufferSize(event.audioQuality, state.audioQuality);
} catch (e, stack) { } catch (e, stack) {
AppLogger.reportError(e, stack); AppLogger.reportError(e, stack);
} }
@ -79,6 +80,24 @@ class UserPreferencesNotifier extends Notifier<PreferencesTableData> {
}); });
} }
/// Sets audio player's buffer size based on the selected audio quality
/// Uncompressed quality gets a larger buffer size for smoother playback
/// while other qualities use a standard buffer size.
Future<void> _updatePlayerBufferSize(
SourceQualities newQuality,
SourceQualities oldQuality,
) async {
if (newQuality == SourceQualities.uncompressed) {
audioPlayer.setDemuxerBufferSize(6 * 1024 * 1024); // 6MB
return;
}
if (oldQuality == SourceQualities.uncompressed &&
newQuality != SourceQualities.uncompressed) {
audioPlayer.setDemuxerBufferSize(4 * 1024 * 1024); // 4MB
}
}
Future<void> setData(PreferencesTableCompanion data) async { Future<void> setData(PreferencesTableCompanion data) async {
final db = ref.read(databaseProvider); final db = ref.read(databaseProvider);
@ -155,6 +174,7 @@ class UserPreferencesNotifier extends Notifier<PreferencesTableData> {
void setAudioQuality(SourceQualities quality) { void setAudioQuality(SourceQualities quality) {
setData(PreferencesTableCompanion(audioQuality: Value(quality))); setData(PreferencesTableCompanion(audioQuality: Value(quality)));
_updatePlayerBufferSize(quality, state.audioQuality);
} }
void setDownloadLocation(String downloadDir) { void setDownloadLocation(String downloadDir) {
@ -204,6 +224,11 @@ class UserPreferencesNotifier extends Notifier<PreferencesTableData> {
} }
void setAudioSource(AudioSource type) { void setAudioSource(AudioSource type) {
// Only allow uncompressed quality for DAB Music
if (type != AudioSource.dabMusic &&
state.audioQuality == SourceQualities.uncompressed) {
setAudioQuality(SourceQualities.high);
}
setData(PreferencesTableCompanion(audioSource: Value(type))); setData(PreferencesTableCompanion(audioSource: Value(type)));
} }

View File

@ -131,4 +131,8 @@ class SpotubeAudioPlayer extends AudioPlayerInterface
Future<void> setAudioNormalization(bool normalize) async { Future<void> setAudioNormalization(bool normalize) async {
await _mkPlayer.setAudioNormalization(normalize); await _mkPlayer.setAudioNormalization(normalize);
} }
Future<void> setDemuxerBufferSize(int sizeInBytes) async {
await _mkPlayer.setDemuxerBufferSize(sizeInBytes);
}
} }

View File

@ -133,4 +133,12 @@ class CustomPlayer extends Player {
await nativePlayer.setProperty('af', ''); await nativePlayer.setProperty('af', '');
} }
} }
Future<void> setDemuxerBufferSize(int sizeInBytes) async {
await nativePlayer.setProperty('demuxer-max-bytes', sizeInBytes.toString());
await nativePlayer.setProperty(
'demuxer-max-back-bytes',
sizeInBytes.toString(),
);
}
} }

View File

@ -3,13 +3,15 @@ import 'package:spotube/models/playback/track_sources.dart';
enum SourceCodecs { enum SourceCodecs {
m4a._("M4a (Best for downloaded music)"), m4a._("M4a (Best for downloaded music)"),
weba._("WebA (Best for streamed music)\nDoesn't support audio metadata"), weba._("WebA (Best for streamed music)\nDoesn't support audio metadata"),
mp3._("MP3 (Widely supported audio format)"); mp3._("MP3 (Widely supported audio format)"),
flac._("FLAC (Lossless, best quality)\nLarge file size");
final String label; final String label;
const SourceCodecs._(this.label); const SourceCodecs._(this.label);
} }
enum SourceQualities { enum SourceQualities {
uncompressed(3),
high(2), high(2),
medium(1), medium(1),
low(0); low(0);

View File

@ -212,7 +212,10 @@ abstract class SourcedTrack extends BasicSourcedTrack {
final preferences = ref.read(userPreferencesProvider); final preferences = ref.read(userPreferencesProvider);
return switch (preferences.audioSource) { return switch (preferences.audioSource) {
AudioSource.dabMusic => SourceCodecs.mp3, AudioSource.dabMusic =>
preferences.audioQuality == SourceQualities.uncompressed
? SourceCodecs.flac
: SourceCodecs.mp3,
AudioSource.jiosaavn => SourceCodecs.m4a, AudioSource.jiosaavn => SourceCodecs.m4a,
_ => preferences.streamMusicCodec _ => preferences.streamMusicCodec
}; };

View File

@ -56,9 +56,10 @@ class DABMusicSourcedTrack extends SourcedTrack {
SourceQualities quality, SourceQualities quality,
) async { ) async {
try { try {
final isUncompressed = quality == SourceQualities.uncompressed;
final streamResponse = await dabMusicApiClient.music.getStream( final streamResponse = await dabMusicApiClient.music.getStream(
trackId: id, trackId: id,
quality: "5", // mp3 320kbps (best available) quality: isUncompressed ? "27" : "5",
); );
if (streamResponse.url == null) { if (streamResponse.url == null) {
throw Exception("No stream URL found for track ID: $id"); throw Exception("No stream URL found for track ID: $id");
@ -66,9 +67,11 @@ class DABMusicSourcedTrack extends SourcedTrack {
return [ return [
TrackSource( TrackSource(
url: streamResponse.url!, url: streamResponse.url!,
quality: SourceQualities.high, quality: isUncompressed
bitrate: "320kbps", ? SourceQualities.uncompressed
codec: SourceCodecs.mp3, : SourceQualities.high,
bitrate: isUncompressed ? "2998kbps" : "320kbps",
codec: isUncompressed ? SourceCodecs.flac : SourceCodecs.mp3,
), ),
]; ];
} catch (e, stackTrace) { } catch (e, stackTrace) {
@ -126,7 +129,7 @@ class DABMusicSourcedTrack extends SourcedTrack {
if (results.isEmpty) { if (results.isEmpty) {
final res = await dabMusicApiClient.music.getSearch( final res = await dabMusicApiClient.music.getSearch(
q: SourcedTrack.getSearchTerm(query), q: SourcedTrack.getSearchTerm(query),
limit: 20, limit: 5,
); );
results = res.tracks ?? <Track>[]; results = res.tracks ?? <Track>[];
} }

View File

@ -1,118 +1,147 @@
{ {
"ar": [ "ar": [
"source" "source",
"uncompressed"
], ],
"bn": [ "bn": [
"source" "source",
"uncompressed"
], ],
"ca": [ "ca": [
"source" "source",
"uncompressed"
], ],
"cs": [ "cs": [
"source" "source",
"uncompressed"
], ],
"de": [ "de": [
"source" "source",
"uncompressed"
], ],
"es": [ "es": [
"source" "source",
"uncompressed"
], ],
"eu": [ "eu": [
"source" "source",
"uncompressed"
], ],
"fa": [ "fa": [
"source" "source",
"uncompressed"
], ],
"fi": [ "fi": [
"source" "source",
"uncompressed"
], ],
"fr": [ "fr": [
"source" "source",
"uncompressed"
], ],
"hi": [ "hi": [
"source" "source",
"uncompressed"
], ],
"id": [ "id": [
"source" "source",
"uncompressed"
], ],
"it": [ "it": [
"source" "source",
"uncompressed"
], ],
"ja": [ "ja": [
"source" "source",
"uncompressed"
], ],
"ka": [ "ka": [
"source" "source",
"uncompressed"
], ],
"ko": [ "ko": [
"source" "source",
"uncompressed"
], ],
"ne": [ "ne": [
"source" "source",
"uncompressed"
], ],
"nl": [ "nl": [
"audio_source", "audio_source",
"source" "source",
"uncompressed"
], ],
"pl": [ "pl": [
"source" "source",
"uncompressed"
], ],
"pt": [ "pt": [
"source" "source",
"uncompressed"
], ],
"ru": [ "ru": [
"source" "source",
"uncompressed"
], ],
"ta": [ "ta": [
"source" "source",
"uncompressed"
], ],
"th": [ "th": [
"source" "source",
"uncompressed"
], ],
"tl": [ "tl": [
"source" "source",
"uncompressed"
], ],
"tr": [ "tr": [
"source" "source",
"uncompressed"
], ],
"uk": [ "uk": [
"source" "source",
"uncompressed"
], ],
"vi": [ "vi": [
"source" "source",
"uncompressed"
], ],
"zh": [ "zh": [
"source" "source",
"uncompressed"
], ],
"zh_TW": [ "zh_TW": [
"source" "source",
"uncompressed"
] ]
} }