From 312f7fbe779ea6760677215a2d01bac0eff521f8 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 2 Feb 2023 18:43:12 +0600 Subject: [PATCH] refactor(playback): new immutable queue based playback manager Dropping support for search format, track match algorithm in favor of server track cache and alternative track source --- android/app/build.gradle | 4 +- android/build.gradle | 2 +- .../gradle/wrapper/gradle-wrapper.properties | 2 +- lib/collections/intents.dart | 42 +- lib/components/album/album_card.dart | 31 +- lib/components/library/user_local_tracks.dart | 71 +- lib/components/player/player_actions.dart | 36 +- lib/components/player/player_controls.dart | 219 +++--- lib/components/player/player_overlay.dart | 33 +- lib/components/player/player_queue.dart | 32 +- .../player/player_track_details.dart | 10 +- .../player/sibling_tracks_sheet.dart | 40 +- lib/components/playlist/playlist_card.dart | 42 +- lib/components/root/bottom_player.dart | 31 +- .../shared/track_table/track_tile.dart | 9 +- .../shared/track_table/tracks_table_view.dart | 8 +- lib/extensions/video.dart | 47 ++ lib/hooks/playback_hooks.dart | 20 - lib/hooks/use_synced_lyrics.dart | 7 +- lib/main.dart | 37 +- lib/models/local_track.dart | 60 ++ lib/models/spotube_track.dart | 173 ++++- lib/pages/album/album.dart | 38 +- lib/pages/artist/artist.dart | 37 +- lib/pages/library/library.dart | 2 +- lib/pages/lyrics/genius_lyrics.dart | 21 +- lib/pages/lyrics/lyrics.dart | 10 +- lib/pages/lyrics/synced_lyrics.dart | 241 +++--- lib/pages/player/player.dart | 11 +- lib/pages/playlist/playlist.dart | 49 +- lib/pages/search/search.dart | 43 +- lib/pages/settings/settings.dart | 1 - lib/provider/blacklist_provider.dart | 17 +- lib/provider/downloader_provider.dart | 21 +- lib/provider/playback_provider.dart | 696 ------------------ lib/provider/playlist_queue_provider.dart | 427 +++++++++++ lib/provider/user_preferences_provider.dart | 6 +- lib/services/linux_audio_service.dart | 97 +-- lib/services/mobile_audio_service.dart | 52 +- lib/utils/persisted_state_notifier.dart | 66 +- lib/utils/primitive_utils.dart | 16 + lib/utils/type_conversion_utils.dart | 1 + pubspec.lock | 4 +- pubspec.yaml | 2 + 44 files changed, 1398 insertions(+), 1416 deletions(-) delete mode 100644 lib/hooks/playback_hooks.dart create mode 100644 lib/models/local_track.dart delete mode 100644 lib/provider/playback_provider.dart create mode 100644 lib/provider/playlist_queue_provider.dart diff --git a/android/app/build.gradle b/android/app/build.gradle index b767c01c..1d519014 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -77,6 +77,6 @@ flutter { } dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" - implementation 'com.android.support:multidex:1.0.3' + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" + implementation 'com.android.support:multidex:2.0.1' } diff --git a/android/build.gradle b/android/build.gradle index a4d7066f..f820e957 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,5 +1,5 @@ buildscript { - ext.kotlin_version = '1.6.10' + ext.kotlin_version = '1.7.21' repositories { google() mavenCentral() diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 562c5e44..02e5f581 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-all.zip diff --git a/lib/collections/intents.dart b/lib/collections/intents.dart index 42e5ace7..ad60a7fa 100644 --- a/lib/collections/intents.dart +++ b/lib/collections/intents.dart @@ -2,11 +2,10 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import 'package:spotify/spotify.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/playback_provider.dart'; +import 'package:spotube/provider/playlist_queue_provider.dart'; import 'package:spotube/utils/platform.dart'; import 'package:window_manager/window_manager.dart'; @@ -23,23 +22,22 @@ class PlayPauseAction extends Action { if (PlayerControls.focusNode.canRequestFocus) { PlayerControls.focusNode.requestFocus(); } - final playback = intent.ref.read(playbackProvider); - if (playback.track == null) { + final playlist = intent.ref.read(PlaylistQueueNotifier.provider); + final playlistNotifier = intent.ref.read(PlaylistQueueNotifier.notifier); + if (playlist == null) { return null; - } else if (playback.track != null && - playback.currentDuration == Duration.zero && - await playback.player.getCurrentPosition() == Duration.zero) { - if (playback.track!.ytUri.startsWith("http")) { - final track = Track.fromJson(playback.track!.toJson()); - playback.track = null; - await playback.play(track); - } else { - final track = playback.track; - playback.track = null; - await playback.play(track!); - } + } else if (!PlaylistQueueNotifier.isPlaying) { + // if (playlist.activeTrack is SpotubeTrack && + // (playlist.activeTrack as SpotubeTrack).ytUri.startsWith("http")) { + // final track = + // Track.fromJson((playlist.activeTrack as SpotubeTrack).toJson()); + + // await playlistNotifier.play(track); + // } else { + // } + await playlistNotifier.play(); } else { - await playback.togglePlayPause(); + await playlistNotifier.pause(); } return null; } @@ -102,9 +100,9 @@ class SeekIntent extends Intent { class SeekAction extends Action { @override invoke(intent) async { - final playback = intent.ref.read(playbackProvider); - if ((playback.playlist == null && playback.track == null) || - playback.status == PlaybackStatus.loading) { + final playlist = intent.ref.read(PlaylistQueueNotifier.provider); + final playlistNotifier = intent.ref.read(PlaylistQueueNotifier.notifier); + if (playlist == null || playlist.isLoading) { DirectionalFocusAction().invoke( DirectionalFocusIntent( intent.forward ? TraversalDirection.right : TraversalDirection.left, @@ -113,8 +111,8 @@ class SeekAction extends Action { return null; } final position = - (await playback.player.getCurrentPosition() ?? Duration.zero).inSeconds; - await playback.seekPosition( + (await audioPlayer.getCurrentPosition() ?? Duration.zero).inSeconds; + await playlistNotifier.seek( Duration( seconds: intent.forward ? position + 5 : position - 5, ), diff --git a/lib/components/album/album_card.dart b/lib/components/album/album_card.dart index 5c9f5537..14da1354 100644 --- a/lib/components/album/album_card.dart +++ b/lib/components/album/album_card.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.dart'; +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/hooks/use_breakpoint_value.dart'; -import 'package:spotube/models/current_playlist.dart'; -import 'package:spotube/provider/playback_provider.dart'; import 'package:spotube/provider/spotify_provider.dart'; +import 'package:spotube/provider/playlist_queue_provider.dart'; import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; @@ -20,9 +20,10 @@ class AlbumCard extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - Playback playback = ref.watch(playbackProvider); - bool isPlaylistPlaying = - playback.playlist != null && playback.playlist!.id == album.id; + final playlist = ref.watch(PlaylistQueueNotifier.provider); + final playing = useStream(PlaylistQueueNotifier.playing).data ?? false; + final playlistNotifier = ref.watch(PlaylistQueueNotifier.notifier); + bool isPlaylistPlaying = playlistNotifier.isPlayingPlaylist(album.tracks!); final int marginH = useBreakpointValue(sm: 10, md: 15, lg: 20, xl: 20, xxl: 20); return PlaybuttonCard( @@ -32,9 +33,8 @@ class AlbumCard extends HookConsumerWidget { ), viewType: viewType, margin: EdgeInsets.symmetric(horizontal: marginH.toDouble()), - isPlaying: isPlaylistPlaying && playback.isPlaying, - isLoading: playback.status == PlaybackStatus.loading && - playback.playlist?.id == album.id, + isPlaying: isPlaylistPlaying && playing, + isLoading: isPlaylistPlaying && playlist?.isLoading == true, title: album.name!, description: "Album • ${TypeConversionUtils.artists_X_String(album.artists ?? [])}", @@ -43,10 +43,10 @@ class AlbumCard extends HookConsumerWidget { }, onPlaybuttonPressed: () async { SpotifyApi spotify = ref.read(spotifyProvider); - if (isPlaylistPlaying && playback.isPlaying) { - return playback.pause(); - } else if (isPlaylistPlaying && !playback.isPlaying) { - return playback.resume(); + if (isPlaylistPlaying && playing) { + return playlistNotifier.pause(); + } else if (isPlaylistPlaying && !playing) { + return playlistNotifier.resume(); } List tracks = (await spotify.albums.getTracks(album.id!).all()) .map((track) => @@ -54,12 +54,7 @@ class AlbumCard extends HookConsumerWidget { .toList(); if (tracks.isEmpty) return; - await playback.playPlaylist(CurrentPlaylist( - tracks: tracks, - id: album.id!, - name: album.name!, - thumbnail: album.images!.first.url!, - )); + await playlistNotifier.loadAndPlay(tracks); }, ); } diff --git a/lib/components/library/user_local_tracks.dart b/lib/components/library/user_local_tracks.dart index 499c49c1..b1bde2e8 100644 --- a/lib/components/library/user_local_tracks.dart +++ b/lib/components/library/user_local_tracks.dart @@ -21,9 +21,8 @@ import 'package:spotube/components/shared/sort_tracks_dropdown.dart'; import 'package:spotube/components/shared/track_table/track_tile.dart'; import 'package:spotube/hooks/use_async_effect.dart'; import 'package:spotube/hooks/use_breakpoints.dart'; -import 'package:spotube/models/current_playlist.dart'; -import 'package:spotube/models/logger.dart'; -import 'package:spotube/provider/playback_provider.dart'; +import 'package:spotube/models/local_track.dart'; +import 'package:spotube/provider/playlist_queue_provider.dart'; import 'package:spotube/provider/user_preferences_provider.dart'; import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/primitive_utils.dart'; @@ -58,7 +57,7 @@ enum SortBy { dateAdded, } -final localTracksProvider = FutureProvider>((ref) async { +final localTracksProvider = FutureProvider>((ref) async { try { if (kIsWeb) return []; final downloadLocation = ref.watch( @@ -97,9 +96,8 @@ final localTracksProvider = FutureProvider>((ref) async { return {"metadata": metadata, "file": f, "art": imageFile.path}; } on FfiException catch (e) { - if (e.message == "NoTag: reader does not contain an id3 tag") { - getLogger(FutureProvider>) - .v("[Fetching metadata]", e.message); + if (e.message != "NoTag: reader does not contain an id3 tag") { + rethrow; } return {}; } catch (e, stack) { @@ -114,10 +112,13 @@ final localTracksProvider = FutureProvider>((ref) async { final tracks = filesWithMetadata .map( - (fileWithMetadata) => TypeConversionUtils.localTrack_X_Track( - fileWithMetadata["file"], - metadata: fileWithMetadata["metadata"], - art: fileWithMetadata["art"], + (fileWithMetadata) => LocalTrack.fromTrack( + track: TypeConversionUtils.localTrack_X_Track( + fileWithMetadata["file"], + metadata: fileWithMetadata["metadata"], + art: fileWithMetadata["art"], + ), + path: fileWithMetadata["file"].path, ), ) .toList(); @@ -132,37 +133,36 @@ final localTracksProvider = FutureProvider>((ref) async { class UserLocalTracks extends HookConsumerWidget { const UserLocalTracks({Key? key}) : super(key: key); - void playLocalTracks(Playback playback, List tracks, - {Track? currentTrack}) async { + void playLocalTracks( + PlaylistQueueNotifier playback, + List tracks, { + LocalTrack? currentTrack, + }) async { currentTrack ??= tracks.first; - final isPlaylistPlaying = playback.playlist?.id == "local"; + final isPlaylistPlaying = playback.isPlayingPlaylist(tracks); if (!isPlaylistPlaying) { - await playback.playPlaylist( - CurrentPlaylist( - tracks: tracks, - id: "local", - name: "Local Tracks", - thumbnail: TypeConversionUtils.image_X_UrlString( - null, - placeholder: ImagePlaceholder.collection, - ), - isLocal: true, - ), - tracks.indexWhere((s) => s.id == currentTrack?.id), + await playback.loadAndPlay( + tracks, + active: tracks.indexWhere((s) => s.id == currentTrack?.id), ); } else if (isPlaylistPlaying && currentTrack.id != null && - currentTrack.id != playback.track?.id) { - await playback.play(currentTrack); + currentTrack.id != playback.state?.activeTrack.id) { + await playback.playAt( + tracks.indexWhere((s) => s.id == currentTrack?.id), + ); } } @override Widget build(BuildContext context, ref) { final sortBy = useState(SortBy.none); - final playback = ref.watch(playbackProvider); - final isPlaylistPlaying = playback.playlist?.id == "local"; + final playlist = ref.watch(PlaylistQueueNotifier.provider); + final playlistNotifier = ref.watch(PlaylistQueueNotifier.notifier); final trackSnapshot = ref.watch(localTracksProvider); + final isPlaylistPlaying = playlistNotifier.isPlayingPlaylist( + trackSnapshot.value ?? [], + ); final isMounted = useIsMounted(); final breakpoint = useBreakpoints(); @@ -198,9 +198,10 @@ class UserLocalTracks extends HookConsumerWidget { ? () { if (trackSnapshot.value?.isNotEmpty == true) { if (!isPlaylistPlaying) { - playLocalTracks(playback, trackSnapshot.value!); + playLocalTracks( + playlistNotifier, trackSnapshot.value!); } else { - playback.stop(); + playlistNotifier.stop(); } } } @@ -267,17 +268,17 @@ class UserLocalTracks extends HookConsumerWidget { itemBuilder: (context, index) { final track = filteredTracks[index]; return TrackTile( - playback, + playlist, duration: "${track.duration?.inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(track.duration?.inSeconds.remainder(60) ?? 0)}", track: MapEntry(index, track), - isActive: playback.track?.id == track.id, + isActive: playlist?.activeTrack.id == track.id, isChecked: false, showCheck: false, isLocal: true, onTrackPlayButtonPressed: (currentTrack) { return playLocalTracks( - playback, + playlistNotifier, sortedTracks, currentTrack: track, ); diff --git a/lib/components/player/player_actions.dart b/lib/components/player/player_actions.dart index f6771418..050670d9 100644 --- a/lib/components/player/player_actions.dart +++ b/lib/components/player/player_actions.dart @@ -5,14 +5,14 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:platform_ui/platform_ui.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/library/user_local_tracks.dart'; import 'package:spotube/components/player/player_queue.dart'; import 'package:spotube/components/player/sibling_tracks_sheet.dart'; import 'package:spotube/components/shared/heart_button.dart'; +import 'package:spotube/models/local_track.dart'; import 'package:spotube/models/logger.dart'; import 'package:spotube/provider/auth_provider.dart'; import 'package:spotube/provider/downloader_provider.dart'; -import 'package:spotube/provider/playback_provider.dart'; +import 'package:spotube/provider/playlist_queue_provider.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; class PlayerActions extends HookConsumerWidget { @@ -29,26 +29,26 @@ class PlayerActions extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final Playback playback = ref.watch(playbackProvider); - final isLocalTrack = playback.playlist?.isLocal == true; + final playlist = ref.watch(PlaylistQueueNotifier.provider); + final isLocalTrack = playlist?.activeTrack is LocalTrack; final downloader = ref.watch(downloaderProvider); - final isInQueue = - downloader.inQueue.any((element) => element.id == playback.track?.id); - final localTracks = ref.watch(localTracksProvider).value; + final isInQueue = downloader.inQueue + .any((element) => element.id == playlist?.activeTrack.id); + final localTracks = [] /* ref.watch(localTracksProvider).value */; final auth = ref.watch(authProvider); final isDownloaded = useMemoized(() { - return localTracks?.any( + return localTracks.any( (element) => - element.name == playback.track?.name && - element.album?.name == playback.track?.album?.name && + element.name == playlist?.activeTrack.name && + element.album?.name == playlist?.activeTrack.album?.name && TypeConversionUtils.artists_X_String( element.artists ?? []) == TypeConversionUtils.artists_X_String( - playback.track?.artists ?? []), + playlist?.activeTrack.artists ?? []), ) == true; - }, [localTracks, playback.track]); + }, [localTracks, playlist?.activeTrack]); return Row( mainAxisAlignment: mainAxisAlignment, @@ -56,7 +56,7 @@ class PlayerActions extends HookConsumerWidget { PlatformIconButton( icon: const Icon(SpotubeIcons.queue), tooltip: 'Queue', - onPressed: playback.playlist != null + onPressed: playlist != null ? () { showModalBottomSheet( context: context, @@ -82,7 +82,7 @@ class PlayerActions extends HookConsumerWidget { PlatformIconButton( icon: const Icon(SpotubeIcons.alternativeRoute), tooltip: "Alternative Track Sources", - onPressed: playback.track != null + onPressed: playlist?.activeTrack != null ? () { showModalBottomSheet( context: context, @@ -119,12 +119,12 @@ class PlayerActions extends HookConsumerWidget { icon: Icon( isDownloaded ? SpotubeIcons.done : SpotubeIcons.download, ), - onPressed: playback.track != null - ? () => downloader.addToQueue(playback.track!) + onPressed: playlist?.activeTrack != null + ? () => downloader.addToQueue(playlist!.activeTrack) : null, ), - if (playback.track != null && !isLocalTrack && auth.isLoggedIn) - TrackHeartButton(track: playback.track!), + if (playlist?.activeTrack != null && !isLocalTrack && auth.isLoggedIn) + TrackHeartButton(track: playlist!.activeTrack), ...(extraActions ?? []) ], ); diff --git a/lib/components/player/player_controls.dart b/lib/components/player/player_controls.dart index 57b6f5d3..bdba3cdc 100644 --- a/lib/components/player/player_controls.dart +++ b/lib/components/player/player_controls.dart @@ -4,10 +4,9 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:platform_ui/platform_ui.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/hooks/playback_hooks.dart'; import 'package:spotube/collections/intents.dart'; import 'package:spotube/models/logger.dart'; -import 'package:spotube/provider/playback_provider.dart'; +import 'package:spotube/provider/playlist_queue_provider.dart'; import 'package:spotube/utils/primitive_utils.dart'; class PlayerControls extends HookConsumerWidget { @@ -37,12 +36,9 @@ class PlayerControls extends HookConsumerWidget { SeekIntent: SeekAction(), }, []); - final Playback playback = ref.watch(playbackProvider); - - final onNext = useNextTrack(ref); - final onPrevious = usePreviousTrack(ref); - - final duration = playback.currentDuration; + final playlist = ref.watch(PlaylistQueueNotifier.provider); + final playlistNotifier = ref.watch(PlaylistQueueNotifier.notifier); + final playing = useStream(PlaylistQueueNotifier.playing).data ?? false; return GestureDetector( behavior: HitTestBehavior.translucent, @@ -59,80 +55,90 @@ class PlayerControls extends HookConsumerWidget { constraints: const BoxConstraints(maxWidth: 600), child: Column( children: [ - StreamBuilder( - stream: playback.player.onPositionChanged, - builder: (context, snapshot) { + HookBuilder( + builder: (context) { + final duration = + useStream(PlaylistQueueNotifier.duration).data ?? + Duration.zero; + final positionSnapshot = + useStream(PlaylistQueueNotifier.position); + final position = positionSnapshot.data ?? Duration.zero; final totalMinutes = PrimitiveUtils.zeroPadNumStr( duration.inMinutes.remainder(60)); final totalSeconds = PrimitiveUtils.zeroPadNumStr( duration.inSeconds.remainder(60)); - final currentMinutes = snapshot.hasData - ? PrimitiveUtils.zeroPadNumStr( - snapshot.data!.inMinutes.remainder(60)) - : "00"; - final currentSeconds = snapshot.hasData - ? PrimitiveUtils.zeroPadNumStr( - snapshot.data!.inSeconds.remainder(60)) - : "00"; + final currentMinutes = PrimitiveUtils.zeroPadNumStr( + position.inMinutes.remainder(60)); + final currentSeconds = PrimitiveUtils.zeroPadNumStr( + position.inSeconds.remainder(60)); final sliderMax = duration.inSeconds; - final sliderValue = snapshot.data?.inSeconds ?? 0; + final sliderValue = position.inSeconds; - return HookBuilder( - builder: (context) { - final progressStatic = - (sliderMax == 0 || sliderValue > sliderMax) - ? 0 - : sliderValue / sliderMax; + final progressStatic = + (sliderMax == 0 || sliderValue > sliderMax) + ? 0 + : sliderValue / sliderMax; - final progress = useState( - useMemoized(() => progressStatic, []), - ); + final progress = useState( + useMemoized(() => progressStatic, []), + ); - useEffect(() { - progress.value = progressStatic; - return null; - }, [progressStatic]); + useEffect(() { + progress.value = progressStatic; + return null; + }, [progressStatic]); - return Column( - children: [ - PlatformTooltip( - message: "Slide to seek forward or backward", - child: PlatformSlider( - // cannot divide by zero - // there's an edge case for value being bigger - // than total duration. Keeping it resolved - value: progress.value.toDouble(), - onChanged: (v) { - progress.value = v; - }, - onChangeEnd: (value) async { - await playback.seekPosition( - Duration( - seconds: (value * sliderMax).toInt(), - ), - ); - }, - activeColor: iconColor, + useEffect(() { + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (positionSnapshot.hasData && + duration == Duration.zero) { + await Future.delayed(const Duration(milliseconds: 200)); + await playlistNotifier.pause(); + await Future.delayed(const Duration(milliseconds: 400)); + await playlistNotifier.resume(); + } + }); + return null; + }, [positionSnapshot.hasData, duration]); + + return Column( + children: [ + PlatformTooltip( + message: "Slide to seek forward or backward", + child: PlatformSlider( + // cannot divide by zero + // there's an edge case for value being bigger + // than total duration. Keeping it resolved + value: progress.value.toDouble(), + onChanged: (v) { + progress.value = v; + }, + onChangeEnd: (value) async { + await playlistNotifier.seek( + Duration( + seconds: (value * sliderMax).toInt(), + ), + ); + }, + activeColor: iconColor, + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8.0, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + PlatformText( + "$currentMinutes:$currentSeconds", ), - ), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8.0, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - PlatformText( - "$currentMinutes:$currentSeconds", - ), - PlatformText("$totalMinutes:$totalSeconds"), - ], - ), - ), - ], - ); - }, + PlatformText("$totalMinutes:$totalSeconds"), + ], + ), + ), + ], ); }, ), @@ -140,44 +146,43 @@ class PlayerControls extends HookConsumerWidget { mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ PlatformIconButton( - tooltip: playback.isShuffled + tooltip: playlistNotifier.isShuffled ? "Unshuffle playlist" : "Shuffle playlist", icon: Icon( SpotubeIcons.shuffle, - color: playback.isShuffled + color: playlistNotifier.isShuffled ? PlatformTheme.of(context).primaryColor : null, ), - onPressed: playback.playlist == null + onPressed: playlist == null ? null : () { - playback.setIsShuffled(!playback.isShuffled); + if (playlistNotifier.isShuffled) { + playlistNotifier.unshuffle(); + } else { + playlistNotifier.shuffle(); + } }, ), PlatformIconButton( - tooltip: "Previous track", - icon: Icon( - SpotubeIcons.skipBack, - color: iconColor, - ), - onPressed: () { - onPrevious(); - }), + tooltip: "Previous track", + icon: Icon( + SpotubeIcons.skipBack, + color: iconColor, + ), + onPressed: playlistNotifier.previous, + ), PlatformIconButton( - tooltip: playback.isPlaying - ? "Pause playback" - : "Resume playback", - icon: playback.status == PlaybackStatus.loading + tooltip: playing ? "Pause playback" : "Resume playback", + icon: playlist?.isLoading == true ? const SizedBox( height: 20, width: 20, child: PlatformCircularProgressIndicator(), ) : Icon( - playback.isPlaying - ? SpotubeIcons.pause - : SpotubeIcons.play, + playing ? SpotubeIcons.pause : SpotubeIcons.play, color: iconColor, ), onPressed: Actions.handler( @@ -191,7 +196,7 @@ class PlayerControls extends HookConsumerWidget { SpotubeIcons.skipForward, color: iconColor, ), - onPressed: () => onNext(), + onPressed: playlistNotifier.next, ), PlatformIconButton( tooltip: "Stop playback", @@ -199,23 +204,23 @@ class PlayerControls extends HookConsumerWidget { SpotubeIcons.stop, color: iconColor, ), - onPressed: playback.track != null ? playback.stop : null, - ), - PlatformIconButton( - tooltip: - !playback.isLoop ? "Loop Track" : "Repeat playlist", - icon: Icon( - playback.isLoop - ? SpotubeIcons.repeatOne - : SpotubeIcons.repeat, - ), - onPressed: - playback.track == null || playback.playlist == null - ? null - : () { - playback.setIsLoop(!playback.isLoop); - }, + onPressed: playlist != null ? playlistNotifier.stop : null, ), + // PlatformIconButton( + // tooltip: + // !playlist.isLoop ? "Loop Track" : "Repeat playlist", + // icon: Icon( + // playlist.isLoop + // ? SpotubeIcons.repeatOne + // : SpotubeIcons.repeat, + // ), + // onPressed: + // playlist.track == null || playlist.playlist == null + // ? null + // : () { + // playlist.setIsLoop(!playlist.isLoop); + // }, + // ), ], ), const SizedBox(height: 5) diff --git a/lib/components/player/player_overlay.dart b/lib/components/player/player_overlay.dart index 8c9fa3e3..c3a9f95e 100644 --- a/lib/components/player/player_overlay.dart +++ b/lib/components/player/player_overlay.dart @@ -1,15 +1,15 @@ import 'dart:ui'; import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:platform_ui/platform_ui.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/player/player_track_details.dart'; -import 'package:spotube/hooks/playback_hooks.dart'; import 'package:spotube/hooks/use_palette_color.dart'; import 'package:spotube/collections/intents.dart'; -import 'package:spotube/provider/playback_provider.dart'; +import 'package:spotube/provider/playlist_queue_provider.dart'; import 'package:spotube/utils/service_utils.dart'; class PlayerOverlay extends HookConsumerWidget { @@ -24,16 +24,10 @@ class PlayerOverlay extends HookConsumerWidget { Widget build(BuildContext context, ref) { final paletteColor = usePaletteColor(albumArt, ref); final canShow = ref.watch( - playbackProvider.select( - (s) => - s.track != null || - s.isPlaying || - s.status == PlaybackStatus.loading, - ), + PlaylistQueueNotifier.provider.select((s) => s != null), ); - - final onNext = useNextTrack(ref); - final onPrevious = usePreviousTrack(ref); + final playlistNotifier = ref.watch(PlaylistQueueNotifier.notifier); + final playing = useStream(PlaylistQueueNotifier.playing).data ?? false; return GestureDetector( onVerticalDragEnd: (details) { @@ -83,18 +77,17 @@ class PlayerOverlay extends HookConsumerWidget { Row( children: [ IconButton( - icon: Icon( - SpotubeIcons.skipBack, - color: paletteColor.bodyTextColor, - ), - onPressed: () { - onPrevious(); - }), + icon: Icon( + SpotubeIcons.skipBack, + color: paletteColor.bodyTextColor, + ), + onPressed: playlistNotifier.previous, + ), Consumer( builder: (context, ref, _) { return IconButton( icon: Icon( - ref.read(playbackProvider).isPlaying + playing ? SpotubeIcons.pause : SpotubeIcons.play, color: paletteColor.bodyTextColor, @@ -111,7 +104,7 @@ class PlayerOverlay extends HookConsumerWidget { SpotubeIcons.skipForward, color: paletteColor.bodyTextColor, ), - onPressed: () => onNext(), + onPressed: playlistNotifier.next, ), ], ), diff --git a/lib/components/player/player_queue.dart b/lib/components/player/player_queue.dart index ee6f92df..4d2413b5 100644 --- a/lib/components/player/player_queue.dart +++ b/lib/components/player/player_queue.dart @@ -8,7 +8,7 @@ import 'package:scroll_to_index/scroll_to_index.dart'; import 'package:spotube/components/shared/fallbacks/not_found.dart'; import 'package:spotube/components/shared/track_table/track_tile.dart'; import 'package:spotube/hooks/use_auto_scroll_controller.dart'; -import 'package:spotube/provider/playback_provider.dart'; +import 'package:spotube/provider/playlist_queue_provider.dart'; import 'package:spotube/utils/primitive_utils.dart'; class PlayerQueue extends HookConsumerWidget { @@ -20,9 +20,10 @@ class PlayerQueue extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final playback = ref.watch(playbackProvider); + final playlist = ref.watch(PlaylistQueueNotifier.provider); + final playlistNotifier = ref.watch(PlaylistQueueNotifier.notifier); final controller = useAutoScrollController(); - final tracks = playback.playlist?.tracks ?? []; + final tracks = playlist?.tracks ?? {}; if (tracks.isEmpty) { return const NotFound(vertical: true); @@ -38,9 +39,8 @@ class PlayerQueue extends HookConsumerWidget { PlatformTheme.of(context).textTheme?.subheading?.color; useEffect(() { - if (playback.track == null || playback.playlist == null) return null; - final index = playback.playlist!.tracks - .indexWhere((track) => track.id == playback.track!.id); + if (playlist == null) return null; + final index = playlist.active; if (index < 0) return; controller.scrollToIndex( index, @@ -77,14 +77,6 @@ class PlayerQueue extends HookConsumerWidget { ), ), PlatformText.subheading("Queue"), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: PlatformText( - playback.playlist?.name ?? "", - overflow: TextOverflow.ellipsis, - style: PlatformTextTheme.of(context).body, - ), - ), const SizedBox(height: 10), Flexible( child: ListView.builder( @@ -92,7 +84,7 @@ class PlayerQueue extends HookConsumerWidget { itemCount: tracks.length, shrinkWrap: true, itemBuilder: (context, i) { - final track = tracks.asMap().entries.elementAt(i); + final track = tracks.toList().asMap().entries.elementAt(i); String duration = "${track.value.duration?.inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}"; return AutoScrollTag( @@ -102,13 +94,15 @@ class PlayerQueue extends HookConsumerWidget { child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: TrackTile( - playback, + playlist, track: track, duration: duration, - isActive: playback.track?.id == track.value.id, + isActive: playlist?.activeTrack.id == track.value.id, onTrackPlayButtonPressed: (currentTrack) async { - if (playback.track?.id == track.value.id) return; - await playback.setPlaylistPosition(i); + if (playlist?.activeTrack.id == track.value.id) { + return; + } + await playlistNotifier.playAt(i); }, ), ), diff --git a/lib/components/player/player_track_details.dart b/lib/components/player/player_track_details.dart index 08b59960..26df2ba7 100644 --- a/lib/components/player/player_track_details.dart +++ b/lib/components/player/player_track_details.dart @@ -4,7 +4,7 @@ import 'package:platform_ui/platform_ui.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/playback_provider.dart'; +import 'package:spotube/provider/playlist_queue_provider.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; class PlayerTrackDetails extends HookConsumerWidget { @@ -16,7 +16,7 @@ class PlayerTrackDetails extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final breakpoint = useBreakpoints(); - final playback = ref.watch(playbackProvider); + final playback = ref.watch(PlaylistQueueNotifier.provider); return Row( children: [ @@ -38,7 +38,7 @@ class PlayerTrackDetails extends HookConsumerWidget { if (breakpoint.isLessThanOrEqualTo(Breakpoints.md)) Flexible( child: PlatformText( - playback.track?.name ?? "Not playing", + playback?.activeTrack.name ?? "Not playing", overflow: TextOverflow.ellipsis, style: TextStyle(fontWeight: FontWeight.bold, color: color), ), @@ -51,12 +51,12 @@ class PlayerTrackDetails extends HookConsumerWidget { child: Column( children: [ PlatformText( - playback.track?.name ?? "Not playing", + playback?.activeTrack.name ?? "Not playing", overflow: TextOverflow.ellipsis, style: TextStyle(fontWeight: FontWeight.bold, color: color), ), TypeConversionUtils.artists_X_ClickableArtists( - playback.track?.artists ?? [], + playback?.activeTrack.artists ?? [], ) ], ), diff --git a/lib/components/player/sibling_tracks_sheet.dart b/lib/components/player/sibling_tracks_sheet.dart index 312bc6dc..ec463ed8 100644 --- a/lib/components/player/sibling_tracks_sheet.dart +++ b/lib/components/player/sibling_tracks_sheet.dart @@ -1,12 +1,13 @@ import 'dart:ui'; import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:platform_ui/platform_ui.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; -import 'package:spotube/provider/playback_provider.dart'; +import 'package:spotube/models/spotube_track.dart'; +import 'package:spotube/provider/playlist_queue_provider.dart'; import 'package:spotube/utils/primitive_utils.dart'; +import 'package:youtube_explode_dart/youtube_explode_dart.dart'; class SiblingTracksSheet extends HookConsumerWidget { final bool floating; @@ -17,7 +18,13 @@ class SiblingTracksSheet extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final playback = ref.watch(playbackProvider); + final playlist = ref.watch(PlaylistQueueNotifier.provider); + final playlistNotifier = ref.watch(PlaylistQueueNotifier.notifier); + + final siblings = playlist?.isLoading == false + ? (playlist!.activeTrack as SpotubeTrack).siblings + :