From e54762be6add6524ab614d103fc3557a101c75f4 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 28 Sep 2023 17:02:41 +0600 Subject: [PATCH] feat: customizable stream/download file formats (#757) * feat: add codec configuration in settings * fix: show no value for codec configuration in smaller screen * feat: implement configurable codec for download & streaming music --- lib/collections/spotube_icons.dart | 2 + lib/components/player/player_actions.dart | 1 - lib/l10n/app_en.arb | 4 +- lib/models/spotube_track.dart | 31 ++++++-- lib/pages/settings/settings.dart | 38 +++++++++ lib/provider/download_manager_provider.dart | 14 +++- .../proxy_playlist/next_fetcher_mixin.dart | 1 + .../proxy_playlist_provider.dart | 18 +++-- lib/provider/user_preferences_provider.dart | 77 ++++++++++++------- lib/services/youtube/youtube.dart | 34 +++++--- lib/utils/type_conversion_utils.dart | 10 +-- untranslated_messages.json | 52 +++++++++---- 12 files changed, 208 insertions(+), 74 deletions(-) diff --git a/lib/collections/spotube_icons.dart b/lib/collections/spotube_icons.dart index 8a4e63da3..c586375f1 100644 --- a/lib/collections/spotube_icons.dart +++ b/lib/collections/spotube_icons.dart @@ -98,4 +98,6 @@ abstract class SpotubeIcons { static const edit = FeatherIcons.edit; static const web = FeatherIcons.globe; static const amoled = FeatherIcons.sunset; + static const file = FeatherIcons.file; + static const stream = Icons.stream_rounded; } diff --git a/lib/components/player/player_actions.dart b/lib/components/player/player_actions.dart index 78fb53b7d..b3a1e3408 100644 --- a/lib/components/player/player_actions.dart +++ b/lib/components/player/player_actions.dart @@ -13,7 +13,6 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/duration.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/models/logger.dart'; -import 'package:spotube/models/spotube_track.dart'; import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 50c4a1399..c37f93b10 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -268,5 +268,7 @@ "normalize_audio": "Normalize audio", "change_cover": "Change cover", "add_cover": "Add cover", - "restore_defaults": "Restore defaults" + "restore_defaults": "Restore defaults", + "download_music_codec": "Download music codec", + "streaming_music_codec": "Streaming music codec" } \ No newline at end of file diff --git a/lib/models/spotube_track.dart b/lib/models/spotube_track.dart index a0c5f1320..a8b94ef5c 100644 --- a/lib/models/spotube_track.dart +++ b/lib/models/spotube_track.dart @@ -23,6 +23,7 @@ class TrackNotFoundException implements Exception { class SpotubeTrack extends Track { final YoutubeVideoInfo ytTrack; final String ytUri; + final MusicCodec codec; final List siblings; @@ -30,6 +31,7 @@ class SpotubeTrack extends Track { this.ytTrack, this.ytUri, this.siblings, + this.codec, ) : super(); SpotubeTrack.fromTrack({ @@ -37,6 +39,7 @@ class SpotubeTrack extends Track { required this.ytTrack, required this.ytUri, required this.siblings, + required this.codec, }) : super() { album = track.album; artists = track.artists; @@ -149,6 +152,7 @@ class SpotubeTrack extends Track { static Future fetchFromTrack( Track track, YoutubeEndpoints client, + MusicCodec codec, ) async { final matchedCachedTrack = await MatchedTrack.box.get(track.id!); var siblings = []; @@ -157,16 +161,17 @@ class SpotubeTrack extends Track { if (matchedCachedTrack != null && matchedCachedTrack.searchMode == client.preferences.searchMode) { (ytVideo, ytStreamUrl) = await client.video( - matchedCachedTrack.youtubeId, - matchedCachedTrack.searchMode, - ); + matchedCachedTrack.youtubeId, matchedCachedTrack.searchMode, codec); } else { siblings = await fetchSiblings(track, client); if (siblings.isEmpty) { throw TrackNotFoundException(track); } - (ytVideo, ytStreamUrl) = - await client.video(siblings.first.id, siblings.first.searchMode); + (ytVideo, ytStreamUrl) = await client.video( + siblings.first.id, + siblings.first.searchMode, + codec, + ); await MatchedTrack.box.put( track.id!, @@ -183,6 +188,7 @@ class SpotubeTrack extends Track { ytTrack: ytVideo, ytUri: ytStreamUrl, siblings: siblings, + codec: codec, ); } @@ -193,8 +199,12 @@ class SpotubeTrack extends Track { // sibling tracks that were manually searched and swapped final isStepSibling = siblings.none((element) => element.id == video.id); - final (ytVideo, ytStreamUrl) = - await client.video(video.id, siblings.first.searchMode); + final (ytVideo, ytStreamUrl) = await client.video( + video.id, + siblings.first.searchMode, + // siblings are always swapped when streaming + client.preferences.streamMusicCodec, + ); if (!isStepSibling) { await MatchedTrack.box.put( @@ -215,6 +225,7 @@ class SpotubeTrack extends Track { video, ...siblings.where((element) => element.id != video.id), ], + codec: client.preferences.streamMusicCodec, ); } @@ -226,6 +237,10 @@ class SpotubeTrack extends Track { siblings: List.castFrom>(map["siblings"]) .map((sibling) => YoutubeVideoInfo.fromJson(sibling)) .toList(), + codec: MusicCodec.values.firstWhere( + (element) => element.name == map["codec"], + orElse: () => MusicCodec.m4a, + ), ); } @@ -242,6 +257,7 @@ class SpotubeTrack extends Track { ytTrack: ytTrack, ytUri: ytUri, siblings: siblings, + codec: codec, ); } @@ -268,6 +284,7 @@ class SpotubeTrack extends Track { "ytTrack": ytTrack.toJson(), "ytUri": ytUri, "siblings": siblings.map((sibling) => sibling.toJson()).toList(), + "codec": codec.name, }; } } diff --git a/lib/pages/settings/settings.dart b/lib/pages/settings/settings.dart index f4676a240..f537a3a9c 100644 --- a/lib/pages/settings/settings.dart +++ b/lib/pages/settings/settings.dart @@ -459,6 +459,44 @@ class SettingsPage extends HookConsumerWidget { value: preferences.normalizeAudio, onChanged: preferences.setNormalizeAudio, ), + AdaptiveSelectTile( + secondary: const Icon(SpotubeIcons.stream), + title: Text(context.l10n.streaming_music_codec), + value: preferences.streamMusicCodec, + showValueWhenUnfolded: false, + options: MusicCodec.values + .map((e) => DropdownMenuItem( + value: e, + child: Text( + e.label, + style: theme.textTheme.labelMedium, + ), + )) + .toList(), + onChanged: (value) { + if (value == null) return; + preferences.setStreamMusicCodec(value); + }, + ), + AdaptiveSelectTile( + secondary: const Icon(SpotubeIcons.file), + title: Text(context.l10n.download_music_codec), + value: preferences.downloadMusicCodec, + showValueWhenUnfolded: false, + options: MusicCodec.values + .map((e) => DropdownMenuItem( + value: e, + child: Text( + e.label, + style: theme.textTheme.labelMedium, + ), + )) + .toList(), + onChanged: (value) { + if (value == null) return; + preferences.setDownloadMusicCodec(value); + }, + ), ], ), SectionCardWithHeading( diff --git a/lib/provider/download_manager_provider.dart b/lib/provider/download_manager_provider.dart index 78c072708..bf92e9e09 100644 --- a/lib/provider/download_manager_provider.dart +++ b/lib/provider/download_manager_provider.dart @@ -40,7 +40,11 @@ class DownloadManagerProvider extends ChangeNotifier { await oldFile.exists()) { await oldFile.rename(savePath); } - if (status != DownloadStatus.completed) return; + if (status != DownloadStatus.completed || + //? WebA audiotagging is not supported yet + //? Although in future by converting weba to opus & then tagging it + //? is possible using vorbis comments + downloadCodec == MusicCodec.weba) return; final file = File(request.path); @@ -89,6 +93,8 @@ class DownloadManagerProvider extends ChangeNotifier { YoutubeEndpoints get yt => ref.read(youtubeProvider); String get downloadDirectory => ref.read(userPreferencesProvider.select((s) => s.downloadLocation)); + MusicCodec get downloadCodec => + ref.read(userPreferencesProvider.select((s) => s.downloadMusicCodec)); int get $downloadCount => dl .getAllDownloads() @@ -130,7 +136,7 @@ class DownloadManagerProvider extends ChangeNotifier { String getTrackFileUrl(Track track) { final name = - "${track.name} - ${TypeConversionUtils.artists_X_String(track.artists ?? [])}.m4a"; + "${track.name} - ${TypeConversionUtils.artists_X_String(track.artists ?? [])}.${downloadCodec.name}"; return join(downloadDirectory, PrimitiveUtils.toSafeFileName(name)); } @@ -166,7 +172,7 @@ class DownloadManagerProvider extends ChangeNotifier { await oldFile.rename("$savePath.old"); } - if (track is SpotubeTrack) { + if (track is SpotubeTrack && track.codec == downloadCodec) { final downloadTask = await dl.addDownload(track.ytUri, savePath); if (downloadTask != null) { $history.add(track); @@ -174,7 +180,7 @@ class DownloadManagerProvider extends ChangeNotifier { } else { $backHistory.add(track); final spotubeTrack = - await SpotubeTrack.fetchFromTrack(track, yt).then((d) { + await SpotubeTrack.fetchFromTrack(track, yt, downloadCodec).then((d) { $backHistory.remove(track); return d; }); diff --git a/lib/provider/proxy_playlist/next_fetcher_mixin.dart b/lib/provider/proxy_playlist/next_fetcher_mixin.dart index fce006b08..2f60dcd41 100644 --- a/lib/provider/proxy_playlist/next_fetcher_mixin.dart +++ b/lib/provider/proxy_playlist/next_fetcher_mixin.dart @@ -30,6 +30,7 @@ mixin NextFetcher on StateNotifier { final future = SpotubeTrack.fetchFromTrack( track, youtube, + preferences.streamMusicCodec, ); if (i == 0) { return await future; diff --git a/lib/provider/proxy_playlist/proxy_playlist_provider.dart b/lib/provider/proxy_playlist/proxy_playlist_provider.dart index 69464199b..be01978ed 100644 --- a/lib/provider/proxy_playlist/proxy_playlist_provider.dart +++ b/lib/provider/proxy_playlist/proxy_playlist_provider.dart @@ -185,10 +185,12 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier ), ); } catch (e) { - currentSegments.value = ( - source: audioPlayer.currentSource!, - segments: [], - ); + if (audioPlayer.currentSource != null) { + currentSegments.value = ( + source: audioPlayer.currentSource!, + segments: [], + ); + } } finally { isFetchingSegments.value = false; } @@ -223,7 +225,11 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier final nthFetchedTrack = switch (track.runtimeType) { SpotubeTrack => track as SpotubeTrack, - _ => await SpotubeTrack.fetchFromTrack(track, youtube), + _ => await SpotubeTrack.fetchFromTrack( + track, + youtube, + preferences.streamMusicCodec, + ), }; await audioPlayer.replaceSource( @@ -309,10 +315,12 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier final addableTrack = await SpotubeTrack.fetchFromTrack( tracks.elementAtOrNull(initialIndex) ?? tracks.first, youtube, + preferences.streamMusicCodec, ).catchError((e, stackTrace) { return SpotubeTrack.fetchFromTrack( tracks.elementAtOrNull(initialIndex + 1) ?? tracks.first, youtube, + preferences.streamMusicCodec, ); }); diff --git a/lib/provider/user_preferences_provider.dart b/lib/provider/user_preferences_provider.dart index 7abbad408..2da8e8dd8 100644 --- a/lib/provider/user_preferences_provider.dart +++ b/lib/provider/user_preferences_provider.dart @@ -40,39 +40,36 @@ enum YoutubeApiType { String get label => name[0].toUpperCase() + name.substring(1); } +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); +} + class UserPreferences extends PersistedChangeNotifier { - ThemeMode themeMode; - Market recommendationMarket; - bool saveTrackLyrics; - bool checkUpdate; AudioQuality audioQuality; - - late SpotubeColor accentColorScheme; bool albumColorSync; - - String downloadLocation; - - LayoutMode layoutMode; - - CloseBehavior closeBehavior; - + bool amoledDarkTheme; + bool checkUpdate; + bool normalizeAudio; + bool saveTrackLyrics; bool showSystemTrayIcon; - + bool skipNonMusic; + bool systemTitleBar; + CloseBehavior closeBehavior; + late SpotubeColor accentColorScheme; + LayoutMode layoutMode; Locale locale; - - String pipedInstance; - + Market recommendationMarket; SearchMode searchMode; - - bool skipNonMusic; - + String downloadLocation; + String pipedInstance; + ThemeMode themeMode; YoutubeApiType youtubeApiType; - - bool systemTitleBar; - - bool amoledDarkTheme; - - bool normalizeAudio; + MusicCodec streamMusicCodec; + MusicCodec downloadMusicCodec; final Ref ref; @@ -96,6 +93,8 @@ class UserPreferences extends PersistedChangeNotifier { this.systemTitleBar = false, this.amoledDarkTheme = false, this.normalizeAudio = true, + this.streamMusicCodec = MusicCodec.weba, + this.downloadMusicCodec = MusicCodec.m4a, SpotubeColor? accentColorScheme, }) : super() { this.accentColorScheme = @@ -129,6 +128,20 @@ class UserPreferences extends PersistedChangeNotifier { setAmoledDarkTheme(false); setNormalizeAudio(true); setAccentColorScheme(SpotubeColor(Colors.blue.value, name: "Blue")); + setStreamMusicCodec(MusicCodec.weba); + setDownloadMusicCodec(MusicCodec.m4a); + } + + void setStreamMusicCodec(MusicCodec codec) { + streamMusicCodec = codec; + notifyListeners(); + updatePersistence(); + } + + void setDownloadMusicCodec(MusicCodec codec) { + downloadMusicCodec = codec; + notifyListeners(); + updatePersistence(); } void setThemeMode(ThemeMode mode) { @@ -327,6 +340,16 @@ class UserPreferences extends PersistedChangeNotifier { normalizeAudio = map["normalizeAudio"] ?? normalizeAudio; audioPlayer.setAudioNormalization(normalizeAudio); + + streamMusicCodec = MusicCodec.values.firstWhere( + (codec) => codec.name == map["streamMusicCodec"], + orElse: () => MusicCodec.weba, + ); + + downloadMusicCodec = MusicCodec.values.firstWhere( + (codec) => codec.name == map["downloadMusicCodec"], + orElse: () => MusicCodec.m4a, + ); } @override @@ -352,6 +375,8 @@ class UserPreferences extends PersistedChangeNotifier { 'systemTitleBar': systemTitleBar, "amoledDarkTheme": amoledDarkTheme, "normalizeAudio": normalizeAudio, + "streamMusicCodec": streamMusicCodec.name, + "downloadMusicCodec": downloadMusicCodec.name, }; } diff --git a/lib/services/youtube/youtube.dart b/lib/services/youtube/youtube.dart index fbf559d46..c8c277e3c 100644 --- a/lib/services/youtube/youtube.dart +++ b/lib/services/youtube/youtube.dart @@ -181,24 +181,33 @@ class YoutubeEndpoints { } } - String _pipedStreamResponseToStreamUrl(PipedStreamResponse stream) { + String _pipedStreamResponseToStreamUrl( + PipedStreamResponse stream, + MusicCodec codec, + ) { + final pipedStreamFormat = switch (codec) { + MusicCodec.m4a => PipedAudioStreamFormat.m4a, + MusicCodec.weba => PipedAudioStreamFormat.webm, + }; + return switch (preferences.audioQuality) { - AudioQuality.high => stream - .highestBitrateAudioStreamOfFormat(PipedAudioStreamFormat.m4a)! - .url, - AudioQuality.low => stream - .lowestBitrateAudioStreamOfFormat(PipedAudioStreamFormat.m4a)! - .url, + AudioQuality.high => + stream.highestBitrateAudioStreamOfFormat(pipedStreamFormat)!.url, + AudioQuality.low => + stream.lowestBitrateAudioStreamOfFormat(pipedStreamFormat)!.url, }; } - Future streamingUrl(String id) async { + Future streamingUrl(String id, MusicCodec codec) async { if (youtube != null) { final res = await PrimitiveUtils.raceMultiple( () => youtube!.videos.streams.getManifest(id), ); final audioOnlyManifests = res.audioOnly.where((info) { - return info.codec.mimeType == "audio/mp4"; + return switch (codec) { + MusicCodec.m4a => info.codec.mimeType == "audio/mp4", + MusicCodec.weba => info.codec.mimeType == "audio/webm", + }; }); return switch (preferences.audioQuality) { @@ -208,26 +217,27 @@ class YoutubeEndpoints { audioOnlyManifests.sortByBitrate().last.url.toString(), }; } else { - return _pipedStreamResponseToStreamUrl(await piped!.streams(id)); + return _pipedStreamResponseToStreamUrl(await piped!.streams(id), codec); } } Future<(YoutubeVideoInfo info, String streamingUrl)> video( String id, SearchMode searchMode, + MusicCodec codec, ) async { if (youtube != null) { final res = await youtube!.videos.get(id); return ( YoutubeVideoInfo.fromVideo(res), - await streamingUrl(id), + await streamingUrl(id, codec), ); } else { try { final res = await piped!.streams(id); return ( YoutubeVideoInfo.fromStreamResponse(res, searchMode), - _pipedStreamResponseToStreamUrl(res), + _pipedStreamResponseToStreamUrl(res, codec), ); } on Exception catch (e) { await showPipedErrorDialog(e); diff --git a/lib/utils/type_conversion_utils.dart b/lib/utils/type_conversion_utils.dart index 68a8d9a43..a805272ca 100644 --- a/lib/utils/type_conversion_utils.dart +++ b/lib/utils/type_conversion_utils.dart @@ -126,21 +126,21 @@ abstract class TypeConversionUtils { }) { final track = Track(); track.album = Album() - ..name = metadata?.album ?? "Spotube" + ..name = metadata?.album ?? "Unknown" ..images = [if (art != null) Image()..url = art] ..genres = [if (metadata?.genre != null) metadata!.genre!] ..artists = [ Artist() - ..name = metadata?.albumArtist ?? "Spotube" - ..id = metadata?.albumArtist ?? "Spotube" + ..name = metadata?.albumArtist ?? "Unknown" + ..id = metadata?.albumArtist ?? "Unknown" ..type = "artist", ] ..id = metadata?.album ..releaseDate = metadata?.year?.toString(); track.artists = [ Artist() - ..name = metadata?.artist ?? "Spotube" - ..id = metadata?.artist ?? "Spotube" + ..name = metadata?.artist ?? "Unknown" + ..id = metadata?.artist ?? "Unknown" ]; track.id = metadata?.title ?? basenameWithoutExtension(file.path); diff --git a/untranslated_messages.json b/untranslated_messages.json index 0a7cf4f55..8e0df973d 100644 --- a/untranslated_messages.json +++ b/untranslated_messages.json @@ -4,7 +4,9 @@ "normalize_audio", "change_cover", "add_cover", - "restore_defaults" + "restore_defaults", + "download_music_codec", + "streaming_music_codec" ], "bn": [ @@ -12,7 +14,9 @@ "normalize_audio", "change_cover", "add_cover", - "restore_defaults" + "restore_defaults", + "download_music_codec", + "streaming_music_codec" ], "ca": [ @@ -20,7 +24,9 @@ "normalize_audio", "change_cover", "add_cover", - "restore_defaults" + "restore_defaults", + "download_music_codec", + "streaming_music_codec" ], "de": [ @@ -28,7 +34,9 @@ "normalize_audio", "change_cover", "add_cover", - "restore_defaults" + "restore_defaults", + "download_music_codec", + "streaming_music_codec" ], "es": [ @@ -36,7 +44,9 @@ "normalize_audio", "change_cover", "add_cover", - "restore_defaults" + "restore_defaults", + "download_music_codec", + "streaming_music_codec" ], "fr": [ @@ -44,7 +54,9 @@ "normalize_audio", "change_cover", "add_cover", - "restore_defaults" + "restore_defaults", + "download_music_codec", + "streaming_music_codec" ], "hi": [ @@ -52,7 +64,9 @@ "normalize_audio", "change_cover", "add_cover", - "restore_defaults" + "restore_defaults", + "download_music_codec", + "streaming_music_codec" ], "ja": [ @@ -60,7 +74,9 @@ "normalize_audio", "change_cover", "add_cover", - "restore_defaults" + "restore_defaults", + "download_music_codec", + "streaming_music_codec" ], "pl": [ @@ -68,7 +84,9 @@ "normalize_audio", "change_cover", "add_cover", - "restore_defaults" + "restore_defaults", + "download_music_codec", + "streaming_music_codec" ], "pt": [ @@ -76,7 +94,9 @@ "normalize_audio", "change_cover", "add_cover", - "restore_defaults" + "restore_defaults", + "download_music_codec", + "streaming_music_codec" ], "ru": [ @@ -84,7 +104,9 @@ "normalize_audio", "change_cover", "add_cover", - "restore_defaults" + "restore_defaults", + "download_music_codec", + "streaming_music_codec" ], "uk": [ @@ -92,7 +114,9 @@ "normalize_audio", "change_cover", "add_cover", - "restore_defaults" + "restore_defaults", + "download_music_codec", + "streaming_music_codec" ], "zh": [ @@ -100,6 +124,8 @@ "normalize_audio", "change_cover", "add_cover", - "restore_defaults" + "restore_defaults", + "download_music_codec", + "streaming_music_codec" ] }