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

View File

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

View File

@ -248,5 +248,6 @@
"logs": "Logs", "logs": "Logs",
"developers": "Developers", "developers": "Developers",
"not_logged_in": "You're not logged in", "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(MatchedTrackAdapter());
Hive.registerAdapter(SkipSegmentAdapter()); Hive.registerAdapter(SkipSegmentAdapter());
Hive.registerAdapter(SearchModeAdapter());
await Hive.openLazyBox<MatchedTrack>( await Hive.openLazyBox<MatchedTrack>(
MatchedTrack.boxName, MatchedTrack.boxName,

View File

@ -1,5 +1,4 @@
import "package:hive/hive.dart"; import "package:hive/hive.dart";
part "matched_track.g.dart"; part "matched_track.g.dart";
@HiveType(typeId: 1) @HiveType(typeId: 1)
@ -8,6 +7,8 @@ class MatchedTrack {
String youtubeId; String youtubeId;
@HiveField(1) @HiveField(1)
String spotifyId; String spotifyId;
@HiveField(2)
SearchMode searchMode;
String? id; String? id;
DateTime? createdAt; DateTime? createdAt;
@ -21,12 +22,14 @@ class MatchedTrack {
MatchedTrack({ MatchedTrack({
required this.youtubeId, required this.youtubeId,
required this.spotifyId, required this.spotifyId,
required this.searchMode,
this.id, this.id,
this.createdAt, this.createdAt,
}); });
factory MatchedTrack.fromJson(Map<String, dynamic> json) { factory MatchedTrack.fromJson(Map<String, dynamic> json) {
return MatchedTrack( return MatchedTrack(
searchMode: SearchMode.fromString(json["searchMode"]),
youtubeId: json["youtube_id"], youtubeId: json["youtube_id"],
spotifyId: json["spotify_id"], spotifyId: json["spotify_id"],
id: json["id"], id: json["id"],
@ -39,7 +42,27 @@ class MatchedTrack {
"youtube_id": youtubeId, "youtube_id": youtubeId,
"spotify_id": spotifyId, "spotify_id": spotifyId,
"id": id, "id": id,
"searchMode": searchMode.name,
"created_at": createdAt?.toString() "created_at": createdAt?.toString()
}..removeWhere((key, value) => value == null); }..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( return MatchedTrack(
youtubeId: fields[0] as String, youtubeId: fields[0] as String,
spotifyId: fields[1] as String, spotifyId: fields[1] as String,
searchMode: fields[2] as SearchMode,
); );
} }
@override @override
void write(BinaryWriter writer, MatchedTrack obj) { void write(BinaryWriter writer, MatchedTrack obj) {
writer writer
..writeByte(2) ..writeByte(3)
..writeByte(0) ..writeByte(0)
..write(obj.youtubeId) ..write(obj.youtubeId)
..writeByte(1) ..writeByte(1)
..write(obj.spotifyId); ..write(obj.spotifyId)
..writeByte(2)
..write(obj.searchMode);
} }
@override @override
@ -42,3 +45,42 @@ class MatchedTrackAdapter extends TypeAdapter<MatchedTrack> {
runtimeType == other.runtimeType && runtimeType == other.runtimeType &&
typeId == other.typeId; 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 'dart:async';
import 'package:piped_client/piped_client.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/extensions/album_simple.dart'; import 'package:spotube/extensions/album_simple.dart';
import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/artist_simple.dart';
import 'package:spotube/models/matched_track.dart'; import 'package:spotube/models/matched_track.dart';
import 'package:spotube/provider/user_preferences_provider.dart'; import 'package:spotube/services/youtube/youtube.dart';
import 'package:spotube/utils/platform.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/service_utils.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
class SpotubeTrack extends Track { class SpotubeTrack extends Track {
final PipedStreamResponse ytTrack; final YoutubeVideoInfo ytTrack;
final String ytUri; final String ytUri;
final List<PipedSearchItemStream> siblings; final List<YoutubeVideoInfo> siblings;
SpotubeTrack( SpotubeTrack(
this.ytTrack, this.ytTrack,
@ -48,25 +46,10 @@ class SpotubeTrack extends Track {
uri = track.uri; uri = track.uri;
} }
static PipedAudioStream getStreamInfo( static Future<List<YoutubeVideoInfo>> fetchSiblings(
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(
Track track, Track track,
PipedClient client, [ YoutubeEndpoints client,
PipedFilter filter = PipedFilter.musicSongs, ) async {
]) async {
final artists = (track.artists ?? []) final artists = (track.artists ?? [])
.map((ar) => ar.name) .map((ar) => ar.name)
.toList() .toList()
@ -79,22 +62,21 @@ class SpotubeTrack extends Track {
onlyCleanArtist: true, onlyCleanArtist: true,
).trim(); ).trim();
final List<PipedSearchItemStream> siblings = final List<YoutubeVideoInfo> siblings =
await client.search("$title - ${artists.join(", ")}", filter).then( await client.search("$title - ${artists.join(", ")}").then(
(res) { (res) {
final siblings = res.items final siblings = res
.whereType<PipedSearchItemStream>()
.where((item) { .where((item) {
return artists.any( return artists.any(
(artist) => (artist) =>
artist.toLowerCase() == item.uploaderName.toLowerCase(), artist.toLowerCase() == item.channelName.toLowerCase(),
); );
}) })
.take(10) .take(10)
.toList(); .toList();
if (siblings.isEmpty) { if (siblings.isEmpty) {
return res.items.whereType<PipedSearchItemStream>().take(10).toList(); return res.take(10).toList();
} }
return siblings; return siblings;
@ -106,61 +88,53 @@ class SpotubeTrack extends Track {
static Future<SpotubeTrack> fetchFromTrack( static Future<SpotubeTrack> fetchFromTrack(
Track track, Track track,
UserPreferences preferences, YoutubeEndpoints client,
PipedClient client,
) async { ) async {
final matchedCachedTrack = await MatchedTrack.box.get(track.id!); final matchedCachedTrack = await MatchedTrack.box.get(track.id!);
var siblings = <PipedSearchItemStream>[]; var siblings = <YoutubeVideoInfo>[];
PipedStreamResponse ytVideo; YoutubeVideoInfo ytVideo;
if (matchedCachedTrack != null) { String ytStreamUrl;
ytVideo = await client.streams(matchedCachedTrack.youtubeId); if (matchedCachedTrack != null &&
} else { matchedCachedTrack.searchMode == client.preferences.searchMode) {
siblings = await fetchSiblings( (ytVideo, ytStreamUrl) = await client.video(
track, matchedCachedTrack.youtubeId,
client, matchedCachedTrack.searchMode,
switch (preferences.searchMode) {
SearchMode.youtube => PipedFilter.video,
SearchMode.youtubeMusic => PipedFilter.musicSongs,
},
); );
} else {
siblings = await fetchSiblings(track, client);
if (siblings.isEmpty) { if (siblings.isEmpty) {
throw Exception("Failed to find any results for ${track.name}"); 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( await MatchedTrack.box.put(
track.id!, track.id!,
MatchedTrack( MatchedTrack(
youtubeId: ytVideo.id, youtubeId: ytVideo.id,
spotifyId: track.id!, spotifyId: track.id!,
searchMode: siblings.first.searchMode,
), ),
); );
} }
final PipedAudioStream ytStream =
getStreamInfo(ytVideo, preferences.audioQuality);
return SpotubeTrack.fromTrack( return SpotubeTrack.fromTrack(
track: track, track: track,
ytTrack: ytVideo, ytTrack: ytVideo,
ytUri: ytStream.url, ytUri: ytStreamUrl,
siblings: siblings, siblings: siblings,
); );
} }
Future<SpotubeTrack?> swappedCopy( Future<SpotubeTrack?> swappedCopy(
PipedSearchItemStream video, YoutubeVideoInfo video,
UserPreferences preferences, YoutubeEndpoints client,
PipedClient client,
) async { ) async {
// sibling tracks that were manually searched and swapped // sibling tracks that were manually searched and swapped
final isStepSibling = siblings.none((element) => element.id == video.id); final isStepSibling = siblings.none((element) => element.id == video.id);
final ytVideo = await client.streams(video.id); final (ytVideo, ytStreamUrl) =
await client.video(video.id, siblings.first.searchMode);
final ytStream = getStreamInfo(ytVideo, preferences.audioQuality);
final ytUri = ytStream.url;
if (!isStepSibling) { if (!isStepSibling) {
await MatchedTrack.box.put( await MatchedTrack.box.put(
@ -168,6 +142,7 @@ class SpotubeTrack extends Track {
MatchedTrack( MatchedTrack(
youtubeId: video.id, youtubeId: video.id,
spotifyId: id!, spotifyId: id!,
searchMode: siblings.first.searchMode,
), ),
); );
} }
@ -175,7 +150,7 @@ class SpotubeTrack extends Track {
return SpotubeTrack.fromTrack( return SpotubeTrack.fromTrack(
track: this, track: this,
ytTrack: ytVideo, ytTrack: ytVideo,
ytUri: ytUri, ytUri: ytStreamUrl,
siblings: [ siblings: [
video, video,
...siblings.where((element) => element.id != video.id), ...siblings.where((element) => element.id != video.id),
@ -186,24 +161,20 @@ class SpotubeTrack extends Track {
static SpotubeTrack fromJson(Map<String, dynamic> map) { static SpotubeTrack fromJson(Map<String, dynamic> map) {
return SpotubeTrack.fromTrack( return SpotubeTrack.fromTrack(
track: Track.fromJson(map), track: Track.fromJson(map),
ytTrack: PipedStreamResponse.fromJson(map["ytTrack"]), ytTrack: YoutubeVideoInfo.fromJson(map["ytTrack"]),
ytUri: map["ytUri"], ytUri: map["ytUri"],
siblings: List.castFrom<dynamic, Map<String, dynamic>>(map["siblings"]) siblings: List.castFrom<dynamic, Map<String, dynamic>>(map["siblings"])
.map((sibling) => PipedSearchItemStream.fromJson(sibling)) .map((sibling) => YoutubeVideoInfo.fromJson(sibling))
.toList(), .toList(),
); );
} }
Future<SpotubeTrack> populatedCopy( Future<SpotubeTrack> populatedCopy(YoutubeEndpoints client) async {
PipedClient client,
PipedFilter filter,
) async {
if (this.siblings.isNotEmpty) return this; if (this.siblings.isNotEmpty) return this;
final siblings = await fetchSiblings( final siblings = await fetchSiblings(
this, this,
client, client,
filter,
); );
return SpotubeTrack.fromTrack( 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/constrains.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/l10n/l10n.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/download_manager_provider.dart';
import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/provider/user_preferences_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'; import 'package:url_launcher/url_launcher_string.dart';
class SettingsPage extends HookConsumerWidget { class SettingsPage extends HookConsumerWidget {
@ -290,49 +291,11 @@ class SettingsPage extends HookConsumerWidget {
} }
}, },
), ),
Consumer(builder: (context, ref, child) { AdaptiveSelectTile<YoutubeApiType>(
final instanceList = secondary: const Icon(SpotubeIcons.youtube),
ref.watch(pipedInstancesFutureProvider); title: Text(context.l10n.youtube_api_type),
value: preferences.youtubeApiType,
return instanceList.when( options: YoutubeApiType.values
data: (data) {
return AdaptiveSelectTile<String>(
secondary: const Icon(SpotubeIcons.piped),
title: Text(context.l10n.piped_instance),
subtitle: Text(context.l10n.piped_description),
value: preferences.pipedInstance,
showValueWhenUnfolded: false,
options: data
.sortedBy((e) => e.name)
.map(
(e) => DropdownMenuItem(
value: e.apiUrl,
child: Text(
"${e.name}\n"
"${e.locations.map(countryCodeToEmoji).join(" ")}",
),
),
)
.toList(),
onChanged: (value) {
if (value != null) {
preferences.setPipedInstance(value);
}
},
);
},
loading: () => const Center(
child: CircularProgressIndicator(),
),
error: (error, stackTrace) =>
Text(error.toString()),
);
}),
AdaptiveSelectTile<SearchMode>(
secondary: const Icon(SpotubeIcons.search),
title: Text(context.l10n.search_mode),
value: preferences.searchMode,
options: SearchMode.values
.map((e) => DropdownMenuItem( .map((e) => DropdownMenuItem(
value: e, value: e,
child: Text(e.label), child: Text(e.label),
@ -340,32 +303,92 @@ class SettingsPage extends HookConsumerWidget {
.toList(), .toList(),
onChanged: (value) { onChanged: (value) {
if (value == null) return; if (value == null) return;
preferences.setSearchMode(value); preferences.setYoutubeApiType(value);
}, },
), ),
AnimatedOpacity( AnimatedSwitcher(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 300),
opacity: child: preferences.youtubeApiType ==
preferences.searchMode == SearchMode.youtubeMusic YoutubeApiType.youtube
? 0 ? const SizedBox.shrink()
: 1, : Consumer(builder: (context, ref, child) {
child: AnimatedSize( final instanceList =
duration: const Duration(milliseconds: 200), ref.watch(pipedInstancesFutureProvider);
child: SizedBox(
height: preferences.searchMode == return instanceList.when(
SearchMode.youtubeMusic data: (data) {
? 0 return AdaptiveSelectTile<String>(
: 50, secondary:
child: SwitchListTile( const Icon(SpotubeIcons.piped),
secondary: const Icon(SpotubeIcons.skip), title:
title: Text(context.l10n.skip_non_music), Text(context.l10n.piped_instance),
value: preferences.skipNonMusic, subtitle: Text(
onChanged: (state) { context.l10n.piped_description),
preferences.setSkipNonMusic(state); value: preferences.pipedInstance,
}, showValueWhenUnfolded: false,
), options: data
), .sortedBy((e) => e.name)
), .map(
(e) => DropdownMenuItem(
value: e.apiUrl,
child: Text(
"${e.name}\n"
"${e.locations.map(countryCodeToEmoji).join(" ")}",
),
),
)
.toList(),
onChanged: (value) {
if (value != null) {
preferences.setPipedInstance(value);
}
},
);
},
loading: () => const Center(
child: CircularProgressIndicator(),
),
error: (error, stackTrace) =>
Text(error.toString()),
);
}),
),
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,
options: SearchMode.values
.map((e) => DropdownMenuItem(
value: e,
child: Text(e.label),
))
.toList(),
onChanged: (value) {
if (value == null) return;
preferences.setSearchMode(value);
},
),
),
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,
onChanged: (state) {
preferences.setSkipNonMusic(state);
},
),
), ),
ListTile( ListTile(
leading: const Icon(SpotubeIcons.playlistRemove), leading: const Icon(SpotubeIcons.playlistRemove),

View File

@ -7,13 +7,14 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart'; import 'package:http/http.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:piped_client/piped_client.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/collections/routes.dart'; import 'package:spotube/collections/routes.dart';
import 'package:spotube/components/shared/dialogs/replace_downloaded_dialog.dart'; import 'package:spotube/components/shared/dialogs/replace_downloaded_dialog.dart';
import 'package:spotube/models/spotube_track.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/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'; import 'package:spotube/utils/type_conversion_utils.dart';
class DownloadManagerProvider extends StateNotifier<List<SpotubeTrack>> { class DownloadManagerProvider extends StateNotifier<List<SpotubeTrack>> {
@ -104,7 +105,7 @@ class DownloadManagerProvider extends StateNotifier<List<SpotubeTrack>> {
} }
UserPreferences get preferences => ref.read(userPreferencesProvider); UserPreferences get preferences => ref.read(userPreferencesProvider);
PipedClient get pipedClient => ref.read(pipedClientProvider); YoutubeEndpoints get youtube => ref.read(youtubeProvider);
int get totalDownloads => state.length; int get totalDownloads => state.length;
List<Track> get items => state; List<Track> get items => state;
@ -129,8 +130,7 @@ class DownloadManagerProvider extends StateNotifier<List<SpotubeTrack>> {
? track ? track
: await SpotubeTrack.fetchFromTrack( : await SpotubeTrack.fetchFromTrack(
track, track,
preferences, youtube,
pipedClient,
); );
state = [...state, spotubeTrack]; state = [...state, spotubeTrack];
final task = DownloadTask( 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:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:piped_client/piped_client.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/matched_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/proxy_playlist/proxy_playlist.dart';
import 'package:spotube/provider/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences_provider.dart';
import 'package:spotube/services/supabase.dart'; import 'package:spotube/services/supabase.dart';
import 'package:spotube/services/youtube/youtube.dart';
mixin NextFetcher on StateNotifier<ProxyPlaylist> { mixin NextFetcher on StateNotifier<ProxyPlaylist> {
Future<List<SpotubeTrack>> fetchTracks( Future<List<SpotubeTrack>> fetchTracks(
UserPreferences preferences, UserPreferences preferences,
PipedClient pipedClient, { YoutubeEndpoints youtube, {
int count = 3, int count = 3,
int offset = 0, int offset = 0,
}) async { }) async {
@ -29,8 +29,7 @@ mixin NextFetcher on StateNotifier<ProxyPlaylist> {
bareTracks.mapIndexed((i, track) async { bareTracks.mapIndexed((i, track) async {
final future = SpotubeTrack.fetchFromTrack( final future = SpotubeTrack.fetchFromTrack(
track, track,
preferences, youtube,
pipedClient,
); );
if (i == 0) { if (i == 0) {
return await future; return await future;
@ -117,6 +116,7 @@ mixin NextFetcher on StateNotifier<ProxyPlaylist> {
MatchedTrack( MatchedTrack(
youtubeId: spotubeTrack.ytTrack.id, youtubeId: spotubeTrack.ytTrack.id,
spotifyId: spotubeTrack.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:hive/hive.dart';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:palette_generator/palette_generator.dart'; import 'package:palette_generator/palette_generator.dart';
import 'package:piped_client/piped_client.dart';
import 'package:spotify/spotify.dart'; 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/spotube_track.dart';
import 'package:spotube/provider/blacklist_provider.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/next_fetcher_mixin.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_provider.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_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/provider/piped_provider.dart'; import 'package:spotube/services/youtube/youtube.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';
@ -51,7 +52,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
late final AudioServices notificationService; late final AudioServices notificationService;
UserPreferences get preferences => ref.read(userPreferencesProvider); UserPreferences get preferences => ref.read(userPreferencesProvider);
PipedClient get pipedClient => ref.read(pipedClientProvider); 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);
@ -213,7 +214,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
final nthFetchedTrack = switch (track.runtimeType) { final nthFetchedTrack = switch (track.runtimeType) {
SpotubeTrack => track as SpotubeTrack, SpotubeTrack => track as SpotubeTrack,
_ => await SpotubeTrack.fetchFromTrack(track, preferences, pipedClient), _ => await SpotubeTrack.fetchFromTrack(track, youtube),
}; };
await audioPlayer.replaceSource( await audioPlayer.replaceSource(
@ -298,8 +299,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
} else { } else {
final addableTrack = await SpotubeTrack.fetchFromTrack( final addableTrack = await SpotubeTrack.fetchFromTrack(
tracks.elementAtOrNull(initialIndex) ?? tracks.first, tracks.elementAtOrNull(initialIndex) ?? tracks.first,
preferences, youtube,
pipedClient,
); );
state = state.copyWith( state = state.copyWith(
@ -387,13 +387,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
Future<void> populateSibling() async { Future<void> populateSibling() async {
if (state.activeTrack is SpotubeTrack) { if (state.activeTrack is SpotubeTrack) {
final activeTrackWithSiblingsForSure = final activeTrackWithSiblingsForSure =
await (state.activeTrack as SpotubeTrack).populatedCopy( await (state.activeTrack as SpotubeTrack).populatedCopy(youtube);
pipedClient,
switch (preferences.searchMode) {
SearchMode.youtube => PipedFilter.video,
SearchMode.youtubeMusic => PipedFilter.musicSongs,
},
);
state = state.copyWith( state = state.copyWith(
tracks: mergeTracks([activeTrackWithSiblingsForSure], state.tracks), tracks: mergeTracks([activeTrackWithSiblingsForSure], state.tracks),
@ -403,11 +397,11 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
} }
} }
Future<void> swapSibling(PipedSearchItem video) async { Future<void> swapSibling(YoutubeVideoInfo video) async {
if (state.activeTrack is SpotubeTrack && video is PipedSearchItemStream) { if (state.activeTrack is SpotubeTrack) {
await populateSibling(); await populateSibling();
final newTrack = await (state.activeTrack as SpotubeTrack) final newTrack =
.swappedCopy(video, preferences, pipedClient); await (state.activeTrack as SpotubeTrack).swappedCopy(video, youtube);
if (newTrack == null) return; if (newTrack == null) return;
state = state.copyWith( state = state.copyWith(
tracks: mergeTracks([newTrack], state.tracks), 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:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.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';
@ -28,13 +29,11 @@ enum CloseBehavior {
close, close,
} }
enum SearchMode { enum YoutubeApiType {
youtube._internal('YouTube'), youtube,
youtubeMusic._internal('YouTube Music'); piped;
final String label; String get label => name[0].toUpperCase() + name.substring(1);
const SearchMode._internal(this.label);
} }
class UserPreferences extends PersistedChangeNotifier { class UserPreferences extends PersistedChangeNotifier {
@ -63,6 +62,8 @@ class UserPreferences extends PersistedChangeNotifier {
bool skipNonMusic; bool skipNonMusic;
YoutubeApiType youtubeApiType;
final Ref ref; final Ref ref;
UserPreferences( UserPreferences(
@ -82,6 +83,7 @@ class UserPreferences extends PersistedChangeNotifier {
this.pipedInstance = "https://pipedapi.kavin.rocks", this.pipedInstance = "https://pipedapi.kavin.rocks",
this.searchMode = SearchMode.youtube, this.searchMode = SearchMode.youtube,
this.skipNonMusic = true, this.skipNonMusic = true,
this.youtubeApiType = YoutubeApiType.youtube,
}) : super() { }) : super() {
if (downloadLocation.isEmpty) { if (downloadLocation.isEmpty) {
_getDefaultDownloadDirectory().then( _getDefaultDownloadDirectory().then(
@ -188,6 +190,12 @@ class UserPreferences extends PersistedChangeNotifier {
updatePersistence(); updatePersistence();
} }
void setYoutubeApiType(YoutubeApiType type) {
youtubeApiType = type;
notifyListeners();
updatePersistence();
}
Future<String> _getDefaultDownloadDirectory() async { Future<String> _getDefaultDownloadDirectory() async {
if (kIsAndroid) return "/storage/emulated/0/Download/Spotube"; if (kIsAndroid) return "/storage/emulated/0/Download/Spotube";
@ -240,6 +248,11 @@ class UserPreferences extends PersistedChangeNotifier {
); );
skipNonMusic = map["skipNonMusic"] ?? skipNonMusic; skipNonMusic = map["skipNonMusic"] ?? skipNonMusic;
youtubeApiType = YoutubeApiType.values.firstWhere(
(type) => type.name == map["youtubeApiType"],
orElse: () => YoutubeApiType.youtube,
);
} }
@override @override
@ -261,6 +274,7 @@ class UserPreferences extends PersistedChangeNotifier {
"pipedInstance": pipedInstance, "pipedInstance": pipedInstance,
"searchMode": searchMode.name, "searchMode": searchMode.name,
"skipNonMusic": skipNonMusic, "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:flutter/widgets.dart' hide Image;
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:piped_client/piped_client.dart';
import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/collections/assets.gen.dart';
import 'package:spotube/components/shared/links/anchor_button.dart'; import 'package:spotube/components/shared/links/anchor_button.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/models/matched_track.dart';
import 'package:spotube/models/spotube_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/primitive_utils.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/service_utils.dart';
@ -127,28 +128,19 @@ abstract class TypeConversionUtils {
String? art, String? art,
}) { }) {
final track = SpotubeTrack( final track = SpotubeTrack(
PipedStreamResponse( YoutubeVideoInfo(
searchMode: SearchMode.youtube,
id: "dQw4w9WgXcQ", id: "dQw4w9WgXcQ",
title: basenameWithoutExtension(file.path), title: basenameWithoutExtension(file.path),
dash: null,
description: "",
dislikes: -1,
duration: Duration(milliseconds: metadata?.durationMs?.toInt() ?? 0), duration: Duration(milliseconds: metadata?.durationMs?.toInt() ?? 0),
hls: null, dislikes: 0,
lbryId: "", likes: 0,
likes: -1,
livestream: false,
proxyUrl: "",
thumbnailUrl: art ?? "", thumbnailUrl: art ?? "",
uploadedDate: DateTime.now().toUtc().toString(), views: 0,
uploader: metadata?.albumArtist ?? "", channelName: metadata?.albumArtist ?? "Spotube",
uploaderUrl: "", channelId: metadata?.albumArtist ?? "Spotube",
uploaderVerified: false, publishedAt:
views: -1, metadata?.year != null ? DateTime(metadata!.year!) : DateTime(2003),
audioStreams: [],
videoStreams: [],
relatedStreams: [],
subtitles: [],
), ),
file.path, file.path,
[], [],

View File

@ -2132,6 +2132,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.4" 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: sdks:
dart: ">=3.0.2 <4.0.0" dart: ">=3.0.0 <4.0.0"
flutter: ">=3.10.0" flutter: ">=3.10.0"

View File

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

View File

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