From 8b8915e28fb61f4cc3a7260ef0982ea3d9e1c30a Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Fri, 30 Aug 2024 12:56:28 +0800 Subject: [PATCH] :sparkles: Mini player --- lib/router.dart | 13 ++ lib/screens/explore.dart | 4 + lib/screens/player/mini.dart | 161 ++++++++++++++++++ lib/screens/player/view.dart | 6 +- lib/services/lyrics/provider.dart | 8 +- lib/services/server/active_sourced_track.dart | 38 +++-- lib/widgets/lyrics/synced_lyrics.dart | 3 + lib/widgets/player/bottom_player.dart | 111 ++++++------ lib/widgets/player/controls.dart | 72 ++++++++ lib/widgets/volume_slider.dart | 6 +- 10 files changed, 341 insertions(+), 81 deletions(-) create mode 100644 lib/screens/player/mini.dart create mode 100644 lib/widgets/player/controls.dart diff --git a/lib/router.dart b/lib/router.dart index c385759..bc1f145 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -6,6 +6,7 @@ import 'package:rhythm_box/screens/auth/mobile_login.dart'; import 'package:rhythm_box/screens/explore.dart'; import 'package:rhythm_box/screens/library/view.dart'; import 'package:rhythm_box/screens/player/lyrics.dart'; +import 'package:rhythm_box/screens/player/mini.dart'; import 'package:rhythm_box/screens/player/view.dart'; import 'package:rhythm_box/screens/playlist/view.dart'; import 'package:rhythm_box/screens/search/view.dart'; @@ -77,6 +78,18 @@ final router = GoRouter(routes: [ ), ], ), + ShellRoute( + builder: (context, state, child) => child, + routes: [ + GoRoute( + path: '/player/mini', + name: 'playerMini', + builder: (context, state) => MiniPlayerScreen( + prevSize: state.extra as Size, + ), + ), + ], + ), ShellRoute( builder: (context, state, child) => child, routes: [ diff --git a/lib/screens/explore.dart b/lib/screens/explore.dart index 2bccb6a..92a5f5f 100644 --- a/lib/screens/explore.dart +++ b/lib/screens/explore.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; import 'package:get/get.dart'; import 'package:rhythm_box/providers/recent_played.dart'; import 'package:rhythm_box/providers/spotify.dart'; @@ -72,17 +73,20 @@ class _ExploreScreenState extends State { title: 'New Releases', list: _newReleasesPlaylist, ), + if (_newReleasesPlaylist?.isNotEmpty ?? false) const Gap(16), if (_recentlyPlaylist?.isNotEmpty ?? false) PlaylistSection( isLoading: _isLoading['recently']!, title: 'Recent Played', list: _recentlyPlaylist, ), + if (_recentlyPlaylist?.isNotEmpty ?? false) const Gap(16), PlaylistSection( isLoading: _isLoading['featured']!, title: 'Featured', list: _featuredPlaylist, ), + const Gap(16), ], ), ), diff --git a/lib/screens/player/mini.dart b/lib/screens/player/mini.dart new file mode 100644 index 0000000..d9167d4 --- /dev/null +++ b/lib/screens/player/mini.dart @@ -0,0 +1,161 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:go_router/go_router.dart'; +import 'package:rhythm_box/platform.dart'; +import 'package:rhythm_box/widgets/lyrics/synced_lyrics.dart'; +import 'package:rhythm_box/widgets/player/bottom_player.dart'; +import 'package:window_manager/window_manager.dart'; + +class MiniPlayerScreen extends StatefulWidget { + final Size prevSize; + + const MiniPlayerScreen({super.key, required this.prevSize}); + + @override + State createState() => _MiniPlayerScreenState(); +} + +class _MiniPlayerScreenState extends State { + bool _wasMaximized = false; + + bool _areaActive = false; + bool _isHoverMode = true; + + void _exitMiniPlayer() async { + if (!PlatformInfo.isDesktop) return; + + try { + await windowManager.setMinimumSize(const Size(300, 700)); + await windowManager.setAlwaysOnTop(false); + if (_wasMaximized) { + await windowManager.maximize(); + } else { + await windowManager.setSize(widget.prevSize); + } + await windowManager.setAlignment(Alignment.center); + if (!PlatformInfo.isLinux) { + await windowManager.setHasShadow(true); + } + await Future.delayed(const Duration(milliseconds: 200)); + } finally { + if (context.mounted) { + if (GoRouter.of(context).canPop()) { + GoRouter.of(context).pop(); + } else { + GoRouter.of(context).replaceNamed('player'); + } + } + } + } + + @override + void initState() { + super.initState(); + if (PlatformInfo.isDesktop) { + WidgetsBinding.instance.addPostFrameCallback((_) async { + _wasMaximized = await windowManager.isMaximized(); + }); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return MouseRegion( + onEnter: !_isHoverMode + ? null + : (event) { + setState(() => _areaActive = true); + }, + onExit: !_isHoverMode + ? null + : (event) { + setState(() => _areaActive = false); + }, + child: DefaultTabController( + length: 2, + child: Scaffold( + backgroundColor: theme.colorScheme.surface, + appBar: PreferredSize( + preferredSize: const Size.fromHeight(60), + child: AnimatedCrossFade( + duration: const Duration(milliseconds: 200), + crossFadeState: _areaActive + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + secondChild: const SizedBox(), + firstChild: Material( + color: theme.colorScheme.surfaceContainer, + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.fullscreen_exit), + onPressed: () => _exitMiniPlayer(), + ), + const Spacer(), + IconButton( + icon: _isHoverMode + ? const Icon(Icons.touch_app) + : const Icon(Icons.touch_app_outlined), + style: ButtonStyle( + foregroundColor: _isHoverMode + ? WidgetStateProperty.all(theme.colorScheme.primary) + : null, + ), + onPressed: () async { + setState(() { + _areaActive = true; + _isHoverMode = !_isHoverMode; + }); + }, + ), + if (PlatformInfo.isDesktop) + FutureBuilder( + future: windowManager.isAlwaysOnTop(), + builder: (context, snapshot) { + return IconButton( + icon: Icon( + snapshot.data == true + ? Icons.push_pin + : Icons.push_pin_outlined, + ), + style: ButtonStyle( + foregroundColor: snapshot.data == true + ? WidgetStateProperty.all( + theme.colorScheme.primary) + : null, + ), + onPressed: snapshot.data == null + ? null + : () async { + await windowManager.setAlwaysOnTop( + snapshot.data == true ? false : true, + ); + }, + ); + }, + ), + ], + ).paddingSymmetric(horizontal: 24), + ), + ), + ), + body: Column( + children: [ + const Expanded(child: SyncedLyrics(defaultTextZoom: 67)), + SizedBox( + height: 85, + child: BottomPlayer( + isMiniPlayer: true, + usePop: true, + onTap: () => _exitMiniPlayer(), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/player/view.dart b/lib/screens/player/view.dart index 2f3ac7e..3ea5350 100644 --- a/lib/screens/player/view.dart +++ b/lib/screens/player/view.dart @@ -73,8 +73,8 @@ class _PlayerScreenState extends State { child: Row( children: [ Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, + child: ListView( + padding: const EdgeInsets.symmetric(vertical: 24), children: [ Obx( () => LimitedBox( @@ -356,7 +356,7 @@ class _PlayerScreenState extends State { ) ], ), - ).marginAll(24), + ).marginSymmetric(horizontal: 24), ), ); } diff --git a/lib/services/lyrics/provider.dart b/lib/services/lyrics/provider.dart index 3460a2f..0f0b23c 100644 --- a/lib/services/lyrics/provider.dart +++ b/lib/services/lyrics/provider.dart @@ -166,7 +166,13 @@ class SyncedLyricsProvider extends GetxController { return lyrics; } catch (e, stackTrace) { log('[Lyrics] Error: $e; Trace:\n$stackTrace'); - rethrow; + return SubtitleSimple( + uri: Uri.parse('https://example.com/not-found'), + name: 'Lyrics Not Found', + lyrics: [], + rating: 0, + provider: 'Not Found', + ); } } } diff --git a/lib/services/server/active_sourced_track.dart b/lib/services/server/active_sourced_track.dart index 8add6f7..23d4f57 100755 --- a/lib/services/server/active_sourced_track.dart +++ b/lib/services/server/active_sourced_track.dart @@ -1,8 +1,11 @@ +import 'dart:developer'; + import 'package:get/get.dart'; import 'package:rhythm_box/providers/audio_player.dart'; import 'package:rhythm_box/services/audio_player/audio_player.dart'; import 'package:rhythm_box/services/sourced_track/models/source_info.dart'; import 'package:rhythm_box/services/sourced_track/sourced_track.dart'; +import 'package:rhythm_box/widgets/tracks/querying_track_info.dart'; class ActiveSourcedTrackProvider extends GetxController { Rx state = Rx(null); @@ -17,23 +20,32 @@ class ActiveSourcedTrackProvider extends GetxController { } Future swapSibling(SourceInfo sibling) async { - if (state.value == null) return; - await populateSibling(); - final newTrack = await state.value!.swapWithSibling(sibling); - if (newTrack == null) return; + final query = Get.find(); + query.isQueryingTrackInfo.value = true; - state.value = newTrack; - await audioPlayer.pause(); + try { + if (state.value == null) return; + await populateSibling(); + final newTrack = await state.value!.swapWithSibling(sibling); + if (newTrack == null) return; - final playback = Get.find(); - final oldActiveIndex = audioPlayer.currentIndex; + state.value = newTrack; + await audioPlayer.pause(); - await playback.addTracksAtFirst([newTrack]); - await Future.delayed(const Duration(milliseconds: 300)); - await playback.jumpToTrack(newTrack); + final playback = Get.find(); + final oldActiveIndex = audioPlayer.currentIndex; - await audioPlayer.removeTrack(oldActiveIndex); + await playback.addTracksAtFirst([newTrack]); + await Future.delayed(const Duration(milliseconds: 30)); - await audioPlayer.resume(); + await audioPlayer.removeTrack(oldActiveIndex); + await playback.jumpToTrack(newTrack); + + await audioPlayer.resume(); + } catch (e, stack) { + log('[Playback] Failed to swap with siblings. Error: $e; Trace:\n$stack'); + } finally { + query.isQueryingTrackInfo.value = false; + } } } diff --git a/lib/widgets/lyrics/synced_lyrics.dart b/lib/widgets/lyrics/synced_lyrics.dart index 1e59fa6..6d349c9 100644 --- a/lib/widgets/lyrics/synced_lyrics.dart +++ b/lib/widgets/lyrics/synced_lyrics.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; +import 'package:gap/gap.dart'; import 'package:get/get.dart'; import 'package:rhythm_box/providers/audio_player.dart'; import 'package:rhythm_box/services/audio_player/audio_player.dart'; @@ -112,6 +113,7 @@ class _SyncedLyricsState extends State { cacheExtent: 10000, controller: _autoScrollController, slivers: [ + const SliverGap(16), if (_lyric == null) const SliverFillRemaining( child: Center( @@ -223,6 +225,7 @@ class _SyncedLyricsState extends State { ), ), ), + const SliverGap(16), ], ); } diff --git a/lib/widgets/player/bottom_player.dart b/lib/widgets/player/bottom_player.dart index 4f74e56..00a57c5 100644 --- a/lib/widgets/player/bottom_player.dart +++ b/lib/widgets/player/bottom_player.dart @@ -5,18 +5,27 @@ import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:get/get.dart'; import 'package:go_router/go_router.dart'; +import 'package:rhythm_box/platform.dart'; import 'package:rhythm_box/providers/audio_player.dart'; -import 'package:rhythm_box/services/audio_player/audio_player.dart'; import 'package:rhythm_box/services/audio_services/image.dart'; import 'package:rhythm_box/widgets/auto_cache_image.dart'; +import 'package:rhythm_box/widgets/player/controls.dart'; import 'package:rhythm_box/widgets/player/track_details.dart'; import 'package:rhythm_box/widgets/tracks/querying_track_info.dart'; import 'package:rhythm_box/widgets/volume_slider.dart'; +import 'package:window_manager/window_manager.dart'; class BottomPlayer extends StatefulWidget { final bool usePop; + final bool isMiniPlayer; + final Function? onTap; - const BottomPlayer({super.key, this.usePop = false}); + const BottomPlayer({ + super.key, + this.usePop = false, + this.isMiniPlayer = false, + this.onTap, + }); @override State createState() => _BottomPlayerState(); @@ -42,19 +51,8 @@ class _BottomPlayerState extends State (_playback.state.value.activeTrack?.album?.images?.length ?? 1) - 1, ); - bool get _isPlaying => _playback.isPlaying.value; - bool get _isFetchingActiveTrack => _query.isQueryingTrackInfo.value; - List? _subscriptions; - Future _togglePlayState() async { - if (!audioPlayer.isPlaying) { - await audioPlayer.resume(); - } else { - await audioPlayer.pause(); - } - } - bool _isLifted = false; @override @@ -94,47 +92,6 @@ class _BottomPlayerState extends State @override Widget build(BuildContext context) { - final controls = Obx( - () => Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MediaQuery.of(context).size.width >= 720 - ? MainAxisAlignment.center - : MainAxisAlignment.end, - children: [ - if (MediaQuery.of(context).size.width >= 720) - IconButton( - icon: const Icon(Icons.skip_previous), - onPressed: - _isFetchingActiveTrack ? null : audioPlayer.skipToPrevious, - ) - else - IconButton( - icon: const Icon(Icons.skip_next), - onPressed: _isFetchingActiveTrack ? null : audioPlayer.skipToNext, - ), - IconButton.filled( - icon: _isFetchingActiveTrack - ? const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator( - strokeWidth: 3, - ), - ) - : Icon( - !_isPlaying ? Icons.play_arrow : Icons.pause, - ), - onPressed: _isFetchingActiveTrack ? null : _togglePlayState, - ), - if (MediaQuery.of(context).size.width >= 720) - IconButton( - icon: const Icon(Icons.skip_next), - onPressed: _isFetchingActiveTrack ? null : audioPlayer.skipToNext, - ) - ], - ), - ); - return SizeTransition( sizeFactor: _animation, axis: Axis.vertical, @@ -197,19 +154,49 @@ class _BottomPlayerState extends State ), const Gap(12), if (MediaQuery.of(context).size.width >= 720) - Expanded(child: controls) + const Expanded(child: PlayerControls()) else - controls, + const PlayerControls(), if (MediaQuery.of(context).size.width >= 720) const Gap(12), if (MediaQuery.of(context).size.width >= 720) - const Expanded( + Expanded( child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ - Expanded( - child: VolumeSlider( - mainAxisAlignment: MainAxisAlignment.end, + if (!widget.isMiniPlayer && PlatformInfo.isDesktop) + IconButton( + icon: const Icon( + Icons.picture_in_picture, + size: 18, + ), + onPressed: () async { + if (!PlatformInfo.isDesktop) return; + + final prevSize = await windowManager.getSize(); + await windowManager.setMinimumSize( + const Size(300, 300), + ); + await windowManager.setAlwaysOnTop(true); + if (!PlatformInfo.isLinux) { + await windowManager.setHasShadow(false); + } + await windowManager + .setAlignment(Alignment.topRight); + await windowManager + .setSize(const Size(400, 500)); + await Future.delayed( + const Duration(milliseconds: 100), + () async { + GoRouter.of(context).pushNamed( + 'playerMini', + extra: prevSize, + ); + }, + ); + }, ), + const VolumeSlider( + mainAxisAlignment: MainAxisAlignment.end, ) ], ), @@ -220,6 +207,10 @@ class _BottomPlayerState extends State ], ), onTap: () { + if (widget.onTap != null) { + widget.onTap!(); + return; + } if (widget.usePop) { GoRouter.of(context).pop(); } else { diff --git a/lib/widgets/player/controls.dart b/lib/widgets/player/controls.dart new file mode 100644 index 0000000..ccb80b6 --- /dev/null +++ b/lib/widgets/player/controls.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:rhythm_box/providers/audio_player.dart'; +import 'package:rhythm_box/services/audio_player/audio_player.dart'; +import 'package:rhythm_box/widgets/tracks/querying_track_info.dart'; + +class PlayerControls extends StatefulWidget { + const PlayerControls({super.key}); + + @override + State createState() => _PlayerControlsState(); +} + +class _PlayerControlsState extends State { + late final AudioPlayerProvider _playback = Get.find(); + late final QueryingTrackInfoProvider _query = Get.find(); + + bool get _isPlaying => _playback.isPlaying.value; + bool get _isFetchingActiveTrack => _query.isQueryingTrackInfo.value; + + Future _togglePlayState() async { + if (!audioPlayer.isPlaying) { + await audioPlayer.resume(); + } else { + await audioPlayer.pause(); + } + } + + @override + Widget build(BuildContext context) { + return Obx( + () => Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MediaQuery.of(context).size.width >= 720 + ? MainAxisAlignment.center + : MainAxisAlignment.end, + children: [ + if (MediaQuery.of(context).size.width >= 720) + IconButton( + icon: const Icon(Icons.skip_previous), + onPressed: + _isFetchingActiveTrack ? null : audioPlayer.skipToPrevious, + ) + else + IconButton( + icon: const Icon(Icons.skip_next), + onPressed: _isFetchingActiveTrack ? null : audioPlayer.skipToNext, + ), + IconButton.filled( + icon: _isFetchingActiveTrack + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 3, + ), + ) + : Icon( + !_isPlaying ? Icons.play_arrow : Icons.pause, + ), + onPressed: _isFetchingActiveTrack ? null : _togglePlayState, + ), + if (MediaQuery.of(context).size.width >= 720) + IconButton( + icon: const Icon(Icons.skip_next), + onPressed: _isFetchingActiveTrack ? null : audioPlayer.skipToNext, + ) + ], + ), + ); + } +} diff --git a/lib/widgets/volume_slider.dart b/lib/widgets/volume_slider.dart index 77269b6..7236c3a 100644 --- a/lib/widgets/volume_slider.dart +++ b/lib/widgets/volume_slider.dart @@ -4,12 +4,10 @@ import 'package:get/get.dart'; import 'package:rhythm_box/providers/volume.dart'; class VolumeSlider extends StatelessWidget { - final bool isFullWidth; final MainAxisAlignment mainAxisAlignment; const VolumeSlider({ super.key, - this.isFullWidth = false, this.mainAxisAlignment = MainAxisAlignment.start, }); @@ -48,7 +46,7 @@ class VolumeSlider extends StatelessWidget { onChanged: vol.setVolume, ), ), - ).paddingOnly(right: 24, left: 8); + ).paddingSymmetric(horizontal: 8); return Row( mainAxisAlignment: mainAxisAlignment, children: [ @@ -69,7 +67,7 @@ class VolumeSlider extends StatelessWidget { } }, ), - if (isFullWidth) Expanded(child: slider) else slider, + slider, ], ); });