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
This commit is contained in:
Kingkor Roy Tirtho 2022-03-12 19:10:21 +06:00
parent aaf74b46d4
commit 932462d773
21 changed files with 689 additions and 457 deletions

View File

@ -50,6 +50,7 @@ class AlbumCard extends HookConsumerWidget {
thumbnail: album.images!.first.url!, thumbnail: album.images!.first.url!,
); );
playback.setCurrentTrack = tracks.first; playback.setCurrentTrack = tracks.first;
await playback.startPlaying();
}, },
); );
} }

View File

@ -12,7 +12,8 @@ class AlbumView extends ConsumerWidget {
final AlbumSimple album; final AlbumSimple album;
const AlbumView(this.album, {Key? key}) : super(key: key); const AlbumView(this.album, {Key? key}) : super(key: key);
playPlaylist(Playback playback, List<Track> tracks, {Track? currentTrack}) { playPlaylist(Playback playback, List<Track> tracks,
{Track? currentTrack}) async {
currentTrack ??= tracks.first; currentTrack ??= tracks.first;
var isPlaylistPlaying = playback.currentPlaylist?.id == album.id; var isPlaylistPlaying = playback.currentPlaylist?.id == album.id;
if (!isPlaylistPlaying) { if (!isPlaylistPlaying) {
@ -28,6 +29,7 @@ class AlbumView extends ConsumerWidget {
currentTrack.id != playback.currentTrack?.id) { currentTrack.id != playback.currentTrack?.id) {
playback.setCurrentTrack = currentTrack; playback.setCurrentTrack = currentTrack;
} }
await playback.startPlaying();
} }
@override @override

View File

@ -6,10 +6,8 @@ import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/components/Album/AlbumCard.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/Artist/ArtistCard.dart';
import 'package:spotube/components/Shared/PageWindowTitleBar.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/components/Shared/TracksTableView.dart';
import 'package:spotube/helpers/image-to-url-string.dart'; import 'package:spotube/helpers/image-to-url-string.dart';
import 'package:spotube/helpers/readable-number.dart'; import 'package:spotube/helpers/readable-number.dart';
@ -162,7 +160,8 @@ class ArtistProfile extends HookConsumerWidget {
Playback playback = ref.watch(playbackProvider); Playback playback = ref.watch(playbackProvider);
var isPlaylistPlaying = var isPlaylistPlaying =
playback.currentPlaylist?.id == snapshot.data?.id; playback.currentPlaylist?.id == snapshot.data?.id;
playPlaylist(List<Track> tracks, {Track? currentTrack}) { playPlaylist(List<Track> tracks,
{Track? currentTrack}) async {
currentTrack ??= tracks.first; currentTrack ??= tracks.first;
if (!isPlaylistPlaying) { if (!isPlaylistPlaying) {
playback.setCurrentPlaylist = CurrentPlaylist( playback.setCurrentPlaylist = CurrentPlaylist(
@ -177,6 +176,7 @@ class ArtistProfile extends HookConsumerWidget {
currentTrack.id != playback.currentTrack?.id) { currentTrack.id != playback.currentTrack?.id) {
playback.setCurrentTrack = currentTrack; playback.setCurrentTrack = currentTrack;
} }
await playback.startPlaying();
} }
return Column(children: [ return Column(children: [

View File

@ -39,11 +39,14 @@ class CategoryCard extends HookWidget {
usePagingController<int, PlaylistSimple>(firstPageKey: 0); usePagingController<int, PlaylistSimple>(firstPageKey: 0);
final _error = useState(false); final _error = useState(false);
final mounted = useIsMounted();
useEffect(() { useEffect(() {
listener(pageKey) async { listener(pageKey) async {
try { try {
if (playlists != null && playlists?.isNotEmpty == true) { if (playlists != null &&
playlists?.isNotEmpty == true &&
mounted()) {
return pagingController.appendLastPage(playlists!.toList()); return pagingController.appendLastPage(playlists!.toList());
} }
final Page<PlaylistSimple> page = await (category.id != final Page<PlaylistSimple> page = await (category.id !=
@ -52,6 +55,7 @@ class CategoryCard extends HookWidget {
: spotifyApi.playlists.featured) : spotifyApi.playlists.featured)
.getPage(3, pageKey); .getPage(3, pageKey);
if (!mounted()) return;
if (page.isLast && page.items != null) { if (page.isLast && page.items != null) {
pagingController.appendLastPage(page.items!.toList()); pagingController.appendLastPage(page.items!.toList());
} else if (page.items != null) { } else if (page.items != null) {
@ -60,8 +64,10 @@ class CategoryCard extends HookWidget {
} }
if (_error.value) _error.value = false; if (_error.value) _error.value = false;
} catch (e, stack) { } catch (e, stack) {
if (!_error.value) _error.value = true; if (mounted()) {
pagingController.error = e; if (!_error.value) _error.value = true;
pagingController.error = e;
}
print( print(
"[CategoryCard.pagingController.addPageRequestListener] $e"); "[CategoryCard.pagingController.addPageRequestListener] $e");
print(stack); print(stack);

View File

@ -19,6 +19,7 @@ import 'package:spotube/components/Player/Player.dart';
import 'package:spotube/components/Library/UserLibrary.dart'; import 'package:spotube/components/Library/UserLibrary.dart';
import 'package:spotube/helpers/oauth-login.dart'; import 'package:spotube/helpers/oauth-login.dart';
import 'package:spotube/hooks/useBreakpointValue.dart'; import 'package:spotube/hooks/useBreakpointValue.dart';
import 'package:spotube/hooks/useHotKeys.dart';
import 'package:spotube/hooks/usePagingController.dart'; import 'package:spotube/hooks/usePagingController.dart';
import 'package:spotube/hooks/useSharedPreferences.dart'; import 'package:spotube/hooks/useSharedPreferences.dart';
import 'package:spotube/models/LocalStorageKeys.dart'; import 'package:spotube/models/LocalStorageKeys.dart';
@ -56,6 +57,9 @@ class Home extends HookConsumerWidget {
final localStorage = useSharedPreferences(); final localStorage = useSharedPreferences();
// initializing global hot keys
useHotKeys(ref);
useEffect(() { useEffect(() {
if (localStorage == null) return null; if (localStorage == null) return null;
final String? clientId = final String? clientId =

View File

@ -10,15 +10,11 @@ import 'package:spotube/components/Player/PlayerTrackDetails.dart';
import 'package:spotube/components/Shared/DownloadTrackButton.dart'; import 'package:spotube/components/Shared/DownloadTrackButton.dart';
import 'package:spotube/components/Player/PlayerControls.dart'; import 'package:spotube/components/Player/PlayerControls.dart';
import 'package:spotube/helpers/image-to-url-string.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/useBreakpoints.dart';
import 'package:spotube/hooks/usePaletteColor.dart';
import 'package:spotube/models/LocalStorageKeys.dart'; import 'package:spotube/models/LocalStorageKeys.dart';
import 'package:spotube/provider/Playback.dart'; import 'package:spotube/provider/Playback.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:spotube/provider/SpotifyDI.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 { class Player extends HookConsumerWidget {
const Player({Key? key}) : super(key: key); const Player({Key? key}) : super(key: key);
@ -26,88 +22,17 @@ class Player extends HookConsumerWidget {
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
Playback playback = ref.watch(playbackProvider); Playback playback = ref.watch(playbackProvider);
final _isPlaying = useState(false);
final _shuffled = useState(false);
final _volume = useState(0.0); final _volume = useState(0.0);
final _duration = useState<Duration?>(null);
final _currentTrackId = useState<String?>(null);
final breakpoint = useBreakpoints(); final breakpoint = useBreakpoints();
final AudioPlayer player = useMemoized(() => AudioPlayer(), []); final AudioPlayer player = playback.player;
final YoutubeExplode youtube = useMemoized(() => YoutubeExplode(), []);
final Future<SharedPreferences> future = final Future<SharedPreferences> future =
useMemoized(SharedPreferences.getInstance); useMemoized(SharedPreferences.getInstance);
final AsyncSnapshot<SharedPreferences?> localStorage = final AsyncSnapshot<SharedPreferences?> localStorage =
useFuture(future, initialData: null); 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(() { useEffect(() {
if (localStorage.hasData) { if (localStorage.hasData) {
_volume.value = localStorage.data?.getDouble(LocalStorageKeys.volume) ?? _volume.value = localStorage.data?.getDouble(LocalStorageKeys.volume) ??
@ -116,68 +41,6 @@ class Player extends HookConsumerWidget {
return null; return null;
}, [localStorage.data]); }, [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( String albumArt = useMemoized(
() => imageToUrlString( () => imageToUrlString(
playback.currentTrack?.album?.images, 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(() { useEffect(() {
// clearing the overlay-entry as passing the already available // clearing the overlay-entry as passing the already available
// entry will result in splashing while resizing the window // entry will result in splashing while resizing the window
@ -274,11 +70,7 @@ class Player extends HookConsumerWidget {
if (breakpoint.isLessThanOrEqualTo(Breakpoints.md)) { if (breakpoint.isLessThanOrEqualTo(Breakpoints.md)) {
entryRef.value = OverlayEntry( entryRef.value = OverlayEntry(
opaque: false, opaque: false,
builder: (context) => PlayerOverlay( builder: (context) => PlayerOverlay(albumArt: albumArt),
controls: controls,
albumArt: albumArt,
paletteColor: paletteColor,
),
); );
// I can't believe useEffect doesn't run Post Frame aka // I can't believe useEffect doesn't run Post Frame aka
// after rendering/painting the UI // after rendering/painting the UI
@ -307,9 +99,9 @@ class Player extends HookConsumerWidget {
children: [ children: [
Expanded(child: PlayerTrackDetails(albumArt: albumArt)), Expanded(child: PlayerTrackDetails(albumArt: albumArt)),
// controls // controls
Flexible( const Expanded(
flex: 3, flex: 3,
child: controls, child: PlayerControls(),
), ),
// add to saved tracks // add to saved tracks
Expanded( Expanded(

View File

@ -1,173 +1,163 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/helpers/zero-pad-num-str.dart';
import 'package:spotube/hooks/useBreakpoints.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';
class PlayerControls extends HookConsumerWidget { class PlayerControls extends HookConsumerWidget {
final Stream<Duration> 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; final Color? iconColor;
const PlayerControls({ 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, this.iconColor,
Key? key, Key? key,
}) : super(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 @override
Widget build(BuildContext context, ref) { 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<Duration?>(playback.duration);
var _hotKeys = [];
useEffect(() { useEffect(() {
_hotKeys = [ listener(Duration? duration) {
GlobalKeyActions( _duration.value = duration;
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)));
};
});
final breakpoint = useBreakpoints(); playback.addDurationChangeListener(listener);
Widget controlButtons = Material( return () => playback.removeDurationChangeListener(listener);
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(),
)
],
),
);
if (breakpoint.isLessThanOrEqualTo(Breakpoints.md)) { final onNext = useNextTrack(playback);
return controlButtons;
} final onPrevious = usePreviousTrack(playback);
final _playOrPause = useTogglePlayPause(playback);
final duration = _duration.value ?? Duration.zero;
return Container( return Container(
constraints: const BoxConstraints(maxWidth: 700), constraints: const BoxConstraints(maxWidth: 700),
child: Column( child: Column(
children: [ children: [
StreamBuilder<Duration>( StreamBuilder<Duration>(
stream: positionStream, stream: player.positionStream,
builder: (context, snapshot) { builder: (context, snapshot) {
var totalMinutes = final totalMinutes =
zeroPadNumStr(duration.inMinutes.remainder(60)); zeroPadNumStr(duration.inMinutes.remainder(60));
var totalSeconds = final totalSeconds =
zeroPadNumStr(duration.inSeconds.remainder(60)); zeroPadNumStr(duration.inSeconds.remainder(60));
var currentMinutes = snapshot.hasData final currentMinutes = snapshot.hasData
? zeroPadNumStr(snapshot.data!.inMinutes.remainder(60)) ? zeroPadNumStr(snapshot.data!.inMinutes.remainder(60))
: "00"; : "00";
var currentSeconds = snapshot.hasData final currentSeconds = snapshot.hasData
? zeroPadNumStr(snapshot.data!.inSeconds.remainder(60)) ? zeroPadNumStr(snapshot.data!.inSeconds.remainder(60))
: "00"; : "00";
var sliderMax = duration.inSeconds; final sliderMax = duration.inSeconds;
var sliderValue = snapshot.data?.inSeconds ?? 0; final sliderValue = snapshot.data?.inSeconds ?? 0;
return Row( return Column(
children: [ children: [
Expanded( Slider.adaptive(
child: Slider.adaptive( // cannot divide by zero
// cannot divide by zero // there's an edge case for value being bigger
// there's an edge case for value being bigger // than total duration. Keeping it resolved
// than total duration. Keeping it resolved value: (sliderMax == 0 || sliderValue > sliderMax)
value: (sliderMax == 0 || sliderValue > sliderMax) ? 0
? 0 : sliderValue / sliderMax,
: sliderValue / sliderMax, onChanged: (value) {},
onChanged: (value) {}, onChangeEnd: (value) {
onChangeEnd: (value) { player.seek(
onSeek?.call(value * sliderMax); 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,
)
],
),
], ],
)); ));
} }

View File

@ -1,40 +1,38 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.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/components/Player/PlayerTrackDetails.dart';
import 'package:spotube/hooks/playback.dart';
import 'package:spotube/hooks/useBreakpoints.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 { class PlayerOverlay extends HookConsumerWidget {
final Widget controls;
final String albumArt; final String albumArt;
final PaletteColor paletteColor;
const PlayerOverlay({ const PlayerOverlay({
required this.controls,
required this.albumArt, required this.albumArt,
required this.paletteColor,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, ref) {
final breakpoint = useBreakpoints(); final breakpoint = useBreakpoints();
final isCurrentRoute = useState<bool?>(null); final isCurrentRoute = useIsCurrentRoute("/");
final paletteColor = usePaletteColor(context, albumArt);
final playback = ref.watch(playbackProvider);
useEffect(() { if (isCurrentRoute == false) {
WidgetsBinding.instance?.addPostFrameCallback((timer) {
final matches = GoRouter.of(context).location == "/";
if (matches != isCurrentRoute.value) {
isCurrentRoute.value = matches;
}
});
return null;
});
if (isCurrentRoute.value == false) {
return Container(); return Container();
} }
final onNext = useNextTrack(playback);
final onPrevious = usePreviousTrack(playback);
final _playOrPause = useTogglePlayPause(playback);
return Positioned( return Positioned(
right: (breakpoint.isMd ? 10 : 5), right: (breakpoint.isMd ? 10 : 5),
left: (breakpoint.isSm ? 5 : 80), left: (breakpoint.isSm ? 5 : 80),
@ -46,17 +44,49 @@ class PlayerOverlay extends HookWidget {
color: paletteColor.color, color: paletteColor.color,
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(5),
), ),
child: Row( child: Material(
mainAxisAlignment: MainAxisAlignment.spaceBetween, type: MaterialType.transparency,
children: [ child: Row(
Expanded( mainAxisAlignment: MainAxisAlignment.spaceBetween,
child: PlayerTrackDetails( children: [
albumArt: albumArt, Expanded(
color: paletteColor.bodyTextColor, child: GestureDetector(
onTap: () => GoRouter.of(context).push(
"/player",
extra: paletteColor,
),
child: PlayerTrackDetails(
albumArt: albumArt,
color: paletteColor.bodyTextColor,
),
),
), ),
), Row(
Expanded(child: controls), 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,
),
],
),
],
),
), ),
), ),
); );

View File

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

View File

@ -54,6 +54,7 @@ class PlaylistCard extends HookConsumerWidget {
thumbnail: imageToUrlString(playlist.images), thumbnail: imageToUrlString(playlist.images),
); );
playback.setCurrentTrack = tracks.first; playback.setCurrentTrack = tracks.first;
await playback.startPlaying();
}, },
); );
} }

View File

@ -11,7 +11,8 @@ class PlaylistView extends ConsumerWidget {
final PlaylistSimple playlist; final PlaylistSimple playlist;
const PlaylistView(this.playlist, {Key? key}) : super(key: key); const PlaylistView(this.playlist, {Key? key}) : super(key: key);
playPlaylist(Playback playback, List<Track> tracks, {Track? currentTrack}) { playPlaylist(Playback playback, List<Track> tracks,
{Track? currentTrack}) async {
currentTrack ??= tracks.first; currentTrack ??= tracks.first;
var isPlaylistPlaying = playback.currentPlaylist?.id != null && var isPlaylistPlaying = playback.currentPlaylist?.id != null &&
playback.currentPlaylist?.id == playlist.id; playback.currentPlaylist?.id == playlist.id;
@ -28,6 +29,7 @@ class PlaylistView extends ConsumerWidget {
currentTrack.id != playback.currentTrack?.id) { currentTrack.id != playback.currentTrack?.id) {
playback.setCurrentTrack = currentTrack; playback.setCurrentTrack = currentTrack;
} }
await playback.startPlaying();
} }
@override @override

View File

@ -125,6 +125,7 @@ class Search extends HookConsumerWidget {
playback.currentTrack?.id) { playback.currentTrack?.id) {
playback.setCurrentTrack = currentTrack; playback.setCurrentTrack = currentTrack;
} }
await playback.startPlaying();
}, },
); );
}), }),

42
lib/hooks/playback.dart Normal file
View File

@ -0,0 +1,42 @@
import 'package:spotube/provider/Playback.dart';
Future<void> 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<void> 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<void> 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);
}
};
}

45
lib/hooks/useHotKeys.dart Normal file
View File

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

View File

@ -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<bool?>(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;
}

View File

@ -3,21 +3,22 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:palette_generator/palette_generator.dart'; import 'package:palette_generator/palette_generator.dart';
PaletteColor usePaletteColor(String imageUrl) { PaletteColor usePaletteColor(BuildContext context, imageUrl) {
final paletteColor = final paletteColor =
useState<PaletteColor>(PaletteColor(Colors.grey[300]!, 0)); useState<PaletteColor>(PaletteColor(Colors.grey[300]!, 0));
final mounted = useIsMounted();
final context = useContext();
useEffect(() { useEffect(() {
PaletteGenerator.fromImageProvider( WidgetsBinding.instance?.addPostFrameCallback((timeStamp) async {
CachedNetworkImageProvider( final palette = await PaletteGenerator.fromImageProvider(
imageUrl, CachedNetworkImageProvider(
cacheKey: imageUrl, imageUrl,
maxHeight: 50, cacheKey: imageUrl,
maxWidth: 50, maxHeight: 50,
), maxWidth: 50,
).then((palette) { ),
);
if (!mounted()) return;
final color = Theme.of(context).brightness == Brightness.light final color = Theme.of(context).brightness == Brightness.light
? palette.lightMutedColor ?? palette.lightVibrantColor ? palette.lightMutedColor ?? palette.lightVibrantColor
: palette.darkMutedColor ?? palette.darkVibrantColor; : palette.darkMutedColor ?? palette.darkVibrantColor;

View File

@ -7,16 +7,11 @@ import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:hotkey_manager/hotkey_manager.dart'; import 'package:hotkey_manager/hotkey_manager.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotify/spotify.dart'; import 'package:spotube/models/GoRouteDeclarations.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/LocalStorageKeys.dart'; import 'package:spotube/models/LocalStorageKeys.dart';
import 'package:spotube/provider/AudioPlayer.dart';
import 'package:spotube/provider/ThemeProvider.dart'; import 'package:spotube/provider/ThemeProvider.dart';
import 'package:spotube/provider/YouTube.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@ -33,59 +28,12 @@ void main() async {
} }
class MyApp extends HookConsumerWidget { class MyApp extends HookConsumerWidget {
final GoRouter _router = GoRouter( final GoRouter _router = createGoRouter();
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),
);
},
),
],
);
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
var themeMode = ref.watch(themeProvider); var themeMode = ref.watch(themeProvider);
var player = ref.watch(audioPlayerProvider);
var youtube = ref.watch(youtubeProvider);
useEffect(() { useEffect(() {
SharedPreferences.getInstance().then((localStorage) { SharedPreferences.getInstance().then((localStorage) {
String? themeMode = localStorage.getString(LocalStorageKeys.themeMode); String? themeMode = localStorage.getString(LocalStorageKeys.themeMode);
@ -102,7 +50,10 @@ class MyApp extends HookConsumerWidget {
themeNotifier.state = ThemeMode.system; themeNotifier.state = ThemeMode.system;
} }
}); });
return null; return () {
player.dispose();
youtube.close();
};
}, []); }, []);
return MaterialApp.router( return MaterialApp.router(

View File

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

View File

@ -0,0 +1,6 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:just_audio/just_audio.dart';
final audioPlayerProvider = Provider<AudioPlayer>((ref) {
return AudioPlayer();
});

View File

@ -1,6 +1,13 @@
import 'dart:async';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:just_audio/just_audio.dart';
import 'package:spotify/spotify.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 { class CurrentPlaylist {
List<Track>? _tempTrack; List<Track>? _tempTrack;
@ -8,6 +15,7 @@ class CurrentPlaylist {
String id; String id;
String name; String name;
String thumbnail; String thumbnail;
CurrentPlaylist({ CurrentPlaylist({
required this.tracks, required this.tracks,
required this.id, required this.id,
@ -37,13 +45,95 @@ class CurrentPlaylist {
class Playback extends ChangeNotifier { class Playback extends ChangeNotifier {
CurrentPlaylist? _currentPlaylist; CurrentPlaylist? _currentPlaylist;
Track? _currentTrack; Track? _currentTrack;
Playback({CurrentPlaylist? currentPlaylist, Track? currentTrack}) {
_currentPlaylist = currentPlaylist; // states
_currentTrack = currentTrack; 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<Function(Duration?)> _durationListeners = [];
// listeners
StreamSubscription<bool>? _playingStreamListener;
StreamSubscription<Duration?>? _durationStreamListener;
StreamSubscription<ProcessingState>? _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; CurrentPlaylist? get currentPlaylist => _currentPlaylist;
Track? get currentTrack => _currentTrack; 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) { set setCurrentTrack(Track track) {
_currentTrack = track; _currentTrack = track;
@ -55,7 +145,10 @@ class Playback extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
reset() { void reset() {
_isPlaying = false;
_duration = null;
_callAllDurationListeners(null);
_currentPlaylist = null; _currentPlaylist = null;
_currentTrack = null; _currentTrack = null;
notifyListeners(); notifyListeners();
@ -76,6 +169,77 @@ class Playback extends ChangeNotifier {
return false; 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<void> 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>((_) => Playback()); final playbackProvider = ChangeNotifierProvider<Playback>((ref) {
final player = ref.watch(audioPlayerProvider);
final youtube = ref.watch(youtubeProvider);
return Playback(player: player, youtube: youtube);
});

View File

@ -0,0 +1,4 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
final youtubeProvider = Provider<YoutubeExplode>((ref) => YoutubeExplode());