feat: Add JioSaavn as audio source (#881)

* feat: implement new SourcedTrack for youtube and piped

* refactor: replace old spotube track with sourced track

* feat: add jiosaavn as audio source

* fix: download not working other than jiosaavn

* Merge branch 'dev' into feat-jiosaavn
This commit is contained in:
Kingkor Roy Tirtho 2023-11-15 18:34:46 +06:00 committed by GitHub
parent 57c03ad045
commit 14069cd4fe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
45 changed files with 1691 additions and 1009 deletions

View File

@ -5,9 +5,9 @@ import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/spotube_track.dart';
import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/download_manager_provider.dart';
import 'package:spotube/services/download_manager/download_status.dart'; import 'package:spotube/services/download_manager/download_status.dart';
import 'package:spotube/services/sourced_track/sourced_track.dart';
import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart';
class DownloadItem extends HookConsumerWidget { class DownloadItem extends HookConsumerWidget {
@ -24,25 +24,25 @@ class DownloadItem extends HookConsumerWidget {
final taskStatus = useState<DownloadStatus?>(null); final taskStatus = useState<DownloadStatus?>(null);
useEffect(() { useEffect(() {
if (track is! SpotubeTrack) return null; if (track is! SourcedTrack) return null;
final notifier = downloadManager.getStatusNotifier(track as SpotubeTrack); final notifier = downloadManager.getStatusNotifier(track as SourcedTrack);
taskStatus.value = notifier?.value; taskStatus.value = notifier?.value;
listener() {
void listener() {
taskStatus.value = notifier?.value; taskStatus.value = notifier?.value;
} }
downloadManager notifier?.addListener(listener);
.getStatusNotifier(track as SpotubeTrack)
?.addListener(listener);
return () { return () {
downloadManager notifier?.removeListener(listener);
.getStatusNotifier(track as SpotubeTrack)
?.removeListener(listener);
}; };
}, [track]); }, [track]);
final isQueryingSourceInfo =
taskStatus.value == null || track is! SourcedTrack;
return ListTile( return ListTile(
leading: Padding( leading: Padding(
padding: const EdgeInsets.symmetric(horizontal: 5), padding: const EdgeInsets.symmetric(horizontal: 5),
@ -63,7 +63,7 @@ class DownloadItem extends HookConsumerWidget {
track.artists ?? <Artist>[], track.artists ?? <Artist>[],
mainAxisAlignment: WrapAlignment.start, mainAxisAlignment: WrapAlignment.start,
), ),
trailing: taskStatus.value == null || track is! SpotubeTrack trailing: isQueryingSourceInfo
? Text( ? Text(
context.l10n.querying_info, context.l10n.querying_info,
style: Theme.of(context).textTheme.labelMedium, style: Theme.of(context).textTheme.labelMedium,
@ -72,7 +72,7 @@ class DownloadItem extends HookConsumerWidget {
DownloadStatus.downloading => HookBuilder(builder: (context) { DownloadStatus.downloading => HookBuilder(builder: (context) {
final taskProgress = useListenable(useMemoized( final taskProgress = useListenable(useMemoized(
() => downloadManager () => downloadManager
.getProgressNotifier(track as SpotubeTrack), .getProgressNotifier(track as SourcedTrack),
[track], [track],
)); ));
return SizedBox( return SizedBox(
@ -86,13 +86,13 @@ class DownloadItem extends HookConsumerWidget {
IconButton( IconButton(
icon: const Icon(SpotubeIcons.pause), icon: const Icon(SpotubeIcons.pause),
onPressed: () { onPressed: () {
downloadManager.pause(track as SpotubeTrack); downloadManager.pause(track as SourcedTrack);
}), }),
const SizedBox(width: 10), const SizedBox(width: 10),
IconButton( IconButton(
icon: const Icon(SpotubeIcons.close), icon: const Icon(SpotubeIcons.close),
onPressed: () { onPressed: () {
downloadManager.cancel(track as SpotubeTrack); downloadManager.cancel(track as SourcedTrack);
}), }),
], ],
), ),
@ -104,13 +104,13 @@ class DownloadItem extends HookConsumerWidget {
IconButton( IconButton(
icon: const Icon(SpotubeIcons.play), icon: const Icon(SpotubeIcons.play),
onPressed: () { onPressed: () {
downloadManager.resume(track as SpotubeTrack); downloadManager.resume(track as SourcedTrack);
}), }),
const SizedBox(width: 10), const SizedBox(width: 10),
IconButton( IconButton(
icon: const Icon(SpotubeIcons.close), icon: const Icon(SpotubeIcons.close),
onPressed: () { onPressed: () {
downloadManager.cancel(track as SpotubeTrack); downloadManager.cancel(track as SourcedTrack);
}) })
], ],
), ),
@ -126,7 +126,7 @@ class DownloadItem extends HookConsumerWidget {
IconButton( IconButton(
icon: const Icon(SpotubeIcons.refresh), icon: const Icon(SpotubeIcons.refresh),
onPressed: () { onPressed: () {
downloadManager.retry(track as SpotubeTrack); downloadManager.retry(track as SourcedTrack);
}, },
), ),
], ],
@ -137,7 +137,7 @@ class DownloadItem extends HookConsumerWidget {
DownloadStatus.queued => IconButton( DownloadStatus.queued => IconButton(
icon: const Icon(SpotubeIcons.close), icon: const Icon(SpotubeIcons.close),
onPressed: () { onPressed: () {
downloadManager.removeFromQueue(track as SpotubeTrack); downloadManager.removeFromQueue(track as SourcedTrack);
}), }),
}, },
); );

View File

@ -1,5 +1,6 @@
import 'dart:ui'; import 'dart:ui';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
@ -12,13 +13,13 @@ import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/duration.dart'; import 'package:spotube/extensions/duration.dart';
import 'package:spotube/hooks/utils/use_debounce.dart'; import 'package:spotube/hooks/utils/use_debounce.dart';
import 'package:spotube/models/matched_track.dart';
import 'package:spotube/models/spotube_track.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
import 'package:spotube/provider/youtube_provider.dart'; import 'package:spotube/services/sourced_track/models/source_info.dart';
import 'package:spotube/services/youtube/youtube.dart'; import 'package:spotube/services/sourced_track/models/video_info.dart';
import 'package:spotube/services/sourced_track/sourced_track.dart';
import 'package:spotube/services/sourced_track/sources/youtube.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/service_utils.dart';
import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart';
@ -35,7 +36,6 @@ class SiblingTracksSheet extends HookConsumerWidget {
final playlist = ref.watch(ProxyPlaylistNotifier.provider); final playlist = ref.watch(ProxyPlaylistNotifier.provider);
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
final preferences = ref.watch(userPreferencesProvider); final preferences = ref.watch(userPreferencesProvider);
final youtube = ref.watch(youtubeProvider);
final isSearching = useState(false); final isSearching = useState(false);
final searchMode = useState(preferences.searchMode); final searchMode = useState(preferences.searchMode);
@ -61,18 +61,31 @@ class SiblingTracksSheet extends HookConsumerWidget {
final searchRequest = useMemoized(() async { final searchRequest = useMemoized(() async {
if (searchTerm.trim().isEmpty) { if (searchTerm.trim().isEmpty) {
return <YoutubeVideoInfo>[]; return <SourceInfo>[];
} }
return youtube.search(searchTerm.trim()); final results = await youtubeClient.search.search(searchTerm.trim());
return await Future.wait(
results.map(YoutubeVideoInfo.fromVideo).mapIndexed((i, video) async {
final siblingType = await YoutubeSourcedTrack.toSiblingType(i, video);
return siblingType.info;
}),
);
}, [ }, [
searchTerm, searchTerm,
searchMode.value, searchMode.value,
]); ]);
final siblings = playlist.isFetching == false final siblings = useMemoized(
? (playlist.activeTrack as SpotubeTrack).siblings () => playlist.isFetching == false
: <YoutubeVideoInfo>[]; ? [
(playlist.activeTrack as SourcedTrack).sourceInfo,
...(playlist.activeTrack as SourcedTrack).siblings,
]
: <SourceInfo>[],
[playlist.isFetching, playlist.activeTrack],
);
final borderRadius = floating final borderRadius = floating
? BorderRadius.circular(10) ? BorderRadius.circular(10)
@ -82,21 +95,21 @@ class SiblingTracksSheet extends HookConsumerWidget {
); );
useEffect(() { useEffect(() {
if (playlist.activeTrack is SpotubeTrack && if (playlist.activeTrack is SourcedTrack &&
(playlist.activeTrack as SpotubeTrack).siblings.isEmpty) { (playlist.activeTrack as SourcedTrack).siblings.isEmpty) {
playlistNotifier.populateSibling(); playlistNotifier.populateSibling();
} }
return null; return null;
}, [playlist.activeTrack]); }, [playlist.activeTrack]);
final itemBuilder = useCallback( final itemBuilder = useCallback(
(YoutubeVideoInfo video) { (SourceInfo sourceInfo) {
return ListTile( return ListTile(
title: Text(video.title), title: Text(sourceInfo.title),
leading: Padding( leading: Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: UniversalImage( child: UniversalImage(
path: video.thumbnailUrl, path: sourceInfo.thumbnail,
height: 60, height: 60,
width: 60, width: 60,
), ),
@ -104,16 +117,18 @@ class SiblingTracksSheet extends HookConsumerWidget {
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(5),
), ),
trailing: Text(video.duration.toHumanReadableString()), trailing: Text(sourceInfo.duration.toHumanReadableString()),
subtitle: Text(video.channelName), subtitle: Text(sourceInfo.artist),
enabled: playlist.isFetching != true, enabled: playlist.isFetching != true,
selected: playlist.isFetching != true && selected: playlist.isFetching != true &&
video.id == (playlist.activeTrack as SpotubeTrack).ytTrack.id, sourceInfo.id ==
(playlist.activeTrack as SourcedTrack).sourceInfo.id,
selectedTileColor: theme.popupMenuTheme.color, selectedTileColor: theme.popupMenuTheme.color,
onTap: () { onTap: () {
if (playlist.isFetching == false && if (playlist.isFetching == false &&
video.id != (playlist.activeTrack as SpotubeTrack).ytTrack.id) { sourceInfo.id !=
playlistNotifier.swapSibling(video); (playlist.activeTrack as SourcedTrack).sourceInfo.id) {
playlistNotifier.swapSibling(sourceInfo);
Navigator.of(context).pop(); Navigator.of(context).pop();
} }
}, },
@ -175,7 +190,7 @@ class SiblingTracksSheet extends HookConsumerWidget {
}, },
) )
else ...[ else ...[
if (preferences.youtubeApiType == YoutubeApiType.piped) if (preferences.audioSource == AudioSource.piped)
PopupMenuButton( PopupMenuButton(
icon: const Icon(SpotubeIcons.filter, size: 18), icon: const Icon(SpotubeIcons.filter, size: 18),
onSelected: (SearchMode mode) { onSelected: (SearchMode mode) {

View File

@ -6,8 +6,7 @@ import 'package:spotube/components/shared/links/hyper_link.dart';
import 'package:spotube/components/shared/links/link_text.dart'; import 'package:spotube/components/shared/links/link_text.dart';
import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/spotube_track.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'; import 'package:spotube/utils/type_conversion_utils.dart';
import 'package:spotube/extensions/duration.dart'; import 'package:spotube/extensions/duration.dart';
@ -37,8 +36,8 @@ class TrackDetailsDialog extends HookWidget {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.blue), style: const TextStyle(color: Colors.blue),
), ),
context.l10n.duration: (track is SpotubeTrack context.l10n.duration: (track is SourcedTrack
? (track as SpotubeTrack).ytTrack.duration ? (track as SourcedTrack).sourceInfo.duration
: track.duration!) : track.duration!)
.toHumanReadableString(), .toHumanReadableString(),
if (track.album!.releaseDate != null) if (track.album!.releaseDate != null)
@ -46,33 +45,27 @@ class TrackDetailsDialog extends HookWidget {
context.l10n.popularity: track.popularity?.toString() ?? "0", context.l10n.popularity: track.popularity?.toString() ?? "0",
}; };
final ytTrack = final sourceInfo =
track is SpotubeTrack ? (track as SpotubeTrack).ytTrack : null; track is SourcedTrack ? (track as SourcedTrack).sourceInfo : null;
final ytTracksDetailsMap = ytTrack == null final ytTracksDetailsMap = sourceInfo == null
? {} ? {}
: { : {
context.l10n.youtube: Hyperlink( context.l10n.youtube: Hyperlink(
"https://piped.video/watch?v=${ytTrack.id}", "https://piped.video/watch?v=${sourceInfo.id}",
"https://piped.video/watch?v=${ytTrack.id}", "https://piped.video/watch?v=${sourceInfo.id}",
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
context.l10n.channel: Hyperlink( context.l10n.channel: Hyperlink(
ytTrack.channelName, sourceInfo.artist,
"https://youtube.com${ytTrack.channelName}", sourceInfo.artistUrl,
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
context.l10n.likes:
PrimitiveUtils.toReadableNumber(ytTrack.likes.toDouble()),
context.l10n.dislikes:
PrimitiveUtils.toReadableNumber(ytTrack.dislikes.toDouble()),
context.l10n.views:
PrimitiveUtils.toReadableNumber(ytTrack.views.toDouble()),
context.l10n.streamUrl: Hyperlink( context.l10n.streamUrl: Hyperlink(
(track as SpotubeTrack).ytUri, (track as SourcedTrack).url,
(track as SpotubeTrack).ytUri, (track as SourcedTrack).url,
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),

View File

@ -110,7 +110,7 @@ class TrackOptions extends HookConsumerWidget {
]); ]);
final progressNotifier = useMemoized(() { final progressNotifier = useMemoized(() {
final spotubeTrack = downloadManager.mapToSpotubeTrack(track); final spotubeTrack = downloadManager.mapToSourcedTrack(track);
if (spotubeTrack == null) return null; if (spotubeTrack == null) return null;
return downloadManager.getProgressNotifier(spotubeTrack); return downloadManager.getProgressNotifier(spotubeTrack);
}); });

View File

@ -60,7 +60,7 @@ class TracksTableView extends HookConsumerWidget {
ref.watch(downloadManagerProvider); ref.watch(downloadManagerProvider);
final downloader = ref.watch(downloadManagerProvider.notifier); final downloader = ref.watch(downloadManagerProvider.notifier);
final apiType = final apiType =
ref.watch(userPreferencesProvider.select((s) => s.youtubeApiType)); ref.watch(userPreferencesProvider.select((s) => s.audioSource));
const tableHeadStyle = TextStyle(fontWeight: FontWeight.bold, fontSize: 16); const tableHeadStyle = TextStyle(fontWeight: FontWeight.bold, fontSize: 16);
final selected = useState<List<String>>([]); final selected = useState<List<String>>([]);
@ -195,7 +195,7 @@ class TracksTableView extends HookConsumerWidget {
switch (action) { switch (action) {
case "download": case "download":
{ {
final confirmed = apiType == YoutubeApiType.piped || final confirmed = apiType == AudioSource.piped ||
await showDialog( await showDialog(
context: context, context: context,
builder: (context) { builder: (context) {

View File

@ -18,8 +18,8 @@ import 'package:spotube/hooks/configurators/use_disable_battery_optimizations.da
import 'package:spotube/hooks/configurators/use_get_storage_perms.dart'; import 'package:spotube/hooks/configurators/use_get_storage_perms.dart';
import 'package:spotube/l10n/l10n.dart'; import 'package:spotube/l10n/l10n.dart';
import 'package:spotube/models/logger.dart'; import 'package:spotube/models/logger.dart';
import 'package:spotube/models/matched_track.dart';
import 'package:spotube/models/skip_segment.dart'; import 'package:spotube/models/skip_segment.dart';
import 'package:spotube/models/source_match.dart';
import 'package:spotube/provider/palette_provider.dart'; import 'package:spotube/provider/palette_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/audio_player.dart';
@ -71,16 +71,18 @@ Future<void> main(List<String> rawArgs) async {
cacheDir: hiveCacheDir, cacheDir: hiveCacheDir,
connectivity: FlQueryInternetConnectionCheckerAdapter(), connectivity: FlQueryInternetConnectionCheckerAdapter(),
); );
Hive.registerAdapter(MatchedTrackAdapter());
Hive.registerAdapter(SkipSegmentAdapter()); Hive.registerAdapter(SkipSegmentAdapter());
Hive.registerAdapter(SearchModeAdapter());
Hive.registerAdapter(SourceMatchAdapter());
Hive.registerAdapter(SourceTypeAdapter());
// Cache versioning entities with Adapter // Cache versioning entities with Adapter
MatchedTrack.version = 'v1'; SourceMatch.version = 'v1';
SkipSegment.version = 'v1'; SkipSegment.version = 'v1';
await Hive.openLazyBox<MatchedTrack>( await Hive.openLazyBox<SourceMatch>(
MatchedTrack.boxName, SourceMatch.boxName,
path: hiveCacheDir, path: hiveCacheDir,
); );
await Hive.openLazyBox( await Hive.openLazyBox(

View File

@ -1,6 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/models/spotube_track.dart';
import 'package:spotube/extensions/track.dart'; import 'package:spotube/extensions/track.dart';
import 'package:spotube/services/sourced_track/sourced_track.dart';
class CurrentPlaylist { class CurrentPlaylist {
List<Track>? _tempTrack; List<Track>? _tempTrack;
@ -18,13 +19,13 @@ class CurrentPlaylist {
this.isLocal = false, this.isLocal = false,
}); });
static CurrentPlaylist fromJson(Map<String, dynamic> map) { static CurrentPlaylist fromJson(Map<String, dynamic> map, Ref ref) {
return CurrentPlaylist( return CurrentPlaylist(
id: map["id"], id: map["id"],
tracks: List.castFrom<dynamic, Track>(map["tracks"] tracks: List.castFrom<dynamic, Track>(map["tracks"]
.map( .map(
(track) => map["isLocal"] == true (track) => map["isLocal"] == true
? SpotubeTrack.fromJson(track) ? SourcedTrack.fromJson(track, ref: ref)
: Track.fromJson(track), : Track.fromJson(track),
) )
.toList()), .toList()),
@ -66,7 +67,7 @@ class CurrentPlaylist {
"name": name, "name": name,
"tracks": tracks "tracks": tracks
.map((track) => .map((track) =>
track is SpotubeTrack ? track.toJson() : track.toJson()) track is SourcedTrack ? track.toJson() : track.toJson())
.toList(), .toList(),
"thumbnail": thumbnail, "thumbnail": thumbnail,
"isLocal": isLocal, "isLocal": isLocal,

View File

@ -1,69 +0,0 @@
import "package:hive/hive.dart";
part "matched_track.g.dart";
@HiveType(typeId: 1)
class MatchedTrack {
@HiveField(0)
String youtubeId;
@HiveField(1)
String spotifyId;
@HiveField(2)
SearchMode searchMode;
String? id;
DateTime? createdAt;
bool get isSynced => id != null;
static String version = 'v1';
static final boxName = "oss.krtirtho.spotube.matched_tracks.$version";
static LazyBox<MatchedTrack> get box => Hive.lazyBox<MatchedTrack>(boxName);
MatchedTrack({
required this.youtubeId,
required this.spotifyId,
required this.searchMode,
this.id,
this.createdAt,
});
factory MatchedTrack.fromJson(Map<String, dynamic> json) {
return MatchedTrack(
searchMode: SearchMode.fromString(json["searchMode"]),
youtubeId: json["youtube_id"],
spotifyId: json["spotify_id"],
id: json["id"],
createdAt: DateTime.parse(json["created_at"]),
);
}
Map<String, dynamic> toJson() {
return {
"youtube_id": youtubeId,
"spotify_id": spotifyId,
"id": id,
"searchMode": searchMode.name,
"created_at": createdAt?.toString()
}..removeWhere((key, value) => value == null);
}
}
@HiveType(typeId: 4)
enum SearchMode {
@HiveField(0)
youtube._internal('YouTube'),
@HiveField(1)
youtubeMusic._internal('YouTube Music');
final String label;
const SearchMode._internal(this.label);
factory SearchMode.fromString(String value) {
return SearchMode.values.firstWhere(
(element) => element.name == value,
orElse: () => SearchMode.youtube,
);
}
}

View File

@ -1,86 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'matched_track.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class MatchedTrackAdapter extends TypeAdapter<MatchedTrack> {
@override
final int typeId = 1;
@override
MatchedTrack read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return MatchedTrack(
youtubeId: fields[0] as String,
spotifyId: fields[1] as String,
searchMode: fields[2] as SearchMode,
);
}
@override
void write(BinaryWriter writer, MatchedTrack obj) {
writer
..writeByte(3)
..writeByte(0)
..write(obj.youtubeId)
..writeByte(1)
..write(obj.spotifyId)
..writeByte(2)
..write(obj.searchMode);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is MatchedTrackAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}
class SearchModeAdapter extends TypeAdapter<SearchMode> {
@override
final int typeId = 4;
@override
SearchMode read(BinaryReader reader) {
switch (reader.readByte()) {
case 0:
return SearchMode.youtube;
case 1:
return SearchMode.youtubeMusic;
default:
return SearchMode.youtube;
}
}
@override
void write(BinaryWriter writer, SearchMode obj) {
switch (obj) {
case SearchMode.youtube:
writer.writeByte(0);
break;
case SearchMode.youtubeMusic:
writer.writeByte(1);
break;
}
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SearchModeAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@ -0,0 +1,54 @@
import 'package:hive/hive.dart';
import 'package:json_annotation/json_annotation.dart';
part 'source_match.g.dart';
@JsonEnum()
@HiveType(typeId: 5)
enum SourceType {
@HiveField(0)
youtube._("YouTube"),
@HiveField(1)
youtubeMusic._("YouTube Music"),
@HiveField(2)
jiosaavn._("JioSaavn");
final String label;
const SourceType._(this.label);
}
@JsonSerializable()
@HiveType(typeId: 6)
class SourceMatch {
@HiveField(0)
String id;
@HiveField(1)
String sourceId;
@HiveField(2)
SourceType sourceType;
@HiveField(3)
DateTime createdAt;
SourceMatch({
required this.id,
required this.sourceId,
required this.sourceType,
required this.createdAt,
});
factory SourceMatch.fromJson(Map<String, dynamic> json) =>
_$SourceMatchFromJson(json);
Map<String, dynamic> toJson() => _$SourceMatchToJson(this);
static String version = 'v1';
static final boxName = "oss.krtirtho.spotube.source_matches.$version";
static LazyBox<SourceMatch> get box => Hive.lazyBox<SourceMatch>(boxName);
}

View File

@ -0,0 +1,119 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'source_match.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class SourceMatchAdapter extends TypeAdapter<SourceMatch> {
@override
final int typeId = 6;
@override
SourceMatch read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return SourceMatch(
id: fields[0] as String,
sourceId: fields[1] as String,
sourceType: fields[2] as SourceType,
createdAt: fields[3] as DateTime,
);
}
@override
void write(BinaryWriter writer, SourceMatch obj) {
writer
..writeByte(4)
..writeByte(0)
..write(obj.id)
..writeByte(1)
..write(obj.sourceId)
..writeByte(2)
..write(obj.sourceType)
..writeByte(3)
..write(obj.createdAt);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SourceMatchAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}
class SourceTypeAdapter extends TypeAdapter<SourceType> {
@override
final int typeId = 5;
@override
SourceType read(BinaryReader reader) {
switch (reader.readByte()) {
case 0:
return SourceType.youtube;
case 1:
return SourceType.youtubeMusic;
case 2:
return SourceType.jiosaavn;
default:
return SourceType.youtube;
}
}
@override
void write(BinaryWriter writer, SourceType obj) {
switch (obj) {
case SourceType.youtube:
writer.writeByte(0);
break;
case SourceType.youtubeMusic:
writer.writeByte(1);
break;
case SourceType.jiosaavn:
writer.writeByte(2);
break;
}
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SourceTypeAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
SourceMatch _$SourceMatchFromJson(Map<String, dynamic> json) => SourceMatch(
id: json['id'] as String,
sourceId: json['sourceId'] as String,
sourceType: $enumDecode(_$SourceTypeEnumMap, json['sourceType']),
createdAt: DateTime.parse(json['createdAt'] as String),
);
Map<String, dynamic> _$SourceMatchToJson(SourceMatch instance) =>
<String, dynamic>{
'id': instance.id,
'sourceId': instance.sourceId,
'sourceType': _$SourceTypeEnumMap[instance.sourceType]!,
'createdAt': instance.createdAt.toIso8601String(),
};
const _$SourceTypeEnumMap = {
SourceType.youtube: 'youtube',
SourceType.youtubeMusic: 'youtubeMusic',
SourceType.jiosaavn: 'jiosaavn',
};

View File

@ -1,274 +0,0 @@
import 'dart:async';
import 'package:spotify/spotify.dart';
import 'package:spotube/extensions/track.dart';
import 'package:spotube/models/matched_track.dart';
import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
import 'package:spotube/services/youtube/youtube.dart';
import 'package:spotube/utils/service_utils.dart';
import 'package:collection/collection.dart';
final officialMusicRegex = RegExp(
r"official\s(video|audio|music\svideo|lyric\svideo|visualizer)",
caseSensitive: false,
);
class TrackNotFoundException implements Exception {
factory TrackNotFoundException(Track track) {
throw Exception("Failed to find any results for ${track.name}");
}
}
class SpotubeTrack extends Track {
final YoutubeVideoInfo ytTrack;
final String ytUri;
final MusicCodec codec;
final List<YoutubeVideoInfo> siblings;
SpotubeTrack(
this.ytTrack,
this.ytUri,
this.siblings,
this.codec,
) : super();
SpotubeTrack.fromTrack({
required Track track,
required this.ytTrack,
required this.ytUri,
required this.siblings,
required this.codec,
}) : super() {
album = track.album;
artists = track.artists;
availableMarkets = track.availableMarkets;
discNumber = track.discNumber;
durationMs = track.durationMs;
explicit = track.explicit;
externalIds = track.externalIds;
externalUrls = track.externalUrls;
href = track.href;
id = track.id;
isPlayable = track.isPlayable;
linkedFrom = track.linkedFrom;
name = track.name;
popularity = track.popularity;
previewUrl = track.previewUrl;
trackNumber = track.trackNumber;
type = track.type;
uri = track.uri;
}
static Future<List<YoutubeVideoInfo>> fetchSiblings(
Track track,
YoutubeEndpoints client,
) async {
final artists = (track.artists ?? [])
.map((ar) => ar.name)
.toList()
.whereNotNull()
.toList();
final title = ServiceUtils.getTitle(
track.name!,
artists: artists,
onlyCleanArtist: true,
).trim();
final query = "$title - ${artists.join(", ")}";
final List<YoutubeVideoInfo> siblings = await client.search(query).then(
(res) {
final isYoutubeApi =
client.preferences.youtubeApiType == YoutubeApiType.youtube;
final siblings = isYoutubeApi ||
client.preferences.searchMode == SearchMode.youtube
? ServiceUtils.onlyContainsEnglish(query)
? res
: res
.sorted((a, b) => b.views.compareTo(a.views))
.map((sibling) {
int score = 0;
for (final artist in artists) {
final isSameChannelArtist =
sibling.channelName.toLowerCase() ==
artist.toLowerCase();
final channelContainsArtist = sibling.channelName
.toLowerCase()
.contains(artist.toLowerCase());
if (isSameChannelArtist || channelContainsArtist) {
score += 1;
}
final titleContainsArtist = sibling.title
.toLowerCase()
.contains(artist.toLowerCase());
if (titleContainsArtist) {
score += 1;
}
}
final titleContainsTrackName = sibling.title
.toLowerCase()
.contains(track.name!.toLowerCase());
final hasOfficialFlag = officialMusicRegex
.hasMatch(sibling.title.toLowerCase());
if (titleContainsTrackName) {
score += 3;
}
if (hasOfficialFlag) {
score += 1;
}
if (hasOfficialFlag && titleContainsTrackName) {
score += 2;
}
return (sibling: sibling, score: score);
})
.sorted((a, b) => b.score.compareTo(a.score))
.map((e) => e.sibling)
: res.sorted((a, b) => b.views.compareTo(a.views)).where((item) {
return artists.any(
(artist) =>
artist.toLowerCase() == item.channelName.toLowerCase(),
);
});
return siblings.take(10).toList();
},
);
return siblings;
}
static Future<SpotubeTrack> fetchFromTrack(
Track track,
YoutubeEndpoints client,
MusicCodec codec,
) async {
final matchedCachedTrack = await MatchedTrack.box.get(track.id!);
var siblings = <YoutubeVideoInfo>[];
YoutubeVideoInfo ytVideo;
String ytStreamUrl;
if (matchedCachedTrack != null &&
matchedCachedTrack.searchMode == client.preferences.searchMode) {
(ytVideo, ytStreamUrl) = await client.video(
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,
codec,
);
await MatchedTrack.box.put(
track.id!,
MatchedTrack(
youtubeId: ytVideo.id,
spotifyId: track.id!,
searchMode: siblings.first.searchMode,
),
);
}
return SpotubeTrack.fromTrack(
track: track,
ytTrack: ytVideo,
ytUri: ytStreamUrl,
siblings: siblings,
codec: codec,
);
}
Future<SpotubeTrack?> swappedCopy(
YoutubeVideoInfo video,
YoutubeEndpoints client,
) async {
// 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,
// siblings are always swapped when streaming
client.preferences.streamMusicCodec,
);
if (!isStepSibling) {
await MatchedTrack.box.put(
id!,
MatchedTrack(
youtubeId: video.id,
spotifyId: id!,
searchMode: siblings.first.searchMode,
),
);
}
return SpotubeTrack.fromTrack(
track: this,
ytTrack: ytVideo,
ytUri: ytStreamUrl,
siblings: [
video,
...siblings.where((element) => element.id != video.id),
],
codec: client.preferences.streamMusicCodec,
);
}
static SpotubeTrack fromJson(Map<String, dynamic> map) {
return SpotubeTrack.fromTrack(
track: Track.fromJson(map),
ytTrack: YoutubeVideoInfo.fromJson(map["ytTrack"]),
ytUri: map["ytUri"],
siblings: List.castFrom<dynamic, Map<String, dynamic>>(map["siblings"])
.map((sibling) => YoutubeVideoInfo.fromJson(sibling))
.toList(),
codec: MusicCodec.values.firstWhere(
(element) => element.name == map["codec"],
orElse: () => MusicCodec.m4a,
),
);
}
Future<SpotubeTrack> populatedCopy(YoutubeEndpoints client) async {
if (this.siblings.isNotEmpty) return this;
final siblings = await fetchSiblings(
this,
client,
);
return SpotubeTrack.fromTrack(
track: this,
ytTrack: ytTrack,
ytUri: ytUri,
siblings: siblings,
codec: codec,
);
}
Map<String, dynamic> toJson() {
return {
// super values
...TrackJson.trackToJson(this),
// this values
"ytTrack": ytTrack.toJson(),
"ytUri": ytUri,
"siblings": siblings.map((sibling) => sibling.toJson()).toList(),
"codec": codec.name,
};
}
}

View File

@ -8,9 +8,9 @@ import 'package:spotube/components/shared/track_table/track_collection_view/trac
import 'package:spotube/components/shared/track_table/track_collection_view/track_collection_view.dart'; import 'package:spotube/components/shared/track_table/track_collection_view/track_collection_view.dart';
import 'package:spotube/components/shared/track_table/tracks_table_view.dart'; import 'package:spotube/components/shared/track_table/tracks_table_view.dart';
import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/models/spotube_track.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/services/queries/queries.dart'; import 'package:spotube/services/queries/queries.dart';
import 'package:spotube/services/sourced_track/sourced_track.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/service_utils.dart';
import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart';
@ -68,7 +68,7 @@ class AlbumPage extends HookConsumerWidget {
() => () =>
tracksSnapshot.data?.any((s) => s.id! == playlist.activeTrack?.id!) == tracksSnapshot.data?.any((s) => s.id! == playlist.activeTrack?.id!) ==
true && true &&
playlist.activeTrack is SpotubeTrack, playlist.activeTrack is SourcedTrack,
[playlist.activeTrack, tracksSnapshot.data], [playlist.activeTrack, tracksSnapshot.data],
); );

View File

@ -11,9 +11,9 @@ import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/models/logger.dart'; import 'package:spotube/models/logger.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/models/spotube_track.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/services/queries/queries.dart'; import 'package:spotube/services/queries/queries.dart';
import 'package:spotube/services/sourced_track/sourced_track.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/service_utils.dart';
import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart';
@ -59,7 +59,7 @@ class PlaylistView extends HookConsumerWidget {
tracksSnapshot.data tracksSnapshot.data
?.any((s) => s.id! == proxyPlaylist.activeTrack?.id!) == ?.any((s) => s.id! == proxyPlaylist.activeTrack?.id!) ==
true && true &&
proxyPlaylist.activeTrack is SpotubeTrack, proxyPlaylist.activeTrack is SourcedTrack,
[proxyPlaylist.activeTrack, tracksSnapshot.data], [proxyPlaylist.activeTrack, tracksSnapshot.data],
); );

View File

@ -8,10 +8,10 @@ import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/settings/section_card_with_heading.dart'; import 'package:spotube/components/settings/section_card_with_heading.dart';
import 'package:spotube/components/shared/adaptive/adaptive_select_tile.dart'; import 'package:spotube/components/shared/adaptive/adaptive_select_tile.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/matched_track.dart';
import 'package:spotube/provider/piped_instances_provider.dart'; import 'package:spotube/provider/piped_instances_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
import 'package:spotube/services/sourced_track/enums.dart';
class SettingsPlaybackSection extends HookConsumerWidget { class SettingsPlaybackSection extends HookConsumerWidget {
const SettingsPlaybackSection({Key? key}) : super(key: key); const SettingsPlaybackSection({Key? key}) : super(key: key);
@ -25,17 +25,21 @@ class SettingsPlaybackSection extends HookConsumerWidget {
return SectionCardWithHeading( return SectionCardWithHeading(
heading: context.l10n.playback, heading: context.l10n.playback,
children: [ children: [
AdaptiveSelectTile<AudioQuality>( AdaptiveSelectTile<SourceQualities>(
secondary: const Icon(SpotubeIcons.audioQuality), secondary: const Icon(SpotubeIcons.audioQuality),
title: Text(context.l10n.audio_quality), title: Text(context.l10n.audio_quality),
value: preferences.audioQuality, value: preferences.audioQuality,
options: [ options: [
DropdownMenuItem( DropdownMenuItem(
value: AudioQuality.high, value: SourceQualities.high,
child: Text(context.l10n.high), child: Text(context.l10n.high),
), ),
DropdownMenuItem( DropdownMenuItem(
value: AudioQuality.low, value: SourceQualities.medium,
child: Text(context.l10n.medium),
),
DropdownMenuItem(
value: SourceQualities.low,
child: Text(context.l10n.low), child: Text(context.l10n.low),
), ),
], ],
@ -45,11 +49,11 @@ class SettingsPlaybackSection extends HookConsumerWidget {
} }
}, },
), ),
AdaptiveSelectTile<YoutubeApiType>( AdaptiveSelectTile<AudioSource>(
secondary: const Icon(SpotubeIcons.api), secondary: const Icon(SpotubeIcons.api),
title: Text(context.l10n.youtube_api_type), title: Text(context.l10n.youtube_api_type),
value: preferences.youtubeApiType, value: preferences.audioSource,
options: YoutubeApiType.values options: AudioSource.values
.map((e) => DropdownMenuItem( .map((e) => DropdownMenuItem(
value: e, value: e,
child: Text(e.label), child: Text(e.label),
@ -57,12 +61,12 @@ class SettingsPlaybackSection extends HookConsumerWidget {
.toList(), .toList(),
onChanged: (value) { onChanged: (value) {
if (value == null) return; if (value == null) return;
preferencesNotifier.setYoutubeApiType(value); preferencesNotifier.setAudioSource(value);
}, },
), ),
AnimatedSwitcher( AnimatedSwitcher(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
child: preferences.youtubeApiType == YoutubeApiType.youtube child: preferences.audioSource != AudioSource.piped
? const SizedBox.shrink() ? const SizedBox.shrink()
: Consumer(builder: (context, ref, child) { : Consumer(builder: (context, ref, child) {
final instanceList = ref.watch(pipedInstancesFutureProvider); final instanceList = ref.watch(pipedInstancesFutureProvider);
@ -129,7 +133,7 @@ class SettingsPlaybackSection extends HookConsumerWidget {
), ),
AnimatedSwitcher( AnimatedSwitcher(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
child: preferences.youtubeApiType == YoutubeApiType.youtube child: preferences.audioSource != AudioSource.piped
? const SizedBox.shrink() ? const SizedBox.shrink()
: AdaptiveSelectTile<SearchMode>( : AdaptiveSelectTile<SearchMode>(
secondary: const Icon(SpotubeIcons.search), secondary: const Icon(SpotubeIcons.search),
@ -149,17 +153,18 @@ class SettingsPlaybackSection extends HookConsumerWidget {
), ),
AnimatedSwitcher( AnimatedSwitcher(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
child: preferences.searchMode == SearchMode.youtubeMusic && child: preferences.searchMode == SearchMode.youtube &&
preferences.youtubeApiType == YoutubeApiType.piped (preferences.audioSource == AudioSource.piped ||
? const SizedBox.shrink() preferences.audioSource == AudioSource.youtube)
: SwitchListTile( ? SwitchListTile(
secondary: const Icon(SpotubeIcons.skip), secondary: const Icon(SpotubeIcons.skip),
title: Text(context.l10n.skip_non_music), title: Text(context.l10n.skip_non_music),
value: preferences.skipNonMusic, value: preferences.skipNonMusic,
onChanged: (state) { onChanged: (state) {
preferencesNotifier.setSkipNonMusic(state); preferencesNotifier.setSkipNonMusic(state);
}, },
), )
: const SizedBox.shrink(),
), ),
ListTile( ListTile(
leading: const Icon(SpotubeIcons.playlistRemove), leading: const Icon(SpotubeIcons.playlistRemove),
@ -176,12 +181,13 @@ class SettingsPlaybackSection extends HookConsumerWidget {
value: preferences.normalizeAudio, value: preferences.normalizeAudio,
onChanged: preferencesNotifier.setNormalizeAudio, onChanged: preferencesNotifier.setNormalizeAudio,
), ),
AdaptiveSelectTile<MusicCodec>( if (preferences.audioSource != AudioSource.jiosaavn)
AdaptiveSelectTile<SourceCodecs>(
secondary: const Icon(SpotubeIcons.stream), secondary: const Icon(SpotubeIcons.stream),
title: Text(context.l10n.streaming_music_codec), title: Text(context.l10n.streaming_music_codec),
value: preferences.streamMusicCodec, value: preferences.streamMusicCodec,
showValueWhenUnfolded: false, showValueWhenUnfolded: false,
options: MusicCodec.values options: SourceCodecs.values
.map((e) => DropdownMenuItem( .map((e) => DropdownMenuItem(
value: e, value: e,
child: Text( child: Text(
@ -195,12 +201,13 @@ class SettingsPlaybackSection extends HookConsumerWidget {
preferencesNotifier.setStreamMusicCodec(value); preferencesNotifier.setStreamMusicCodec(value);
}, },
), ),
AdaptiveSelectTile<MusicCodec>( if (preferences.audioSource != AudioSource.jiosaavn)
AdaptiveSelectTile<SourceCodecs>(
secondary: const Icon(SpotubeIcons.file), secondary: const Icon(SpotubeIcons.file),
title: Text(context.l10n.download_music_codec), title: Text(context.l10n.download_music_codec),
value: preferences.downloadMusicCodec, value: preferences.downloadMusicCodec,
showValueWhenUnfolded: false, showValueWhenUnfolded: false,
options: MusicCodec.values options: SourceCodecs.values
.map((e) => DropdownMenuItem( .map((e) => DropdownMenuItem(
value: e, value: e,
child: Text( child: Text(

View File

@ -9,25 +9,23 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:metadata_god/metadata_god.dart'; import 'package:metadata_god/metadata_god.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/models/spotube_track.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
import 'package:spotube/provider/youtube_provider.dart';
import 'package:spotube/services/download_manager/download_manager.dart'; import 'package:spotube/services/download_manager/download_manager.dart';
import 'package:spotube/services/youtube/youtube.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/primitive_utils.dart';
import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart';
class DownloadManagerProvider extends ChangeNotifier { class DownloadManagerProvider extends ChangeNotifier {
DownloadManagerProvider({required this.ref}) DownloadManagerProvider({required this.ref})
: $history = <SpotubeTrack>{}, : $history = <SourcedTrack>{},
$backHistory = <Track>{}, $backHistory = <Track>{},
dl = DownloadManager() { dl = DownloadManager() {
dl.statusStream.listen((event) async { dl.statusStream.listen((event) async {
final (:request, :status) = event; final (:request, :status) = event;
final track = $history.firstWhereOrNull( final track = $history.firstWhereOrNull(
(element) => element.ytUri == request.url, (element) => element.url == request.url,
); );
if (track == null) return; if (track == null) return;
@ -45,7 +43,7 @@ class DownloadManagerProvider extends ChangeNotifier {
//? WebA audiotagging is not supported yet //? WebA audiotagging is not supported yet
//? Although in future by converting weba to opus & then tagging it //? Although in future by converting weba to opus & then tagging it
//? is possible using vorbis comments //? is possible using vorbis comments
downloadCodec == MusicCodec.weba) return; downloadCodec == SourceCodecs.weba) return;
final file = File(request.path); final file = File(request.path);
@ -91,10 +89,9 @@ class DownloadManagerProvider extends ChangeNotifier {
final Ref<DownloadManagerProvider> ref; final Ref<DownloadManagerProvider> ref;
YoutubeEndpoints get yt => ref.read(youtubeProvider);
String get downloadDirectory => String get downloadDirectory =>
ref.read(userPreferencesProvider.select((s) => s.downloadLocation)); ref.read(userPreferencesProvider.select((s) => s.downloadLocation));
MusicCodec get downloadCodec => SourceCodecs get downloadCodec =>
ref.read(userPreferencesProvider.select((s) => s.downloadMusicCodec)); ref.read(userPreferencesProvider.select((s) => s.downloadMusicCodec));
int get $downloadCount => dl int get $downloadCount => dl
@ -107,7 +104,7 @@ class DownloadManagerProvider extends ChangeNotifier {
) )
.length; .length;
final Set<SpotubeTrack> $history; final Set<SourcedTrack> $history;
// these are the tracks which metadata hasn't been fetched yet // these are the tracks which metadata hasn't been fetched yet
final Set<Track> $backHistory; final Set<Track> $backHistory;
final DownloadManager dl; final DownloadManager dl;
@ -144,9 +141,9 @@ class DownloadManagerProvider extends ChangeNotifier {
bool isActive(Track track) { bool isActive(Track track) {
if ($backHistory.contains(track)) return true; if ($backHistory.contains(track)) return true;
final spotubeTrack = mapToSpotubeTrack(track); final sourcedTrack = mapToSourcedTrack(track);
if (spotubeTrack == null) return false; if (sourcedTrack == null) return false;
return dl return dl
.getAllDownloads() .getAllDownloads()
@ -157,7 +154,7 @@ class DownloadManagerProvider extends ChangeNotifier {
download.status.value == DownloadStatus.queued, download.status.value == DownloadStatus.queued,
) )
.map((e) => e.request.url) .map((e) => e.request.url)
.contains(spotubeTrack.ytUri); .contains(sourcedTrack.getUrlOfCodec(downloadCodec));
} }
/// For singular downloads /// For singular downloads
@ -173,21 +170,27 @@ class DownloadManagerProvider extends ChangeNotifier {
await oldFile.rename("$savePath.old"); await oldFile.rename("$savePath.old");
} }
if (track is SpotubeTrack && track.codec == downloadCodec) { if (track is SourcedTrack && track.codec == downloadCodec) {
final downloadTask = await dl.addDownload(track.ytUri, savePath); final downloadTask =
await dl.addDownload(track.getUrlOfCodec(downloadCodec), savePath);
if (downloadTask != null) { if (downloadTask != null) {
$history.add(track); $history.add(track);
} }
} else { } else {
$backHistory.add(track); $backHistory.add(track);
final spotubeTrack = final sourcedTrack = await SourcedTrack.fetchFromTrack(
await SpotubeTrack.fetchFromTrack(track, yt, downloadCodec).then((d) { ref: ref,
track: track,
).then((d) {
$backHistory.remove(track); $backHistory.remove(track);
return d; return d;
}); });
final downloadTask = await dl.addDownload(spotubeTrack.ytUri, savePath); final downloadTask = await dl.addDownload(
sourcedTrack.getUrlOfCodec(downloadCodec),
savePath,
);
if (downloadTask != null) { if (downloadTask != null) {
$history.add(spotubeTrack); $history.add(sourcedTrack);
} }
} }
@ -196,7 +199,7 @@ class DownloadManagerProvider extends ChangeNotifier {
Future<void> batchAddToQueue(List<Track> tracks) async { Future<void> batchAddToQueue(List<Track> tracks) async {
$backHistory.addAll( $backHistory.addAll(
tracks.where((element) => element is! SpotubeTrack), tracks.where((element) => element is! SourcedTrack),
); );
notifyListeners(); notifyListeners();
for (final track in tracks) { for (final track in tracks) {
@ -216,25 +219,25 @@ class DownloadManagerProvider extends ChangeNotifier {
} }
} }
Future<void> removeFromQueue(SpotubeTrack track) async { Future<void> removeFromQueue(SourcedTrack track) async {
await dl.removeDownload(track.ytUri); await dl.removeDownload(track.getUrlOfCodec(downloadCodec));
$history.remove(track); $history.remove(track);
} }
Future<void> pause(SpotubeTrack track) { Future<void> pause(SourcedTrack track) {
return dl.pauseDownload(track.ytUri); return dl.pauseDownload(track.getUrlOfCodec(downloadCodec));
} }
Future<void> resume(SpotubeTrack track) { Future<void> resume(SourcedTrack track) {
return dl.resumeDownload(track.ytUri); return dl.resumeDownload(track.getUrlOfCodec(downloadCodec));
} }
Future<void> retry(SpotubeTrack track) { Future<void> retry(SourcedTrack track) {
return addToQueue(track); return addToQueue(track);
} }
void cancel(SpotubeTrack track) { void cancel(SourcedTrack track) {
dl.cancelDownload(track.ytUri); dl.cancelDownload(track.getUrlOfCodec(downloadCodec));
} }
void cancelAll() { void cancelAll() {
@ -244,20 +247,20 @@ class DownloadManagerProvider extends ChangeNotifier {
} }
} }
SpotubeTrack? mapToSpotubeTrack(Track track) { SourcedTrack? mapToSourcedTrack(Track track) {
if (track is SpotubeTrack) { if (track is SourcedTrack) {
return track; return track;
} else { } else {
return $history.firstWhereOrNull((element) => element.id == track.id); return $history.firstWhereOrNull((element) => element.id == track.id);
} }
} }
ValueNotifier<DownloadStatus>? getStatusNotifier(SpotubeTrack track) { ValueNotifier<DownloadStatus>? getStatusNotifier(SourcedTrack track) {
return dl.getDownload(track.ytUri)?.status; return dl.getDownload(track.getUrlOfCodec(downloadCodec))?.status;
} }
ValueNotifier<double>? getProgressNotifier(SpotubeTrack track) { ValueNotifier<double>? getProgressNotifier(SourcedTrack track) {
return dl.getDownload(track.ytUri)?.progress; return dl.getDownload(track.getUrlOfCodec(downloadCodec))?.progress;
} }
} }

View File

@ -1,10 +1,11 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:piped_client/piped_client.dart'; import 'package:piped_client/piped_client.dart';
import 'package:spotube/provider/youtube_provider.dart'; import 'package:spotube/services/sourced_track/sources/piped.dart';
final pipedInstancesFutureProvider = FutureProvider<List<PipedInstance>>( final pipedInstancesFutureProvider = FutureProvider<List<PipedInstance>>(
(ref) async { (ref) async {
final youtube = ref.watch(youtubeProvider); final pipedClient = ref.watch(pipedProvider);
return await youtube.piped?.instanceList() ?? [];
return await pipedClient.instanceList();
}, },
); );

View File

@ -3,36 +3,30 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/models/local_track.dart'; import 'package:spotube/models/local_track.dart';
import 'package:spotube/models/logger.dart'; import 'package:spotube/models/logger.dart';
import 'package:spotube/models/matched_track.dart';
import 'package:spotube/models/spotube_track.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart';
import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart';
import 'package:spotube/services/supabase.dart';
import 'package:spotube/services/youtube/youtube.dart';
final logger = getLogger("NextFetcherMixin"); final logger = getLogger("NextFetcherMixin");
mixin NextFetcher on StateNotifier<ProxyPlaylist> { mixin NextFetcher on StateNotifier<ProxyPlaylist> {
Future<List<SpotubeTrack>> fetchTracks( Future<List<SourcedTrack>> fetchTracks(
UserPreferences preferences, Ref ref, {
YoutubeEndpoints youtube, {
int count = 3, int count = 3,
int offset = 0, int offset = 0,
}) async { }) async {
/// get [count] [state.tracks] that are not [SpotubeTrack] and [LocalTrack] /// get [count] [state.tracks] that are not [SourcedTrack] and [LocalTrack]
final bareTracks = state.tracks final bareTracks = state.tracks
.skip(offset) .skip(offset)
.where((element) => element is! SpotubeTrack && element is! LocalTrack) .where((element) => element is! SourcedTrack && element is! LocalTrack)
.take(count); .take(count);
/// fetch [bareTracks] one by one with 100ms delay /// fetch [bareTracks] one by one with 100ms delay
final fetchedTracks = await Future.wait( final fetchedTracks = await Future.wait(
bareTracks.mapIndexed((i, track) async { bareTracks.mapIndexed((i, track) async {
final future = SpotubeTrack.fetchFromTrack( final future = SourcedTrack.fetchFromTrack(
track, ref: ref,
youtube, track: track,
preferences.streamMusicCodec,
); );
if (i == 0) { if (i == 0) {
return await future; return await future;
@ -47,9 +41,9 @@ mixin NextFetcher on StateNotifier<ProxyPlaylist> {
return fetchedTracks; return fetchedTracks;
} }
/// Merges List of [SpotubeTrack]s with [Track]s and outputs a mixed List /// Merges List of [SourcedTrack]s with [Track]s and outputs a mixed List
Set<Track> mergeTracks( Set<Track> mergeTracks(
Iterable<SpotubeTrack> fetchTracks, Iterable<SourcedTrack> fetchTracks,
Iterable<Track> tracks, Iterable<Track> tracks,
) { ) {
return tracks.map((track) { return tracks.map((track) {
@ -80,12 +74,12 @@ mixin NextFetcher on StateNotifier<ProxyPlaylist> {
/// Returns appropriate Media source for [Track] /// Returns appropriate Media source for [Track]
/// ///
/// * If [Track] is [SpotubeTrack] then return [SpotubeTrack.ytUri] /// * If [Track] is [SourcedTrack] then return [SourcedTrack.ytUri]
/// * If [Track] is [LocalTrack] then return [LocalTrack.path] /// * If [Track] is [LocalTrack] then return [LocalTrack.path]
/// * If [Track] is [Track] then return [Track.id] with [isUnPlayable] source /// * If [Track] is [Track] then return [Track.id] with [isUnPlayable] source
String makeAppropriateSource(Track track) { String makeAppropriateSource(Track track) {
if (track is SpotubeTrack) { if (track is SourcedTrack) {
return track.ytUri; return track.url;
} else if (track is LocalTrack) { } else if (track is LocalTrack) {
return track.path; return track.path;
} else { } else {
@ -103,7 +97,7 @@ mixin NextFetcher on StateNotifier<ProxyPlaylist> {
final track = state.tracks.firstWhereOrNull( final track = state.tracks.firstWhereOrNull(
(track) => (track) =>
trackToUnplayableSource(track) == source || trackToUnplayableSource(track) == source ||
(track is SpotubeTrack && track.ytUri == source) || (track is SourcedTrack && track.url == source) ||
(track is LocalTrack && track.path == source), (track is LocalTrack && track.path == source),
); );
return track; return track;
@ -111,23 +105,4 @@ mixin NextFetcher on StateNotifier<ProxyPlaylist> {
.whereNotNull() .whereNotNull()
.toList(); .toList();
} }
/// This method must be called after any playback operation as
/// it can increase the latency
Future<void> storeTrack(Track track, SpotubeTrack spotubeTrack) async {
try {
if (track is! SpotubeTrack) {
await supabase.insertTrack(
MatchedTrack(
youtubeId: spotubeTrack.ytTrack.id,
spotifyId: spotubeTrack.id!,
searchMode: spotubeTrack.ytTrack.searchMode,
),
);
}
} catch (e, stackTrace) {
logger.e(e.toString());
logger.t(stackTrace);
}
}
} }

View File

@ -1,8 +1,9 @@
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/extensions/track.dart'; import 'package:spotube/extensions/track.dart';
import 'package:spotube/models/local_track.dart'; import 'package:spotube/models/local_track.dart';
import 'package:spotube/models/spotube_track.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart';
class ProxyPlaylist { class ProxyPlaylist {
final Set<Track> tracks; final Set<Track> tracks;
@ -11,11 +12,14 @@ class ProxyPlaylist {
ProxyPlaylist(this.tracks, [this.active, this.collections = const {}]); ProxyPlaylist(this.tracks, [this.active, this.collections = const {}]);
factory ProxyPlaylist.fromJson(Map<String, dynamic> json) { factory ProxyPlaylist.fromJson(
Map<String, dynamic> json,
Ref ref,
) {
return ProxyPlaylist( return ProxyPlaylist(
List.castFrom<dynamic, Map<String, dynamic>>( List.castFrom<dynamic, Map<String, dynamic>>(
json['tracks'] ?? <Map<String, dynamic>>[], json['tracks'] ?? <Map<String, dynamic>>[],
).map(_makeAppropriateTrack).toSet(), ).map((t) => _makeAppropriateTrack(t, ref)).toSet(),
json['active'] as int?, json['active'] as int?,
json['collections'] == null json['collections'] == null
? {} ? {}
@ -28,7 +32,7 @@ class ProxyPlaylist {
bool get isFetching => bool get isFetching =>
activeTrack != null && activeTrack != null &&
activeTrack is! SpotubeTrack && activeTrack is! SourcedTrack &&
activeTrack is! LocalTrack; activeTrack is! LocalTrack;
bool containsCollection(String collection) { bool containsCollection(String collection) {
@ -44,9 +48,9 @@ class ProxyPlaylist {
return tracks.every(containsTrack); return tracks.every(containsTrack);
} }
static Track _makeAppropriateTrack(Map<String, dynamic> track) { static Track _makeAppropriateTrack(Map<String, dynamic> track, Ref ref) {
if (track.containsKey("ytUri")) { if (track.containsKey("ytUri")) {
return SpotubeTrack.fromJson(track); return SourcedTrack.fromJson(track, ref: ref);
} else if (track.containsKey("path")) { } else if (track.containsKey("path")) {
return LocalTrack.fromJson(track); return LocalTrack.fromJson(track);
} else { } else {
@ -59,7 +63,7 @@ class ProxyPlaylist {
static Map<String, dynamic> _makeAppropriateTrackJson(Track track) { static Map<String, dynamic> _makeAppropriateTrackJson(Track track) {
return switch (track.runtimeType) { return switch (track.runtimeType) {
LocalTrack => track.toJson(), LocalTrack => track.toJson(),
SpotubeTrack => track.toJson(), SourcedTrack => track.toJson(),
_ => track.toJson(), _ => track.toJson(),
}; };
} }

View File

@ -12,9 +12,10 @@ import 'package:spotify/spotify.dart';
import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/models/local_track.dart'; import 'package:spotube/models/local_track.dart';
import 'package:spotube/models/logger.dart'; import 'package:spotube/models/logger.dart';
import 'package:spotube/models/matched_track.dart';
import 'package:spotube/models/skip_segment.dart'; import 'package:spotube/models/skip_segment.dart';
import 'package:spotube/models/spotube_track.dart'; import 'package:spotube/models/source_match.dart';
import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/blacklist_provider.dart';
import 'package:spotube/provider/palette_provider.dart'; import 'package:spotube/provider/palette_provider.dart';
import 'package:spotube/provider/proxy_playlist/next_fetcher_mixin.dart'; import 'package:spotube/provider/proxy_playlist/next_fetcher_mixin.dart';
@ -22,17 +23,20 @@ import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart';
import 'package:spotube/provider/scrobbler_provider.dart'; import 'package:spotube/provider/scrobbler_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
import 'package:spotube/provider/youtube_provider.dart';
import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/audio_player.dart';
import 'package:spotube/services/audio_services/audio_services.dart'; import 'package:spotube/services/audio_services/audio_services.dart';
import 'package:spotube/services/youtube/youtube.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/sourced_track.dart';
import 'package:spotube/services/supabase.dart';
import 'package:spotube/utils/persisted_state_notifier.dart'; import 'package:spotube/utils/persisted_state_notifier.dart';
import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart';
/// Things implemented: /// Things implemented:
/// * [x] Sponsor-Block skip /// * [x] Sponsor-Block skip
/// * [x] Prefetch next track as [SpotubeTrack] on 80% of current track /// * [x] Prefetch next track as [SourcedTrack] on 80% of current track
/// * [x] Mixed Queue containing both [SpotubeTrack] and [LocalTrack] /// * [x] Mixed Queue containing both [SourcedTrack] and [LocalTrack]
/// * [x] Modification of the Queue /// * [x] Modification of the Queue
/// * [x] Add track at the end /// * [x] Add track at the end
/// * [x] Add track at the beginning /// * [x] Add track at the beginning
@ -56,7 +60,6 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
ScrobblerNotifier get scrobbler => ref.read(scrobblerProvider.notifier); ScrobblerNotifier get scrobbler => ref.read(scrobblerProvider.notifier);
UserPreferences get preferences => ref.read(userPreferencesProvider); UserPreferences get preferences => ref.read(userPreferencesProvider);
YoutubeEndpoints get youtube => ref.read(youtubeProvider);
ProxyPlaylist get playlist => state; ProxyPlaylist get playlist => state;
BlackListNotifier get blacklist => BlackListNotifier get blacklist =>
ref.read(BlackListNotifier.provider.notifier); ref.read(BlackListNotifier.provider.notifier);
@ -168,11 +171,11 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
return; return;
} }
try { try {
final isYTMusicMode = final isNotYTMode = preferences.audioSource != AudioSource.youtube ||
preferences.youtubeApiType == YoutubeApiType.piped && (preferences.audioSource == AudioSource.piped &&
preferences.searchMode == SearchMode.youtubeMusic; preferences.searchMode == SearchMode.youtubeMusic);
if (isYTMusicMode || !preferences.skipNonMusic) return; if (isNotYTMode || !preferences.skipNonMusic) return;
final isNotSameSegmentId = final isNotSameSegmentId =
currentSegments.value?.source != audioPlayer.currentSource; currentSegments.value?.source != audioPlayer.currentSource;
@ -184,7 +187,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
currentSegments.value = ( currentSegments.value = (
source: audioPlayer.currentSource!, source: audioPlayer.currentSource!,
segments: await getAndCacheSkipSegments( segments: await getAndCacheSkipSegments(
(state.activeTrack as SpotubeTrack).ytTrack.id, (state.activeTrack as SourcedTrack).sourceInfo.id,
), ),
); );
} catch (e) { } catch (e) {
@ -237,7 +240,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
}(); }();
} }
Future<SpotubeTrack?> ensureSourcePlayable(String source) async { Future<SourcedTrack?> ensureSourcePlayable(String source) async {
if (isPlayable(source)) return null; if (isPlayable(source)) return null;
final track = mapSourcesToTracks([source]).firstOrNull; final track = mapSourcesToTracks([source]).firstOrNull;
@ -247,17 +250,13 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
} }
final nthFetchedTrack = switch (track.runtimeType) { final nthFetchedTrack = switch (track.runtimeType) {
SpotubeTrack => track as SpotubeTrack, SourcedTrack => track as SourcedTrack,
_ => await SpotubeTrack.fetchFromTrack( _ => await SourcedTrack.fetchFromTrack(ref: ref, track: track),
track,
youtube,
preferences.streamMusicCodec,
),
}; };
await audioPlayer.replaceSource( await audioPlayer.replaceSource(
source, source,
nthFetchedTrack.ytUri, nthFetchedTrack.url,
); );
return nthFetchedTrack; return nthFetchedTrack;
@ -335,15 +334,13 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
); );
await notificationService.addTrack(indexTrack); await notificationService.addTrack(indexTrack);
} else { } else {
final addableTrack = await SpotubeTrack.fetchFromTrack( final addableTrack = await SourcedTrack.fetchFromTrack(
tracks.elementAtOrNull(initialIndex) ?? tracks.first, ref: ref,
youtube, track: tracks.elementAtOrNull(initialIndex) ?? tracks.first,
preferences.streamMusicCodec,
).catchError((e, stackTrace) { ).catchError((e, stackTrace) {
return SpotubeTrack.fetchFromTrack( return SourcedTrack.fetchFromTrack(
tracks.elementAtOrNull(initialIndex + 1) ?? tracks.first, ref: ref,
youtube, track: tracks.elementAtOrNull(initialIndex + 1) ?? tracks.first,
preferences.streamMusicCodec,
); );
}); });
@ -437,9 +434,9 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
} }
Future<void> populateSibling() async { Future<void> populateSibling() async {
if (state.activeTrack is SpotubeTrack) { if (state.activeTrack is SourcedTrack) {
final activeTrackWithSiblingsForSure = final activeTrackWithSiblingsForSure =
await (state.activeTrack as SpotubeTrack).populatedCopy(youtube); await (state.activeTrack as SourcedTrack).copyWithSibling();
state = state.copyWith( state = state.copyWith(
tracks: mergeTracks([activeTrackWithSiblingsForSure], state.tracks), tracks: mergeTracks([activeTrackWithSiblingsForSure], state.tracks),
@ -449,11 +446,11 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
} }
} }
Future<void> swapSibling(YoutubeVideoInfo video) async { Future<void> swapSibling(SourceInfo sibling) async {
if (state.activeTrack is SpotubeTrack) { if (state.activeTrack is SourcedTrack) {
await populateSibling(); await populateSibling();
final newTrack = final newTrack =
await (state.activeTrack as SpotubeTrack).swappedCopy(video, youtube); await (state.activeTrack as SourcedTrack).swapWithSibling(sibling);
if (newTrack == null) return; if (newTrack == null) return;
state = state.copyWith( state = state.copyWith(
tracks: mergeTracks([newTrack], state.tracks), tracks: mergeTracks([newTrack], state.tracks),
@ -564,7 +561,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
Future<List<SkipSegment>> getAndCacheSkipSegments(String id) async { Future<List<SkipSegment>> getAndCacheSkipSegments(String id) async {
if (!preferences.skipNonMusic || if (!preferences.skipNonMusic ||
(preferences.youtubeApiType == YoutubeApiType.piped && (preferences.audioSource == AudioSource.piped &&
preferences.searchMode == SearchMode.youtubeMusic)) return []; preferences.searchMode == SearchMode.youtubeMusic)) return [];
try { try {
@ -628,6 +625,30 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
} }
} }
/// This method must be called after any playback operation as
/// it can increase the latency
Future<void> storeTrack(Track track, SourcedTrack sourcedTrack) async {
try {
if (track is! SourcedTrack) {
await supabase.insertTrack(
SourceMatch(
id: sourcedTrack.id!,
createdAt: DateTime.now(),
sourceId: sourcedTrack.sourceInfo.id,
sourceType: preferences.audioSource == AudioSource.jiosaavn
? SourceType.jiosaavn
: preferences.searchMode == SearchMode.youtube
? SourceType.youtube
: SourceType.youtubeMusic,
),
);
}
} catch (e, stackTrace) {
logger.e(e.toString());
logger.t(stackTrace);
}
}
@override @override
set state(state) { set state(state) {
super.state = state; super.state = state;
@ -652,7 +673,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
@override @override
FutureOr<ProxyPlaylist> fromJson(Map<String, dynamic> json) { FutureOr<ProxyPlaylist> fromJson(Map<String, dynamic> json) {
return ProxyPlaylist.fromJson(json); return ProxyPlaylist.fromJson(json, ref);
} }
@override @override

View File

@ -6,11 +6,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/components/settings/color_scheme_picker_dialog.dart'; import 'package:spotube/components/settings/color_scheme_picker_dialog.dart';
import 'package:spotube/models/matched_track.dart';
import 'package:spotube/provider/palette_provider.dart'; import 'package:spotube/provider/palette_provider.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/audio_player.dart';
import 'package:spotube/services/sourced_track/enums.dart';
import 'package:spotube/utils/persisted_state_notifier.dart'; import 'package:spotube/utils/persisted_state_notifier.dart';
import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/platform.dart';
@ -26,11 +26,11 @@ class UserPreferencesNotifier extends PersistedStateNotifier<UserPreferences> {
state = UserPreferences.withDefaults(); state = UserPreferences.withDefaults();
} }
void setStreamMusicCodec(MusicCodec codec) { void setStreamMusicCodec(SourceCodecs codec) {
state = state.copyWith(streamMusicCodec: codec); state = state.copyWith(streamMusicCodec: codec);
} }
void setDownloadMusicCodec(MusicCodec codec) { void setDownloadMusicCodec(SourceCodecs codec) {
state = state.copyWith(downloadMusicCodec: codec); state = state.copyWith(downloadMusicCodec: codec);
} }
@ -60,7 +60,7 @@ class UserPreferencesNotifier extends PersistedStateNotifier<UserPreferences> {
state = state.copyWith(checkUpdate: check); state = state.copyWith(checkUpdate: check);
} }
void setAudioQuality(AudioQuality quality) { void setAudioQuality(SourceQualities quality) {
state = state.copyWith(audioQuality: quality); state = state.copyWith(audioQuality: quality);
} }
@ -97,8 +97,8 @@ class UserPreferencesNotifier extends PersistedStateNotifier<UserPreferences> {
state = state.copyWith(skipNonMusic: skip); state = state.copyWith(skipNonMusic: skip);
} }
void setYoutubeApiType(YoutubeApiType type) { void setAudioSource(AudioSource type) {
state = state.copyWith(youtubeApiType: type); state = state.copyWith(audioSource: type);
} }
void setSystemTitleBar(bool isSystemTitleBar) { void setSystemTitleBar(bool isSystemTitleBar) {

View File

@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
import 'package:json_annotation/json_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/components/settings/color_scheme_picker_dialog.dart'; import 'package:spotube/components/settings/color_scheme_picker_dialog.dart';
import 'package:spotube/models/matched_track.dart'; import 'package:spotube/services/sourced_track/enums.dart';
part 'user_preferences_state.g.dart'; part 'user_preferences_state.g.dart';
@ -15,12 +15,6 @@ enum LayoutMode {
adaptive, adaptive,
} }
@JsonEnum()
enum AudioQuality {
high,
low,
}
@JsonEnum() @JsonEnum()
enum CloseBehavior { enum CloseBehavior {
minimizeToTray, minimizeToTray,
@ -28,9 +22,10 @@ enum CloseBehavior {
} }
@JsonEnum() @JsonEnum()
enum YoutubeApiType { enum AudioSource {
youtube, youtube,
piped; piped,
jiosaavn;
String get label => name[0].toUpperCase() + name.substring(1); String get label => name[0].toUpperCase() + name.substring(1);
} }
@ -44,13 +39,27 @@ enum MusicCodec {
const MusicCodec._(this.label); const MusicCodec._(this.label);
} }
@JsonEnum()
enum SearchMode {
youtube._("YouTube"),
youtubeMusic._("YouTube Music");
final String label;
const SearchMode._(this.label);
factory SearchMode.fromString(String key) {
return SearchMode.values.firstWhere((e) => e.name == key);
}
}
@JsonSerializable() @JsonSerializable()
final class UserPreferences { final class UserPreferences {
@JsonKey( @JsonKey(
defaultValue: AudioQuality.high, defaultValue: SourceQualities.high,
unknownEnumValue: AudioQuality.high, unknownEnumValue: SourceQualities.high,
) )
final AudioQuality audioQuality; final SourceQualities audioQuality;
@JsonKey(defaultValue: true) @JsonKey(defaultValue: true)
final bool albumColorSync; final bool albumColorSync;
@ -172,22 +181,22 @@ final class UserPreferences {
final ThemeMode themeMode; final ThemeMode themeMode;
@JsonKey( @JsonKey(
defaultValue: YoutubeApiType.youtube, defaultValue: AudioSource.youtube,
unknownEnumValue: YoutubeApiType.youtube, unknownEnumValue: AudioSource.youtube,
) )
final YoutubeApiType youtubeApiType; final AudioSource audioSource;
@JsonKey( @JsonKey(
defaultValue: MusicCodec.weba, defaultValue: SourceCodecs.weba,
unknownEnumValue: MusicCodec.weba, unknownEnumValue: SourceCodecs.weba,
) )
final MusicCodec streamMusicCodec; final SourceCodecs streamMusicCodec;
@JsonKey( @JsonKey(
defaultValue: MusicCodec.m4a, defaultValue: SourceCodecs.m4a,
unknownEnumValue: MusicCodec.m4a, unknownEnumValue: SourceCodecs.m4a,
) )
final MusicCodec downloadMusicCodec; final SourceCodecs downloadMusicCodec;
UserPreferences({ UserPreferences({
required this.audioQuality, required this.audioQuality,
@ -207,7 +216,7 @@ final class UserPreferences {
required this.downloadLocation, required this.downloadLocation,
required this.pipedInstance, required this.pipedInstance,
required this.themeMode, required this.themeMode,
required this.youtubeApiType, required this.audioSource,
required this.streamMusicCodec, required this.streamMusicCodec,
required this.downloadMusicCodec, required this.downloadMusicCodec,
}); });
@ -229,7 +238,7 @@ final class UserPreferences {
SpotubeColor? accentColorScheme, SpotubeColor? accentColorScheme,
bool? albumColorSync, bool? albumColorSync,
bool? checkUpdate, bool? checkUpdate,
AudioQuality? audioQuality, SourceQualities? audioQuality,
String? downloadLocation, String? downloadLocation,
LayoutMode? layoutMode, LayoutMode? layoutMode,
CloseBehavior? closeBehavior, CloseBehavior? closeBehavior,
@ -238,13 +247,13 @@ final class UserPreferences {
String? pipedInstance, String? pipedInstance,
SearchMode? searchMode, SearchMode? searchMode,
bool? skipNonMusic, bool? skipNonMusic,
YoutubeApiType? youtubeApiType, AudioSource? audioSource,
Market? recommendationMarket, Market? recommendationMarket,
bool? saveTrackLyrics, bool? saveTrackLyrics,
bool? amoledDarkTheme, bool? amoledDarkTheme,
bool? normalizeAudio, bool? normalizeAudio,
MusicCodec? downloadMusicCodec, SourceCodecs? downloadMusicCodec,
MusicCodec? streamMusicCodec, SourceCodecs? streamMusicCodec,
bool? systemTitleBar, bool? systemTitleBar,
}) { }) {
return UserPreferences( return UserPreferences(
@ -261,7 +270,7 @@ final class UserPreferences {
pipedInstance: pipedInstance ?? this.pipedInstance, pipedInstance: pipedInstance ?? this.pipedInstance,
searchMode: searchMode ?? this.searchMode, searchMode: searchMode ?? this.searchMode,
skipNonMusic: skipNonMusic ?? this.skipNonMusic, skipNonMusic: skipNonMusic ?? this.skipNonMusic,
youtubeApiType: youtubeApiType ?? this.youtubeApiType, audioSource: audioSource ?? this.audioSource,
recommendationMarket: recommendationMarket ?? this.recommendationMarket, recommendationMarket: recommendationMarket ?? this.recommendationMarket,
amoledDarkTheme: amoledDarkTheme ?? this.amoledDarkTheme, amoledDarkTheme: amoledDarkTheme ?? this.amoledDarkTheme,
downloadMusicCodec: downloadMusicCodec ?? this.downloadMusicCodec, downloadMusicCodec: downloadMusicCodec ?? this.downloadMusicCodec,

View File

@ -9,9 +9,9 @@ part of 'user_preferences_state.dart';
UserPreferences _$UserPreferencesFromJson(Map<String, dynamic> json) => UserPreferences _$UserPreferencesFromJson(Map<String, dynamic> json) =>
UserPreferences( UserPreferences(
audioQuality: $enumDecodeNullable( audioQuality: $enumDecodeNullable(
_$AudioQualityEnumMap, json['audioQuality'], _$SourceQualitiesEnumMap, json['audioQuality'],
unknownValue: AudioQuality.high) ?? unknownValue: SourceQualities.high) ??
AudioQuality.high, SourceQualities.high,
albumColorSync: json['albumColorSync'] as bool? ?? true, albumColorSync: json['albumColorSync'] as bool? ?? true,
amoledDarkTheme: json['amoledDarkTheme'] as bool? ?? false, amoledDarkTheme: json['amoledDarkTheme'] as bool? ?? false,
checkUpdate: json['checkUpdate'] as bool? ?? true, checkUpdate: json['checkUpdate'] as bool? ?? true,
@ -51,23 +51,23 @@ UserPreferences _$UserPreferencesFromJson(Map<String, dynamic> json) =>
themeMode: $enumDecodeNullable(_$ThemeModeEnumMap, json['themeMode'], themeMode: $enumDecodeNullable(_$ThemeModeEnumMap, json['themeMode'],
unknownValue: ThemeMode.system) ?? unknownValue: ThemeMode.system) ??
ThemeMode.system, ThemeMode.system,
youtubeApiType: $enumDecodeNullable( audioSource: $enumDecodeNullable(
_$YoutubeApiTypeEnumMap, json['youtubeApiType'], _$AudioSourceEnumMap, json['audioSource'],
unknownValue: YoutubeApiType.youtube) ?? unknownValue: AudioSource.youtube) ??
YoutubeApiType.youtube, AudioSource.youtube,
streamMusicCodec: $enumDecodeNullable( streamMusicCodec: $enumDecodeNullable(
_$MusicCodecEnumMap, json['streamMusicCodec'], _$SourceCodecsEnumMap, json['streamMusicCodec'],
unknownValue: MusicCodec.weba) ?? unknownValue: SourceCodecs.weba) ??
MusicCodec.weba, SourceCodecs.weba,
downloadMusicCodec: $enumDecodeNullable( downloadMusicCodec: $enumDecodeNullable(
_$MusicCodecEnumMap, json['downloadMusicCodec'], _$SourceCodecsEnumMap, json['downloadMusicCodec'],
unknownValue: MusicCodec.m4a) ?? unknownValue: SourceCodecs.m4a) ??
MusicCodec.m4a, SourceCodecs.m4a,
); );
Map<String, dynamic> _$UserPreferencesToJson(UserPreferences instance) => Map<String, dynamic> _$UserPreferencesToJson(UserPreferences instance) =>
<String, dynamic>{ <String, dynamic>{
'audioQuality': _$AudioQualityEnumMap[instance.audioQuality]!, 'audioQuality': _$SourceQualitiesEnumMap[instance.audioQuality]!,
'albumColorSync': instance.albumColorSync, 'albumColorSync': instance.albumColorSync,
'amoledDarkTheme': instance.amoledDarkTheme, 'amoledDarkTheme': instance.amoledDarkTheme,
'checkUpdate': instance.checkUpdate, 'checkUpdate': instance.checkUpdate,
@ -85,14 +85,15 @@ Map<String, dynamic> _$UserPreferencesToJson(UserPreferences instance) =>
'downloadLocation': instance.downloadLocation, 'downloadLocation': instance.downloadLocation,
'pipedInstance': instance.pipedInstance, 'pipedInstance': instance.pipedInstance,
'themeMode': _$ThemeModeEnumMap[instance.themeMode]!, 'themeMode': _$ThemeModeEnumMap[instance.themeMode]!,
'youtubeApiType': _$YoutubeApiTypeEnumMap[instance.youtubeApiType]!, 'audioSource': _$AudioSourceEnumMap[instance.audioSource]!,
'streamMusicCodec': _$MusicCodecEnumMap[instance.streamMusicCodec]!, 'streamMusicCodec': _$SourceCodecsEnumMap[instance.streamMusicCodec]!,
'downloadMusicCodec': _$MusicCodecEnumMap[instance.downloadMusicCodec]!, 'downloadMusicCodec': _$SourceCodecsEnumMap[instance.downloadMusicCodec]!,
}; };
const _$AudioQualityEnumMap = { const _$SourceQualitiesEnumMap = {
AudioQuality.high: 'high', SourceQualities.high: 'high',
AudioQuality.low: 'low', SourceQualities.medium: 'medium',
SourceQualities.low: 'low',
}; };
const _$CloseBehaviorEnumMap = { const _$CloseBehaviorEnumMap = {
@ -370,12 +371,13 @@ const _$ThemeModeEnumMap = {
ThemeMode.dark: 'dark', ThemeMode.dark: 'dark',
}; };
const _$YoutubeApiTypeEnumMap = { const _$AudioSourceEnumMap = {
YoutubeApiType.youtube: 'youtube', AudioSource.youtube: 'youtube',
YoutubeApiType.piped: 'piped', AudioSource.piped: 'piped',
AudioSource.jiosaavn: 'jiosaavn',
}; };
const _$MusicCodecEnumMap = { const _$SourceCodecsEnumMap = {
MusicCodec.m4a: 'm4a', SourceCodecs.m4a: 'm4a',
MusicCodec.weba: 'weba', SourceCodecs.weba: 'weba',
}; };

View File

@ -1,8 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/services/youtube/youtube.dart';
final youtubeProvider = Provider<YoutubeEndpoints>((ref) {
final preferences = ref.watch(userPreferencesProvider);
return YoutubeEndpoints(preferences);
});

View File

@ -5,9 +5,9 @@ import 'dart:async';
import 'package:media_kit/media_kit.dart' as mk; import 'package:media_kit/media_kit.dart' as mk;
import 'package:spotube/models/spotube_track.dart';
import 'package:spotube/services/audio_player/loop_mode.dart'; import 'package:spotube/services/audio_player/loop_mode.dart';
import 'package:spotube/services/audio_player/playback_state.dart'; import 'package:spotube/services/audio_player/playback_state.dart';
import 'package:spotube/services/sourced_track/sourced_track.dart';
part 'audio_players_streams_mixin.dart'; part 'audio_players_streams_mixin.dart';
part 'audio_player_impl.dart'; part 'audio_player_impl.dart';

View File

@ -121,11 +121,13 @@ class SpotubeAudioPlayer extends AudioPlayerInterface
// } // }
} }
List<SpotubeTrack> resolveTracksForSource(List<SpotubeTrack> tracks) { // TODO: Make sure audio player soruces are also
return tracks.where((e) => sources.contains(e.ytUri)).toList(); // TODO: changed when preferences sources are changed
List<SourcedTrack> resolveTracksForSource(List<SourcedTrack> tracks) {
return tracks.where((e) => sources.contains(e.url)).toList();
} }
bool tracksExistsInPlaylist(List<SpotubeTrack> tracks) { bool tracksExistsInPlaylist(List<SourcedTrack> tracks) {
return resolveTracksForSource(tracks).length == tracks.length; return resolveTracksForSource(tracks).length == tracks.length;
} }

View File

@ -2,10 +2,10 @@ import 'package:audio_service/audio_service.dart';
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/models/spotube_track.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/services/audio_services/mobile_audio_service.dart'; import 'package:spotube/services/audio_services/mobile_audio_service.dart';
import 'package:spotube/services/audio_services/windows_audio_service.dart'; import 'package:spotube/services/audio_services/windows_audio_service.dart';
import 'package:spotube/services/sourced_track/sourced_track.dart';
import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart';
class AudioServices { class AudioServices {
@ -47,8 +47,8 @@ class AudioServices {
album: track.album?.name ?? "", album: track.album?.name ?? "",
title: track.name!, title: track.name!,
artist: TypeConversionUtils.artists_X_String(track.artists ?? <Artist>[]), artist: TypeConversionUtils.artists_X_String(track.artists ?? <Artist>[]),
duration: track is SpotubeTrack duration: track is SourcedTrack
? track.ytTrack.duration ? track.sourceInfo.duration
: Duration(milliseconds: track.durationMs ?? 0), : Duration(milliseconds: track.durationMs ?? 0),
artUri: Uri.parse(TypeConversionUtils.image_X_UrlString( artUri: Uri.parse(TypeConversionUtils.image_X_UrlString(
track.album?.images ?? <Image>[], track.album?.images ?? <Image>[],

View File

@ -3,13 +3,12 @@ import 'dart:io';
import 'package:dbus/dbus.dart'; import 'package:dbus/dbus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotube/models/spotube_track.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/audio_player.dart';
import 'package:spotube/services/audio_player/loop_mode.dart'; import 'package:spotube/services/audio_player/loop_mode.dart';
import 'package:spotube/services/sourced_track/sourced_track.dart';
import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart';
import 'package:window_manager/window_manager.dart';
final dbus = DBusClient.session(); final dbus = DBusClient.session();
@ -321,8 +320,8 @@ class _MprisMediaPlayer2Player extends DBusObject {
), ),
"xesam:title": DBusString(playlist.activeTrack!.name!), "xesam:title": DBusString(playlist.activeTrack!.name!),
"xesam:url": DBusString( "xesam:url": DBusString(
playlist.activeTrack is SpotubeTrack playlist.activeTrack is SourcedTrack
? (playlist.activeTrack as SpotubeTrack).ytUri ? (playlist.activeTrack as SourcedTrack).url
: playlist.activeTrack!.previewUrl ?? "", : playlist.activeTrack!.previewUrl ?? "",
), ),
"xesam:genre": const DBusString("Unknown"), "xesam:genre": const DBusString("Unknown"),

View File

@ -8,7 +8,7 @@ import 'package:spotify/spotify.dart';
import 'package:spotube/extensions/map.dart'; import 'package:spotube/extensions/map.dart';
import 'package:spotube/hooks/spotify/use_spotify_query.dart'; import 'package:spotube/hooks/spotify/use_spotify_query.dart';
import 'package:spotube/models/lyrics.dart'; import 'package:spotube/models/lyrics.dart';
import 'package:spotube/models/spotube_track.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/service_utils.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
@ -44,7 +44,7 @@ class LyricsQueries {
return useQuery<SubtitleSimple, dynamic>( return useQuery<SubtitleSimple, dynamic>(
"synced-lyrics/${track?.id}}", "synced-lyrics/${track?.id}}",
() async { () async {
if (track == null || track is! SpotubeTrack) { if (track == null || track is! SourcedTrack) {
throw "No track currently"; throw "No track currently";
} }
final timedLyrics = await ServiceUtils.getTimedLyrics(track); final timedLyrics = await ServiceUtils.getTimedLyrics(track);

View File

@ -0,0 +1,18 @@
import 'package:spotube/services/sourced_track/models/source_info.dart';
import 'package:spotube/services/sourced_track/models/source_map.dart';
enum SourceCodecs {
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 {
high,
medium,
low,
}
typedef SiblingType = ({SourceInfo info, SourceMap? source});

View File

@ -0,0 +1,7 @@
import 'package:spotify/spotify.dart';
class TrackNotFoundException implements Exception {
factory TrackNotFoundException(Track track) {
throw Exception("Failed to find any results for ${track.name}");
}
}

View File

@ -0,0 +1,33 @@
import 'package:json_annotation/json_annotation.dart';
part 'source_info.g.dart';
@JsonSerializable()
class SourceInfo {
final String id;
final String title;
final String artist;
final String artistUrl;
final String? album;
final String thumbnail;
final String pageUrl;
final Duration duration;
SourceInfo({
required this.id,
required this.title,
required this.artist,
required this.thumbnail,
required this.pageUrl,
required this.duration,
required this.artistUrl,
this.album,
});
factory SourceInfo.fromJson(Map<String, dynamic> json) =>
_$SourceInfoFromJson(json);
Map<String, dynamic> toJson() => _$SourceInfoToJson(this);
}

View File

@ -0,0 +1,30 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'source_info.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
SourceInfo _$SourceInfoFromJson(Map<String, dynamic> json) => SourceInfo(
id: json['id'] as String,
title: json['title'] as String,
artist: json['artist'] as String,
thumbnail: json['thumbnail'] as String,
pageUrl: json['pageUrl'] as String,
duration: Duration(microseconds: json['duration'] as int),
artistUrl: json['artistUrl'] as String,
album: json['album'] as String?,
);
Map<String, dynamic> _$SourceInfoToJson(SourceInfo instance) =>
<String, dynamic>{
'id': instance.id,
'title': instance.title,
'artist': instance.artist,
'artistUrl': instance.artistUrl,
'album': instance.album,
'thumbnail': instance.thumbnail,
'pageUrl': instance.pageUrl,
'duration': instance.duration.inMicroseconds,
};

View File

@ -0,0 +1,58 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:spotube/services/sourced_track/enums.dart';
part 'source_map.g.dart';
@JsonSerializable()
class SourceQualityMap {
final String high;
final String medium;
final String low;
const SourceQualityMap({
required this.high,
required this.medium,
required this.low,
});
factory SourceQualityMap.fromJson(Map<String, dynamic> json) =>
_$SourceQualityMapFromJson(json);
Map<String, dynamic> toJson() => _$SourceQualityMapToJson(this);
operator [](SourceQualities key) {
switch (key) {
case SourceQualities.high:
return high;
case SourceQualities.medium:
return medium;
case SourceQualities.low:
return low;
}
}
}
@JsonSerializable()
class SourceMap {
final SourceQualityMap? weba;
final SourceQualityMap? m4a;
const SourceMap({
this.weba,
this.m4a,
});
factory SourceMap.fromJson(Map<String, dynamic> json) =>
_$SourceMapFromJson(json);
Map<String, dynamic> toJson() => _$SourceMapToJson(this);
operator [](SourceCodecs key) {
switch (key) {
case SourceCodecs.weba:
return weba;
case SourceCodecs.m4a:
return m4a;
}
}
}

View File

@ -0,0 +1,35 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'source_map.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
SourceQualityMap _$SourceQualityMapFromJson(Map<String, dynamic> json) =>
SourceQualityMap(
high: json['high'] as String,
medium: json['medium'] as String,
low: json['low'] as String,
);
Map<String, dynamic> _$SourceQualityMapToJson(SourceQualityMap instance) =>
<String, dynamic>{
'high': instance.high,
'medium': instance.medium,
'low': instance.low,
};
SourceMap _$SourceMapFromJson(Map<String, dynamic> json) => SourceMap(
weba: json['weba'] == null
? null
: SourceQualityMap.fromJson(json['weba'] as Map<String, dynamic>),
m4a: json['m4a'] == null
? null
: SourceQualityMap.fromJson(json['m4a'] as Map<String, dynamic>),
);
Map<String, dynamic> _$SourceMapToJson(SourceMap instance) => <String, dynamic>{
'weba': instance.weba,
'm4a': instance.m4a,
};

View File

@ -0,0 +1,114 @@
import 'package:piped_client/piped_client.dart';
import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
class YoutubeVideoInfo {
final SearchMode searchMode;
final String title;
final Duration duration;
final String thumbnailUrl;
final String id;
final int likes;
final int dislikes;
final int views;
final String channelName;
final String channelId;
final DateTime publishedAt;
YoutubeVideoInfo({
required this.searchMode,
required this.title,
required this.duration,
required this.thumbnailUrl,
required this.id,
required this.likes,
required this.dislikes,
required this.views,
required this.channelName,
required this.publishedAt,
required this.channelId,
});
YoutubeVideoInfo.fromJson(Map<String, dynamic> json)
: title = json['title'],
searchMode = SearchMode.fromString(json['searchMode']),
duration = Duration(seconds: json['duration']),
thumbnailUrl = json['thumbnailUrl'],
id = json['id'],
likes = json['likes'],
dislikes = json['dislikes'],
views = json['views'],
channelName = json['channelName'],
channelId = json['channelId'],
publishedAt = DateTime.tryParse(json['publishedAt']) ?? DateTime.now();
Map<String, dynamic> toJson() => {
'title': title,
'duration': duration.inSeconds,
'thumbnailUrl': thumbnailUrl,
'id': id,
'likes': likes,
'dislikes': dislikes,
'views': views,
'channelName': channelName,
'channelId': channelId,
'publishedAt': publishedAt.toIso8601String(),
'searchMode': searchMode.name,
};
factory YoutubeVideoInfo.fromVideo(Video video) {
return YoutubeVideoInfo(
searchMode: SearchMode.youtube,
title: video.title,
duration: video.duration ?? Duration.zero,
thumbnailUrl: video.thumbnails.mediumResUrl,
id: video.id.value,
likes: video.engagement.likeCount ?? 0,
dislikes: video.engagement.dislikeCount ?? 0,
views: video.engagement.viewCount,
channelName: video.author,
channelId: '/c/${video.channelId.value}',
publishedAt: video.uploadDate ?? DateTime(2003, 9, 9),
);
}
factory YoutubeVideoInfo.fromSearchItemStream(
PipedSearchItemStream searchItem,
SearchMode searchMode,
) {
return YoutubeVideoInfo(
searchMode: searchMode,
title: searchItem.title,
duration: searchItem.duration,
thumbnailUrl: searchItem.thumbnail,
id: searchItem.id,
likes: 0,
dislikes: 0,
views: searchItem.views,
channelName: searchItem.uploaderName,
channelId: searchItem.uploaderUrl ?? "",
publishedAt: searchItem.uploadedDate != null
? DateTime.tryParse(searchItem.uploadedDate!) ?? DateTime(2003, 9, 9)
: DateTime(2003, 9, 9),
);
}
factory YoutubeVideoInfo.fromStreamResponse(
PipedStreamResponse stream, SearchMode searchMode) {
return YoutubeVideoInfo(
searchMode: searchMode,
title: stream.title,
duration: stream.duration,
thumbnailUrl: stream.thumbnailUrl,
id: stream.id,
likes: stream.likes,
dislikes: stream.dislikes,
views: stream.views,
channelName: stream.uploader,
publishedAt: stream.uploadedDate != null
? DateTime.tryParse(stream.uploadedDate!) ?? DateTime(2003, 9, 9)
: DateTime(2003, 9, 9),
channelId: stream.uploaderUrl,
);
}
}

View File

@ -0,0 +1,171 @@
import 'package:collection/collection.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_state.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';
abstract class SourcedTrack extends Track {
final SourceMap source;
final List<SourceInfo> siblings;
final SourceInfo sourceInfo;
final Ref ref;
SourcedTrack({
required this.ref,
required this.source,
required this.siblings,
required this.sourceInfo,
required Track track,
}) {
id = track.id;
name = track.name;
artists = track.artists;
album = track.album;
durationMs = track.durationMs;
discNumber = track.discNumber;
explicit = track.explicit;
externalIds = track.externalIds;
href = track.href;
isPlayable = track.isPlayable;
linkedFrom = track.linkedFrom;
popularity = track.popularity;
previewUrl = track.previewUrl;
trackNumber = track.trackNumber;
type = track.type;
uri = track.uri;
}
static SourcedTrack fromJson(
Map<String, dynamic> json, {
required Ref ref,
}) {
final preferences = ref.read(userPreferencesProvider);
final sourceInfo = SourceInfo.fromJson(json);
final source = SourceMap.fromJson(json);
final track = Track.fromJson(json);
final siblings = (json["siblings"] as List)
.map((sibling) => SourceInfo.fromJson(sibling))
.toList()
.cast<SourceInfo>();
return switch (preferences.audioSource) {
AudioSource.youtube => YoutubeSourcedTrack(
ref: ref,
source: source,
siblings: siblings,
sourceInfo: sourceInfo,
track: track,
),
AudioSource.piped => PipedSourcedTrack(
ref: ref,
source: source,
siblings: siblings,
sourceInfo: sourceInfo,
track: track,
),
AudioSource.jiosaavn => JioSaavnSourcedTrack(
ref: ref,
source: source,
siblings: siblings,
sourceInfo: sourceInfo,
track: track,
),
};
}
static String getSearchTerm(Track track) {
final artists = (track.artists ?? [])
.map((ar) => ar.name)
.toList()
.whereNotNull()
.toList();
final title = ServiceUtils.getTitle(
track.name!,
artists: artists,
onlyCleanArtist: true,
).trim();
return "$title - ${artists.join(", ")}";
}
static Future<SourcedTrack> fetchFromTrack({
required Track track,
required Ref ref,
}) async {
try {
final preferences = ref.read(userPreferencesProvider);
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({
required Track track,
required Ref ref,
}) {
final preferences = ref.read(userPreferencesProvider);
return switch (preferences.audioSource) {
AudioSource.piped =>
PipedSourcedTrack.fetchSiblings(track: track, ref: ref),
AudioSource.youtube =>
YoutubeSourcedTrack.fetchSiblings(track: track, ref: ref),
AudioSource.jiosaavn =>
JioSaavnSourcedTrack.fetchSiblings(track: track, ref: ref),
};
}
Future<SourcedTrack> copyWithSibling();
Future<SourcedTrack?> swapWithSibling(SourceInfo sibling);
Future<SourcedTrack?> swapWithSiblingOfIndex(int index) {
return swapWithSibling(siblings[index]);
}
String get url {
final preferences = ref.read(userPreferencesProvider);
final codec = preferences.audioSource == AudioSource.jiosaavn
? SourceCodecs.m4a
: preferences.streamMusicCodec;
return getUrlOfCodec(codec);
}
String getUrlOfCodec(SourceCodecs codec) {
final preferences = ref.read(userPreferencesProvider);
return source[codec]?[preferences.audioQuality] ??
// this will ensure playback doesn't break
source[codec == SourceCodecs.m4a ? SourceCodecs.weba : SourceCodecs.m4a]
[preferences.audioQuality];
}
SourceCodecs get codec {
final preferences = ref.read(userPreferencesProvider);
return preferences.audioSource == AudioSource.jiosaavn
? 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

@ -0,0 +1,257 @@
import 'package:collection/collection.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:piped_client/piped_client.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/models/source_match.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_state.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/models/video_info.dart';
import 'package:spotube/services/sourced_track/sourced_track.dart';
import 'package:spotube/services/sourced_track/sources/youtube.dart';
import 'package:spotube/utils/service_utils.dart';
final pipedProvider = Provider<PipedClient>(
(ref) {
final instance =
ref.watch(userPreferencesProvider.select((s) => s.pipedInstance));
return PipedClient(instance: instance);
},
);
class PipedSourcedTrack extends SourcedTrack {
PipedSourcedTrack({
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);
final preferences = ref.read(userPreferencesProvider);
final pipedClient = ref.read(pipedProvider);
if (cachedSource == null) {
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: preferences.searchMode == SearchMode.youtube
? SourceType.youtube
: SourceType.youtubeMusic,
createdAt: DateTime.now(),
sourceId: siblings.first.info.id,
),
);
return PipedSourcedTrack(
ref: ref,
siblings: siblings.map((s) => s.info).skip(1).toList(),
source: siblings.first.source as SourceMap,
sourceInfo: siblings.first.info,
track: track,
);
} else {
final manifest = await pipedClient.streams(cachedSource.sourceId);
return PipedSourcedTrack(
ref: ref,
siblings: [],
source: toSourceMap(manifest),
sourceInfo: SourceInfo(
id: manifest.id,
artist: manifest.uploader,
artistUrl: manifest.uploaderUrl,
pageUrl: "https://www.youtube.com/watch?v=${manifest.id}",
thumbnail: manifest.thumbnailUrl,
title: manifest.title,
duration: manifest.duration,
album: null,
),
track: track,
);
}
}
static SourceMap toSourceMap(PipedStreamResponse manifest) {
final m4a = manifest.audioStreams
.where((audio) => audio.format == PipedAudioStreamFormat.m4a)
.sorted((a, b) => a.bitrate.compareTo(b.bitrate));
final weba = manifest.audioStreams
.where((audio) => audio.format == PipedAudioStreamFormat.webm)
.sorted((a, b) => a.bitrate.compareTo(b.bitrate));
return SourceMap(
m4a: SourceQualityMap(
high: m4a.first.url.toString(),
medium: (m4a.elementAtOrNull(m4a.length ~/ 2) ?? m4a[1]).url.toString(),
low: m4a.last.url.toString(),
),
weba: SourceQualityMap(
high: weba.first.url.toString(),
medium:
(weba.elementAtOrNull(weba.length ~/ 2) ?? weba[1]).url.toString(),
low: weba.last.url.toString(),
),
);
}
static Future<SiblingType> toSiblingType(
int index,
YoutubeVideoInfo item,
PipedClient pipedClient,
) async {
SourceMap? sourceMap;
if (index == 0) {
final manifest = await pipedClient.streams(item.id);
sourceMap = toSourceMap(manifest);
}
final SiblingType sibling = (
info: SourceInfo(
id: item.id,
artist: item.channelName,
artistUrl: "https://www.youtube.com/${item.channelId}",
pageUrl: "https://www.youtube.com/watch?v=${item.id}",
thumbnail: item.thumbnailUrl,
title: item.title,
duration: item.duration,
album: null,
),
source: sourceMap,
);
return sibling;
}
static Future<List<SiblingType>> fetchSiblings({
required Track track,
required Ref ref,
}) async {
final pipedClient = ref.read(pipedProvider);
final preference = ref.read(userPreferencesProvider);
final query = SourcedTrack.getSearchTerm(track);
final PipedSearchResult(items: searchResults) = await pipedClient.search(
query,
preference.searchMode == SearchMode.youtube
? PipedFilter.video
: PipedFilter.musicSongs,
);
final isYouTubeMusic = preference.searchMode == SearchMode.youtubeMusic;
if (isYouTubeMusic) {
final artists = (track.artists ?? [])
.map((ar) => ar.name)
.toList()
.whereNotNull()
.toList();
return await Future.wait(
searchResults
.map(
(result) => YoutubeVideoInfo.fromSearchItemStream(
result as PipedSearchItemStream,
preference.searchMode,
),
)
.sorted((a, b) => b.views.compareTo(a.views))
.where(
(item) => artists.any(
(artist) =>
artist.toLowerCase() == item.channelName.toLowerCase(),
),
)
.mapIndexed((i, r) => toSiblingType(i, r, pipedClient)),
);
}
if (ServiceUtils.onlyContainsEnglish(query)) {
return await Future.wait(
searchResults
.whereType<PipedSearchItemStream>()
.map(
(result) => YoutubeVideoInfo.fromSearchItemStream(
result,
preference.searchMode,
),
)
.mapIndexed((i, r) => toSiblingType(i, r, pipedClient)),
);
}
final rankedSiblings = YoutubeSourcedTrack.rankResults(
searchResults
.map(
(result) => YoutubeVideoInfo.fromSearchItemStream(
result as PipedSearchItemStream,
preference.searchMode,
),
)
.toList(),
track,
);
return await Future.wait(
rankedSiblings.mapIndexed((i, r) => toSiblingType(i, r, pipedClient)),
);
}
@override
Future<SourcedTrack> copyWithSibling() async {
if (siblings.isNotEmpty) {
return this;
}
final fetchedSiblings = await fetchSiblings(ref: ref, track: this);
return PipedSourcedTrack(
ref: ref,
siblings: fetchedSiblings
.where((s) => s.info.id != sourceInfo.id)
.map((s) => s.info)
.toList(),
source: source,
sourceInfo: sourceInfo,
track: this,
);
}
@override
Future<SourcedTrack?> 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 pipedClient = ref.read(pipedProvider);
final manifest = await pipedClient.streams(newSourceInfo.id);
return PipedSourcedTrack(
ref: ref,
siblings: newSiblings,
source: toSourceMap(manifest),
sourceInfo: newSourceInfo,
track: this,
);
}
}

View File

@ -0,0 +1,256 @@
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/models/video_info.dart';
import 'package:spotube/services/sourced_track/sourced_track.dart';
import 'package:spotube/utils/service_utils.dart';
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
final youtubeClient = YoutubeExplode();
final officialMusicRegex = RegExp(
r"official\s(video|audio|music\svideo|lyric\svideo|visualizer)",
caseSensitive: false,
);
class YoutubeSourcedTrack extends SourcedTrack {
YoutubeSourcedTrack({
required super.source,
required super.siblings,
required super.sourceInfo,
required super.track,
required super.ref,
});
static Future<YoutubeSourcedTrack> fetchFromTrack({
required Track track,
required Ref ref,
}) async {
final cachedSource = await SourceMatch.box.get(track.id);
if (cachedSource == null || cachedSource.sourceType != SourceType.youtube) {
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.youtube,
createdAt: DateTime.now(),
sourceId: siblings.first.info.id,
),
);
return YoutubeSourcedTrack(
ref: ref,
siblings: siblings.map((s) => s.info).skip(1).toList(),
source: siblings.first.source as SourceMap,
sourceInfo: siblings.first.info,
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) {
final m4a = manifest.audioOnly
.where((audio) => audio.codec.mimeType == "audio/mp4")
.sortByBitrate();
final weba = manifest.audioOnly
.where((audio) => audio.codec.mimeType == "audio/webm")
.sortByBitrate();
return SourceMap(
m4a: SourceQualityMap(
high: m4a.first.url.toString(),
medium: (m4a.elementAtOrNull(m4a.length ~/ 2) ?? m4a[1]).url.toString(),
low: m4a.last.url.toString(),
),
weba: SourceQualityMap(
high: weba.first.url.toString(),
medium:
(weba.elementAtOrNull(weba.length ~/ 2) ?? weba[1]).url.toString(),
low: weba.last.url.toString(),
),
);
}
static Future<SiblingType> toSiblingType(
int index,
YoutubeVideoInfo item,
) async {
SourceMap? sourceMap;
if (index == 0) {
final manifest =
await youtubeClient.videos.streamsClient.getManifest(item.id);
sourceMap = toSourceMap(manifest);
}
final SiblingType sibling = (
info: SourceInfo(
id: item.id,
artist: item.channelName,
artistUrl: "https://www.youtube.com/channel/${item.channelId}",
pageUrl: "https://www.youtube.com/watch?v=${item.id}",
thumbnail: item.thumbnailUrl,
title: item.title,
duration: item.duration,
album: null,
),
source: sourceMap,
);
return sibling;
}
static List<YoutubeVideoInfo> rankResults(
List<YoutubeVideoInfo> results, Track track) {
final artists = (track.artists ?? [])
.map((ar) => ar.name)
.toList()
.whereNotNull()
.toList();
return results
.sorted((a, b) => b.views.compareTo(a.views))
.map((sibling) {
int score = 0;
for (final artist in artists) {
final isSameChannelArtist =
sibling.channelName.toLowerCase() == artist.toLowerCase();
final channelContainsArtist = sibling.channelName
.toLowerCase()
.contains(artist.toLowerCase());
if (isSameChannelArtist || channelContainsArtist) {
score += 1;
}
final titleContainsArtist =
sibling.title.toLowerCase().contains(artist.toLowerCase());
if (titleContainsArtist) {
score += 1;
}
}
final titleContainsTrackName =
sibling.title.toLowerCase().contains(track.name!.toLowerCase());
final hasOfficialFlag =
officialMusicRegex.hasMatch(sibling.title.toLowerCase());
if (titleContainsTrackName) {
score += 3;
}
if (hasOfficialFlag) {
score += 1;
}
if (hasOfficialFlag && titleContainsTrackName) {
score += 2;
}
return (sibling: sibling, score: score);
})
.sorted((a, b) => b.score.compareTo(a.score))
.map((e) => e.sibling)
.toList();
}
static Future<List<SiblingType>> fetchSiblings({
required Track track,
required Ref ref,
}) async {
final query = SourcedTrack.getSearchTerm(track);
final searchResults = await youtubeClient.search.search(
query,
filter: TypeFilters.video,
);
if (ServiceUtils.onlyContainsEnglish(query)) {
return await Future.wait(searchResults
.map(YoutubeVideoInfo.fromVideo)
.mapIndexed(toSiblingType));
}
final rankedSiblings = rankResults(
searchResults.map(YoutubeVideoInfo.fromVideo).toList(),
track,
);
return await Future.wait(rankedSiblings.mapIndexed(toSiblingType));
}
@override
Future<YoutubeSourcedTrack?> 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 manifest =
await youtubeClient.videos.streamsClient.getManifest(newSourceInfo.id);
return YoutubeSourcedTrack(
ref: ref,
siblings: newSiblings,
source: toSourceMap(manifest),
sourceInfo: newSourceInfo,
track: this,
);
}
@override
Future<YoutubeSourcedTrack> copyWithSibling() async {
if (siblings.isNotEmpty) {
return this;
}
final fetchedSiblings = await fetchSiblings(ref: ref, track: this);
return YoutubeSourcedTrack(
ref: ref,
siblings: fetchedSiblings
.where((s) => s.info.id != sourceInfo.id)
.map((s) => s.info)
.toList(),
source: source,
sourceInfo: sourceInfo,
track: this,
);
}
}

View File

@ -1,5 +1,5 @@
import 'package:spotube/collections/env.dart'; import 'package:spotube/collections/env.dart';
import 'package:spotube/models/matched_track.dart'; import 'package:spotube/models/source_match.dart';
import 'package:supabase/supabase.dart'; import 'package:supabase/supabase.dart';
class SupabaseService { class SupabaseService {
@ -8,7 +8,9 @@ class SupabaseService {
Env.supabaseAnonKey ?? "", Env.supabaseAnonKey ?? "",
); );
Future<void> insertTrack(MatchedTrack track) async { Future<void> insertTrack(SourceMatch track) async {
return null;
// TODO: Fix this
await api.from("tracks").insert(track.toJson()); await api.from("tracks").insert(track.toJson());
} }
} }

View File

@ -1,248 +0,0 @@
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:piped_client/piped_client.dart';
import 'package:spotube/collections/routes.dart';
import 'package:spotube/components/shared/dialogs/piped_down_dialog.dart';
import 'package:spotube/models/matched_track.dart';
import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
import 'package:spotube/utils/primitive_utils.dart';
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
class YoutubeVideoInfo {
final SearchMode searchMode;
final String title;
final Duration duration;
final String thumbnailUrl;
final String id;
final int likes;
final int dislikes;
final int views;
final String channelName;
final String channelId;
final DateTime publishedAt;
YoutubeVideoInfo({
required this.searchMode,
required this.title,
required this.duration,
required this.thumbnailUrl,
required this.id,
required this.likes,
required this.dislikes,
required this.views,
required this.channelName,
required this.publishedAt,
required this.channelId,
});
YoutubeVideoInfo.fromJson(Map<String, dynamic> json)
: title = json['title'],
searchMode = SearchMode.fromString(json['searchMode']),
duration = Duration(seconds: json['duration']),
thumbnailUrl = json['thumbnailUrl'],
id = json['id'],
likes = json['likes'],
dislikes = json['dislikes'],
views = json['views'],
channelName = json['channelName'],
channelId = json['channelId'],
publishedAt = DateTime.tryParse(json['publishedAt']) ?? DateTime.now();
Map<String, dynamic> toJson() => {
'title': title,
'duration': duration.inSeconds,
'thumbnailUrl': thumbnailUrl,
'id': id,
'likes': likes,
'dislikes': dislikes,
'views': views,
'channelName': channelName,
'channelId': channelId,
'publishedAt': publishedAt.toIso8601String(),
'searchMode': searchMode.name,
};
factory YoutubeVideoInfo.fromVideo(Video video) {
return YoutubeVideoInfo(
searchMode: SearchMode.youtube,
title: video.title,
duration: video.duration ?? Duration.zero,
thumbnailUrl: video.thumbnails.mediumResUrl,
id: video.id.value,
likes: video.engagement.likeCount ?? 0,
dislikes: video.engagement.dislikeCount ?? 0,
views: video.engagement.viewCount,
channelName: video.author,
channelId: '/c/${video.channelId.value}',
publishedAt: video.uploadDate ?? DateTime(2003, 9, 9),
);
}
factory YoutubeVideoInfo.fromSearchItemStream(
PipedSearchItemStream searchItem,
SearchMode searchMode,
) {
return YoutubeVideoInfo(
searchMode: searchMode,
title: searchItem.title,
duration: searchItem.duration,
thumbnailUrl: searchItem.thumbnail,
id: searchItem.id,
likes: 0,
dislikes: 0,
views: searchItem.views,
channelName: searchItem.uploaderName,
channelId: searchItem.uploaderUrl ?? "",
publishedAt: searchItem.uploadedDate != null
? DateTime.tryParse(searchItem.uploadedDate!) ?? DateTime(2003, 9, 9)
: DateTime(2003, 9, 9),
);
}
factory YoutubeVideoInfo.fromStreamResponse(
PipedStreamResponse stream, SearchMode searchMode) {
return YoutubeVideoInfo(
searchMode: searchMode,
title: stream.title,
duration: stream.duration,
thumbnailUrl: stream.thumbnailUrl,
id: stream.id,
likes: stream.likes,
dislikes: stream.dislikes,
views: stream.views,
channelName: stream.uploader,
publishedAt: stream.uploadedDate != null
? DateTime.tryParse(stream.uploadedDate!) ?? DateTime(2003, 9, 9)
: DateTime(2003, 9, 9),
channelId: stream.uploaderUrl,
);
}
}
class YoutubeEndpoints {
PipedClient? piped;
YoutubeExplode? youtube;
final UserPreferences preferences;
YoutubeEndpoints(this.preferences) {
switch (preferences.youtubeApiType) {
case YoutubeApiType.youtube:
youtube = YoutubeExplode();
break;
case YoutubeApiType.piped:
piped = PipedClient(instance: preferences.pipedInstance);
break;
}
}
Future<void> showPipedErrorDialog(Exception e) async {
if (e is DioException && (e.response?.statusCode ?? 0) >= 500) {
final context = rootNavigatorKey?.currentContext;
if (context != null) {
await showDialog(
context: context,
builder: (context) => const PipedDownDialog(),
);
}
}
}
Future<List<YoutubeVideoInfo>> search(String query) async {
if (youtube != null) {
final res = await youtube!.search(
query,
filter: TypeFilters.video,
);
return res.map(YoutubeVideoInfo.fromVideo).toList();
} else {
try {
final res = await piped!.search(
query,
switch (preferences.searchMode) {
SearchMode.youtube => PipedFilter.video,
SearchMode.youtubeMusic => PipedFilter.musicSongs,
},
);
return res.items
.whereType<PipedSearchItemStream>()
.map(
(e) => YoutubeVideoInfo.fromSearchItemStream(
e,
preferences.searchMode,
),
)
.toList();
} on Exception catch (e) {
await showPipedErrorDialog(e);
rethrow;
}
}
}
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(pipedStreamFormat)!.url,
AudioQuality.low =>
stream.lowestBitrateAudioStreamOfFormat(pipedStreamFormat)!.url,
};
}
Future<String> 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 switch (codec) {
MusicCodec.m4a => info.codec.mimeType == "audio/mp4",
MusicCodec.weba => info.codec.mimeType == "audio/webm",
};
});
return switch (preferences.audioQuality) {
AudioQuality.high =>
audioOnlyManifests.withHighestBitrate().url.toString(),
AudioQuality.low =>
audioOnlyManifests.sortByBitrate().last.url.toString(),
};
} else {
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, codec),
);
} else {
try {
final res = await piped!.streams(id);
return (
YoutubeVideoInfo.fromStreamResponse(res, searchMode),
_pipedStreamResponseToStreamUrl(res, codec),
);
} on Exception catch (e) {
await showPipedErrorDialog(e);
rethrow;
}
}
}
}

View File

@ -8,7 +8,7 @@ import 'package:spotube/components/library/user_local_tracks.dart';
import 'package:spotube/models/logger.dart'; import 'package:spotube/models/logger.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:spotube/models/lyrics.dart'; import 'package:spotube/models/lyrics.dart';
import 'package:spotube/models/spotube_track.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart';
import 'package:spotube/utils/primitive_utils.dart'; import 'package:spotube/utils/primitive_utils.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
@ -171,7 +171,7 @@ abstract class ServiceUtils {
static const baseUri = "https://www.rentanadviser.com/subtitles"; static const baseUri = "https://www.rentanadviser.com/subtitles";
@Deprecated("In favor spotify lyrics api, this isn't needed anymore") @Deprecated("In favor spotify lyrics api, this isn't needed anymore")
static Future<SubtitleSimple?> getTimedLyrics(SpotubeTrack track) async { static Future<SubtitleSimple?> getTimedLyrics(SourcedTrack track) async {
final artistNames = final artistNames =
track.artists?.map((artist) => artist.name!).toList() ?? []; track.artists?.map((artist) => artist.name!).toList() ?? [];
final query = getTitle( final query = getTitle(
@ -199,7 +199,7 @@ abstract class ServiceUtils {
false; false;
final hasTrackName = title.contains(track.name!.toLowerCase()); final hasTrackName = title.contains(track.name!.toLowerCase());
final isNotLive = !PrimitiveUtils.containsTextInBracket(title, "live"); final isNotLive = !PrimitiveUtils.containsTextInBracket(title, "live");
final exactYtMatch = title == track.ytTrack.title.toLowerCase(); final exactYtMatch = title == track.sourceInfo.title.toLowerCase();
if (exactYtMatch) points = 7; if (exactYtMatch) points = 7;
for (final criteria in [hasTrackName, hasAllArtists, isNotLive]) { for (final criteria in [hasTrackName, hasAllArtists, isNotLive]) {
if (criteria) points++; if (criteria) points++;

View File

@ -385,6 +385,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.3" version: "1.0.3"
dart_des:
dependency: transitive
description:
name: dart_des
sha256: "0a66afb8883368c824497fd2a1fd67bdb1a785965a3956728382c03d40747c33"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
dart_style: dart_style:
dependency: transitive dependency: transitive
description: description:
@ -1174,6 +1182,15 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.4" version: "1.0.4"
jiosaavn:
dependency: "direct main"
description:
path: "."
ref: HEAD
resolved-ref: "8a7cda9b8b687cde28e0f7fcb10adb0d4fde1007"
url: "https://github.com/KRTirtho/jiosaavn.git"
source: git
version: "0.1.0"
js: js:
dependency: transitive dependency: transitive
description: description:

View File

@ -106,6 +106,9 @@ dependencies:
simple_icons: ^7.10.0 simple_icons: ^7.10.0
audio_service_mpris: ^0.1.0 audio_service_mpris: ^0.1.0
file_picker: ^6.0.0 file_picker: ^6.0.0
jiosaavn:
git:
url: https://github.com/KRTirtho/jiosaavn.git
draggable_scrollbar: draggable_scrollbar:
git: git:
url: https://github.com/thielepaul/flutter-draggable-scrollbar.git url: https://github.com/thielepaul/flutter-draggable-scrollbar.git