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 logs = FeatherIcons.fileText;
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",
"logs": "Logs",
"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/models/logger.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/user_preferences_provider.dart';
import 'package:spotube/services/audio_player/audio_player.dart';
@ -101,6 +102,7 @@ Future<void> main(List<String> rawArgs) async {
connectivity: FlQueryConnectivityPlusAdapter(),
);
Hive.registerAdapter(MatchedTrackAdapter());
Hive.registerAdapter(SkipSegmentAdapter());
await Hive.openLazyBox<MatchedTrack>(
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:convert';
import 'package:catcher/catcher.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:http/http.dart';
import 'package:piped_client/piped_client.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/extensions/album_simple.dart';
import 'package:spotube/extensions/artist_simple.dart';
import 'package:spotube/models/logger.dart';
import 'package:spotube/models/matched_track.dart';
import 'package:spotube/provider/user_preferences_provider.dart';
import 'package:spotube/utils/platform.dart';
import 'package:spotube/utils/service_utils.dart';
import 'package:collection/collection.dart';
typedef SkipSegment = ({int start, int end});
class SpotubeTrack extends Track {
final PipedStreamResponse ytTrack;
final String ytUri;
@ -68,8 +69,9 @@ class SpotubeTrack extends Track {
static Future<List<PipedSearchItemStream>> fetchSiblings(
Track track,
PipedClient client,
) async {
PipedClient client, [
PipedFilter filter = PipedFilter.musicSongs,
]) async {
final artists = (track.artists ?? [])
.map((ar) => ar.name)
.toList()
@ -82,12 +84,8 @@ class SpotubeTrack extends Track {
onlyCleanArtist: true,
).trim();
final List<PipedSearchItemStream> siblings = await client
.search(
"$title - ${artists.join(", ")}",
PipedFilter.musicSongs,
)
.then(
final List<PipedSearchItemStream> siblings =
await client.search("$title - ${artists.join(", ")}", filter).then(
(res) {
final siblings = res.items
.whereType<PipedSearchItemStream>()
@ -122,7 +120,14 @@ class SpotubeTrack extends Track {
if (matchedCachedTrack != null) {
ytVideo = await client.streams(matchedCachedTrack.youtubeId);
} 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) {
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;
final siblings = await fetchSiblings(this, client);
final siblings = await fetchSiblings(
this,
client,
filter,
);
return SpotubeTrack.fromTrack(
track: this,

View File

@ -324,6 +324,45 @@ class SettingsPage extends HookConsumerWidget {
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(
secondary: const Icon(SpotubeIcons.download),
title: Text(context.l10n.pre_download_play),

View File

@ -1,12 +1,19 @@
import 'dart:async';
import 'dart:convert';
import 'package:catcher/catcher.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.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:piped_client/piped_client.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/models/local_track.dart';
import 'package:spotube/models/logger.dart';
import 'package:spotube/models/skip_segment.dart';
import 'package:spotube/models/spotube_track.dart';
import 'package:spotube/provider/blacklist_provider.dart';
import 'package:spotube/provider/palette_provider.dart';
@ -62,7 +69,9 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
() async {
notificationService = await AudioServices.create(ref, this);
audioPlayer.activeSourceChangedStream.listen((newActiveSource) {
(String, List<SkipSegment>)? currentSegments;
bool isFetchingSegments = false;
audioPlayer.activeSourceChangedStream.listen((newActiveSource) async {
final newActiveTrack =
mapSourcesToTracks([newActiveSource]).firstOrNull;
@ -78,6 +87,10 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
.indexWhere((element) => element.id == newActiveTrack.id),
);
isFetchingSegments = true;
isFetchingSegments = false;
if (preferences.albumColorSync) {
updatePalette();
}
@ -101,7 +114,9 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
bool isPreSearching = false;
listenTo60Percent(percent) async {
if (isPreSearching || audioPlayer.currentSource == null) return;
if (isPreSearching ||
audioPlayer.currentSource == null ||
audioPlayer.nextSource == null) return;
try {
isPreSearching = true;
@ -112,6 +127,19 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
if (track != null) {
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
@ -144,6 +172,34 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
isPlayable(audioPlayer.nextSource!)) return;
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 {
if (state.activeTrack is SpotubeTrack) {
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(
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
set state(state) {
super.state = state;

View File

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

View File

@ -1654,6 +1654,14 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: "direct main"
description:

View File

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