diff --git a/lib/extensions/track.dart b/lib/extensions/track.dart index d8258a6d..9755179d 100644 --- a/lib/extensions/track.dart +++ b/lib/extensions/track.dart @@ -5,6 +5,7 @@ import 'package:path/path.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/extensions/album_simple.dart'; import 'package:spotube/extensions/artist_simple.dart'; +import 'package:spotube/services/audio_player/audio_player.dart'; extension TrackExtensions on Track { Track fromFile( @@ -90,3 +91,9 @@ extension TrackSimpleExtensions on TrackSimple { return track; } } + +extension TracksToMediaExtension on Iterable { + List asMediaList() { + return map((track) => SpotubeMedia(track)).toList(); + } +} diff --git a/lib/provider/proxy_playlist/next_fetcher_mixin.dart b/lib/provider/proxy_playlist/next_fetcher_mixin.dart deleted file mode 100644 index 1d2cfde8..00000000 --- a/lib/provider/proxy_playlist/next_fetcher_mixin.dart +++ /dev/null @@ -1,108 +0,0 @@ -import 'package:collection/collection.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/models/local_track.dart'; -import 'package:spotube/models/logger.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; -import 'package:spotube/services/sourced_track/sourced_track.dart'; - -final logger = getLogger("NextFetcherMixin"); - -mixin NextFetcher on StateNotifier { - Future> fetchTracks( - Ref ref, { - int count = 3, - int offset = 0, - }) async { - /// get [count] [state.tracks] that are not [SourcedTrack] and [LocalTrack] - - final bareTracks = state.tracks - .skip(offset) - .where((element) => element is! SourcedTrack && element is! LocalTrack) - .take(count); - - /// fetch [bareTracks] one by one with 100ms delay - final fetchedTracks = await Future.wait( - bareTracks.mapIndexed((i, track) async { - final future = SourcedTrack.fetchFromTrack( - ref: ref, - track: track, - ); - if (i == 0) { - return await future; - } - return await Future.delayed( - const Duration(milliseconds: 100), - () => future, - ); - }), - ); - - return fetchedTracks; - } - - /// Merges List of [SourcedTrack]s with [Track]s and outputs a mixed List - Set mergeTracks( - Iterable fetchTracks, - Iterable tracks, - ) { - return tracks.map((track) { - final fetchedTrack = fetchTracks.firstWhereOrNull( - (fetchTrack) => fetchTrack.id == track.id, - ); - if (fetchedTrack != null) { - return fetchedTrack; - } - return track; - }).toSet(); - } - - /// Checks if [Track] is playable - bool isUnPlayable(String source) { - return source.startsWith('https://youtube.com/unplayable.m4a?id='); - } - - bool isPlayable(String source) => !isUnPlayable(source); - - /// Returns [Track.id] from [isUnPlayable] source that is not playable - String getIdFromUnPlayable(String source) { - return source - .split('&') - .first - .replaceFirst('https://youtube.com/unplayable.m4a?id=', ''); - } - - /// Returns appropriate Media source for [Track] - /// - /// * If [Track] is [SourcedTrack] then return [SourcedTrack.ytUri] - /// * If [Track] is [LocalTrack] then return [LocalTrack.path] - /// * If [Track] is [Track] then return [Track.id] with [isUnPlayable] source - String makeAppropriateSource(Track track) { - if (track is SourcedTrack) { - return track.url; - } else if (track is LocalTrack) { - return track.path; - } else { - return trackToUnplayableSource(track); - } - } - - String trackToUnplayableSource(Track track) { - return "https://youtube.com/unplayable.m4a?id=${track.id}&title=${Uri.encodeComponent(track.name!)}"; - } - - List mapSourcesToTracks(List sources) { - return sources - .map((source) { - final track = state.tracks.firstWhereOrNull( - (track) => - trackToUnplayableSource(track) == source || - (track is SourcedTrack && track.url == source) || - (track is LocalTrack && track.path == source), - ); - return track; - }) - .whereNotNull() - .toList(); - } -} diff --git a/lib/provider/proxy_playlist/player_listeners.dart b/lib/provider/proxy_playlist/player_listeners.dart index 9069f3e1..efe2ed5f 100644 --- a/lib/provider/proxy_playlist/player_listeners.dart +++ b/lib/provider/proxy_playlist/player_listeners.dart @@ -3,87 +3,24 @@ 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 subscribeToSourceChanges() => - audioPlayer.activeSourceChangedStream.listen((event) { - try { - final newActiveTrack = mapSourcesToTracks([event]).firstOrNull; + StreamSubscription subscribeToPlaylist() { + return audioPlayer.playlistStream.listen((playlist) { + state = state.copyWith( + tracks: playlist.medias + .map((media) => (media as SpotubeMedia).track) + .toSet(), + active: playlist.index, + ); - 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); - } + notificationService.addTrack(state.activeTrack!); + discord.updatePresence(state.activeTrack!); + updatePalette(); }); } diff --git a/lib/provider/proxy_playlist/proxy_playlist.dart b/lib/provider/proxy_playlist/proxy_playlist.dart index efc818ed..c02f473e 100644 --- a/lib/provider/proxy_playlist/proxy_playlist.dart +++ b/lib/provider/proxy_playlist/proxy_playlist.dart @@ -1,5 +1,4 @@ import 'package:collection/collection.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/extensions/track.dart'; import 'package:spotube/models/local_track.dart'; @@ -14,12 +13,11 @@ class ProxyPlaylist { factory ProxyPlaylist.fromJson( Map json, - Ref ref, ) { return ProxyPlaylist( List.castFrom>( json['tracks'] ?? >[], - ).map((t) => _makeAppropriateTrack(t, ref)).toSet(), + ).map((t) => _makeAppropriateTrack(t)).toSet(), json['active'] as int?, json['collections'] == null ? {} @@ -58,10 +56,8 @@ class ProxyPlaylist { return tracks.every(containsTrack); } - static Track _makeAppropriateTrack(Map track, Ref ref) { - if (track.containsKey("ytUri")) { - return SourcedTrack.fromJson(track, ref: ref); - } else if (track.containsKey("path")) { + static Track _makeAppropriateTrack(Map track) { + if (track.containsKey("path")) { return LocalTrack.fromJson(track); } else { return Track.fromJson(track); diff --git a/lib/provider/proxy_playlist/proxy_playlist_provider.dart b/lib/provider/proxy_playlist/proxy_playlist_provider.dart index 438088de..ec53578d 100644 --- a/lib/provider/proxy_playlist/proxy_playlist_provider.dart +++ b/lib/provider/proxy_playlist/proxy_playlist_provider.dart @@ -1,17 +1,15 @@ import 'dart:async'; import 'dart:math'; -import 'package:collection/collection.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:palette_generator/palette_generator.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/image.dart'; -import 'package:spotube/models/local_track.dart'; +import 'package:spotube/extensions/track.dart'; import 'package:spotube/provider/blacklist_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/player_listeners.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; import 'package:spotube/provider/scrobbler_provider.dart'; @@ -21,12 +19,10 @@ import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_services/audio_services.dart'; import 'package:spotube/provider/discord_provider.dart'; import 'package:spotube/services/sourced_track/models/source_info.dart'; -import 'package:spotube/services/sourced_track/sourced_track.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; -class ProxyPlaylistNotifier extends PersistedStateNotifier - with NextFetcher { +class ProxyPlaylistNotifier extends PersistedStateNotifier { final Ref ref; late final AudioServices notificationService; @@ -54,49 +50,22 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier _subscriptions = [ // These are subscription methods from player_listeners.dart - subscribeToSourceChanges(), - subscribeToPercentCompletion(), - subscribeToShuffleChanges(), + subscribeToPlaylist(), subscribeToSkipSponsor(), subscribeToScrobbleChanged(), ]; } - - Future ensureSourcePlayable(String source) async { - if (isPlayable(source)) return null; - - final track = mapSourcesToTracks([source]).firstOrNull; - - if (track == null || track is LocalTrack) { - return null; - } - - final nthFetchedTrack = switch (track.runtimeType) { - SourcedTrack() => track as SourcedTrack, - _ => await SourcedTrack.fetchFromTrack(ref: ref, track: track), - }; - - await audioPlayer.replaceSource( - source, - nthFetchedTrack.url, - ); - - return nthFetchedTrack; - } - // Basic methods for adding or removing tracks to playlist Future addTrack(Track track) async { if (blacklist.contains(track)) return; - state = state.copyWith(tracks: {...state.tracks, track}); - await audioPlayer.addTrack(makeAppropriateSource(track)); + await audioPlayer.addTrack(SpotubeMedia(track)); } Future addTracks(Iterable tracks) async { tracks = blacklist.filter(tracks).toList() as List; - state = state.copyWith(tracks: {...state.tracks, ...tracks}); for (final track in tracks) { - await audioPlayer.addTrack(makeAppropriateSource(track)); + await audioPlayer.addTrack(SpotubeMedia(track)); } } @@ -114,25 +83,17 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier } Future removeTrack(String trackId) async { - final track = - state.tracks.firstWhereOrNull((element) => element.id == trackId); - if (track == null) return; - state = state.copyWith(tracks: {...state.tracks..remove(track)}); - final index = audioPlayer.sources.indexOf(makeAppropriateSource(track)); - if (index == -1) return; - await audioPlayer.removeTrack(index); + final trackIndex = + state.tracks.toList().indexWhere((element) => element.id == trackId); + if (trackIndex == -1) return; + await audioPlayer.removeTrack(trackIndex); } Future removeTracks(Iterable tracksIds) async { - final tracks = - state.tracks.where((element) => tracksIds.contains(element.id)); - - state = state.copyWith(tracks: { - ...state.tracks..removeWhere((element) => tracksIds.contains(element.id)) - }); + final tracks = state.tracks.map((t) => t.id!).toList(); for (final track in tracks) { - final index = audioPlayer.sources.indexOf(makeAppropriateSource(track)); + final index = tracks.indexOf(track); if (index == -1) continue; await audioPlayer.removeTrack(index); } @@ -144,64 +105,16 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier bool autoPlay = false, }) async { tracks = blacklist.filter(tracks).toList() as List; - final indexTrack = tracks.elementAtOrNull(initialIndex) ?? tracks.first; - - if (indexTrack is LocalTrack) { - state = state.copyWith( - tracks: tracks.toSet(), - active: initialIndex, - collections: {}, - ); - await notificationService.addTrack(indexTrack); - discord.updatePresence(indexTrack); - } else { - final addableTrack = await SourcedTrack.fetchFromTrack( - ref: ref, - track: tracks.elementAtOrNull(initialIndex) ?? tracks.first, - ).catchError((e, stackTrace) { - return SourcedTrack.fetchFromTrack( - ref: ref, - track: tracks.elementAtOrNull(initialIndex + 1) ?? tracks.first, - ); - }); - - state = state.copyWith( - tracks: mergeTracks([addableTrack], tracks), - active: initialIndex, - collections: {}, - ); - await notificationService.addTrack(addableTrack); - discord.updatePresence(addableTrack); - } await audioPlayer.openPlaylist( - state.tracks.map(makeAppropriateSource).toList(), + tracks.asMediaList(), initialIndex: initialIndex, autoPlay: autoPlay, ); } Future jumpTo(int index) async { - final oldTrack = - mapSourcesToTracks([audioPlayer.currentSource!]).firstOrNull; - - state = state.copyWith(active: index); - await audioPlayer.pause(); - final track = await ensureSourcePlayable(audioPlayer.sources[index]); - - if (track != null) { - state = state.copyWith( - tracks: mergeTracks([track], state.tracks), - active: index, - ); - } - await audioPlayer.jumpTo(index); - - if (oldTrack != null || track != null) { - await notificationService.addTrack(track ?? oldTrack!); - discord.updatePresence(track ?? oldTrack!); - } } Future jumpToTrack(Track track) async { @@ -211,7 +124,6 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier await jumpTo(index); } - // TODO: add safe guards for active/playing track that needs to be moved Future moveTrack(int oldIndex, int newIndex) async { if (oldIndex == newIndex || newIndex < 0 || @@ -219,11 +131,6 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier newIndex > state.tracks.length - 1 || oldIndex > state.tracks.length - 1) return; - final tracks = state.tracks.toList(); - final track = tracks.removeAt(oldIndex); - tracks.insert(newIndex, track); - state = state.copyWith(tracks: {...tracks}); - await audioPlayer.moveTrack(oldIndex, newIndex); } @@ -233,104 +140,56 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier } tracks = blacklist.filter(tracks).toList() as List; - final destIndex = state.active != null ? state.active! + 1 : 0; - final newTracks = state.tracks.toList()..insertAll(destIndex, tracks); - state = state.copyWith(tracks: newTracks.toSet()); - tracks.forEachIndexed((index, track) async { - audioPlayer.addTrackAt( - makeAppropriateSource(track), - destIndex + index, + for (int i = 0; i < tracks.length; i++) { + final track = tracks.elementAt(i); + + await audioPlayer.addTrackAt( + SpotubeMedia(track), + i + 1, ); - }); + } } Future populateSibling() async { - if (state.activeTrack is SourcedTrack) { - final activeTrackWithSiblingsForSure = - await (state.activeTrack as SourcedTrack).copyWithSibling(); + // if (state.activeTrack is SourcedTrack) { + // final activeTrackWithSiblingsForSure = + // await (state.activeTrack as SourcedTrack).copyWithSibling(); - state = state.copyWith( - tracks: mergeTracks([activeTrackWithSiblingsForSure], state.tracks), - active: state.tracks.toList().indexWhere( - (element) => element.id == activeTrackWithSiblingsForSure.id), - ); - } + // state = state.copyWith( + // tracks: mergeTracks([activeTrackWithSiblingsForSure], state.tracks), + // active: state.tracks.toList().indexWhere( + // (element) => element.id == activeTrackWithSiblingsForSure.id), + // ); + // } } Future swapSibling(SourceInfo sibling) async { - if (state.activeTrack is SourcedTrack) { - await populateSibling(); - final newTrack = - await (state.activeTrack as SourcedTrack).swapWithSibling(sibling); - if (newTrack == null) return; - state = state.copyWith( - tracks: mergeTracks([newTrack], state.tracks), - active: state.tracks - .toList() - .indexWhere((element) => element.id == newTrack.id), - ); - await audioPlayer.pause(); - await audioPlayer.replaceSource( - audioPlayer.currentSource!, - makeAppropriateSource(newTrack), - ); - } + // if (state.activeTrack is SourcedTrack) { + // await populateSibling(); + // final newTrack = + // await (state.activeTrack as SourcedTrack).swapWithSibling(sibling); + // if (newTrack == null) return; + // state = state.copyWith( + // tracks: mergeTracks([newTrack], state.tracks), + // active: state.tracks + // .toList() + // .indexWhere((element) => element.id == newTrack.id), + // ); + // await audioPlayer.pause(); + // await audioPlayer.replaceSource( + // audioPlayer.currentSource!, + // makeAppropriateSource(newTrack), + // ); + // } } Future next() async { - if (audioPlayer.nextSource == null) return; - final oldTrack = mapSourcesToTracks([audioPlayer.nextSource!]).firstOrNull; - - state = state.copyWith( - active: state.tracks - .toList() - .indexWhere((element) => element.id == oldTrack?.id), - ); - - await audioPlayer.pause(); - final track = await ensureSourcePlayable(audioPlayer.nextSource!); - - if (track != null) { - state = state.copyWith( - tracks: mergeTracks([track], state.tracks), - active: state.tracks - .toList() - .indexWhere((element) => element.id == track.id), - ); - } await audioPlayer.skipToNext(); - - if (oldTrack != null || track != null) { - await notificationService.addTrack(track ?? oldTrack!); - discord.updatePresence(track ?? oldTrack!); - } } Future previous() async { - if (audioPlayer.previousSource == null) return; - final oldTrack = - mapSourcesToTracks([audioPlayer.previousSource!]).firstOrNull; - state = state.copyWith( - active: state.tracks - .toList() - .indexWhere((element) => element.id == oldTrack?.id), - ); - await audioPlayer.pause(); - final track = await ensureSourcePlayable(audioPlayer.previousSource!); - if (track != null) { - state = state.copyWith( - tracks: mergeTracks([track], state.tracks), - active: state.tracks - .toList() - .indexWhere((element) => element.id == track.id), - ); - } await audioPlayer.skipToPrevious(); - if (oldTrack != null || track != null) { - await notificationService.addTrack(track ?? oldTrack!); - discord.updatePresence(track ?? oldTrack!); - } } Future stop() async { @@ -385,7 +244,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier @override FutureOr fromJson(Map json) { - return ProxyPlaylist.fromJson(json, ref); + return ProxyPlaylist.fromJson(json); } @override diff --git a/lib/services/audio_player/audio_player.dart b/lib/services/audio_player/audio_player.dart index 0a22bec1..6382dddd 100644 --- a/lib/services/audio_player/audio_player.dart +++ b/lib/services/audio_player/audio_player.dart @@ -1,6 +1,7 @@ import 'package:catcher_2/catcher_2.dart'; -import 'package:media_kit/media_kit.dart'; -import 'package:spotube/services/audio_player/mk_state_player.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/models/local_track.dart'; +import 'package:spotube/services/audio_player/custom_player.dart'; // import 'package:just_audio/just_audio.dart' as ja; import 'dart:async'; @@ -13,12 +14,29 @@ import 'package:spotube/services/sourced_track/sourced_track.dart'; part 'audio_players_streams_mixin.dart'; part 'audio_player_impl.dart'; +class SpotubeMedia extends mk.Media { + final Track track; + SpotubeMedia( + this.track, { + Map? extras, + super.httpHeaders, + }) : super( + track is LocalTrack + ? track.path + : "http://localhost:3000/stream/${track.id}", + extras: { + ...?extras, + "trackId": track.id, + }, + ); +} + abstract class AudioPlayerInterface { - final MkPlayerWithState _mkPlayer; + final CustomPlayer _mkPlayer; // final ja.AudioPlayer? _justAudxio; AudioPlayerInterface() - : _mkPlayer = MkPlayerWithState( + : _mkPlayer = CustomPlayer( configuration: const mk.PlayerConfiguration( title: "Spotube", ), @@ -61,18 +79,18 @@ abstract class AudioPlayerInterface { } } - Future get selectedDevice async { + Future get selectedDevice async { return _mkPlayer.state.audioDevice; } - Future> get devices async { + Future> get devices async { return _mkPlayer.state.audioDevices; } bool get hasSource { - return _mkPlayer.playlist.medias.isNotEmpty; + return _mkPlayer.state.playlist.medias.isNotEmpty; // if (mkSupportedPlatform) { - // return _mkPlayer.playlist.medias.isNotEmpty; + // return _mkPlayer.state.playlist.medias.isNotEmpty; // } else { // return _justAudio!.audioSource != null; // } @@ -125,7 +143,7 @@ abstract class AudioPlayerInterface { } PlaybackLoopMode get loopMode { - return PlaybackLoopMode.fromPlaylistMode(_mkPlayer.loopMode); + return PlaybackLoopMode.fromPlaylistMode(_mkPlayer.state.playlistMode); // if (mkSupportedPlatform) { // return PlaybackLoopMode.fromPlaylistMode(_mkPlayer.loopMode); // } else { diff --git a/lib/services/audio_player/audio_player_impl.dart b/lib/services/audio_player/audio_player_impl.dart index bfa13220..cfbe4368 100644 --- a/lib/services/audio_player/audio_player_impl.dart +++ b/lib/services/audio_player/audio_player_impl.dart @@ -83,7 +83,7 @@ class SpotubeAudioPlayer extends AudioPlayerInterface // await _justAudio?.setSpeed(speed); } - Future setAudioDevice(AudioDevice device) async { + Future setAudioDevice(mk.AudioDevice device) async { await _mkPlayer.setAudioDevice(device); } @@ -95,7 +95,7 @@ class SpotubeAudioPlayer extends AudioPlayerInterface // Playlist related Future openPlaylist( - List tracks, { + List tracks, { bool autoPlay = true, int initialIndex = 0, }) async { @@ -103,10 +103,7 @@ class SpotubeAudioPlayer extends AudioPlayerInterface assert(initialIndex <= tracks.length - 1); // if (mkSupportedPlatform) { await _mkPlayer.open( - mk.Playlist( - tracks.map(mk.Media.new).toList(), - index: initialIndex, - ), + mk.Playlist(tracks, index: initialIndex), play: autoPlay, ); // } else { @@ -137,7 +134,7 @@ class SpotubeAudioPlayer extends AudioPlayerInterface List get sources { // if (mkSupportedPlatform) { - return _mkPlayer.playlist.medias.map((e) => e.uri).toList(); + return _mkPlayer.state.playlist.medias.map((e) => e.uri).toList(); // } else { // return _justAudio!.sequenceState?.effectiveSequence // .map((e) => (e as ja.UriAudioSource).uri.toString()) @@ -148,9 +145,9 @@ class SpotubeAudioPlayer extends AudioPlayerInterface String? get currentSource { // if (mkSupportedPlatform) { - if (_mkPlayer.playlist.index == -1) return null; - return _mkPlayer.playlist.medias - .elementAtOrNull(_mkPlayer.playlist.index) + if (_mkPlayer.state.playlist.index == -1) return null; + return _mkPlayer.state.playlist.medias + .elementAtOrNull(_mkPlayer.state.playlist.index) ?.uri; // } else { // return (_justAudio?.sequenceState?.effectiveSequence @@ -165,12 +162,13 @@ class SpotubeAudioPlayer extends AudioPlayerInterface // if (mkSupportedPlatform) { if (loopMode == PlaybackLoopMode.all && - _mkPlayer.playlist.index == _mkPlayer.playlist.medias.length - 1) { + _mkPlayer.state.playlist.index == + _mkPlayer.state.playlist.medias.length - 1) { return sources.first; } - return _mkPlayer.playlist.medias - .elementAtOrNull(_mkPlayer.playlist.index + 1) + return _mkPlayer.state.playlist.medias + .elementAtOrNull(_mkPlayer.state.playlist.index + 1) ?.uri; // } else { // return (_justAudio?.sequenceState?.effectiveSequence @@ -182,13 +180,14 @@ class SpotubeAudioPlayer extends AudioPlayerInterface } String? get previousSource { - if (loopMode == PlaybackLoopMode.all && _mkPlayer.playlist.index == 0) { + if (loopMode == PlaybackLoopMode.all && + _mkPlayer.state.playlist.index == 0) { return sources.last; } // if (mkSupportedPlatform) { - return _mkPlayer.playlist.medias - .elementAtOrNull(_mkPlayer.playlist.index - 1) + return _mkPlayer.state.playlist.medias + .elementAtOrNull(_mkPlayer.state.playlist.index - 1) ?.uri; // } else { // return (_justAudio?.sequenceState?.effectiveSequence @@ -223,20 +222,18 @@ class SpotubeAudioPlayer extends AudioPlayerInterface // } } - Future addTrack(String url) async { - final urlType = _resolveUrlType(url); + Future addTrack(mk.Media media) async { // if (mkSupportedPlatform && urlType is mk.Media) { - await _mkPlayer.add(urlType as mk.Media); + await _mkPlayer.add(media); // } else { // await (_justAudio!.audioSource as ja.ConcatenatingAudioSource) // .add(urlType as ja.AudioSource); // } } - Future addTrackAt(String url, int index) async { - final urlType = _resolveUrlType(url); + Future addTrackAt(mk.Media media, int index) async { // if (mkSupportedPlatform && urlType is mk.Media) { - await _mkPlayer.insert(index, urlType as mk.Media); + await _mkPlayer.insert(index, media); // } else { // await (_justAudio!.audioSource as ja.ConcatenatingAudioSource) // .insert(index, urlType as ja.AudioSource); @@ -270,7 +267,7 @@ class SpotubeAudioPlayer extends AudioPlayerInterface if (oldSourceIndex == -1) return; // if (mkSupportedPlatform) { - _mkPlayer.replace(oldSource, newSource); + // _mkPlayer.replace(oldSource, newSource); // } else { // final playlist = _justAudio!.audioSource as ja.ConcatenatingAudioSource; diff --git a/lib/services/audio_player/audio_players_streams_mixin.dart b/lib/services/audio_player/audio_players_streams_mixin.dart index 54e36c6b..f6fe0630 100644 --- a/lib/services/audio_player/audio_players_streams_mixin.dart +++ b/lib/services/audio_player/audio_players_streams_mixin.dart @@ -73,7 +73,7 @@ mixin SpotubeAudioPlayersStreams on AudioPlayerInterface { Stream get loopModeStream { // if (mkSupportedPlatform) { - return _mkPlayer.loopModeStream.map(PlaybackLoopMode.fromPlaylistMode); + return _mkPlayer.stream.playlistMode.map(PlaybackLoopMode.fromPlaylistMode); // } else { // return _justAudio!.loopModeStream // .map(PlaybackLoopMode.fromLoopMode) @@ -127,7 +127,7 @@ mixin SpotubeAudioPlayersStreams on AudioPlayerInterface { // if (mkSupportedPlatform) { return _mkPlayer.indexChangeStream .map((event) { - return _mkPlayer.playlist.medias.elementAtOrNull(event)?.uri; + return _mkPlayer.state.playlist.medias.elementAtOrNull(event)?.uri; }) .where((event) => event != null) .cast(); @@ -141,11 +141,13 @@ mixin SpotubeAudioPlayersStreams on AudioPlayerInterface { // } } - Stream> get devicesStream => + Stream> get devicesStream => _mkPlayer.stream.audioDevices.asBroadcastStream(); - Stream get selectedDeviceStream => + Stream get selectedDeviceStream => _mkPlayer.stream.audioDevice.asBroadcastStream(); Stream get errorStream => _mkPlayer.stream.error; + + Stream get playlistStream => _mkPlayer.stream.playlist; } diff --git a/lib/services/audio_player/custom_player.dart b/lib/services/audio_player/custom_player.dart new file mode 100644 index 00000000..ec47bbb7 --- /dev/null +++ b/lib/services/audio_player/custom_player.dart @@ -0,0 +1,141 @@ +import 'dart:async'; +import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; +import 'package:catcher_2/catcher_2.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:flutter_broadcasts/flutter_broadcasts.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:audio_session/audio_session.dart'; +// ignore: implementation_imports +import 'package:spotube/services/audio_player/playback_state.dart'; + +/// MediaKit [Player] by default doesn't have a state stream. +/// This class adds a state stream to the [Player] class. +class CustomPlayer extends Player { + final StreamController _playerStateStream; + final StreamController _shuffleStream; + + late final List _subscriptions; + + bool _shuffled; + int _androidAudioSessionId = 0; + String _packageName = ""; + AndroidAudioManager? _androidAudioManager; + + CustomPlayer({super.configuration}) + : _playerStateStream = StreamController.broadcast(), + _shuffleStream = StreamController.broadcast(), + _shuffled = false { + _subscriptions = [ + stream.buffering.listen((event) { + _playerStateStream.add(AudioPlaybackState.buffering); + }), + stream.playing.listen((playing) { + if (playing) { + _playerStateStream.add(AudioPlaybackState.playing); + } else { + _playerStateStream.add(AudioPlaybackState.paused); + } + }), + stream.completed.listen((isCompleted) async { + if (!isCompleted) return; + _playerStateStream.add(AudioPlaybackState.completed); + }), + stream.playlist.listen((event) { + if (event.medias.isEmpty) { + _playerStateStream.add(AudioPlaybackState.stopped); + } + }), + stream.error.listen((event) { + Catcher2.reportCheckedError('[MediaKitError] \n$event', null); + }), + ]; + PackageInfo.fromPlatform().then((packageInfo) { + _packageName = packageInfo.packageName; + }); + if (DesktopTools.platform.isAndroid) { + _androidAudioManager = AndroidAudioManager(); + AudioSession.instance.then((s) async { + _androidAudioSessionId = + await _androidAudioManager!.generateAudioSessionId(); + notifyAudioSessionUpdate(true); + + await nativePlayer.setProperty( + "audiotrack-session-id", + _androidAudioSessionId.toString(), + ); + await nativePlayer.setProperty("ao", "audiotrack,opensles,"); + }); + } + } + + Future notifyAudioSessionUpdate(bool active) async { + if (DesktopTools.platform.isAndroid) { + sendBroadcast( + BroadcastMessage( + name: active + ? "android.media.action.OPEN_AUDIO_EFFECT_CONTROL_SESSION" + : "android.media.action.CLOSE_AUDIO_EFFECT_CONTROL_SESSION", + data: { + "android.media.extra.AUDIO_SESSION": _androidAudioSessionId, + "android.media.extra.PACKAGE_NAME": _packageName + }, + ), + ); + } + } + + bool get shuffled => _shuffled; + + Stream get playerStateStream => _playerStateStream.stream; + Stream get shuffleStream => _shuffleStream.stream; + Stream get indexChangeStream { + int oldIndex = state.playlist.index; + return stream.playlist.map((event) => event.index).where((newIndex) { + if (newIndex != oldIndex) { + oldIndex = newIndex; + return true; + } + return false; + }); + } + + @override + Future setShuffle(bool shuffle) async { + _shuffled = shuffle; + await super.setShuffle(shuffle); + _shuffleStream.add(shuffle); + } + + @override + Future stop() async { + await super.stop(); + + _shuffled = false; + _playerStateStream.add(AudioPlaybackState.stopped); + _shuffleStream.add(false); + } + + @override + Future dispose() async { + for (var element in _subscriptions) { + element.cancel(); + } + await notifyAudioSessionUpdate(false); + return super.dispose(); + } + + NativePlayer get nativePlayer => platform as NativePlayer; + + Future insert(int index, Media media) async { + await add(media); + await move(state.playlist.medias.length, index); + } + + Future setAudioNormalization(bool normalize) async { + if (normalize) { + await nativePlayer.setProperty('af', 'dynaudnorm=g=5:f=250:r=0.9:p=0.5'); + } else { + await nativePlayer.setProperty('af', ''); + } + } +} diff --git a/lib/services/audio_player/mk_state_player.dart b/lib/services/audio_player/mk_state_player.dart deleted file mode 100644 index 8b796d66..00000000 --- a/lib/services/audio_player/mk_state_player.dart +++ /dev/null @@ -1,382 +0,0 @@ -import 'dart:async'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; -import 'package:catcher_2/catcher_2.dart'; -import 'package:collection/collection.dart'; -import 'package:media_kit/media_kit.dart'; -import 'package:flutter_broadcasts/flutter_broadcasts.dart'; -import 'package:package_info_plus/package_info_plus.dart'; -import 'package:audio_session/audio_session.dart'; -// ignore: implementation_imports -import 'package:spotube/services/audio_player/playback_state.dart'; - -/// MediaKit [Player] by default doesn't have a state stream. -/// This class adds a state stream to the [Player] class. -class MkPlayerWithState extends Player { - final StreamController _playerStateStream; - final StreamController _playlistStream; - final StreamController _shuffleStream; - final StreamController _loopModeStream; - - late final List _subscriptions; - - bool _shuffled; - PlaylistMode _loopMode; - - Playlist? _playlist; - List? _tempMedias; - int _androidAudioSessionId = 0; - String _packageName = ""; - AndroidAudioManager? _androidAudioManager; - - MkPlayerWithState({super.configuration}) - : _playerStateStream = StreamController.broadcast(), - _shuffleStream = StreamController.broadcast(), - _loopModeStream = StreamController.broadcast(), - _playlistStream = StreamController.broadcast(), - _shuffled = false, - _loopMode = PlaylistMode.none { - _subscriptions = [ - stream.buffering.listen((event) { - _playerStateStream.add(AudioPlaybackState.buffering); - }), - stream.playing.listen((playing) { - if (playing) { - _playerStateStream.add(AudioPlaybackState.playing); - } else { - _playerStateStream.add(AudioPlaybackState.paused); - } - }), - stream.completed.listen((isCompleted) async { - try { - if (!isCompleted) return; - - _playerStateStream.add(AudioPlaybackState.completed); - if (loopMode == PlaylistMode.single) { - await super.open(_playlist!.medias[_playlist!.index], play: true); - } else { - await next(); - await Future.delayed(const Duration(milliseconds: 250), play); - } - } catch (e, stackTrace) { - Catcher2.reportCheckedError(e, stackTrace); - } - }), - stream.playlist.listen((event) { - if (event.medias.isEmpty) { - _playerStateStream.add(AudioPlaybackState.stopped); - } - }), - stream.error.listen((event) { - Catcher2.reportCheckedError('[MediaKitError] \n$event', null); - }), - ]; - PackageInfo.fromPlatform().then((packageInfo) { - _packageName = packageInfo.packageName; - }); - if (DesktopTools.platform.isAndroid) { - _androidAudioManager = AndroidAudioManager(); - AudioSession.instance.then((s) async { - _androidAudioSessionId = - await _androidAudioManager!.generateAudioSessionId(); - notifyAudioSessionUpdate(true); - - await nativePlayer.setProperty( - "audiotrack-session-id", - _androidAudioSessionId.toString(), - ); - await nativePlayer.setProperty("ao", "audiotrack,opensles,"); - }); - } - } - - Future notifyAudioSessionUpdate(bool active) async { - if (DesktopTools.platform.isAndroid) { - sendBroadcast( - BroadcastMessage( - name: active - ? "android.media.action.OPEN_AUDIO_EFFECT_CONTROL_SESSION" - : "android.media.action.CLOSE_AUDIO_EFFECT_CONTROL_SESSION", - data: { - "android.media.extra.AUDIO_SESSION": _androidAudioSessionId, - "android.media.extra.PACKAGE_NAME": _packageName - }, - ), - ); - } - } - - bool get shuffled => _shuffled; - PlaylistMode get loopMode => _loopMode; - Playlist get playlist => _playlist ?? const Playlist([], index: -1); - - Stream get playerStateStream => _playerStateStream.stream; - Stream get shuffleStream => _shuffleStream.stream; - Stream get loopModeStream => _loopModeStream.stream; - Stream get playlistStream => _playlistStream.stream; - Stream get indexChangeStream { - int oldIndex = playlist.index; - return playlistStream.map((event) => event.index).where((newIndex) { - if (newIndex != oldIndex) { - oldIndex = newIndex; - return true; - } - return false; - }); - } - - set playlist(Playlist playlist) { - _playlist = playlist; - _playlistStream.add(playlist); - } - - @override - Future setShuffle(bool shuffle) async { - _shuffled = shuffle; - if (shuffle) { - _tempMedias = _playlist!.medias; - final active = _playlist!.medias[_playlist!.index]; - final newMedias = _playlist!.medias.toList() - ..shuffle() - ..remove(active) - ..insert(0, active); - playlist = _playlist!.copyWith( - medias: newMedias, - index: newMedias.indexOf(active), - ); - } else { - if (_tempMedias == null) return; - playlist = _playlist!.copyWith( - medias: _tempMedias!, - index: _tempMedias?.indexOf( - _playlist!.medias[_playlist!.index], - ), - ); - _tempMedias = null; - } - await super.setShuffle(shuffle); - _shuffleStream.add(shuffle); - } - - @override - Future setPlaylistMode(PlaylistMode playlistMode) async { - _loopMode = playlistMode; - await super.setPlaylistMode(playlistMode); - _loopModeStream.add(playlistMode); - } - - @override - Future stop() async { - await super.stop(); - await pause(); - await seek(Duration.zero); - - _loopMode = PlaylistMode.none; - _shuffled = false; - _playlist = null; - _tempMedias = null; - _playerStateStream.add(AudioPlaybackState.stopped); - _shuffleStream.add(false); - } - - @override - Future dispose() async { - for (var element in _subscriptions) { - element.cancel(); - } - await notifyAudioSessionUpdate(false); - return super.dispose(); - } - - @override - Future open( - Playable playable, { - bool play = true, - }) async { - await stop(); - if (playable is Playlist) { - playlist = playable; - super.open(playable.medias[playable.index], play: play); - } - await super.open(playable, play: play); - } - - @override - Future next() async { - if (_playlist == null) { - return; - } - - final isLast = _playlist!.index == _playlist!.medias.length - 1; - - if (isLast) { - switch (loopMode) { - case PlaylistMode.loop: - playlist = _playlist!.copyWith(index: 0); - super.open(_playlist!.medias[_playlist!.index], play: true); - break; - case PlaylistMode.none: - // Fixes auto-repeating the last track - await super.stop(); - break; - default: - } - } else { - playlist = _playlist!.copyWith(index: _playlist!.index + 1); - - return super.open(_playlist!.medias[_playlist!.index], play: true); - } - } - - @override - Future previous() async { - if (_playlist == null || _playlist!.index - 1 < 0) return; - - if (loopMode == PlaylistMode.loop && _playlist!.index == 0) { - playlist = _playlist!.copyWith(index: _playlist!.medias.length - 1); - return super.open(_playlist!.medias[_playlist!.index], play: true); - } else if (_playlist!.index != 0) { - playlist = _playlist!.copyWith(index: _playlist!.index - 1); - return super.open(_playlist!.medias[_playlist!.index], play: true); - } - } - - @override - Future jump(int index) async { - if (_playlist == null || index < 0 || index >= _playlist!.medias.length) { - return; - } - - playlist = _playlist!.copyWith(index: index); - return super.open(_playlist!.medias[index], play: true); - } - - @override - Future move(int from, int to) async { - if (_playlist == null || - from >= _playlist!.medias.length || - to >= _playlist!.medias.length) return; - - final active = _playlist!.medias[_playlist!.index]; - final newPlaylist = _playlist!.copyWith( - medias: _playlist!.medias.mapIndexed((index, element) { - if (index == from) { - return _playlist!.medias[to]; - } else if (index == to) { - return _playlist!.medias[from]; - } - return element; - }).toList(), - ); - playlist = _playlist!.copyWith( - index: newPlaylist.medias.indexOf(active), - medias: newPlaylist.medias, - ); - } - - /// This replaces the old source with a new one - /// - /// If the old source is playing, the new one will play - /// from the beginning - /// - /// This doesn't work when [playlist] is null - void replace(String oldUrl, String newUrl) { - if (_playlist == null) { - return; - } - - final isOldUrlPlaying = _playlist!.medias[_playlist!.index].uri == oldUrl; - - // ends the loop where match is found - // tends to be a bit more efficient than forEach - _playlist!.medias.firstWhereIndexedOrNull((i, media) { - if (media.uri != oldUrl) return false; - if (isOldUrlPlaying) { - pause(); - } - final copyMedias = [..._playlist!.medias]; - copyMedias[i] = Media(newUrl, extras: media.extras); - playlist = _playlist!.copyWith(medias: copyMedias); - if (isOldUrlPlaying) { - super.open( - copyMedias[i], - play: true, - ); - } - - // replace in the _tempMedias if it's not null - if (shuffled && _tempMedias != null) { - final tempIndex = _tempMedias!.indexOf(media); - _tempMedias![tempIndex] = Media(newUrl, extras: media.extras); - } - return true; - }); - } - - @override - Future add(Media media) async { - if (_playlist == null) return; - - playlist = _playlist!.copyWith( - medias: [..._playlist!.medias, media], - ); - - if (shuffled && _tempMedias != null) { - _tempMedias!.add(media); - } - } - - FutureOr insert(int index, Media media) { - if (_playlist == null || - index < 0 || - (_playlist!.medias.length > 1 && - index > _playlist!.medias.length - 1)) { - return null; - } - - final newMedias = _playlist!.medias.toList()..insert(index, media); - - playlist = _playlist!.copyWith( - medias: newMedias, - index: newMedias.indexOf(_playlist!.medias[_playlist!.index]), - ); - - if (shuffled && _tempMedias != null) { - _tempMedias!.insert(index, media); - } - } - - /// Doesn't work when active media is the one to be removed - @override - Future remove(int index) async { - if (_playlist == null || - index < 0 || - index > _playlist!.medias.length - 1 || - _playlist!.index == index) { - return; - } - - final targetItem = _playlist!.medias.elementAtOrNull(index); - if (targetItem == null) return; - - if (shuffled && _tempMedias != null) { - _tempMedias!.remove(targetItem); - } - - final newMedias = _playlist!.medias.toList()..removeAt(index); - - playlist = _playlist!.copyWith( - medias: newMedias, - index: newMedias.indexOf(_playlist!.medias[_playlist!.index]), - ); - } - - NativePlayer get nativePlayer => platform as NativePlayer; - - Future setAudioNormalization(bool normalize) async { - if (normalize) { - await nativePlayer.setProperty('af', 'dynaudnorm=g=5:f=250:r=0.9:p=0.5'); - } else { - await nativePlayer.setProperty('af', ''); - } - } -}