feat: add piped search mode

This commit is contained in:
Kingkor Roy Tirtho 2023-06-28 14:05:01 +06:00
parent a9b5a714e4
commit 17a25a501e
11 changed files with 315 additions and 22 deletions

View File

@ -89,4 +89,6 @@ abstract class SpotubeIcons {
static const timer = FeatherIcons.clock; static const timer = FeatherIcons.clock;
static const logs = FeatherIcons.fileText; static const logs = FeatherIcons.fileText;
static const clipboard = FeatherIcons.clipboard; static const clipboard = FeatherIcons.clipboard;
static const youtube = FeatherIcons.youtube;
static const skip = FeatherIcons.fastForward;
} }

View File

@ -247,5 +247,6 @@
"custom_hours": "Custom Hours", "custom_hours": "Custom Hours",
"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"
} }

View File

@ -24,6 +24,7 @@ import 'package:spotube/hooks/use_disable_battery_optimizations.dart';
import 'package:spotube/l10n/l10n.dart'; import 'package:spotube/l10n/l10n.dart';
import 'package:spotube/models/logger.dart'; import 'package:spotube/models/logger.dart';
import 'package:spotube/models/matched_track.dart'; import 'package:spotube/models/matched_track.dart';
import 'package:spotube/models/skip_segment.dart';
import 'package:spotube/provider/palette_provider.dart'; import 'package:spotube/provider/palette_provider.dart';
import 'package:spotube/provider/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences_provider.dart';
import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/audio_player.dart';
@ -101,6 +102,7 @@ Future<void> main(List<String> rawArgs) async {
connectivity: FlQueryConnectivityPlusAdapter(), connectivity: FlQueryConnectivityPlusAdapter(),
); );
Hive.registerAdapter(MatchedTrackAdapter()); Hive.registerAdapter(MatchedTrackAdapter());
Hive.registerAdapter(SkipSegmentAdapter());
await Hive.openLazyBox<MatchedTrack>( await Hive.openLazyBox<MatchedTrack>(
MatchedTrack.boxName, MatchedTrack.boxName,

View File

@ -0,0 +1,24 @@
import 'package:hive/hive.dart';
part 'skip_segment.g.dart';
@HiveType(typeId: 2)
class SkipSegment {
@HiveField(0)
final int start;
@HiveField(1)
final int end;
SkipSegment(this.start, this.end);
static const boxName = "oss.krtirtho.spotube.skip_segments";
static LazyBox<SkipSegment> get box => Hive.lazyBox<SkipSegment>(boxName);
SkipSegment.fromJson(Map<String, dynamic> json)
: start = json['start'],
end = json['end'];
Map<String, dynamic> toJson() => {
'start': start,
'end': end,
};
}

View File

@ -0,0 +1,44 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'skip_segment.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class SkipSegmentAdapter extends TypeAdapter<SkipSegment> {
@override
final int typeId = 2;
@override
SkipSegment read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return SkipSegment(
fields[0] as int,
fields[1] as int,
);
}
@override
void write(BinaryWriter writer, SkipSegment obj) {
writer
..writeByte(2)
..writeByte(0)
..write(obj.start)
..writeByte(1)
..write(obj.end);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SkipSegmentAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@ -1,19 +1,20 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'package:catcher/catcher.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:piped_client/piped_client.dart'; 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/logger.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/provider/user_preferences_provider.dart';
import 'package:spotube/utils/platform.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';
typedef SkipSegment = ({int start, int end});
class SpotubeTrack extends Track { class SpotubeTrack extends Track {
final PipedStreamResponse ytTrack; final PipedStreamResponse ytTrack;
final String ytUri; final String ytUri;
@ -68,8 +69,9 @@ class SpotubeTrack extends Track {
static Future<List<PipedSearchItemStream>> fetchSiblings( static Future<List<PipedSearchItemStream>> fetchSiblings(
Track track, Track track,
PipedClient client, PipedClient client, [
) async { PipedFilter filter = PipedFilter.musicSongs,
]) async {
final artists = (track.artists ?? []) final artists = (track.artists ?? [])
.map((ar) => ar.name) .map((ar) => ar.name)
.toList() .toList()
@ -82,12 +84,8 @@ class SpotubeTrack extends Track {
onlyCleanArtist: true, onlyCleanArtist: true,
).trim(); ).trim();
final List<PipedSearchItemStream> siblings = await client final List<PipedSearchItemStream> siblings =
.search( await client.search("$title - ${artists.join(", ")}", filter).then(
"$title - ${artists.join(", ")}",
PipedFilter.musicSongs,
)
.then(
(res) { (res) {
final siblings = res.items final siblings = res.items
.whereType<PipedSearchItemStream>() .whereType<PipedSearchItemStream>()
@ -122,7 +120,14 @@ class SpotubeTrack extends Track {
if (matchedCachedTrack != null) { if (matchedCachedTrack != null) {
ytVideo = await client.streams(matchedCachedTrack.youtubeId); ytVideo = await client.streams(matchedCachedTrack.youtubeId);
} else { } else {
siblings = await fetchSiblings(track, client); siblings = await fetchSiblings(
track,
client,
switch (preferences.searchMode) {
SearchMode.youtube => PipedFilter.video,
SearchMode.youtubeMusic => PipedFilter.musicSongs,
},
);
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}");
} }
@ -229,10 +234,17 @@ class SpotubeTrack extends Track {
); );
} }
Future<SpotubeTrack> populatedCopy(PipedClient client) async { Future<SpotubeTrack> populatedCopy(
PipedClient client,
PipedFilter filter,
) async {
if (this.siblings.isNotEmpty) return this; if (this.siblings.isNotEmpty) return this;
final siblings = await fetchSiblings(this, client); final siblings = await fetchSiblings(
this,
client,
filter,
);
return SpotubeTrack.fromTrack( return SpotubeTrack.fromTrack(
track: this, track: this,

View File

@ -324,6 +324,45 @@ class SettingsPage extends HookConsumerWidget {
Text(error.toString()), Text(error.toString()),
); );
}), }),
AdaptiveSelectTile<SearchMode>(
secondary: const Icon(SpotubeIcons.youtube),
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);
},
),
AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity:
preferences.searchMode == SearchMode.youtubeMusic
? 0
: 1,
child: AnimatedSize(
duration: const Duration(milliseconds: 200),
child: SizedBox(
height: preferences.searchMode ==
SearchMode.youtubeMusic
? 0
: 50,
child: SwitchListTile(
secondary: const Icon(SpotubeIcons.skip),
title: Text(context.l10n.skip_non_music),
value: preferences.skipNonMusic,
onChanged: (state) {
preferences.setSkipNonMusic(state);
},
),
),
),
),
SwitchListTile( SwitchListTile(
secondary: const Icon(SpotubeIcons.download), secondary: const Icon(SpotubeIcons.download),
title: Text(context.l10n.pre_download_play), title: Text(context.l10n.pre_download_play),

View File

@ -1,12 +1,19 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'package:catcher/catcher.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hive/hive.dart';
import 'package:http/http.dart';
import 'package:palette_generator/palette_generator.dart'; import 'package:palette_generator/palette_generator.dart';
import 'package:piped_client/piped_client.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/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';
import 'package:spotube/provider/palette_provider.dart'; import 'package:spotube/provider/palette_provider.dart';
@ -62,7 +69,9 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
() async { () async {
notificationService = await AudioServices.create(ref, this); notificationService = await AudioServices.create(ref, this);
audioPlayer.activeSourceChangedStream.listen((newActiveSource) { (String, List<SkipSegment>)? currentSegments;
bool isFetchingSegments = false;
audioPlayer.activeSourceChangedStream.listen((newActiveSource) async {
final newActiveTrack = final newActiveTrack =
mapSourcesToTracks([newActiveSource]).firstOrNull; mapSourcesToTracks([newActiveSource]).firstOrNull;
@ -78,6 +87,10 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
.indexWhere((element) => element.id == newActiveTrack.id), .indexWhere((element) => element.id == newActiveTrack.id),
); );
isFetchingSegments = true;
isFetchingSegments = false;
if (preferences.albumColorSync) { if (preferences.albumColorSync) {
updatePalette(); updatePalette();
} }
@ -101,7 +114,9 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
bool isPreSearching = false; bool isPreSearching = false;
listenTo60Percent(percent) async { listenTo60Percent(percent) async {
if (isPreSearching || audioPlayer.currentSource == null) return; if (isPreSearching ||
audioPlayer.currentSource == null ||
audioPlayer.nextSource == null) return;
try { try {
isPreSearching = true; isPreSearching = true;
@ -112,6 +127,19 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
if (track != null) { if (track != null) {
state = state.copyWith(tracks: mergeTracks([track], state.tracks)); state = state.copyWith(tracks: mergeTracks([track], state.tracks));
if (currentSegments == null ||
(oldTrack?.id != null &&
currentSegments!.$1 != oldTrack!.id!) &&
!isFetchingSegments) {
isFetchingSegments = true;
currentSegments = (
audioPlayer.currentSource!,
await getAndCacheSkipSegments(
track.ytTrack.id,
),
);
isFetchingSegments = false;
}
} }
/// Sometimes fetching can take a lot of time, so we need to check /// Sometimes fetching can take a lot of time, so we need to check
@ -144,6 +172,34 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
isPlayable(audioPlayer.nextSource!)) return; isPlayable(audioPlayer.nextSource!)) return;
await audioPlayer.pause(); await audioPlayer.pause();
}); });
audioPlayer.positionStream.listen((position) async {
if (preferences.searchMode == SearchMode.youtubeMusic ||
!preferences.skipNonMusic) return;
if (currentSegments == null ||
currentSegments!.$1 != state.activeTrack!.id! &&
!isFetchingSegments) {
isFetchingSegments = true;
currentSegments = (
audioPlayer.currentSource!,
await getAndCacheSkipSegments(
(state.activeTrack as SpotubeTrack).ytTrack.id,
),
);
isFetchingSegments = false;
}
final (_, segments) = currentSegments!;
if (segments.isEmpty) return;
for (final segment in segments) {
if ((position.inSeconds >= segment.start &&
position.inSeconds < segment.end)) {
await audioPlayer.seek(Duration(seconds: segment.end));
}
}
});
}(); }();
} }
@ -332,7 +388,13 @@ 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(pipedClient); await (state.activeTrack as SpotubeTrack).populatedCopy(
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),
@ -449,6 +511,64 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
}); });
} }
Future<List<SkipSegment>> getAndCacheSkipSegments(String id) async {
if (!preferences.skipNonMusic ||
preferences.searchMode != SearchMode.youtube) return [];
try {
final box = await Hive.openLazyBox<List>(SkipSegment.boxName);
final cached = await box.get(id);
if (cached != null && cached.isNotEmpty) {
return List.castFrom<dynamic, SkipSegment>(cached);
}
final res = await get(Uri(
scheme: "https",
host: "sponsor.ajay.app",
path: "/api/skipSegments",
queryParameters: {
"videoID": id,
"category": [
'sponsor',
'selfpromo',
'interaction',
'intro',
'outro',
'music_offtopic'
],
"actionType": 'skip'
},
));
if (res.body == "Not Found") {
return List.castFrom<dynamic, SkipSegment>([]);
}
final data = jsonDecode(res.body) as List;
final segments = data.map((obj) {
final start = obj["segment"].first.toInt();
final end = obj["segment"].last.toInt();
return SkipSegment(
start,
end,
);
}).toList();
getLogger('getSkipSegments').v(
"[SponsorBlock] successfully fetched skip segments for $id",
);
await box.put(
id,
segments,
);
return List.castFrom<dynamic, SkipSegment>(segments);
} catch (e, stack) {
await box.put(id, []);
Catcher.reportCheckedError(e, stack);
return List.castFrom<dynamic, SkipSegment>([]);
}
}
@override @override
set state(state) { set state(state) {
super.state = state; super.state = state;

View File

@ -28,6 +28,15 @@ enum CloseBehavior {
close, close,
} }
enum SearchMode {
youtube._internal('YouTube'),
youtubeMusic._internal('YouTubeMusic');
final String label;
const SearchMode._internal(this.label);
}
class UserPreferences extends PersistedChangeNotifier { class UserPreferences extends PersistedChangeNotifier {
ThemeMode themeMode; ThemeMode themeMode;
String recommendationMarket; String recommendationMarket;
@ -52,6 +61,10 @@ class UserPreferences extends PersistedChangeNotifier {
String pipedInstance; String pipedInstance;
SearchMode searchMode;
bool skipNonMusic;
final Ref ref; final Ref ref;
UserPreferences( UserPreferences(
@ -70,6 +83,8 @@ class UserPreferences extends PersistedChangeNotifier {
this.showSystemTrayIcon = true, this.showSystemTrayIcon = true,
this.locale = const Locale("system", "system"), this.locale = const Locale("system", "system"),
this.pipedInstance = "https://pipedapi.kavin.rocks", this.pipedInstance = "https://pipedapi.kavin.rocks",
this.searchMode = SearchMode.youtubeMusic,
this.skipNonMusic = true,
}) : super() { }) : super() {
if (downloadLocation.isEmpty) { if (downloadLocation.isEmpty) {
_getDefaultDownloadDirectory().then( _getDefaultDownloadDirectory().then(
@ -170,6 +185,18 @@ class UserPreferences extends PersistedChangeNotifier {
updatePersistence(); updatePersistence();
} }
void setSearchMode(SearchMode mode) {
searchMode = mode;
notifyListeners();
updatePersistence();
}
void setSkipNonMusic(bool skip) {
skipNonMusic = skip;
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";
@ -217,6 +244,13 @@ class UserPreferences extends PersistedChangeNotifier {
localeMap != null ? Locale(localeMap?["lc"], localeMap?["cc"]) : locale; localeMap != null ? Locale(localeMap?["lc"], localeMap?["cc"]) : locale;
pipedInstance = map["pipedInstance"] ?? pipedInstance; pipedInstance = map["pipedInstance"] ?? pipedInstance;
searchMode = SearchMode.values.firstWhere(
(mode) => mode.name == map["searchMode"],
orElse: () => SearchMode.youtubeMusic,
);
skipNonMusic = map["skipNonMusic"] ?? skipNonMusic;
} }
@override @override
@ -237,6 +271,8 @@ class UserPreferences extends PersistedChangeNotifier {
"locale": "locale":
jsonEncode({"lc": locale.languageCode, "cc": locale.countryCode}), jsonEncode({"lc": locale.languageCode, "cc": locale.countryCode}),
"pipedInstance": pipedInstance, "pipedInstance": pipedInstance,
"searchMode": searchMode.name,
"skipNonMusic": skipNonMusic,
}; };
} }
} }

View File

@ -1654,6 +1654,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.1" version: "1.0.1"
simple_icons:
dependency: "direct main"
description:
name: simple_icons
sha256: "8aa6832dc7a263a3213e40ecbf1328a392308c809d534a3b860693625890483b"
url: "https://pub.dev"
source: hosted
version: "7.10.0"
skeleton_text: skeleton_text:
dependency: "direct main" dependency: "direct main"
description: description:

View File

@ -63,11 +63,13 @@
"custom_hours", "custom_hours",
"logs", "logs",
"developers", "developers",
"not_logged_in" "not_logged_in",
"search_mode"
], ],
"de": [ "de": [
"not_logged_in" "not_logged_in",
"search_mode"
], ],
"fr": [ "fr": [
@ -134,7 +136,8 @@
"custom_hours", "custom_hours",
"logs", "logs",
"developers", "developers",
"not_logged_in" "not_logged_in",
"search_mode"
], ],
"hi": [ "hi": [
@ -201,10 +204,12 @@
"custom_hours", "custom_hours",
"logs", "logs",
"developers", "developers",
"not_logged_in" "not_logged_in",
"search_mode"
], ],
"ja": [ "ja": [
"not_logged_in" "not_logged_in",
"search_mode"
] ]
} }