feat: add jiosaavn as audio source

This commit is contained in:
Kingkor Roy Tirtho 2023-11-12 18:02:01 +06:00
parent 84ed9e1c6e
commit 3de153e478
10 changed files with 293 additions and 130 deletions

View File

@ -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<SearchMode>(
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<MusicCodec>(
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<MusicCodec>(
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<SourceCodecs>(
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<SourceCodecs>(
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);
},
),
],
);
}

View File

@ -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

View File

@ -171,10 +171,11 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
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;

View File

@ -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,
);
}

View File

@ -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 {

View File

@ -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:

View File

@ -21,9 +21,6 @@ Map<String, dynamic> _$SourceQualityMapToJson(SourceQualityMap instance) =>
};
SourceMap _$SourceMapFromJson(Map<String, dynamic> json) => SourceMap(
mp4: json['mp4'] == null
? null
: SourceQualityMap.fromJson(json['mp4'] as Map<String, dynamic>),
weba: json['weba'] == null
? null
: SourceQualityMap.fromJson(json['weba'] as Map<String, dynamic>),
@ -33,7 +30,6 @@ SourceMap _$SourceMapFromJson(Map<String, dynamic> json) => SourceMap(
);
Map<String, dynamic> _$SourceMapToJson(SourceMap instance) => <String, dynamic>{
'mp4': instance.mp4,
'weba': instance.weba,
'm4a': instance.m4a,
};

View File

@ -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<SourcedTrack> 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<List<SiblingType>> 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;
}
}

View File

@ -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<SourcedTrack> 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("&amp;", "&"),
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<List<SiblingType>> 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<JioSaavnSourcedTrack> 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<JioSaavnSourcedTrack?> 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,
);
}
}

View File

@ -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) {