refactor(playback): migration to ProxyPlaylist based playback

This commit is contained in:
Kingkor Roy Tirtho 2023-05-13 12:46:56 +06:00
parent 3ba3df7265
commit 5f70207076
37 changed files with 915 additions and 922 deletions

View File

@ -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();
} else {
// TODO: Implement play on start
// await playlistNotifier.play();
}
if (!audioPlayer.isPlaying) {
await audioPlayer.resume();
} 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,
),

View File

@ -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
?.map((e) =>
TypeConversionUtils.simpleTrack_X_Track(e, album))
.toList() ??
[]);
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!));
},
),
);

View File

@ -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,
);

View File

@ -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 ?? [])
],
);

View File

@ -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
? context.l10n.unshuffle_playlist
: context.l10n.shuffle_playlist,
icon: const Icon(SpotubeIcons.shuffle),
style: playlist?.shuffled == true
? activeButtonStyle
: buttonStyle,
onPressed: playlist == null || playlist.isLoading
? null
: () {
if (playlist.shuffled == true) {
playlistNotifier.setShuffle(false);
} else {
playlistNotifier.setShuffle(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: shuffled ? activeButtonStyle : buttonStyle,
onPressed: playlist.isFetching
? null
: () {
if (shuffled) {
audioPlayer.setShuffle(false);
} else {
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
? context.l10n.loop_track
: context.l10n.repeat_playlist,
icon: Icon(
playlist?.loopMode == PlaybackLoopMode.one
? SpotubeIcons.repeatOne
: SpotubeIcons.repeat,
),
style: playlist?.loopMode == PlaybackLoopMode.one
? activeButtonStyle
: buttonStyle,
onPressed: playlist == null || playlist.isLoading
? null
: () {
if (playlist.loopMode == PlaybackLoopMode.one) {
playlistNotifier
.setLoopMode(PlaybackLoopMode.all);
} else {
playlistNotifier
.setLoopMode(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(
loopMode == PlaybackLoopMode.one
? SpotubeIcons.repeatOne
: SpotubeIcons.repeat,
),
style: loopMode == PlaybackLoopMode.one
? activeButtonStyle
: buttonStyle,
onPressed: playlist.isFetching
? null
: () {
if (loopMode == PlaybackLoopMode.one) {
audioPlayer
.setLoopMode(PlaybackLoopMode.all);
} else {
audioPlayer
.setLoopMode(PlaybackLoopMode.one);
}
},
);
}),
],
),
const SizedBox(height: 5)

View File

@ -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,

View File

@ -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(

View File

@ -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 ?? [],
)
],
),

View File

@ -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) {

View File

@ -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!));
},
),
);

View File

@ -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,
),
);
}),

View File

@ -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),

View File

@ -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,

View File

@ -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();
},

View File

@ -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;

View File

@ -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();
}
}
},

View File

@ -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(),

View File

@ -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();

View File

@ -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,

View File

@ -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,
),

View File

@ -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,

View File

@ -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();

View File

@ -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();
}
}
},

View File

@ -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,
);
}
}
},

View File

@ -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) {

View File

@ -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};
}
}

View 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}";
}
}
}

View 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,
);
}
}

View 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;
}
}
}

View File

@ -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();

View File

@ -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);
}
}
}

View File

@ -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}) {

View File

@ -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);
}

View File

@ -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,
));

View File

@ -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,
);

View File

@ -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();

View File

@ -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