spotube/lib/provider/audio_player/audio_player_streams.dart
2025-08-01 22:18:29 +06:00

179 lines
6.3 KiB
Dart

import 'dart:async';
import 'dart:math';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/models/playback/track_sources.dart';
import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/provider/audio_player/state.dart';
import 'package:spotube/provider/discord_provider.dart';
import 'package:spotube/provider/history/history.dart';
import 'package:spotube/provider/metadata_plugin/core/scrobble.dart';
import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart';
import 'package:spotube/provider/server/track_sources.dart';
import 'package:spotube/provider/skip_segments/skip_segments.dart';
import 'package:spotube/provider/scrobbler/scrobbler.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/services/audio_player/audio_player.dart';
import 'package:spotube/services/audio_services/audio_services.dart';
import 'package:spotube/services/logger/logger.dart';
class AudioPlayerStreamListeners {
final Ref ref;
late final AudioServices notificationService;
AudioPlayerStreamListeners(this.ref) {
AudioServices.create(ref, ref.read(audioPlayerProvider.notifier)).then(
(value) => notificationService = value,
);
final subscriptions = [
subscribeToPlaylist(),
subscribeToSkipSponsor(),
subscribeToScrobbleChanged(),
subscribeToPosition(),
subscribeToPlayerError(),
];
ref.onDispose(() {
for (final subscription in subscriptions) {
subscription.cancel();
}
});
}
ScrobblerNotifier get scrobbler => ref.read(scrobblerProvider.notifier);
UserPreferences get preferences => ref.read(userPreferencesProvider);
DiscordNotifier get discord => ref.read(discordProvider.notifier);
AudioPlayerState get audioPlayerState => ref.read(audioPlayerProvider);
PlaybackHistoryActions get history =>
ref.read(playbackHistoryActionsProvider);
StreamSubscription subscribeToPlaylist() {
return audioPlayer.playlistStream.listen((mpvPlaylist) {
try {
if (audioPlayerState.activeTrack == null) return;
notificationService.addTrack(audioPlayerState.activeTrack!);
discord.updatePresence(audioPlayerState.activeTrack!);
} catch (e, stack) {
AppLogger.reportError(e, stack);
}
});
}
StreamSubscription subscribeToSkipSponsor() {
return audioPlayer.positionStream.listen((position) async {
try {
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));
}
} catch (e, stack) {
AppLogger.reportError(e, stack);
}
});
}
StreamSubscription subscribeToScrobbleChanged() {
String? lastScrobbled;
return audioPlayer.positionStream.listen((position) async {
try {
final uid = audioPlayerState.activeTrack is SpotubeLocalTrackObject
? (audioPlayerState.activeTrack as SpotubeLocalTrackObject).path
: audioPlayerState.activeTrack?.id;
/// According to Listenbrainz and Last.fm, a scrobble should be sent
/// after 4 minutes of listening or 50% of the track duration,
/// whichever is less.
final minimumListenTime = min(audioPlayer.duration.inSeconds ~/ 2, 240);
if (audioPlayerState.activeTrack == null ||
lastScrobbled == uid ||
position.inSeconds < minimumListenTime ||
audioPlayer.duration == Duration.zero ||
position == Duration.zero) {
return;
}
scrobbler.scrobble(audioPlayerState.activeTrack!);
ref
.read(metadataPluginScrobbleProvider.notifier)
.scrobble(audioPlayerState.activeTrack!);
lastScrobbled = uid;
/// The [Track] from Playlist.getTracks doesn't contain artist images
/// so we need to fetch them from the API
var activeTrack = audioPlayerState.activeTrack!;
if (activeTrack.artists.any((a) => a.images == null)) {
final metadataPlugin = await ref.read(metadataPluginProvider.future);
final artists = await Future.wait(
activeTrack.artists
.map((artist) => metadataPlugin!.artist.getArtist(artist.id)),
);
activeTrack = activeTrack.copyWith(
artists: artists
.map((e) => SpotubeSimpleArtistObject.fromJson(e.toJson()))
.toList(),
);
}
await history.addTrack(activeTrack);
} catch (e, stack) {
AppLogger.reportError(e, stack);
}
});
}
StreamSubscription subscribeToPosition() {
String lastTrack = ""; // used to prevent multiple calls to the same track
return audioPlayer.positionStream.listen((event) async {
final percentProgress =
(event.inSeconds / max(audioPlayer.duration.inSeconds, 1)) * 100;
try {
if (percentProgress < 80 ||
audioPlayerState.currentIndex == -1 ||
audioPlayerState.currentIndex ==
audioPlayerState.tracks.length - 1) {
return;
}
final nextTrack = audioPlayerState.tracks
.elementAtOrNull(audioPlayerState.currentIndex + 1);
if (nextTrack == null ||
lastTrack == nextTrack.id ||
nextTrack is SpotubeLocalTrackObject) {
return;
}
try {
await ref.read(
trackSourcesProvider(
TrackSourceQuery.fromTrack(nextTrack as SpotubeFullTrackObject),
).future,
);
} finally {
lastTrack = nextTrack.id;
}
} catch (e, stack) {
AppLogger.reportError(e, stack);
}
});
}
StreamSubscription subscribeToPlayerError() {
return audioPlayer.errorStream.listen((event) {});
}
}
final audioPlayerStreamListenersProvider =
Provider<AudioPlayerStreamListeners>(AudioPlayerStreamListeners.new);