mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-13 16:05:18 +00:00
refactor(playback): migration to ProxyPlaylist based playback
This commit is contained in:
parent
3ba3df7265
commit
5f70207076
@ -5,7 +5,8 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:spotube/components/player/player_controls.dart';
|
||||
import 'package:spotube/collections/routes.dart';
|
||||
import 'package:spotube/models/logger.dart';
|
||||
import 'package:spotube/provider/playlist_queue_provider.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/services/audio_player/audio_player.dart';
|
||||
import 'package:spotube/utils/platform.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
@ -23,19 +24,11 @@ class PlayPauseAction extends Action<PlayPauseIntent> {
|
||||
if (PlayerControls.focusNode.canRequestFocus) {
|
||||
PlayerControls.focusNode.requestFocus();
|
||||
}
|
||||
final playlist = intent.ref.read(PlaylistQueueNotifier.provider);
|
||||
final playlistNotifier = intent.ref.read(PlaylistQueueNotifier.notifier);
|
||||
if (playlist == null) {
|
||||
return null;
|
||||
} else if (!audioPlayer.isPlaying) {
|
||||
if (audioPlayer.hasSource && !await audioPlayer.isCompleted) {
|
||||
await playlistNotifier.resume();
|
||||
|
||||
if (!audioPlayer.isPlaying) {
|
||||
await audioPlayer.resume();
|
||||
} else {
|
||||
// TODO: Implement play on start
|
||||
// await playlistNotifier.play();
|
||||
}
|
||||
} else {
|
||||
await playlistNotifier.pause();
|
||||
await audioPlayer.pause();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@ -98,9 +91,8 @@ class SeekIntent extends Intent {
|
||||
class SeekAction extends Action<SeekIntent> {
|
||||
@override
|
||||
invoke(intent) async {
|
||||
final playlist = intent.ref.read(PlaylistQueueNotifier.provider);
|
||||
final playlistNotifier = intent.ref.read(PlaylistQueueNotifier.notifier);
|
||||
if (playlist == null || playlist.isLoading) {
|
||||
final playlist = intent.ref.read(ProxyPlaylistNotifier.provider);
|
||||
if (playlist.isFetching) {
|
||||
DirectionalFocusAction().invoke(
|
||||
DirectionalFocusIntent(
|
||||
intent.forward ? TraversalDirection.right : TraversalDirection.left,
|
||||
@ -109,7 +101,7 @@ class SeekAction extends Action<SeekIntent> {
|
||||
return null;
|
||||
}
|
||||
final position = (await audioPlayer.position ?? Duration.zero).inSeconds;
|
||||
await playlistNotifier.seek(
|
||||
await audioPlayer.seek(
|
||||
Duration(
|
||||
seconds: intent.forward ? position + 5 : position - 5,
|
||||
),
|
||||
|
@ -5,7 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/components/shared/playbutton_card.dart';
|
||||
import 'package:spotube/hooks/use_breakpoint_value.dart';
|
||||
import 'package:spotube/provider/playlist_queue_provider.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/provider/spotify_provider.dart';
|
||||
import 'package:spotube/services/audio_player/audio_player.dart';
|
||||
import 'package:spotube/utils/service_utils.dart';
|
||||
@ -41,16 +41,15 @@ class AlbumCard extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final playlist = ref.watch(PlaylistQueueNotifier.provider);
|
||||
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
|
||||
final playing =
|
||||
useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying;
|
||||
final playlistNotifier = ref.watch(PlaylistQueueNotifier.notifier);
|
||||
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
|
||||
final queryClient = useQueryClient();
|
||||
final query = queryClient
|
||||
.getQuery<List<TrackSimple>, dynamic>("album-tracks/${album.id}");
|
||||
bool isPlaylistPlaying = useMemoized(
|
||||
() =>
|
||||
playlistNotifier.isPlayingPlaylist(query?.data ?? album.tracks ?? []),
|
||||
() => playlist.containsTracks(query?.data ?? album.tracks ?? []),
|
||||
[playlistNotifier, query?.data, album.tracks],
|
||||
);
|
||||
final int marginH =
|
||||
@ -66,7 +65,7 @@ class AlbumCard extends HookConsumerWidget {
|
||||
),
|
||||
margin: EdgeInsets.symmetric(horizontal: marginH.toDouble()),
|
||||
isPlaying: isPlaylistPlaying,
|
||||
isLoading: isPlaylistPlaying && playlist?.isLoading == true,
|
||||
isLoading: isPlaylistPlaying && playlist.isFetching == true,
|
||||
title: album.name!,
|
||||
description:
|
||||
"${AlbumType.from(album.albumType!).formatted} • ${TypeConversionUtils.artists_X_String<ArtistSimple>(album.artists ?? [])}",
|
||||
@ -77,16 +76,19 @@ class AlbumCard extends HookConsumerWidget {
|
||||
updating.value = true;
|
||||
try {
|
||||
if (isPlaylistPlaying && playing) {
|
||||
return playlistNotifier.pause();
|
||||
return audioPlayer.pause();
|
||||
} else if (isPlaylistPlaying && !playing) {
|
||||
return playlistNotifier.resume();
|
||||
return audioPlayer.resume();
|
||||
}
|
||||
|
||||
await playlistNotifier.loadAndPlay(album.tracks
|
||||
await playlistNotifier.load(
|
||||
album.tracks
|
||||
?.map((e) =>
|
||||
TypeConversionUtils.simpleTrack_X_Track(e, album))
|
||||
.toList() ??
|
||||
[]);
|
||||
[],
|
||||
autoPlay: true,
|
||||
);
|
||||
} finally {
|
||||
updating.value = false;
|
||||
}
|
||||
@ -115,16 +117,15 @@ class AlbumCard extends HookConsumerWidget {
|
||||
);
|
||||
|
||||
if (fetchedTracks == null || fetchedTracks.isEmpty) return;
|
||||
playlistNotifier.add(
|
||||
fetchedTracks,
|
||||
);
|
||||
playlistNotifier.addTracks(fetchedTracks);
|
||||
if (context.mounted) {
|
||||
final snackbar = SnackBar(
|
||||
content: Text("Added ${album.tracks?.length} tracks to queue"),
|
||||
action: SnackBarAction(
|
||||
label: "Undo",
|
||||
onPressed: () {
|
||||
playlistNotifier.remove(fetchedTracks);
|
||||
playlistNotifier
|
||||
.removeTracks(fetchedTracks.map((e) => e.id!));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
@ -22,7 +22,7 @@ import 'package:spotube/components/shared/track_table/track_tile.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/hooks/use_async_effect.dart';
|
||||
import 'package:spotube/models/local_track.dart';
|
||||
import 'package:spotube/provider/playlist_queue_provider.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/provider/user_preferences_provider.dart';
|
||||
import 'package:spotube/utils/platform.dart';
|
||||
import 'package:spotube/utils/primitive_utils.dart';
|
||||
@ -132,33 +132,35 @@ class UserLocalTracks extends HookConsumerWidget {
|
||||
const UserLocalTracks({Key? key}) : super(key: key);
|
||||
|
||||
void playLocalTracks(
|
||||
PlaylistQueueNotifier playback,
|
||||
WidgetRef ref,
|
||||
List<LocalTrack> tracks, {
|
||||
LocalTrack? currentTrack,
|
||||
}) async {
|
||||
final playlist = ref.read(ProxyPlaylistNotifier.provider);
|
||||
final playback = ref.read(ProxyPlaylistNotifier.notifier);
|
||||
currentTrack ??= tracks.first;
|
||||
final isPlaylistPlaying = playback.isPlayingPlaylist(tracks);
|
||||
final isPlaylistPlaying = playlist.containsTracks(tracks);
|
||||
if (!isPlaylistPlaying) {
|
||||
await playback.loadAndPlay(
|
||||
await playback.load(
|
||||
tracks,
|
||||
active: tracks.indexWhere((s) => s.id == currentTrack?.id),
|
||||
initialIndex: tracks.indexWhere((s) => s.id == currentTrack?.id),
|
||||
autoPlay: true,
|
||||
);
|
||||
} else if (isPlaylistPlaying &&
|
||||
currentTrack.id != null &&
|
||||
currentTrack.id != playback.state?.activeTrack.id) {
|
||||
await playback.playTrack(currentTrack);
|
||||
currentTrack.id != playlist.activeTrack?.id) {
|
||||
await playback.jumpToTrack(currentTrack);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final sortBy = useState<SortBy>(SortBy.none);
|
||||
final playlist = ref.watch(PlaylistQueueNotifier.provider);
|
||||
final playlistNotifier = ref.watch(PlaylistQueueNotifier.notifier);
|
||||
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
|
||||
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
|
||||
final trackSnapshot = ref.watch(localTracksProvider);
|
||||
final isPlaylistPlaying = playlistNotifier.isPlayingPlaylist(
|
||||
trackSnapshot.value ?? [],
|
||||
);
|
||||
final isPlaylistPlaying =
|
||||
playlist.containsTracks(trackSnapshot.value ?? []);
|
||||
final isMounted = useIsMounted();
|
||||
|
||||
final searchText = useState<String>("");
|
||||
@ -194,9 +196,12 @@ class UserLocalTracks extends HookConsumerWidget {
|
||||
if (trackSnapshot.value?.isNotEmpty == true) {
|
||||
if (!isPlaylistPlaying) {
|
||||
playLocalTracks(
|
||||
playlistNotifier, trackSnapshot.value!);
|
||||
ref,
|
||||
trackSnapshot.value!,
|
||||
);
|
||||
} else {
|
||||
playlistNotifier.stop();
|
||||
// TODO: Remove stop capability
|
||||
// playlistNotifier.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -272,13 +277,13 @@ class UserLocalTracks extends HookConsumerWidget {
|
||||
duration:
|
||||
"${track.duration?.inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(track.duration?.inSeconds.remainder(60) ?? 0)}",
|
||||
track: MapEntry(index, track),
|
||||
isActive: playlist?.activeTrack.id == track.id,
|
||||
isActive: playlist.activeTrack?.id == track.id,
|
||||
isChecked: false,
|
||||
showCheck: false,
|
||||
isLocal: true,
|
||||
onTrackPlayButtonPressed: (currentTrack) {
|
||||
return playLocalTracks(
|
||||
playlistNotifier,
|
||||
ref,
|
||||
sortedTracks,
|
||||
currentTrack: track,
|
||||
);
|
||||
|
@ -13,7 +13,7 @@ import 'package:spotube/models/local_track.dart';
|
||||
import 'package:spotube/models/logger.dart';
|
||||
import 'package:spotube/provider/authentication_provider.dart';
|
||||
import 'package:spotube/provider/downloader_provider.dart';
|
||||
import 'package:spotube/provider/playlist_queue_provider.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
|
||||
class PlayerActions extends HookConsumerWidget {
|
||||
@ -30,26 +30,26 @@ class PlayerActions extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final playlist = ref.watch(PlaylistQueueNotifier.provider);
|
||||
final isLocalTrack = playlist?.activeTrack is LocalTrack;
|
||||
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
|
||||
final isLocalTrack = playlist.activeTrack is LocalTrack;
|
||||
final downloader = ref.watch(downloaderProvider);
|
||||
final isInQueue = downloader.inQueue
|
||||
.any((element) => element.id == playlist?.activeTrack.id);
|
||||
.any((element) => element.id == playlist.activeTrack?.id);
|
||||
final localTracks = [] /* ref.watch(localTracksProvider).value */;
|
||||
final auth = ref.watch(AuthenticationNotifier.provider);
|
||||
|
||||
final isDownloaded = useMemoized(() {
|
||||
return localTracks.any(
|
||||
(element) =>
|
||||
element.name == playlist?.activeTrack.name &&
|
||||
element.album?.name == playlist?.activeTrack.album?.name &&
|
||||
element.name == playlist.activeTrack?.name &&
|
||||
element.album?.name == playlist.activeTrack?.album?.name &&
|
||||
TypeConversionUtils.artists_X_String<Artist>(
|
||||
element.artists ?? []) ==
|
||||
TypeConversionUtils.artists_X_String<Artist>(
|
||||
playlist?.activeTrack.artists ?? []),
|
||||
playlist.activeTrack?.artists ?? []),
|
||||
) ==
|
||||
true;
|
||||
}, [localTracks, playlist?.activeTrack]);
|
||||
}, [localTracks, playlist.activeTrack]);
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: mainAxisAlignment,
|
||||
@ -57,7 +57,7 @@ class PlayerActions extends HookConsumerWidget {
|
||||
IconButton(
|
||||
icon: const Icon(SpotubeIcons.queue),
|
||||
tooltip: context.l10n.queue,
|
||||
onPressed: playlist != null
|
||||
onPressed: playlist.activeTrack != null
|
||||
? () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
@ -83,7 +83,7 @@ class PlayerActions extends HookConsumerWidget {
|
||||
IconButton(
|
||||
icon: const Icon(SpotubeIcons.alternativeRoute),
|
||||
tooltip: context.l10n.alternative_track_sources,
|
||||
onPressed: playlist?.activeTrack != null
|
||||
onPressed: playlist.activeTrack != null
|
||||
? () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
@ -120,12 +120,12 @@ class PlayerActions extends HookConsumerWidget {
|
||||
icon: Icon(
|
||||
isDownloaded ? SpotubeIcons.done : SpotubeIcons.download,
|
||||
),
|
||||
onPressed: playlist?.activeTrack != null
|
||||
? () => downloader.addToQueue(playlist!.activeTrack)
|
||||
onPressed: playlist.activeTrack != null
|
||||
? () => downloader.addToQueue(playlist.activeTrack!)
|
||||
: null,
|
||||
),
|
||||
if (playlist?.activeTrack != null && !isLocalTrack && auth != null)
|
||||
TrackHeartButton(track: playlist!.activeTrack),
|
||||
if (playlist.activeTrack != null && !isLocalTrack && auth != null)
|
||||
TrackHeartButton(track: playlist.activeTrack!),
|
||||
...(extraActions ?? [])
|
||||
],
|
||||
);
|
||||
|
@ -9,7 +9,7 @@ import 'package:spotube/collections/intents.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/hooks/use_progress.dart';
|
||||
import 'package:spotube/models/logger.dart';
|
||||
import 'package:spotube/provider/playlist_queue_provider.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/services/audio_player/audio_player.dart';
|
||||
import 'package:spotube/services/audio_player/loop_mode.dart';
|
||||
import 'package:spotube/utils/primitive_utils.dart';
|
||||
@ -43,8 +43,8 @@ class PlayerControls extends HookConsumerWidget {
|
||||
SeekIntent: SeekAction(),
|
||||
},
|
||||
[]);
|
||||
final playlist = ref.watch(PlaylistQueueNotifier.provider);
|
||||
final playlistNotifier = ref.watch(PlaylistQueueNotifier.notifier);
|
||||
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
|
||||
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
|
||||
|
||||
final playing =
|
||||
useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying;
|
||||
@ -145,13 +145,13 @@ class PlayerControls extends HookConsumerWidget {
|
||||
// than total duration. Keeping it resolved
|
||||
value: progress.value.toDouble(),
|
||||
secondaryTrackValue: progressObj.item4,
|
||||
onChanged: playlist?.isLoading == true || buffering
|
||||
onChanged: playlist.isFetching == true || buffering
|
||||
? null
|
||||
: (v) {
|
||||
progress.value = v;
|
||||
},
|
||||
onChangeEnd: (value) async {
|
||||
await playlistNotifier.seek(
|
||||
await audioPlayer.seek(
|
||||
Duration(
|
||||
seconds: (value * duration.inSeconds).toInt(),
|
||||
),
|
||||
@ -186,24 +186,27 @@ class PlayerControls extends HookConsumerWidget {
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: playlist?.shuffled == true
|
||||
StreamBuilder<bool>(
|
||||
stream: audioPlayer.shuffledStream,
|
||||
builder: (context, snapshot) {
|
||||
final shuffled = snapshot.data ?? false;
|
||||
return IconButton(
|
||||
tooltip: shuffled
|
||||
? context.l10n.unshuffle_playlist
|
||||
: context.l10n.shuffle_playlist,
|
||||
icon: const Icon(SpotubeIcons.shuffle),
|
||||
style: playlist?.shuffled == true
|
||||
? activeButtonStyle
|
||||
: buttonStyle,
|
||||
onPressed: playlist == null || playlist.isLoading
|
||||
style: shuffled ? activeButtonStyle : buttonStyle,
|
||||
onPressed: playlist.isFetching
|
||||
? null
|
||||
: () {
|
||||
if (playlist.shuffled == true) {
|
||||
playlistNotifier.setShuffle(false);
|
||||
if (shuffled) {
|
||||
audioPlayer.setShuffle(false);
|
||||
} else {
|
||||
playlistNotifier.setShuffle(true);
|
||||
audioPlayer.setShuffle(true);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
IconButton(
|
||||
tooltip: context.l10n.previous_track,
|
||||
icon: const Icon(SpotubeIcons.skipBack),
|
||||
@ -214,7 +217,7 @@ class PlayerControls extends HookConsumerWidget {
|
||||
tooltip: playing
|
||||
? context.l10n.pause_playback
|
||||
: context.l10n.resume_playback,
|
||||
icon: playlist?.isLoading == true
|
||||
icon: playlist.isFetching == true
|
||||
? SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
@ -227,7 +230,7 @@ class PlayerControls extends HookConsumerWidget {
|
||||
playing ? SpotubeIcons.pause : SpotubeIcons.play,
|
||||
),
|
||||
style: resumePauseStyle,
|
||||
onPressed: playlist?.isLoading == true
|
||||
onPressed: playlist.isFetching == true
|
||||
? null
|
||||
: Actions.handler<PlayPauseIntent>(
|
||||
context,
|
||||
@ -240,30 +243,35 @@ class PlayerControls extends HookConsumerWidget {
|
||||
style: buttonStyle,
|
||||
onPressed: playlistNotifier.next,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: playlist?.loopMode == PlaybackLoopMode.one
|
||||
StreamBuilder<PlaybackLoopMode>(
|
||||
stream: audioPlayer.loopModeStream,
|
||||
builder: (context, snapshot) {
|
||||
final loopMode = snapshot.data ?? PlaybackLoopMode.none;
|
||||
return IconButton(
|
||||
tooltip: loopMode == PlaybackLoopMode.one
|
||||
? context.l10n.loop_track
|
||||
: context.l10n.repeat_playlist,
|
||||
icon: Icon(
|
||||
playlist?.loopMode == PlaybackLoopMode.one
|
||||
loopMode == PlaybackLoopMode.one
|
||||
? SpotubeIcons.repeatOne
|
||||
: SpotubeIcons.repeat,
|
||||
),
|
||||
style: playlist?.loopMode == PlaybackLoopMode.one
|
||||
style: loopMode == PlaybackLoopMode.one
|
||||
? activeButtonStyle
|
||||
: buttonStyle,
|
||||
onPressed: playlist == null || playlist.isLoading
|
||||
onPressed: playlist.isFetching
|
||||
? null
|
||||
: () {
|
||||
if (playlist.loopMode == PlaybackLoopMode.one) {
|
||||
playlistNotifier
|
||||
if (loopMode == PlaybackLoopMode.one) {
|
||||
audioPlayer
|
||||
.setLoopMode(PlaybackLoopMode.all);
|
||||
} else {
|
||||
playlistNotifier
|
||||
audioPlayer
|
||||
.setLoopMode(PlaybackLoopMode.one);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 5)
|
||||
|
@ -9,7 +9,7 @@ import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/components/player/player_track_details.dart';
|
||||
import 'package:spotube/collections/intents.dart';
|
||||
import 'package:spotube/hooks/use_progress.dart';
|
||||
import 'package:spotube/provider/playlist_queue_provider.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/services/audio_player/audio_player.dart';
|
||||
import 'package:spotube/utils/service_utils.dart';
|
||||
|
||||
@ -24,10 +24,10 @@ class PlayerOverlay extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final canShow = ref.watch(
|
||||
PlaylistQueueNotifier.provider.select((s) => s != null),
|
||||
ProxyPlaylistNotifier.provider.select((s) => s != null),
|
||||
);
|
||||
final playlistNotifier = ref.watch(PlaylistQueueNotifier.notifier);
|
||||
final playlist = ref.watch(PlaylistQueueNotifier.provider);
|
||||
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
|
||||
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
|
||||
final playing =
|
||||
useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying;
|
||||
|
||||
@ -116,7 +116,7 @@ class PlayerOverlay extends HookConsumerWidget {
|
||||
Consumer(
|
||||
builder: (context, ref, _) {
|
||||
return IconButton(
|
||||
icon: playlist?.isLoading == true
|
||||
icon: playlist.isFetching
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
|
@ -10,7 +10,7 @@ import 'package:spotube/components/shared/fallbacks/not_found.dart';
|
||||
import 'package:spotube/components/shared/track_table/track_tile.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/hooks/use_auto_scroll_controller.dart';
|
||||
import 'package:spotube/provider/playlist_queue_provider.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/utils/primitive_utils.dart';
|
||||
|
||||
class PlayerQueue extends HookConsumerWidget {
|
||||
@ -22,10 +22,10 @@ class PlayerQueue extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final playlist = ref.watch(PlaylistQueueNotifier.provider);
|
||||
final playlistNotifier = ref.watch(PlaylistQueueNotifier.notifier);
|
||||
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
|
||||
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
|
||||
final controller = useAutoScrollController();
|
||||
final tracks = playlist?.tracks ?? {};
|
||||
final tracks = playlist.tracks;
|
||||
|
||||
if (tracks.isEmpty) {
|
||||
return const NotFound(vertical: true);
|
||||
@ -41,11 +41,11 @@ class PlayerQueue extends HookConsumerWidget {
|
||||
final headlineColor = theme.textTheme.headlineSmall?.color;
|
||||
|
||||
useEffect(() {
|
||||
if (playlist == null) return null;
|
||||
final index = playlist.active;
|
||||
if (index < 0) return;
|
||||
if (playlist.active == null) return null;
|
||||
|
||||
if (playlist.active! < 0) return;
|
||||
controller.scrollToIndex(
|
||||
index,
|
||||
playlist.active!,
|
||||
preferPosition: AutoScrollPosition.middle,
|
||||
);
|
||||
return null;
|
||||
@ -113,7 +113,7 @@ class PlayerQueue extends HookConsumerWidget {
|
||||
Flexible(
|
||||
child: ReorderableListView.builder(
|
||||
onReorder: (oldIndex, newIndex) {
|
||||
playlistNotifier.reorder(oldIndex, newIndex);
|
||||
playlistNotifier.moveTrack(oldIndex, newIndex);
|
||||
},
|
||||
scrollController: controller,
|
||||
itemCount: tracks.length,
|
||||
@ -133,12 +133,12 @@ class PlayerQueue extends HookConsumerWidget {
|
||||
playlist,
|
||||
track: track,
|
||||
duration: duration,
|
||||
isActive: playlist?.activeTrack.id == track.value.id,
|
||||
isActive: playlist.activeTrack?.id == track.value.id,
|
||||
onTrackPlayButtonPressed: (currentTrack) async {
|
||||
if (playlist?.activeTrack.id == track.value.id) {
|
||||
if (playlist.activeTrack?.id == track.value.id) {
|
||||
return;
|
||||
}
|
||||
await playlistNotifier.playTrack(currentTrack);
|
||||
await playlistNotifier.jumpToTrack(currentTrack);
|
||||
},
|
||||
leadingActions: [
|
||||
ReorderableDragStartListener(
|
||||
|
@ -5,7 +5,7 @@ import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/collections/assets.gen.dart';
|
||||
import 'package:spotube/components/shared/image/universal_image.dart';
|
||||
import 'package:spotube/hooks/use_breakpoints.dart';
|
||||
import 'package:spotube/provider/playlist_queue_provider.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
|
||||
class PlayerTrackDetails extends HookConsumerWidget {
|
||||
@ -18,11 +18,11 @@ class PlayerTrackDetails extends HookConsumerWidget {
|
||||
Widget build(BuildContext context, ref) {
|
||||
final theme = Theme.of(context);
|
||||
final breakpoint = useBreakpoints();
|
||||
final playback = ref.watch(PlaylistQueueNotifier.provider);
|
||||
final playback = ref.watch(ProxyPlaylistNotifier.provider);
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
if (playback != null)
|
||||
if (playback.activeTrack != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
constraints: const BoxConstraints(
|
||||
@ -44,7 +44,7 @@ class PlayerTrackDetails extends HookConsumerWidget {
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
playback?.activeTrack.name ?? "",
|
||||
playback.activeTrack?.name ?? "",
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: color,
|
||||
@ -52,7 +52,7 @@ class PlayerTrackDetails extends HookConsumerWidget {
|
||||
),
|
||||
Text(
|
||||
TypeConversionUtils.artists_X_String<Artist>(
|
||||
playback?.activeTrack.artists ?? [],
|
||||
playback.activeTrack?.artists ?? [],
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall!.copyWith(color: color),
|
||||
@ -66,12 +66,12 @@ class PlayerTrackDetails extends HookConsumerWidget {
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
playback?.activeTrack.name ?? "",
|
||||
playback.activeTrack?.name ?? "",
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(fontWeight: FontWeight.bold, color: color),
|
||||
),
|
||||
TypeConversionUtils.artists_X_ClickableArtists(
|
||||
playback?.activeTrack.artists ?? [],
|
||||
playback.activeTrack?.artists ?? [],
|
||||
)
|
||||
],
|
||||
),
|
||||
|
@ -7,7 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotube/components/shared/image/universal_image.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/models/spotube_track.dart';
|
||||
import 'package:spotube/provider/playlist_queue_provider.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/utils/primitive_utils.dart';
|
||||
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
||||
|
||||
@ -21,11 +21,11 @@ class SiblingTracksSheet extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final theme = Theme.of(context);
|
||||
final playlist = ref.watch(PlaylistQueueNotifier.provider);
|
||||
final playlistNotifier = ref.watch(PlaylistQueueNotifier.notifier);
|
||||
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
|
||||
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
|
||||
|
||||
final siblings = playlist?.isLoading == false
|
||||
? (playlist!.activeTrack as SpotubeTrack).siblings
|
||||
final siblings = playlist.isFetching == false
|
||||
? (playlist.activeTrack as SpotubeTrack).siblings
|
||||
: <Video>[];
|
||||
|
||||
final borderRadius = floating
|
||||
@ -36,12 +36,12 @@ class SiblingTracksSheet extends HookConsumerWidget {
|
||||
);
|
||||
|
||||
useEffect(() {
|
||||
if (playlist?.activeTrack is SpotubeTrack &&
|
||||
(playlist?.activeTrack as SpotubeTrack).siblings.isEmpty) {
|
||||
if (playlist.activeTrack is SpotubeTrack &&
|
||||
(playlist.activeTrack as SpotubeTrack).siblings.isEmpty) {
|
||||
playlistNotifier.populateSibling();
|
||||
}
|
||||
return null;
|
||||
}, [playlist?.activeTrack]);
|
||||
}, [playlist.activeTrack]);
|
||||
|
||||
return BackdropFilter(
|
||||
filter: ImageFilter.blur(
|
||||
@ -91,18 +91,18 @@ class SiblingTracksSheet extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
subtitle: Text(video.author),
|
||||
enabled: playlist?.isLoading != true,
|
||||
selected: playlist?.isLoading != true &&
|
||||
enabled: playlist.isFetching != true,
|
||||
selected: playlist.isFetching != true &&
|
||||
video.id.value ==
|
||||
(playlist?.activeTrack as SpotubeTrack)
|
||||
(playlist.activeTrack as SpotubeTrack)
|
||||
.ytTrack
|
||||
.id
|
||||
.value,
|
||||
selectedTileColor: theme.popupMenuTheme.color,
|
||||
onTap: () async {
|
||||
if (playlist?.isLoading == false &&
|
||||
if (playlist.isFetching == false &&
|
||||
video.id.value !=
|
||||
(playlist?.activeTrack as SpotubeTrack)
|
||||
(playlist.activeTrack as SpotubeTrack)
|
||||
.ytTrack
|
||||
.id
|
||||
.value) {
|
||||
|
@ -4,7 +4,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/components/shared/playbutton_card.dart';
|
||||
import 'package:spotube/provider/playlist_queue_provider.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/provider/spotify_provider.dart';
|
||||
import 'package:spotube/services/audio_player/audio_player.dart';
|
||||
import 'package:spotube/services/queries/queries.dart';
|
||||
@ -19,8 +19,8 @@ class PlaylistCard extends HookConsumerWidget {
|
||||
}) : super(key: key);
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final playlistQueue = ref.watch(PlaylistQueueNotifier.provider);
|
||||
final playlistNotifier = ref.watch(PlaylistQueueNotifier.notifier);
|
||||
final playlistQueue = ref.watch(ProxyPlaylistNotifier.provider);
|
||||
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
|
||||
final playing =
|
||||
useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying;
|
||||
final queryBowl = QueryClient.of(context);
|
||||
@ -29,8 +29,7 @@ class PlaylistCard extends HookConsumerWidget {
|
||||
);
|
||||
final tracks = useState<List<TrackSimple>?>(null);
|
||||
bool isPlaylistPlaying = useMemoized(
|
||||
() =>
|
||||
playlistNotifier.isPlayingPlaylist(tracks.value ?? query?.data ?? []),
|
||||
() => playlistQueue.containsTracks(tracks.value ?? query?.data ?? []),
|
||||
[playlistNotifier, tracks.value, query?.data],
|
||||
);
|
||||
|
||||
@ -46,8 +45,8 @@ class PlaylistCard extends HookConsumerWidget {
|
||||
placeholder: ImagePlaceholder.collection,
|
||||
),
|
||||
isPlaying: isPlaylistPlaying,
|
||||
isLoading: (isPlaylistPlaying && playlistQueue?.isLoading == true) ||
|
||||
updating.value,
|
||||
isLoading:
|
||||
(isPlaylistPlaying && playlistQueue.isFetching) || updating.value,
|
||||
onTap: () {
|
||||
ServiceUtils.navigate(
|
||||
context,
|
||||
@ -59,9 +58,9 @@ class PlaylistCard extends HookConsumerWidget {
|
||||
try {
|
||||
updating.value = true;
|
||||
if (isPlaylistPlaying && playing) {
|
||||
return playlistNotifier.pause();
|
||||
return audioPlayer.pause();
|
||||
} else if (isPlaylistPlaying && !playing) {
|
||||
return playlistNotifier.resume();
|
||||
return audioPlayer.resume();
|
||||
}
|
||||
|
||||
List<Track> fetchedTracks = await queryBowl.fetchQuery(
|
||||
@ -72,7 +71,7 @@ class PlaylistCard extends HookConsumerWidget {
|
||||
|
||||
if (fetchedTracks.isEmpty) return;
|
||||
|
||||
await playlistNotifier.loadAndPlay(fetchedTracks);
|
||||
await playlistNotifier.load(fetchedTracks, autoPlay: true);
|
||||
tracks.value = fetchedTracks;
|
||||
} finally {
|
||||
updating.value = false;
|
||||
@ -90,7 +89,7 @@ class PlaylistCard extends HookConsumerWidget {
|
||||
|
||||
if (fetchedTracks.isEmpty) return;
|
||||
|
||||
playlistNotifier.add(fetchedTracks);
|
||||
playlistNotifier.addTracks(fetchedTracks);
|
||||
tracks.value = fetchedTracks;
|
||||
if (context.mounted) {
|
||||
final snackbar = SnackBar(
|
||||
@ -98,7 +97,8 @@ class PlaylistCard extends HookConsumerWidget {
|
||||
action: SnackBarAction(
|
||||
label: "Undo",
|
||||
onPressed: () {
|
||||
playlistNotifier.remove(fetchedTracks);
|
||||
playlistNotifier
|
||||
.removeTracks(fetchedTracks.map((e) => e.id!));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
@ -17,8 +17,9 @@ import 'package:spotube/hooks/use_breakpoints.dart';
|
||||
import 'package:spotube/hooks/use_brightness_value.dart';
|
||||
import 'package:spotube/models/logger.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotube/provider/playlist_queue_provider.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/provider/user_preferences_provider.dart';
|
||||
import 'package:spotube/services/audio_player/audio_player.dart';
|
||||
import 'package:spotube/utils/platform.dart';
|
||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
|
||||
@ -28,21 +29,21 @@ class BottomPlayer extends HookConsumerWidget {
|
||||
final logger = getLogger(BottomPlayer);
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final playlist = ref.watch(PlaylistQueueNotifier.provider);
|
||||
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
|
||||
final layoutMode =
|
||||
ref.watch(userPreferencesProvider.select((s) => s.layoutMode));
|
||||
|
||||
final breakpoint = useBreakpoints();
|
||||
|
||||
String albumArt = useMemoized(
|
||||
() => playlist?.activeTrack.album?.images?.isNotEmpty == true
|
||||
() => playlist.activeTrack?.album?.images?.isNotEmpty == true
|
||||
? TypeConversionUtils.image_X_UrlString(
|
||||
playlist?.activeTrack.album?.images,
|
||||
index: (playlist?.activeTrack.album?.images?.length ?? 1) - 1,
|
||||
playlist.activeTrack?.album?.images,
|
||||
index: (playlist.activeTrack?.album?.images?.length ?? 1) - 1,
|
||||
placeholder: ImagePlaceholder.albumArt,
|
||||
)
|
||||
: Assets.albumPlaceholder.path,
|
||||
[playlist?.activeTrack.album?.images],
|
||||
[playlist.activeTrack?.album?.images],
|
||||
);
|
||||
|
||||
final theme = Theme.of(context);
|
||||
@ -90,9 +91,8 @@ class BottomPlayer extends HookConsumerWidget {
|
||||
constraints: const BoxConstraints(maxWidth: 200),
|
||||
child: HookBuilder(builder: (context) {
|
||||
final volumeState =
|
||||
ref.watch(VolumeProvider.provider);
|
||||
final volumeNotifier =
|
||||
ref.watch(VolumeProvider.provider.notifier);
|
||||
useStream(audioPlayer.volumeStream).data ??
|
||||
audioPlayer.volume;
|
||||
final volume = useState(volumeState);
|
||||
|
||||
useEffect(() {
|
||||
@ -107,12 +107,10 @@ class BottomPlayer extends HookConsumerWidget {
|
||||
if (event is PointerScrollEvent) {
|
||||
if (event.scrollDelta.dy > 0) {
|
||||
final value = volume.value - .2;
|
||||
volumeNotifier
|
||||
.setVolume(value < 0 ? 0 : value);
|
||||
audioPlayer.setVolume(value < 0 ? 0 : value);
|
||||
} else {
|
||||
final value = volume.value + .2;
|
||||
volumeNotifier
|
||||
.setVolume(value > 1 ? 1 : value);
|
||||
audioPlayer.setVolume(value > 1 ? 1 : value);
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -123,7 +121,7 @@ class BottomPlayer extends HookConsumerWidget {
|
||||
onChanged: (v) {
|
||||
volume.value = v;
|
||||
},
|
||||
onChangeEnd: volumeNotifier.setVolume,
|
||||
onChangeEnd: audioPlayer.setVolume,
|
||||
),
|
||||
);
|
||||
}),
|
||||
|
@ -15,14 +15,15 @@ import 'package:spotube/hooks/use_breakpoints.dart';
|
||||
import 'package:spotube/models/logger.dart';
|
||||
import 'package:spotube/provider/authentication_provider.dart';
|
||||
import 'package:spotube/provider/blacklist_provider.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart';
|
||||
import 'package:spotube/provider/spotify_provider.dart';
|
||||
import 'package:spotube/provider/playlist_queue_provider.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/services/mutations/mutations.dart';
|
||||
|
||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
|
||||
class TrackTile extends HookConsumerWidget {
|
||||
final PlaylistQueue? playlist;
|
||||
final ProxyPlaylist playlist;
|
||||
final MapEntry<int, Track> track;
|
||||
final String duration;
|
||||
final void Function(Track currentTrack)? onTrackPlayButtonPressed;
|
||||
@ -75,7 +76,7 @@ class TrackTile extends HookConsumerWidget {
|
||||
);
|
||||
final auth = ref.watch(AuthenticationNotifier.provider);
|
||||
final spotify = ref.watch(spotifyProvider);
|
||||
final playlistQueueNotifier = ref.watch(PlaylistQueueNotifier.notifier);
|
||||
final playback = ref.watch(ProxyPlaylistNotifier.notifier);
|
||||
|
||||
final removingTrack = useState<String?>(null);
|
||||
final removeTrack = useMutations.playlist.removeTrackOf(
|
||||
@ -238,7 +239,7 @@ class TrackTile extends HookConsumerWidget {
|
||||
)
|
||||
: null,
|
||||
child: Icon(
|
||||
playlist?.activeTrack.id == track.value.id
|
||||
playlist.activeTrack?.id == track.value.id
|
||||
? SpotubeIcons.pause
|
||||
: SpotubeIcons.play,
|
||||
),
|
||||
@ -312,11 +313,11 @@ class TrackTile extends HookConsumerWidget {
|
||||
tooltip: context.l10n.more_actions,
|
||||
itemBuilder: (context) {
|
||||
return [
|
||||
if (!playlistQueueNotifier.isTrackOnQueue(track.value)) ...[
|
||||
if (!playlist.containsTrack(track.value)) ...[
|
||||
PopupMenuItem(
|
||||
padding: EdgeInsets.zero,
|
||||
onTap: () {
|
||||
playlistQueueNotifier.add([track.value]);
|
||||
playback.addTrack(track.value);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
@ -334,7 +335,7 @@ class TrackTile extends HookConsumerWidget {
|
||||
PopupMenuItem(
|
||||
padding: EdgeInsets.zero,
|
||||
onTap: () {
|
||||
playlistQueueNotifier.playNext([track.value]);
|
||||
playback.addTracksAtFirst([track.value]);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
@ -352,10 +353,10 @@ class TrackTile extends HookConsumerWidget {
|
||||
] else
|
||||
PopupMenuItem(
|
||||
padding: EdgeInsets.zero,
|
||||
onTap: playlist?.activeTrack.id == track.value.id
|
||||
onTap: playlist.activeTrack?.id == track.value.id
|
||||
? null
|
||||
: () {
|
||||
playlistQueueNotifier.remove([track.value]);
|
||||
playback.removeTrack(track.value.id!);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
@ -366,7 +367,7 @@ class TrackTile extends HookConsumerWidget {
|
||||
),
|
||||
);
|
||||
},
|
||||
enabled: playlist?.activeTrack.id != track.value.id,
|
||||
enabled: playlist.activeTrack?.id != track.value.id,
|
||||
child: ListTile(
|
||||
leading: const Icon(SpotubeIcons.queueRemove),
|
||||
title: Text(context.l10n.remove_from_queue),
|
||||
|
@ -14,7 +14,7 @@ import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/hooks/use_breakpoints.dart';
|
||||
import 'package:spotube/provider/blacklist_provider.dart';
|
||||
import 'package:spotube/provider/downloader_provider.dart';
|
||||
import 'package:spotube/provider/playlist_queue_provider.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/utils/primitive_utils.dart';
|
||||
import 'package:spotube/utils/service_utils.dart';
|
||||
|
||||
@ -41,8 +41,8 @@ class TracksTableView extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(context, ref) {
|
||||
final playlist = ref.watch(PlaylistQueueNotifier.provider);
|
||||
final playlistNotifier = ref.watch(PlaylistQueueNotifier.notifier);
|
||||
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
|
||||
final playback = ref.watch(ProxyPlaylistNotifier.notifier);
|
||||
final downloader = ref.watch(downloaderProvider);
|
||||
TextStyle tableHeadStyle =
|
||||
const TextStyle(fontWeight: FontWeight.bold, fontSize: 16);
|
||||
@ -229,14 +229,14 @@ class TracksTableView extends HookConsumerWidget {
|
||||
}
|
||||
case "play-next":
|
||||
{
|
||||
playlistNotifier.playNext(selectedTracks.toList());
|
||||
playback.addTracksAtFirst(selectedTracks);
|
||||
selected.value = [];
|
||||
showCheck.value = false;
|
||||
break;
|
||||
}
|
||||
case "add-to-queue":
|
||||
{
|
||||
playlistNotifier.add(selectedTracks.toList());
|
||||
playback.addTracks(selectedTracks);
|
||||
selected.value = [];
|
||||
showCheck.value = false;
|
||||
break;
|
||||
@ -310,7 +310,7 @@ class TracksTableView extends HookConsumerWidget {
|
||||
track: track,
|
||||
duration: duration,
|
||||
userPlaylist: userPlaylist,
|
||||
isActive: playlist?.activeTrack.id == track.value.id,
|
||||
isActive: playlist.activeTrack?.id == track.value.id,
|
||||
onTrackPlayButtonPressed: onTrackPlayButtonPressed,
|
||||
isChecked: selected.value.contains(track.value.id),
|
||||
showCheck: showCheck.value,
|
||||
|
@ -3,7 +3,8 @@ import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotube/collections/intents.dart';
|
||||
import 'package:spotube/provider/playlist_queue_provider.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/provider/user_preferences_provider.dart';
|
||||
|
||||
void useInitSysTray(WidgetRef ref) {
|
||||
@ -12,15 +13,15 @@ void useInitSysTray(WidgetRef ref) {
|
||||
|
||||
final initializeMenu = useCallback(() async {
|
||||
systemTray.value?.destroy();
|
||||
final playlistQueue = ref.read(PlaylistQueueNotifier.notifier);
|
||||
final playlist = ref.read(ProxyPlaylistNotifier.provider);
|
||||
final playlistQueue = ref.read(ProxyPlaylistNotifier.notifier);
|
||||
final preferences = ref.read(userPreferencesProvider);
|
||||
if (!preferences.showSystemTrayIcon) {
|
||||
await systemTray.value?.destroy();
|
||||
systemTray.value = null;
|
||||
return;
|
||||
}
|
||||
final enabled =
|
||||
playlistQueue.isLoaded && playlistQueue.state?.isLoading != true;
|
||||
final enabled = !playlist.isFetching;
|
||||
systemTray.value = await DesktopTools.createSystemTrayMenu(
|
||||
title: "Spotube",
|
||||
iconPath: "assets/spotube-logo.png",
|
||||
@ -51,7 +52,7 @@ void useInitSysTray(WidgetRef ref) {
|
||||
MenuItemLabel(
|
||||
label: "Next",
|
||||
name: "next",
|
||||
enabled: enabled && (playlistQueue.state?.tracks.length ?? 0) > 1,
|
||||
enabled: enabled && (playlist.tracks.length) > 1,
|
||||
onClicked: (p0) async {
|
||||
await playlistQueue.next();
|
||||
},
|
||||
@ -59,7 +60,7 @@ void useInitSysTray(WidgetRef ref) {
|
||||
MenuItemLabel(
|
||||
label: "Previous",
|
||||
name: "previous",
|
||||
enabled: enabled && (playlistQueue.state?.tracks.length ?? 0) > 1,
|
||||
enabled: enabled && (playlist.tracks.length) > 1,
|
||||
onClicked: (p0) async {
|
||||
await playlistQueue.previous();
|
||||
},
|
||||
@ -101,8 +102,8 @@ void useInitSysTray(WidgetRef ref) {
|
||||
|
||||
useReassemble(initializeMenu);
|
||||
|
||||
ref.listen<PlaylistQueue?>(
|
||||
PlaylistQueueNotifier.provider,
|
||||
ref.listen<ProxyPlaylist?>(
|
||||
ProxyPlaylistNotifier.provider,
|
||||
(previous, next) {
|
||||
initializeMenu();
|
||||
},
|
||||
|
@ -1,16 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotube/provider/playlist_queue_provider.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/services/audio_player/audio_player.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
Tuple4<double, Duration, Duration, double> useProgress(WidgetRef ref) {
|
||||
ref.watch(PlaylistQueueNotifier.provider);
|
||||
ref.watch(ProxyPlaylistNotifier.provider);
|
||||
|
||||
final bufferProgress =
|
||||
useStream(audioPlayer.bufferedPositionStream).data?.inSeconds ?? 0;
|
||||
final playlistNotifier = ref.watch(PlaylistQueueNotifier.notifier);
|
||||
|
||||
// Duration future is needed for getting the duration of the song
|
||||
// as stream can be null when no event occurs (Mostly needed for android)
|
||||
@ -32,9 +31,9 @@ Tuple4<double, Duration, Duration, double> useProgress(WidgetRef ref) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
if (positionSnapshot.hasData && duration == Duration.zero) {
|
||||
await Future.delayed(const Duration(milliseconds: 200));
|
||||
await playlistNotifier.pause();
|
||||
await audioPlayer.pause();
|
||||
await Future.delayed(const Duration(milliseconds: 400));
|
||||
await playlistNotifier.resume();
|
||||
await audioPlayer.resume();
|
||||
}
|
||||
});
|
||||
return null;
|
||||
|
@ -7,7 +7,7 @@ import 'package:spotube/components/shared/heart_button.dart';
|
||||
import 'package:spotube/components/shared/track_table/track_collection_view.dart';
|
||||
import 'package:spotube/components/shared/track_table/tracks_table_view.dart';
|
||||
import 'package:spotube/hooks/use_breakpoints.dart';
|
||||
import 'package:spotube/provider/playlist_queue_provider.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/services/queries/queries.dart';
|
||||
import 'package:spotube/utils/service_utils.dart';
|
||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
@ -17,32 +17,32 @@ class AlbumPage extends HookConsumerWidget {
|
||||
const AlbumPage(this.album, {Key? key}) : super(key: key);
|
||||
|
||||
Future<void> playPlaylist(
|
||||
PlaylistQueueNotifier playback,
|
||||
List<Track> tracks,
|
||||
WidgetRef ref, {
|
||||
Track? currentTrack,
|
||||
}) async {
|
||||
final playlist = ref.read(PlaylistQueueNotifier.provider);
|
||||
final playlist = ref.read(ProxyPlaylistNotifier.provider);
|
||||
final playback = ref.read(ProxyPlaylistNotifier.notifier);
|
||||
final sortBy = ref.read(trackCollectionSortState(album.id!));
|
||||
final sortedTracks = ServiceUtils.sortTracks(tracks, sortBy);
|
||||
currentTrack ??= sortedTracks.first;
|
||||
final isPlaylistPlaying = playback.isPlayingPlaylist(tracks);
|
||||
final isPlaylistPlaying = playlist.containsTracks(tracks);
|
||||
if (!isPlaylistPlaying) {
|
||||
await playback.loadAndPlay(
|
||||
await playback.load(
|
||||
sortedTracks,
|
||||
active: sortedTracks.indexWhere((s) => s.id == currentTrack?.id),
|
||||
initialIndex: sortedTracks.indexWhere((s) => s.id == currentTrack?.id),
|
||||
);
|
||||
} else if (isPlaylistPlaying &&
|
||||
currentTrack.id != null &&
|
||||
currentTrack.id != playlist?.activeTrack.id) {
|
||||
await playback.playTrack(currentTrack);
|
||||
currentTrack.id != playlist.activeTrack?.id) {
|
||||
await playback.jumpToTrack(currentTrack);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
ref.watch(PlaylistQueueNotifier.provider);
|
||||
final playback = ref.watch(PlaylistQueueNotifier.notifier);
|
||||
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
|
||||
final playback = ref.watch(ProxyPlaylistNotifier.notifier);
|
||||
|
||||
final tracksSnapshot = useQueries.album.tracksOf(ref, album.id!);
|
||||
|
||||
@ -56,7 +56,7 @@ class AlbumPage extends HookConsumerWidget {
|
||||
final breakpoint = useBreakpoints();
|
||||
|
||||
final isAlbumPlaying = useMemoized(
|
||||
() => playback.isPlayingPlaylist(tracksSnapshot.data ?? []),
|
||||
() => playlist.containsTracks(tracksSnapshot.data ?? []),
|
||||
[playback, tracksSnapshot.data],
|
||||
);
|
||||
return TrackCollectionView(
|
||||
@ -72,7 +72,6 @@ class AlbumPage extends HookConsumerWidget {
|
||||
if (tracksSnapshot.hasData) {
|
||||
if (!isAlbumPlaying) {
|
||||
playPlaylist(
|
||||
playback,
|
||||
tracksSnapshot.data!
|
||||
.map((track) =>
|
||||
TypeConversionUtils.simpleTrack_X_Track(track, album))
|
||||
@ -81,7 +80,6 @@ class AlbumPage extends HookConsumerWidget {
|
||||
);
|
||||
} else if (isAlbumPlaying && track != null) {
|
||||
playPlaylist(
|
||||
playback,
|
||||
tracksSnapshot.data!
|
||||
.map((track) =>
|
||||
TypeConversionUtils.simpleTrack_X_Track(track, album))
|
||||
@ -90,18 +88,14 @@ class AlbumPage extends HookConsumerWidget {
|
||||
ref,
|
||||
);
|
||||
} else {
|
||||
playback.remove(
|
||||
tracksSnapshot.data!
|
||||
.map((track) =>
|
||||
TypeConversionUtils.simpleTrack_X_Track(track, album))
|
||||
.toList(),
|
||||
);
|
||||
playback
|
||||
.removeTracks(tracksSnapshot.data!.map((track) => track.id!));
|
||||
}
|
||||
}
|
||||
},
|
||||
onAddToQueue: () {
|
||||
if (tracksSnapshot.hasData && !isAlbumPlaying) {
|
||||
playback.add(
|
||||
playback.addTracks(
|
||||
tracksSnapshot.data!
|
||||
.map((track) =>
|
||||
TypeConversionUtils.simpleTrack_X_Track(track, album))
|
||||
@ -125,19 +119,18 @@ class AlbumPage extends HookConsumerWidget {
|
||||
..shuffle();
|
||||
if (!isAlbumPlaying) {
|
||||
playPlaylist(
|
||||
playback,
|
||||
tracks,
|
||||
ref,
|
||||
);
|
||||
} else if (isAlbumPlaying && track != null) {
|
||||
playPlaylist(
|
||||
playback,
|
||||
tracks,
|
||||
ref,
|
||||
currentTrack: track,
|
||||
);
|
||||
} else {
|
||||
playback.stop();
|
||||
// TODO: Disable ability to stop playback from playlist/album
|
||||
// playback.stop();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -19,7 +19,7 @@ import 'package:spotube/models/logger.dart';
|
||||
import 'package:spotube/provider/authentication_provider.dart';
|
||||
import 'package:spotube/provider/blacklist_provider.dart';
|
||||
import 'package:spotube/provider/spotify_provider.dart';
|
||||
import 'package:spotube/provider/playlist_queue_provider.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/services/queries/queries.dart';
|
||||
|
||||
import 'package:spotube/utils/primitive_utils.dart';
|
||||
@ -57,8 +57,8 @@ class ArtistPage extends HookConsumerWidget {
|
||||
|
||||
final breakpoint = useBreakpoints();
|
||||
|
||||
final playlistNotifier = ref.watch(PlaylistQueueNotifier.notifier);
|
||||
final playlist = ref.watch(PlaylistQueueNotifier.provider);
|
||||
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
|
||||
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
|
||||
|
||||
final auth = ref.watch(AuthenticationNotifier.provider);
|
||||
|
||||
@ -298,8 +298,7 @@ class ArtistPage extends HookConsumerWidget {
|
||||
artistId,
|
||||
);
|
||||
|
||||
final isPlaylistPlaying =
|
||||
playlistNotifier.isPlayingPlaylist(
|
||||
final isPlaylistPlaying = playlist.containsTracks(
|
||||
topTracksQuery.data ?? <Track>[],
|
||||
);
|
||||
|
||||
@ -318,13 +317,16 @@ class ArtistPage extends HookConsumerWidget {
|
||||
{Track? currentTrack}) async {
|
||||
currentTrack ??= tracks.first;
|
||||
if (!isPlaylistPlaying) {
|
||||
playlistNotifier.loadAndPlay(tracks,
|
||||
active: tracks.indexWhere(
|
||||
(s) => s.id == currentTrack?.id));
|
||||
playlistNotifier.load(
|
||||
tracks,
|
||||
initialIndex: tracks
|
||||
.indexWhere((s) => s.id == currentTrack?.id),
|
||||
autoPlay: true,
|
||||
);
|
||||
} else if (isPlaylistPlaying &&
|
||||
currentTrack.id != null &&
|
||||
currentTrack.id != playlist?.activeTrack.id) {
|
||||
await playlistNotifier.playTrack(currentTrack);
|
||||
currentTrack.id != playlist.activeTrack?.id) {
|
||||
await playlistNotifier.jumpToTrack(currentTrack);
|
||||
}
|
||||
}
|
||||
|
||||
@ -341,7 +343,8 @@ class ArtistPage extends HookConsumerWidget {
|
||||
SpotubeIcons.queueAdd,
|
||||
),
|
||||
onPressed: () {
|
||||
playlistNotifier.add(topTracks.toList());
|
||||
playlistNotifier
|
||||
.addTracks(topTracks.toList());
|
||||
scaffoldMessenger.showSnackBar(
|
||||
SnackBar(
|
||||
width: 300,
|
||||
@ -380,7 +383,7 @@ class ArtistPage extends HookConsumerWidget {
|
||||
duration: duration,
|
||||
track: track,
|
||||
isActive:
|
||||
playlist?.activeTrack.id == track.value.id,
|
||||
playlist.activeTrack?.id == track.value.id,
|
||||
onTrackPlayButtonPressed: (currentTrack) =>
|
||||
playPlaylist(
|
||||
topTracks.toList(),
|
||||
|
@ -16,7 +16,7 @@ import 'package:spotube/hooks/use_palette_color.dart';
|
||||
import 'package:spotube/pages/lyrics/plain_lyrics.dart';
|
||||
import 'package:spotube/pages/lyrics/synced_lyrics.dart';
|
||||
import 'package:spotube/provider/authentication_provider.dart';
|
||||
import 'package:spotube/provider/playlist_queue_provider.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/utils/platform.dart';
|
||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
|
||||
@ -26,14 +26,14 @@ class LyricsPage extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final playlist = ref.watch(PlaylistQueueNotifier.provider);
|
||||
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
|
||||
String albumArt = useMemoized(
|
||||
() => TypeConversionUtils.image_X_UrlString(
|
||||
playlist?.activeTrack.album?.images,
|
||||
index: (playlist?.activeTrack.album?.images?.length ?? 1) - 1,
|
||||
playlist.activeTrack?.album?.images,
|
||||
index: (playlist.activeTrack?.album?.images?.length ?? 1) - 1,
|
||||
placeholder: ImagePlaceholder.albumArt,
|
||||
),
|
||||
[playlist?.activeTrack.album?.images],
|
||||
[playlist.activeTrack?.album?.images],
|
||||
);
|
||||
final palette = usePaletteColor(albumArt, ref);
|
||||
final breakpoint = useBreakpoints();
|
||||
|
@ -15,7 +15,7 @@ import 'package:spotube/hooks/use_force_update.dart';
|
||||
import 'package:spotube/pages/lyrics/plain_lyrics.dart';
|
||||
import 'package:spotube/pages/lyrics/synced_lyrics.dart';
|
||||
import 'package:spotube/provider/authentication_provider.dart';
|
||||
import 'package:spotube/provider/playlist_queue_provider.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/utils/platform.dart';
|
||||
|
||||
class MiniLyricsPage extends HookConsumerWidget {
|
||||
@ -28,7 +28,7 @@ class MiniLyricsPage extends HookConsumerWidget {
|
||||
final prevSize = useRef<Size?>(null);
|
||||
final wasMaximized = useRef<bool>(false);
|
||||
|
||||
final playlistQueue = ref.watch(PlaylistQueueNotifier.provider);
|
||||
final playlistQueue = ref.watch(ProxyPlaylistNotifier.provider);
|
||||
|
||||
final areaActive = useState(false);
|
||||
final hoverMode = useState(true);
|
||||
@ -146,9 +146,9 @@ class MiniLyricsPage extends HookConsumerWidget {
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
if (playlistQueue != null)
|
||||
if (playlistQueue.activeTrack != null)
|
||||
Text(
|
||||
playlistQueue.activeTrack.name!,
|
||||
playlistQueue.activeTrack!.name!,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
Expanded(
|
||||
@ -178,7 +178,7 @@ class MiniLyricsPage extends HookConsumerWidget {
|
||||
IconButton(
|
||||
icon: const Icon(SpotubeIcons.queue),
|
||||
tooltip: context.l10n.queue,
|
||||
onPressed: playlistQueue != null
|
||||
onPressed: playlistQueue.activeTrack != null
|
||||
? () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
|
@ -7,7 +7,7 @@ import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/components/lyrics/zoom_controls.dart';
|
||||
import 'package:spotube/components/shared/shimmers/shimmer_lyrics.dart';
|
||||
import 'package:spotube/hooks/use_breakpoints.dart';
|
||||
import 'package:spotube/provider/playlist_queue_provider.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
|
||||
import 'package:spotube/services/queries/queries.dart';
|
||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
@ -25,7 +25,7 @@ class PlainLyrics extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final playlist = ref.watch(PlaylistQueueNotifier.provider);
|
||||
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
|
||||
final lyricsQuery = useQueries.lyrics.spotifySynced(
|
||||
ref,
|
||||
playlist?.activeTrack,
|
||||
@ -43,7 +43,7 @@ class PlainLyrics extends HookConsumerWidget {
|
||||
if (isModal != true) ...[
|
||||
Center(
|
||||
child: Text(
|
||||
playlist?.activeTrack.name ?? "",
|
||||
playlist.activeTrack?.name ?? "",
|
||||
style: breakpoint >= Breakpoints.md
|
||||
? textTheme.displaySmall
|
||||
: textTheme.headlineMedium?.copyWith(
|
||||
@ -55,7 +55,7 @@ class PlainLyrics extends HookConsumerWidget {
|
||||
Center(
|
||||
child: Text(
|
||||
TypeConversionUtils.artists_X_String<Artist>(
|
||||
playlist?.activeTrack.artists ?? []),
|
||||
playlist.activeTrack?.artists ?? []),
|
||||
style: (breakpoint >= Breakpoints.md
|
||||
? textTheme.headlineSmall
|
||||
: textTheme.titleLarge)
|
||||
@ -74,7 +74,7 @@ class PlainLyrics extends HookConsumerWidget {
|
||||
return const ShimmerLyrics();
|
||||
} else if (lyricsQuery.hasError) {
|
||||
return Text(
|
||||
"Sorry, no Lyrics were found for `${playlist?.activeTrack.name}` :'(\n${lyricsQuery.error.toString()}",
|
||||
"Sorry, no Lyrics were found for `${playlist.activeTrack?.name}` :'(\n${lyricsQuery.error.toString()}",
|
||||
style: textTheme.bodyLarge?.copyWith(
|
||||
color: palette.bodyTextColor,
|
||||
),
|
||||
|
@ -10,7 +10,7 @@ import 'package:spotube/hooks/use_auto_scroll_controller.dart';
|
||||
import 'package:spotube/hooks/use_breakpoints.dart';
|
||||
import 'package:spotube/hooks/use_synced_lyrics.dart';
|
||||
import 'package:scroll_to_index/scroll_to_index.dart';
|
||||
import 'package:spotube/provider/playlist_queue_provider.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/services/queries/queries.dart';
|
||||
|
||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
@ -31,7 +31,7 @@ class SyncedLyrics extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final playlist = ref.watch(PlaylistQueueNotifier.provider);
|
||||
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
|
||||
|
||||
final breakpoint = useBreakpoints();
|
||||
final controller = useAutoScrollController();
|
||||
@ -77,7 +77,7 @@ class SyncedLyrics extends HookConsumerWidget {
|
||||
if (isModal != true)
|
||||
Center(
|
||||
child: Text(
|
||||
playlist?.activeTrack.name ?? "Not Playing",
|
||||
playlist.activeTrack?.name ?? "Not Playing",
|
||||
style: headlineTextStyle,
|
||||
),
|
||||
),
|
||||
@ -85,7 +85,7 @@ class SyncedLyrics extends HookConsumerWidget {
|
||||
Center(
|
||||
child: Text(
|
||||
TypeConversionUtils.artists_X_String<Artist>(
|
||||
playlist?.activeTrack.artists ?? []),
|
||||
playlist.activeTrack?.artists ?? []),
|
||||
style: breakpoint >= Breakpoints.md
|
||||
? textTheme.headlineSmall
|
||||
: textTheme.titleLarge,
|
||||
|
@ -18,7 +18,7 @@ import 'package:spotube/hooks/use_palette_color.dart';
|
||||
import 'package:spotube/models/local_track.dart';
|
||||
import 'package:spotube/pages/lyrics/lyrics.dart';
|
||||
import 'package:spotube/provider/authentication_provider.dart';
|
||||
import 'package:spotube/provider/playlist_queue_provider.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
|
||||
class PlayerView extends HookConsumerWidget {
|
||||
@ -30,10 +30,10 @@ class PlayerView extends HookConsumerWidget {
|
||||
Widget build(BuildContext context, ref) {
|
||||
final theme = Theme.of(context);
|
||||
final auth = ref.watch(AuthenticationNotifier.provider);
|
||||
final currentTrack = ref.watch(PlaylistQueueNotifier.provider.select(
|
||||
final currentTrack = ref.watch(ProxyPlaylistNotifier.provider.select(
|
||||
(value) => value?.activeTrack,
|
||||
));
|
||||
final isLocalTrack = ref.watch(PlaylistQueueNotifier.provider.select(
|
||||
final isLocalTrack = ref.watch(ProxyPlaylistNotifier.provider.select(
|
||||
(value) => value?.activeTrack is LocalTrack,
|
||||
));
|
||||
final breakpoint = useBreakpoints();
|
||||
|
@ -8,7 +8,7 @@ import 'package:spotube/hooks/use_breakpoints.dart';
|
||||
import 'package:spotube/models/logger.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/provider/playlist_queue_provider.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/services/queries/queries.dart';
|
||||
|
||||
import 'package:spotube/utils/service_utils.dart';
|
||||
@ -20,31 +20,33 @@ class PlaylistView extends HookConsumerWidget {
|
||||
PlaylistView(this.playlist, {Key? key}) : super(key: key);
|
||||
|
||||
Future<void> playPlaylist(
|
||||
PlaylistQueueNotifier playlistNotifier,
|
||||
List<Track> tracks,
|
||||
WidgetRef ref, {
|
||||
Track? currentTrack,
|
||||
}) async {
|
||||
final proxyPlaylist = ref.read(ProxyPlaylistNotifier.provider);
|
||||
final playback = ref.read(ProxyPlaylistNotifier.notifier);
|
||||
final sortBy = ref.read(trackCollectionSortState(playlist.id!));
|
||||
final sortedTracks = ServiceUtils.sortTracks(tracks, sortBy);
|
||||
currentTrack ??= sortedTracks.first;
|
||||
final isPlaylistPlaying = playlistNotifier.isPlayingPlaylist(tracks);
|
||||
final isPlaylistPlaying = proxyPlaylist.containsTracks(tracks);
|
||||
if (!isPlaylistPlaying) {
|
||||
await playlistNotifier.loadAndPlay(
|
||||
await playback.load(
|
||||
sortedTracks,
|
||||
active: sortedTracks.indexWhere((s) => s.id == currentTrack?.id),
|
||||
initialIndex: sortedTracks.indexWhere((s) => s.id == currentTrack?.id),
|
||||
autoPlay: true,
|
||||
);
|
||||
} else if (isPlaylistPlaying &&
|
||||
currentTrack.id != null &&
|
||||
currentTrack.id != playlistNotifier.state?.activeTrack.id) {
|
||||
await playlistNotifier.playTrack(currentTrack);
|
||||
currentTrack.id != proxyPlaylist.activeTrack?.id) {
|
||||
await playback.jumpToTrack(currentTrack);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
ref.watch(PlaylistQueueNotifier.provider);
|
||||
final playlistNotifier = ref.watch(PlaylistQueueNotifier.notifier);
|
||||
final proxyPlaylist = ref.watch(ProxyPlaylistNotifier.provider);
|
||||
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
|
||||
|
||||
final breakpoint = useBreakpoints();
|
||||
|
||||
@ -52,7 +54,7 @@ class PlaylistView extends HookConsumerWidget {
|
||||
final tracksSnapshot = useQueries.playlist.tracksOfQuery(ref, playlist.id!);
|
||||
|
||||
final isPlaylistPlaying = useMemoized(
|
||||
() => playlistNotifier.isPlayingPlaylist(tracksSnapshot.data ?? []),
|
||||
() => proxyPlaylist.containsTracks(tracksSnapshot.data ?? []),
|
||||
[playlistNotifier, tracksSnapshot.data],
|
||||
);
|
||||
|
||||
@ -76,26 +78,25 @@ class PlaylistView extends HookConsumerWidget {
|
||||
if (tracksSnapshot.hasData) {
|
||||
if (!isPlaylistPlaying) {
|
||||
playPlaylist(
|
||||
playlistNotifier,
|
||||
tracksSnapshot.data!,
|
||||
ref,
|
||||
currentTrack: track,
|
||||
);
|
||||
} else if (isPlaylistPlaying && track != null) {
|
||||
playPlaylist(
|
||||
playlistNotifier,
|
||||
tracksSnapshot.data!,
|
||||
ref,
|
||||
currentTrack: track,
|
||||
);
|
||||
} else {
|
||||
playlistNotifier.remove(tracksSnapshot.data!);
|
||||
playlistNotifier
|
||||
.removeTracks(tracksSnapshot.data!.map((e) => e.id!));
|
||||
}
|
||||
}
|
||||
},
|
||||
onAddToQueue: () {
|
||||
if (tracksSnapshot.hasData && !isPlaylistPlaying) {
|
||||
playlistNotifier.add(tracksSnapshot.data!);
|
||||
playlistNotifier.addTracks(tracksSnapshot.data!);
|
||||
}
|
||||
},
|
||||
bottomSpace: breakpoint.isLessThanOrEqualTo(Breakpoints.md),
|
||||
@ -125,20 +126,19 @@ class PlaylistView extends HookConsumerWidget {
|
||||
if (tracksSnapshot.hasData) {
|
||||
if (!isPlaylistPlaying) {
|
||||
playPlaylist(
|
||||
playlistNotifier,
|
||||
tracks,
|
||||
ref,
|
||||
currentTrack: track,
|
||||
);
|
||||
} else if (isPlaylistPlaying && track != null) {
|
||||
playPlaylist(
|
||||
playlistNotifier,
|
||||
tracks,
|
||||
ref,
|
||||
currentTrack: track,
|
||||
);
|
||||
} else {
|
||||
playlistNotifier.stop();
|
||||
// TODO: Remove the ability to stop the playlist
|
||||
// playlistNotifier.stop();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -19,7 +19,7 @@ import 'package:spotube/components/playlist/playlist_card.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/hooks/use_breakpoints.dart';
|
||||
import 'package:spotube/provider/authentication_provider.dart';
|
||||
import 'package:spotube/provider/playlist_queue_provider.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/services/queries/queries.dart';
|
||||
|
||||
import 'package:spotube/utils/platform.dart';
|
||||
@ -96,9 +96,9 @@ class SearchPage extends HookConsumerWidget {
|
||||
HookBuilder(
|
||||
builder: (context) {
|
||||
final playlist =
|
||||
ref.watch(PlaylistQueueNotifier.provider);
|
||||
ref.watch(ProxyPlaylistNotifier.provider);
|
||||
final playlistNotifier =
|
||||
ref.watch(PlaylistQueueNotifier.notifier);
|
||||
ref.watch(ProxyPlaylistNotifier.notifier);
|
||||
List<AlbumSimple> albums = [];
|
||||
List<Artist> artists = [];
|
||||
List<Track> tracks = [];
|
||||
@ -154,18 +154,17 @@ class SearchPage extends HookConsumerWidget {
|
||||
playlist,
|
||||
track: track,
|
||||
duration: duration,
|
||||
isActive: playlist?.activeTrack.id ==
|
||||
isActive: playlist.activeTrack?.id ==
|
||||
track.value.id,
|
||||
onTrackPlayButtonPressed:
|
||||
(currentTrack) async {
|
||||
final isTrackPlaying =
|
||||
playlist?.activeTrack.id ==
|
||||
playlist.activeTrack?.id ==
|
||||
currentTrack.id;
|
||||
if (!isTrackPlaying &&
|
||||
context.mounted) {
|
||||
final shouldPlay =
|
||||
(playlist?.tracks.length ?? 0) >
|
||||
20
|
||||
(playlist.tracks.length) > 20
|
||||
? await showPromptDialog(
|
||||
context: context,
|
||||
title: context.l10n
|
||||
@ -174,16 +173,17 @@ class SearchPage extends HookConsumerWidget {
|
||||
),
|
||||
message: context.l10n
|
||||
.queue_clear_alert(
|
||||
playlist?.tracks
|
||||
.length ??
|
||||
0,
|
||||
playlist
|
||||
.tracks.length,
|
||||
),
|
||||
)
|
||||
: true;
|
||||
|
||||
if (shouldPlay) {
|
||||
await playlistNotifier
|
||||
.loadAndPlay([currentTrack]);
|
||||
await playlistNotifier.load(
|
||||
[currentTrack],
|
||||
autoPlay: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -55,6 +55,10 @@ class BlackListNotifier
|
||||
state = state.difference({element});
|
||||
}
|
||||
|
||||
bool contains(TrackSimple track) {
|
||||
return filter([track]).isNotEmpty;
|
||||
}
|
||||
|
||||
Iterable<TrackSimple> filter(Iterable<TrackSimple> tracks) {
|
||||
return tracks.where(
|
||||
(track) {
|
||||
|
@ -1,555 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.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/models/local_track.dart';
|
||||
import 'package:spotube/models/spotube_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/user_preferences_provider.dart';
|
||||
import 'package:spotube/services/audio_player/audio_player.dart';
|
||||
import 'package:spotube/services/audio_player/loop_mode.dart';
|
||||
import 'package:spotube/services/audio_services/audio_services.dart';
|
||||
import 'package:spotube/utils/persisted_state_notifier.dart';
|
||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
import 'package:youtube_explode_dart/youtube_explode_dart.dart' hide Playlist;
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
class PlaylistQueue {
|
||||
final Set<Track> tracks;
|
||||
final int active;
|
||||
|
||||
Track get activeTrack => tracks.elementAt(active);
|
||||
|
||||
final bool shuffled;
|
||||
final PlaybackLoopMode loopMode;
|
||||
|
||||
static Future<PlaylistQueue> fromJson(
|
||||
Map<String, dynamic> json,
|
||||
UserPreferences preferences,
|
||||
) async {
|
||||
final List? tracks = json['tracks'];
|
||||
return PlaylistQueue(
|
||||
Set.from(
|
||||
await Future.wait(
|
||||
tracks?.mapIndexed(
|
||||
(i, e) async {
|
||||
final jsonTrack =
|
||||
Map.castFrom<dynamic, dynamic, String, dynamic>(e);
|
||||
|
||||
if (e["path"] != null) {
|
||||
return LocalTrack.fromJson(jsonTrack);
|
||||
} else if (i == json["active"] && !json.containsKey("path")) {
|
||||
return await SpotubeTrack.fetchFromTrack(
|
||||
Track.fromJson(jsonTrack),
|
||||
preferences,
|
||||
);
|
||||
} else {
|
||||
return Track.fromJson(jsonTrack);
|
||||
}
|
||||
},
|
||||
) ??
|
||||
[],
|
||||
),
|
||||
),
|
||||
active: json['active'],
|
||||
shuffled: json['shuffled'],
|
||||
loopMode: PlaybackLoopMode.fromString(json['loopMode'] ?? ''),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'tracks': tracks.map(
|
||||
(e) {
|
||||
if (e is SpotubeTrack) {
|
||||
return e.toJson();
|
||||
} else if (e is LocalTrack) {
|
||||
return e.toJson();
|
||||
} else {
|
||||
return e.toJson();
|
||||
}
|
||||
},
|
||||
).toList(),
|
||||
'active': active,
|
||||
'shuffled': shuffled,
|
||||
'loopMode': loopMode.name,
|
||||
};
|
||||
}
|
||||
|
||||
bool get isLoading =>
|
||||
activeTrack is LocalTrack ? false : activeTrack is! SpotubeTrack;
|
||||
|
||||
PlaylistQueue(
|
||||
this.tracks, {
|
||||
this.active = 0,
|
||||
this.shuffled = false,
|
||||
this.loopMode = PlaybackLoopMode.none,
|
||||
}) : assert(active < tracks.length && active >= 0, "Invalid active index");
|
||||
|
||||
PlaylistQueue copyWith({
|
||||
Set<Track>? tracks,
|
||||
int? active,
|
||||
bool? shuffled,
|
||||
PlaybackLoopMode? loopMode,
|
||||
}) {
|
||||
return PlaylistQueue(
|
||||
tracks ?? this.tracks,
|
||||
active: active ?? this.active,
|
||||
shuffled: shuffled ?? this.shuffled,
|
||||
loopMode: loopMode ?? this.loopMode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PlaylistQueueNotifier extends PersistedStateNotifier<PlaylistQueue?> {
|
||||
final Ref ref;
|
||||
|
||||
late AudioServices audioServices;
|
||||
|
||||
static final provider =
|
||||
StateNotifierProvider<PlaylistQueueNotifier, PlaylistQueue?>(
|
||||
(ref) => PlaylistQueueNotifier._(ref),
|
||||
);
|
||||
|
||||
static final notifier = provider.notifier;
|
||||
|
||||
PlaylistQueueNotifier._(this.ref) : super(null, "playlist") {
|
||||
configure();
|
||||
}
|
||||
|
||||
void configure() async {
|
||||
audioServices = await AudioServices.create(ref, this);
|
||||
|
||||
audioPlayer.currentIndexChangedStream.listen((index) async {
|
||||
if (!isLoaded) return;
|
||||
state = state!.copyWith(active: index);
|
||||
await audioServices.addTrack(state!.activeTrack);
|
||||
});
|
||||
|
||||
audioPlayer.almostCompleteStream.listen((_) async {
|
||||
if (!isLoaded) return;
|
||||
final nextTrack = state!.tracks.elementAtOrNull(state!.active + 1);
|
||||
final sources = audioPlayer.sources;
|
||||
|
||||
// we don't have a next track or the next track is already loaded
|
||||
// only when the next track isn't loaded we load next 3 tracks
|
||||
if (nextTrack == null ||
|
||||
nextTrack is SpotubeTrack && sources.contains(nextTrack.ytUri)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final List<SpotubeTrack> fetchedTracks = [];
|
||||
|
||||
// load next 3 tracks
|
||||
final tracks = await Future.wait(state!.tracks
|
||||
.toList()
|
||||
.skip(state!.active + 1)
|
||||
.take(3)
|
||||
.mapIndexed((i, track) async {
|
||||
if (track is LocalTrack) return Future.value(track.path);
|
||||
if (track is SpotubeTrack) return Future.value(track.ytUri);
|
||||
if (i == 0) {
|
||||
final fetchedTrack =
|
||||
await SpotubeTrack.fetchFromTrack(track, preferences);
|
||||
fetchedTracks.add(fetchedTrack);
|
||||
return fetchedTrack.ytUri;
|
||||
}
|
||||
// Adding delay to not spoof the YouTube API for IP Block
|
||||
final fetchedTrack = await Future.delayed(
|
||||
const Duration(milliseconds: 100),
|
||||
() => SpotubeTrack.fetchFromTrack(track, preferences),
|
||||
);
|
||||
|
||||
fetchedTracks.add(fetchedTrack);
|
||||
return fetchedTrack.ytUri;
|
||||
}));
|
||||
|
||||
// replacing the tracks with the fetched tracks
|
||||
// in proxy playlist
|
||||
state = state!.copyWith(
|
||||
tracks: state!.tracks.map((track) {
|
||||
final fetchedTrack =
|
||||
fetchedTracks.firstWhereOrNull((e) => e.id == track.id);
|
||||
|
||||
if (fetchedTrack != null) {
|
||||
return fetchedTrack;
|
||||
}
|
||||
return track;
|
||||
}).toSet(),
|
||||
);
|
||||
|
||||
for (final track in tracks) {
|
||||
if (sources.contains(track)) continue;
|
||||
await audioPlayer.addTrack(track);
|
||||
}
|
||||
});
|
||||
|
||||
bool isPreSearching = false;
|
||||
|
||||
audioPlayer.positionStream.listen((pos) async {
|
||||
if (!isLoaded) return;
|
||||
final currentDuration = await audioPlayer.duration ?? Duration.zero;
|
||||
|
||||
// skip all the activeTrack.skipSegments
|
||||
if (state?.isLoading != true &&
|
||||
state?.activeTrack is SpotubeTrack &&
|
||||
(state?.activeTrack as SpotubeTrack?)?.skipSegments.isNotEmpty ==
|
||||
true &&
|
||||
preferences.skipSponsorSegments) {
|
||||
for (final segment
|
||||
in (state!.activeTrack as SpotubeTrack).skipSegments) {
|
||||
if ((pos.inSeconds >= segment["start"]! &&
|
||||
pos.inSeconds < segment["end"]!)) {
|
||||
await audioPlayer.pause();
|
||||
await seek(Duration(seconds: segment["end"]!));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// when the track progress is above 80%, track isn't the last
|
||||
// and is not already fetched and nothing is fetching currently
|
||||
if (pos.inSeconds > currentDuration.inSeconds * .8 &&
|
||||
state!.active != state!.tracks.length - 1 &&
|
||||
state!.tracks.elementAt(state!.active + 1) is! SpotubeTrack &&
|
||||
!isPreSearching) {
|
||||
isPreSearching = true;
|
||||
final tracks = state!.tracks.toList();
|
||||
final newTrack = await SpotubeTrack.fetchFromTrack(
|
||||
state!.tracks.elementAt(state!.active + 1),
|
||||
preferences,
|
||||
);
|
||||
tracks[state!.active + 1] = newTrack;
|
||||
await audioPlayer.preload(newTrack.ytUri);
|
||||
state = state!.copyWith(tracks: Set.from(tracks));
|
||||
isPreSearching = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// properties
|
||||
|
||||
// getters
|
||||
UserPreferences get preferences => ref.read(userPreferencesProvider);
|
||||
BlackListNotifier get blacklist =>
|
||||
ref.read(BlackListNotifier.provider.notifier);
|
||||
|
||||
bool get isLoaded => state != null;
|
||||
|
||||
List<Video> get siblings => state?.isLoading == false
|
||||
? (state!.activeTrack as SpotubeTrack).siblings
|
||||
: [];
|
||||
|
||||
// modifiers
|
||||
void add(List<Track> tracks) {
|
||||
if (!isLoaded) {
|
||||
loadAndPlay(tracks);
|
||||
} else {
|
||||
state = state?.copyWith(
|
||||
tracks: state!.tracks..addAll(tracks),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void playNext(List<Track> tracks) {
|
||||
if (!isLoaded) {
|
||||
loadAndPlay(tracks);
|
||||
} else {
|
||||
final stateTracks = state!.tracks.toList();
|
||||
|
||||
stateTracks.insertAll(state!.active + 1, tracks);
|
||||
|
||||
state = state?.copyWith(tracks: Set.from(stateTracks));
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Removal of track support
|
||||
void remove(List<Track> tracks) {
|
||||
if (!isLoaded) return;
|
||||
final trackIds = tracks.map((e) => e.id!).toSet();
|
||||
final newTracks = state!.tracks.whereNot(
|
||||
(element) => trackIds.contains(element.id),
|
||||
);
|
||||
|
||||
if (newTracks.isEmpty) {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
state = state?.copyWith(
|
||||
tracks: newTracks.toSet(),
|
||||
active: !newTracks.contains(state!.activeTrack)
|
||||
? state!.active > newTracks.length - 1
|
||||
? newTracks.length - 1
|
||||
: state!.active
|
||||
: null,
|
||||
);
|
||||
// if (state!.isLoading) {
|
||||
// play();
|
||||
// }
|
||||
}
|
||||
|
||||
// TODO: Swap sibling support
|
||||
Future<void> swapSibling(Video video) async {
|
||||
if (!isLoaded || state!.isLoading) return;
|
||||
await pause();
|
||||
final tracks = state!.tracks.toList();
|
||||
final track = await (state!.activeTrack as SpotubeTrack)
|
||||
.swappedCopy(video, preferences);
|
||||
if (track == null) return;
|
||||
tracks[state!.active] = track;
|
||||
|
||||
state = state!.copyWith(tracks: Set.from(tracks));
|
||||
// await play();
|
||||
}
|
||||
|
||||
Future<void> populateSibling() async {
|
||||
if (!isLoaded || state!.isLoading) return;
|
||||
final tracks = state!.tracks.toList();
|
||||
final track = await (state!.activeTrack as SpotubeTrack).populatedCopy();
|
||||
tracks[state!.active] = track;
|
||||
state = state!.copyWith(tracks: Set.from(tracks));
|
||||
}
|
||||
|
||||
// Future<void> play() async {
|
||||
// if (!isLoaded) return;
|
||||
// await pause();
|
||||
// await audioServices.addTrack(state!.activeTrack);
|
||||
// if (state!.activeTrack is LocalTrack) {
|
||||
// await audioPlayer.play((state!.activeTrack as LocalTrack).path);
|
||||
// return;
|
||||
// }
|
||||
// if (state!.activeTrack is! SpotubeTrack) {
|
||||
// final tracks = state!.tracks.toList();
|
||||
// tracks[state!.active] = await SpotubeTrack.fetchFromTrack(
|
||||
// state!.activeTrack,
|
||||
// preferences,
|
||||
// );
|
||||
|
||||
// state = state!.copyWith(tracks: Set.from(tracks));
|
||||
// }
|
||||
|
||||
// await audioServices.addTrack(state!.activeTrack);
|
||||
|
||||
// final cached =
|
||||
// await DefaultCacheManager().getFileFromCache(state!.activeTrack.id!);
|
||||
// if (preferences.predownload && cached != null) {
|
||||
// await audioPlayer.play(cached.file.path);
|
||||
// } else {
|
||||
// await audioPlayer.play((state!.activeTrack as SpotubeTrack).ytUri);
|
||||
// }
|
||||
// }
|
||||
|
||||
// TODO: Implement Playtrack
|
||||
Future<void> playTrack(Track track) async {
|
||||
if (!isLoaded) return;
|
||||
final active =
|
||||
state!.tracks.toList().indexWhere((element) => element.id == track.id);
|
||||
if (active == -1) return;
|
||||
state = state!.copyWith(active: active);
|
||||
}
|
||||
|
||||
Future<void> load(Iterable<Track> tracks, {int active = 0}) async {
|
||||
final activeTrack = tracks.elementAt(active);
|
||||
final filtered = Set.from(blacklist.filter(tracks));
|
||||
state = PlaylistQueue(
|
||||
Set.from(blacklist.filter(tracks)),
|
||||
active: filtered
|
||||
.toList()
|
||||
.indexWhere((element) => element.id == activeTrack.id),
|
||||
);
|
||||
|
||||
// load 3 items first to avoid huge initial loading time
|
||||
final firstTracks = await Future.wait(
|
||||
filtered
|
||||
.skip(active == 0 ? 0 : active - 1)
|
||||
.take(3)
|
||||
.mapIndexed((i, track) {
|
||||
if (track is LocalTrack) return Future.value(track.path);
|
||||
if (i == 0) {
|
||||
return SpotubeTrack.fetchFromTrack(track, preferences).then(
|
||||
(s) => s.ytUri,
|
||||
);
|
||||
}
|
||||
// Adding delay to not spoof the YouTube API for IP Block
|
||||
return Future.delayed(
|
||||
const Duration(milliseconds: 100),
|
||||
() => SpotubeTrack.fetchFromTrack(track, preferences).then(
|
||||
(s) => s.ytUri,
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
final localTracks = tracks
|
||||
.where(
|
||||
(element) =>
|
||||
element is LocalTrack && !firstTracks.contains(element.path),
|
||||
)
|
||||
.map((e) => (e as LocalTrack).path);
|
||||
|
||||
await audioPlayer.openPlaylist(
|
||||
[...firstTracks, ...localTracks],
|
||||
autoPlay: false,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> loadAndPlay(Iterable<Track> tracks, {int active = 0}) async {
|
||||
await load(tracks, active: active);
|
||||
await resume();
|
||||
}
|
||||
|
||||
Future<void> pause() {
|
||||
return audioPlayer.pause();
|
||||
}
|
||||
|
||||
Future<void> resume() {
|
||||
return audioPlayer.resume();
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
audioServices.deactivateSession();
|
||||
state = null;
|
||||
|
||||
return audioPlayer.stop();
|
||||
}
|
||||
|
||||
Future<void> next() async {
|
||||
return audioPlayer.skipToNext();
|
||||
}
|
||||
|
||||
Future<void> previous() async {
|
||||
return audioPlayer.skipToPrevious();
|
||||
}
|
||||
|
||||
Future<void> seek(Duration position) async {
|
||||
if (!isLoaded) return;
|
||||
await audioPlayer.seek(position);
|
||||
await resume();
|
||||
}
|
||||
|
||||
Future<void> setShuffle(bool shuffle) async {
|
||||
if (!isLoaded) return;
|
||||
audioPlayer.setShuffle(shuffle);
|
||||
state = state!.copyWith(shuffled: await audioPlayer.isShuffled());
|
||||
}
|
||||
|
||||
Future<void> setLoopMode(PlaybackLoopMode loopMode) async {
|
||||
if (!isLoaded) return;
|
||||
audioPlayer.setLoopMode(loopMode);
|
||||
state = state!.copyWith(loopMode: loopMode);
|
||||
}
|
||||
|
||||
// utility
|
||||
bool isPlayingPlaylist(Iterable<TrackSimple> playlist) {
|
||||
if (!isLoaded || playlist.isEmpty) return false;
|
||||
|
||||
final trackIds = state!.tracks.map((track) => track.id!);
|
||||
return blacklist
|
||||
.filter(playlist)
|
||||
.every((track) => trackIds.contains(track.id!));
|
||||
}
|
||||
|
||||
bool isTrackOnQueue(TrackSimple track) {
|
||||
if (!isLoaded) return false;
|
||||
final trackIds = state!.tracks.map((track) => track.id!);
|
||||
return trackIds.contains(track.id!);
|
||||
}
|
||||
|
||||
void reorder(int oldIndex, int newIndex) {
|
||||
if (!isLoaded) return;
|
||||
|
||||
final tracks = state!.tracks.toList();
|
||||
final track = tracks.removeAt(oldIndex);
|
||||
tracks.insert(newIndex, track);
|
||||
final active =
|
||||
tracks.indexWhere((element) => element.id == state!.activeTrack.id);
|
||||
state = state!.copyWith(tracks: Set.from(tracks), active: active);
|
||||
}
|
||||
|
||||
Future<void> updatePalette() {
|
||||
return Future.microtask(() async {
|
||||
final palette = await PaletteGenerator.fromImageProvider(
|
||||
UniversalImage.imageProvider(
|
||||
TypeConversionUtils.image_X_UrlString(
|
||||
state?.activeTrack.album?.images,
|
||||
placeholder: ImagePlaceholder.albumArt,
|
||||
),
|
||||
height: 50,
|
||||
width: 50,
|
||||
),
|
||||
);
|
||||
ref.read(paletteProvider.notifier).state = palette;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
set state(state) {
|
||||
if (preferences.albumColorSync &&
|
||||
state != null &&
|
||||
state.active != this.state?.active) {
|
||||
updatePalette();
|
||||
} else if (state == null && ref.read(paletteProvider) != null) {
|
||||
ref.read(paletteProvider.notifier).state = null;
|
||||
}
|
||||
super.state = state;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<PlaylistQueue>? fromJson(Map<String, dynamic> json) {
|
||||
if (json.isEmpty) return null;
|
||||
return PlaylistQueue.fromJson(json, preferences);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return state?.toJson() ?? {};
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
audioServices.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class VolumeProvider extends PersistedStateNotifier<double> {
|
||||
VolumeProvider() : super(1, 'volume');
|
||||
|
||||
static final provider = StateNotifierProvider<VolumeProvider, double>((ref) {
|
||||
return VolumeProvider();
|
||||
});
|
||||
|
||||
Future<void> setVolume(double volume) async {
|
||||
if (volume > 1) {
|
||||
state = 1;
|
||||
} else if (volume < 0) {
|
||||
state = 0;
|
||||
} else {
|
||||
state = volume;
|
||||
}
|
||||
await audioPlayer.setVolume(state);
|
||||
await audioPlayer.setVolume(state);
|
||||
}
|
||||
|
||||
void increaseVolume() {
|
||||
setVolume(state + 0.1);
|
||||
}
|
||||
|
||||
void decreaseVolume() {
|
||||
setVolume(state - 0.1);
|
||||
}
|
||||
|
||||
@override
|
||||
double fromJson(Map<String, dynamic> json) {
|
||||
return json['volume'] as double;
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return {'volume': state};
|
||||
}
|
||||
}
|
79
lib/provider/proxy_playlist/next_fetcher_mixin.dart
Normal file
79
lib/provider/proxy_playlist/next_fetcher_mixin.dart
Normal file
@ -0,0 +1,79 @@
|
||||
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/spotube_track.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart';
|
||||
import 'package:spotube/provider/user_preferences_provider.dart';
|
||||
|
||||
mixin NextFetcher on StateNotifier<ProxyPlaylist> {
|
||||
Future<List<SpotubeTrack>> fetchTracks(
|
||||
UserPreferences preferences, {
|
||||
int count = 3,
|
||||
int offset = 0,
|
||||
}) async {
|
||||
/// get [count] [state.tracks] that are not [SpotubeTrack] and [LocalTrack]
|
||||
|
||||
final bareTracks = state.tracks
|
||||
.skip(offset)
|
||||
.where((element) => element is! SpotubeTrack && 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 = SpotubeTrack.fetchFromTrack(track, preferences);
|
||||
if (i == 0) {
|
||||
return await future;
|
||||
}
|
||||
return await Future.delayed(
|
||||
const Duration(milliseconds: 100),
|
||||
() => future,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
return fetchedTracks;
|
||||
}
|
||||
|
||||
/// Merges List of [SpotubeTrack]s with [Track]s and outputs a mixed List
|
||||
Set<Track> mergeTracks(
|
||||
Iterable<SpotubeTrack> fetchTracks,
|
||||
Iterable<Track> 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=');
|
||||
}
|
||||
|
||||
/// Returns [Track.id] from [isUnPlayable] source that is not playable
|
||||
String getIdFromUnPlayable(String source) {
|
||||
return source.replaceFirst('https://youtube.com/unplayable.m4a?id=', '');
|
||||
}
|
||||
|
||||
/// Returns appropriate Media source for [Track]
|
||||
///
|
||||
/// * If [Track] is [SpotubeTrack] then return [SpotubeTrack.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 SpotubeTrack) {
|
||||
return track.ytUri;
|
||||
} else if (track is LocalTrack) {
|
||||
return track.path;
|
||||
} else {
|
||||
return "https://youtube.com/unplayable.m4a?id=${track.id}";
|
||||
}
|
||||
}
|
||||
}
|
74
lib/provider/proxy_playlist/proxy_playlist.dart
Normal file
74
lib/provider/proxy_playlist/proxy_playlist.dart
Normal file
@ -0,0 +1,74 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/extensions/track.dart';
|
||||
import 'package:spotube/models/local_track.dart';
|
||||
import 'package:spotube/models/spotube_track.dart';
|
||||
|
||||
class ProxyPlaylist {
|
||||
final Set<Track> tracks;
|
||||
final int? active;
|
||||
|
||||
ProxyPlaylist(this.tracks, [this.active]);
|
||||
factory ProxyPlaylist.fromJson(Map<String, dynamic> json) {
|
||||
return ProxyPlaylist(
|
||||
(json['tracks'] as List<Map<String, dynamic>>)
|
||||
.map(_makeAppropriateTrack)
|
||||
.toSet(),
|
||||
json['active'] as int,
|
||||
);
|
||||
}
|
||||
|
||||
Track? get activeTrack =>
|
||||
active == null ? null : tracks.elementAtOrNull(active!);
|
||||
|
||||
bool get isFetching =>
|
||||
activeTrack != null &&
|
||||
activeTrack is! SpotubeTrack &&
|
||||
activeTrack is! LocalTrack;
|
||||
|
||||
bool containsTrack(TrackSimple track) {
|
||||
return tracks.firstWhereOrNull((element) => element.id == track.id) != null;
|
||||
}
|
||||
|
||||
bool containsTracks(Iterable<TrackSimple> tracks) {
|
||||
if (tracks.isEmpty) return false;
|
||||
return tracks.every(containsTrack);
|
||||
}
|
||||
|
||||
static Track _makeAppropriateTrack(Map<String, dynamic> track) {
|
||||
if (track.containsKey("ytUri")) {
|
||||
return SpotubeTrack.fromJson(track);
|
||||
} else if (track.containsKey("path")) {
|
||||
return LocalTrack.fromJson(track);
|
||||
} else {
|
||||
return Track.fromJson(track);
|
||||
}
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _makeAppropriateTrackJson(Track track) {
|
||||
if (track is SpotubeTrack) {
|
||||
return track.toJson();
|
||||
} else if (track is LocalTrack) {
|
||||
return track.toJson();
|
||||
} else {
|
||||
return track.toJson();
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'tracks': tracks.map(_makeAppropriateTrackJson).toList(),
|
||||
'active': active,
|
||||
};
|
||||
}
|
||||
|
||||
ProxyPlaylist copyWith({
|
||||
Set<Track>? tracks,
|
||||
int? active,
|
||||
}) {
|
||||
return ProxyPlaylist(
|
||||
tracks ?? this.tracks,
|
||||
active ?? this.active,
|
||||
);
|
||||
}
|
||||
}
|
297
lib/provider/proxy_playlist/proxy_playlist_provider.dart
Normal file
297
lib/provider/proxy_playlist/proxy_playlist_provider.dart
Normal file
@ -0,0 +1,297 @@
|
||||
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/models/local_track.dart';
|
||||
import 'package:spotube/models/spotube_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/proxy_playlist.dart';
|
||||
import 'package:spotube/provider/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/utils/type_conversion_utils.dart';
|
||||
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
||||
|
||||
/// Things to implement:
|
||||
/// * [x] Sponsor-Block skip
|
||||
/// * [x] Prefetch next track as [SpotubeTrack] on 80% of current track
|
||||
/// * [ ] Mixed Queue containing both [SpotubeTrack] and [LocalTrack]
|
||||
/// * [ ] Caching and loading of cache of tracks
|
||||
///
|
||||
/// 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 StateNotifier<ProxyPlaylist>
|
||||
with NextFetcher {
|
||||
final Ref ref;
|
||||
late final AudioServices notificationService;
|
||||
|
||||
UserPreferences get preferences => ref.read(userPreferencesProvider);
|
||||
BlackListNotifier get blacklist =>
|
||||
ref.read(BlackListNotifier.provider.notifier);
|
||||
|
||||
static final provider =
|
||||
StateNotifierProvider<ProxyPlaylistNotifier, ProxyPlaylist>(
|
||||
(ref) => ProxyPlaylistNotifier(ref),
|
||||
);
|
||||
|
||||
static AlwaysAliveRefreshable<ProxyPlaylistNotifier> get notifier =>
|
||||
provider.notifier;
|
||||
|
||||
ProxyPlaylistNotifier(this.ref) : super(ProxyPlaylist({})) {
|
||||
() async {
|
||||
notificationService = await AudioServices.create(ref, this);
|
||||
|
||||
audioPlayer.currentIndexChangedStream.listen((index) async {
|
||||
if (index == -1) return;
|
||||
final track = state.tracks.elementAtOrNull(index);
|
||||
if (track == null) return;
|
||||
notificationService.addTrack(track);
|
||||
state = state.copyWith(active: index);
|
||||
|
||||
if (preferences.albumColorSync) {
|
||||
updatePalette();
|
||||
}
|
||||
});
|
||||
|
||||
bool isPreSearching = false;
|
||||
audioPlayer.percentCompletedStream(80).listen((_) async {
|
||||
if (isPreSearching) return;
|
||||
try {
|
||||
isPreSearching = true;
|
||||
|
||||
// TODO: Make repeat mode sensitive changes later
|
||||
final track =
|
||||
await ensureNthSourcePlayable(audioPlayer.currentIndex + 1);
|
||||
if (track != null) {
|
||||
state = state.copyWith(tracks: mergeTracks([track], state.tracks));
|
||||
}
|
||||
|
||||
/// Sometimes fetching can take a lot of time, so we need to check
|
||||
/// if next source is playable or not at 99% progress. If not, then
|
||||
/// it'll be paused automatically
|
||||
///
|
||||
/// After fetching the nextSource and replacing it, we need to check
|
||||
/// if the player is paused or not. If it is paused, then we need to
|
||||
/// resume it to skip to next track
|
||||
if (audioPlayer.isPaused) {
|
||||
await audioPlayer.resume();
|
||||
}
|
||||
} finally {
|
||||
isPreSearching = false;
|
||||
}
|
||||
});
|
||||
|
||||
// player stops at 99% if nextSource is still not playable
|
||||
audioPlayer.percentCompletedStream(99).listen((_) async {
|
||||
final nextSource =
|
||||
audioPlayer.sources.elementAtOrNull(audioPlayer.currentIndex + 1);
|
||||
if (nextSource == null || !isUnPlayable(nextSource)) return;
|
||||
await audioPlayer.pause();
|
||||
});
|
||||
|
||||
audioPlayer.positionStream.listen((pos) async {
|
||||
if (audioPlayer.currentIndex == -1) return;
|
||||
final activeSource =
|
||||
audioPlayer.sources.elementAtOrNull(audioPlayer.currentIndex);
|
||||
if (activeSource == null) return;
|
||||
final activeTrack = state.tracks.firstWhereOrNull(
|
||||
(element) => element is SpotubeTrack && element.ytUri == activeSource,
|
||||
) as SpotubeTrack?;
|
||||
if (activeTrack == null) return;
|
||||
// skip all the activeTrack.skipSegments
|
||||
if (activeTrack.skipSegments.isNotEmpty == true &&
|
||||
preferences.skipSponsorSegments) {
|
||||
for (final segment in activeTrack.skipSegments) {
|
||||
if (pos.inSeconds < segment["start"]! ||
|
||||
pos.inSeconds > segment["end"]!) continue;
|
||||
await audioPlayer.pause();
|
||||
await audioPlayer.seek(Duration(seconds: segment["end"]!));
|
||||
}
|
||||
}
|
||||
});
|
||||
}();
|
||||
}
|
||||
|
||||
Future<SpotubeTrack?> ensureNthSourcePlayable(int n) async {
|
||||
final sources = audioPlayer.sources;
|
||||
if (n < 0 || n > sources.length - 1) return null;
|
||||
final nthSource = sources.elementAtOrNull(n);
|
||||
if (nthSource == null || !isUnPlayable(nthSource)) return null;
|
||||
|
||||
final nthTrack = state.tracks.firstWhereOrNull(
|
||||
(element) => element.id == getIdFromUnPlayable(nthSource),
|
||||
);
|
||||
if (nthTrack == null ||
|
||||
nthTrack is SpotubeTrack ||
|
||||
nthTrack is LocalTrack) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final nthFetchedTrack =
|
||||
await SpotubeTrack.fetchFromTrack(nthTrack, preferences);
|
||||
|
||||
await audioPlayer.replaceSource(
|
||||
nthSource,
|
||||
nthFetchedTrack.ytUri,
|
||||
);
|
||||
|
||||
return nthFetchedTrack;
|
||||
}
|
||||
|
||||
// Basic methods for adding or removing tracks to playlist
|
||||
|
||||
Future<void> addTrack(Track track) async {
|
||||
if (blacklist.contains(track)) return;
|
||||
state = state.copyWith(tracks: {...state.tracks, track});
|
||||
await audioPlayer.addTrack(makeAppropriateSource(track));
|
||||
}
|
||||
|
||||
Future<void> addTracks(Iterable<Track> tracks) async {
|
||||
tracks = blacklist.filter(tracks).toList() as List<Track>;
|
||||
state = state.copyWith(tracks: {...state.tracks, ...tracks});
|
||||
for (final track in tracks) {
|
||||
await audioPlayer.addTrack(makeAppropriateSource(track));
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Safely Remove playing tracks
|
||||
|
||||
void removeTrack(String trackId) {
|
||||
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;
|
||||
audioPlayer.removeTrack(index);
|
||||
}
|
||||
|
||||
void removeTracks(Iterable<String> tracksIds) {
|
||||
final tracks =
|
||||
state.tracks.where((element) => tracksIds.contains(element.id));
|
||||
|
||||
state = state.copyWith(tracks: {
|
||||
...state.tracks..removeWhere((element) => tracksIds.contains(element.id))
|
||||
});
|
||||
|
||||
for (final track in tracks) {
|
||||
final index = audioPlayer.sources.indexOf(makeAppropriateSource(track));
|
||||
if (index == -1) continue;
|
||||
audioPlayer.removeTrack(index);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> load(
|
||||
List<Track> tracks, {
|
||||
int initialIndex = 0,
|
||||
bool autoPlay = false,
|
||||
}) async {
|
||||
tracks = blacklist.filter(tracks).toList() as List<Track>;
|
||||
final addableTrack =
|
||||
await SpotubeTrack.fetchFromTrack(tracks[initialIndex], preferences);
|
||||
|
||||
state = state.copyWith(
|
||||
tracks: mergeTracks([addableTrack], tracks), active: initialIndex);
|
||||
|
||||
await audioPlayer.openPlaylist(
|
||||
state.tracks.map(makeAppropriateSource).toList(),
|
||||
initialIndex: initialIndex,
|
||||
autoPlay: autoPlay,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> jumpTo(int index) async {
|
||||
final track = await ensureNthSourcePlayable(index);
|
||||
if (track != null) {
|
||||
state = state.copyWith(tracks: mergeTracks([track], state.tracks));
|
||||
}
|
||||
await audioPlayer.jumpTo(index);
|
||||
}
|
||||
|
||||
Future<void> jumpToTrack(Track track) async {
|
||||
final index =
|
||||
state.tracks.toList().indexWhere((element) => element.id == track.id);
|
||||
if (index == -1) return;
|
||||
await jumpTo(index);
|
||||
}
|
||||
|
||||
// TODO: add safe guards for active/playing track that needs to be moved
|
||||
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;
|
||||
|
||||
final tracks = state.tracks.toList();
|
||||
final track = tracks.removeAt(oldIndex);
|
||||
tracks.insert(newIndex, track);
|
||||
state = state.copyWith(tracks: {...tracks});
|
||||
|
||||
await audioPlayer.moveTrack(oldIndex, newIndex);
|
||||
}
|
||||
|
||||
Future<void> addTracksAtFirst(Iterable<Track> track) async {}
|
||||
Future<void> populateSibling() async {}
|
||||
Future<void> swapSibling(Video video) async {}
|
||||
|
||||
Future<void> next() async {
|
||||
final track = await ensureNthSourcePlayable(audioPlayer.currentIndex + 1);
|
||||
if (track != null) {
|
||||
state = state.copyWith(tracks: mergeTracks([track], state.tracks));
|
||||
}
|
||||
await audioPlayer.skipToNext();
|
||||
}
|
||||
|
||||
Future<void> previous() async {
|
||||
final track = await ensureNthSourcePlayable(audioPlayer.currentIndex - 1);
|
||||
if (track != null) {
|
||||
state = state.copyWith(tracks: mergeTracks([track], state.tracks));
|
||||
}
|
||||
await audioPlayer.skipToPrevious();
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
state = ProxyPlaylist({});
|
||||
await audioPlayer.stop();
|
||||
}
|
||||
|
||||
Future<void> updatePalette() {
|
||||
return Future.microtask(() async {
|
||||
final activeTrack = state.tracks.firstWhereOrNull(
|
||||
(track) =>
|
||||
track is SpotubeTrack &&
|
||||
track.ytUri ==
|
||||
audioPlayer.sources.elementAtOrNull(audioPlayer.currentIndex),
|
||||
);
|
||||
|
||||
if (activeTrack == null) return;
|
||||
|
||||
final palette = await PaletteGenerator.fromImageProvider(
|
||||
UniversalImage.imageProvider(
|
||||
TypeConversionUtils.image_X_UrlString(
|
||||
activeTrack.album?.images,
|
||||
placeholder: ImagePlaceholder.albumArt,
|
||||
),
|
||||
height: 50,
|
||||
width: 50,
|
||||
),
|
||||
);
|
||||
ref.read(paletteProvider.notifier).state = palette;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
set state(state) {
|
||||
super.state = state;
|
||||
if (state.tracks.isEmpty && ref.read(paletteProvider) != null) {
|
||||
ref.read(paletteProvider.notifier).state = null;
|
||||
}
|
||||
}
|
||||
}
|
@ -6,7 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotube/components/settings/color_scheme_picker_dialog.dart';
|
||||
import 'package:spotube/provider/palette_provider.dart';
|
||||
import 'package:spotube/provider/playlist_queue_provider.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
|
||||
import 'package:spotube/utils/persisted_change_notifier.dart';
|
||||
import 'package:spotube/utils/platform.dart';
|
||||
@ -114,7 +114,7 @@ class UserPreferences extends PersistedChangeNotifier {
|
||||
if (!sync) {
|
||||
ref.read(paletteProvider.notifier).state = null;
|
||||
} else {
|
||||
ref.read(PlaylistQueueNotifier.notifier).updatePalette();
|
||||
ref.read(ProxyPlaylistNotifier.notifier).updatePalette();
|
||||
}
|
||||
notifyListeners();
|
||||
updatePersistence();
|
||||
|
@ -63,15 +63,15 @@ class SpotubeAudioPlayer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Stream that emits when the player is almost (80%) complete
|
||||
Stream<void> get almostCompleteStream {
|
||||
/// Stream that emits when the player is almost (%) complete
|
||||
Stream<void> percentCompletedStream(double percent) {
|
||||
return positionStream
|
||||
.asyncMap((event) async => [event, await duration])
|
||||
.where((event) {
|
||||
final position = event[0] as Duration;
|
||||
final duration = event[1] as Duration;
|
||||
|
||||
return position.inSeconds > (duration.inSeconds * .8).toInt();
|
||||
return position.inSeconds > duration.inSeconds * percent / 100;
|
||||
}).asBroadcastStream();
|
||||
}
|
||||
|
||||
@ -83,6 +83,36 @@ class SpotubeAudioPlayer {
|
||||
}
|
||||
}
|
||||
|
||||
Stream<bool> get shuffledStream {
|
||||
if (mkSupportedPlatform) {
|
||||
return _mkPlayer!.shuffleStream.asBroadcastStream();
|
||||
} else {
|
||||
return _justAudio!.shuffleModeEnabledStream.asBroadcastStream();
|
||||
}
|
||||
}
|
||||
|
||||
Stream<PlaybackLoopMode> get loopModeStream {
|
||||
if (mkSupportedPlatform) {
|
||||
return _mkPlayer!.loopModeStream
|
||||
.map(PlaybackLoopMode.fromPlaylistMode)
|
||||
.asBroadcastStream();
|
||||
} else {
|
||||
return _justAudio!.loopModeStream
|
||||
.map(PlaybackLoopMode.fromLoopMode)
|
||||
.asBroadcastStream();
|
||||
}
|
||||
}
|
||||
|
||||
Stream<double> get volumeStream {
|
||||
if (mkSupportedPlatform) {
|
||||
return _mkPlayer!.streams.volume
|
||||
.map((event) => event / 100)
|
||||
.asBroadcastStream();
|
||||
} else {
|
||||
return _justAudio!.volumeStream.asBroadcastStream();
|
||||
}
|
||||
}
|
||||
|
||||
Stream<bool> get bufferingStream {
|
||||
if (mkSupportedPlatform) {
|
||||
return Stream.value(false).asBroadcastStream();
|
||||
@ -191,6 +221,30 @@ class SpotubeAudioPlayer {
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> get isShuffled async {
|
||||
if (mkSupportedPlatform) {
|
||||
return _mkPlayer!.shuffled;
|
||||
} else {
|
||||
return _justAudio!.shuffleModeEnabled;
|
||||
}
|
||||
}
|
||||
|
||||
Future<PlaybackLoopMode> get loopMode async {
|
||||
if (mkSupportedPlatform) {
|
||||
return PlaybackLoopMode.fromPlaylistMode(_mkPlayer!.loopMode);
|
||||
} else {
|
||||
return PlaybackLoopMode.fromLoopMode(_justAudio!.loopMode);
|
||||
}
|
||||
}
|
||||
|
||||
double get volume {
|
||||
if (mkSupportedPlatform) {
|
||||
return _mkPlayer!.state.volume / 100;
|
||||
} else {
|
||||
return _justAudio!.volume;
|
||||
}
|
||||
}
|
||||
|
||||
bool get isBuffering {
|
||||
if (mkSupportedPlatform) {
|
||||
// audioplayers doesn't have the capability to get buffering state
|
||||
@ -257,9 +311,7 @@ class SpotubeAudioPlayer {
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
_mkLooped = PlaybackLoopMode.none;
|
||||
_mkShuffled = false;
|
||||
await _mkPlayer?.pause();
|
||||
await _mkPlayer?.stop();
|
||||
await _justAudio?.stop();
|
||||
}
|
||||
|
||||
@ -295,7 +347,7 @@ class SpotubeAudioPlayer {
|
||||
if (mkSupportedPlatform) {
|
||||
await _mkPlayer!.open(
|
||||
mk.Playlist(
|
||||
tracks.map((e) => mk.Media(e)).toList(),
|
||||
tracks.map(mk.Media.new).toList(),
|
||||
index: initialIndex,
|
||||
),
|
||||
play: autoPlay,
|
||||
@ -359,7 +411,7 @@ class SpotubeAudioPlayer {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> skipToIndex(int index) async {
|
||||
Future<void> jumpTo(int index) async {
|
||||
if (mkSupportedPlatform) {
|
||||
await _mkPlayer!.jump(index);
|
||||
} else {
|
||||
@ -395,6 +447,39 @@ class SpotubeAudioPlayer {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> replaceSource(String oldSource, String newSource) async {
|
||||
final willBeReplacedIndex = sources.indexOf(oldSource);
|
||||
if (willBeReplacedIndex == -1) return;
|
||||
|
||||
if (mkSupportedPlatform) {
|
||||
final sourcesCp = sources.toList();
|
||||
sourcesCp[willBeReplacedIndex] = newSource;
|
||||
await _mkPlayer!.open(
|
||||
mk.Playlist(
|
||||
sourcesCp.map(mk.Media.new).toList(),
|
||||
index: currentIndex,
|
||||
),
|
||||
play: false,
|
||||
);
|
||||
} else {
|
||||
await addTrack(newSource);
|
||||
await removeTrack(willBeReplacedIndex);
|
||||
|
||||
int newSourceIndex = sources.indexOf(newSource);
|
||||
while (newSourceIndex == -1) {
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
newSourceIndex = sources.indexOf(newSource);
|
||||
}
|
||||
await moveTrack(newSourceIndex, willBeReplacedIndex);
|
||||
newSourceIndex = sources.indexOf(newSource);
|
||||
while (newSourceIndex != willBeReplacedIndex) {
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
await moveTrack(newSourceIndex, willBeReplacedIndex);
|
||||
newSourceIndex = sources.indexOf(newSource);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clearPlaylist() async {
|
||||
if (mkSupportedPlatform) {
|
||||
await Future.wait(
|
||||
@ -407,41 +492,19 @@ class SpotubeAudioPlayer {
|
||||
}
|
||||
}
|
||||
|
||||
bool _mkShuffled = false;
|
||||
|
||||
Future<void> setShuffle(bool shuffle) async {
|
||||
if (mkSupportedPlatform) {
|
||||
await _mkPlayer!.setShuffle(shuffle);
|
||||
_mkShuffled = shuffle;
|
||||
} else {
|
||||
await _justAudio!.setShuffleModeEnabled(shuffle);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> isShuffled() async {
|
||||
if (mkSupportedPlatform) {
|
||||
return _mkShuffled;
|
||||
} else {
|
||||
return _justAudio!.shuffleModeEnabled;
|
||||
}
|
||||
}
|
||||
|
||||
PlaybackLoopMode _mkLooped = PlaybackLoopMode.none;
|
||||
|
||||
Future<void> setLoopMode(PlaybackLoopMode loop) async {
|
||||
if (mkSupportedPlatform) {
|
||||
await _mkPlayer!.setPlaylistMode(loop.toPlaylistMode());
|
||||
_mkLooped = loop;
|
||||
} else {
|
||||
await _justAudio!.setLoopMode(loop.toLoopMode());
|
||||
}
|
||||
}
|
||||
|
||||
Future<PlaybackLoopMode> getLoopMode() async {
|
||||
if (mkSupportedPlatform) {
|
||||
return _mkLooped;
|
||||
} else {
|
||||
return PlaybackLoopMode.fromLoopMode(_justAudio!.loopMode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -7,11 +7,20 @@ import 'package:spotube/services/audio_player/playback_state.dart';
|
||||
/// This class adds a state stream to the [Player] class.
|
||||
class MkPlayerWithState extends Player {
|
||||
final StreamController<AudioPlaybackState> _playerStateStream;
|
||||
final StreamController<bool> _shuffleStream;
|
||||
final StreamController<PlaylistMode> _loopModeStream;
|
||||
|
||||
late final List<StreamSubscription> _subscriptions;
|
||||
|
||||
bool _shuffled;
|
||||
PlaylistMode _loopMode;
|
||||
|
||||
MkPlayerWithState({super.configuration})
|
||||
: _playerStateStream = StreamController.broadcast() {
|
||||
: _playerStateStream = StreamController.broadcast(),
|
||||
_shuffleStream = StreamController.broadcast(),
|
||||
_loopModeStream = StreamController.broadcast(),
|
||||
_shuffled = false,
|
||||
_loopMode = PlaylistMode.none {
|
||||
_subscriptions = [
|
||||
streams.buffering.listen((event) {
|
||||
_playerStateStream.add(AudioPlaybackState.buffering);
|
||||
@ -34,7 +43,35 @@ class MkPlayerWithState extends Player {
|
||||
];
|
||||
}
|
||||
|
||||
bool get shuffled => _shuffled;
|
||||
PlaylistMode get loopMode => _loopMode;
|
||||
|
||||
Stream<AudioPlaybackState> get playerStateStream => _playerStateStream.stream;
|
||||
Stream<bool> get shuffleStream => _shuffleStream.stream;
|
||||
Stream<PlaylistMode> get loopModeStream => _loopModeStream.stream;
|
||||
|
||||
@override
|
||||
Future<void> setShuffle(bool shuffle) async {
|
||||
_shuffled = shuffle;
|
||||
await super.setShuffle(shuffle);
|
||||
_shuffleStream.add(shuffle);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setPlaylistMode(PlaylistMode playlistMode) async {
|
||||
_loopMode = playlistMode;
|
||||
await super.setPlaylistMode(playlistMode);
|
||||
_loopModeStream.add(playlistMode);
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
pause();
|
||||
_loopMode = PlaylistMode.none;
|
||||
_shuffled = false;
|
||||
for (int i = 0; i < state.playlist.medias.length; i++) {
|
||||
await remove(i);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
FutureOr<void> dispose({int code = 0}) {
|
||||
|
@ -3,7 +3,7 @@ import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/models/spotube_track.dart';
|
||||
import 'package:spotube/provider/playlist_queue_provider.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/services/audio_services/linux_audio_service.dart';
|
||||
import 'package:spotube/services/audio_services/mobile_audio_service.dart';
|
||||
import 'package:spotube/services/audio_services/windows_audio_service.dart';
|
||||
@ -18,15 +18,12 @@ class AudioServices {
|
||||
|
||||
static Future<AudioServices> create(
|
||||
Ref ref,
|
||||
PlaylistQueueNotifier playlistQueueNotifier,
|
||||
ProxyPlaylistNotifier playback,
|
||||
) async {
|
||||
final mobile =
|
||||
DesktopTools.platform.isMobile || DesktopTools.platform.isMacOS
|
||||
? await AudioService.init(
|
||||
builder: () => MobileAudioService(
|
||||
playlistQueueNotifier,
|
||||
ref.read(VolumeProvider.provider.notifier),
|
||||
),
|
||||
builder: () => MobileAudioService(playback),
|
||||
config: const AudioServiceConfig(
|
||||
androidNotificationChannelId: 'com.krtirtho.Spotube',
|
||||
androidNotificationChannelName: 'Spotube',
|
||||
@ -35,11 +32,10 @@ class AudioServices {
|
||||
)
|
||||
: null;
|
||||
final smtc = DesktopTools.platform.isWindows
|
||||
? WindowsAudioService(ref, playlistQueueNotifier)
|
||||
: null;
|
||||
final mpris = DesktopTools.platform.isLinux
|
||||
? LinuxAudioService(ref, playlistQueueNotifier)
|
||||
? WindowsAudioService(ref, playback)
|
||||
: null;
|
||||
final mpris =
|
||||
DesktopTools.platform.isLinux ? LinuxAudioService(ref, playback) : null;
|
||||
|
||||
return AudioServices(mobile, smtc, mpris);
|
||||
}
|
||||
|
@ -5,7 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:mpris_service/mpris_service.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/models/spotube_track.dart';
|
||||
import 'package:spotube/provider/playlist_queue_provider.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/services/audio_player/audio_player.dart';
|
||||
import 'package:spotube/services/audio_player/loop_mode.dart';
|
||||
import 'package:spotube/services/audio_player/playback_state.dart';
|
||||
@ -14,7 +14,7 @@ import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
class LinuxAudioService {
|
||||
late final MPRIS mpris;
|
||||
final Ref ref;
|
||||
final PlaylistQueueNotifier playlistNotifier;
|
||||
final ProxyPlaylistNotifier playlistNotifier;
|
||||
|
||||
final subscriptions = <StreamSubscription>[];
|
||||
|
||||
@ -30,26 +30,24 @@ class LinuxAudioService {
|
||||
mpris.playbackStatus = MPRISPlaybackStatus.stopped;
|
||||
mpris.setEventHandler(MPRISEventHandler(
|
||||
loopStatus: (value) async {
|
||||
playlistNotifier.setLoopMode(
|
||||
audioPlayer.setLoopMode(
|
||||
PlaybackLoopMode.fromMPRISLoopStatus(value),
|
||||
);
|
||||
},
|
||||
next: playlistNotifier.next,
|
||||
pause: playlistNotifier.pause,
|
||||
play: playlistNotifier.resume,
|
||||
pause: audioPlayer.pause,
|
||||
play: audioPlayer.resume,
|
||||
playPause: () async {
|
||||
if (audioPlayer.isPlaying) {
|
||||
await playlistNotifier.pause();
|
||||
await audioPlayer.pause();
|
||||
} else {
|
||||
await playlistNotifier.resume();
|
||||
await audioPlayer.resume();
|
||||
}
|
||||
},
|
||||
seek: playlistNotifier.seek,
|
||||
shuffle: playlistNotifier.setShuffle,
|
||||
seek: audioPlayer.seek,
|
||||
shuffle: audioPlayer.setShuffle,
|
||||
stop: playlistNotifier.stop,
|
||||
volume: (value) async {
|
||||
await ref.read(VolumeProvider.provider.notifier).setVolume(value);
|
||||
},
|
||||
volume: audioPlayer.setVolume,
|
||||
previous: playlistNotifier.previous,
|
||||
));
|
||||
|
||||
|
@ -2,29 +2,29 @@ import 'dart:async';
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:audio_session/audio_session.dart';
|
||||
import 'package:spotube/provider/playlist_queue_provider.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/services/audio_player/audio_player.dart';
|
||||
import 'package:spotube/services/audio_player/loop_mode.dart';
|
||||
|
||||
class MobileAudioService extends BaseAudioHandler {
|
||||
AudioSession? session;
|
||||
final PlaylistQueueNotifier playlistNotifier;
|
||||
final VolumeProvider volumeNotifier;
|
||||
final ProxyPlaylistNotifier playlistNotifier;
|
||||
|
||||
PlaylistQueue? get playlist => playlistNotifier.state;
|
||||
ProxyPlaylist get playlist => playlistNotifier.state;
|
||||
|
||||
MobileAudioService(this.playlistNotifier, this.volumeNotifier) {
|
||||
MobileAudioService(this.playlistNotifier) {
|
||||
AudioSession.instance.then((s) {
|
||||
session = s;
|
||||
session?.configure(const AudioSessionConfiguration.music());
|
||||
s.interruptionEventStream.listen((event) async {
|
||||
switch (event.type) {
|
||||
case AudioInterruptionType.duck:
|
||||
await volumeNotifier.setVolume(event.begin ? 0.5 : 1.0);
|
||||
await audioPlayer.setVolume(event.begin ? 0.5 : 1.0);
|
||||
break;
|
||||
case AudioInterruptionType.pause:
|
||||
case AudioInterruptionType.unknown:
|
||||
await playlistNotifier.pause();
|
||||
await audioPlayer.pause();
|
||||
break;
|
||||
}
|
||||
});
|
||||
@ -47,25 +47,25 @@ class MobileAudioService extends BaseAudioHandler {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> play() => playlistNotifier.resume();
|
||||
Future<void> play() => audioPlayer.resume();
|
||||
|
||||
@override
|
||||
Future<void> pause() => playlistNotifier.pause();
|
||||
Future<void> pause() => audioPlayer.pause();
|
||||
|
||||
@override
|
||||
Future<void> seek(Duration position) => playlistNotifier.seek(position);
|
||||
Future<void> seek(Duration position) => audioPlayer.seek(position);
|
||||
|
||||
@override
|
||||
Future<void> setShuffleMode(AudioServiceShuffleMode shuffleMode) async {
|
||||
await super.setShuffleMode(shuffleMode);
|
||||
|
||||
playlistNotifier.setShuffle(shuffleMode == AudioServiceShuffleMode.all);
|
||||
audioPlayer.setShuffle(shuffleMode == AudioServiceShuffleMode.all);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setRepeatMode(AudioServiceRepeatMode repeatMode) async {
|
||||
super.setRepeatMode(repeatMode);
|
||||
playlistNotifier.setLoopMode(
|
||||
audioPlayer.setLoopMode(
|
||||
PlaybackLoopMode.fromAudioServiceRepeatMode(repeatMode),
|
||||
);
|
||||
}
|
||||
@ -109,12 +109,11 @@ class MobileAudioService extends BaseAudioHandler {
|
||||
playing: audioPlayer.isPlaying,
|
||||
updatePosition: position,
|
||||
bufferedPosition: await audioPlayer.bufferedPosition ?? Duration.zero,
|
||||
shuffleMode: playlist?.shuffled == true
|
||||
shuffleMode: await audioPlayer.isShuffled == true
|
||||
? AudioServiceShuffleMode.all
|
||||
: AudioServiceShuffleMode.none,
|
||||
repeatMode: playlist?.loopMode.toAudioServiceRepeatMode() ??
|
||||
AudioServiceRepeatMode.none,
|
||||
processingState: playlist?.isLoading == true
|
||||
repeatMode: (await audioPlayer.loopMode).toAudioServiceRepeatMode(),
|
||||
processingState: playlist.isFetching == true
|
||||
? AudioProcessingState.loading
|
||||
: AudioProcessingState.ready,
|
||||
);
|
||||
|
@ -3,7 +3,7 @@ import 'dart:async';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:smtc_windows/smtc_windows.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/provider/playlist_queue_provider.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/services/audio_player/audio_player.dart';
|
||||
import 'package:spotube/services/audio_player/playback_state.dart';
|
||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
@ -11,7 +11,7 @@ import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
class WindowsAudioService {
|
||||
final SMTCWindows smtc;
|
||||
final Ref ref;
|
||||
final PlaylistQueueNotifier playlistNotifier;
|
||||
final ProxyPlaylistNotifier playlistNotifier;
|
||||
|
||||
final subscriptions = <StreamSubscription>[];
|
||||
|
||||
@ -21,10 +21,10 @@ class WindowsAudioService {
|
||||
final buttonStream = smtc.buttonPressStream.listen((event) {
|
||||
switch (event) {
|
||||
case PressedButton.play:
|
||||
playlistNotifier.resume();
|
||||
audioPlayer.resume();
|
||||
break;
|
||||
case PressedButton.pause:
|
||||
playlistNotifier.pause();
|
||||
audioPlayer.pause();
|
||||
break;
|
||||
case PressedButton.next:
|
||||
playlistNotifier.next();
|
||||
|
@ -58,8 +58,8 @@ dependencies:
|
||||
just_audio: ^0.9.32
|
||||
logger: ^1.1.0
|
||||
media_kit: ^0.0.7+1
|
||||
media_kit_libs_linux: ^1.0.2
|
||||
media_kit_libs_windows_audio: ^1.0.3
|
||||
media_kit_libs_linux: ^1.0.2
|
||||
media_kit_native_event_loop: ^1.0.3
|
||||
metadata_god: ^0.4.1
|
||||
mime: ^1.0.2
|
||||
|
Loading…
Reference in New Issue
Block a user