mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-13 16:05:18 +00:00
feat: re-introduce youtube API along with piped
This commit is contained in:
parent
b47ef98197
commit
b54ee96233
@ -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,6 +171,7 @@ class SiblingTracksSheet extends HookConsumerWidget {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
else ...[
|
else ...[
|
||||||
|
if (preferences.youtubeApiType == YoutubeApiType.piped)
|
||||||
PopupMenuButton(
|
PopupMenuButton(
|
||||||
icon: const Icon(SpotubeIcons.filter, size: 18),
|
icon: const Icon(SpotubeIcons.filter, size: 18),
|
||||||
onSelected: (SearchMode mode) {
|
onSelected: (SearchMode mode) {
|
||||||
|
@ -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,
|
||||||
),
|
),
|
||||||
|
@ -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"
|
||||||
}
|
}
|
@ -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,
|
||||||
|
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
@ -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(
|
||||||
|
@ -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,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 =
|
final instanceList =
|
||||||
ref.watch(pipedInstancesFutureProvider);
|
ref.watch(pipedInstancesFutureProvider);
|
||||||
|
|
||||||
return instanceList.when(
|
return instanceList.when(
|
||||||
data: (data) {
|
data: (data) {
|
||||||
return AdaptiveSelectTile<String>(
|
return AdaptiveSelectTile<String>(
|
||||||
secondary: const Icon(SpotubeIcons.piped),
|
secondary:
|
||||||
title: Text(context.l10n.piped_instance),
|
const Icon(SpotubeIcons.piped),
|
||||||
subtitle: Text(context.l10n.piped_description),
|
title:
|
||||||
|
Text(context.l10n.piped_instance),
|
||||||
|
subtitle: Text(
|
||||||
|
context.l10n.piped_description),
|
||||||
value: preferences.pipedInstance,
|
value: preferences.pipedInstance,
|
||||||
showValueWhenUnfolded: false,
|
showValueWhenUnfolded: false,
|
||||||
options: data
|
options: data
|
||||||
@ -328,7 +352,13 @@ class SettingsPage extends HookConsumerWidget {
|
|||||||
Text(error.toString()),
|
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),
|
secondary: const Icon(SpotubeIcons.search),
|
||||||
title: Text(context.l10n.search_mode),
|
title: Text(context.l10n.search_mode),
|
||||||
value: preferences.searchMode,
|
value: preferences.searchMode,
|
||||||
@ -343,20 +373,15 @@ class SettingsPage extends HookConsumerWidget {
|
|||||||
preferences.setSearchMode(value);
|
preferences.setSearchMode(value);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
AnimatedOpacity(
|
),
|
||||||
duration: const Duration(milliseconds: 200),
|
AnimatedSwitcher(
|
||||||
opacity:
|
duration: const Duration(milliseconds: 300),
|
||||||
preferences.searchMode == SearchMode.youtubeMusic
|
child: preferences.searchMode ==
|
||||||
? 0
|
SearchMode.youtubeMusic &&
|
||||||
: 1,
|
preferences.youtubeApiType ==
|
||||||
child: AnimatedSize(
|
YoutubeApiType.piped
|
||||||
duration: const Duration(milliseconds: 200),
|
? const SizedBox.shrink()
|
||||||
child: SizedBox(
|
: SwitchListTile(
|
||||||
height: preferences.searchMode ==
|
|
||||||
SearchMode.youtubeMusic
|
|
||||||
? 0
|
|
||||||
: 50,
|
|
||||||
child: 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,
|
||||||
@ -365,8 +390,6 @@ class SettingsPage extends HookConsumerWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(SpotubeIcons.playlistRemove),
|
leading: const Icon(SpotubeIcons.playlistRemove),
|
||||||
title: Text(context.l10n.blacklist),
|
title: Text(context.l10n.blacklist),
|
||||||
|
@ -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(
|
||||||
|
10
lib/provider/piped_instances_provider.dart
Normal file
10
lib/provider/piped_instances_provider.dart
Normal 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() ?? [];
|
||||||
|
},
|
||||||
|
);
|
@ -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(),
|
|
||||||
);
|
|
@ -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,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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),
|
||||||
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
8
lib/provider/youtube_provider.dart
Normal file
8
lib/provider/youtube_provider.dart
Normal 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);
|
||||||
|
});
|
221
lib/services/youtube/youtube.dart
Normal file
221
lib/services/youtube/youtube.dart
Normal 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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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,
|
||||||
[],
|
[],
|
||||||
|
10
pubspec.lock
10
pubspec.lock
@ -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"
|
||||||
|
@ -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
|
||||||
|
@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user