mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-12 23:45:18 +00:00
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:
parent
aaf74b46d4
commit
932462d773
@ -50,6 +50,7 @@ class AlbumCard extends HookConsumerWidget {
|
||||
thumbnail: album.images!.first.url!,
|
||||
);
|
||||
playback.setCurrentTrack = tracks.first;
|
||||
await playback.startPlaying();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
@ -12,7 +12,8 @@ class AlbumView extends ConsumerWidget {
|
||||
final AlbumSimple album;
|
||||
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;
|
||||
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
|
||||
|
@ -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<Track> tracks, {Track? currentTrack}) {
|
||||
playPlaylist(List<Track> 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: [
|
||||
|
@ -39,11 +39,14 @@ class CategoryCard extends HookWidget {
|
||||
usePagingController<int, PlaylistSimple>(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<PlaylistSimple> 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);
|
||||
|
@ -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 =
|
||||
|
@ -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<Duration?>(null);
|
||||
final _currentTrackId = useState<String?>(null);
|
||||
|
||||
final breakpoint = useBreakpoints();
|
||||
|
||||
final AudioPlayer player = useMemoized(() => AudioPlayer(), []);
|
||||
final YoutubeExplode youtube = useMemoized(() => YoutubeExplode(), []);
|
||||
final AudioPlayer player = playback.player;
|
||||
|
||||
final Future<SharedPreferences> future =
|
||||
useMemoized(SharedPreferences.getInstance);
|
||||
final AsyncSnapshot<SharedPreferences?> 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(
|
||||
|
@ -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<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;
|
||||
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<Duration?>(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<Duration>(
|
||||
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,
|
||||
)
|
||||
],
|
||||
),
|
||||
],
|
||||
));
|
||||
}
|
||||
|
@ -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<bool?>(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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
98
lib/components/Player/PlayerView.dart
Normal file
98
lib/components/Player/PlayerView.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -54,6 +54,7 @@ class PlaylistCard extends HookConsumerWidget {
|
||||
thumbnail: imageToUrlString(playlist.images),
|
||||
);
|
||||
playback.setCurrentTrack = tracks.first;
|
||||
await playback.startPlaying();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
@ -11,7 +11,8 @@ class PlaylistView extends ConsumerWidget {
|
||||
final PlaylistSimple playlist;
|
||||
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;
|
||||
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
|
||||
|
@ -125,6 +125,7 @@ class Search extends HookConsumerWidget {
|
||||
playback.currentTrack?.id) {
|
||||
playback.setCurrentTrack = currentTrack;
|
||||
}
|
||||
await playback.startPlaying();
|
||||
},
|
||||
);
|
||||
}),
|
||||
|
42
lib/hooks/playback.dart
Normal file
42
lib/hooks/playback.dart
Normal 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
45
lib/hooks/useHotKeys.dart
Normal 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)));
|
||||
};
|
||||
});
|
||||
}
|
18
lib/hooks/useIsCurrentRoute.dart
Normal file
18
lib/hooks/useIsCurrentRoute.dart
Normal 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;
|
||||
}
|
@ -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>(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;
|
||||
|
@ -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(
|
||||
|
74
lib/models/GoRouteDeclarations.dart
Normal file
74
lib/models/GoRouteDeclarations.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
],
|
||||
);
|
6
lib/provider/AudioPlayer.dart
Normal file
6
lib/provider/AudioPlayer.dart
Normal 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();
|
||||
});
|
@ -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<Track>? _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<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;
|
||||
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<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);
|
||||
});
|
||||
|
4
lib/provider/YouTube.dart
Normal file
4
lib/provider/YouTube.dart
Normal 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());
|
Loading…
Reference in New Issue
Block a user