import 'dart:math'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:audio_service/audio_service.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:freezer/api/cache.dart'; import 'package:freezer/api/deezer.dart'; import 'package:freezer/api/download.dart'; import 'package:freezer/api/player.dart'; import 'package:freezer/settings.dart'; import 'package:freezer/translations.i18n.dart'; import 'package:freezer/ui/elements.dart'; import 'package:freezer/ui/lyrics.dart'; import 'package:freezer/ui/menu.dart'; import 'package:freezer/ui/settings_screen.dart'; import 'package:freezer/ui/tiles.dart'; import 'package:just_audio/just_audio.dart'; import 'package:marquee/marquee.dart'; import 'package:palette_generator/palette_generator.dart'; import 'package:photo_view/photo_view.dart'; import 'cached_image.dart'; import '../api/definitions.dart'; import 'player_bar.dart'; import 'dart:ui'; import 'dart:convert'; import 'dart:async'; //Changing item in queue view and pressing back causes the pageView to skip song bool pageViewLock = false; //So can be updated when going back from lyrics late Function updateColor; class PlayerScreen extends StatefulWidget { static const _blurStrength = 50.0; @override _PlayerScreenState createState() => _PlayerScreenState(); } class _PlayerScreenState extends State { LinearGradient? _bgGradient; late StreamSubscription _mediaItemSub; late StreamSubscription _playerStateSub; ImageProvider? _blurImage; bool _wasConnected = true; //Calculate background color Future _updateColor() async { if (!settings.colorGradientBackground! && !settings.blurPlayerBackground!) return; final imageProvider = CachedNetworkImageProvider( audioHandler.mediaItem.value!.extras!['thumb'] ?? audioHandler.mediaItem.value!.artUri as String); //BG Image if (settings.blurPlayerBackground!) setState(() => _blurImage = imageProvider); if (settings.colorGradientBackground!) { //Run in isolate PaletteGenerator palette = await PaletteGenerator.fromImageProvider(imageProvider); setState(() => _bgGradient = LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ palette.dominantColor!.color.withOpacity(0.7), Color.fromARGB(0, 0, 0, 0) ], stops: [ 0.0, 0.6 ])); } } void _playbackStateChanged() { // if (audioHandler.mediaItem.value == null) { // //playerHelper.startService(); // setState(() => _wasConnected = false); // } else if (!_wasConnected) setState(() => _wasConnected = true); } @override void initState() { Future.delayed(Duration(milliseconds: 600), _updateColor); _playbackStateChanged(); _mediaItemSub = audioHandler.mediaItem.listen((event) { _playbackStateChanged(); _updateColor(); }); _playerStateSub = audioHandler.playbackState.listen((_) => _playbackStateChanged()); updateColor = this._updateColor; super.initState(); } @override void dispose() { _mediaItemSub.cancel(); _playerStateSub.cancel(); super.dispose(); } @override Widget build(BuildContext context) { final hasBackground = settings.blurPlayerBackground! || settings.colorGradientBackground!; final color = hasBackground ? Colors.transparent : Theme.of(context).scaffoldBackgroundColor; return AnnotatedRegion( value: SystemUiOverlayStyle( statusBarColor: color, statusBarBrightness: Brightness.light, statusBarIconBrightness: Brightness.light, systemNavigationBarIconBrightness: Brightness.light, systemNavigationBarColor: color, systemNavigationBarDividerColor: color, ), child: Stack( children: [ if (hasBackground) Positioned.fill( child: ImageFiltered( imageFilter: ImageFilter.blur( sigmaX: PlayerScreen._blurStrength, sigmaY: PlayerScreen._blurStrength, tileMode: TileMode.mirror), child: DecoratedBox( decoration: BoxDecoration( gradient: _bgGradient, image: _blurImage == null ? null : DecorationImage( image: _blurImage!, fit: BoxFit.cover, colorFilter: ColorFilter.mode( Colors.white.withOpacity(0.5), BlendMode.dstATop))), ), ), ), Scaffold( backgroundColor: hasBackground ? Colors.transparent : null, body: _wasConnected ? SafeArea( child: OrientationBuilder( builder: (context, orientation) => orientation == Orientation.landscape ? PlayerScreenHorizontal() : PlayerScreenVertical(), ), ) : Center(child: CircularProgressIndicator()), ), ], ), ); } } //Landscape class PlayerScreenHorizontal extends StatefulWidget { @override _PlayerScreenHorizontalState createState() => _PlayerScreenHorizontalState(); } class _PlayerScreenHorizontalState extends State { @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.all(16.0), child: Row( mainAxisSize: MainAxisSize.max, children: [ Expanded( flex: 5, child: BigAlbumArt(), ), //Right side Expanded( flex: 5, child: Column( mainAxisSize: MainAxisSize.max, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Padding( padding: EdgeInsets.fromLTRB(8, 0, 8, 0), child: Container( child: PlayerScreenTopRow( textSize: ScreenUtil().setSp(24), iconSize: ScreenUtil().setSp(36), textWidth: ScreenUtil().setWidth(350), short: true), )), Column( mainAxisSize: MainAxisSize.min, children: [ Container( height: ScreenUtil().setSp(50), child: audioHandler .mediaItem.value!.displayTitle!.length >= 22 ? Marquee( text: audioHandler.mediaItem.value!.displayTitle!, style: TextStyle( fontSize: 40.sp, fontWeight: FontWeight.bold), blankSpace: 32.0, startPadding: 10.0, accelerationDuration: Duration(seconds: 1), pauseAfterRound: Duration(seconds: 2), ) : Text( audioHandler.mediaItem.value!.displayTitle!, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 40.sp, fontWeight: FontWeight.bold), )), const SizedBox(height: 4.0), Text( audioHandler.mediaItem.value!.displaySubtitle ?? '', maxLines: 1, textAlign: TextAlign.center, overflow: TextOverflow.clip, style: TextStyle( fontSize: 32.sp, color: Theme.of(context).primaryColor, ), ), ], ), const Padding( padding: EdgeInsets.symmetric(horizontal: 16.0), child: SeekBar(), ), PlaybackControls(56.sp), Padding( padding: const EdgeInsets.symmetric(horizontal: 10.0), child: BottomBarControls(size: 42.sp), ) ], ), ) ], ), ); } } //Portrait class PlayerScreenVertical extends StatefulWidget { @override _PlayerScreenVerticalState createState() => _PlayerScreenVerticalState(); } class _PlayerScreenVerticalState extends State { @override Widget build(BuildContext context) { return Padding( padding: EdgeInsets.fromLTRB(30, 4, 16, 0), child: Column( mainAxisSize: MainAxisSize.max, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ PlayerScreenTopRow(), Padding( padding: EdgeInsets.symmetric(horizontal: 16.0), child: SizedBox( child: BigAlbumArt(), width: 1000.w, height: 1000.w, ), ), const SizedBox(height: 4.0), Column( mainAxisSize: MainAxisSize.min, children: [ Container( height: ScreenUtil().setSp(80), child: audioHandler.mediaItem.value!.displayTitle!.length >= 26 ? Marquee( text: audioHandler.mediaItem.value!.displayTitle!, style: TextStyle( fontSize: ScreenUtil().setSp(64), fontWeight: FontWeight.bold), blankSpace: 32.0, startPadding: 10.0, accelerationDuration: Duration(seconds: 1), pauseAfterRound: Duration(seconds: 2), ) : Text( audioHandler.mediaItem.value!.displayTitle!, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: ScreenUtil().setSp(64), fontWeight: FontWeight.bold), )), const SizedBox(height: 4), Text( audioHandler.mediaItem.value!.displaySubtitle ?? '', maxLines: 1, textAlign: TextAlign.center, overflow: TextOverflow.clip, style: TextStyle( fontSize: ScreenUtil().setSp(52), color: Theme.of(context).primaryColor, ), ), ], ), SeekBar(), PlaybackControls(86.sp), Padding( padding: EdgeInsets.symmetric(vertical: 0, horizontal: 16.0), child: BottomBarControls(size: 56.sp), ) ], )); } } class QualityInfoWidget extends StatefulWidget { @override _QualityInfoWidgetState createState() => _QualityInfoWidgetState(); } class _QualityInfoWidgetState extends State { String value = ''; late StreamSubscription streamSubscription; //Load data from native void _load() async { if (audioHandler.mediaItem.value == null) return; Map? data = await DownloadManager.platform.invokeMethod( "getStreamInfo", {"id": audioHandler.mediaItem.value!.id}); //N/A if (data == null) { if (mounted) setState(() => value = ''); //If not show, try again later if (audioHandler.mediaItem.value!.extras!['show'] == null) Future.delayed(Duration(milliseconds: 200), _load); return; } //Update StreamQualityInfo info = StreamQualityInfo.fromJson(data); setState(() { value = '${info.format} ${info.bitrate(audioHandler.mediaItem.value!.duration)}kbps'; }); } @override void initState() { SchedulerBinding.instance!.addPostFrameCallback((_) => _load()); streamSubscription = audioHandler.mediaItem.listen((_) => _load()); super.initState(); } @override void dispose() { streamSubscription.cancel(); super.dispose(); } @override Widget build(BuildContext context) { return TextButton( child: Text(value), onPressed: () { Navigator.of(context) .push(MaterialPageRoute(builder: (context) => QualitySettings())); }, ); } } class PlayerMenuButton extends StatelessWidget { final double size; const PlayerMenuButton({required this.size}); @override Widget build(BuildContext context) { return IconButton( icon: Icon( Icons.more_vert, size: size, semanticLabel: "Options".i18n, ), onPressed: () { final currentMediaItem = audioHandler.mediaItem.value!; Track t = Track.fromMediaItem(currentMediaItem); MenuSheet m = MenuSheet(context, navigateCallback: () { Navigator.of(context).pop(); }); if (currentMediaItem.extras!['show'] == null) m.defaultTrackMenu(t, options: [m.sleepTimer(), m.wakelock()]); else m.defaultShowEpisodeMenu( Show.fromJson(jsonDecode(currentMediaItem.extras!['show'])), ShowEpisode.fromMediaItem(currentMediaItem), options: [m.sleepTimer(), m.wakelock()]); }, ); } } class RepeatButton extends StatefulWidget { final double iconSize; RepeatButton(this.iconSize, {Key? key}) : super(key: key); @override _RepeatButtonState createState() => _RepeatButtonState(); } class _RepeatButtonState extends State { // ignore: missing_return Icon get repeatIcon { switch (playerHelper.repeatType) { case LoopMode.off: return Icon( Icons.repeat, size: widget.iconSize, semanticLabel: "Repeat off".i18n, ); case LoopMode.all: return Icon( Icons.repeat, color: Theme.of(context).primaryColor, size: widget.iconSize, semanticLabel: "Repeat".i18n, ); case LoopMode.one: return Icon( Icons.repeat_one, color: Theme.of(context).primaryColor, size: widget.iconSize, semanticLabel: "Repeat one".i18n, ); } } @override Widget build(BuildContext context) { return IconButton( icon: repeatIcon, onPressed: () async { await playerHelper.changeRepeat(); setState(() {}); }, ); } } class PlaybackControls extends StatefulWidget { final double iconSize; PlaybackControls(this.iconSize, {Key? key}) : super(key: key); @override _PlaybackControlsState createState() => _PlaybackControlsState(); } class _PlaybackControlsState 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) { return Padding( padding: EdgeInsets.symmetric(horizontal: 8.0), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisSize: MainAxisSize.max, children: [ IconButton( icon: Icon( Icons.sentiment_very_dissatisfied, semanticLabel: "Dislike".i18n, ), iconSize: widget.iconSize * 0.75, onPressed: () async { await deezerAPI.dislikeTrack(audioHandler.mediaItem.value!.id); if (playerHelper.queueIndex < audioHandler.queue.value.length - 1) { audioHandler.skipToNext(); } }), PrevNextButton(widget.iconSize, prev: true), PlayPauseButton(widget.iconSize * 1.25), PrevNextButton(widget.iconSize), IconButton( icon: libraryIcon, iconSize: widget.iconSize * 0.75, onPressed: () async { if (cache.libraryTracks == null) cache.libraryTracks = []; if (cache.checkTrackFavorite( Track.fromMediaItem(audioHandler.mediaItem.value!))) { //Remove from library setState(() => cache.libraryTracks! .remove(audioHandler.mediaItem.value!.id)); await deezerAPI .removeFavorite(audioHandler.mediaItem.value!.id); await cache.save(); } else { //Add setState(() => cache.libraryTracks!.add(audioHandler.mediaItem.value!.id)); await deezerAPI .addFavoriteTrack(audioHandler.mediaItem.value!.id); await cache.save(); } }, ) ], ), ); } } class BigAlbumArt extends StatefulWidget { @override _BigAlbumArtState createState() => _BigAlbumArtState(); } class _BigAlbumArtState extends State { PageController _pageController = PageController( initialPage: playerHelper.queueIndex, viewportFraction: 1.0, ); StreamSubscription? _currentItemSub; bool _animationLock = true; @override void initState() { _currentItemSub = audioHandler.mediaItem.listen((event) async { _animationLock = true; // TODO: a lookup in the entire queue isn't that good, this can definitely be improved in some way await _pageController.animateToPage(playerHelper.queueIndex, duration: Duration(milliseconds: 300), curve: Curves.easeInOut); _animationLock = false; }); super.initState(); } @override void dispose() { _currentItemSub?.cancel(); super.dispose(); } @override Widget build(BuildContext context) { return GestureDetector( onVerticalDragUpdate: (DragUpdateDetails details) { if (details.delta.dy > 16) { Navigator.of(context).pop(); } }, onTap: () => Navigator.push( context, PageRouteBuilder( opaque: false, // transparent background barrierDismissible: true, pageBuilder: (context, _, __) { return PhotoView( imageProvider: CachedNetworkImageProvider( audioHandler.mediaItem.value!.artUri.toString()), maxScale: 8.0, minScale: 0.2, heroAttributes: PhotoViewHeroAttributes( tag: audioHandler.mediaItem.value!.id), backgroundDecoration: BoxDecoration(color: Color.fromARGB(0x90, 0, 0, 0))); })), child: PageView( controller: _pageController, onPageChanged: (int index) { if (pageViewLock) { pageViewLock = false; return; } if (_animationLock) return; audioHandler.skipToQueueItem(index); }, children: List.generate( audioHandler.queue.value.length, (i) => Padding( padding: const EdgeInsets.all(8.0), child: Hero( tag: audioHandler.queue.value[i].id, child: CachedImage( url: audioHandler.queue.value[i].artUri.toString(), ), ), )), ), ); } } //Top row containing QueueSource, queue... class PlayerScreenTopRow extends StatelessWidget { final double? textSize; final double? iconSize; final double? textWidth; final bool? short; PlayerScreenTopRow( {this.textSize, this.iconSize, this.textWidth, this.short}); @override Widget build(BuildContext context) { return Row( mainAxisSize: MainAxisSize.max, mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.center, children: [ Container( width: this.textWidth ?? ScreenUtil().setWidth(800), child: Text( (short ?? false) ? (playerHelper.queueSource!.text ?? '') : 'Playing from:'.i18n + ' ' + (playerHelper.queueSource?.text ?? ''), maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: TextAlign.left, style: TextStyle(fontSize: this.textSize ?? ScreenUtil().setSp(38)), ), ), IconButton( icon: Icon( Icons.menu, semanticLabel: "Queue".i18n, ), iconSize: this.iconSize ?? ScreenUtil().setSp(52), splashRadius: this.iconSize ?? ScreenUtil().setWidth(52), onPressed: () => Navigator.of(context) .push(MaterialPageRoute(builder: (context) => QueueScreen())), ), ], ); } } class SeekBar extends StatefulWidget { const SeekBar(); @override _SeekBarState 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, _) => Slider( 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: EdgeInsets.symmetric(horizontal: 24.0), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ ValueListenableBuilder( valueListenable: position, builder: (context, value, _) => Text( _timeString(value), style: TextStyle( fontSize: ScreenUtil().setSp(35), color: Theme.of(context) .textTheme .bodyText1! .color! .withOpacity(.75)), )), StreamBuilder( stream: audioHandler.mediaItem, builder: (context, snapshot) => Text( _timeString(snapshot.data?.duration ?? Duration.zero), style: TextStyle( fontSize: ScreenUtil().setSp(35), color: Theme.of(context) .textTheme .bodyText1! .color! .withOpacity(.75)), )), ], ), ), ], ); } } class QueueScreen extends StatefulWidget { @override _QueueScreenState createState() => _QueueScreenState(); } class _QueueScreenState extends State { late StreamSubscription _queueSub; static const _dismissibleBackground = DecoratedBox( decoration: BoxDecoration(color: Colors.red), child: Align( child: Padding( padding: EdgeInsets.symmetric(horizontal: 24.0), child: Icon(Icons.delete)), alignment: Alignment.centerLeft)); static const _dismissibleSecondaryBackground = DecoratedBox( decoration: BoxDecoration(color: Colors.red), child: Align( child: Padding( padding: EdgeInsets.symmetric(horizontal: 24.0), child: Icon(Icons.delete)), alignment: Alignment.centerRight)); /// Basically a simple list that keeps itself synchronized with [AudioHandler.queue], /// so that the [ReorderableListView] is updated instanly (as it should be) List _queueCache = []; @override void initState() { _queueCache = audioHandler.queue.value; _queueSub = audioHandler.queue.listen((newQueue) { print('got queue $newQueue'); // avoid rebuilding if the cache has got the right update if (listEquals(_queueCache, newQueue)) { print('avoiding rebuilding queue since they are the same'); return; } setState(() => _queueCache = newQueue); }); super.initState(); } @override void dispose() { _queueSub.cancel(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( appBar: FreezerAppBar( 'Queue'.i18n, systemUiOverlayStyle: SystemUiOverlayStyle( statusBarColor: Colors.transparent, statusBarIconBrightness: Brightness.light, statusBarBrightness: Brightness.light, systemNavigationBarColor: Theme.of(context).scaffoldBackgroundColor, systemNavigationBarDividerColor: Color( Theme.of(context).scaffoldBackgroundColor.value - 0x00111111), systemNavigationBarIconBrightness: Brightness.light, ), actions: [ IconButton( icon: Icon( Icons.shuffle, semanticLabel: "Shuffle".i18n, ), onPressed: () async { await playerHelper.toggleShuffle(); setState(() {}); }, ) ], ), body: SafeArea( child: ReorderableListView.builder( onReorder: (int oldIndex, int newIndex) { if (oldIndex == playerHelper.queueIndex) return; setState(() => _queueCache.reorder(oldIndex, newIndex)); playerHelper.reorder(oldIndex, newIndex); }, itemCount: _queueCache.length, itemBuilder: (BuildContext context, int i) { Track track = Track.fromMediaItem(audioHandler.queue.value[i]); return Dismissible( key: Key(track.id), background: _dismissibleBackground, secondaryBackground: _dismissibleSecondaryBackground, onDismissed: (_) { audioHandler.removeQueueItemAt(i); setState(() => _queueCache.removeAt(i)); }, child: TrackTile( track, onTap: () { pageViewLock = true; audioHandler .skipToQueueItem(i) .then((value) => Navigator.of(context).pop()); }, key: Key(track.id), ), ); }, ), ), ); } } class BottomBarControls extends StatelessWidget { final double size; const BottomBarControls({Key? key, required this.size}) : super(key: key); @override Widget build(BuildContext context) { return Row( mainAxisSize: MainAxisSize.max, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ IconButton( icon: Icon( Icons.subtitles, size: size, semanticLabel: "Lyrics".i18n, ), onPressed: () async { await Navigator.of(context) .push(MaterialPageRoute(builder: (context) => LyricsScreen())); updateColor(); }, ), 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); }, ), QualityInfoWidget(), RepeatButton(size), PlayerMenuButton(size: size) ], ); } }