mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-13 07:55:18 +00:00
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:
parent
57c03ad045
commit
14069cd4fe
@ -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);
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
@ -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) {
|
||||||
|
@ -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,
|
||||||
),
|
),
|
||||||
|
@ -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);
|
||||||
});
|
});
|
||||||
|
@ -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) {
|
||||||
|
@ -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(
|
||||||
|
@ -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,
|
||||||
|
@ -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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
54
lib/models/source_match.dart
Normal file
54
lib/models/source_match.dart
Normal 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);
|
||||||
|
}
|
119
lib/models/source_match.g.dart
Normal file
119
lib/models/source_match.g.dart
Normal 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',
|
||||||
|
};
|
@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
@ -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],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -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],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -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,44 +181,46 @@ class SettingsPlaybackSection extends HookConsumerWidget {
|
|||||||
value: preferences.normalizeAudio,
|
value: preferences.normalizeAudio,
|
||||||
onChanged: preferencesNotifier.setNormalizeAudio,
|
onChanged: preferencesNotifier.setNormalizeAudio,
|
||||||
),
|
),
|
||||||
AdaptiveSelectTile<MusicCodec>(
|
if (preferences.audioSource != AudioSource.jiosaavn)
|
||||||
secondary: const Icon(SpotubeIcons.stream),
|
AdaptiveSelectTile<SourceCodecs>(
|
||||||
title: Text(context.l10n.streaming_music_codec),
|
secondary: const Icon(SpotubeIcons.stream),
|
||||||
value: preferences.streamMusicCodec,
|
title: Text(context.l10n.streaming_music_codec),
|
||||||
showValueWhenUnfolded: false,
|
value: preferences.streamMusicCodec,
|
||||||
options: MusicCodec.values
|
showValueWhenUnfolded: false,
|
||||||
.map((e) => DropdownMenuItem(
|
options: SourceCodecs.values
|
||||||
value: e,
|
.map((e) => DropdownMenuItem(
|
||||||
child: Text(
|
value: e,
|
||||||
e.label,
|
child: Text(
|
||||||
style: theme.textTheme.labelMedium,
|
e.label,
|
||||||
),
|
style: theme.textTheme.labelMedium,
|
||||||
))
|
),
|
||||||
.toList(),
|
))
|
||||||
onChanged: (value) {
|
.toList(),
|
||||||
if (value == null) return;
|
onChanged: (value) {
|
||||||
preferencesNotifier.setStreamMusicCodec(value);
|
if (value == null) return;
|
||||||
},
|
preferencesNotifier.setStreamMusicCodec(value);
|
||||||
),
|
},
|
||||||
AdaptiveSelectTile<MusicCodec>(
|
),
|
||||||
secondary: const Icon(SpotubeIcons.file),
|
if (preferences.audioSource != AudioSource.jiosaavn)
|
||||||
title: Text(context.l10n.download_music_codec),
|
AdaptiveSelectTile<SourceCodecs>(
|
||||||
value: preferences.downloadMusicCodec,
|
secondary: const Icon(SpotubeIcons.file),
|
||||||
showValueWhenUnfolded: false,
|
title: Text(context.l10n.download_music_codec),
|
||||||
options: MusicCodec.values
|
value: preferences.downloadMusicCodec,
|
||||||
.map((e) => DropdownMenuItem(
|
showValueWhenUnfolded: false,
|
||||||
value: e,
|
options: SourceCodecs.values
|
||||||
child: Text(
|
.map((e) => DropdownMenuItem(
|
||||||
e.label,
|
value: e,
|
||||||
style: theme.textTheme.labelMedium,
|
child: Text(
|
||||||
),
|
e.label,
|
||||||
))
|
style: theme.textTheme.labelMedium,
|
||||||
.toList(),
|
),
|
||||||
onChanged: (value) {
|
))
|
||||||
if (value == null) return;
|
.toList(),
|
||||||
preferencesNotifier.setDownloadMusicCodec(value);
|
onChanged: (value) {
|
||||||
},
|
if (value == null) return;
|
||||||
),
|
preferencesNotifier.setDownloadMusicCodec(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -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(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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) {
|
||||||
|
@ -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,
|
||||||
|
@ -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',
|
||||||
};
|
};
|
||||||
|
@ -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);
|
|
||||||
});
|
|
@ -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';
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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>[],
|
||||||
|
@ -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"),
|
||||||
|
@ -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);
|
||||||
|
18
lib/services/sourced_track/enums.dart
Normal file
18
lib/services/sourced_track/enums.dart
Normal 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});
|
7
lib/services/sourced_track/exceptions.dart
Normal file
7
lib/services/sourced_track/exceptions.dart
Normal 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}");
|
||||||
|
}
|
||||||
|
}
|
33
lib/services/sourced_track/models/source_info.dart
Normal file
33
lib/services/sourced_track/models/source_info.dart
Normal 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);
|
||||||
|
}
|
30
lib/services/sourced_track/models/source_info.g.dart
Normal file
30
lib/services/sourced_track/models/source_info.g.dart
Normal 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,
|
||||||
|
};
|
58
lib/services/sourced_track/models/source_map.dart
Normal file
58
lib/services/sourced_track/models/source_map.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
35
lib/services/sourced_track/models/source_map.g.dart
Normal file
35
lib/services/sourced_track/models/source_map.g.dart
Normal 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,
|
||||||
|
};
|
114
lib/services/sourced_track/models/video_info.dart
Normal file
114
lib/services/sourced_track/models/video_info.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
171
lib/services/sourced_track/sourced_track.dart
Normal file
171
lib/services/sourced_track/sourced_track.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
159
lib/services/sourced_track/sources/jiosaavn.dart
Normal file
159
lib/services/sourced_track/sources/jiosaavn.dart
Normal 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("&", "&"),
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
257
lib/services/sourced_track/sources/piped.dart
Normal file
257
lib/services/sourced_track/sources/piped.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
256
lib/services/sourced_track/sources/youtube.dart
Normal file
256
lib/services/sourced_track/sources/youtube.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -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++;
|
||||||
|
17
pubspec.lock
17
pubspec.lock
@ -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:
|
||||||
|
@ -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
|
||||||
|
Loading…
Reference in New Issue
Block a user