💄 Enchanced mini player
This commit is contained in:
@@ -1,17 +1,37 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:groovybox/data/db.dart' as db;
|
||||
import 'package:groovybox/logic/metadata_service.dart';
|
||||
import 'package:groovybox/providers/audio_provider.dart';
|
||||
import 'package:groovybox/ui/screens/player_screen.dart';
|
||||
import 'package:groovybox/ui/widgets/track_tile.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
class MiniPlayer extends HookConsumerWidget {
|
||||
final bool enableTapToOpen;
|
||||
|
||||
const MiniPlayer({super.key, this.enableTapToOpen = true});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isDesktop = MediaQuery.sizeOf(context).width > 800;
|
||||
|
||||
if (isDesktop) {
|
||||
return _DesktopMiniPlayer(enableTapToOpen: enableTapToOpen);
|
||||
} else {
|
||||
return _MobileMiniPlayer(enableTapToOpen: enableTapToOpen);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _MobileMiniPlayer extends HookConsumerWidget {
|
||||
final bool enableTapToOpen;
|
||||
|
||||
const _MobileMiniPlayer({required this.enableTapToOpen});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final audioHandler = ref.watch(audioHandlerProvider);
|
||||
@@ -31,7 +51,6 @@ class MiniPlayer extends HookConsumerWidget {
|
||||
}
|
||||
final media = medias[index];
|
||||
final path = Uri.parse(media.uri).path;
|
||||
// Using common parse for path if it's a file URI
|
||||
final filePath = Uri.decodeFull(path);
|
||||
|
||||
final metadataAsync = ref.watch(trackMetadataProvider(filePath));
|
||||
@@ -76,8 +95,6 @@ class MiniPlayer extends HookConsumerWidget {
|
||||
|
||||
return SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
// Let's keep a small thumb or make it visible on hover/touch.
|
||||
// Standard Slider has a thumb.
|
||||
trackHeight: 2,
|
||||
overlayShape: SliderComponentShape.noOverlay,
|
||||
thumbShape: const RoundSliderThumbShape(
|
||||
@@ -108,7 +125,7 @@ class MiniPlayer extends HookConsumerWidget {
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
// Cover Art (Small)
|
||||
// Cover Art
|
||||
AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: metadataAsync.when(
|
||||
@@ -126,6 +143,7 @@ class MiniPlayer extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
// Title & Artist
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
@@ -160,13 +178,14 @@ class MiniPlayer extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
// Play/Pause Button
|
||||
StreamBuilder<bool>(
|
||||
stream: player.stream.playing,
|
||||
initialData: player.state.playing,
|
||||
builder: (context, snapshot) {
|
||||
final playing = snapshot.data ?? false;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: IconButton.filled(
|
||||
color: Theme.of(
|
||||
context,
|
||||
@@ -190,6 +209,12 @@ class MiniPlayer extends HookConsumerWidget {
|
||||
);
|
||||
},
|
||||
),
|
||||
// Next Button
|
||||
IconButton(
|
||||
icon: const Icon(Icons.skip_next),
|
||||
onPressed: player.next,
|
||||
iconSize: 24,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -216,3 +241,505 @@ class MiniPlayer extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DesktopMiniPlayer extends HookConsumerWidget {
|
||||
final bool enableTapToOpen;
|
||||
|
||||
const _DesktopMiniPlayer({required this.enableTapToOpen});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final audioHandler = ref.watch(audioHandlerProvider);
|
||||
final player = audioHandler.player;
|
||||
|
||||
final isDragging = useState(false);
|
||||
final dragValue = useState(0.0);
|
||||
|
||||
return StreamBuilder<Playlist>(
|
||||
stream: player.stream.playlist,
|
||||
initialData: player.state.playlist,
|
||||
builder: (context, snapshot) {
|
||||
final index = snapshot.data?.index ?? 0;
|
||||
final medias = snapshot.data?.medias ?? [];
|
||||
if (medias.isEmpty || index < 0 || index >= medias.length) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final media = medias[index];
|
||||
final path = Uri.parse(media.uri).path;
|
||||
final filePath = Uri.decodeFull(path);
|
||||
|
||||
final metadataAsync = ref.watch(trackMetadataProvider(filePath));
|
||||
|
||||
Widget content = Container(
|
||||
height: 72,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Progress Bar
|
||||
SizedBox(
|
||||
height: 4,
|
||||
width: double.infinity,
|
||||
child: StreamBuilder<Duration>(
|
||||
stream: player.stream.position,
|
||||
initialData: player.state.position,
|
||||
builder: (context, snapshot) {
|
||||
final position = snapshot.data ?? Duration.zero;
|
||||
return StreamBuilder<Duration>(
|
||||
stream: player.stream.duration,
|
||||
initialData: player.state.duration,
|
||||
builder: (context, durationSnapshot) {
|
||||
final total = durationSnapshot.data ?? Duration.zero;
|
||||
final max = total.inMilliseconds.toDouble();
|
||||
final positionValue = position.inMilliseconds
|
||||
.toDouble()
|
||||
.clamp(0.0, max > 0 ? max : 0.0);
|
||||
|
||||
final currentValue = isDragging.value
|
||||
? dragValue.value
|
||||
: positionValue;
|
||||
|
||||
return SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
trackHeight: 2,
|
||||
overlayShape: SliderComponentShape.noOverlay,
|
||||
thumbShape: const RoundSliderThumbShape(
|
||||
enabledThumbRadius: 6,
|
||||
),
|
||||
trackShape: const RectangularSliderTrackShape(),
|
||||
),
|
||||
child: Slider(
|
||||
padding: EdgeInsets.zero,
|
||||
value: currentValue,
|
||||
min: 0,
|
||||
max: max > 0 ? max : 1.0,
|
||||
onChanged: (val) {
|
||||
isDragging.value = true;
|
||||
dragValue.value = val;
|
||||
},
|
||||
onChangeEnd: (val) {
|
||||
isDragging.value = false;
|
||||
player.seek(Duration(milliseconds: val.toInt()));
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
Flexible(
|
||||
flex: 3,
|
||||
child: Row(
|
||||
children: [
|
||||
// Cover Art
|
||||
AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: metadataAsync.when(
|
||||
data: (meta) => meta.artBytes != null
|
||||
? Image.memory(
|
||||
meta.artBytes!,
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: Container(
|
||||
color: Colors.grey[800],
|
||||
child: const Icon(
|
||||
Icons.music_note,
|
||||
color: Colors.white54,
|
||||
),
|
||||
),
|
||||
loading: () => Container(color: Colors.grey[800]),
|
||||
error: (_, _) =>
|
||||
Container(color: Colors.grey[800]),
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
// Title & Artist
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12.0,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
metadataAsync.when(
|
||||
data: (meta) => Text(
|
||||
meta.title ??
|
||||
Uri.parse(media.uri).pathSegments.last,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
loading: () => const Text('Loading...'),
|
||||
error: (_, _) => Text(
|
||||
Uri.parse(media.uri).pathSegments.last,
|
||||
),
|
||||
),
|
||||
metadataAsync.when(
|
||||
data: (meta) => Text(
|
||||
meta.artist ?? 'Unknown Artist',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (_, _) => const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Playback Controls
|
||||
Flexible(
|
||||
flex: 7,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Loop Toggle
|
||||
StreamBuilder<PlaylistMode>(
|
||||
stream: player.stream.playlistMode,
|
||||
initialData: player.state.playlistMode,
|
||||
builder: (context, snapshot) {
|
||||
final mode = snapshot.data ?? PlaylistMode.none;
|
||||
IconData icon;
|
||||
Color? color;
|
||||
switch (mode) {
|
||||
case PlaylistMode.none:
|
||||
icon = Icons.repeat;
|
||||
color = Theme.of(context).disabledColor;
|
||||
break;
|
||||
case PlaylistMode.single:
|
||||
icon = Icons.repeat_one;
|
||||
color = Theme.of(context).colorScheme.primary;
|
||||
break;
|
||||
case PlaylistMode.loop:
|
||||
icon = Icons.repeat;
|
||||
color = Theme.of(context).colorScheme.primary;
|
||||
break;
|
||||
}
|
||||
return IconButton(
|
||||
icon: Icon(icon, color: color),
|
||||
onPressed: () {
|
||||
final mode = player.state.playlistMode;
|
||||
switch (mode) {
|
||||
case PlaylistMode.none:
|
||||
player.setPlaylistMode(
|
||||
PlaylistMode.single,
|
||||
);
|
||||
break;
|
||||
case PlaylistMode.single:
|
||||
player.setPlaylistMode(PlaylistMode.loop);
|
||||
break;
|
||||
case PlaylistMode.loop:
|
||||
player.setPlaylistMode(PlaylistMode.none);
|
||||
break;
|
||||
}
|
||||
},
|
||||
iconSize: 20,
|
||||
);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.skip_previous),
|
||||
onPressed: player.previous,
|
||||
iconSize: 24,
|
||||
),
|
||||
StreamBuilder<bool>(
|
||||
stream: player.stream.playing,
|
||||
initialData: player.state.playing,
|
||||
builder: (context, snapshot) {
|
||||
final playing = snapshot.data ?? false;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
),
|
||||
child: IconButton.filled(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primaryContainer,
|
||||
icon: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 100),
|
||||
transitionBuilder:
|
||||
(
|
||||
Widget child,
|
||||
Animation<double> animation,
|
||||
) {
|
||||
return ScaleTransition(
|
||||
scale: animation,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: Icon(
|
||||
playing ? Icons.pause : Icons.play_arrow,
|
||||
key: ValueKey<bool>(playing),
|
||||
),
|
||||
),
|
||||
onPressed: playing
|
||||
? player.pause
|
||||
: player.play,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.skip_next),
|
||||
onPressed: player.next,
|
||||
iconSize: 24,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.queue_music),
|
||||
onPressed: () =>
|
||||
_showQueueDialog(context, ref, player),
|
||||
iconSize: 24,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Volume Slider
|
||||
Flexible(
|
||||
flex: 3,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.volume_up,
|
||||
size: 16,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: StreamBuilder<double>(
|
||||
stream: player.stream.volume,
|
||||
builder: (context, snapshot) {
|
||||
final volume = snapshot.data ?? 100.0;
|
||||
return Slider(
|
||||
value: volume,
|
||||
min: 0,
|
||||
max: 100,
|
||||
divisions: 100,
|
||||
label: volume.round().toString(),
|
||||
onChanged: (value) {
|
||||
player.setVolume(value);
|
||||
},
|
||||
);
|
||||
},
|
||||
).padding(right: 24),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (enableTapToOpen) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
fullscreenDialog: true,
|
||||
builder: (_) => const PlayerScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: content,
|
||||
);
|
||||
} else {
|
||||
return content;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showQueueDialog(BuildContext context, WidgetRef ref, Player player) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (bottomSheetContext) => SizedBox(
|
||||
height: MediaQuery.of(context).size.height * 0.7,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 12, 24, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text('Queue', style: TextStyle(fontSize: 20)),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.of(bottomSheetContext).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: StreamBuilder<Playlist>(
|
||||
stream: player.stream.playlist,
|
||||
initialData: player.state.playlist,
|
||||
builder: (context, snapshot) {
|
||||
final playlist = snapshot.data;
|
||||
if (playlist == null || playlist.medias.isEmpty) {
|
||||
return const Center(child: Text('No tracks in queue'));
|
||||
}
|
||||
|
||||
return ReorderableListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: playlist.medias.length,
|
||||
buildDefaultDragHandles: false,
|
||||
onReorder: (oldIndex, newIndex) {
|
||||
if (oldIndex < newIndex) {
|
||||
newIndex -= 1;
|
||||
}
|
||||
player.move(oldIndex, newIndex);
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
final media = playlist.medias[index];
|
||||
final isCurrent = index == playlist.index;
|
||||
final trackPath = Uri.decodeFull(
|
||||
Uri.parse(media.uri).path,
|
||||
);
|
||||
final trackAsync = ref.watch(
|
||||
trackByPathProvider(trackPath),
|
||||
);
|
||||
|
||||
return trackAsync.when(
|
||||
loading: () => SizedBox(
|
||||
key: Key('loading_$index'),
|
||||
height: 72,
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
error: (error, stack) => Dismissible(
|
||||
key: Key('queue_item_error_$index'),
|
||||
direction: DismissDirection.endToStart,
|
||||
background: Container(
|
||||
color: Colors.red,
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 20),
|
||||
child: const Icon(
|
||||
Icons.delete,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
onDismissed: (direction) => player.remove(index),
|
||||
child: TrackTile(
|
||||
key: Key('track_tile_error_$index'),
|
||||
leading: Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: Text(
|
||||
(index + 1).toString().padLeft(2, '0'),
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
),
|
||||
track: db.Track(
|
||||
id: -1,
|
||||
path: trackPath,
|
||||
title: Uri.parse(media.uri).pathSegments.last,
|
||||
artist:
|
||||
media.extras?['artist'] as String? ??
|
||||
'Unknown Artist',
|
||||
album: media.extras?['album'] as String?,
|
||||
duration: null,
|
||||
artUri: null,
|
||||
lyrics: null,
|
||||
lyricsOffset: 0,
|
||||
addedAt: DateTime.now(),
|
||||
),
|
||||
isPlaying: isCurrent,
|
||||
onTap: () => player.jump(index),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
),
|
||||
),
|
||||
data: (track) => ClipRRect(
|
||||
key: Key('queue_item_$index'),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: ReorderableDelayedDragStartListener(
|
||||
index: index,
|
||||
child: Dismissible(
|
||||
key: Key('dismissible_$index'),
|
||||
direction: DismissDirection.endToStart,
|
||||
background: Container(
|
||||
color: Colors.red,
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 20),
|
||||
child: const Icon(
|
||||
Icons.delete,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
onDismissed: (direction) => player.remove(index),
|
||||
child: TrackTile(
|
||||
leading: Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: Text(
|
||||
(index + 1).toString().padLeft(2, '0'),
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
),
|
||||
track:
|
||||
track ??
|
||||
db.Track(
|
||||
id: -1,
|
||||
path: trackPath,
|
||||
title: Uri.parse(
|
||||
media.uri,
|
||||
).pathSegments.last,
|
||||
artist:
|
||||
media.extras?['artist'] as String? ??
|
||||
'Unknown Artist',
|
||||
album: media.extras?['album'] as String?,
|
||||
duration: null,
|
||||
artUri: null,
|
||||
lyrics: null,
|
||||
lyricsOffset: 0,
|
||||
addedAt: DateTime.now(),
|
||||
),
|
||||
isPlaying: isCurrent,
|
||||
onTap: () => player.jump(index),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user