fix(playback): sponsor block skips and stutters in same position

This commit is contained in:
Kingkor Roy Tirtho 2024-04-07 13:05:40 +06:00
parent f26503990c
commit 0d080b77b7
8 changed files with 270 additions and 254 deletions

View File

@ -2,6 +2,7 @@
"cmake.configureOnOpen": false, "cmake.configureOnOpen": false,
"cSpell.words": [ "cSpell.words": [
"acousticness", "acousticness",
"Amoled",
"Buildless", "Buildless",
"danceability", "danceability",
"fuzzywuzzy", "fuzzywuzzy",

View File

@ -6,7 +6,7 @@ abstract class FakeData {
static final Image image = Image() static final Image image = Image()
..height = 1 ..height = 1
..width = 1 ..width = 1
..url = "url"; ..url = "https://dummyimage.com/100x100/cfcfcf/cfcfcf.jpg";
static final Followers followers = Followers() static final Followers followers = Followers()
..href = "text" ..href = "text"

View File

@ -24,7 +24,6 @@ import 'package:spotube/models/logger.dart';
import 'package:spotube/models/skip_segment.dart'; import 'package:spotube/models/skip_segment.dart';
import 'package:spotube/models/source_match.dart'; import 'package:spotube/models/source_match.dart';
import 'package:spotube/provider/connect/clients.dart'; import 'package:spotube/provider/connect/clients.dart';
import 'package:spotube/provider/connect/connect.dart';
import 'package:spotube/provider/connect/server.dart'; import 'package:spotube/provider/connect/server.dart';
import 'package:spotube/provider/palette_provider.dart'; import 'package:spotube/provider/palette_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';

View File

@ -0,0 +1,132 @@
// ignore_for_file: invalid_use_of_protected_member
import 'dart:async';
import 'package:catcher_2/catcher_2.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:spotube/models/local_track.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/proxy_playlist/skip_segments.dart';
import 'package:spotube/services/audio_player/audio_player.dart';
import 'package:spotube/services/sourced_track/exceptions.dart';
extension ProxyPlaylistListeners on ProxyPlaylistNotifier {
StreamSubscription<String> subscribeToSourceChanges() =>
audioPlayer.activeSourceChangedStream.listen((event) {
try {
final newActiveTrack = mapSourcesToTracks([event]).firstOrNull;
if (newActiveTrack == null ||
newActiveTrack.id == state.activeTrack?.id) {
return;
}
notificationService.addTrack(newActiveTrack);
discord.updatePresence(newActiveTrack);
state = state.copyWith(
active: state.tracks
.toList()
.indexWhere((element) => element.id == newActiveTrack.id),
);
updatePalette();
} catch (e, stackTrace) {
Catcher2.reportCheckedError(e, stackTrace);
}
});
StreamSubscription subscribeToPercentCompletion() {
final isPreSearching = ObjectRef(false);
return audioPlayer.percentCompletedStream(2).listen((event) async {
if (isPreSearching.value ||
audioPlayer.currentSource == null ||
audioPlayer.nextSource == null ||
isPlayable(audioPlayer.nextSource!)) return;
try {
isPreSearching.value = true;
final track = await ensureSourcePlayable(audioPlayer.nextSource!);
if (track != null) {
state = state.copyWith(tracks: mergeTracks([track], state.tracks));
}
} catch (e, stackTrace) {
// Removing tracks that were not found to avoid queue interruption
if (e is TrackNotFoundError) {
final oldTrack =
mapSourcesToTracks([audioPlayer.nextSource!]).firstOrNull;
await removeTrack(oldTrack!.id!);
}
Catcher2.reportCheckedError(e, stackTrace);
} finally {
isPreSearching.value = false;
}
});
}
StreamSubscription subscribeToShuffleChanges() {
return audioPlayer.shuffledStream.listen((event) {
try {
final newlyOrderedTracks = mapSourcesToTracks(audioPlayer.sources);
final newActiveIndex = newlyOrderedTracks.indexWhere(
(element) => element.id == state.activeTrack?.id,
);
if (newActiveIndex == -1) return;
state = state.copyWith(
tracks: newlyOrderedTracks.toSet(),
active: newActiveIndex,
);
} catch (e, stackTrace) {
Catcher2.reportCheckedError(e, stackTrace);
}
});
}
StreamSubscription subscribeToSkipSponsor() {
return audioPlayer.positionStream.listen((position) async {
final currentSegments = await ref.read(segmentProvider.future);
if (currentSegments?.segments.isNotEmpty != true ||
position < const Duration(seconds: 3)) return;
for (final segment in currentSegments!.segments) {
final seconds = position.inSeconds;
if (seconds < segment.start || seconds >= segment.end) continue;
await audioPlayer.seek(Duration(seconds: segment.end + 1));
}
});
}
StreamSubscription subscribeToScrobbleChanged() {
String? lastScrobbled;
return audioPlayer.positionStream.listen((position) {
try {
final uid = state.activeTrack is LocalTrack
? (state.activeTrack as LocalTrack).path
: state.activeTrack?.id;
if (state.activeTrack == null ||
lastScrobbled == uid ||
position.inSeconds < 30) {
return;
}
scrobbler.scrobble(state.activeTrack!);
lastScrobbled = uid;
} catch (e, stack) {
Catcher2.reportCheckedError(e, stack);
}
});
}
StreamSubscription subscribeToPlayerError() {
return audioPlayer.errorStream.listen((event) {});
}
}

View File

@ -1,24 +1,18 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'dart:math'; import 'dart:math';
import 'package:catcher_2/catcher_2.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart';
import 'package:palette_generator/palette_generator.dart'; import 'package:palette_generator/palette_generator.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/extensions/image.dart'; import 'package:spotube/extensions/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/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';
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/player_listeners.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart';
import 'package:spotube/provider/scrobbler_provider.dart'; import 'package:spotube/provider/scrobbler_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
@ -26,34 +20,11 @@ import 'package:spotube/provider/user_preferences/user_preferences_state.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/discord_provider.dart'; import 'package:spotube/provider/discord_provider.dart';
import 'package:spotube/services/sourced_track/exceptions.dart';
import 'package:spotube/services/sourced_track/models/source_info.dart'; import 'package:spotube/services/sourced_track/models/source_info.dart';
import 'package:spotube/services/sourced_track/sourced_track.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart';
import 'package:spotube/services/sourced_track/sources/piped.dart';
import 'package:spotube/services/sourced_track/sources/youtube.dart';
import 'package:spotube/utils/persisted_state_notifier.dart'; import 'package:spotube/utils/persisted_state_notifier.dart';
/// Things implemented:
/// * [x] Sponsor-Block skip
/// * [x] Prefetch next track as [SourcedTrack] on 80% of current track
/// * [x] Mixed Queue containing both [SourcedTrack] and [LocalTrack]
/// * [x] Modification of the Queue
/// * [x] Add track at the end
/// * [x] Add track at the beginning
/// * [x] Remove track
/// * [x] Reorder track
/// * [x] Caching and loading of cache of tracks
/// * [x] Shuffling
/// * [x] loop => playlist, track, none
/// * [x] Alternative Track Source
/// * [x] Blacklisting of tracks and artist
///
/// Don'ts:
/// * It'll not have any proxy method for [SpotubeAudioPlayer]
/// * It'll not store any sort of player state e.g playing, paused, shuffled etc
/// * For that, use [SpotubeAudioPlayer]
class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist> class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
with NextFetcher { with NextFetcher {
final Ref ref; final Ref ref;
@ -74,162 +45,21 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
static AlwaysAliveRefreshable<ProxyPlaylistNotifier> get notifier => static AlwaysAliveRefreshable<ProxyPlaylistNotifier> get notifier =>
provider.notifier; provider.notifier;
List<StreamSubscription> _subscriptions = [];
ProxyPlaylistNotifier(this.ref) : super(ProxyPlaylist({}), "playlist") { ProxyPlaylistNotifier(this.ref) : super(ProxyPlaylist({}), "playlist") {
() async { AudioServices.create(ref, this).then(
notificationService = await AudioServices.create(ref, this); (value) => notificationService = value,
);
// listeners state _subscriptions = [
final currentSegments = // These are subscription methods from player_listeners.dart
// using source as unique id because alternative track source support subscribeToSourceChanges(),
ObjectRef<({String source, List<SkipSegment> segments})?>(null); subscribeToPercentCompletion(),
final isPreSearching = ObjectRef(false); subscribeToShuffleChanges(),
final isFetchingSegments = ObjectRef(false); subscribeToSkipSponsor(),
subscribeToScrobbleChanged(),
audioPlayer.activeSourceChangedStream.listen((newActiveSource) async { ];
try {
final newActiveTrack =
mapSourcesToTracks([newActiveSource]).firstOrNull;
if (newActiveTrack == null ||
newActiveTrack.id == state.activeTrack?.id) {
return;
}
notificationService.addTrack(newActiveTrack);
discord.updatePresence(newActiveTrack);
state = state.copyWith(
active: state.tracks
.toList()
.indexWhere((element) => element.id == newActiveTrack.id),
);
updatePalette();
} catch (e, stackTrace) {
Catcher2.reportCheckedError(e, stackTrace);
}
});
audioPlayer.shuffledStream.listen((event) {
try {
final newlyOrderedTracks = mapSourcesToTracks(audioPlayer.sources);
final newActiveIndex = newlyOrderedTracks.indexWhere(
(element) => element.id == state.activeTrack?.id,
);
if (newActiveIndex == -1) return;
state = state.copyWith(
tracks: newlyOrderedTracks.toSet(),
active: newActiveIndex,
);
} catch (e, stackTrace) {
Catcher2.reportCheckedError(e, stackTrace);
}
});
listenTo2Percent(int percent) async {
if (isPreSearching.value ||
audioPlayer.currentSource == null ||
audioPlayer.nextSource == null ||
isPlayable(audioPlayer.nextSource!)) return;
try {
isPreSearching.value = true;
final track = await ensureSourcePlayable(audioPlayer.nextSource!);
if (track != null) {
state = state.copyWith(tracks: mergeTracks([track], state.tracks));
}
} catch (e, stackTrace) {
// Removing tracks that were not found to avoid queue interruption
if (e is TrackNotFoundError) {
final oldTrack =
mapSourcesToTracks([audioPlayer.nextSource!]).firstOrNull;
await removeTrack(oldTrack!.id!);
}
Catcher2.reportCheckedError(e, stackTrace);
} finally {
isPreSearching.value = false;
}
}
audioPlayer.percentCompletedStream(2).listen(listenTo2Percent);
audioPlayer.positionStream.listen((position) async {
if (state.activeTrack == null || state.activeTrack is LocalTrack) {
isFetchingSegments.value = false;
return;
}
try {
final isNotYTMode = state.activeTrack is! YoutubeSourcedTrack &&
(state.activeTrack is PipedSourcedTrack &&
preferences.searchMode == SearchMode.youtubeMusic);
if (isNotYTMode || !preferences.skipNonMusic) return;
final isNotSameSegmentId =
currentSegments.value?.source != audioPlayer.currentSource;
if (currentSegments.value == null ||
(isNotSameSegmentId && !isFetchingSegments.value)) {
isFetchingSegments.value = true;
try {
currentSegments.value = (
source: audioPlayer.currentSource!,
segments: await getAndCacheSkipSegments(
(state.activeTrack as SourcedTrack).sourceInfo.id,
),
);
} catch (e) {
if (audioPlayer.currentSource != null) {
currentSegments.value = (
source: audioPlayer.currentSource!,
segments: [],
);
}
} finally {
isFetchingSegments.value = false;
}
}
// skipping in first 2 second breaks stream
if (currentSegments.value == null ||
currentSegments.value!.segments.isEmpty ||
position < const Duration(seconds: 3)) return;
for (final segment in currentSegments.value!.segments) {
if (position.inSeconds >= segment.start &&
position.inSeconds < segment.end) {
await audioPlayer.seek(Duration(seconds: segment.end));
}
}
} catch (e, stackTrace) {
Catcher2.reportCheckedError(e, stackTrace);
}
});
String? lastScrobbled;
audioPlayer.positionStream.listen((position) {
try {
final uid = state.activeTrack is LocalTrack
? (state.activeTrack as LocalTrack).path
: state.activeTrack?.id;
if (state.activeTrack == null ||
lastScrobbled == uid ||
position.inSeconds < 30) {
return;
}
scrobbler.scrobble(state.activeTrack!);
lastScrobbled = uid;
} catch (e, stack) {
Catcher2.reportCheckedError(e, stack);
}
});
}();
} }
Future<SourcedTrack?> ensureSourcePlayable(String source) async { Future<SourcedTrack?> ensureSourcePlayable(String source) async {
@ -283,8 +113,6 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
}); });
} }
// TODO: Safely Remove playing tracks
Future<void> removeTrack(String trackId) async { Future<void> removeTrack(String trackId) async {
final track = final track =
state.tracks.firstWhereOrNull((element) => element.id == trackId); state.tracks.firstWhereOrNull((element) => element.id == trackId);
@ -533,72 +361,6 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
}); });
} }
Future<List<SkipSegment>> getAndCacheSkipSegments(String id) async {
if (!preferences.skipNonMusic ||
(preferences.audioSource == AudioSource.piped &&
preferences.searchMode == SearchMode.youtubeMusic)) return [];
try {
final cached = await SkipSegment.box.get(id);
if (cached != null && cached.isNotEmpty) {
return List.castFrom<dynamic, SkipSegment>(
(cached as List)
.map(
(json) => SkipSegment.fromJson(
Map.castFrom<dynamic, dynamic, String, dynamic>(json),
),
)
.toList(),
);
}
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').t(
"[SponsorBlock] successfully fetched skip segments for $id",
);
await SkipSegment.box.put(
id,
segments.map((e) => e.toJson()).toList(),
);
return List.castFrom<dynamic, SkipSegment>(segments);
} catch (e, stack) {
await SkipSegment.box.put(id, []);
Catcher2.reportCheckedError(e, stack);
return List.castFrom<dynamic, SkipSegment>([]);
}
}
@override @override
set state(state) { set state(state) {
super.state = state; super.state = state;
@ -631,4 +393,12 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
final json = state.toJson(); final json = state.toJson();
return json; return json;
} }
@override
void dispose() {
for (final subscription in _subscriptions) {
subscription.cancel();
}
super.dispose();
}
} }

View File

@ -0,0 +1,110 @@
import 'dart:convert';
import 'package:catcher_2/catcher_2.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart';
import 'package:spotube/models/local_track.dart';
import 'package:spotube/models/skip_segment.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
import 'package:spotube/services/sourced_track/sourced_track.dart';
class SourcedSegments {
final String source;
final List<SkipSegment> segments;
SourcedSegments({required this.source, required this.segments});
}
Future<List<SkipSegment>> getAndCacheSkipSegments(String id) async {
try {
final cached = await SkipSegment.box.get(id) as List?;
if (cached != null && cached.isNotEmpty) {
return List.castFrom<dynamic, SkipSegment>(
cached
.map(
(json) => SkipSegment.fromJson(
Map.castFrom<dynamic, dynamic, String, dynamic>(json),
),
)
.toList(),
);
}
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();
await SkipSegment.box.put(
id,
segments.map((e) => e.toJson()).toList(),
);
return List.castFrom<dynamic, SkipSegment>(segments);
} catch (e, stack) {
await SkipSegment.box.put(id, []);
Catcher2.reportCheckedError(e, stack);
return List.castFrom<dynamic, SkipSegment>([]);
}
}
final segmentProvider = FutureProvider<SourcedSegments?>(
(ref) async {
final track = ref.watch(
ProxyPlaylistNotifier.provider.select((s) => s.activeTrack),
);
if (track == null) return null;
if (track is LocalTrack || track is! SourcedTrack) return null;
final skipNonMusic = ref.watch(
userPreferencesProvider.select(
(s) {
final isPipedYTMusicMode = s.audioSource == AudioSource.piped &&
s.searchMode == SearchMode.youtubeMusic;
return s.skipNonMusic && !isPipedYTMusicMode;
},
),
);
if (!skipNonMusic) {
return SourcedSegments(
segments: [],
source: track.sourceInfo.id,
);
}
final segments = await getAndCacheSkipSegments(track.sourceInfo.id);
return SourcedSegments(
source: track.sourceInfo.id,
segments: segments,
);
},
);

View File

@ -146,4 +146,6 @@ mixin SpotubeAudioPlayersStreams on AudioPlayerInterface {
Stream<AudioDevice> get selectedDeviceStream => Stream<AudioDevice> get selectedDeviceStream =>
_mkPlayer.stream.audioDevice.asBroadcastStream(); _mkPlayer.stream.audioDevice.asBroadcastStream();
Stream<String> get errorStream => _mkPlayer.stream.error;
} }

View File

@ -131,6 +131,8 @@ abstract class SourcedTrack extends Track {
}; };
} on HttpClientClosedException catch (_) { } on HttpClientClosedException catch (_) {
return await PipedSourcedTrack.fetchFromTrack(track: track, ref: ref); return await PipedSourcedTrack.fetchFromTrack(track: track, ref: ref);
} on VideoUnplayableException catch (_) {
return await PipedSourcedTrack.fetchFromTrack(track: track, ref: ref);
} catch (e) { } catch (e) {
if (e is DioException || e is ClientException || e is SocketException) { if (e is DioException || e is ClientException || e is SocketException) {
if (preferences.audioSource == AudioSource.jiosaavn) { if (preferences.audioSource == AudioSource.jiosaavn) {