From 932462d77378e79efdec2c2400e1e9512c01f24a Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 12 Mar 2022 19:10:21 +0600 Subject: [PATCH] sperated PlayerControl from PlayerOverlay PlayerControls slider & duration are now vertical hotkey init moved to Home Player & YoutubeExplode are provided through riverpod Playback handles all things Player used to do GoRoutes are seperated from main to individual model file usePaletteColor bugfix occuring for before initilizing mount --- lib/components/Album/AlbumCard.dart | 1 + lib/components/Album/AlbumView.dart | 4 +- lib/components/Artist/ArtistProfile.dart | 6 +- lib/components/Category/CategoryCard.dart | 12 +- lib/components/Home/Home.dart | 4 + lib/components/Player/Player.dart | 218 +------------------ lib/components/Player/PlayerControls.dart | 252 +++++++++++----------- lib/components/Player/PlayerOverlay.dart | 90 +++++--- lib/components/Player/PlayerView.dart | 98 +++++++++ lib/components/Playlist/PlaylistCard.dart | 1 + lib/components/Playlist/PlaylistView.dart | 4 +- lib/components/Search/Search.dart | 1 + lib/hooks/playback.dart | 42 ++++ lib/hooks/useHotKeys.dart | 45 ++++ lib/hooks/useIsCurrentRoute.dart | 18 ++ lib/hooks/usePaletteColor.dart | 23 +- lib/main.dart | 69 +----- lib/models/GoRouteDeclarations.dart | 74 +++++++ lib/provider/AudioPlayer.dart | 6 + lib/provider/Playback.dart | 174 ++++++++++++++- lib/provider/YouTube.dart | 4 + 21 files changed, 689 insertions(+), 457 deletions(-) create mode 100644 lib/components/Player/PlayerView.dart create mode 100644 lib/hooks/playback.dart create mode 100644 lib/hooks/useHotKeys.dart create mode 100644 lib/hooks/useIsCurrentRoute.dart create mode 100644 lib/models/GoRouteDeclarations.dart create mode 100644 lib/provider/AudioPlayer.dart create mode 100644 lib/provider/YouTube.dart diff --git a/lib/components/Album/AlbumCard.dart b/lib/components/Album/AlbumCard.dart index 9aa17145..81163b1b 100644 --- a/lib/components/Album/AlbumCard.dart +++ b/lib/components/Album/AlbumCard.dart @@ -50,6 +50,7 @@ class AlbumCard extends HookConsumerWidget { thumbnail: album.images!.first.url!, ); playback.setCurrentTrack = tracks.first; + await playback.startPlaying(); }, ); } diff --git a/lib/components/Album/AlbumView.dart b/lib/components/Album/AlbumView.dart index 80b1b937..2d1f0da8 100644 --- a/lib/components/Album/AlbumView.dart +++ b/lib/components/Album/AlbumView.dart @@ -12,7 +12,8 @@ class AlbumView extends ConsumerWidget { final AlbumSimple album; const AlbumView(this.album, {Key? key}) : super(key: key); - playPlaylist(Playback playback, List tracks, {Track? currentTrack}) { + playPlaylist(Playback playback, List tracks, + {Track? currentTrack}) async { currentTrack ??= tracks.first; var isPlaylistPlaying = playback.currentPlaylist?.id == album.id; if (!isPlaylistPlaying) { @@ -28,6 +29,7 @@ class AlbumView extends ConsumerWidget { currentTrack.id != playback.currentTrack?.id) { playback.setCurrentTrack = currentTrack; } + await playback.startPlaying(); } @override diff --git a/lib/components/Artist/ArtistProfile.dart b/lib/components/Artist/ArtistProfile.dart index 0b3cd8b5..2ae74acb 100644 --- a/lib/components/Artist/ArtistProfile.dart +++ b/lib/components/Artist/ArtistProfile.dart @@ -6,10 +6,8 @@ import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/Album/AlbumCard.dart'; -import 'package:spotube/components/Artist/ArtistAlbumView.dart'; import 'package:spotube/components/Artist/ArtistCard.dart'; import 'package:spotube/components/Shared/PageWindowTitleBar.dart'; -import 'package:spotube/components/Shared/SpotubePageRoute.dart'; import 'package:spotube/components/Shared/TracksTableView.dart'; import 'package:spotube/helpers/image-to-url-string.dart'; import 'package:spotube/helpers/readable-number.dart'; @@ -162,7 +160,8 @@ class ArtistProfile extends HookConsumerWidget { Playback playback = ref.watch(playbackProvider); var isPlaylistPlaying = playback.currentPlaylist?.id == snapshot.data?.id; - playPlaylist(List tracks, {Track? currentTrack}) { + playPlaylist(List tracks, + {Track? currentTrack}) async { currentTrack ??= tracks.first; if (!isPlaylistPlaying) { playback.setCurrentPlaylist = CurrentPlaylist( @@ -177,6 +176,7 @@ class ArtistProfile extends HookConsumerWidget { currentTrack.id != playback.currentTrack?.id) { playback.setCurrentTrack = currentTrack; } + await playback.startPlaying(); } return Column(children: [ diff --git a/lib/components/Category/CategoryCard.dart b/lib/components/Category/CategoryCard.dart index 65225ef9..fec5085b 100644 --- a/lib/components/Category/CategoryCard.dart +++ b/lib/components/Category/CategoryCard.dart @@ -39,11 +39,14 @@ class CategoryCard extends HookWidget { usePagingController(firstPageKey: 0); final _error = useState(false); + final mounted = useIsMounted(); useEffect(() { listener(pageKey) async { try { - if (playlists != null && playlists?.isNotEmpty == true) { + if (playlists != null && + playlists?.isNotEmpty == true && + mounted()) { return pagingController.appendLastPage(playlists!.toList()); } final Page page = await (category.id != @@ -52,6 +55,7 @@ class CategoryCard extends HookWidget { : spotifyApi.playlists.featured) .getPage(3, pageKey); + if (!mounted()) return; if (page.isLast && page.items != null) { pagingController.appendLastPage(page.items!.toList()); } else if (page.items != null) { @@ -60,8 +64,10 @@ class CategoryCard extends HookWidget { } if (_error.value) _error.value = false; } catch (e, stack) { - if (!_error.value) _error.value = true; - pagingController.error = e; + if (mounted()) { + if (!_error.value) _error.value = true; + pagingController.error = e; + } print( "[CategoryCard.pagingController.addPageRequestListener] $e"); print(stack); diff --git a/lib/components/Home/Home.dart b/lib/components/Home/Home.dart index cdbdfdf5..449a5bc7 100644 --- a/lib/components/Home/Home.dart +++ b/lib/components/Home/Home.dart @@ -19,6 +19,7 @@ import 'package:spotube/components/Player/Player.dart'; import 'package:spotube/components/Library/UserLibrary.dart'; import 'package:spotube/helpers/oauth-login.dart'; import 'package:spotube/hooks/useBreakpointValue.dart'; +import 'package:spotube/hooks/useHotKeys.dart'; import 'package:spotube/hooks/usePagingController.dart'; import 'package:spotube/hooks/useSharedPreferences.dart'; import 'package:spotube/models/LocalStorageKeys.dart'; @@ -56,6 +57,9 @@ class Home extends HookConsumerWidget { final localStorage = useSharedPreferences(); + // initializing global hot keys + useHotKeys(ref); + useEffect(() { if (localStorage == null) return null; final String? clientId = diff --git a/lib/components/Player/Player.dart b/lib/components/Player/Player.dart index 5879172a..ff04d3b0 100644 --- a/lib/components/Player/Player.dart +++ b/lib/components/Player/Player.dart @@ -10,15 +10,11 @@ import 'package:spotube/components/Player/PlayerTrackDetails.dart'; import 'package:spotube/components/Shared/DownloadTrackButton.dart'; import 'package:spotube/components/Player/PlayerControls.dart'; import 'package:spotube/helpers/image-to-url-string.dart'; -import 'package:spotube/helpers/search-youtube.dart'; import 'package:spotube/hooks/useBreakpoints.dart'; -import 'package:spotube/hooks/usePaletteColor.dart'; import 'package:spotube/models/LocalStorageKeys.dart'; import 'package:spotube/provider/Playback.dart'; import 'package:flutter/material.dart'; import 'package:spotube/provider/SpotifyDI.dart'; -import 'package:spotube/provider/ThemeProvider.dart'; -import 'package:youtube_explode_dart/youtube_explode_dart.dart'; class Player extends HookConsumerWidget { const Player({Key? key}) : super(key: key); @@ -26,88 +22,17 @@ class Player extends HookConsumerWidget { Widget build(BuildContext context, ref) { Playback playback = ref.watch(playbackProvider); - final _isPlaying = useState(false); - final _shuffled = useState(false); final _volume = useState(0.0); - final _duration = useState(null); - final _currentTrackId = useState(null); final breakpoint = useBreakpoints(); - final AudioPlayer player = useMemoized(() => AudioPlayer(), []); - final YoutubeExplode youtube = useMemoized(() => YoutubeExplode(), []); + final AudioPlayer player = playback.player; + final Future future = useMemoized(SharedPreferences.getInstance); final AsyncSnapshot localStorage = useFuture(future, initialData: null); - var _movePlaylistPositionBy = useCallback((int pos) { - Playback playback = ref.read(playbackProvider); - if (playback.currentTrack != null && playback.currentPlaylist != null) { - int index = playback.currentPlaylist!.trackIds - .indexOf(playback.currentTrack!.id!) + - pos; - - var safeIndex = index > playback.currentPlaylist!.trackIds.length - 1 - ? 0 - : index < 0 - ? playback.currentPlaylist!.trackIds.length - : index; - Track? track = - playback.currentPlaylist!.tracks.asMap().containsKey(safeIndex) - ? playback.currentPlaylist!.tracks.elementAt(safeIndex) - : null; - if (track != null) { - playback.setCurrentTrack = track; - _duration.value = null; - } - } - }, [_duration]); - - useEffect(() { - var playingStreamListener = player.playingStream.listen((playing) async { - _isPlaying.value = playing; - }); - - var durationStreamListener = - player.durationStream.listen((duration) async { - if (duration != null) { - // Actually things doesn't work all the time as they were - // described. So instead of listening to a `playback.ready` - // stream, it has to listen to duration stream since duration - // is always added to the Stream sink after all icyMetadata has - // been loaded thus indicating buffering started - if (duration != Duration.zero && duration != _duration.value) { - // this line is for prev/next or already playing playlist - if (player.playing) await player.pause(); - await player.play(); - } - _duration.value = duration; - } - }); - - var processingStateStreamListener = - player.processingStateStream.listen((event) async { - try { - if (event == ProcessingState.completed && - _currentTrackId.value != null) { - _movePlaylistPositionBy(1); - } - } catch (e, stack) { - print("[PrecessingStateStreamListener] $e"); - print(stack); - } - }); - - return () { - playingStreamListener.cancel(); - durationStreamListener.cancel(); - processingStateStreamListener.cancel(); - player.dispose(); - youtube.close(); - }; - }, []); - useEffect(() { if (localStorage.hasData) { _volume.value = localStorage.data?.getDouble(LocalStorageKeys.volume) ?? @@ -116,68 +41,6 @@ class Player extends HookConsumerWidget { return null; }, [localStorage.data]); - final _playTrack = - useCallback((Track currentTrack, Playback playback) async { - try { - if (currentTrack.id != _currentTrackId.value) { - Uri? parsedUri = Uri.tryParse(currentTrack.uri ?? ""); - if (parsedUri != null && parsedUri.hasAbsolutePath) { - await player - .setAudioSource( - AudioSource.uri(parsedUri), - preload: true, - ) - .then((value) async { - _currentTrackId.value = currentTrack.id; - if (_duration.value != null) { - _duration.value = value; - } - }); - } - var ytTrack = await toYoutubeTrack(youtube, currentTrack); - if (playback.setTrackUriById(currentTrack.id!, ytTrack.uri!)) { - await player - .setAudioSource(AudioSource.uri(Uri.parse(ytTrack.uri!))) - .then((value) { - _currentTrackId.value = currentTrack.id; - }); - } - } - } catch (e, stack) { - print("[Player._playTrack()] $e"); - print(stack); - } - }, [player, _currentTrackId, _duration]); - - useEffect(() { - if (playback.currentPlaylist != null && playback.currentTrack != null) { - _playTrack(playback.currentTrack!, playback); - } - return null; - }, [playback.currentPlaylist, playback.currentTrack]); - - var _onNext = useCallback(() async { - try { - await player.pause(); - await player.seek(Duration.zero); - _movePlaylistPositionBy(1); - } catch (e, stack) { - print("[PlayerControls.onNext()] $e"); - print(stack); - } - }, [player]); - - var _onPrevious = useCallback(() async { - try { - await player.pause(); - await player.seek(Duration.zero); - _movePlaylistPositionBy(-1); - } catch (e, stack) { - print("[PlayerControls.onPrevious()] $e"); - print(stack); - } - }, [player]); - String albumArt = useMemoized( () => imageToUrlString( playback.currentTrack?.album?.images, @@ -200,73 +63,6 @@ class Player extends HookConsumerWidget { } } - final paletteColor = usePaletteColor(albumArt); - - final controls = PlayerControls( - iconColor: paletteColor.bodyTextColor, - positionStream: player.positionStream, - isPlaying: _isPlaying.value, - duration: _duration.value ?? Duration.zero, - shuffled: _shuffled.value, - onNext: _onNext, - onPrevious: _onPrevious, - onPause: () async { - try { - await player.pause(); - } catch (e, stack) { - print("[PlayerControls.onPause()] $e"); - print(stack); - } - }, - onPlay: () async { - try { - await player.play(); - } catch (e, stack) { - print("[PlayerControls.onPlay()] $e"); - print(stack); - } - }, - onSeek: (value) async { - try { - await player.seek(Duration(seconds: value.toInt())); - } catch (e, stack) { - print("[PlayerControls.onSeek()] $e"); - print(stack); - } - }, - onShuffle: () async { - if (playback.currentTrack == null || playback.currentPlaylist == null) { - return; - } - try { - if (!_shuffled.value) { - playback.currentPlaylist!.shuffle(); - _shuffled.value = true; - } else { - playback.currentPlaylist!.unshuffle(); - _shuffled.value = false; - } - } catch (e, stack) { - print("[PlayerControls.onShuffle()] $e"); - print(stack); - } - }, - onStop: () async { - try { - await player.pause(); - await player.seek(Duration.zero); - _isPlaying.value = false; - _currentTrackId.value = null; - _duration.value = null; - _shuffled.value = false; - playback.reset(); - } catch (e, stack) { - print("[PlayerControls.onStop()] $e"); - print(stack); - } - }, - ); - useEffect(() { // clearing the overlay-entry as passing the already available // entry will result in splashing while resizing the window @@ -274,11 +70,7 @@ class Player extends HookConsumerWidget { if (breakpoint.isLessThanOrEqualTo(Breakpoints.md)) { entryRef.value = OverlayEntry( opaque: false, - builder: (context) => PlayerOverlay( - controls: controls, - albumArt: albumArt, - paletteColor: paletteColor, - ), + builder: (context) => PlayerOverlay(albumArt: albumArt), ); // I can't believe useEffect doesn't run Post Frame aka // after rendering/painting the UI @@ -307,9 +99,9 @@ class Player extends HookConsumerWidget { children: [ Expanded(child: PlayerTrackDetails(albumArt: albumArt)), // controls - Flexible( + const Expanded( flex: 3, - child: controls, + child: PlayerControls(), ), // add to saved tracks Expanded( diff --git a/lib/components/Player/PlayerControls.dart b/lib/components/Player/PlayerControls.dart index c5a5e4b5..376e1165 100644 --- a/lib/components/Player/PlayerControls.dart +++ b/lib/components/Player/PlayerControls.dart @@ -1,173 +1,163 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:hotkey_manager/hotkey_manager.dart'; +import 'package:just_audio/just_audio.dart'; import 'package:spotube/helpers/zero-pad-num-str.dart'; -import 'package:spotube/hooks/useBreakpoints.dart'; -import 'package:spotube/models/GlobalKeyActions.dart'; -import 'package:spotube/provider/UserPreferences.dart'; +import 'package:spotube/hooks/playback.dart'; +import 'package:spotube/provider/Playback.dart'; class PlayerControls extends HookConsumerWidget { - final Stream positionStream; - final bool isPlaying; - final Duration duration; - final bool shuffled; - final Function? onStop; - final Function? onShuffle; - final Function(double value)? onSeek; - final Function? onNext; - final Function? onPrevious; - final Function? onPlay; - final Function? onPause; final Color? iconColor; const PlayerControls({ - required this.positionStream, - required this.isPlaying, - required this.duration, - required this.shuffled, - this.onShuffle, - this.onStop, - this.onSeek, - this.onNext, - this.onPrevious, - this.onPlay, - this.onPause, this.iconColor, Key? key, }) : super(key: key); - _playOrPause(key) async { - try { - isPlaying ? await onPause?.call() : await onPlay?.call(); - } catch (e, stack) { - print("[PlayPauseShortcut] $e"); - print(stack); - } - } - @override Widget build(BuildContext context, ref) { - UserPreferences preferences = ref.watch(userPreferencesProvider); + final Playback playback = ref.watch(playbackProvider); + final AudioPlayer player = playback.player; + + final _shuffled = useState(false); + final _duration = useState(playback.duration); - var _hotKeys = []; useEffect(() { - _hotKeys = [ - GlobalKeyActions( - HotKey(KeyCode.space, scope: HotKeyScope.inapp), - _playOrPause, - ), - if (preferences.nextTrackHotKey != null) - GlobalKeyActions( - preferences.nextTrackHotKey!, (key) => onNext?.call()), - if (preferences.prevTrackHotKey != null) - GlobalKeyActions( - preferences.prevTrackHotKey!, (key) => onPrevious?.call()), - if (preferences.playPauseHotKey != null) - GlobalKeyActions(preferences.playPauseHotKey!, _playOrPause) - ]; - Future.wait( - _hotKeys.map((e) { - return hotKeyManager.register( - e.hotKey, - keyDownHandler: e.onKeyDown, - ); - }), - ); - return () { - Future.wait(_hotKeys.map((e) => hotKeyManager.unregister(e.hotKey))); - }; - }); + listener(Duration? duration) { + _duration.value = duration; + } - final breakpoint = useBreakpoints(); + playback.addDurationChangeListener(listener); - Widget controlButtons = Material( - type: MaterialType.transparency, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - if (breakpoint.isMoreThan(Breakpoints.md)) - IconButton( - icon: const Icon(Icons.shuffle_rounded), - color: shuffled ? Theme.of(context).primaryColor : null, - onPressed: () { - onShuffle?.call(); - }), - IconButton( - icon: const Icon(Icons.skip_previous_rounded), - color: iconColor, - onPressed: () { - onPrevious?.call(); - }), - IconButton( - icon: Icon( - isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded, - ), - color: iconColor, - onPressed: () => _playOrPause(null), - ), - IconButton( - icon: const Icon(Icons.skip_next_rounded), - onPressed: () => onNext?.call(), - color: iconColor, - ), - if (breakpoint.isMoreThan(Breakpoints.md)) - IconButton( - icon: const Icon(Icons.stop_rounded), - onPressed: () => onStop?.call(), - ) - ], - ), - ); + return () => playback.removeDurationChangeListener(listener); + }, []); - if (breakpoint.isLessThanOrEqualTo(Breakpoints.md)) { - return controlButtons; - } + final onNext = useNextTrack(playback); + + final onPrevious = usePreviousTrack(playback); + + final _playOrPause = useTogglePlayPause(playback); + + final duration = _duration.value ?? Duration.zero; return Container( constraints: const BoxConstraints(maxWidth: 700), child: Column( children: [ StreamBuilder( - stream: positionStream, + stream: player.positionStream, builder: (context, snapshot) { - var totalMinutes = + final totalMinutes = zeroPadNumStr(duration.inMinutes.remainder(60)); - var totalSeconds = + final totalSeconds = zeroPadNumStr(duration.inSeconds.remainder(60)); - var currentMinutes = snapshot.hasData + final currentMinutes = snapshot.hasData ? zeroPadNumStr(snapshot.data!.inMinutes.remainder(60)) : "00"; - var currentSeconds = snapshot.hasData + final currentSeconds = snapshot.hasData ? zeroPadNumStr(snapshot.data!.inSeconds.remainder(60)) : "00"; - var sliderMax = duration.inSeconds; - var sliderValue = snapshot.data?.inSeconds ?? 0; - return Row( + final sliderMax = duration.inSeconds; + final sliderValue = snapshot.data?.inSeconds ?? 0; + return Column( children: [ - Expanded( - child: Slider.adaptive( - // cannot divide by zero - // there's an edge case for value being bigger - // than total duration. Keeping it resolved - value: (sliderMax == 0 || sliderValue > sliderMax) - ? 0 - : sliderValue / sliderMax, - onChanged: (value) {}, - onChangeEnd: (value) { - onSeek?.call(value * sliderMax); - }, + Slider.adaptive( + // cannot divide by zero + // there's an edge case for value being bigger + // than total duration. Keeping it resolved + value: (sliderMax == 0 || sliderValue > sliderMax) + ? 0 + : sliderValue / sliderMax, + onChanged: (value) {}, + onChangeEnd: (value) { + player.seek( + Duration( + seconds: (value * sliderMax).toInt(), + ), + ); + }, + activeColor: iconColor, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "$currentMinutes:$currentSeconds", + ), + Text("$totalMinutes:$totalSeconds"), + ], ), ), - Text( - "$currentMinutes:$currentSeconds/$totalMinutes:$totalSeconds", - ) ], ); }), - controlButtons, + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + IconButton( + icon: const Icon(Icons.shuffle_rounded), + color: _shuffled.value + ? Theme.of(context).primaryColor + : iconColor, + onPressed: () { + if (playback.currentTrack == null || + playback.currentPlaylist == null) { + return; + } + try { + if (!_shuffled.value) { + playback.currentPlaylist!.shuffle(); + _shuffled.value = true; + } else { + playback.currentPlaylist!.unshuffle(); + _shuffled.value = false; + } + } catch (e, stack) { + print("[PlayerControls.onShuffle()] $e"); + print(stack); + } + }), + IconButton( + icon: const Icon(Icons.skip_previous_rounded), + color: iconColor, + onPressed: () { + onPrevious(); + }), + IconButton( + icon: Icon( + playback.isPlaying + ? Icons.pause_rounded + : Icons.play_arrow_rounded, + ), + color: iconColor, + onPressed: _playOrPause, + ), + IconButton( + icon: const Icon(Icons.skip_next_rounded), + onPressed: () => onNext(), + color: iconColor, + ), + IconButton( + icon: const Icon(Icons.stop_rounded), + color: iconColor, + onPressed: playback.currentTrack != null + ? () async { + try { + await player.pause(); + await player.seek(Duration.zero); + _shuffled.value = false; + playback.reset(); + } catch (e, stack) { + print("[PlayerControls.onStop()] $e"); + print(stack); + } + } + : null, + ) + ], + ), ], )); } diff --git a/lib/components/Player/PlayerOverlay.dart b/lib/components/Player/PlayerOverlay.dart index a64662bb..b3140fb7 100644 --- a/lib/components/Player/PlayerOverlay.dart +++ b/lib/components/Player/PlayerOverlay.dart @@ -1,40 +1,38 @@ import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; -import 'package:palette_generator/palette_generator.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/components/Player/PlayerTrackDetails.dart'; +import 'package:spotube/hooks/playback.dart'; import 'package:spotube/hooks/useBreakpoints.dart'; +import 'package:spotube/hooks/useIsCurrentRoute.dart'; +import 'package:spotube/hooks/usePaletteColor.dart'; +import 'package:spotube/provider/Playback.dart'; -class PlayerOverlay extends HookWidget { - final Widget controls; +class PlayerOverlay extends HookConsumerWidget { final String albumArt; - final PaletteColor paletteColor; + const PlayerOverlay({ - required this.controls, required this.albumArt, - required this.paletteColor, Key? key, }) : super(key: key); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, ref) { final breakpoint = useBreakpoints(); - final isCurrentRoute = useState(null); + final isCurrentRoute = useIsCurrentRoute("/"); + final paletteColor = usePaletteColor(context, albumArt); + final playback = ref.watch(playbackProvider); - useEffect(() { - WidgetsBinding.instance?.addPostFrameCallback((timer) { - final matches = GoRouter.of(context).location == "/"; - if (matches != isCurrentRoute.value) { - isCurrentRoute.value = matches; - } - }); - return null; - }); - - if (isCurrentRoute.value == false) { + if (isCurrentRoute == false) { return Container(); } + final onNext = useNextTrack(playback); + + final onPrevious = usePreviousTrack(playback); + + final _playOrPause = useTogglePlayPause(playback); + return Positioned( right: (breakpoint.isMd ? 10 : 5), left: (breakpoint.isSm ? 5 : 80), @@ -46,17 +44,49 @@ class PlayerOverlay extends HookWidget { color: paletteColor.color, borderRadius: BorderRadius.circular(5), ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: PlayerTrackDetails( - albumArt: albumArt, - color: paletteColor.bodyTextColor, + child: Material( + type: MaterialType.transparency, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: GestureDetector( + onTap: () => GoRouter.of(context).push( + "/player", + extra: paletteColor, + ), + child: PlayerTrackDetails( + albumArt: albumArt, + color: paletteColor.bodyTextColor, + ), + ), ), - ), - Expanded(child: controls), - ], + Row( + children: [ + IconButton( + icon: const Icon(Icons.skip_previous_rounded), + color: paletteColor.bodyTextColor, + onPressed: () { + onPrevious(); + }), + IconButton( + icon: Icon( + playback.isPlaying + ? Icons.pause_rounded + : Icons.play_arrow_rounded, + ), + color: paletteColor.bodyTextColor, + onPressed: _playOrPause, + ), + IconButton( + icon: const Icon(Icons.skip_next_rounded), + onPressed: () => onNext(), + color: paletteColor.bodyTextColor, + ), + ], + ), + ], + ), ), ), ); diff --git a/lib/components/Player/PlayerView.dart b/lib/components/Player/PlayerView.dart new file mode 100644 index 00000000..c036bcdb --- /dev/null +++ b/lib/components/Player/PlayerView.dart @@ -0,0 +1,98 @@ +import 'dart:math'; + +import 'package:cached_network_image/cached_network_image.dart'; +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:palette_generator/palette_generator.dart'; +import 'package:spotube/components/Player/PlayerControls.dart'; +import 'package:spotube/components/Shared/PageWindowTitleBar.dart'; +import 'package:spotube/helpers/artists-to-clickable-artists.dart'; +import 'package:spotube/helpers/image-to-url-string.dart'; +import 'package:spotube/hooks/useBreakpoints.dart'; +import 'package:spotube/provider/Playback.dart'; + +class PlayerView extends HookConsumerWidget { + final PaletteColor paletteColor; + const PlayerView({ + required this.paletteColor, + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final currentTrack = ref.watch(playbackProvider.select( + (value) => value.currentTrack, + )); + final breakpoint = useBreakpoints(); + + useEffect(() { + if (breakpoint.isMoreThan(Breakpoints.md)) { + WidgetsBinding.instance?.addPostFrameCallback((_) { + GoRouter.of(context).pop(); + }); + } + return null; + }, [breakpoint]); + + String albumArt = useMemoized( + () => imageToUrlString( + currentTrack?.album?.images, + index: (currentTrack?.album?.images?.length ?? 1) - 1, + ), + [currentTrack?.album?.images], + ); + + return Scaffold( + appBar: const PageWindowTitleBar( + leading: BackButton(), + ), + backgroundColor: paletteColor.color, + body: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + children: [ + Text( + currentTrack?.name ?? "Not playing", + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.headline4?.copyWith( + fontWeight: FontWeight.bold, + color: paletteColor.titleTextColor, + ), + ), + artistsToClickableArtists( + currentTrack?.artists ?? [], + mainAxisAlignment: MainAxisAlignment.center, + textStyle: Theme.of(context).textTheme.headline6!.copyWith( + fontWeight: FontWeight.bold, + color: paletteColor.bodyTextColor, + ), + ), + ], + ), + HookBuilder(builder: (context) { + final ticker = useSingleTickerProvider(); + final controller = useAnimationController( + duration: const Duration(seconds: 10), + vsync: ticker, + )..repeat(); + return RotationTransition( + turns: Tween(begin: 0.0, end: 1.0).animate(controller), + child: CircleAvatar( + backgroundImage: CachedNetworkImageProvider( + albumArt, + cacheKey: albumArt, + ), + radius: MediaQuery.of(context).size.width * + (breakpoint.isSm ? 0.4 : 0.3), + ), + ); + }), + PlayerControls(iconColor: paletteColor.bodyTextColor), + ], + ), + ); + } +} diff --git a/lib/components/Playlist/PlaylistCard.dart b/lib/components/Playlist/PlaylistCard.dart index 8f854c41..95f2c60c 100644 --- a/lib/components/Playlist/PlaylistCard.dart +++ b/lib/components/Playlist/PlaylistCard.dart @@ -54,6 +54,7 @@ class PlaylistCard extends HookConsumerWidget { thumbnail: imageToUrlString(playlist.images), ); playback.setCurrentTrack = tracks.first; + await playback.startPlaying(); }, ); } diff --git a/lib/components/Playlist/PlaylistView.dart b/lib/components/Playlist/PlaylistView.dart index c615e3cd..1c8ef708 100644 --- a/lib/components/Playlist/PlaylistView.dart +++ b/lib/components/Playlist/PlaylistView.dart @@ -11,7 +11,8 @@ class PlaylistView extends ConsumerWidget { final PlaylistSimple playlist; const PlaylistView(this.playlist, {Key? key}) : super(key: key); - playPlaylist(Playback playback, List tracks, {Track? currentTrack}) { + playPlaylist(Playback playback, List tracks, + {Track? currentTrack}) async { currentTrack ??= tracks.first; var isPlaylistPlaying = playback.currentPlaylist?.id != null && playback.currentPlaylist?.id == playlist.id; @@ -28,6 +29,7 @@ class PlaylistView extends ConsumerWidget { currentTrack.id != playback.currentTrack?.id) { playback.setCurrentTrack = currentTrack; } + await playback.startPlaying(); } @override diff --git a/lib/components/Search/Search.dart b/lib/components/Search/Search.dart index 07953903..6dd2e493 100644 --- a/lib/components/Search/Search.dart +++ b/lib/components/Search/Search.dart @@ -125,6 +125,7 @@ class Search extends HookConsumerWidget { playback.currentTrack?.id) { playback.setCurrentTrack = currentTrack; } + await playback.startPlaying(); }, ); }), diff --git a/lib/hooks/playback.dart b/lib/hooks/playback.dart new file mode 100644 index 00000000..5fe1bdce --- /dev/null +++ b/lib/hooks/playback.dart @@ -0,0 +1,42 @@ +import 'package:spotube/provider/Playback.dart'; + +Future Function() useNextTrack(Playback playback) { + return () async { + try { + await playback.player.pause(); + await playback.player.seek(Duration.zero); + playback.movePlaylistPositionBy(1); + } catch (e, stack) { + print("[PlayerControls.onNext()] $e"); + print(stack); + } + }; +} + +Future Function() usePreviousTrack(Playback playback) { + return () async { + try { + await playback.player.pause(); + await playback.player.seek(Duration.zero); + playback.movePlaylistPositionBy(-1); + } catch (e, stack) { + print("[PlayerControls.onPrevious()] $e"); + print(stack); + } + }; +} + +Future Function([dynamic]) useTogglePlayPause(Playback playback) { + return ([key]) async { + print("CLICK CLICK"); + try { + if (playback.currentTrack == null) return; + playback.isPlaying + ? await playback.player.pause() + : await playback.player.play(); + } catch (e, stack) { + print("[PlayPauseShortcut] $e"); + print(stack); + } + }; +} diff --git a/lib/hooks/useHotKeys.dart b/lib/hooks/useHotKeys.dart new file mode 100644 index 00000000..2ccfb349 --- /dev/null +++ b/lib/hooks/useHotKeys.dart @@ -0,0 +1,45 @@ +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:hotkey_manager/hotkey_manager.dart'; +import 'package:spotube/hooks/playback.dart'; +import 'package:spotube/models/GlobalKeyActions.dart'; +import 'package:spotube/provider/Playback.dart'; +import 'package:spotube/provider/UserPreferences.dart'; + +useHotKeys(WidgetRef ref) { + final playback = ref.watch(playbackProvider); + final preferences = ref.watch(userPreferencesProvider); + List _hotKeys = []; + + final onNext = useNextTrack(playback); + + final onPrevious = usePreviousTrack(playback); + + final _playOrPause = useTogglePlayPause(playback); + + useEffect(() { + _hotKeys = [ + GlobalKeyActions( + HotKey(KeyCode.space, scope: HotKeyScope.inapp), + _playOrPause, + ), + if (preferences.nextTrackHotKey != null) + GlobalKeyActions(preferences.nextTrackHotKey!, (key) => onNext()), + if (preferences.prevTrackHotKey != null) + GlobalKeyActions(preferences.prevTrackHotKey!, (key) => onPrevious()), + if (preferences.playPauseHotKey != null) + GlobalKeyActions(preferences.playPauseHotKey!, _playOrPause) + ]; + Future.wait( + _hotKeys.map((e) { + return hotKeyManager.register( + e.hotKey, + keyDownHandler: e.onKeyDown, + ); + }), + ); + return () { + Future.wait(_hotKeys.map((e) => hotKeyManager.unregister(e.hotKey))); + }; + }); +} diff --git a/lib/hooks/useIsCurrentRoute.dart b/lib/hooks/useIsCurrentRoute.dart new file mode 100644 index 00000000..eeb1ff77 --- /dev/null +++ b/lib/hooks/useIsCurrentRoute.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; + +bool? useIsCurrentRoute([String matcher = "/"]) { + final isCurrentRoute = useState(null); + final context = useContext(); + useEffect(() { + WidgetsBinding.instance?.addPostFrameCallback((timer) { + final isCurrent = GoRouter.of(context).location == matcher; + if (isCurrent != isCurrentRoute.value) { + isCurrentRoute.value = isCurrent; + } + }); + return null; + }); + return isCurrentRoute.value; +} diff --git a/lib/hooks/usePaletteColor.dart b/lib/hooks/usePaletteColor.dart index 75a21ca4..6e19c8e1 100644 --- a/lib/hooks/usePaletteColor.dart +++ b/lib/hooks/usePaletteColor.dart @@ -3,21 +3,22 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:palette_generator/palette_generator.dart'; -PaletteColor usePaletteColor(String imageUrl) { +PaletteColor usePaletteColor(BuildContext context, imageUrl) { final paletteColor = useState(PaletteColor(Colors.grey[300]!, 0)); - - final context = useContext(); + final mounted = useIsMounted(); useEffect(() { - PaletteGenerator.fromImageProvider( - CachedNetworkImageProvider( - imageUrl, - cacheKey: imageUrl, - maxHeight: 50, - maxWidth: 50, - ), - ).then((palette) { + WidgetsBinding.instance?.addPostFrameCallback((timeStamp) async { + final palette = await PaletteGenerator.fromImageProvider( + CachedNetworkImageProvider( + imageUrl, + cacheKey: imageUrl, + maxHeight: 50, + maxWidth: 50, + ), + ); + if (!mounted()) return; final color = Theme.of(context).brightness == Brightness.light ? palette.lightMutedColor ?? palette.lightVibrantColor : palette.darkMutedColor ?? palette.darkVibrantColor; diff --git a/lib/main.dart b/lib/main.dart index ddf5ad8c..a3a3c56c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,16 +7,11 @@ import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hotkey_manager/hotkey_manager.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/components/Album/AlbumView.dart'; -import 'package:spotube/components/Artist/ArtistAlbumView.dart'; -import 'package:spotube/components/Artist/ArtistProfile.dart'; -import 'package:spotube/components/Home/Home.dart'; -import 'package:spotube/components/Playlist/PlaylistView.dart'; -import 'package:spotube/components/Settings.dart'; -import 'package:spotube/components/Shared/SpotubePageRoute.dart'; +import 'package:spotube/models/GoRouteDeclarations.dart'; import 'package:spotube/models/LocalStorageKeys.dart'; +import 'package:spotube/provider/AudioPlayer.dart'; import 'package:spotube/provider/ThemeProvider.dart'; +import 'package:spotube/provider/YouTube.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -33,59 +28,12 @@ void main() async { } class MyApp extends HookConsumerWidget { - final GoRouter _router = GoRouter( - routes: [ - GoRoute( - path: "/", - builder: (context, state) => const Home(), - ), - GoRoute( - path: "/settings", - pageBuilder: (context, state) => SpotubePage( - child: const Settings(), - ), - ), - GoRoute( - path: "/album/:id", - pageBuilder: (context, state) { - assert(state.extra is AlbumSimple); - return SpotubePage(child: AlbumView(state.extra as AlbumSimple)); - }, - ), - GoRoute( - path: "/artist/:id", - pageBuilder: (context, state) { - assert(state.params["id"] != null); - return SpotubePage(child: ArtistProfile(state.params["id"]!)); - }, - ), - GoRoute( - path: "/artist-album/:id", - pageBuilder: (context, state) { - assert(state.params["id"] != null); - assert(state.extra is String); - return SpotubePage( - child: ArtistAlbumView( - state.params["id"]!, - state.extra as String, - ), - ); - }, - ), - GoRoute( - path: "/playlist/:id", - pageBuilder: (context, state) { - assert(state.extra is PlaylistSimple); - return SpotubePage( - child: PlaylistView(state.extra as PlaylistSimple), - ); - }, - ), - ], - ); + final GoRouter _router = createGoRouter(); @override Widget build(BuildContext context, ref) { var themeMode = ref.watch(themeProvider); + var player = ref.watch(audioPlayerProvider); + var youtube = ref.watch(youtubeProvider); useEffect(() { SharedPreferences.getInstance().then((localStorage) { String? themeMode = localStorage.getString(LocalStorageKeys.themeMode); @@ -102,7 +50,10 @@ class MyApp extends HookConsumerWidget { themeNotifier.state = ThemeMode.system; } }); - return null; + return () { + player.dispose(); + youtube.close(); + }; }, []); return MaterialApp.router( diff --git a/lib/models/GoRouteDeclarations.dart b/lib/models/GoRouteDeclarations.dart new file mode 100644 index 00000000..926c2213 --- /dev/null +++ b/lib/models/GoRouteDeclarations.dart @@ -0,0 +1,74 @@ +import 'package:go_router/go_router.dart'; +import 'package:palette_generator/palette_generator.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/components/Album/AlbumView.dart'; +import 'package:spotube/components/Artist/ArtistAlbumView.dart'; +import 'package:spotube/components/Artist/ArtistProfile.dart'; +import 'package:spotube/components/Home/Home.dart'; +import 'package:spotube/components/Player/PlayerControls.dart'; +import 'package:spotube/components/Player/PlayerView.dart'; +import 'package:spotube/components/Playlist/PlaylistView.dart'; +import 'package:spotube/components/Settings.dart'; +import 'package:spotube/components/Shared/SpotubePageRoute.dart'; + +GoRouter createGoRouter() => GoRouter( + routes: [ + GoRoute( + path: "/", + builder: (context, state) => const Home(), + ), + GoRoute( + path: "/settings", + pageBuilder: (context, state) => SpotubePage( + child: const Settings(), + ), + ), + GoRoute( + path: "/album/:id", + pageBuilder: (context, state) { + assert(state.extra is AlbumSimple); + return SpotubePage(child: AlbumView(state.extra as AlbumSimple)); + }, + ), + GoRoute( + path: "/artist/:id", + pageBuilder: (context, state) { + assert(state.params["id"] != null); + return SpotubePage(child: ArtistProfile(state.params["id"]!)); + }, + ), + GoRoute( + path: "/artist-album/:id", + pageBuilder: (context, state) { + assert(state.params["id"] != null); + assert(state.extra is String); + return SpotubePage( + child: ArtistAlbumView( + state.params["id"]!, + state.extra as String, + ), + ); + }, + ), + GoRoute( + path: "/playlist/:id", + pageBuilder: (context, state) { + assert(state.extra is PlaylistSimple); + return SpotubePage( + child: PlaylistView(state.extra as PlaylistSimple), + ); + }, + ), + GoRoute( + path: "/player", + pageBuilder: (context, state) { + assert(state.extra is PaletteColor); + return SpotubePage( + child: PlayerView( + paletteColor: state.extra as PaletteColor, + ), + ); + }, + ) + ], + ); diff --git a/lib/provider/AudioPlayer.dart b/lib/provider/AudioPlayer.dart new file mode 100644 index 00000000..6aff379a --- /dev/null +++ b/lib/provider/AudioPlayer.dart @@ -0,0 +1,6 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:just_audio/just_audio.dart'; + +final audioPlayerProvider = Provider((ref) { + return AudioPlayer(); +}); diff --git a/lib/provider/Playback.dart b/lib/provider/Playback.dart index b8978555..a94fc842 100644 --- a/lib/provider/Playback.dart +++ b/lib/provider/Playback.dart @@ -1,6 +1,13 @@ +import 'dart:async'; + import 'package:flutter/cupertino.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:just_audio/just_audio.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/helpers/search-youtube.dart'; +import 'package:spotube/provider/AudioPlayer.dart'; +import 'package:spotube/provider/YouTube.dart'; +import 'package:youtube_explode_dart/youtube_explode_dart.dart'; class CurrentPlaylist { List? _tempTrack; @@ -8,6 +15,7 @@ class CurrentPlaylist { String id; String name; String thumbnail; + CurrentPlaylist({ required this.tracks, required this.id, @@ -37,13 +45,95 @@ class CurrentPlaylist { class Playback extends ChangeNotifier { CurrentPlaylist? _currentPlaylist; Track? _currentTrack; - Playback({CurrentPlaylist? currentPlaylist, Track? currentTrack}) { - _currentPlaylist = currentPlaylist; - _currentTrack = currentTrack; + + // states + bool _isPlaying = false; + Duration? _duration; + + // using custom listeners for duration as it changes super quickly + // which will cause re-renders in components that don't even need it + // thus only allowing to listen to change in duration through only + // a listener function + List _durationListeners = []; + + // listeners + StreamSubscription? _playingStreamListener; + StreamSubscription? _durationStreamListener; + StreamSubscription? _processingStateStreamListener; + + AudioPlayer player; + YoutubeExplode youtube; + Playback({ + required this.player, + required this.youtube, + CurrentPlaylist? currentPlaylist, + Track? currentTrack, + }) : _currentPlaylist = currentPlaylist, + _currentTrack = currentTrack { + _playingStreamListener = player.playingStream.listen( + (playing) { + _isPlaying = playing; + notifyListeners(); + }, + ); + + _durationStreamListener = player.durationStream.listen((duration) async { + if (duration != null) { + // Actually things doesn't work all the time as they were + // described. So instead of listening to a `_ready` + // stream, it has to listen to duration stream since duration + // is always added to the Stream sink after all icyMetadata has + // been loaded thus indicating buffering started + if (duration != Duration.zero && duration != _duration) { + // this line is for prev/next or already playing playlist + if (player.playing) await player.pause(); + await player.play(); + } + _duration = duration; + _callAllDurationListeners(duration); + // for avoiding unnecessary re-renders in other components that + // doesn't need duration + } + }); + + _processingStateStreamListener = + player.processingStateStream.listen((event) async { + try { + if (event == ProcessingState.completed && _currentTrack?.id != null) { + movePlaylistPositionBy(1); + } + } catch (e, stack) { + print("[PrecessingStateStreamListener] $e"); + print(stack); + } + }); } CurrentPlaylist? get currentPlaylist => _currentPlaylist; Track? get currentTrack => _currentTrack; + bool get isPlaying => _isPlaying; + + /// this duration field is almost static & changes occasionally + /// + /// If you want realtime duration with state-update/re-render + /// use custom state & the [addDurationChangeListener] function to do so + Duration? get duration => _duration; + + _callAllDurationListeners(Duration? arg) { + for (var listener in _durationListeners) { + listener(arg); + } + } + + void addDurationChangeListener(void Function(Duration? duration) listener) { + _durationListeners.add(listener); + } + + void removeDurationChangeListener( + void Function(Duration? duration) listener) { + _durationListeners = + _durationListeners.where((p) => p != listener).toList(); + } set setCurrentTrack(Track track) { _currentTrack = track; @@ -55,7 +145,10 @@ class Playback extends ChangeNotifier { notifyListeners(); } - reset() { + void reset() { + _isPlaying = false; + _duration = null; + _callAllDurationListeners(null); _currentPlaylist = null; _currentTrack = null; notifyListeners(); @@ -76,6 +169,77 @@ class Playback extends ChangeNotifier { return false; } } + + @override + dispose() { + _processingStateStreamListener?.cancel(); + _durationStreamListener?.cancel(); + _playingStreamListener?.cancel(); + super.dispose(); + } + + movePlaylistPositionBy(int pos) { + if (_currentTrack != null && _currentPlaylist != null) { + int index = _currentPlaylist!.trackIds.indexOf(_currentTrack!.id!) + pos; + + var safeIndex = index > _currentPlaylist!.trackIds.length - 1 + ? 0 + : index < 0 + ? _currentPlaylist!.trackIds.length + : index; + Track? track = _currentPlaylist!.tracks.asMap().containsKey(safeIndex) + ? _currentPlaylist!.tracks.elementAt(safeIndex) + : null; + if (track != null) { + _duration = null; + _callAllDurationListeners(null); + _currentTrack = track; + notifyListeners(); + // starts to play the newly entered next/prev track + startPlaying(); + } + } + } + + Future startPlaying([Track? track]) async { + try { + // the track is already playing so no need to change that + if (track != null && track.id == _currentTrack?.id) return; + track ??= _currentTrack; + if (track != null) { + Uri? parsedUri = Uri.tryParse(track.uri ?? ""); + if (parsedUri != null && parsedUri.hasAbsolutePath) { + await player + .setAudioSource( + AudioSource.uri(parsedUri), + preload: true, + ) + .then((value) async { + _currentTrack = track; + _duration = value; + _callAllDurationListeners(value); + notifyListeners(); + }); + } + final ytTrack = await toYoutubeTrack(youtube, track); + if (setTrackUriById(track.id!, ytTrack.uri!)) { + await player + .setAudioSource(AudioSource.uri(Uri.parse(ytTrack.uri!))) + .then((value) { + _currentTrack = track; + notifyListeners(); + }); + } + } + } catch (e, stack) { + print("[Playback.startPlaying] $e"); + print(stack); + } + } } -var playbackProvider = ChangeNotifierProvider((_) => Playback()); +final playbackProvider = ChangeNotifierProvider((ref) { + final player = ref.watch(audioPlayerProvider); + final youtube = ref.watch(youtubeProvider); + return Playback(player: player, youtube: youtube); +}); diff --git a/lib/provider/YouTube.dart b/lib/provider/YouTube.dart new file mode 100644 index 00000000..d96f8c1f --- /dev/null +++ b/lib/provider/YouTube.dart @@ -0,0 +1,4 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:youtube_explode_dart/youtube_explode_dart.dart'; + +final youtubeProvider = Provider((ref) => YoutubeExplode());