From 9aee0568bf42eed9fea8d517e960a010abf0ebf2 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 30 Sep 2023 22:03:34 +0600 Subject: [PATCH] feat: swipe to open player view (#765) * feat: sliding up player support * fix: minor glitches --- lib/collections/routes.dart | 10 - lib/components/player/player.dart | 329 +++++++++ lib/components/player/player_overlay.dart | 79 +- .../root/spotube_navigation_bar.dart | 80 +- lib/components/shared/panels/controller.dart | 142 ++++ lib/components/shared/panels/helpers.dart | 96 +++ .../shared/panels/sliding_up_panel.dart | 686 ++++++++++++++++++ lib/pages/player/player.dart | 322 -------- lib/provider/user_preferences_provider.dart | 9 +- 9 files changed, 1357 insertions(+), 396 deletions(-) create mode 100644 lib/components/player/player.dart create mode 100644 lib/components/shared/panels/controller.dart create mode 100644 lib/components/shared/panels/helpers.dart create mode 100644 lib/components/shared/panels/sliding_up_panel.dart delete mode 100644 lib/pages/player/player.dart diff --git a/lib/collections/routes.dart b/lib/collections/routes.dart index ebdfb8bc3..81ebb3e66 100644 --- a/lib/collections/routes.dart +++ b/lib/collections/routes.dart @@ -19,7 +19,6 @@ import 'package:spotube/pages/library/library.dart'; import 'package:spotube/pages/desktop_login/login_tutorial.dart'; import 'package:spotube/pages/desktop_login/desktop_login.dart'; import 'package:spotube/pages/lyrics/lyrics.dart'; -import 'package:spotube/pages/player/player.dart'; import 'package:spotube/pages/playlist/playlist.dart'; import 'package:spotube/pages/root/root_app.dart'; import 'package:spotube/pages/settings/settings.dart'; @@ -153,14 +152,5 @@ final router = GoRouter( pageBuilder: (context, state) => const SpotubePage(child: LastFMLoginPage()), ), - GoRoute( - path: "/player", - parentNavigatorKey: rootNavigatorKey, - pageBuilder: (context, state) { - return const SpotubePage( - child: PlayerView(), - ); - }, - ), ], ); diff --git a/lib/components/player/player.dart b/lib/components/player/player.dart new file mode 100644 index 000000000..dc57c9b0b --- /dev/null +++ b/lib/components/player/player.dart @@ -0,0 +1,329 @@ +import 'package:auto_size_text/auto_size_text.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:spotify/spotify.dart' hide Offset; +import 'package:spotube/collections/assets.gen.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/player/player_actions.dart'; +import 'package:spotube/components/player/player_controls.dart'; +import 'package:spotube/components/player/player_queue.dart'; +import 'package:spotube/components/player/volume_slider.dart'; +import 'package:spotube/components/shared/animated_gradient.dart'; +import 'package:spotube/components/shared/dialogs/track_details_dialog.dart'; +import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/hooks/use_custom_status_bar_color.dart'; +import 'package:spotube/hooks/use_palette_color.dart'; +import 'package:spotube/models/local_track.dart'; +import 'package:spotube/pages/lyrics/lyrics.dart'; +import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/utils/type_conversion_utils.dart'; + +class PlayerView extends HookConsumerWidget { + final bool isOpen; + final Function() onClosePage; + + const PlayerView({ + Key? key, + required this.isOpen, + required this.onClosePage, + }) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final theme = Theme.of(context); + final auth = ref.watch(AuthenticationNotifier.provider); + final currentTrack = ref.watch(ProxyPlaylistNotifier.provider.select( + (value) => value.activeTrack, + )); + final isLocalTrack = ref.watch(ProxyPlaylistNotifier.provider.select( + (value) => value.activeTrack is LocalTrack, + )); + final mediaQuery = MediaQuery.of(context); + + useEffect(() { + if (mediaQuery.lgAndUp) { + WidgetsBinding.instance.addPostFrameCallback((_) { + onClosePage(); + }); + } + return null; + }, [mediaQuery.lgAndUp]); + + String albumArt = useMemoized( + () => TypeConversionUtils.image_X_UrlString( + currentTrack?.album?.images, + placeholder: ImagePlaceholder.albumArt, + ), + [currentTrack?.album?.images], + ); + + final palette = usePaletteGenerator(albumArt); + final bgColor = palette.dominantColor?.color ?? theme.colorScheme.primary; + final titleTextColor = palette.dominantColor?.titleTextColor; + final bodyTextColor = palette.dominantColor?.bodyTextColor; + + useCustomStatusBarColor( + bgColor, + isOpen, + noSetBGColor: true, + ); + + return IconTheme( + data: theme.iconTheme.copyWith(color: bodyTextColor), + child: Scaffold( + appBar: PreferredSize( + preferredSize: const Size.fromHeight(kToolbarHeight), + child: SafeArea( + minimum: const EdgeInsets.only(top: 30), + child: PageWindowTitleBar( + backgroundColor: Colors.transparent, + foregroundColor: titleTextColor, + toolbarOpacity: 1, + leading: IconButton( + icon: const Icon(SpotubeIcons.angleDown, size: 18), + onPressed: onClosePage, + ), + actions: [ + IconButton( + icon: const Icon(SpotubeIcons.info, size: 18), + tooltip: context.l10n.details, + style: IconButton.styleFrom(foregroundColor: bodyTextColor), + onPressed: currentTrack == null + ? null + : () { + showDialog( + context: context, + builder: (context) { + return TrackDetailsDialog( + track: currentTrack, + ); + }); + }, + ) + ], + ), + ), + ), + extendBodyBehindAppBar: true, + body: AnimateGradient( + animateAlignments: true, + primaryBegin: Alignment.topLeft, + primaryEnd: Alignment.bottomLeft, + secondaryBegin: Alignment.bottomRight, + secondaryEnd: Alignment.topRight, + duration: const Duration(seconds: 15), + primaryColors: [ + palette.dominantColor?.color ?? theme.colorScheme.primary, + palette.mutedColor?.color ?? theme.colorScheme.secondary, + ], + secondaryColors: [ + (palette.darkVibrantColor ?? palette.lightVibrantColor)?.color ?? + theme.colorScheme.primaryContainer, + (palette.darkMutedColor ?? palette.lightMutedColor)?.color ?? + theme.colorScheme.secondaryContainer, + ], + child: SingleChildScrollView( + child: Container( + alignment: Alignment.center, + width: double.infinity, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 580), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + children: [ + Container( + margin: const EdgeInsets.all(8), + constraints: const BoxConstraints( + maxHeight: 300, maxWidth: 300), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + boxShadow: const [ + BoxShadow( + color: Colors.black26, + spreadRadius: 2, + blurRadius: 10, + offset: Offset(0, 0), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: UniversalImage( + path: albumArt, + placeholder: Assets.albumPlaceholder.path, + fit: BoxFit.cover, + ), + ), + ), + const SizedBox(height: 60), + Container( + padding: const EdgeInsets.symmetric(horizontal: 16), + alignment: Alignment.centerLeft, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AutoSizeText( + currentTrack?.name ?? "Not playing", + style: TextStyle( + color: titleTextColor, + fontSize: 22, + ), + maxFontSize: 22, + maxLines: 1, + textAlign: TextAlign.start, + ), + if (isLocalTrack) + Text( + TypeConversionUtils.artists_X_String( + currentTrack?.artists ?? [], + ), + style: theme.textTheme.bodyMedium!.copyWith( + fontWeight: FontWeight.bold, + color: bodyTextColor, + ), + ) + else + TypeConversionUtils.artists_X_ClickableArtists( + currentTrack?.artists ?? [], + textStyle: + theme.textTheme.bodyMedium!.copyWith( + fontWeight: FontWeight.bold, + color: bodyTextColor, + ), + onRouteChange: (route) { + onClosePage(); + GoRouter.of(context).push(route); + }, + ), + ], + ), + ), + const SizedBox(height: 10), + PlayerControls(palette: palette), + const SizedBox(height: 25), + PlayerActions( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + showQueue: false, + ), + const SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + const SizedBox(width: 10), + Expanded( + child: OutlinedButton.icon( + icon: const Icon(SpotubeIcons.queue), + label: Text(context.l10n.queue), + style: OutlinedButton.styleFrom( + foregroundColor: bodyTextColor, + side: BorderSide( + color: bodyTextColor ?? Colors.white, + ), + ), + onPressed: currentTrack != null + ? () { + showModalBottomSheet( + context: context, + isDismissible: true, + enableDrag: true, + isScrollControlled: true, + backgroundColor: Colors.black12, + barrierColor: Colors.black12, + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(10), + ), + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context) + .size + .height * + .7, + ), + builder: (context) { + return const PlayerQueue( + floating: false); + }, + ); + } + : null), + ), + if (auth != null) const SizedBox(width: 10), + if (auth != null) + Expanded( + child: OutlinedButton.icon( + label: Text(context.l10n.lyrics), + icon: const Icon(SpotubeIcons.music), + style: OutlinedButton.styleFrom( + foregroundColor: bodyTextColor, + side: BorderSide( + color: bodyTextColor ?? Colors.white, + ), + ), + onPressed: () { + showModalBottomSheet( + context: context, + isDismissible: true, + enableDrag: true, + isScrollControlled: true, + backgroundColor: Colors.black38, + barrierColor: Colors.black12, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(20), + topRight: Radius.circular(20), + ), + ), + constraints: BoxConstraints( + maxHeight: + MediaQuery.of(context).size.height * + 0.8, + ), + builder: (context) => + const LyricsPage(isModal: true), + ); + }, + ), + ), + const SizedBox(width: 10), + ], + ), + const SizedBox(height: 25), + SliderTheme( + data: theme.sliderTheme.copyWith( + activeTrackColor: titleTextColor, + inactiveTrackColor: bodyTextColor, + thumbColor: titleTextColor, + overlayColor: titleTextColor?.withOpacity(0.2), + trackHeight: 2, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 8, + ), + ), + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: VolumeSlider( + fullWidth: true, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/components/player/player_overlay.dart b/lib/components/player/player_overlay.dart index 889e66093..b69dc4bdc 100644 --- a/lib/components/player/player_overlay.dart +++ b/lib/components/player/player_overlay.dart @@ -2,16 +2,17 @@ import 'dart:ui'; 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:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/player/player_track_details.dart'; +import 'package:spotube/components/root/spotube_navigation_bar.dart'; +import 'package:spotube/components/shared/panels/sliding_up_panel.dart'; +import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/intents.dart'; import 'package:spotube/hooks/use_progress.dart'; +import 'package:spotube/components/player/player.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/utils/service_utils.dart'; class PlayerOverlay extends HookConsumerWidget { final String albumArt; @@ -39,22 +40,32 @@ class PlayerOverlay extends HookConsumerWidget { topRight: Radius.circular(10), ); - return GestureDetector( - onVerticalDragEnd: (details) { - int sensitivity = 8; - if (details.primaryVelocity != null && - details.primaryVelocity! < -sensitivity) { - ServiceUtils.push(context, "/player"); - } + final mediaQuery = MediaQuery.of(context); + + final panelController = useMemoized(() => PanelController(), []); + + useEffect(() { + return () { + panelController.dispose(); + }; + }, []); + + return SlidingUpPanel( + maxHeight: mediaQuery.size.height, + backdropEnabled: false, + minHeight: canShow ? 53 : 0, + onPanelSlide: (position) { + final invertedPosition = 1 - position; + ref.read(navigationPanelHeight.notifier).state = 50 * invertedPosition; }, - child: ClipRRect( + controller: panelController, + collapsed: ClipRRect( borderRadius: radius, child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15), child: AnimatedContainer( duration: const Duration(milliseconds: 250), - width: MediaQuery.of(context).size.width, - height: canShow ? 53 : 0, + width: mediaQuery.size.width, decoration: BoxDecoration( color: theme.colorScheme.secondaryContainer.withOpacity(.8), borderRadius: radius, @@ -95,18 +106,16 @@ class PlayerOverlay extends HookConsumerWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: () => - GoRouter.of(context).push("/player"), - child: Container( - width: double.infinity, - color: Colors.transparent, - child: PlayerTrackDetails( - albumArt: albumArt, - color: textColor, - ), + child: GestureDetector( + onTap: () { + panelController.open(); + }, + child: Container( + width: double.infinity, + color: Colors.transparent, + child: PlayerTrackDetails( + albumArt: albumArt, + color: textColor, ), ), ), @@ -165,6 +174,26 @@ class PlayerOverlay extends HookConsumerWidget { ), ), ), + panelBuilder: (position) { + final navigationHeight = ref.watch(navigationPanelHeight); + if (navigationHeight == 50) return const SizedBox(); + + return AnimatedContainer( + clipBehavior: Clip.antiAlias, + duration: const Duration(milliseconds: 250), + decoration: navigationHeight == 0 + ? const BoxDecoration(borderRadius: BorderRadius.zero) + : const BoxDecoration(borderRadius: radius), + child: HorizontalScrollableWidget( + child: PlayerView( + isOpen: panelController.isPanelOpen, + onClosePage: () { + panelController.close(); + }, + ), + ), + ); + }, ); } } diff --git a/lib/components/root/spotube_navigation_bar.dart b/lib/components/root/spotube_navigation_bar.dart index ee8e33197..ec4c4f2d6 100644 --- a/lib/components/root/spotube_navigation_bar.dart +++ b/lib/components/root/spotube_navigation_bar.dart @@ -13,6 +13,8 @@ import 'package:spotube/hooks/use_brightness_value.dart'; import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/user_preferences_provider.dart'; +final navigationPanelHeight = StateProvider((ref) => 50); + class SpotubeNavigationBar extends HookConsumerWidget { final int selectedIndex; final void Function(int) onSelectedIndexChanged; @@ -41,6 +43,8 @@ class SpotubeNavigationBar extends HookConsumerWidget { final navbarTileList = useMemoized(() => getNavbarTileList(context.l10n), [context.l10n]); + final panelHeight = ref.watch(navigationPanelHeight); + useEffect(() { insideSelectedIndex.value = selectedIndex; return null; @@ -51,44 +55,48 @@ class SpotubeNavigationBar extends HookConsumerWidget { return const SizedBox(); } - return ClipRect( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15), - child: CurvedNavigationBar( - backgroundColor: - theme.colorScheme.secondaryContainer.withOpacity(0.72), - buttonBackgroundColor: buttonColor, - color: theme.colorScheme.background, - height: 50, - animationDuration: const Duration(milliseconds: 350), - items: navbarTileList.map( - (e) { - /// Using this [Builder] as an workaround for the first item's - /// icon color not updating unless navigating to another page - return Builder(builder: (context) { - return MouseRegion( - cursor: SystemMouseCursors.click, - child: Badge( - isLabelVisible: e.id == "library" && downloadCount > 0, - label: Text(downloadCount.toString()), - child: Icon( - e.icon, - color: Theme.of(context).colorScheme.primary, + return AnimatedContainer( + duration: const Duration(milliseconds: 100), + height: panelHeight, + child: ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15), + child: CurvedNavigationBar( + backgroundColor: + theme.colorScheme.secondaryContainer.withOpacity(0.72), + buttonBackgroundColor: buttonColor, + color: theme.colorScheme.background, + height: panelHeight, + animationDuration: const Duration(milliseconds: 350), + items: navbarTileList.map( + (e) { + /// Using this [Builder] as an workaround for the first item's + /// icon color not updating unless navigating to another page + return Builder(builder: (context) { + return MouseRegion( + cursor: SystemMouseCursors.click, + child: Badge( + isLabelVisible: e.id == "library" && downloadCount > 0, + label: Text(downloadCount.toString()), + child: Icon( + e.icon, + color: Theme.of(context).colorScheme.primary, + ), ), - ), - ); - }); + ); + }); + }, + ).toList(), + index: insideSelectedIndex.value, + onTap: (i) { + insideSelectedIndex.value = i; + if (navbarTileList[i].id == "settings") { + Sidebar.goToSettings(context); + return; + } + onSelectedIndexChanged(i); }, - ).toList(), - index: insideSelectedIndex.value, - onTap: (i) { - insideSelectedIndex.value = i; - if (navbarTileList[i].id == "settings") { - Sidebar.goToSettings(context); - return; - } - onSelectedIndexChanged(i); - }, + ), ), ), ); diff --git a/lib/components/shared/panels/controller.dart b/lib/components/shared/panels/controller.dart new file mode 100644 index 000000000..a573c06c2 --- /dev/null +++ b/lib/components/shared/panels/controller.dart @@ -0,0 +1,142 @@ +part of panels; + +class PanelController extends ChangeNotifier { + SlidingUpPanelState? _panelState; + + void _addState(SlidingUpPanelState panelState) { + _panelState = panelState; + notifyListeners(); + } + + bool _forceScrollChange = false; + + /// use this function when scroll change in func + /// Example: + /// panelController.forseScrollChange(scrollController.animateTo(100, duration: Duration(milliseconds: 400), curve: Curves.ease)) + Future forceScrollChange(Future func) async { + _forceScrollChange = true; + _panelState!._scrollingEnabled = true; + await func; + // if (_panelState!._sc.offset == 0) { + // _panelState!._scrollingEnabled = true; + // } + if (panelPosition < 1) { + _panelState!._scMinOffset = _panelState!._scrollController.offset; + } + _forceScrollChange = false; + } + + bool __nowTargetForceDraggable = false; + + bool get _nowTargetForceDraggable => __nowTargetForceDraggable; + + set _nowTargetForceDraggable(bool value) { + __nowTargetForceDraggable = value; + notifyListeners(); + } + + /// Determine if the panelController is attached to an instance + /// of the SlidingUpPanel (this property must return true before any other + /// functions can be used) + bool get isAttached => _panelState != null; + + /// Closes the sliding panel to its collapsed state (i.e. to the minHeight) + Future close() { + assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); + return _panelState!._close(); + } + + /// Opens the sliding panel fully + /// (i.e. to the maxHeight) + Future open() { + assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); + return _panelState!._open(); + } + + /// Hides the sliding panel (i.e. is invisible) + Future hide() { + assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); + return _panelState!._hide(); + } + + /// Shows the sliding panel in its collapsed state + /// (i.e. "un-hide" the sliding panel) + Future show() { + assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); + return _panelState!._show(); + } + + /// Animates the panel position to the value. + /// The value must between 0.0 and 1.0 + /// where 0.0 is fully collapsed and 1.0 is completely open. + /// (optional) duration specifies the time for the animation to complete + /// (optional) curve specifies the easing behavior of the animation. + Future animatePanelToPosition(double value, + {Duration? duration, Curve curve = Curves.linear}) { + assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); + assert(0.0 <= value && value <= 1.0); + return _panelState! + ._animatePanelToPosition(value, duration: duration, curve: curve); + } + + /// Animates the panel position to the snap point + /// Requires that the SlidingUpPanel snapPoint property is not null + /// (optional) duration specifies the time for the animation to complete + /// (optional) curve specifies the easing behavior of the animation. + Future animatePanelToSnapPoint( + {Duration? duration, Curve curve = Curves.linear}) { + assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); + assert(_panelState!.widget.snapPoint != null, + "SlidingUpPanel snapPoint property must not be null"); + return _panelState! + ._animatePanelToSnapPoint(duration: duration, curve: curve); + } + + /// Sets the panel position (without animation). + /// The value must between 0.0 and 1.0 + /// where 0.0 is fully collapsed and 1.0 is completely open. + set panelPosition(double value) { + assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); + assert(0.0 <= value && value <= 1.0); + _panelState!._panelPosition = value; + } + + /// Gets the current panel position. + /// Returns the % offset from collapsed state + /// to the open state + /// as a decimal between 0.0 and 1.0 + /// where 0.0 is fully collapsed and + /// 1.0 is full open. + double get panelPosition { + assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); + return _panelState!._panelPosition; + } + + /// Returns whether or not the panel is + /// currently animating. + bool get isPanelAnimating { + assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); + return _panelState!._isPanelAnimating; + } + + /// Returns whether or not the + /// panel is open. + bool get isPanelOpen { + assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); + return _panelState!._isPanelOpen; + } + + /// Returns whether or not the + /// panel is closed. + bool get isPanelClosed { + assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); + return _panelState!._isPanelClosed; + } + + /// Returns whether or not the + /// panel is shown/hidden. + bool get isPanelShown { + assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); + return _panelState!._isPanelShown; + } +} diff --git a/lib/components/shared/panels/helpers.dart b/lib/components/shared/panels/helpers.dart new file mode 100644 index 000000000..2e754bdf7 --- /dev/null +++ b/lib/components/shared/panels/helpers.dart @@ -0,0 +1,96 @@ +part of panels; + +/// if you want to prevent the panel from being dragged using the widget, +/// wrap the widget with this +class IgnoreDraggableWidget extends SingleChildRenderObjectWidget { + const IgnoreDraggableWidget({ + super.key, + required super.child, + }); + + @override + IgnoreDraggableWidgetWidgetRenderBox createRenderObject( + BuildContext context, + ) { + return IgnoreDraggableWidgetWidgetRenderBox(); + } +} + +class IgnoreDraggableWidgetWidgetRenderBox extends RenderPointerListener { + @override + HitTestBehavior get behavior => HitTestBehavior.opaque; +} + +/// if you want to force the panel to be dragged using the widget, +/// wrap the widget with this +/// For example, use [Scrollable] inside to allow the panel to be dragged +/// even if the scroll is not at position 0. +class ForceDraggableWidget extends SingleChildRenderObjectWidget { + const ForceDraggableWidget({ + super.key, + required super.child, + }); + + @override + ForceDraggableWidgetRenderBox createRenderObject( + BuildContext context, + ) { + return ForceDraggableWidgetRenderBox(); + } +} + +class ForceDraggableWidgetRenderBox extends RenderPointerListener { + @override + HitTestBehavior get behavior => HitTestBehavior.opaque; +} + +/// To make [ForceDraggableWidget] work in [Scrollable] widgets +class PanelScrollPhysics extends ScrollPhysics { + final PanelController controller; + const PanelScrollPhysics({required this.controller, ScrollPhysics? parent}) + : super(parent: parent); + @override + PanelScrollPhysics applyTo(ScrollPhysics? ancestor) { + return PanelScrollPhysics( + controller: controller, parent: buildParent(ancestor)); + } + + @override + double applyPhysicsToUserOffset(ScrollMetrics position, double offset) { + if (controller._nowTargetForceDraggable) return 0.0; + return super.applyPhysicsToUserOffset(position, offset); + } + + @override + Simulation? createBallisticSimulation( + ScrollMetrics position, double velocity) { + if (controller._nowTargetForceDraggable) { + return super.createBallisticSimulation(position, 0); + } + return super.createBallisticSimulation(position, velocity); + } + + @override + bool get allowImplicitScrolling => false; +} + +/// if you want to prevent unwanted panel dragging when scrolling widgets [Scrollable] with horizontal axis +/// wrap the widget with this +class HorizontalScrollableWidget extends SingleChildRenderObjectWidget { + const HorizontalScrollableWidget({ + super.key, + required super.child, + }); + + @override + HorizontalScrollableWidgetRenderBox createRenderObject( + BuildContext context, + ) { + return HorizontalScrollableWidgetRenderBox(); + } +} + +class HorizontalScrollableWidgetRenderBox extends RenderPointerListener { + @override + HitTestBehavior get behavior => HitTestBehavior.opaque; +} diff --git a/lib/components/shared/panels/sliding_up_panel.dart b/lib/components/shared/panels/sliding_up_panel.dart new file mode 100644 index 000000000..137d5eb7a --- /dev/null +++ b/lib/components/shared/panels/sliding_up_panel.dart @@ -0,0 +1,686 @@ +/* +Name: Zotov Vladimir +Date: 18/06/22 +Purpose: Defines the package: sliding_up_panel2 +Copyright: © 2022, Zotov Vladimir. All rights reserved. +Licensing: More information can be found here: https://github.com/Zotov-VD/sliding_up_panel/blob/master/LICENSE + +This product includes software developed by Akshath Jain (https://akshathjain.com) +*/ + +library panels; + +import 'dart:math'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/physics.dart'; +import 'package:flutter/rendering.dart'; + +part 'controller.dart'; +part 'helpers.dart'; + +enum SlideDirection { up, down } + +enum PanelState { open, closed } + +class SlidingUpPanel extends StatefulWidget { + /// Returns the Widget that slides into view. When the + /// panel is collapsed and if [collapsed] is null, + /// then top portion of this Widget will be displayed; + /// otherwise, [collapsed] will be displayed overtop + /// of this Widget. + final Widget? Function(double position)? panelBuilder; + + /// The Widget displayed overtop the [panel] when collapsed. + /// This fades out as the panel is opened. + final Widget? collapsed; + + /// The Widget that lies underneath the sliding panel. + /// This Widget automatically sizes itself + /// to fill the screen. + final Widget? body; + + /// Optional persistent widget that floats above the [panel] and attaches + /// to the top of the [panel]. Content at the top of the panel will be covered + /// by this widget. Add padding to the bottom of the `panel` to + /// avoid coverage. + final Widget? header; + + /// Optional persistent widget that floats above the [panel] and + /// attaches to the bottom of the [panel]. Content at the bottom of the panel + /// will be covered by this widget. Add padding to the bottom of the `panel` + /// to avoid coverage. + final Widget? footer; + + /// The height of the sliding panel when fully collapsed. + final double minHeight; + + /// The height of the sliding panel when fully open. + final double maxHeight; + + /// A point between [minHeight] and [maxHeight] that the panel snaps to + /// while animating. A fast swipe on the panel will disregard this point + /// and go directly to the open/close position. This value is represented as a + /// percentage of the total animation distance ([maxHeight] - [minHeight]), + /// so it must be between 0.0 and 1.0, exclusive. + final double? snapPoint; + + /// The amount to inset the children of the sliding panel sheet. + final EdgeInsetsGeometry? padding; + + /// Empty space surrounding the sliding panel sheet. + final EdgeInsetsGeometry? margin; + + /// Set to false to disable the panel from snapping open or closed. + final bool panelSnapping; + + /// Disable panel draggable on scrolling. Defaults to false. + final bool disableDraggableOnScrolling; + + /// If non-null, this can be used to control the state of the panel. + final PanelController? controller; + + /// If non-null, shows a darkening shadow over the [body] as the panel slides open. + final bool backdropEnabled; + + /// Shows a darkening shadow of this [Color] over the [body] as the panel slides open. + final Color backdropColor; + + /// The opacity of the backdrop when the panel is fully open. + /// This value can range from 0.0 to 1.0 where 0.0 is completely transparent + /// and 1.0 is completely opaque. + final double backdropOpacity; + + /// Flag that indicates whether or not tapping the + /// backdrop closes the panel. Defaults to true. + final bool backdropTapClosesPanel; + + /// If non-null, this callback + /// is called as the panel slides around with the + /// current position of the panel. The position is a double + /// between 0.0 and 1.0 where 0.0 is fully collapsed and 1.0 is fully open. + final void Function(double position)? onPanelSlide; + + /// If non-null, this callback is called when the + /// panel is fully opened + final VoidCallback? onPanelOpened; + + /// If non-null, this callback is called when the panel + /// is fully collapsed. + final VoidCallback? onPanelClosed; + + /// If non-null and true, the SlidingUpPanel exhibits a + /// parallax effect as the panel slides up. Essentially, + /// the body slides up as the panel slides up. + final bool parallaxEnabled; + + /// Allows for specifying the extent of the parallax effect in terms + /// of the percentage the panel has slid up/down. Recommended values are + /// within 0.0 and 1.0 where 0.0 is no parallax and 1.0 mimics a + /// one-to-one scrolling effect. Defaults to a 10% parallax. + final double parallaxOffset; + + /// Allows toggling of the draggability of the SlidingUpPanel. + /// Set this to false to prevent the user from being able to drag + /// the panel up and down. Defaults to true. + final bool isDraggable; + + /// Either SlideDirection.UP or SlideDirection.DOWN. Indicates which way + /// the panel should slide. Defaults to UP. If set to DOWN, the panel attaches + /// itself to the top of the screen and is fully opened when the user swipes + /// down on the panel. + final SlideDirection slideDirection; + + /// The default state of the panel; either PanelState.OPEN or PanelState.CLOSED. + /// This value defaults to PanelState.CLOSED which indicates that the panel is + /// in the closed position and must be opened. PanelState.OPEN indicates that + /// by default the Panel is open and must be swiped closed by the user. + final PanelState defaultPanelState; + + /// To attach to a [Scrollable] on a panel that + /// links the panel's position to the scroll position. Useful for implementing + /// infinite scroll behavior + final ScrollController? scrollController; + + final BoxDecoration? panelDecoration; + + const SlidingUpPanel( + {Key? key, + this.body, + this.collapsed, + this.minHeight = 100.0, + this.maxHeight = 500.0, + this.snapPoint, + this.padding, + this.margin, + this.panelDecoration, + this.panelSnapping = true, + this.disableDraggableOnScrolling = false, + this.controller, + this.backdropEnabled = false, + this.backdropColor = Colors.black, + this.backdropOpacity = 0.5, + this.backdropTapClosesPanel = true, + this.onPanelSlide, + this.onPanelOpened, + this.onPanelClosed, + this.parallaxEnabled = false, + this.parallaxOffset = 0.1, + this.isDraggable = true, + this.slideDirection = SlideDirection.up, + this.defaultPanelState = PanelState.closed, + this.header, + this.footer, + this.scrollController, + this.panelBuilder}) + : assert(panelBuilder != null), + assert(0 <= backdropOpacity && backdropOpacity <= 1.0), + assert(snapPoint == null || 0 < snapPoint && snapPoint < 1.0), + super(key: key); + + @override + SlidingUpPanelState createState() => SlidingUpPanelState(); +} + +class SlidingUpPanelState extends State + with SingleTickerProviderStateMixin { + late AnimationController _animationController; + late final ScrollController _scrollController; + + bool _scrollingEnabled = false; + final VelocityTracker _velocityTracker = + VelocityTracker.withKind(PointerDeviceKind.touch); + + bool _isPanelVisible = true; + + @override + void initState() { + super.initState(); + + _animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 300), + value: widget.defaultPanelState == PanelState.closed + ? 0.0 + : 1.0 //set the default panel state (i.e. set initial value of _ac) + ) + ..addListener(() { + if (widget.onPanelSlide != null) { + widget.onPanelSlide!(_animationController.value); + } + + if (widget.onPanelOpened != null && + (_animationController.value == 1.0 || + _animationController.value == 0.0)) { + widget.onPanelOpened!(); + } + }); + + // prevent the panel content from being scrolled only if the widget is + // draggable and panel scrolling is enabled + _scrollController = widget.scrollController ?? ScrollController(); + _scrollController.addListener(() { + if (widget.isDraggable && + !widget.disableDraggableOnScrolling && + (!_scrollingEnabled || _panelPosition < 1) && + widget.controller?._forceScrollChange != true) { + _scrollController.jumpTo(_scMinOffset); + } + }); + + widget.controller?._addState(this); + } + + @override + Widget build(BuildContext context) { + final mediaQuery = MediaQuery.of(context); + + return Stack( + alignment: widget.slideDirection == SlideDirection.up + ? Alignment.bottomCenter + : Alignment.topCenter, + children: [ + //make the back widget take up the entire back side + if (widget.body != null) + AnimatedBuilder( + animation: _animationController, + builder: (context, child) { + return Positioned( + top: widget.parallaxEnabled ? _getParallax() : 0.0, + child: child ?? const SizedBox(), + ); + }, + child: SizedBox( + height: mediaQuery.size.height, + width: mediaQuery.size.width, + child: widget.body, + ), + ), + + //the backdrop to overlay on the body + if (widget.backdropEnabled) + GestureDetector( + onVerticalDragEnd: widget.backdropTapClosesPanel + ? (DragEndDetails details) { + // only trigger a close if the drag is towards panel close position + if ((widget.slideDirection == SlideDirection.up ? 1 : -1) * + details.velocity.pixelsPerSecond.dy > + 0) _close(); + } + : null, + onTap: widget.backdropTapClosesPanel ? () => _close() : null, + child: AnimatedBuilder( + animation: _animationController, + builder: (context, _) { + return Container( + height: mediaQuery.size.height, + width: mediaQuery.size.width, + + //set color to null so that touch events pass through + //to the body when the panel is closed, otherwise, + //if a color exists, then touch events won't go through + color: _animationController.value == 0.0 + ? null + : widget.backdropColor.withOpacity( + widget.backdropOpacity * _animationController.value, + ), + ); + }), + ), + + //the actual sliding part + if (_isPanelVisible) + _gestureHandler( + child: AnimatedBuilder( + animation: _animationController, + builder: (context, child) { + return Container( + height: _animationController.value * + (widget.maxHeight - widget.minHeight) + + widget.minHeight, + margin: widget.margin, + padding: widget.padding, + decoration: widget.panelDecoration, + child: child, + ); + }, + child: Stack( + children: [ + //open panel + Positioned( + top: + widget.slideDirection == SlideDirection.up ? 0.0 : null, + bottom: widget.slideDirection == SlideDirection.down + ? 0.0 + : null, + width: mediaQuery.size.width - + (widget.margin != null + ? widget.margin!.horizontal + : 0) - + (widget.padding != null + ? widget.padding!.horizontal + : 0), + child: SizedBox( + height: widget.maxHeight, + child: widget.panelBuilder!( + _animationController.value, + ), + ), + ), + + // footer + if (widget.footer != null) + Positioned( + top: widget.slideDirection == SlideDirection.up + ? null + : 0.0, + bottom: widget.slideDirection == SlideDirection.down + ? null + : 0.0, + child: widget.footer ?? const SizedBox()), + + // header + if (widget.header != null) + Positioned( + top: widget.slideDirection == SlideDirection.up + ? 0.0 + : null, + bottom: widget.slideDirection == SlideDirection.down + ? 0.0 + : null, + child: widget.header ?? const SizedBox(), + ), + + // collapsed panel + Positioned( + top: + widget.slideDirection == SlideDirection.up ? 0.0 : null, + bottom: widget.slideDirection == SlideDirection.down + ? 0.0 + : null, + width: mediaQuery.size.width - + (widget.margin != null + ? widget.margin!.horizontal + : 0) - + (widget.padding != null + ? widget.padding!.horizontal + : 0), + child: AnimatedContainer( + duration: const Duration(milliseconds: 250), + height: widget.minHeight, + child: widget.collapsed == null + ? null + : FadeTransition( + opacity: Tween(begin: 1.0, end: 0.0) + .animate(_animationController), + + // if the panel is open ignore pointers (touch events) on the collapsed + // child so that way touch events go through to whatever is underneath + child: IgnorePointer( + ignoring: _animationController.value == 1.0, + child: widget.collapsed, + ), + ), + ), + ), + ], + ), + ), + ), + ], + ); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + double _getParallax() { + if (widget.slideDirection == SlideDirection.up) { + return -_animationController.value * + (widget.maxHeight - widget.minHeight) * + widget.parallaxOffset; + } else { + return _animationController.value * + (widget.maxHeight - widget.minHeight) * + widget.parallaxOffset; + } + } + + bool _ignoreScrollable = false; + bool _isHorizontalScrollableWidget = false; + Axis? _scrollableAxis; + + // returns a gesture detector if panel is used + // and a listener if panelBuilder is used. + // this is because the listener is designed only for use with linking the scrolling of + // panels and using it for panels that don't want to linked scrolling yields odd results + Widget _gestureHandler({required Widget child}) { + if (!widget.isDraggable) return child; + + return Listener( + onPointerDown: (PointerDownEvent e) { + var rb = context.findRenderObject() as RenderBox; + var result = BoxHitTestResult(); + rb.hitTest(result, position: e.position); + + if (_panelPosition == 1) { + _scMinOffset = 0.0; + } + // if there any widget in the path that must force graggable, + // stop it right here + if (result.path.any((entry) => + entry.target.runtimeType == ForceDraggableWidgetRenderBox)) { + widget.controller?._nowTargetForceDraggable = true; + _scMinOffset = _scrollController.offset; + _isHorizontalScrollableWidget = false; + } else if (result.path.any((entry) => + entry.target.runtimeType == HorizontalScrollableWidgetRenderBox)) { + _isHorizontalScrollableWidget = true; + widget.controller?._nowTargetForceDraggable = false; + } else if (result.path.any((entry) => + entry.target.runtimeType == IgnoreDraggableWidgetWidgetRenderBox)) { + _ignoreScrollable = true; + widget.controller?._nowTargetForceDraggable = false; + _isHorizontalScrollableWidget = false; + return; + } else { + widget.controller?._nowTargetForceDraggable = false; + _isHorizontalScrollableWidget = false; + } + _ignoreScrollable = false; + _velocityTracker.addPosition(e.timeStamp, e.position); + }, + onPointerMove: (PointerMoveEvent e) { + if (_scrollableAxis == null) { + if (e.delta.dx.abs() > e.delta.dy.abs()) { + _scrollableAxis = Axis.horizontal; + } else { + _scrollableAxis = Axis.vertical; + } + } + + if (_isHorizontalScrollableWidget && + _scrollableAxis == Axis.horizontal) { + return; + } + + if (_ignoreScrollable) return; + _velocityTracker.addPosition( + e.timeStamp, + e.position, + ); // add current position for velocity tracking + _onGestureSlide(e.delta.dy); + }, + onPointerUp: (PointerUpEvent e) { + if (_ignoreScrollable) return; + _scrollableAxis = null; + _onGestureEnd(_velocityTracker.getVelocity()); + }, + child: child, + ); + } + + double _scMinOffset = 0.0; + + // handles the sliding gesture + void _onGestureSlide(double dy) { + // only slide the panel if scrolling is not enabled + if (widget.controller?._nowTargetForceDraggable == false && + widget.disableDraggableOnScrolling) { + return; + } + if ((!_scrollingEnabled) || + _panelPosition < 1 || + widget.controller?._nowTargetForceDraggable == true) { + if (widget.slideDirection == SlideDirection.up) { + _animationController.value -= + dy / (widget.maxHeight - widget.minHeight); + } else { + _animationController.value += + dy / (widget.maxHeight - widget.minHeight); + } + } + + // if the panel is open and the user hasn't scrolled, we need to determine + // whether to enable scrolling if the user swipes up, or disable closing and + // begin to close the panel if the user swipes down + if (_isPanelOpen && + _scrollController.hasClients && + _scrollController.offset <= _scMinOffset) { + setState(() { + if (dy < 0) { + _scrollingEnabled = true; + } else { + _scrollingEnabled = false; + } + }); + } + } + + // handles when user stops sliding + void _onGestureEnd(Velocity v) { + if (widget.controller?._nowTargetForceDraggable == false && + widget.disableDraggableOnScrolling) { + return; + } + double minFlingVelocity = 365.0; + double kSnap = 8; + + //let the current animation finish before starting a new one + if (_animationController.isAnimating) return; + + // if scrolling is allowed and the panel is open, we don't want to close + // the panel if they swipe up on the scrollable + if (_isPanelOpen && _scrollingEnabled) return; + + //check if the velocity is sufficient to constitute fling to end + double visualVelocity = + -v.pixelsPerSecond.dy / (widget.maxHeight - widget.minHeight); + + // reverse visual velocity to account for slide direction + if (widget.slideDirection == SlideDirection.down) { + visualVelocity = -visualVelocity; + } + + // get minimum distances to figure out where the panel is at + double d2Close = _animationController.value; + double d2Open = 1 - _animationController.value; + double d2Snap = ((widget.snapPoint ?? 3) - _animationController.value) + .abs(); // large value if null results in not every being the min + double minDistance = min(d2Close, min(d2Snap, d2Open)); + + // check if velocity is sufficient for a fling + if (v.pixelsPerSecond.dy.abs() >= minFlingVelocity) { + // snapPoint exists + if (widget.panelSnapping && widget.snapPoint != null) { + if (v.pixelsPerSecond.dy.abs() >= kSnap * minFlingVelocity || + minDistance == d2Snap) { + _animationController.fling(velocity: visualVelocity); + } else { + _flingPanelToPosition(widget.snapPoint!, visualVelocity); + } + + // no snap point exists + } else if (widget.panelSnapping) { + _animationController.fling(velocity: visualVelocity); + + // panel snapping disabled + } else { + _animationController.animateTo( + _animationController.value + visualVelocity * 0.16, + duration: const Duration(milliseconds: 410), + curve: Curves.decelerate, + ); + } + + return; + } + + // check if the controller is already halfway there + if (widget.panelSnapping) { + if (minDistance == d2Close) { + _close(); + } else if (minDistance == d2Snap) { + _flingPanelToPosition(widget.snapPoint!, visualVelocity); + } else { + _open(); + } + } + } + + void _flingPanelToPosition(double targetPos, double velocity) { + final Simulation simulation = SpringSimulation( + SpringDescription.withDampingRatio( + mass: 1.0, + stiffness: 500.0, + ratio: 1.0, + ), + _animationController.value, + targetPos, + velocity); + + _animationController.animateWith(simulation); + } + + //--------------------------------- + //PanelController related functions + //--------------------------------- + + //close the panel + Future _close() { + return _animationController.fling(velocity: -1.0); + } + + //open the panel + Future _open() { + return _animationController.fling(velocity: 1.0); + } + + //hide the panel (completely offscreen) + Future _hide() { + return _animationController.fling(velocity: -1.0).then((x) { + setState(() { + _isPanelVisible = false; + }); + }); + } + + //show the panel (in collapsed mode) + Future _show() { + return _animationController.fling(velocity: -1.0).then((x) { + setState(() { + _isPanelVisible = true; + }); + }); + } + + //animate the panel position to value - must + //be between 0.0 and 1.0 + Future _animatePanelToPosition(double value, + {Duration? duration, Curve curve = Curves.linear}) { + assert(0.0 <= value && value <= 1.0); + return _animationController.animateTo(value, + duration: duration, curve: curve); + } + + //animate the panel position to the snap point + //REQUIRES that widget.snapPoint != null + Future _animatePanelToSnapPoint( + {Duration? duration, Curve curve = Curves.linear}) { + assert(widget.snapPoint != null); + return _animationController.animateTo(widget.snapPoint!, + duration: duration, curve: curve); + } + + //set the panel position to value - must + //be between 0.0 and 1.0 + set _panelPosition(double value) { + assert(0.0 <= value && value <= 1.0); + _animationController.value = value; + } + + //get the current panel position + //returns the % offset from collapsed state + //as a decimal between 0.0 and 1.0 + double get _panelPosition => _animationController.value; + + //returns whether or not + //the panel is still animating + bool get _isPanelAnimating => _animationController.isAnimating; + + //returns whether or not the + //panel is open + bool get _isPanelOpen => _animationController.value == 1.0; + + //returns whether or not the + //panel is closed + bool get _isPanelClosed => _animationController.value == 0.0; + + //returns whether or not the + //panel is shown/hidden + bool get _isPanelShown => _isPanelVisible; +} diff --git a/lib/pages/player/player.dart b/lib/pages/player/player.dart deleted file mode 100644 index e925ba602..000000000 --- a/lib/pages/player/player.dart +++ /dev/null @@ -1,322 +0,0 @@ -import 'package:auto_size_text/auto_size_text.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:spotify/spotify.dart' hide Offset; -import 'package:spotube/collections/assets.gen.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/player/player_actions.dart'; -import 'package:spotube/components/player/player_controls.dart'; -import 'package:spotube/components/player/player_queue.dart'; -import 'package:spotube/components/player/volume_slider.dart'; -import 'package:spotube/components/shared/animated_gradient.dart'; -import 'package:spotube/components/shared/dialogs/track_details_dialog.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/hooks/use_custom_status_bar_color.dart'; -import 'package:spotube/hooks/use_palette_color.dart'; -import 'package:spotube/models/local_track.dart'; -import 'package:spotube/pages/lyrics/lyrics.dart'; -import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; - -class PlayerView extends HookConsumerWidget { - const PlayerView({ - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context, ref) { - final theme = Theme.of(context); - final auth = ref.watch(AuthenticationNotifier.provider); - final currentTrack = ref.watch(ProxyPlaylistNotifier.provider.select( - (value) => value.activeTrack, - )); - final isLocalTrack = ref.watch(ProxyPlaylistNotifier.provider.select( - (value) => value.activeTrack is LocalTrack, - )); - final mediaQuery = MediaQuery.of(context); - - useEffect(() { - if (mediaQuery.lgAndUp) { - WidgetsBinding.instance.addPostFrameCallback((_) { - GoRouter.of(context).pop(); - }); - } - return null; - }, [mediaQuery.lgAndUp]); - - String albumArt = useMemoized( - () => TypeConversionUtils.image_X_UrlString( - currentTrack?.album?.images, - placeholder: ImagePlaceholder.albumArt, - ), - [currentTrack?.album?.images], - ); - - final palette = usePaletteGenerator(albumArt); - final bgColor = palette.dominantColor?.color ?? theme.colorScheme.primary; - final titleTextColor = palette.dominantColor?.titleTextColor; - final bodyTextColor = palette.dominantColor?.bodyTextColor; - - useCustomStatusBarColor( - bgColor, - GoRouterState.of(context).matchedLocation == "/player", - noSetBGColor: true, - ); - - return IconTheme( - data: theme.iconTheme.copyWith(color: bodyTextColor), - child: Scaffold( - appBar: PageWindowTitleBar( - backgroundColor: Colors.transparent, - foregroundColor: titleTextColor, - toolbarOpacity: 1, - leading: const BackButton(), - actions: [ - IconButton( - icon: const Icon(SpotubeIcons.info, size: 18), - tooltip: context.l10n.details, - style: IconButton.styleFrom(foregroundColor: bodyTextColor), - onPressed: currentTrack == null - ? null - : () { - showDialog( - context: context, - builder: (context) { - return TrackDetailsDialog( - track: currentTrack, - ); - }); - }, - ) - ], - ), - extendBodyBehindAppBar: true, - body: SizedBox( - height: double.infinity, - child: AnimateGradient( - animateAlignments: true, - primaryBegin: Alignment.topLeft, - primaryEnd: Alignment.bottomLeft, - secondaryBegin: Alignment.bottomRight, - secondaryEnd: Alignment.topRight, - duration: const Duration(seconds: 15), - primaryColors: [ - palette.dominantColor?.color ?? theme.colorScheme.primary, - palette.mutedColor?.color ?? theme.colorScheme.secondary, - ], - secondaryColors: [ - (palette.darkVibrantColor ?? palette.lightVibrantColor)?.color ?? - theme.colorScheme.primaryContainer, - (palette.darkMutedColor ?? palette.lightMutedColor)?.color ?? - theme.colorScheme.secondaryContainer, - ], - child: SingleChildScrollView( - child: Container( - alignment: Alignment.center, - width: double.infinity, - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 580), - child: SafeArea( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - children: [ - Container( - margin: const EdgeInsets.all(8), - constraints: const BoxConstraints( - maxHeight: 300, maxWidth: 300), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - boxShadow: const [ - BoxShadow( - color: Colors.black26, - spreadRadius: 2, - blurRadius: 10, - offset: Offset(0, 0), - ), - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(20), - child: UniversalImage( - path: albumArt, - placeholder: Assets.albumPlaceholder.path, - fit: BoxFit.cover, - ), - ), - ), - const SizedBox(height: 60), - Container( - padding: const EdgeInsets.symmetric(horizontal: 16), - alignment: Alignment.centerLeft, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AutoSizeText( - currentTrack?.name ?? "Not playing", - style: TextStyle( - color: titleTextColor, - fontSize: 22, - ), - maxFontSize: 22, - maxLines: 1, - textAlign: TextAlign.start, - ), - if (isLocalTrack) - Text( - TypeConversionUtils.artists_X_String< - Artist>( - currentTrack?.artists ?? [], - ), - style: theme.textTheme.bodyMedium!.copyWith( - fontWeight: FontWeight.bold, - color: bodyTextColor, - ), - ) - else - TypeConversionUtils - .artists_X_ClickableArtists( - currentTrack?.artists ?? [], - textStyle: - theme.textTheme.bodyMedium!.copyWith( - fontWeight: FontWeight.bold, - color: bodyTextColor, - ), - onRouteChange: (route) { - GoRouter.of(context).pop(); - GoRouter.of(context).push(route); - }, - ), - ], - ), - ), - const SizedBox(height: 10), - PlayerControls(palette: palette), - const SizedBox(height: 25), - PlayerActions( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - showQueue: false, - ), - const SizedBox(height: 10), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - const SizedBox(width: 10), - Expanded( - child: OutlinedButton.icon( - icon: const Icon(SpotubeIcons.queue), - label: Text(context.l10n.queue), - style: OutlinedButton.styleFrom( - foregroundColor: bodyTextColor, - side: BorderSide( - color: bodyTextColor ?? Colors.white, - ), - ), - onPressed: currentTrack != null - ? () { - showModalBottomSheet( - context: context, - isDismissible: true, - enableDrag: true, - isScrollControlled: true, - backgroundColor: Colors.black12, - barrierColor: Colors.black12, - shape: RoundedRectangleBorder( - borderRadius: - BorderRadius.circular(10), - ), - constraints: BoxConstraints( - maxHeight: - MediaQuery.of(context) - .size - .height * - .7, - ), - builder: (context) { - return const PlayerQueue( - floating: false); - }, - ); - } - : null), - ), - if (auth != null) const SizedBox(width: 10), - if (auth != null) - Expanded( - child: OutlinedButton.icon( - label: Text(context.l10n.lyrics), - icon: const Icon(SpotubeIcons.music), - style: OutlinedButton.styleFrom( - foregroundColor: bodyTextColor, - side: BorderSide( - color: bodyTextColor ?? Colors.white, - ), - ), - onPressed: () { - showModalBottomSheet( - context: context, - isDismissible: true, - enableDrag: true, - isScrollControlled: true, - backgroundColor: Colors.black38, - barrierColor: Colors.black12, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(20), - topRight: Radius.circular(20), - ), - ), - constraints: BoxConstraints( - maxHeight: MediaQuery.of(context) - .size - .height * - 0.8, - ), - builder: (context) => - const LyricsPage(isModal: true), - ); - }, - ), - ), - const SizedBox(width: 10), - ], - ), - const SizedBox(height: 25), - SliderTheme( - data: theme.sliderTheme.copyWith( - activeTrackColor: titleTextColor, - inactiveTrackColor: bodyTextColor, - thumbColor: titleTextColor, - overlayColor: titleTextColor?.withOpacity(0.2), - trackHeight: 2, - thumbShape: const RoundSliderThumbShape( - enabledThumbRadius: 8, - ), - ), - child: const Padding( - padding: EdgeInsets.symmetric(horizontal: 16), - child: VolumeSlider( - fullWidth: true, - ), - ), - ), - ], - ), - ), - ), - ), - ), - ), - ), - ), - ), - ); - } -} diff --git a/lib/provider/user_preferences_provider.dart b/lib/provider/user_preferences_provider.dart index 2da8e8dd8..086eb2b8c 100644 --- a/lib/provider/user_preferences_provider.dart +++ b/lib/provider/user_preferences_provider.dart @@ -248,9 +248,11 @@ class UserPreferences extends PersistedChangeNotifier { void setSystemTitleBar(bool isSystemTitleBar) { systemTitleBar = isSystemTitleBar; - DesktopTools.window.setTitleBarStyle( - systemTitleBar ? TitleBarStyle.normal : TitleBarStyle.hidden, - ); + if (DesktopTools.platform.isDesktop) { + DesktopTools.window.setTitleBarStyle( + systemTitleBar ? TitleBarStyle.normal : TitleBarStyle.hidden, + ); + } notifyListeners(); updatePersistence(); } @@ -286,6 +288,7 @@ class UserPreferences extends PersistedChangeNotifier { recommendationMarket = Market.values.firstWhere( (market) => market.name == (map["recommendationMarket"] ?? recommendationMarket), + orElse: () => Market.US, ); checkUpdate = map["checkUpdate"] ?? checkUpdate;