// ignore_for_file: unused_import import 'dart:convert'; import 'dart:ui'; import 'dart:async'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:audio_service/audio_service.dart'; import 'package:flutter/services.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:freezer/api/cache.dart'; import 'package:freezer/api/deezer.dart'; import 'package:freezer/api/definitions.dart'; import 'package:freezer/api/player/audio_handler.dart'; import 'package:freezer/api/player/player_helper.dart'; import 'package:freezer/main.dart'; import 'package:freezer/page_routes/fade.dart'; import 'package:freezer/settings.dart'; import 'package:freezer/translations.i18n.dart'; import 'package:freezer/ui/cached_image.dart'; import 'package:freezer/ui/fancy_scaffold.dart'; import 'package:freezer/ui/lyrics_screen.dart'; import 'package:freezer/ui/menu.dart'; import 'package:freezer/ui/player_bar.dart'; import 'package:freezer/ui/queue_screen.dart'; import 'package:freezer/ui/settings_screen.dart'; import 'package:logging/logging.dart'; import 'package:marquee/marquee.dart'; import 'package:palette_generator/palette_generator.dart'; import 'package:photo_view/photo_view.dart'; import 'package:provider/provider.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; const _blurStrength = 90.0; /// A simple [ChangeNotifier] that listens to the [AudioHandler.mediaItem] stream and /// notifies its listeners when background changes class BackgroundProvider extends ChangeNotifier { PaletteGenerator? _palette; Color? _dominantColor; ImageProvider? _imageProvider; StreamSubscription? _mediaItemSub; bool _isDisposed = false; BackgroundProvider(); /// Calculate background color from [mediaItem] /// /// Warning: this function is expensive to call, and should only be called when songs change! Future _updateColor(MediaItem mediaItem) async { if (!settings.colorGradientBackground && !settings.blurPlayerBackground && !settings.enableFilledPlayButton && !settings.playerAlbumArtDropShadow) return; final imageProvider = CachedNetworkImageProvider( mediaItem.extras!['thumb'] ?? mediaItem.artUri.toString(), cacheManager: cacheManager); //Run in isolate _palette = await PaletteGenerator.fromImageProvider(imageProvider); _dominantColor = _palette!.dominantColor!.color; _imageProvider = settings.blurPlayerBackground ? imageProvider : null; if (!_isDisposed) notifyListeners(); } @override void addListener(VoidCallback listener) { print('[PROVIDER] listener added $hasListeners'); _mediaItemSub ??= audioHandler.mediaItem.listen((mediaItem) { if (mediaItem == null) return; _updateColor(mediaItem); }); super.addListener(listener); } @override void removeListener(VoidCallback listener) { super.removeListener(listener); print('[PROVIDER] listener removed! hasListeners? $hasListeners'); } @override void dispose() { print('[PROVIDER] DISPOSED'); _isDisposed = true; _mediaItemSub?.cancel(); super.dispose(); } Color? get dominantColor => _dominantColor; PaletteGenerator? get palette => _palette; ImageProvider? get imageProvider => _imageProvider; } class PlayerScreen extends StatelessWidget { const PlayerScreen({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return ChangeNotifierProvider( lazy: false, create: (context) => BackgroundProvider(), child: PlayerArtColorScheme( child: PlayerScreenBackground( child: MainScreen.of(context).isDesktop ? const PlayerScreenDesktop() : OrientationBuilder( builder: (context, orientation) => orientation == Orientation.landscape ? const PlayerScreenHorizontal() : const PlayerScreenVertical())), ), ); } } /// Will inject a [Theme] containing a [ColorScheme] generated /// from [BackgroundProvider.dominantColor] class PlayerArtColorScheme extends StatelessWidget { final Widget child; final bool enabled; final bool forceDark; const PlayerArtColorScheme({ super.key, required this.child, this.enabled = true, this.forceDark = false, }); @override Widget build(BuildContext context) { final backgroundProvider = context.watch(); if (backgroundProvider.dominantColor == null) { // do nothing return child; } final brightness = forceDark ? Brightness.dark : Theme.of(context).colorScheme.brightness; return Theme( data: Theme.of(context).copyWith( colorScheme: ColorScheme.fromSeed( seedColor: backgroundProvider.dominantColor!, brightness: brightness, )), child: child); } } /// Will change the background based on [BackgroundProvider], /// it will wrap the [child] in a [Scaffold] and [SafeArea] widget class PlayerScreenBackground extends StatelessWidget { final Widget child; final bool enabled; final PreferredSizeWidget? appBar; const PlayerScreenBackground({ super.key, required this.child, this.enabled = true, this.appBar, }); Widget _buildChild( BuildContext context, BackgroundProvider provider, Widget child) { return Stack(children: [ if (provider.imageProvider != null || settings.colorGradientBackground) Positioned.fill( child: provider.imageProvider != null ? DecoratedBox( decoration: const BoxDecoration(color: Colors.black), child: ImageFiltered( imageFilter: ImageFilter.blur( tileMode: TileMode.decal, sigmaX: _blurStrength, sigmaY: _blurStrength, ), child: DecoratedBox( decoration: BoxDecoration( image: DecorationImage( image: provider.imageProvider!, fit: BoxFit.cover, colorFilter: ColorFilter.mode( Colors.white .withOpacity(settings.isDark ? 0.55 : 0.75), BlendMode.dstATop), )), ), ), ) : DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ if (provider.dominantColor != null) provider.dominantColor!, Theme.of(context).scaffoldBackgroundColor, ], stops: const [0.0, 0.6], )), )), child, ]); } static SystemUiOverlayStyle? getSystemUiOverlayStyle(BuildContext context, {bool enabled = true}) { final hasBackground = enabled && (settings.blurPlayerBackground || settings.colorGradientBackground); if (!hasBackground) return null; const color = Colors.transparent; final brightness = hasBackground ? Brightness.light : (ThemeData.estimateBrightnessForColor(color) == Brightness.light ? Brightness.dark : Brightness.light); return SystemUiOverlayStyle( statusBarColor: color, statusBarBrightness: brightness, statusBarIconBrightness: brightness, systemNavigationBarIconBrightness: brightness, systemNavigationBarColor: color, systemNavigationBarDividerColor: color, ); } @override Widget build(BuildContext context) { final hasBackground = enabled && (settings.blurPlayerBackground || settings.colorGradientBackground); final color = hasBackground ? Colors.transparent : Theme.of(context).colorScheme.background; Widget widgetChild = Scaffold( appBar: appBar, backgroundColor: color, body: SafeArea(child: child), ); if (enabled) { widgetChild = Consumer( builder: (context, provider, child) { return _buildChild(context, provider, child!); }, child: widgetChild, ); } final suios = getSystemUiOverlayStyle(context, enabled: enabled); if (appBar == null && suios != null) { widgetChild = AnnotatedRegion( value: suios, child: widgetChild, ); } return widgetChild; } } //Landscape class PlayerScreenHorizontal extends StatelessWidget { const PlayerScreenHorizontal({super.key}); @override Widget build(BuildContext context) { return Row( mainAxisSize: MainAxisSize.max, mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ const Expanded( flex: 4, child: Padding( padding: EdgeInsets.all(8.0), child: BigAlbumArt(), ), ), const SizedBox(width: 56.0), //Right side Expanded( flex: 5, child: Column( mainAxisSize: MainAxisSize.max, mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ Padding( padding: const EdgeInsets.fromLTRB(0, 0, 8, 0), child: PlayerScreenTopRow( textSize: 24.sp, iconSize: 36.sp, textWidth: 350.w, short: true), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 24.0), child: PlayerTextSubtext(textSize: 35.sp), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: SeekBar(textSize: 24.sp), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 24.0), child: PlaybackControls(46.sp), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 24.0), child: BottomBarControls(size: 30.sp), ) ], ), ) ], ); } } //Portrait class PlayerScreenVertical extends StatelessWidget { const PlayerScreenVertical({super.key}); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), child: Column( mainAxisSize: MainAxisSize.max, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 4.0), child: PlayerScreenTopRow( textSize: 14.spMax, iconSize: 20.spMax, ), ), const Flexible(child: BigAlbumArt()), Padding( padding: const EdgeInsets.only(left: 24.0, right: 16.0), child: PlayerTextSubtext(textSize: 24.spMax), ), SeekBar(textSize: 14.spMax), Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: PlaybackControls(32.spMax), ), Padding( padding: const EdgeInsets.symmetric(vertical: 0, horizontal: 16.0), child: BottomBarControls(size: 22.spMax), ) ], )); } } class PlayerScreenDesktop extends StatelessWidget { const PlayerScreenDesktop({super.key}); @override Widget build(BuildContext context) { return Row(children: [ AspectRatio( aspectRatio: 9 / 16, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: Column( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 4.0), child: PlayerScreenTopRow( textSize: 12.h, iconSize: 21.h, showQueueButton: false, ), ), Flexible( child: ConstrainedBox( constraints: BoxConstraints.loose(const Size.square(500)), child: const BigAlbumArt(showLyricsButton: false)), ), Padding( padding: const EdgeInsets.only(left: 24.0, right: 16.0), child: PlayerTextSubtext(textSize: 22.h), ), SeekBar(textSize: 16.h), Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: PlaybackControls(28.h), ), Padding( padding: const EdgeInsets.symmetric(vertical: 0, horizontal: 16.0), child: BottomBarControls( size: 20.h, desktopMode: true, ), ) ]), ), ), const Expanded( flex: 2, child: Padding( padding: EdgeInsets.only(left: 16.0, right: 16.0, top: 16.0), child: _DesktopTabView(), )), ]); } } class _DesktopTabView extends StatelessWidget { const _DesktopTabView({super.key}); @override Widget build(BuildContext context) { return DefaultTabController( length: 2, child: Column(children: [ TabBar( tabs: [ Tab( text: 'Queue'.i18n, height: 48.0, ), Tab( text: 'Lyrics'.i18n, ), ], labelStyle: Theme.of(context) .textTheme .labelLarge! .copyWith(fontSize: 18.0)), Expanded( child: SizedBox.expand( child: Material( type: MaterialType.transparency, child: TabBarView(children: [ QueueListWidget( closePlayer: FancyScaffold.of(context)!.closePanel, isInsidePlayer: true, ), const LyricsWidget(), ]), ), )), ]), ); } } class FitOrScrollText extends StatefulWidget { final String text; final TextStyle style; final TextAlign? textAlign; final TextDirection? textDirection; final int? maxLines; const FitOrScrollText({ required this.text, required this.style, this.textAlign, this.textDirection, this.maxLines, super.key, }); @override State createState() => _FitOrScrollTextState(); } class _FitOrScrollTextState extends State { bool _checkTextFits(String text, BoxConstraints constraints) { final textPainter = TextPainter( text: TextSpan(text: text, style: widget.style), textAlign: widget.textAlign ?? TextAlign.left, textDirection: widget.textDirection ?? TextDirection.ltr, maxLines: widget.maxLines, ); textPainter.layout(maxWidth: constraints.maxWidth); return !(textPainter.didExceedMaxLines || textPainter.height > constraints.maxHeight || textPainter.width > constraints.maxWidth); } @override Widget build(BuildContext context) { return LayoutBuilder(builder: (context, constraints) { return _checkTextFits(widget.text, constraints) ? Text( widget.text, maxLines: widget.maxLines, style: widget.style, ) : Marquee( text: widget.text, style: widget.style, blankSpace: 32.0, startPadding: 0.0, numberOfRounds: 2, accelerationDuration: const Duration(seconds: 1), pauseAfterRound: const Duration(seconds: 2), crossAxisAlignment: CrossAxisAlignment.end, fadingEdgeEndFraction: 0.05, fadingEdgeStartFraction: 0.05, ); }); } } class PlayerTextSubtext extends StatelessWidget { final double textSize; const PlayerTextSubtext({super.key, required this.textSize}); @override Widget build(BuildContext context) { return StreamBuilder( stream: audioHandler.mediaItem, builder: (context, snapshot) { if (!snapshot.hasData) { return const SizedBox(); } final currentMediaItem = snapshot.data!; return Row( mainAxisSize: MainAxisSize.min, children: [ Expanded( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SizedBox( height: 1.5 * textSize, child: FitOrScrollText( key: Key(currentMediaItem.displayTitle!), text: currentMediaItem.displayTitle!, maxLines: 1, style: TextStyle( fontSize: textSize, fontWeight: FontWeight.bold, overflow: TextOverflow.ellipsis)), ), // child: currentMediaItem.displayTitle!.length >= 26 // ? Marquee( // key: Key(currentMediaItem.displayTitle!), // text: currentMediaItem.displayTitle!, // style: TextStyle( // fontSize: textSize, fontWeight: FontWeight.bold), // blankSpace: 32.0, // startPadding: 0.0, // accelerationDuration: const Duration(seconds: 1), // pauseAfterRound: const Duration(seconds: 2), // crossAxisAlignment: CrossAxisAlignment.start, // fadingEdgeEndFraction: 0.05, // fadingEdgeStartFraction: 0.05, // ) // : Text( // currentMediaItem.displayTitle!, // maxLines: 1, // overflow: TextOverflow.ellipsis, // textAlign: TextAlign.start, // style: TextStyle( // fontSize: textSize, fontWeight: FontWeight.bold), // )), Text( currentMediaItem.displaySubtitle ?? '', maxLines: 1, textAlign: TextAlign.start, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: textSize * 0.8, // 20% smaller color: Theme.of(context).colorScheme.primary, ), ), ]), ), const SizedBox(width: 8.0), FavoriteButton(size: textSize), ], ); }); } } class QualityInfoWidget extends StatelessWidget { final double? textSize; const QualityInfoWidget({Key? key, this.textSize}) : super(key: key); String _getQualityStringFromInfo(StreamQualityInfo info) { if (audioHandler.mediaItem.value == null) return ''; int bitrate = info.quality == AudioQuality.MP3_128 ? 128 : info.quality == AudioQuality.MP3_320 ? 320 : info.calculateBitrate(audioHandler.mediaItem.value!.duration!); return '${info.format.name} ${bitrate}kbps'; } @override Widget build(BuildContext context) { return TextButton( // style: ButtonStyle( // elevation: MaterialStatePropertyAll(0.5), // padding: MaterialStatePropertyAll( // EdgeInsets.symmetric(horizontal: 16, vertical: 4)), // foregroundColor: MaterialStatePropertyAll( // Theme.of(context).colorScheme.onSurface)), child: StreamBuilder( stream: playerHelper.streamInfo.map(_getQualityStringFromInfo), builder: (context, snapshot) => Text(snapshot.data ?? '', style: textSize == null ? null : TextStyle(fontSize: textSize! * 0.9))), onPressed: () => Navigator.of(context).push( MaterialPageRoute(builder: (context) => const QualitySettings())), ); } } void Function([TapUpDetails?]) _onMenuPressedCallback(BuildContext context) { return ([details]) { final currentMediaItem = audioHandler.mediaItem.value!; Track t = Track.fromMediaItem(currentMediaItem); MenuSheet m = MenuSheet(context, navigateCallback: () { // close player FancyScaffold.of(context)?.closePanel(); }); if (currentMediaItem.extras!['show'] == null) { m.defaultTrackMenu(t, options: [m.sleepTimer(), m.wakelock()], details: details); } else { m.defaultShowEpisodeMenu(Show.fromJson(currentMediaItem.extras!['show']), ShowEpisode.fromMediaItem(currentMediaItem), options: [m.sleepTimer(), m.wakelock()], details: details); } }; } class PlayerMenuButtonDesktop extends StatelessWidget { final double size; const PlayerMenuButtonDesktop({super.key, required this.size}); @override Widget build(BuildContext context) { return InkWell( customBorder: const CircleBorder(), onTapUp: _onMenuPressedCallback(context), child: Padding( padding: const EdgeInsets.all(8.0), child: Icon( Icons.more_vert, semanticLabel: "Options".i18n, size: size, ), ), ); } } class PlayerMenuButton extends StatelessWidget { final double size; const PlayerMenuButton({super.key, required this.size}); @override Widget build(BuildContext context) { return IconButton( iconSize: size, icon: Icon( Icons.more_vert, semanticLabel: "Options".i18n, ), onPressed: _onMenuPressedCallback(context)); } } class RepeatButton extends StatefulWidget { final double iconSize; const RepeatButton(this.iconSize, {Key? key}) : super(key: key); @override State createState() => _RepeatButtonState(); } class _RepeatButtonState extends State { // ignore: missing_return Icon get repeatIcon { switch (playerHelper.repeatType) { case AudioServiceRepeatMode.none: return Icon( Icons.repeat, semanticLabel: "Repeat off".i18n, ); case AudioServiceRepeatMode.one: return Icon( Icons.repeat_one, semanticLabel: "Repeat one".i18n, ); case AudioServiceRepeatMode.group: case AudioServiceRepeatMode.all: return Icon( Icons.repeat, semanticLabel: "Repeat".i18n, ); } } @override Widget build(BuildContext context) { return IconButton( color: playerHelper.repeatType == AudioServiceRepeatMode.none ? null : Theme.of(context).colorScheme.primary, iconSize: widget.iconSize, icon: repeatIcon, onPressed: () async { await playerHelper.changeRepeat(); setState(() {}); }, ); } } class ShuffleButton extends StatefulWidget { final double iconSize; const ShuffleButton({Key? key, required this.iconSize}) : super(key: key); @override State createState() => _ShuffleButtonState(); } class _ShuffleButtonState extends State { @override Widget build(BuildContext context) => IconButton( icon: const Icon(Icons.shuffle), iconSize: widget.iconSize, color: playerHelper.shuffleEnabled ? Theme.of(context).colorScheme.primary : null, onPressed: _toggleShuffle, ); void _toggleShuffle() { playerHelper.toggleShuffle().then((_) => setState(() {})); } } class FavoriteButton extends StatefulWidget { final double size; const FavoriteButton({Key? key, required this.size}) : super(key: key); @override State createState() => _FavoriteButtonState(); } class _FavoriteButtonState extends State { Icon get libraryIcon { if (cache.checkTrackFavorite( Track.fromMediaItem(audioHandler.mediaItem.value!))) { return Icon( Icons.favorite, semanticLabel: "Unlove".i18n, ); } return Icon( Icons.favorite_border, semanticLabel: "Love".i18n, ); } @override Widget build(BuildContext context) => StreamBuilder( stream: audioHandler.mediaItem, builder: (context, snapshot) { if (!snapshot.hasData || snapshot.data == null) { return IconButton( onPressed: () {}, icon: const Icon(Icons.favorite_border)); } final mediaItem = snapshot.data!; return IconButton( icon: libraryIcon, iconSize: widget.size, onPressed: () async { if (cache.checkTrackFavorite(Track.fromMediaItem(mediaItem))) { //Remove from library setState(() => cache.libraryTracks.remove(mediaItem.id)); await DeezerAPI.instance.removeFavorite(mediaItem.id); await cache.save(); } else { //Add setState(() => cache.libraryTracks.add(mediaItem.id)); await DeezerAPI.instance.addFavoriteTrack(mediaItem.id); await cache.save(); } }, ); }); } class ForwardReplay30Button extends StatelessWidget { final bool forward; const ForwardReplay30Button({super.key, required this.forward}); void _seek(Duration position) { // validate position if (position.isNegative) return; if (position > audioHandler.mediaItem.value!.duration!) return; audioHandler.seek(position); } @override Widget build(BuildContext context) { if (forward) { return IconButton( onPressed: () => _seek(audioHandler.playbackState.value.position + const Duration(seconds: 30)), icon: const Icon(Icons.forward_30)); } return IconButton( onPressed: () => _seek(audioHandler.playbackState.value.position - const Duration(seconds: 30)), icon: const Icon(Icons.replay_30)); } } class PlaybackControls extends StatelessWidget { final double size; const PlaybackControls(this.size, {Key? key}) : super(key: key); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: StreamBuilder( stream: playerHelper.queueSource, builder: (context, snapshot) { final queueSource = snapshot.data; return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisSize: MainAxisSize.max, children: [ queueSource?.source != 'show' ? ShuffleButton(iconSize: size * 0.75) : const ForwardReplay30Button(forward: false), PrevNextButton(size, prev: true), if (settings.enableFilledPlayButton) Consumer(builder: (context, provider, _) { final color = provider.dominantColor == null ? Colors.transparent : Theme.of(context).brightness == Brightness.light ? provider.dominantColor! : darken(provider.dominantColor!); return PlayPauseButton(size * 2.25, filled: true, material3: settings.enableMaterial3PlayButton, color: color, iconColor: Color.lerp( (ThemeData.estimateBrightnessForColor(color) == Brightness.light ? Colors.black : Colors.white), color, 0.25)); }) else PlayPauseButton(size * 1.25), PrevNextButton(size), queueSource?.source != 'show' ? RepeatButton(size * 0.75) : const ForwardReplay30Button(forward: true), ], ); }), ); } } class BigAlbumArt extends StatefulWidget { final bool showLyricsButton; const BigAlbumArt({super.key, this.showLyricsButton = true}); @override State createState() => _BigAlbumArtState(); } class _BigAlbumArtState extends State with WidgetsBindingObserver { final _pageController = PageController( initialPage: playerHelper.queueIndex, keepPage: false, viewportFraction: 1.0, ); StreamSubscription? _currentItemSub; /// is true on pointer down event /// used to distinguish between [PageController.animateToPage] and user gesture bool _userScroll = true; /// whether the user has already scrolled the [PageView], /// so to avoid calling [PageController.animateToPage] again. bool _initiatedByUser = false; void _listenForMediaItemChanges() { if (_currentItemSub != null) return; _currentItemSub = audioHandler.mediaItem.listen((event) async { if (_initiatedByUser) { _initiatedByUser = false; return; } if (!_pageController.hasClients) return; if (_pageController.page?.toInt() == playerHelper.queueIndex) return; print('animating controller to page'); _userScroll = false; await _pageController.animateToPage(playerHelper.queueIndex, duration: const Duration(milliseconds: 300), curve: Curves.easeInOut); _userScroll = true; }); } @override void initState() { _listenForMediaItemChanges(); super.initState(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { switch (state) { case AppLifecycleState.paused: _currentItemSub?.cancel(); case AppLifecycleState.resumed: _listenForMediaItemChanges(); default: break; } super.didChangeAppLifecycleState(state); } @override void dispose() { _currentItemSub?.cancel(); super.dispose(); } void _pushLyrics() async { // enable wakelock if not already enabled final wakelockChanged = !(await WakelockPlus.enabled); if (wakelockChanged) { WakelockPlus.enable(); } builder(ctx) => ChangeNotifierProvider.value( value: Provider.of(context), child: const LyricsScreen()); final pushed = Navigator.of(context).pushRoute(builder: builder); if (wakelockChanged) { pushed.then((_) => WakelockPlus.disable()); } } @override Widget build(BuildContext context) { final child = GestureDetector( onTap: () => Navigator.push( context, FadePageRoute( barrierDismissible: true, opaque: false, builder: (context) { final mediaItem = audioHandler.mediaItem.value!; return ZoomableImageRoute( imageUrl: mediaItem.artUri.toString(), heroKey: mediaItem.id); }, )), child: LayoutBuilder( builder: (context, constraints) => Stack( children: [ StreamBuilder>( stream: audioHandler.queue, initialData: audioHandler.queue.valueOrNull, builder: (context, snapshot) { if (!snapshot.hasData) { return const Center( child: CircularProgressIndicator()); } final queue = snapshot.data!; return PageView.builder( controller: _pageController, onPageChanged: (int index) { // ignore if not initiated by user. if (!_userScroll) return; Logger('BigAlbumArt') .fine('page changed, skipping to media item'); if (queue[index].id == audioHandler.mediaItem.value?.id) { return; } audioHandler.skipToQueueItem(index); }, itemCount: queue.length, itemBuilder: (context, i) => Padding( padding: const EdgeInsets.all(8.0), child: Hero( tag: queue[i].id, child: ClipRRect( borderRadius: BorderRadius.circular(8.0), child: CachedImage( url: queue[i].artUri.toString(), fullThumb: true, ), ), ), )); }), if (widget.showLyricsButton) StreamBuilder( initialData: audioHandler.mediaItem.valueOrNull, stream: audioHandler.mediaItem, builder: (context, snapshot) { if (snapshot.data == null) { return const SizedBox.shrink(); } final l = snapshot.data!.extras?['lyrics'] == null ? null : Lyrics.fromJson( jsonDecode(snapshot.data!.extras!['lyrics'])); if (l == null || l.id == null || l.id == '0') { return const SizedBox.shrink(); } return Positioned( key: const ValueKey('lyrics_button'), bottom: 16.0, right: 16.0, child: LyricsButton( onTap: _pushLyrics, size: constraints.maxHeight / 20, ), ); }, ), ], )), ); return AspectRatio( aspectRatio: 1.0, child: Center( child: settings.playerAlbumArtDropShadow ? Consumer( builder: (context, background, child) => AnimatedContainer( duration: const Duration(seconds: 1), decoration: BoxDecoration(boxShadow: [ BoxShadow( color: (background.dominantColor ?? Colors.transparent) .withOpacity(0.5), spreadRadius: 20.0, blurRadius: 150.0) ]), child: child), child: child, ) : child, ), ); } } class LyricsButton extends StatelessWidget { final VoidCallback onTap; final double size; const LyricsButton({super.key, required this.onTap, this.size = 24.0}); @override Widget build(BuildContext context) { return Consumer( builder: (context, provider, child) => Material( color: Color.lerp(Theme.of(context).colorScheme.background, provider.dominantColor, 0.25), borderRadius: BorderRadius.circular(size * 2 / 3), clipBehavior: Clip.antiAlias, child: child), child: InkWell( onTap: onTap, child: Padding( padding: EdgeInsets.symmetric(horizontal: size / 1.25, vertical: size / 6), child: Row(mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.subtitles, size: size), SizedBox(width: size / 3), Text( 'Lyrics'.i18n, style: TextStyle(fontSize: size * 0.8), ), ]), ), ), ); } } //Top row containing QueueSource, queue... class PlayerScreenTopRow extends StatelessWidget { final double? textSize; final double? iconSize; final double? textWidth; final bool short; final bool showQueueButton; // not needed on desktop const PlayerScreenTopRow( {super.key, this.textSize, this.iconSize, this.textWidth, this.short = false, this.showQueueButton = true}); @override Widget build(BuildContext context) { final size = iconSize ?? 52.sp; return Row( mainAxisSize: MainAxisSize.max, mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.center, children: [ IconButton( onPressed: FancyScaffold.of(context)!.closePanel, icon: Icon( Icons.keyboard_arrow_down, semanticLabel: 'Close'.i18n, ), iconSize: size, splashRadius: size * 1.5, ), Expanded( child: StreamBuilder( stream: playerHelper.queueSource, builder: (context, snapshot) { final queueSource = snapshot.data; if (queueSource == null) { return const SizedBox.shrink(); } return RichText( textAlign: TextAlign.center, maxLines: 2, text: TextSpan( children: [ if (!short) TextSpan( text: '${'PLAYING FROM'.i18n}\n', style: TextStyle( fontWeight: FontWeight.bold, letterSpacing: 1.5, fontSize: (textSize ?? 38.sp) * 0.85)), TextSpan(text: queueSource.text ?? '') ], style: Theme.of(context) .textTheme .bodySmall! .copyWith(fontSize: textSize ?? 38.sp))); }), ), showQueueButton ? IconButton( icon: Icon( Icons.menu, semanticLabel: "Queue".i18n, ), iconSize: size, splashRadius: size * 1.5, onPressed: () => Navigator.of(context).pushRoute( builder: (ctx) => QueueScreen( closePlayer: FancyScaffold.of(context)!.closePanel, )), ) : SizedBox.square(dimension: size + 16.0), ], ); } } class SeekBar extends StatefulWidget { final double textSize; const SeekBar({Key? key, this.textSize = 16.0}) : super(key: key); @override State createState() => _SeekBarState(); } class _SeekBarState extends State { bool _seeking = false; late StreamSubscription _subscription; final position = ValueNotifier(Duration.zero); @override void initState() { _subscription = AudioService.position.listen((position) { if (_seeking) return; // user is seeking this.position.value = position; }); super.initState(); } @override void dispose() { _subscription.cancel(); super.dispose(); } double parseDuration(Duration position) { if (position > duration) return duration.inMilliseconds.toDouble(); return position.inMilliseconds.toDouble(); } //Duration to mm:ss String _timeString(Duration d) { return "${d.inMinutes}:${d.inSeconds.remainder(60).toString().padLeft(2, '0')}"; } Duration get duration { if (audioHandler.mediaItem.value == null) return Duration.zero; return audioHandler.mediaItem.value!.duration!; } @override Widget build(BuildContext context) { return Column( mainAxisSize: MainAxisSize.min, children: [ ValueListenableBuilder( valueListenable: position, builder: (context, value, _) => StreamBuilder( stream: playerHelper.bufferPosition, builder: (context, snapshot) { return Slider( secondaryTrackValue: parseDuration(snapshot.data ?? Duration.zero), focusNode: FocusNode( canRequestFocus: false, skipTraversal: true), // Don't focus on Slider - it doesn't work (and not needed) value: parseDuration(value), max: duration.inMilliseconds.toDouble(), onChangeStart: (double d) { _seeking = true; position.value = Duration(milliseconds: d.toInt()); }, onChanged: (double d) { position.value = Duration(milliseconds: d.toInt()); }, onChangeEnd: (double d) { _seeking = false; audioHandler.seek(Duration(milliseconds: d.toInt())); }, ); })), Padding( padding: const EdgeInsets.symmetric(horizontal: 24.0), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ ValueListenableBuilder( valueListenable: position, builder: (context, value, _) => Text( _timeString(value), style: TextStyle( fontSize: widget.textSize, color: Theme.of(context) .textTheme .bodyMedium! .color! .withOpacity(.75)), )), StreamBuilder( stream: audioHandler.mediaItem, builder: (context, snapshot) => Text( _timeString(snapshot.data?.duration ?? Duration.zero), style: TextStyle( fontSize: widget.textSize, color: Theme.of(context) .textTheme .bodyMedium! .color! .withOpacity(.75)), )), ], ), ), ], ); } } class BottomBarControls extends StatelessWidget { final double size; final bool desktopMode; // removed in desktop mode, because there's a tabbed view which includes it const BottomBarControls({ super.key, required this.size, this.desktopMode = false, }); @override Widget build(BuildContext context) { final iconSize = size * 0.9; if (playerHelper.queueSource.valueOrNull?.source == 'show') { return Row( crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ QualityInfoWidget( textSize: size * 0.75, ), const Expanded(child: SizedBox()), PlayerMenuButton(size: size), ], ); } return Row( mainAxisSize: MainAxisSize.max, mainAxisAlignment: MainAxisAlignment.spaceEvenly, crossAxisAlignment: CrossAxisAlignment.center, children: [ QualityInfoWidget(textSize: size * 0.75), const Expanded(child: SizedBox()), IconButton( icon: Icon( Icons.sentiment_very_dissatisfied, semanticLabel: "Dislike".i18n, ), iconSize: iconSize, onPressed: () async { unawaited(DeezerAPI.instance .dislikeTrack(audioHandler.mediaItem.value!.id)); if (playerHelper.queueIndex < audioHandler.queue.value.length - 1) { audioHandler.skipToNext(); } }), // IconButton( // iconSize: size, // icon: Icon( // Icons.file_download, // semanticLabel: "Download".i18n, // ), // onPressed: () async { // Track t = Track.fromMediaItem(audioHandler.mediaItem.value!); // if (await downloadManager.addOfflineTrack(t, // private: false, context: context, isSingleton: true) != // false) // Fluttertoast.showToast( // msg: 'Downloads added!'.i18n, // gravity: ToastGravity.BOTTOM, // toastLength: Toast.LENGTH_SHORT); // }, // ), desktopMode ? PlayerMenuButtonDesktop(size: iconSize) : PlayerMenuButton(size: iconSize) ], ); } }