diff --git a/lib/ui/widgets/mini_player.dart b/lib/ui/widgets/mini_player.dart index 5181dad..0c662b7 100644 --- a/lib/ui/widgets/mini_player.dart +++ b/lib/ui/widgets/mini_player.dart @@ -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( 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( + 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( + stream: player.stream.position, + initialData: player.state.position, + builder: (context, snapshot) { + final position = snapshot.data ?? Duration.zero; + return StreamBuilder( + 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( + 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( + 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 animation, + ) { + return ScaleTransition( + scale: animation, + child: child, + ); + }, + child: Icon( + playing ? Icons.pause : Icons.play_arrow, + key: ValueKey(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( + 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( + 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, + ), + ), + ), + ), + ), + ); + }, + ); + }, + ), + ), + ], + ), + ), + ); + } +}