2024-08-27 12:49:48 +00:00
|
|
|
import 'dart:async';
|
2024-08-27 16:04:45 +00:00
|
|
|
import 'dart:math';
|
2024-08-27 12:49:48 +00:00
|
|
|
|
|
|
|
import 'package:dismissible_page/dismissible_page.dart';
|
2024-08-27 06:48:31 +00:00
|
|
|
import 'package:flutter/material.dart';
|
2024-08-27 12:49:48 +00:00
|
|
|
import 'package:gap/gap.dart';
|
|
|
|
import 'package:get/get.dart';
|
2024-08-28 10:41:32 +00:00
|
|
|
import 'package:go_router/go_router.dart';
|
2024-08-27 12:49:48 +00:00
|
|
|
import 'package:google_fonts/google_fonts.dart';
|
2024-08-27 17:23:37 +00:00
|
|
|
import 'package:media_kit/media_kit.dart';
|
2024-08-27 12:49:48 +00:00
|
|
|
import 'package:rhythm_box/providers/audio_player.dart';
|
2024-08-27 17:23:37 +00:00
|
|
|
import 'package:rhythm_box/screens/player/queue.dart';
|
2024-08-28 16:33:59 +00:00
|
|
|
import 'package:rhythm_box/screens/player/siblings.dart';
|
2024-08-27 12:49:48 +00:00
|
|
|
import 'package:rhythm_box/services/artist.dart';
|
|
|
|
import 'package:rhythm_box/services/audio_player/audio_player.dart';
|
2024-08-28 16:33:59 +00:00
|
|
|
import 'package:rhythm_box/services/duration.dart';
|
2024-08-27 12:49:48 +00:00
|
|
|
import 'package:rhythm_box/widgets/auto_cache_image.dart';
|
|
|
|
import 'package:rhythm_box/services/audio_services/image.dart';
|
2024-08-29 08:42:48 +00:00
|
|
|
import 'package:rhythm_box/widgets/lyrics/synced_lyrics.dart';
|
2024-08-29 11:10:54 +00:00
|
|
|
import 'package:rhythm_box/widgets/tracks/heart_button.dart';
|
2024-08-27 12:49:48 +00:00
|
|
|
import 'package:rhythm_box/widgets/tracks/querying_track_info.dart';
|
2024-08-27 06:48:31 +00:00
|
|
|
|
|
|
|
class PlayerScreen extends StatefulWidget {
|
2024-08-28 10:41:32 +00:00
|
|
|
const PlayerScreen({super.key});
|
2024-08-27 06:48:31 +00:00
|
|
|
|
|
|
|
@override
|
|
|
|
State<PlayerScreen> createState() => _PlayerScreenState();
|
|
|
|
}
|
|
|
|
|
|
|
|
class _PlayerScreenState extends State<PlayerScreen> {
|
2024-08-27 12:49:48 +00:00
|
|
|
late final AudioPlayerProvider _playback = Get.find();
|
|
|
|
late final QueryingTrackInfoProvider _query = 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;
|
2024-08-27 17:23:37 +00:00
|
|
|
PlaylistMode get _loopMode => _playback.state.value.loopMode;
|
2024-08-27 12:49:48 +00:00
|
|
|
|
|
|
|
Future<void> _togglePlayState() async {
|
|
|
|
if (!audioPlayer.isPlaying) {
|
|
|
|
await audioPlayer.resume();
|
|
|
|
} else {
|
|
|
|
await audioPlayer.pause();
|
|
|
|
}
|
|
|
|
setState(() {});
|
|
|
|
}
|
|
|
|
|
|
|
|
double? _draggingValue;
|
|
|
|
|
2024-08-28 17:45:33 +00:00
|
|
|
static const double maxAlbumSize = 360;
|
|
|
|
|
2024-08-27 06:48:31 +00:00
|
|
|
@override
|
|
|
|
Widget build(BuildContext context) {
|
2024-08-27 12:49:48 +00:00
|
|
|
final size = MediaQuery.of(context).size;
|
2024-08-28 17:45:33 +00:00
|
|
|
final albumSize = max(size.shortestSide, maxAlbumSize).toDouble();
|
|
|
|
|
|
|
|
final isLargeScreen = size.width >= 720;
|
2024-08-27 12:49:48 +00:00
|
|
|
|
|
|
|
return DismissiblePage(
|
|
|
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
|
|
|
onDismissed: () {
|
|
|
|
Navigator.of(context).pop();
|
|
|
|
},
|
|
|
|
direction: DismissiblePageDismissDirection.down,
|
|
|
|
child: Material(
|
|
|
|
color: Colors.transparent,
|
|
|
|
child: SafeArea(
|
2024-08-28 17:45:33 +00:00
|
|
|
child: Row(
|
2024-08-27 12:49:48 +00:00
|
|
|
children: [
|
2024-08-28 17:45:33 +00:00
|
|
|
Expanded(
|
2024-08-30 04:56:28 +00:00
|
|
|
child: ListView(
|
|
|
|
padding: const EdgeInsets.symmetric(vertical: 24),
|
2024-08-28 16:41:40 +00:00
|
|
|
children: [
|
2024-08-29 08:42:48 +00:00
|
|
|
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),
|
|
|
|
),
|
2024-08-27 12:49:48 +00:00
|
|
|
),
|
2024-08-28 16:41:40 +00:00
|
|
|
),
|
2024-08-28 17:45:33 +00:00
|
|
|
const Gap(24),
|
2024-08-29 08:42:48 +00:00
|
|
|
Obx(
|
2024-08-29 11:10:54 +00:00
|
|
|
() => 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)
|
|
|
|
TrackHeartButton(
|
|
|
|
trackId: _playback.state.value.activeTrack!.id!,
|
|
|
|
),
|
|
|
|
],
|
|
|
|
).paddingSymmetric(horizontal: 32),
|
2024-08-28 17:45:33 +00:00
|
|
|
),
|
|
|
|
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),
|
2024-08-28 16:41:40 +00:00
|
|
|
Row(
|
2024-08-28 17:45:33 +00:00
|
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
2024-08-28 16:41:40 +00:00
|
|
|
children: [
|
2024-08-28 17:45:33 +00:00
|
|
|
StreamBuilder<bool>(
|
|
|
|
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);
|
|
|
|
}
|
|
|
|
},
|
|
|
|
);
|
|
|
|
},
|
2024-08-28 16:41:40 +00:00
|
|
|
),
|
2024-08-29 08:42:48 +00:00
|
|
|
Obx(
|
|
|
|
() => IconButton(
|
|
|
|
icon: const Icon(Icons.skip_previous),
|
|
|
|
onPressed: _isFetchingActiveTrack
|
|
|
|
? null
|
|
|
|
: audioPlayer.skipToPrevious,
|
|
|
|
),
|
2024-08-28 16:41:40 +00:00
|
|
|
),
|
2024-08-28 17:45:33 +00:00
|
|
|
const Gap(8),
|
2024-08-29 08:42:48 +00:00
|
|
|
Obx(
|
|
|
|
() => SizedBox(
|
|
|
|
width: 56,
|
|
|
|
height: 56,
|
|
|
|
child: IconButton.filled(
|
|
|
|
icon: _isFetchingActiveTrack
|
|
|
|
? const SizedBox(
|
|
|
|
height: 20,
|
|
|
|
width: 20,
|
|
|
|
child: CircularProgressIndicator(
|
|
|
|
color: Colors.white,
|
|
|
|
strokeWidth: 2.5,
|
|
|
|
),
|
|
|
|
)
|
|
|
|
: Icon(
|
|
|
|
!_isPlaying
|
|
|
|
? Icons.play_arrow
|
|
|
|
: Icons.pause,
|
|
|
|
size: 28,
|
2024-08-28 17:45:33 +00:00
|
|
|
),
|
2024-08-29 08:42:48 +00:00
|
|
|
onPressed: _isFetchingActiveTrack
|
|
|
|
? null
|
|
|
|
: _togglePlayState,
|
|
|
|
),
|
2024-08-28 17:45:33 +00:00
|
|
|
),
|
2024-08-27 17:23:37 +00:00
|
|
|
),
|
2024-08-28 17:45:33 +00:00
|
|
|
const Gap(8),
|
2024-08-29 08:42:48 +00:00
|
|
|
Obx(
|
|
|
|
() => IconButton(
|
|
|
|
icon: const Icon(Icons.skip_next),
|
|
|
|
onPressed: _isFetchingActiveTrack
|
|
|
|
? null
|
|
|
|
: audioPlayer.skipToNext,
|
|
|
|
),
|
2024-08-28 17:45:33 +00:00
|
|
|
),
|
|
|
|
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(() {});
|
2024-08-27 17:23:37 +00:00
|
|
|
}
|
2024-08-28 17:45:33 +00:00
|
|
|
});
|
|
|
|
},
|
|
|
|
),
|
|
|
|
),
|
|
|
|
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');
|
2024-08-27 17:23:37 +00:00
|
|
|
},
|
2024-08-27 12:49:48 +00:00
|
|
|
),
|
2024-08-28 17:45:33 +00:00
|
|
|
),
|
|
|
|
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(() {});
|
|
|
|
}
|
|
|
|
});
|
2024-08-27 17:23:37 +00:00
|
|
|
},
|
2024-08-28 17:45:33 +00:00
|
|
|
),
|
|
|
|
),
|
|
|
|
],
|
2024-08-27 17:23:37 +00:00
|
|
|
),
|
2024-08-28 17:45:33 +00:00
|
|
|
],
|
|
|
|
),
|
2024-08-27 17:23:37 +00:00
|
|
|
),
|
2024-08-28 17:45:33 +00:00
|
|
|
if (isLargeScreen) const Gap(24),
|
|
|
|
if (isLargeScreen)
|
|
|
|
const Expanded(
|
|
|
|
child: SyncedLyrics(defaultTextZoom: 67),
|
|
|
|
)
|
2024-08-27 12:49:48 +00:00
|
|
|
],
|
|
|
|
),
|
2024-08-30 04:56:28 +00:00
|
|
|
).marginSymmetric(horizontal: 24),
|
2024-08-27 12:49:48 +00:00
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
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);
|
2024-08-27 06:48:31 +00:00
|
|
|
}
|
|
|
|
}
|