mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-13 16:05:18 +00:00
PlayerOverlay works as expected
imageToUrlString uses uuid instead of DateTime.now() seperated parts of Player for reuse accross different sizes of screen's specific widgets integrating go_router to follow declarative route approach
This commit is contained in:
parent
b585bf2df2
commit
d608fa7d02
@ -33,7 +33,10 @@ class AlbumCard extends HookConsumerWidget {
|
|||||||
"Album • ${artistsToString<ArtistSimple>(album.artists ?? [])}",
|
"Album • ${artistsToString<ArtistSimple>(album.artists ?? [])}",
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.of(context).push(SpotubePageRoute(
|
Navigator.of(context).push(SpotubePageRoute(
|
||||||
child: AlbumView(album),
|
child: AlbumView(
|
||||||
|
album,
|
||||||
|
key: Key("album-${album.id}"),
|
||||||
|
),
|
||||||
));
|
));
|
||||||
},
|
},
|
||||||
onPlaybuttonPressed: () async {
|
onPlaybuttonPressed: () async {
|
||||||
|
@ -18,7 +18,10 @@ class ArtistCard extends StatelessWidget {
|
|||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.of(context).push(SpotubePageRoute(
|
Navigator.of(context).push(SpotubePageRoute(
|
||||||
child: ArtistProfile(artist.id!),
|
child: ArtistProfile(
|
||||||
|
artist.id!,
|
||||||
|
key: Key("artist-${artist.id}"),
|
||||||
|
),
|
||||||
));
|
));
|
||||||
},
|
},
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
@ -247,6 +247,7 @@ class ArtistProfile extends HookConsumerWidget {
|
|||||||
child: ArtistAlbumView(
|
child: ArtistAlbumView(
|
||||||
artistId,
|
artistId,
|
||||||
snapshot.data?.name ?? "KRTX",
|
snapshot.data?.name ?? "KRTX",
|
||||||
|
key: Key("artist-album-$artistId"),
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
},
|
},
|
||||||
|
@ -168,7 +168,7 @@ class Home extends HookConsumerWidget {
|
|||||||
Expanded(child: MoveWindow()),
|
Expanded(child: MoveWindow()),
|
||||||
if (!Platform.isMacOS) const TitleBarActionButtons(),
|
if (!Platform.isMacOS) const TitleBarActionButtons(),
|
||||||
],
|
],
|
||||||
)),
|
))
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -31,7 +31,9 @@ class Sidebar extends HookConsumerWidget {
|
|||||||
|
|
||||||
static void goToSettings(BuildContext context) {
|
static void goToSettings(BuildContext context) {
|
||||||
Navigator.of(context).push(SpotubePageRoute(
|
Navigator.of(context).push(SpotubePageRoute(
|
||||||
child: const Settings(),
|
child: const Settings(
|
||||||
|
key: Key("settings"),
|
||||||
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -70,7 +70,9 @@ class Lyrics extends HookConsumerWidget {
|
|||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.of(context).push(SpotubePageRoute(
|
Navigator.of(context).push(SpotubePageRoute(
|
||||||
child: const Settings(),
|
child: const Settings(
|
||||||
|
key: Key("settings"),
|
||||||
|
),
|
||||||
));
|
));
|
||||||
},
|
},
|
||||||
child: const Text("Add Access Token"))
|
child: const Text("Add Access Token"))
|
||||||
|
@ -4,13 +4,17 @@ import 'package:cached_network_image/cached_network_image.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:just_audio/just_audio.dart';
|
import 'package:just_audio/just_audio.dart';
|
||||||
|
import 'package:palette_generator/palette_generator.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart' hide Image;
|
||||||
|
import 'package:spotube/components/Player/PlayerOverlay.dart';
|
||||||
|
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/artists-to-clickable-artists.dart';
|
import 'package:spotube/helpers/artists-to-clickable-artists.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/helpers/search-youtube.dart';
|
||||||
|
import 'package:spotube/hooks/useBreakpoints.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';
|
||||||
@ -19,15 +23,17 @@ 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);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, ref) {
|
Widget build(BuildContext context, ref) {
|
||||||
|
Playback playback = ref.watch(playbackProvider);
|
||||||
final _isPlaying = useState(false);
|
final _isPlaying = useState(false);
|
||||||
final _shuffled = useState(false);
|
final _shuffled = useState(false);
|
||||||
final _volume = useState(0.0);
|
final _volume = useState(0.0);
|
||||||
final _duration = useState<Duration?>(null);
|
final _duration = useState<Duration?>(null);
|
||||||
final _currentTrackId = useState<String?>(null);
|
final _currentTrackId = useState<String?>(null);
|
||||||
|
|
||||||
|
final breakpoint = useBreakpoints();
|
||||||
|
|
||||||
final AudioPlayer player = useMemoized(() => AudioPlayer(), []);
|
final AudioPlayer player = useMemoized(() => AudioPlayer(), []);
|
||||||
final YoutubeExplode youtube = useMemoized(() => YoutubeExplode(), []);
|
final YoutubeExplode youtube = useMemoized(() => YoutubeExplode(), []);
|
||||||
final Future<SharedPreferences> future =
|
final Future<SharedPreferences> future =
|
||||||
@ -92,6 +98,7 @@ class Player extends HookConsumerWidget {
|
|||||||
print(stack);
|
print(stack);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return () {
|
return () {
|
||||||
playingStreamListener.cancel();
|
playingStreamListener.cancel();
|
||||||
durationStreamListener.cancel();
|
durationStreamListener.cancel();
|
||||||
@ -106,9 +113,11 @@ class Player extends HookConsumerWidget {
|
|||||||
_volume.value = localStorage.data?.getDouble(LocalStorageKeys.volume) ??
|
_volume.value = localStorage.data?.getDouble(LocalStorageKeys.volume) ??
|
||||||
player.volume;
|
player.volume;
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
}, [localStorage.data]);
|
}, [localStorage.data]);
|
||||||
|
|
||||||
var _playTrack = useCallback((Track currentTrack, Playback playback) async {
|
final _playTrack =
|
||||||
|
useCallback((Track currentTrack, Playback playback) async {
|
||||||
try {
|
try {
|
||||||
if (currentTrack.id != _currentTrackId.value) {
|
if (currentTrack.id != _currentTrackId.value) {
|
||||||
Uri? parsedUri = Uri.tryParse(currentTrack.uri ?? "");
|
Uri? parsedUri = Uri.tryParse(currentTrack.uri ?? "");
|
||||||
@ -140,6 +149,13 @@ class Player extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
}, [player, _currentTrackId, _duration]);
|
}, [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 {
|
var _onNext = useCallback(() async {
|
||||||
try {
|
try {
|
||||||
await player.pause();
|
await player.pause();
|
||||||
@ -162,193 +178,202 @@ class Player extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
}, [player]);
|
}, [player]);
|
||||||
|
|
||||||
|
String albumArt = useMemoized(
|
||||||
|
() => imageToUrlString(
|
||||||
|
playback.currentTrack?.album?.images,
|
||||||
|
index: (playback.currentTrack?.album?.images?.length ?? 1) - 1,
|
||||||
|
),
|
||||||
|
[playback.currentTrack?.album?.images],
|
||||||
|
);
|
||||||
|
|
||||||
|
final entryRef = useRef<OverlayEntry?>(null);
|
||||||
|
|
||||||
|
disposeOverlay() {
|
||||||
|
try {
|
||||||
|
entryRef.value?.remove();
|
||||||
|
entryRef.value = null;
|
||||||
|
} catch (e, stack) {
|
||||||
|
if (e is! AssertionError) {
|
||||||
|
print("[Player.useEffect.cleanup] $e");
|
||||||
|
print(stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final controls = PlayerControls(
|
||||||
|
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
|
||||||
|
if (entryRef.value != null) disposeOverlay();
|
||||||
|
if (breakpoint.isLessThanOrEqualTo(Breakpoints.md)) {
|
||||||
|
entryRef.value = OverlayEntry(
|
||||||
|
opaque: false,
|
||||||
|
builder: (context) => PlayerOverlay(
|
||||||
|
controls: controls,
|
||||||
|
albumArt: albumArt,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// I can't believe useEffect doesn't run Post Frame aka
|
||||||
|
// after rendering/painting the UI
|
||||||
|
// `My disappointment is immeasurable and my day is ruined` XD
|
||||||
|
WidgetsBinding.instance?.addPostFrameCallback((time) {
|
||||||
|
Overlay.of(context)?.insert(entryRef.value!);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return () {
|
||||||
|
disposeOverlay();
|
||||||
|
};
|
||||||
|
}, [breakpoint]);
|
||||||
|
|
||||||
|
// returning an empty non spacious Container as the overlay will take
|
||||||
|
// place in the global overlay stack aka [_entries]
|
||||||
|
if (breakpoint.isLessThanOrEqualTo(Breakpoints.md)) {
|
||||||
|
return Container();
|
||||||
|
}
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
color: Theme.of(context).backgroundColor,
|
color: Theme.of(context).backgroundColor,
|
||||||
child: HookConsumer(
|
child: Material(
|
||||||
builder: (context, ref, widget) {
|
type: MaterialType.transparency,
|
||||||
Playback playback = ref.watch(playbackProvider);
|
child: Row(
|
||||||
if (playback.currentPlaylist != null &&
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
playback.currentTrack != null) {
|
children: [
|
||||||
_playTrack(playback.currentTrack!, playback);
|
Expanded(child: PlayerTrackDetails(albumArt: albumArt)),
|
||||||
}
|
// controls
|
||||||
|
Flexible(
|
||||||
String? albumArt = useMemoized(
|
flex: 3,
|
||||||
() => imageToUrlString(
|
child: controls,
|
||||||
playback.currentTrack?.album?.images,
|
|
||||||
index: (playback.currentTrack?.album?.images?.length ?? 1) - 1,
|
|
||||||
),
|
),
|
||||||
[playback.currentTrack?.album?.images],
|
// add to saved tracks
|
||||||
);
|
Expanded(
|
||||||
|
flex: 1,
|
||||||
return Material(
|
child: Wrap(
|
||||||
type: MaterialType.transparency,
|
alignment: WrapAlignment.center,
|
||||||
child: Row(
|
runAlignment: WrapAlignment.center,
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
children: [
|
||||||
children: [
|
Container(
|
||||||
if (albumArt != null)
|
height: 20,
|
||||||
CachedNetworkImage(
|
constraints: const BoxConstraints(maxWidth: 200),
|
||||||
imageUrl: albumArt,
|
child: Slider.adaptive(
|
||||||
maxHeightDiskCache: 50,
|
value: _volume.value,
|
||||||
maxWidthDiskCache: 50,
|
onChanged: (value) async {
|
||||||
placeholder: (context, url) {
|
try {
|
||||||
return Container(
|
await player.setVolume(value).then((_) {
|
||||||
height: 50,
|
_volume.value = value;
|
||||||
width: 50,
|
localStorage.data?.setDouble(
|
||||||
color: Colors.green[400],
|
LocalStorageKeys.volume,
|
||||||
);
|
value,
|
||||||
},
|
);
|
||||||
),
|
});
|
||||||
// title of the currently playing track
|
} catch (e, stack) {
|
||||||
Flexible(
|
print("[VolumeSlider.onChange()] $e");
|
||||||
flex: 1,
|
print(stack);
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
playback.currentTrack?.name ?? "Not playing",
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
artistsToClickableArtists(
|
|
||||||
playback.currentTrack?.artists ?? [],
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// controls
|
|
||||||
Flexible(
|
|
||||||
flex: 3,
|
|
||||||
child: PlayerControls(
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
Row(
|
||||||
// add to saved tracks
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
Expanded(
|
|
||||||
flex: 1,
|
|
||||||
child: Wrap(
|
|
||||||
alignment: WrapAlignment.center,
|
|
||||||
runAlignment: WrapAlignment.center,
|
|
||||||
children: [
|
children: [
|
||||||
Container(
|
DownloadTrackButton(
|
||||||
height: 20,
|
track: playback.currentTrack,
|
||||||
constraints: const BoxConstraints(maxWidth: 200),
|
|
||||||
child: Slider.adaptive(
|
|
||||||
value: _volume.value,
|
|
||||||
onChanged: (value) async {
|
|
||||||
try {
|
|
||||||
await player.setVolume(value).then((_) {
|
|
||||||
_volume.value = value;
|
|
||||||
localStorage.data?.setDouble(
|
|
||||||
LocalStorageKeys.volume,
|
|
||||||
value,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
} catch (e, stack) {
|
|
||||||
print("[VolumeSlider.onChange()] $e");
|
|
||||||
print(stack);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
DownloadTrackButton(
|
|
||||||
track: playback.currentTrack,
|
|
||||||
),
|
|
||||||
Consumer(builder: (context, ref, widget) {
|
|
||||||
SpotifyApi spotifyApi = ref.watch(spotifyProvider);
|
|
||||||
return FutureBuilder<bool>(
|
|
||||||
future: playback.currentTrack?.id != null
|
|
||||||
? spotifyApi.tracks.me
|
|
||||||
.containsOne(playback.currentTrack!.id!)
|
|
||||||
: Future.value(false),
|
|
||||||
initialData: false,
|
|
||||||
builder: (context, snapshot) {
|
|
||||||
bool isLiked = snapshot.data ?? false;
|
|
||||||
return IconButton(
|
|
||||||
icon: Icon(
|
|
||||||
!isLiked
|
|
||||||
? Icons.favorite_outline_rounded
|
|
||||||
: Icons.favorite_rounded,
|
|
||||||
color: isLiked ? Colors.green : null,
|
|
||||||
),
|
|
||||||
onPressed: () {
|
|
||||||
if (!isLiked &&
|
|
||||||
playback.currentTrack?.id != null) {
|
|
||||||
spotifyApi.tracks.me.saveOne(
|
|
||||||
playback.currentTrack!.id!);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
Consumer(builder: (context, ref, widget) {
|
||||||
|
SpotifyApi spotifyApi = ref.watch(spotifyProvider);
|
||||||
|
return FutureBuilder<bool>(
|
||||||
|
future: playback.currentTrack?.id != null
|
||||||
|
? spotifyApi.tracks.me
|
||||||
|
.containsOne(playback.currentTrack!.id!)
|
||||||
|
: Future.value(false),
|
||||||
|
initialData: false,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
bool isLiked = snapshot.data ?? false;
|
||||||
|
return IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
!isLiked
|
||||||
|
? Icons.favorite_outline_rounded
|
||||||
|
: Icons.favorite_rounded,
|
||||||
|
color: isLiked ? Colors.green : null,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
if (!isLiked &&
|
||||||
|
playback.currentTrack?.id != null) {
|
||||||
|
spotifyApi.tracks.me
|
||||||
|
.saveOne(playback.currentTrack!.id!);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
)
|
],
|
||||||
],
|
),
|
||||||
),
|
)
|
||||||
);
|
],
|
||||||
},
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -5,6 +5,7 @@ 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:hotkey_manager/hotkey_manager.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/models/GlobalKeyActions.dart';
|
import 'package:spotube/models/GlobalKeyActions.dart';
|
||||||
import 'package:spotube/provider/UserPreferences.dart';
|
import 'package:spotube/provider/UserPreferences.dart';
|
||||||
|
|
||||||
@ -77,79 +78,91 @@ class PlayerControls extends HookConsumerWidget {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return Container(
|
final breakpoint = useBreakpoints();
|
||||||
constraints: const BoxConstraints(maxWidth: 700),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
StreamBuilder<Duration>(
|
|
||||||
stream: positionStream,
|
|
||||||
builder: (context, snapshot) {
|
|
||||||
var totalMinutes =
|
|
||||||
zeroPadNumStr(duration.inMinutes.remainder(60));
|
|
||||||
var totalSeconds =
|
|
||||||
zeroPadNumStr(duration.inSeconds.remainder(60));
|
|
||||||
var currentMinutes = snapshot.hasData
|
|
||||||
? zeroPadNumStr(snapshot.data!.inMinutes.remainder(60))
|
|
||||||
: "00";
|
|
||||||
var currentSeconds = snapshot.hasData
|
|
||||||
? zeroPadNumStr(snapshot.data!.inSeconds.remainder(60))
|
|
||||||
: "00";
|
|
||||||
|
|
||||||
var sliderMax = duration.inSeconds;
|
Widget controlButtons = Material(
|
||||||
var sliderValue = snapshot.data?.inSeconds ?? 0;
|
type: MaterialType.transparency,
|
||||||
return Row(
|
child: Row(
|
||||||
children: [
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
Expanded(
|
children: [
|
||||||
child: Slider.adaptive(
|
if (breakpoint.isMoreThan(Breakpoints.md))
|
||||||
// cannot divide by zero
|
IconButton(
|
||||||
// there's an edge case for value being bigger
|
icon: const Icon(Icons.shuffle_rounded),
|
||||||
// than total duration. Keeping it resolved
|
color: shuffled ? Theme.of(context).primaryColor : null,
|
||||||
value: (sliderMax == 0 || sliderValue > sliderMax)
|
onPressed: () {
|
||||||
? 0
|
onShuffle?.call();
|
||||||
: sliderValue / sliderMax,
|
}),
|
||||||
onChanged: (value) {},
|
IconButton(
|
||||||
onChangeEnd: (value) {
|
icon: const Icon(Icons.skip_previous_rounded),
|
||||||
onSeek?.call(value * sliderMax);
|
onPressed: () {
|
||||||
},
|
onPrevious?.call();
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
"$currentMinutes:$currentSeconds/$totalMinutes:$totalSeconds",
|
|
||||||
)
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}),
|
}),
|
||||||
Row(
|
IconButton(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
icon: Icon(
|
||||||
children: [
|
isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded,
|
||||||
IconButton(
|
),
|
||||||
icon: const Icon(Icons.shuffle_rounded),
|
onPressed: () => _playOrPause(null),
|
||||||
color: shuffled ? Theme.of(context).primaryColor : null,
|
),
|
||||||
onPressed: () {
|
IconButton(
|
||||||
onShuffle?.call();
|
icon: const Icon(Icons.skip_next_rounded),
|
||||||
}),
|
onPressed: () => onNext?.call()),
|
||||||
IconButton(
|
if (breakpoint.isMoreThan(Breakpoints.md))
|
||||||
icon: const Icon(Icons.skip_previous_rounded),
|
IconButton(
|
||||||
onPressed: () {
|
icon: const Icon(Icons.stop_rounded),
|
||||||
onPrevious?.call();
|
onPressed: () => onStop?.call(),
|
||||||
}),
|
)
|
||||||
IconButton(
|
|
||||||
icon: Icon(
|
|
||||||
isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded,
|
|
||||||
),
|
|
||||||
onPressed: () => _playOrPause(null),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.skip_next_rounded),
|
|
||||||
onPressed: () => onNext?.call()),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.stop_rounded),
|
|
||||||
onPressed: () => onStop?.call(),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
)
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (breakpoint.isLessThanOrEqualTo(Breakpoints.md)) {
|
||||||
|
return controlButtons;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 700),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
StreamBuilder<Duration>(
|
||||||
|
stream: positionStream,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
var totalMinutes =
|
||||||
|
zeroPadNumStr(duration.inMinutes.remainder(60));
|
||||||
|
var totalSeconds =
|
||||||
|
zeroPadNumStr(duration.inSeconds.remainder(60));
|
||||||
|
var currentMinutes = snapshot.hasData
|
||||||
|
? zeroPadNumStr(snapshot.data!.inMinutes.remainder(60))
|
||||||
|
: "00";
|
||||||
|
var currentSeconds = snapshot.hasData
|
||||||
|
? zeroPadNumStr(snapshot.data!.inSeconds.remainder(60))
|
||||||
|
: "00";
|
||||||
|
|
||||||
|
var sliderMax = duration.inSeconds;
|
||||||
|
var sliderValue = snapshot.data?.inSeconds ?? 0;
|
||||||
|
return Row(
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
"$currentMinutes:$currentSeconds/$totalMinutes:$totalSeconds",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
controlButtons,
|
||||||
|
],
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
55
lib/components/Player/PlayerOverlay.dart
Normal file
55
lib/components/Player/PlayerOverlay.dart
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:palette_generator/palette_generator.dart';
|
||||||
|
import 'package:spotube/components/Player/PlayerTrackDetails.dart';
|
||||||
|
import 'package:spotube/hooks/useBreakpoints.dart';
|
||||||
|
|
||||||
|
class PlayerOverlay extends HookWidget {
|
||||||
|
final Widget controls;
|
||||||
|
final String albumArt;
|
||||||
|
const PlayerOverlay({
|
||||||
|
required this.controls,
|
||||||
|
required this.albumArt,
|
||||||
|
Key? key,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final breakpoint = useBreakpoints();
|
||||||
|
|
||||||
|
return Positioned(
|
||||||
|
right: (breakpoint.isMd ? 10 : 5),
|
||||||
|
left: (breakpoint.isSm ? 5 : 80),
|
||||||
|
bottom: (breakpoint.isSm ? 63 : 10),
|
||||||
|
child: FutureBuilder<PaletteGenerator>(
|
||||||
|
future: PaletteGenerator.fromImageProvider(
|
||||||
|
CachedNetworkImageProvider(
|
||||||
|
albumArt,
|
||||||
|
cacheKey: albumArt,
|
||||||
|
maxHeight: 50,
|
||||||
|
maxWidth: 50,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
return Container(
|
||||||
|
width: MediaQuery.of(context).size.width,
|
||||||
|
height: 50,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: snapshot.hasData
|
||||||
|
? snapshot.data!.colors.first
|
||||||
|
: Colors.blueGrey[200],
|
||||||
|
borderRadius: BorderRadius.circular(5),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
PlayerTrackDetails(albumArt: albumArt),
|
||||||
|
controls,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
71
lib/components/Player/PlayerTrackDetails.dart
Normal file
71
lib/components/Player/PlayerTrackDetails.dart
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:spotube/helpers/artists-to-clickable-artists.dart';
|
||||||
|
import 'package:spotube/hooks/useBreakpoints.dart';
|
||||||
|
import 'package:spotube/provider/Playback.dart';
|
||||||
|
|
||||||
|
class PlayerTrackDetails extends HookConsumerWidget {
|
||||||
|
final String? albumArt;
|
||||||
|
const PlayerTrackDetails({Key? key, this.albumArt}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, ref) {
|
||||||
|
final breakpoint = useBreakpoints();
|
||||||
|
final playback = ref.watch(playbackProvider);
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
if (albumArt != null)
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.all(
|
||||||
|
breakpoint.isLessThanOrEqualTo(Breakpoints.md) ? 5.0 : 0),
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
imageUrl: albumArt!,
|
||||||
|
maxHeightDiskCache: 50,
|
||||||
|
maxWidthDiskCache: 50,
|
||||||
|
cacheKey: albumArt,
|
||||||
|
placeholder: (context, url) {
|
||||||
|
return Container(
|
||||||
|
height: 50,
|
||||||
|
width: 50,
|
||||||
|
color: Colors.green[400],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (breakpoint.isLessThanOrEqualTo(Breakpoints.md)) ...[
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text(
|
||||||
|
playback.currentTrack?.name ?? "Not playing",
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.bodyText1
|
||||||
|
?.copyWith(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
// title of the currently playing track
|
||||||
|
if (breakpoint.isMoreThan(Breakpoints.md))
|
||||||
|
Flexible(
|
||||||
|
flex: 1,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
playback.currentTrack?.name ?? "Not playing",
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.bodyText1
|
||||||
|
?.copyWith(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
artistsToClickableArtists(
|
||||||
|
playback.currentTrack?.artists ?? [],
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -26,9 +26,14 @@ class PlaylistCard extends HookConsumerWidget {
|
|||||||
imageUrl: playlist.images![0].url!,
|
imageUrl: playlist.images![0].url!,
|
||||||
isPlaying: isPlaylistPlaying,
|
isPlaying: isPlaylistPlaying,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.of(context).push(SpotubePageRoute(
|
Navigator.of(context).push(
|
||||||
child: PlaylistView(playlist),
|
SpotubePageRoute(
|
||||||
));
|
child: PlaylistView(
|
||||||
|
playlist,
|
||||||
|
key: Key("playlist-${playlist.id}"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
onPlaybuttonPressed: () async {
|
onPlaybuttonPressed: () async {
|
||||||
if (isPlaylistPlaying) return;
|
if (isPlaylistPlaying) return;
|
||||||
|
@ -4,8 +4,8 @@ class SpotubePageRoute extends PageRouteBuilder {
|
|||||||
final Widget child;
|
final Widget child;
|
||||||
SpotubePageRoute({required this.child})
|
SpotubePageRoute({required this.child})
|
||||||
: super(
|
: super(
|
||||||
pageBuilder: (context, animation, secondaryAnimation) => child,
|
pageBuilder: (context, animation, secondaryAnimation) => child,
|
||||||
);
|
settings: RouteSettings(name: child.key.toString()));
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget buildTransitions(BuildContext context, Animation<double> animation,
|
Widget buildTransitions(BuildContext context, Animation<double> animation,
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
|
import 'package:uuid/uuid.dart' show Uuid;
|
||||||
|
|
||||||
|
const uuid = Uuid();
|
||||||
String imageToUrlString(List<Image>? images, {int index = 0}) {
|
String imageToUrlString(List<Image>? images, {int index = 0}) {
|
||||||
return images != null && images.isNotEmpty
|
return images != null && images.isNotEmpty
|
||||||
? images[0].url!
|
? images[0].url!
|
||||||
: "https://avatars.dicebear.com/api/croodles-neutral/${DateTime.now().toString()}.png";
|
: "https://avatars.dicebear.com/api/croodles-neutral/${uuid.v4()}.png";
|
||||||
}
|
}
|
||||||
|
21
pubspec.lock
21
pubspec.lock
@ -226,6 +226,13 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.0"
|
version: "1.1.0"
|
||||||
|
go_router:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: go_router
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.4"
|
||||||
hooks_riverpod:
|
hooks_riverpod:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -331,6 +338,13 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.1"
|
version: "1.0.1"
|
||||||
|
logging:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: logging
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.2"
|
||||||
matcher:
|
matcher:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -380,6 +394,13 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.2"
|
version: "2.0.2"
|
||||||
|
palette_generator:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: palette_generator
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "0.3.3"
|
||||||
path:
|
path:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
@ -52,6 +52,8 @@ dependencies:
|
|||||||
flutter_riverpod: ^1.0.3
|
flutter_riverpod: ^1.0.3
|
||||||
flutter_hooks: ^0.18.2+1
|
flutter_hooks: ^0.18.2+1
|
||||||
hooks_riverpod: ^1.0.3
|
hooks_riverpod: ^1.0.3
|
||||||
|
go_router: ^3.0.4
|
||||||
|
palette_generator: ^0.3.3
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
Loading…
Reference in New Issue
Block a user