diff --git a/lib/pages/settings/sections/playback.dart b/lib/pages/settings/sections/playback.dart index a61d14ae..049b115e 100644 --- a/lib/pages/settings/sections/playback.dart +++ b/lib/pages/settings/sections/playback.dart @@ -59,12 +59,12 @@ class SettingsPlaybackSection extends HookConsumerWidget { .toList(), onChanged: (value) { if (value == null) return; - preferences.setYoutubeApiType(value); + preferences.setAudioSource(value); }, ), AnimatedSwitcher( duration: const Duration(milliseconds: 300), - child: preferences.audioSource == AudioSource.youtube + child: preferences.audioSource != AudioSource.piped ? const SizedBox.shrink() : Consumer(builder: (context, ref, child) { final instanceList = ref.watch(pipedInstancesFutureProvider); @@ -131,7 +131,7 @@ class SettingsPlaybackSection extends HookConsumerWidget { ), AnimatedSwitcher( duration: const Duration(milliseconds: 300), - child: preferences.audioSource == AudioSource.youtube + child: preferences.audioSource != AudioSource.piped ? const SizedBox.shrink() : AdaptiveSelectTile( secondary: const Icon(SpotubeIcons.search), @@ -151,17 +151,18 @@ class SettingsPlaybackSection extends HookConsumerWidget { ), AnimatedSwitcher( duration: const Duration(milliseconds: 300), - child: preferences.searchMode == SearchMode.youtubeMusic && - preferences.audioSource == AudioSource.piped - ? const SizedBox.shrink() - : SwitchListTile( + child: preferences.searchMode == SearchMode.youtube && + (preferences.audioSource == AudioSource.piped || + preferences.audioSource == AudioSource.youtube) + ? SwitchListTile( secondary: const Icon(SpotubeIcons.skip), title: Text(context.l10n.skip_non_music), value: preferences.skipNonMusic, onChanged: (state) { preferences.setSkipNonMusic(state); }, - ), + ) + : const SizedBox.shrink(), ), ListTile( leading: const Icon(SpotubeIcons.playlistRemove), @@ -178,44 +179,46 @@ class SettingsPlaybackSection 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); - }, - ), + if (preferences.audioSource != AudioSource.jiosaavn) + AdaptiveSelectTile( + secondary: const Icon(SpotubeIcons.stream), + title: Text(context.l10n.streaming_music_codec), + value: preferences.streamMusicCodec, + showValueWhenUnfolded: false, + options: SourceCodecs.values + .map((e) => DropdownMenuItem( + value: e, + child: Text( + e.label, + style: theme.textTheme.labelMedium, + ), + )) + .toList(), + onChanged: (value) { + if (value == null) return; + preferences.setStreamMusicCodec(value); + }, + ), + if (preferences.audioSource != AudioSource.jiosaavn) + AdaptiveSelectTile( + secondary: const Icon(SpotubeIcons.file), + title: Text(context.l10n.download_music_codec), + value: preferences.downloadMusicCodec, + showValueWhenUnfolded: false, + options: SourceCodecs.values + .map((e) => DropdownMenuItem( + value: e, + child: Text( + e.label, + style: theme.textTheme.labelMedium, + ), + )) + .toList(), + onChanged: (value) { + if (value == null) return; + preferences.setDownloadMusicCodec(value); + }, + ), ], ); } diff --git a/lib/provider/download_manager_provider.dart b/lib/provider/download_manager_provider.dart index c50c649f..246ee02c 100644 --- a/lib/provider/download_manager_provider.dart +++ b/lib/provider/download_manager_provider.dart @@ -11,6 +11,7 @@ import 'package:path/path.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/provider/user_preferences_provider.dart'; import 'package:spotube/services/download_manager/download_manager.dart'; +import 'package:spotube/services/sourced_track/enums.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; import 'package:spotube/utils/primitive_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; @@ -42,7 +43,7 @@ class DownloadManagerProvider extends ChangeNotifier { //? 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; + downloadCodec == SourceCodecs.weba) return; final file = File(request.path); @@ -90,7 +91,7 @@ class DownloadManagerProvider extends ChangeNotifier { String get downloadDirectory => ref.read(userPreferencesProvider.select((s) => s.downloadLocation)); - MusicCodec get downloadCodec => + SourceCodecs get downloadCodec => ref.read(userPreferencesProvider.select((s) => s.downloadMusicCodec)); int get $downloadCount => dl diff --git a/lib/provider/proxy_playlist/proxy_playlist_provider.dart b/lib/provider/proxy_playlist/proxy_playlist_provider.dart index b6f1c89e..02ff65fb 100644 --- a/lib/provider/proxy_playlist/proxy_playlist_provider.dart +++ b/lib/provider/proxy_playlist/proxy_playlist_provider.dart @@ -171,10 +171,11 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier return; } try { - final isYTMusicMode = preferences.audioSource == AudioSource.piped && - preferences.searchMode == SearchMode.youtubeMusic; + final isNotYTMode = preferences.audioSource != AudioSource.youtube || + (preferences.audioSource == AudioSource.piped && + preferences.searchMode == SearchMode.youtubeMusic); - if (isYTMusicMode || !preferences.skipNonMusic) return; + if (isNotYTMode || !preferences.skipNonMusic) return; final isNotSameSegmentId = currentSegments.value?.source != audioPlayer.currentSource; diff --git a/lib/provider/user_preferences_provider.dart b/lib/provider/user_preferences_provider.dart index 93bb7f65..e382d9d6 100644 --- a/lib/provider/user_preferences_provider.dart +++ b/lib/provider/user_preferences_provider.dart @@ -37,14 +37,6 @@ enum AudioSource { 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); -} - enum SearchMode { youtube, youtubeMusic; @@ -91,8 +83,8 @@ class UserPreferences extends PersistedChangeNotifier { String pipedInstance; ThemeMode themeMode; AudioSource audioSource; - MusicCodec streamMusicCodec; - MusicCodec downloadMusicCodec; + SourceCodecs streamMusicCodec; + SourceCodecs downloadMusicCodec; final Ref ref; @@ -115,8 +107,8 @@ class UserPreferences extends PersistedChangeNotifier { this.systemTitleBar = false, this.amoledDarkTheme = false, this.normalizeAudio = true, - this.streamMusicCodec = MusicCodec.weba, - this.downloadMusicCodec = MusicCodec.m4a, + this.streamMusicCodec = SourceCodecs.weba, + this.downloadMusicCodec = SourceCodecs.m4a, SpotubeColor? accentColorScheme, }) : super() { this.accentColorScheme = @@ -144,22 +136,22 @@ class UserPreferences extends PersistedChangeNotifier { setPipedInstance("https://pipedapi.kavin.rocks"); setSearchMode(SearchMode.youtube); setSkipNonMusic(true); - setYoutubeApiType(AudioSource.youtube); + setAudioSource(AudioSource.youtube); setSystemTitleBar(false); setAmoledDarkTheme(false); setNormalizeAudio(true); setAccentColorScheme(SpotubeColor(Colors.blue.value, name: "Blue")); - setStreamMusicCodec(MusicCodec.weba); - setDownloadMusicCodec(MusicCodec.m4a); + setStreamMusicCodec(SourceCodecs.weba); + setDownloadMusicCodec(SourceCodecs.m4a); } - void setStreamMusicCodec(MusicCodec codec) { + void setStreamMusicCodec(SourceCodecs codec) { streamMusicCodec = codec; notifyListeners(); updatePersistence(); } - void setDownloadMusicCodec(MusicCodec codec) { + void setDownloadMusicCodec(SourceCodecs codec) { downloadMusicCodec = codec; notifyListeners(); updatePersistence(); @@ -255,7 +247,7 @@ class UserPreferences extends PersistedChangeNotifier { updatePersistence(); } - void setYoutubeApiType(AudioSource type) { + void setAudioSource(AudioSource type) { audioSource = type; notifyListeners(); updatePersistence(); @@ -358,14 +350,14 @@ class UserPreferences extends PersistedChangeNotifier { normalizeAudio = map["normalizeAudio"] ?? normalizeAudio; audioPlayer.setAudioNormalization(normalizeAudio); - streamMusicCodec = MusicCodec.values.firstWhere( + streamMusicCodec = SourceCodecs.values.firstWhere( (codec) => codec.name == map["streamMusicCodec"], - orElse: () => MusicCodec.weba, + orElse: () => SourceCodecs.weba, ); - downloadMusicCodec = MusicCodec.values.firstWhere( + downloadMusicCodec = SourceCodecs.values.firstWhere( (codec) => codec.name == map["downloadMusicCodec"], - orElse: () => MusicCodec.m4a, + orElse: () => SourceCodecs.m4a, ); } diff --git a/lib/services/sourced_track/enums.dart b/lib/services/sourced_track/enums.dart index ac27a18d..48ce1cbd 100644 --- a/lib/services/sourced_track/enums.dart +++ b/lib/services/sourced_track/enums.dart @@ -2,9 +2,11 @@ import 'package:spotube/services/sourced_track/models/source_info.dart'; import 'package:spotube/services/sourced_track/models/source_map.dart'; enum SourceCodecs { - mp4, - weba, - m4a, + m4a._("M4a (Best for downloaded music)"), + weba._("WebA (Best for streamed music)\nDoesn't support audio metadata"); + + final String label; + const SourceCodecs._(this.label); } enum SourceQualities { diff --git a/lib/services/sourced_track/models/source_map.dart b/lib/services/sourced_track/models/source_map.dart index a1748208..f99f95e4 100644 --- a/lib/services/sourced_track/models/source_map.dart +++ b/lib/services/sourced_track/models/source_map.dart @@ -34,12 +34,10 @@ class SourceQualityMap { @JsonSerializable() class SourceMap { - final SourceQualityMap? mp4; final SourceQualityMap? weba; final SourceQualityMap? m4a; const SourceMap({ - this.mp4, this.weba, this.m4a, }); @@ -51,8 +49,6 @@ class SourceMap { operator [](SourceCodecs key) { switch (key) { - case SourceCodecs.mp4: - return mp4; case SourceCodecs.weba: return weba; case SourceCodecs.m4a: diff --git a/lib/services/sourced_track/models/source_map.g.dart b/lib/services/sourced_track/models/source_map.g.dart index 94236ca1..e1085aa8 100644 --- a/lib/services/sourced_track/models/source_map.g.dart +++ b/lib/services/sourced_track/models/source_map.g.dart @@ -21,9 +21,6 @@ Map _$SourceQualityMapToJson(SourceQualityMap instance) => }; SourceMap _$SourceMapFromJson(Map json) => SourceMap( - mp4: json['mp4'] == null - ? null - : SourceQualityMap.fromJson(json['mp4'] as Map), weba: json['weba'] == null ? null : SourceQualityMap.fromJson(json['weba'] as Map), @@ -33,7 +30,6 @@ SourceMap _$SourceMapFromJson(Map json) => SourceMap( ); Map _$SourceMapToJson(SourceMap instance) => { - 'mp4': instance.mp4, 'weba': instance.weba, 'm4a': instance.m4a, }; diff --git a/lib/services/sourced_track/sourced_track.dart b/lib/services/sourced_track/sourced_track.dart index 48fe943f..adebf794 100644 --- a/lib/services/sourced_track/sourced_track.dart +++ b/lib/services/sourced_track/sourced_track.dart @@ -5,6 +5,7 @@ import 'package:spotube/provider/user_preferences_provider.dart'; import 'package:spotube/services/sourced_track/enums.dart'; import 'package:spotube/services/sourced_track/models/source_info.dart'; import 'package:spotube/services/sourced_track/models/source_map.dart'; +import 'package:spotube/services/sourced_track/sources/jiosaavn.dart'; import 'package:spotube/services/sourced_track/sources/piped.dart'; import 'package:spotube/services/sourced_track/sources/youtube.dart'; import 'package:spotube/utils/service_utils.dart'; @@ -60,14 +61,22 @@ abstract class SourcedTrack extends Track { source: source, siblings: siblings, sourceInfo: sourceInfo, - track: track), + track: track, + ), AudioSource.piped => PipedSourcedTrack( ref: ref, source: source, siblings: siblings, sourceInfo: sourceInfo, - track: track), - AudioSource.jiosaavn => throw UnimplementedError(), + track: track, + ), + AudioSource.jiosaavn => JioSaavnSourcedTrack( + ref: ref, + source: source, + siblings: siblings, + sourceInfo: sourceInfo, + track: track, + ), }; } @@ -90,16 +99,22 @@ abstract class SourcedTrack extends Track { static Future fetchFromTrack({ required Track track, required Ref ref, - }) { - final preferences = ref.read(userPreferencesProvider); + }) async { + try { + final preferences = ref.read(userPreferencesProvider); - return switch (preferences.audioSource) { - AudioSource.piped => - PipedSourcedTrack.fetchFromTrack(track: track, ref: ref), - AudioSource.youtube => - YoutubeSourcedTrack.fetchFromTrack(track: track, ref: ref), - AudioSource.jiosaavn => throw UnimplementedError(), - }; + return switch (preferences.audioSource) { + AudioSource.piped => + await PipedSourcedTrack.fetchFromTrack(track: track, ref: ref), + AudioSource.youtube => + await YoutubeSourcedTrack.fetchFromTrack(track: track, ref: ref), + AudioSource.jiosaavn => + await JioSaavnSourcedTrack.fetchFromTrack(track: track, ref: ref), + }; + } catch (e) { + print("Got error: $e"); + return YoutubeSourcedTrack.fetchFromTrack(track: track, ref: ref); + } } static Future> fetchSiblings({ @@ -113,7 +128,8 @@ abstract class SourcedTrack extends Track { PipedSourcedTrack.fetchSiblings(track: track, ref: ref), AudioSource.youtube => YoutubeSourcedTrack.fetchSiblings(track: track, ref: ref), - AudioSource.jiosaavn => throw UnimplementedError(), + AudioSource.jiosaavn => + JioSaavnSourcedTrack.fetchSiblings(track: track, ref: ref), }; } @@ -129,28 +145,26 @@ abstract class SourcedTrack extends Track { final preferences = ref.read(userPreferencesProvider); final codec = preferences.audioSource == AudioSource.jiosaavn - ? SourceCodecs.mp4 - : switch (preferences.streamMusicCodec) { - MusicCodec.m4a => SourceCodecs.m4a, - MusicCodec.weba => SourceCodecs.weba, - }; + ? SourceCodecs.m4a + : preferences.streamMusicCodec; - return source[codec]![preferences.audioQuality]!; + return getUrlOfCodec(codec); } - String getUrlOfCodec(MusicCodec codec) { + String getUrlOfCodec(SourceCodecs codec) { final preferences = ref.read(userPreferencesProvider); - return source[codec == MusicCodec.m4a - ? SourceCodecs.m4a - : SourceCodecs.weba]![preferences.audioQuality]!; + return source[codec]?[preferences.audioQuality] ?? + // this will ensure playback doesn't break + source[codec == SourceCodecs.m4a ? SourceCodecs.weba : SourceCodecs.m4a] + [preferences.audioQuality]; } - MusicCodec get codec { + SourceCodecs get codec { final preferences = ref.read(userPreferencesProvider); return preferences.audioSource == AudioSource.jiosaavn - ? MusicCodec.m4a + ? SourceCodecs.m4a : preferences.streamMusicCodec; } } diff --git a/lib/services/sourced_track/sources/jiosaavn.dart b/lib/services/sourced_track/sources/jiosaavn.dart new file mode 100644 index 00000000..b25eca3b --- /dev/null +++ b/lib/services/sourced_track/sources/jiosaavn.dart @@ -0,0 +1,159 @@ +import 'package:collection/collection.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/models/source_match.dart'; +import 'package:spotube/services/sourced_track/enums.dart'; +import 'package:spotube/services/sourced_track/exceptions.dart'; +import 'package:spotube/services/sourced_track/models/source_info.dart'; +import 'package:spotube/services/sourced_track/models/source_map.dart'; +import 'package:spotube/services/sourced_track/sourced_track.dart'; +import 'package:jiosaavn/jiosaavn.dart'; + +final jiosaavnClient = JioSaavnClient(); + +class JioSaavnSourcedTrack extends SourcedTrack { + JioSaavnSourcedTrack({ + required super.ref, + required super.source, + required super.siblings, + required super.sourceInfo, + required super.track, + }); + + static Future fetchFromTrack({ + required Track track, + required Ref ref, + }) async { + final cachedSource = await SourceMatch.box.get(track.id); + + if (cachedSource == null || + cachedSource.sourceType != SourceType.jiosaavn) { + final siblings = await fetchSiblings(ref: ref, track: track); + + if (siblings.isEmpty) { + throw TrackNotFoundException(track); + } + + await SourceMatch.box.put( + track.id!, + SourceMatch( + id: track.id!, + sourceType: SourceType.jiosaavn, + createdAt: DateTime.now(), + sourceId: siblings.first.info.id, + ), + ); + + return JioSaavnSourcedTrack( + ref: ref, + siblings: siblings.map((s) => s.info).skip(1).toList(), + source: siblings.first.source!, + sourceInfo: siblings.first.info, + track: track, + ); + } + + final [item] = + await jiosaavnClient.songs.detailsById([cachedSource.sourceId]); + + final (:info, :source) = toSiblingType(item); + + return JioSaavnSourcedTrack( + ref: ref, + siblings: [], + source: source!, + sourceInfo: info, + track: track, + ); + } + + static SiblingType toSiblingType(SongResponse result) { + final SiblingType sibling = ( + info: SourceInfo( + artist: [ + result.primaryArtists, + if (result.featuredArtists.isNotEmpty) ", ", + result.featuredArtists + ].join("").replaceAll("&", "&"), + artistUrl: + "https://www.jiosaavn.com/artist/${result.primaryArtistsId.split(",").firstOrNull ?? ""}", + duration: Duration(seconds: int.parse(result.duration)), + id: result.id, + pageUrl: result.url, + thumbnail: result.image?.last.link ?? "", + title: result.name!, + album: result.album.name, + ), + source: SourceMap( + m4a: SourceQualityMap( + high: result.downloadUrl! + .firstWhere((element) => element.quality == "320kbps") + .link, + medium: result.downloadUrl! + .firstWhere((element) => element.quality == "160kbps") + .link, + low: result.downloadUrl! + .firstWhere((element) => element.quality == "96kbps") + .link, + ), + ), + ); + + return sibling; + } + + static Future> fetchSiblings({ + required Track track, + required Ref ref, + }) async { + final query = SourcedTrack.getSearchTerm(track); + + final SongSearchResponse(:results) = + await jiosaavnClient.search.songs(query, limit: 20); + + return results.map(toSiblingType).toList(); + } + + @override + Future copyWithSibling() async { + if (siblings.isNotEmpty) { + return this; + } + final fetchedSiblings = await fetchSiblings(ref: ref, track: this); + + return JioSaavnSourcedTrack( + ref: ref, + siblings: fetchedSiblings + .where((s) => s.info.id != sourceInfo.id) + .map((s) => s.info) + .toList(), + source: source, + sourceInfo: sourceInfo, + track: this, + ); + } + + @override + Future swapWithSibling(SourceInfo sibling) async { + if (sibling.id == sourceInfo.id || + siblings.none((s) => s.id == sibling.id)) { + return null; + } + + final newSourceInfo = siblings.firstWhere((s) => s.id == sibling.id); + final newSiblings = siblings.where((s) => s.id != sibling.id).toList() + ..insert(0, sourceInfo); + + final [item] = await jiosaavnClient.songs.detailsById([newSourceInfo.id]); + + final (:info, :source) = toSiblingType(item); + + return JioSaavnSourcedTrack( + ref: ref, + siblings: newSiblings, + source: source!, + sourceInfo: info, + track: this, + ); + } +} diff --git a/lib/services/sourced_track/sources/youtube.dart b/lib/services/sourced_track/sources/youtube.dart index 9503a37b..096de2d4 100644 --- a/lib/services/sourced_track/sources/youtube.dart +++ b/lib/services/sourced_track/sources/youtube.dart @@ -55,28 +55,27 @@ class YoutubeSourcedTrack extends SourcedTrack { sourceInfo: siblings.first.info, track: track, ); - } else { - final item = await youtubeClient.videos.get(cachedSource.sourceId); - final manifest = await youtubeClient.videos.streamsClient.getManifest( - cachedSource.sourceId, - ); - return YoutubeSourcedTrack( - ref: ref, - siblings: [], - source: toSourceMap(manifest), - sourceInfo: SourceInfo( - id: item.id.value, - artist: item.author, - artistUrl: "https://www.youtube.com/channel/${item.channelId}", - pageUrl: item.url, - thumbnail: item.thumbnails.highResUrl, - title: item.title, - duration: item.duration ?? Duration.zero, - album: null, - ), - track: track, - ); } + final item = await youtubeClient.videos.get(cachedSource.sourceId); + final manifest = await youtubeClient.videos.streamsClient.getManifest( + cachedSource.sourceId, + ); + return YoutubeSourcedTrack( + ref: ref, + siblings: [], + source: toSourceMap(manifest), + sourceInfo: SourceInfo( + id: item.id.value, + artist: item.author, + artistUrl: "https://www.youtube.com/channel/${item.channelId}", + pageUrl: item.url, + thumbnail: item.thumbnails.highResUrl, + title: item.title, + duration: item.duration ?? Duration.zero, + album: null, + ), + track: track, + ); } static SourceMap toSourceMap(StreamManifest manifest) {