spotube/lib/provider/audio_player/audio_player.dart

471 lines
13 KiB
Dart

import 'dart:math';
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:media_kit/media_kit.dart';
import 'package:spotube/extensions/list.dart';
import 'package:spotube/models/database/database.dart';
import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/models/playback/track_sources.dart';
import 'package:spotube/provider/audio_player/state.dart';
import 'package:spotube/provider/blacklist_provider.dart';
import 'package:spotube/provider/database/database.dart';
import 'package:spotube/provider/discord_provider.dart';
import 'package:spotube/provider/server/track_sources.dart';
import 'package:spotube/services/audio_player/audio_player.dart';
import 'package:spotube/services/logger/logger.dart';
class AudioPlayerNotifier extends Notifier<AudioPlayerState> {
BlackListNotifier get _blacklist => ref.read(blacklistProvider.notifier);
void _assertAllowedTracks(Iterable<SpotubeTrackObject> tracks) {
assert(
tracks.every(
(track) =>
track is SpotubeFullTrackObject || track is SpotubeLocalTrackObject,
),
'All tracks must be either SpotubeFullTrackObject or SpotubeLocalTrackObject',
);
}
void _assertAllowedTrack(SpotubeTrackObject tracks) {
assert(
tracks is SpotubeFullTrackObject || tracks is SpotubeLocalTrackObject,
'Track must be either SpotubeFullTrackObject or SpotubeLocalTrackObject',
);
}
Future<void> _syncSavedState() async {
final database = ref.read(databaseProvider);
var playerState =
await database.select(database.audioPlayerStateTable).getSingleOrNull();
if (playerState == null) {
await database.into(database.audioPlayerStateTable).insert(
AudioPlayerStateTableCompanion.insert(
playing: audioPlayer.isPlaying,
loopMode: audioPlayer.loopMode,
shuffled: audioPlayer.isShuffled,
collections: <String>[],
tracks: const Value(<SpotubeTrackObject>[]),
currentIndex: const Value(0),
id: const Value(0),
),
);
playerState =
await database.select(database.audioPlayerStateTable).getSingle();
} else {
await audioPlayer.setLoopMode(playerState.loopMode);
await audioPlayer.setShuffle(playerState.shuffled);
}
final tracks = playerState.tracks;
final currentIndex = playerState.currentIndex;
if (tracks.isEmpty && state.tracks.isNotEmpty) {
await _updatePlayerState(
AudioPlayerStateTableCompanion(
tracks: Value(state.tracks),
currentIndex: Value(currentIndex),
),
);
} else if (tracks.isNotEmpty) {
state = state.copyWith(
tracks: tracks,
currentIndex: currentIndex,
);
await audioPlayer.openPlaylist(
tracks.asMediaList(),
initialIndex: currentIndex,
autoPlay: false,
);
}
if (playerState.collections.isNotEmpty) {
state = state.copyWith(
collections: playerState.collections,
);
}
}
Future<void> _updatePlayerState(
AudioPlayerStateTableCompanion companion,
) async {
final database = ref.read(databaseProvider);
await (database.update(database.audioPlayerStateTable)
..where((tb) => tb.id.equals(0)))
.write(companion);
}
@override
build() {
final subscriptions = [
audioPlayer.playingStream.listen((playing) async {
try {
state = state.copyWith(playing: playing);
await _updatePlayerState(
AudioPlayerStateTableCompanion(
playing: Value(playing),
),
);
} catch (e, stack) {
AppLogger.reportError(e, stack);
}
}),
audioPlayer.loopModeStream.listen((loopMode) async {
try {
state = state.copyWith(loopMode: loopMode);
await _updatePlayerState(
AudioPlayerStateTableCompanion(
loopMode: Value(loopMode),
),
);
} catch (e, stack) {
AppLogger.reportError(e, stack);
}
}),
audioPlayer.shuffledStream.listen((shuffled) async {
try {
state = state.copyWith(shuffled: shuffled);
await _updatePlayerState(
AudioPlayerStateTableCompanion(
shuffled: Value(shuffled),
),
);
} catch (e, stack) {
AppLogger.reportError(e, stack);
}
}),
audioPlayer.playlistStream.listen((playlist) async {
try {
// Playlist and state has to be in sync. This is only meant for
// the shuffle/re-ordering indices to be in sync
if (playlist.medias.length != state.tracks.length) return;
final queries = playlist.medias
.map((media) => TrackSourceQuery.parseUri(media.uri))
.toList();
final trackGroupedById = groupBy(
state.tracks,
(query) => query.id,
);
final tracks = queries
.map((query) => trackGroupedById[query.id]?.firstOrNull)
.nonNulls
.toList();
if (tracks.length != state.tracks.length) {
AppLogger.log.w("Mismatch in tracks after reordering/shuffling.");
final missingTracks =
state.tracks.where((track) => !tracks.contains(track)).toList();
AppLogger.log.w(
"Missing tracks: ${missingTracks.map((e) => e.id).join(", ")}",
);
}
state = state.copyWith(
tracks: tracks,
currentIndex: playlist.index,
);
await _updatePlayerState(
AudioPlayerStateTableCompanion(
currentIndex: Value(state.currentIndex),
tracks: Value(state.tracks),
),
);
} catch (e, stack) {
AppLogger.reportError(e, stack);
}
}),
];
_syncSavedState();
ref.onDispose(() {
for (final subscription in subscriptions) {
subscription.cancel();
}
});
return AudioPlayerState(
loopMode: audioPlayer.loopMode,
playing: audioPlayer.isPlaying,
shuffled: audioPlayer.isShuffled,
tracks: [],
collections: [],
);
}
// Collection related methods
Future<void> addCollections(List<String> collectionIds) async {
state = state.copyWith(collections: [
...state.collections,
...collectionIds,
]);
await _updatePlayerState(
AudioPlayerStateTableCompanion(
collections: Value(state.collections),
),
);
}
Future<void> addCollection(String collectionId) async {
await addCollections([collectionId]);
}
Future<void> removeCollections(List<String> collectionIds) async {
state = state.copyWith(
collections: state.collections
.where((element) => !collectionIds.contains(element))
.toList(),
);
await _updatePlayerState(
AudioPlayerStateTableCompanion(
collections: Value(state.collections),
),
);
}
Future<void> removeCollection(String collectionId) async {
await removeCollections([collectionId]);
}
Future<void> addTracksAtFirst(
Iterable<SpotubeTrackObject> tracks, {
bool allowDuplicates = false,
}) async {
_assertAllowedTracks(tracks);
if (state.tracks.length == 1) {
return addTracks(tracks);
}
final addableTracks = _blacklist.filter(tracks).where(
(track) =>
allowDuplicates ||
!state.tracks.any((element) => _compareTracks(element, track)),
);
state = state.copyWith(
tracks: [...addableTracks, ...state.tracks],
);
for (int i = 0; i < addableTracks.length; i++) {
final track = addableTracks.elementAt(i);
await audioPlayer.addTrackAt(
SpotubeMedia(track),
max(state.currentIndex, 0) + i + 1,
);
}
await _updatePlayerState(
AudioPlayerStateTableCompanion(
tracks: Value(state.tracks),
currentIndex: Value(max(state.currentIndex, 0)),
),
);
}
Future<void> addTrack(SpotubeTrackObject track) async {
_assertAllowedTrack(track);
if (_blacklist.contains(track)) return;
if (state.tracks.any((element) => _compareTracks(element, track))) return;
state = state.copyWith(
tracks: [...state.tracks, track],
);
await audioPlayer.addTrack(SpotubeMedia(track));
await _updatePlayerState(
AudioPlayerStateTableCompanion(
tracks: Value(state.tracks),
currentIndex: Value(max(state.currentIndex, 0)),
),
);
}
Future<void> addTracks(Iterable<SpotubeTrackObject> tracks) async {
_assertAllowedTracks(tracks);
tracks = _blacklist.filter(tracks).toList();
state = state.copyWith(
tracks: [...state.tracks, ...tracks],
);
for (final track in tracks) {
await audioPlayer.addTrack(SpotubeMedia(track));
}
await _updatePlayerState(
AudioPlayerStateTableCompanion(
tracks: Value(state.tracks),
currentIndex: Value(max(state.currentIndex, 0)),
),
);
}
Future<void> removeTrack(String trackId) async {
final index = state.tracks.indexWhere((element) => element.id == trackId);
if (index == -1) return;
state = state.copyWith(
tracks: List.of(state.tracks)..removeAt(index),
);
await audioPlayer.removeTrack(index);
await _updatePlayerState(
AudioPlayerStateTableCompanion(
tracks: Value(state.tracks),
currentIndex: Value(max(state.currentIndex, 0)),
),
);
}
Future<void> removeTracks(Iterable<String> trackIds) async {
final trackIndexes = state.tracks
.where((element) => trackIds.any((trackId) => trackId == element.id))
.mapIndexed((index, element) => index);
final tracks = state.tracks.where(
(element) => !trackIds.contains(element.id),
);
state = state.copyWith(
tracks: tracks.toList(),
);
for (final index in trackIndexes) {
await audioPlayer.removeTrack(index);
}
await _updatePlayerState(
AudioPlayerStateTableCompanion(
tracks: Value(state.tracks),
currentIndex: Value(max(state.currentIndex, 0)),
),
);
}
bool _compareTracks(SpotubeTrackObject a, SpotubeTrackObject b) {
if ((a is SpotubeLocalTrackObject && b is! SpotubeLocalTrackObject) ||
(a is! SpotubeLocalTrackObject && b is SpotubeLocalTrackObject)) {
return false;
}
return a is SpotubeLocalTrackObject && b is SpotubeLocalTrackObject
? (a).path == (b).path
: a.id == b.id;
}
Future<void> load(
List<SpotubeTrackObject> tracks, {
int initialIndex = 0,
bool autoPlay = false,
}) async {
_assertAllowedTracks(tracks);
final medias = _blacklist
.filter(tracks)
.toList()
.asMediaList()
.unique((a, b) => a.uri == b.uri);
// Giving the initial track a boost so MediaKit won't skip
// because of timeout
final intendedActiveTrack = medias.elementAt(initialIndex);
if (intendedActiveTrack.track is! SpotubeLocalTrackObject) {
await ref.read(
trackSourcesProvider(
TrackSourceQuery.fromTrack(
intendedActiveTrack.track as SpotubeFullTrackObject),
).future,
);
}
if (medias.isEmpty) return;
state = state.copyWith(
// These are filtered tracks as well
tracks: medias.map((media) => media.track).toList(),
currentIndex: initialIndex,
collections: [],
);
await audioPlayer.openPlaylist(
medias,
initialIndex: initialIndex,
autoPlay: autoPlay,
);
await _updatePlayerState(
AudioPlayerStateTableCompanion(
tracks: Value(state.tracks),
currentIndex: Value(max(state.currentIndex, 0)),
),
);
}
Future<void> jumpToTrack(SpotubeTrackObject track) async {
final index =
state.tracks.toList().indexWhere((element) => element.id == track.id);
if (index == -1) return;
await audioPlayer.jumpTo(index);
}
Future<void> moveTrack(int oldIndex, int newIndex) async {
if (oldIndex == newIndex ||
newIndex < 0 ||
oldIndex < 0 ||
newIndex > state.tracks.length - 1 ||
oldIndex > state.tracks.length - 1) {
return;
}
await audioPlayer.moveTrack(oldIndex, newIndex);
}
Future<void> stop() async {
state = state.copyWith(
tracks: [],
currentIndex: 0,
collections: [],
loopMode: PlaylistMode.none,
playing: false,
shuffled: false,
);
await audioPlayer.stop();
await _updatePlayerState(
AudioPlayerStateTableCompanion(
tracks: Value(state.tracks),
currentIndex: const Value(0),
collections: const Value(<String>[]),
loopMode: const Value(PlaylistMode.none),
playing: const Value(false),
shuffled: const Value(false),
),
);
ref.read(discordProvider.notifier).clear();
}
}
final audioPlayerProvider =
NotifierProvider<AudioPlayerNotifier, AudioPlayerState>(
() => AudioPlayerNotifier(),
);