import 'dart:async'; import 'dart:math'; import 'package:dismissible_page/dismissible_page.dart'; import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:get/get.dart'; import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:media_kit/media_kit.dart'; import 'package:rhythm_box/providers/audio_player.dart'; import 'package:rhythm_box/providers/auth.dart'; import 'package:rhythm_box/screens/player/queue.dart'; import 'package:rhythm_box/screens/player/siblings.dart'; import 'package:rhythm_box/services/artist.dart'; import 'package:rhythm_box/services/audio_player/audio_player.dart'; import 'package:rhythm_box/services/duration.dart'; import 'package:rhythm_box/widgets/auto_cache_image.dart'; import 'package:rhythm_box/services/audio_services/image.dart'; import 'package:rhythm_box/widgets/lyrics/synced_lyrics.dart'; import 'package:rhythm_box/widgets/tracks/heart_button.dart'; import 'package:rhythm_box/widgets/tracks/querying_track_info.dart'; class PlayerScreen extends StatefulWidget { const PlayerScreen({super.key}); @override State createState() => _PlayerScreenState(); } class _PlayerScreenState extends State { late final AudioPlayerProvider _playback = Get.find(); late final QueryingTrackInfoProvider _query = Get.find(); late final AuthenticationProvider _auth = Get.find(); String? get _albumArt => (_playback.state.value.activeTrack?.album?.images).asUrlString( index: (_playback.state.value.activeTrack?.album?.images?.length ?? 1) - 1, ); bool get _isPlaying => _playback.isPlaying.value; bool get _isFetchingActiveTrack => _query.isQueryingTrackInfo.value; PlaylistMode get _loopMode => _playback.state.value.loopMode; Future _togglePlayState() async { if (!audioPlayer.isPlaying) { await audioPlayer.resume(); } else { await audioPlayer.pause(); } setState(() {}); } double? _draggingValue; static const double maxAlbumSize = 360; @override Widget build(BuildContext context) { final size = MediaQuery.of(context).size; final albumSize = max(size.shortestSide, maxAlbumSize).toDouble(); final isLargeScreen = size.width >= 720; return DismissiblePage( backgroundColor: Theme.of(context).colorScheme.surface, onDismissed: () { Navigator.of(context).pop(); }, direction: DismissiblePageDismissDirection.down, child: Material( color: Colors.transparent, child: SafeArea( child: Row( children: [ Expanded( child: ListView( shrinkWrap: true, padding: const EdgeInsets.symmetric(vertical: 24), children: [ Obx( () => LimitedBox( maxHeight: maxAlbumSize, maxWidth: maxAlbumSize, child: Hero( tag: const Key('current-active-track-album-art'), child: ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(16)), child: AspectRatio( aspectRatio: 1, child: _albumArt != null ? AutoCacheImage( _albumArt!, width: albumSize, height: albumSize, ) : Container( color: Theme.of(context) .colorScheme .surfaceContainerHigh, width: 64, height: 64, child: const Center( child: Icon(Icons.image)), ), ), ).marginSymmetric(horizontal: 24), ), ), ), const Gap(24), Obx( () => Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( _playback.state.value.activeTrack?.name ?? 'Not playing', style: Theme.of(context).textTheme.titleLarge, textAlign: TextAlign.left, ), Text( _playback.state.value.activeTrack?.artists ?.asString() ?? 'No author', style: Theme.of(context).textTheme.bodyMedium, overflow: TextOverflow.ellipsis, textAlign: TextAlign.left, ), ], ), ), if (_playback.state.value.activeTrack != null && _auth.auth.value != null) TrackHeartButton( trackId: _playback.state.value.activeTrack!.id!, ), ], ).paddingSymmetric(horizontal: 32), ), const Gap(24), Obx( () => Column( children: [ SliderTheme( data: SliderThemeData( trackHeight: 2, trackShape: _PlayerProgressTrackShape(), thumbShape: const RoundSliderThumbShape( enabledThumbRadius: 8, ), overlayShape: SliderComponentShape.noOverlay, ), child: Slider( secondaryTrackValue: _playback .durationBuffered.value.inMilliseconds .abs() .toDouble(), value: _draggingValue?.abs() ?? _playback.durationCurrent.value.inMilliseconds .toDouble() .abs(), min: 0, max: max( _playback.durationCurrent.value.inMilliseconds .abs(), _playback.durationTotal.value.inMilliseconds .abs(), ).toDouble(), onChanged: (value) { setState(() => _draggingValue = value); }, onChangeEnd: (value) { audioPlayer.seek( Duration(milliseconds: value.toInt())); }, ), ), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( _playback.durationCurrent.value .toHumanReadableString(), style: GoogleFonts.robotoMono(fontSize: 12), ), Text( _playback.durationTotal.value .toHumanReadableString(), style: GoogleFonts.robotoMono(fontSize: 12), ), ], ).paddingSymmetric(horizontal: 8, vertical: 4), ], ).paddingSymmetric(horizontal: 24), ), const Gap(24), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ StreamBuilder( stream: audioPlayer.shuffledStream, builder: (context, snapshot) { final shuffled = snapshot.data ?? false; return IconButton( icon: Icon( shuffled ? Icons.shuffle_on_outlined : Icons.shuffle, ), onPressed: _isFetchingActiveTrack ? null : () { if (shuffled) { audioPlayer.setShuffle(false); } else { audioPlayer.setShuffle(true); } }, ); }, ), Obx( () => IconButton( icon: const Icon(Icons.skip_previous), onPressed: _isFetchingActiveTrack ? null : audioPlayer.skipToPrevious, ), ), const Gap(8), Obx( () => SizedBox( width: 56, height: 56, child: IconButton.filled( icon: (_isFetchingActiveTrack && _isPlaying) ? const SizedBox( height: 20, width: 20, child: CircularProgressIndicator( color: Colors.white, strokeWidth: 2.5, ), ) : Icon( !_isPlaying ? Icons.play_arrow : Icons.pause, size: 28, ), onPressed: _togglePlayState, ), ), ), const Gap(8), Obx( () => IconButton( icon: const Icon(Icons.skip_next), onPressed: _isFetchingActiveTrack ? null : audioPlayer.skipToNext, ), ), Obx( () => IconButton( icon: Icon( _loopMode == PlaylistMode.none ? Icons.repeat : _loopMode == PlaylistMode.loop ? Icons.repeat_on_outlined : Icons.repeat_one_on_outlined, ), onPressed: _isFetchingActiveTrack ? null : () async { await audioPlayer.setLoopMode( switch (_loopMode) { PlaylistMode.loop => PlaylistMode.single, PlaylistMode.single => PlaylistMode.none, PlaylistMode.none => PlaylistMode.loop, }, ); }, ), ), ], ), const Gap(20), Row( children: [ Expanded( child: TextButton.icon( icon: const Icon(Icons.queue_music), label: const Text('Queue'), onPressed: () { showModalBottomSheet( useRootNavigator: true, isScrollControlled: true, context: context, builder: (context) => const PlayerQueuePopup(), ).then((_) { if (mounted) { setState(() {}); } }); }, ), ), if (!isLargeScreen) const Gap(4), if (!isLargeScreen) Expanded( child: TextButton.icon( icon: const Icon(Icons.lyrics), label: const Text('Lyrics'), onPressed: () { GoRouter.of(context).pushNamed('playerLyrics'); }, ), ), const Gap(4), Expanded( child: TextButton.icon( icon: const Icon(Icons.merge), label: const Text('Sources'), onPressed: () { showModalBottomSheet( useRootNavigator: true, isScrollControlled: true, context: context, builder: (context) => const SiblingTracksPopup(), ).then((_) { if (mounted) { setState(() {}); } }); }, ), ), ], ), ], ), ), if (isLargeScreen) const Gap(24), if (isLargeScreen) const Expanded( child: SyncedLyrics(defaultTextZoom: 67), ) ], ), ).marginSymmetric(horizontal: 24), ), ); } } class _PlayerProgressTrackShape extends RoundedRectSliderTrackShape { @override Rect getPreferredRect({ required RenderBox parentBox, Offset offset = Offset.zero, required SliderThemeData sliderTheme, bool isEnabled = false, bool isDiscrete = false, }) { final trackHeight = sliderTheme.trackHeight; final trackLeft = offset.dx; final trackTop = offset.dy + (parentBox.size.height - trackHeight!) / 2; final trackWidth = parentBox.size.width; return Rect.fromLTWH(trackLeft, trackTop, trackWidth, trackHeight); } }