mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-12 23:45:18 +00:00
462 lines
13 KiB
Dart
462 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();
|
|
|
|
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(),
|
|
);
|