feat: re-introduce youtube API along with piped

This commit is contained in:
Kingkor Roy Tirtho 2023-06-30 10:52:44 +06:00
parent b47ef98197
commit b54ee96233
20 changed files with 537 additions and 251 deletions

View File

@ -3,7 +3,6 @@ import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:piped_client/piped_client.dart';
import 'package:spotify/spotify.dart' hide Offset;
import 'package:spotube/collections/spotube_icons.dart';
@ -11,10 +10,12 @@ import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/hooks/use_debounce.dart';
import 'package:spotube/models/matched_track.dart';
import 'package:spotube/models/spotube_track.dart';
import 'package:spotube/provider/piped_provider.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/user_preferences_provider.dart';
import 'package:spotube/provider/youtube_provider.dart';
import 'package:spotube/services/youtube/youtube.dart';
import 'package:spotube/utils/primitive_utils.dart';
import 'package:spotube/utils/service_utils.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
@ -31,12 +32,11 @@ class SiblingTracksSheet extends HookConsumerWidget {
final theme = Theme.of(context);
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
final preferencesSearchMode =
ref.watch(userPreferencesProvider.select((value) => value.searchMode));
final pipedClient = ref.watch(pipedClientProvider);
final preferences = ref.watch(userPreferencesProvider);
final youtube = ref.watch(youtubeProvider);
final isSearching = useState(false);
final searchMode = useState(preferencesSearchMode);
final searchMode = useState(preferences.searchMode);
final title = ServiceUtils.getTitle(
playlist.activeTrack?.name ?? "",
@ -57,21 +57,10 @@ class SiblingTracksSheet extends HookConsumerWidget {
final searchRequest = useMemoized(() async {
if (searchTerm.trim().isEmpty) {
return <PipedSearchItemStream>[];
return <YoutubeVideoInfo>[];
}
return pipedClient
.search(
searchTerm.trim(),
switch (searchMode.value) {
SearchMode.youtube => PipedFilter.video,
SearchMode.youtubeMusic => PipedFilter.musicSongs,
},
)
.then(
(result) =>
result.items.whereType<PipedSearchItemStream>().toList(),
);
return youtube.search(searchTerm.trim());
}, [
searchTerm,
searchMode.value,
@ -79,7 +68,7 @@ class SiblingTracksSheet extends HookConsumerWidget {
final siblings = playlist.isFetching == false
? (playlist.activeTrack as SpotubeTrack).siblings
: <PipedSearchItemStream>[];
: <YoutubeVideoInfo>[];
final borderRadius = floating
? BorderRadius.circular(10)
@ -96,13 +85,13 @@ class SiblingTracksSheet extends HookConsumerWidget {
return null;
}, [playlist.activeTrack]);
final itemBuilder = useCallback((PipedSearchItemStream video) {
final itemBuilder = useCallback((YoutubeVideoInfo video) {
return ListTile(
title: Text(video.title),
leading: Padding(
padding: const EdgeInsets.all(8.0),
child: UniversalImage(
path: video.thumbnail,
path: video.thumbnailUrl,
height: 60,
width: 60,
),
@ -113,7 +102,7 @@ class SiblingTracksSheet extends HookConsumerWidget {
trailing: Text(
PrimitiveUtils.toReadableDuration(video.duration),
),
subtitle: Text(video.uploaderName),
subtitle: Text(video.channelName),
enabled: playlist.isFetching != true,
selected: playlist.isFetching != true &&
video.id == (playlist.activeTrack as SpotubeTrack).ytTrack.id,
@ -182,6 +171,7 @@ class SiblingTracksSheet extends HookConsumerWidget {
},
)
else ...[
if (preferences.youtubeApiType == YoutubeApiType.piped)
PopupMenuButton(
icon: const Icon(SpotubeIcons.filter, size: 18),
onSelected: (SearchMode mode) {

View File

@ -59,8 +59,8 @@ class TrackDetailsDialog extends HookWidget {
overflow: TextOverflow.ellipsis,
),
context.l10n.channel: Hyperlink(
ytTrack.uploader,
"https://youtube.com${ytTrack.uploaderUrl}",
ytTrack.channelName,
"https://youtube.com${ytTrack.channelName}",
maxLines: 2,
overflow: TextOverflow.ellipsis,
),

View File

@ -248,5 +248,6 @@
"logs": "Logs",
"developers": "Developers",
"not_logged_in": "You're not logged in",
"search_mode": "Search Mode"
"search_mode": "Search Mode",
"youtube_api_type": "YouTube API Type"
}

View File

@ -103,6 +103,7 @@ Future<void> main(List<String> rawArgs) async {
);
Hive.registerAdapter(MatchedTrackAdapter());
Hive.registerAdapter(SkipSegmentAdapter());
Hive.registerAdapter(SearchModeAdapter());
await Hive.openLazyBox<MatchedTrack>(
MatchedTrack.boxName,

View File

@ -1,5 +1,4 @@
import "package:hive/hive.dart";
part "matched_track.g.dart";
@HiveType(typeId: 1)
@ -8,6 +7,8 @@ class MatchedTrack {
String youtubeId;
@HiveField(1)
String spotifyId;
@HiveField(2)
SearchMode searchMode;
String? id;
DateTime? createdAt;
@ -21,12 +22,14 @@ class MatchedTrack {
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"],
@ -39,7 +42,27 @@ class MatchedTrack {
"youtube_id": youtubeId,
"spotify_id": spotifyId,
"id": id,
"searchMode": searchMode.name,
"created_at": createdAt?.toString()
}..removeWhere((key, value) => value == null);
}
}
@HiveType(typeId: 4)
enum SearchMode {
@HiveField(0)
youtube._internal('YouTube'),
@HiveField(1)
youtubeMusic._internal('YouTube Music');
final String label;
const SearchMode._internal(this.label);
factory SearchMode.fromString(String value) {
return SearchMode.values.firstWhere(
(element) => element.name == value,
orElse: () => SearchMode.youtube,
);
}
}

View File

@ -19,17 +19,20 @@ class MatchedTrackAdapter extends TypeAdapter<MatchedTrack> {
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(2)
..writeByte(3)
..writeByte(0)
..write(obj.youtubeId)
..writeByte(1)
..write(obj.spotifyId);
..write(obj.spotifyId)
..writeByte(2)
..write(obj.searchMode);
}
@override
@ -42,3 +45,42 @@ class MatchedTrackAdapter extends TypeAdapter<MatchedTrack> {
runtimeType == other.runtimeType &&
typeId == other.typeId;
}
class SearchModeAdapter extends TypeAdapter<SearchMode> {
@override
final int typeId = 4;
@override
SearchMode read(BinaryReader reader) {
switch (reader.readByte()) {
case 0:
return SearchMode.youtube;
case 1:
return SearchMode.youtubeMusic;
default:
return SearchMode.youtube;
}
}
@override
void write(BinaryWriter writer, SearchMode obj) {
switch (obj) {
case SearchMode.youtube:
writer.writeByte(0);
break;
case SearchMode.youtubeMusic:
writer.writeByte(1);
break;
}
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SearchModeAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@ -1,20 +1,18 @@
import 'dart:async';
import 'package:piped_client/piped_client.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/extensions/album_simple.dart';
import 'package:spotube/extensions/artist_simple.dart';
import 'package:spotube/models/matched_track.dart';
import 'package:spotube/provider/user_preferences_provider.dart';
import 'package:spotube/utils/platform.dart';
import 'package:spotube/services/youtube/youtube.dart';
import 'package:spotube/utils/service_utils.dart';
import 'package:collection/collection.dart';
class SpotubeTrack extends Track {
final PipedStreamResponse ytTrack;
final YoutubeVideoInfo ytTrack;
final String ytUri;
final List<PipedSearchItemStream> siblings;
final List<YoutubeVideoInfo> siblings;
SpotubeTrack(
this.ytTrack,
@ -48,25 +46,10 @@ class SpotubeTrack extends Track {
uri = track.uri;
}
static PipedAudioStream getStreamInfo(
PipedStreamResponse item,
AudioQuality audioQuality,
) {
final streamFormat =
kIsLinux ? PipedAudioStreamFormat.webm : PipedAudioStreamFormat.m4a;
if (audioQuality == AudioQuality.high) {
return item.highestBitrateAudioStreamOfFormat(streamFormat)!;
} else {
return item.lowestBitrateAudioStreamOfFormat(streamFormat)!;
}
}
static Future<List<PipedSearchItemStream>> fetchSiblings(
static Future<List<YoutubeVideoInfo>> fetchSiblings(
Track track,
PipedClient client, [
PipedFilter filter = PipedFilter.musicSongs,
]) async {
YoutubeEndpoints client,
) async {
final artists = (track.artists ?? [])
.map((ar) => ar.name)
.toList()
@ -79,22 +62,21 @@ class SpotubeTrack extends Track {
onlyCleanArtist: true,
).trim();
final List<PipedSearchItemStream> siblings =
await client.search("$title - ${artists.join(", ")}", filter).then(
final List<YoutubeVideoInfo> siblings =
await client.search("$title - ${artists.join(", ")}").then(
(res) {
final siblings = res.items
.whereType<PipedSearchItemStream>()
final siblings = res
.where((item) {
return artists.any(
(artist) =>
artist.toLowerCase() == item.uploaderName.toLowerCase(),
artist.toLowerCase() == item.channelName.toLowerCase(),
);
})
.take(10)
.toList();
if (siblings.isEmpty) {
return res.items.whereType<PipedSearchItemStream>().take(10).toList();
return res.take(10).toList();
}
return siblings;
@ -106,61 +88,53 @@ class SpotubeTrack extends Track {
static Future<SpotubeTrack> fetchFromTrack(
Track track,
UserPreferences preferences,
PipedClient client,
YoutubeEndpoints client,
) async {
final matchedCachedTrack = await MatchedTrack.box.get(track.id!);
var siblings = <PipedSearchItemStream>[];
PipedStreamResponse ytVideo;
if (matchedCachedTrack != null) {
ytVideo = await client.streams(matchedCachedTrack.youtubeId);
} else {
siblings = await fetchSiblings(
track,
client,
switch (preferences.searchMode) {
SearchMode.youtube => PipedFilter.video,
SearchMode.youtubeMusic => PipedFilter.musicSongs,
},
var siblings = <YoutubeVideoInfo>[];
YoutubeVideoInfo ytVideo;
String ytStreamUrl;
if (matchedCachedTrack != null &&
matchedCachedTrack.searchMode == client.preferences.searchMode) {
(ytVideo, ytStreamUrl) = await client.video(
matchedCachedTrack.youtubeId,
matchedCachedTrack.searchMode,
);
} else {
siblings = await fetchSiblings(track, client);
if (siblings.isEmpty) {
throw Exception("Failed to find any results for ${track.name}");
}
ytVideo = await client.streams(siblings.first.id);
(ytVideo, ytStreamUrl) =
await client.video(siblings.first.id, siblings.first.searchMode);
await MatchedTrack.box.put(
track.id!,
MatchedTrack(
youtubeId: ytVideo.id,
spotifyId: track.id!,
searchMode: siblings.first.searchMode,
),
);
}
final PipedAudioStream ytStream =
getStreamInfo(ytVideo, preferences.audioQuality);
return SpotubeTrack.fromTrack(
track: track,
ytTrack: ytVideo,
ytUri: ytStream.url,
ytUri: ytStreamUrl,
siblings: siblings,
);
}
Future<SpotubeTrack?> swappedCopy(
PipedSearchItemStream video,
UserPreferences preferences,
PipedClient client,
YoutubeVideoInfo video,
YoutubeEndpoints client,
) async {
// sibling tracks that were manually searched and swapped
final isStepSibling = siblings.none((element) => element.id == video.id);
final ytVideo = await client.streams(video.id);
final ytStream = getStreamInfo(ytVideo, preferences.audioQuality);
final ytUri = ytStream.url;
final (ytVideo, ytStreamUrl) =
await client.video(video.id, siblings.first.searchMode);
if (!isStepSibling) {
await MatchedTrack.box.put(
@ -168,6 +142,7 @@ class SpotubeTrack extends Track {
MatchedTrack(
youtubeId: video.id,
spotifyId: id!,
searchMode: siblings.first.searchMode,
),
);
}
@ -175,7 +150,7 @@ class SpotubeTrack extends Track {
return SpotubeTrack.fromTrack(
track: this,
ytTrack: ytVideo,
ytUri: ytUri,
ytUri: ytStreamUrl,
siblings: [
video,
...siblings.where((element) => element.id != video.id),
@ -186,24 +161,20 @@ class SpotubeTrack extends Track {
static SpotubeTrack fromJson(Map<String, dynamic> map) {
return SpotubeTrack.fromTrack(
track: Track.fromJson(map),
ytTrack: PipedStreamResponse.fromJson(map["ytTrack"]),
ytTrack: YoutubeVideoInfo.fromJson(map["ytTrack"]),
ytUri: map["ytUri"],
siblings: List.castFrom<dynamic, Map<String, dynamic>>(map["siblings"])
.map((sibling) => PipedSearchItemStream.fromJson(sibling))
.map((sibling) => YoutubeVideoInfo.fromJson(sibling))
.toList(),
);
}
Future<SpotubeTrack> populatedCopy(
PipedClient client,
PipedFilter filter,
) async {
Future<SpotubeTrack> populatedCopy(YoutubeEndpoints client) async {
if (this.siblings.isNotEmpty) return this;
final siblings = await fetchSiblings(
this,
client,
filter,
);
return SpotubeTrack.fromTrack(

View File

@ -20,10 +20,11 @@ import 'package:spotube/collections/spotify_markets.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/l10n/l10n.dart';
import 'package:spotube/models/matched_track.dart';
import 'package:spotube/provider/download_manager_provider.dart';
import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/provider/user_preferences_provider.dart';
import 'package:spotube/provider/piped_provider.dart';
import 'package:spotube/provider/piped_instances_provider.dart';
import 'package:url_launcher/url_launcher_string.dart';
class SettingsPage extends HookConsumerWidget {
@ -290,16 +291,39 @@ class SettingsPage extends HookConsumerWidget {
}
},
),
Consumer(builder: (context, ref, child) {
AdaptiveSelectTile<YoutubeApiType>(
secondary: const Icon(SpotubeIcons.youtube),
title: Text(context.l10n.youtube_api_type),
value: preferences.youtubeApiType,
options: YoutubeApiType.values
.map((e) => DropdownMenuItem(
value: e,
child: Text(e.label),
))
.toList(),
onChanged: (value) {
if (value == null) return;
preferences.setYoutubeApiType(value);
},
),
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: preferences.youtubeApiType ==
YoutubeApiType.youtube
? const SizedBox.shrink()
: Consumer(builder: (context, ref, child) {
final instanceList =
ref.watch(pipedInstancesFutureProvider);
return instanceList.when(
data: (data) {
return AdaptiveSelectTile<String>(
secondary: const Icon(SpotubeIcons.piped),
title: Text(context.l10n.piped_instance),
subtitle: Text(context.l10n.piped_description),
secondary:
const Icon(SpotubeIcons.piped),
title:
Text(context.l10n.piped_instance),
subtitle: Text(
context.l10n.piped_description),
value: preferences.pipedInstance,
showValueWhenUnfolded: false,
options: data
@ -328,7 +352,13 @@ class SettingsPage extends HookConsumerWidget {
Text(error.toString()),
);
}),
AdaptiveSelectTile<SearchMode>(
),
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: preferences.youtubeApiType ==
YoutubeApiType.youtube
? const SizedBox.shrink()
: AdaptiveSelectTile<SearchMode>(
secondary: const Icon(SpotubeIcons.search),
title: Text(context.l10n.search_mode),
value: preferences.searchMode,
@ -343,20 +373,15 @@ class SettingsPage extends HookConsumerWidget {
preferences.setSearchMode(value);
},
),
AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity:
preferences.searchMode == SearchMode.youtubeMusic
? 0
: 1,
child: AnimatedSize(
duration: const Duration(milliseconds: 200),
child: SizedBox(
height: preferences.searchMode ==
SearchMode.youtubeMusic
? 0
: 50,
child: SwitchListTile(
),
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: preferences.searchMode ==
SearchMode.youtubeMusic &&
preferences.youtubeApiType ==
YoutubeApiType.piped
? const SizedBox.shrink()
: SwitchListTile(
secondary: const Icon(SpotubeIcons.skip),
title: Text(context.l10n.skip_non_music),
value: preferences.skipNonMusic,
@ -365,8 +390,6 @@ class SettingsPage extends HookConsumerWidget {
},
),
),
),
),
ListTile(
leading: const Icon(SpotubeIcons.playlistRemove),
title: Text(context.l10n.blacklist),

View File

@ -7,13 +7,14 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart';
import 'package:metadata_god/metadata_god.dart';
import 'package:path/path.dart';
import 'package:piped_client/piped_client.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/routes.dart';
import 'package:spotube/components/shared/dialogs/replace_downloaded_dialog.dart';
import 'package:spotube/models/spotube_track.dart';
import 'package:spotube/provider/piped_provider.dart';
import 'package:spotube/provider/user_preferences_provider.dart';
import 'package:spotube/provider/youtube_provider.dart';
import 'package:spotube/services/youtube/youtube.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
class DownloadManagerProvider extends StateNotifier<List<SpotubeTrack>> {
@ -104,7 +105,7 @@ class DownloadManagerProvider extends StateNotifier<List<SpotubeTrack>> {
}
UserPreferences get preferences => ref.read(userPreferencesProvider);
PipedClient get pipedClient => ref.read(pipedClientProvider);
YoutubeEndpoints get youtube => ref.read(youtubeProvider);
int get totalDownloads => state.length;
List<Track> get items => state;
@ -129,8 +130,7 @@ class DownloadManagerProvider extends StateNotifier<List<SpotubeTrack>> {
? track
: await SpotubeTrack.fetchFromTrack(
track,
preferences,
pipedClient,
youtube,
);
state = [...state, spotubeTrack];
final task = DownloadTask(

View File

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

View File

@ -1,18 +0,0 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:piped_client/piped_client.dart';
import 'package:spotube/provider/user_preferences_provider.dart';
PipedClient _defaultClient = PipedClient();
final pipedClientProvider = Provider((ref) {
final instanceUrl =
ref.watch(userPreferencesProvider.select((s) => s.pipedInstance));
if (instanceUrl == "https://pipedapi.kavin.rocks") return _defaultClient;
return PipedClient(instance: instanceUrl);
});
final pipedInstancesFutureProvider = FutureProvider<List<PipedInstance>>(
(ref) async => _defaultClient.instanceList(),
);

View File

@ -1,7 +1,6 @@
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:piped_client/piped_client.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/models/local_track.dart';
import 'package:spotube/models/matched_track.dart';
@ -9,11 +8,12 @@ import 'package:spotube/models/spotube_track.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart';
import 'package:spotube/provider/user_preferences_provider.dart';
import 'package:spotube/services/supabase.dart';
import 'package:spotube/services/youtube/youtube.dart';
mixin NextFetcher on StateNotifier<ProxyPlaylist> {
Future<List<SpotubeTrack>> fetchTracks(
UserPreferences preferences,
PipedClient pipedClient, {
YoutubeEndpoints youtube, {
int count = 3,
int offset = 0,
}) async {
@ -29,8 +29,7 @@ mixin NextFetcher on StateNotifier<ProxyPlaylist> {
bareTracks.mapIndexed((i, track) async {
final future = SpotubeTrack.fetchFromTrack(
track,
preferences,
pipedClient,
youtube,
);
if (i == 0) {
return await future;
@ -117,6 +116,7 @@ mixin NextFetcher on StateNotifier<ProxyPlaylist> {
MatchedTrack(
youtubeId: spotubeTrack.ytTrack.id,
spotifyId: spotubeTrack.id!,
searchMode: spotubeTrack.ytTrack.searchMode,
),
);
}

View File

@ -7,11 +7,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hive/hive.dart';
import 'package:http/http.dart';
import 'package:palette_generator/palette_generator.dart';
import 'package:piped_client/piped_client.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/models/local_track.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/spotube_track.dart';
import 'package:spotube/provider/blacklist_provider.dart';
@ -19,9 +19,10 @@ import 'package:spotube/provider/palette_provider.dart';
import 'package:spotube/provider/proxy_playlist/next_fetcher_mixin.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart';
import 'package:spotube/provider/user_preferences_provider.dart';
import 'package:spotube/provider/youtube_provider.dart';
import 'package:spotube/services/audio_player/audio_player.dart';
import 'package:spotube/services/audio_services/audio_services.dart';
import 'package:spotube/provider/piped_provider.dart';
import 'package:spotube/services/youtube/youtube.dart';
import 'package:spotube/utils/persisted_state_notifier.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
@ -51,7 +52,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
late final AudioServices notificationService;
UserPreferences get preferences => ref.read(userPreferencesProvider);
PipedClient get pipedClient => ref.read(pipedClientProvider);
YoutubeEndpoints get youtube => ref.read(youtubeProvider);
ProxyPlaylist get playlist => state;
BlackListNotifier get blacklist =>
ref.read(BlackListNotifier.provider.notifier);
@ -213,7 +214,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
final nthFetchedTrack = switch (track.runtimeType) {
SpotubeTrack => track as SpotubeTrack,
_ => await SpotubeTrack.fetchFromTrack(track, preferences, pipedClient),
_ => await SpotubeTrack.fetchFromTrack(track, youtube),
};
await audioPlayer.replaceSource(
@ -298,8 +299,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
} else {
final addableTrack = await SpotubeTrack.fetchFromTrack(
tracks.elementAtOrNull(initialIndex) ?? tracks.first,
preferences,
pipedClient,
youtube,
);
state = state.copyWith(
@ -387,13 +387,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
Future<void> populateSibling() async {
if (state.activeTrack is SpotubeTrack) {
final activeTrackWithSiblingsForSure =
await (state.activeTrack as SpotubeTrack).populatedCopy(
pipedClient,
switch (preferences.searchMode) {
SearchMode.youtube => PipedFilter.video,
SearchMode.youtubeMusic => PipedFilter.musicSongs,
},
);
await (state.activeTrack as SpotubeTrack).populatedCopy(youtube);
state = state.copyWith(
tracks: mergeTracks([activeTrackWithSiblingsForSure], state.tracks),
@ -403,11 +397,11 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
}
}
Future<void> swapSibling(PipedSearchItem video) async {
if (state.activeTrack is SpotubeTrack && video is PipedSearchItemStream) {
Future<void> swapSibling(YoutubeVideoInfo video) async {
if (state.activeTrack is SpotubeTrack) {
await populateSibling();
final newTrack = await (state.activeTrack as SpotubeTrack)
.swappedCopy(video, preferences, pipedClient);
final newTrack =
await (state.activeTrack as SpotubeTrack).swappedCopy(video, youtube);
if (newTrack == null) return;
state = state.copyWith(
tracks: mergeTracks([newTrack], state.tracks),

View File

@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.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/proxy_playlist/proxy_playlist_provider.dart';
@ -28,13 +29,11 @@ enum CloseBehavior {
close,
}
enum SearchMode {
youtube._internal('YouTube'),
youtubeMusic._internal('YouTube Music');
enum YoutubeApiType {
youtube,
piped;
final String label;
const SearchMode._internal(this.label);
String get label => name[0].toUpperCase() + name.substring(1);
}
class UserPreferences extends PersistedChangeNotifier {
@ -63,6 +62,8 @@ class UserPreferences extends PersistedChangeNotifier {
bool skipNonMusic;
YoutubeApiType youtubeApiType;
final Ref ref;
UserPreferences(
@ -82,6 +83,7 @@ class UserPreferences extends PersistedChangeNotifier {
this.pipedInstance = "https://pipedapi.kavin.rocks",
this.searchMode = SearchMode.youtube,
this.skipNonMusic = true,
this.youtubeApiType = YoutubeApiType.youtube,
}) : super() {
if (downloadLocation.isEmpty) {
_getDefaultDownloadDirectory().then(
@ -188,6 +190,12 @@ class UserPreferences extends PersistedChangeNotifier {
updatePersistence();
}
void setYoutubeApiType(YoutubeApiType type) {
youtubeApiType = type;
notifyListeners();
updatePersistence();
}
Future<String> _getDefaultDownloadDirectory() async {
if (kIsAndroid) return "/storage/emulated/0/Download/Spotube";
@ -240,6 +248,11 @@ class UserPreferences extends PersistedChangeNotifier {
);
skipNonMusic = map["skipNonMusic"] ?? skipNonMusic;
youtubeApiType = YoutubeApiType.values.firstWhere(
(type) => type.name == map["youtubeApiType"],
orElse: () => YoutubeApiType.youtube,
);
}
@override
@ -261,6 +274,7 @@ class UserPreferences extends PersistedChangeNotifier {
"pipedInstance": pipedInstance,
"searchMode": searchMode.name,
"skipNonMusic": skipNonMusic,
"youtubeApiType": youtubeApiType.name,
};
}
}

View File

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

View File

@ -0,0 +1,221 @@
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
import 'package:piped_client/piped_client.dart';
import 'package:spotube/models/matched_track.dart';
import 'package:spotube/provider/user_preferences_provider.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<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 {
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();
}
}
String _pipedStreamResponseToStreamUrl(PipedStreamResponse stream) {
final streamFormat = DesktopTools.platform.isLinux
? PipedAudioStreamFormat.webm
: PipedAudioStreamFormat.m4a;
return switch (preferences.audioQuality) {
AudioQuality.high =>
stream.highestBitrateAudioStreamOfFormat(streamFormat)!.url,
AudioQuality.low =>
stream.lowestBitrateAudioStreamOfFormat(streamFormat)!.url,
};
}
Future<String> streamingUrl(String id) async {
if (youtube != null) {
final res = await PrimitiveUtils.raceMultiple(
() => youtube!.videos.streams.getManifest(id),
);
final audioOnlyManifests = res.audioOnly.where((info) {
final isMp4a = info.codec.mimeType == "audio/mp4";
if (DesktopTools.platform.isLinux) {
return !isMp4a;
} else if (DesktopTools.platform.isMacOS ||
DesktopTools.platform.isIOS) {
return isMp4a;
} else {
return true;
}
});
return switch (preferences.audioQuality) {
AudioQuality.high =>
audioOnlyManifests.withHighestBitrate().url.toString(),
AudioQuality.low =>
audioOnlyManifests.sortByBitrate().last.url.toString(),
};
} else {
return _pipedStreamResponseToStreamUrl(await piped!.streams(id));
}
}
Future<(YoutubeVideoInfo info, String streamingUrl)> video(
String id, SearchMode searchMode) async {
if (youtube != null) {
final res = await youtube!.videos.get(id);
return (
YoutubeVideoInfo.fromVideo(res),
await streamingUrl(id),
);
} else {
final res = await piped!.streams(id);
return (
YoutubeVideoInfo.fromStreamResponse(res, searchMode),
_pipedStreamResponseToStreamUrl(res),
);
}
}
}

View File

@ -5,11 +5,12 @@ import 'dart:io';
import 'package:flutter/widgets.dart' hide Image;
import 'package:metadata_god/metadata_god.dart';
import 'package:path/path.dart';
import 'package:piped_client/piped_client.dart';
import 'package:spotube/collections/assets.gen.dart';
import 'package:spotube/components/shared/links/anchor_button.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/models/matched_track.dart';
import 'package:spotube/models/spotube_track.dart';
import 'package:spotube/services/youtube/youtube.dart';
import 'package:spotube/utils/primitive_utils.dart';
import 'package:spotube/utils/service_utils.dart';
@ -127,28 +128,19 @@ abstract class TypeConversionUtils {
String? art,
}) {
final track = SpotubeTrack(
PipedStreamResponse(
YoutubeVideoInfo(
searchMode: SearchMode.youtube,
id: "dQw4w9WgXcQ",
title: basenameWithoutExtension(file.path),
dash: null,
description: "",
dislikes: -1,
duration: Duration(milliseconds: metadata?.durationMs?.toInt() ?? 0),
hls: null,
lbryId: "",
likes: -1,
livestream: false,
proxyUrl: "",
dislikes: 0,
likes: 0,
thumbnailUrl: art ?? "",
uploadedDate: DateTime.now().toUtc().toString(),
uploader: metadata?.albumArtist ?? "",
uploaderUrl: "",
uploaderVerified: false,
views: -1,
audioStreams: [],
videoStreams: [],
relatedStreams: [],
subtitles: [],
views: 0,
channelName: metadata?.albumArtist ?? "Spotube",
channelId: metadata?.albumArtist ?? "Spotube",
publishedAt:
metadata?.year != null ? DateTime(metadata!.year!) : DateTime(2003),
),
file.path,
[],

View File

@ -2132,6 +2132,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.4"
youtube_explode_dart:
dependency: "direct main"
description:
name: youtube_explode_dart
sha256: "07889a6229a63e78f8d45a3b852897c2e0fa42e96c4daa38d411be211575bc38"
url: "https://pub.dev"
source: hosted
version: "1.12.4"
sdks:
dart: ">=3.0.2 <4.0.0"
dart: ">=3.0.0 <4.0.0"
flutter: ">=3.10.0"

View File

@ -96,6 +96,7 @@ dependencies:
background_downloader: ^7.4.0
duration: ^3.0.12
disable_battery_optimization: ^1.1.0+1
youtube_explode_dart: ^1.12.4
dev_dependencies:
build_runner: ^2.3.2

View File

@ -64,12 +64,14 @@
"logs",
"developers",
"not_logged_in",
"search_mode"
"search_mode",
"youtube_api_type"
],
"de": [
"not_logged_in",
"search_mode"
"search_mode",
"youtube_api_type"
],
"fr": [
@ -137,7 +139,8 @@
"logs",
"developers",
"not_logged_in",
"search_mode"
"search_mode",
"youtube_api_type"
],
"hi": [
@ -205,11 +208,13 @@
"logs",
"developers",
"not_logged_in",
"search_mode"
"search_mode",
"youtube_api_type"
],
"ja": [
"not_logged_in",
"search_mode"
"search_mode",
"youtube_api_type"
]
}