From df03b24360f26abe4bbdd50ba3f294ee4beedfb7 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Wed, 17 Dec 2025 23:01:05 +0800 Subject: [PATCH] :sparkles: Player queue view --- devtools_options.yaml | 3 + lib/ui/screens/player_screen.dart | 429 +++++++++++++++++++++--------- lib/ui/widgets/track_tile.dart | 105 ++++++++ 3 files changed, 416 insertions(+), 121 deletions(-) create mode 100644 devtools_options.yaml create mode 100644 lib/ui/widgets/track_tile.dart diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/lib/ui/screens/player_screen.dart b/lib/ui/screens/player_screen.dart index ca8904d..d8c5e75 100644 --- a/lib/ui/screens/player_screen.dart +++ b/lib/ui/screens/player_screen.dart @@ -10,11 +10,15 @@ import 'package:groovybox/providers/audio_provider.dart'; import 'package:groovybox/providers/db_provider.dart'; import 'package:groovybox/providers/lrc_fetcher_provider.dart'; import 'package:groovybox/ui/widgets/mini_player.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'; import 'package:super_sliver_list/super_sliver_list.dart'; +enum ViewMode { cover, lyrics, queue } + class PlayerScreen extends HookConsumerWidget { const PlayerScreen({super.key}); @@ -23,7 +27,7 @@ class PlayerScreen extends HookConsumerWidget { final audioHandler = ref.watch(audioHandlerProvider); final player = audioHandler.player; - final showLyrics = useState(true); + final viewMode = useState(ViewMode.cover); final isMobile = MediaQuery.sizeOf(context).width <= 800; return StreamBuilder( @@ -103,11 +107,11 @@ class PlayerScreen extends HookConsumerWidget { if (isMobile) { return Padding( padding: EdgeInsets.only( - top: MediaQuery.of(context).padding.top + 64, + top: MediaQuery.of(context).padding.top + 40, ), child: _MobileLayout( player: player, - showLyrics: showLyrics, + viewMode: viewMode, metadataAsync: metadataAsync, media: media, trackPath: path, @@ -116,7 +120,7 @@ class PlayerScreen extends HookConsumerWidget { } else { return _DesktopLayout( player: player, - showLyrics: showLyrics, + viewMode: viewMode, metadataAsync: metadataAsync, media: media, trackPath: path, @@ -136,7 +140,7 @@ class PlayerScreen extends HookConsumerWidget { ), ), - _LyricsToggleButton(showLyrics: showLyrics), + _ViewToggleButton(viewMode: viewMode), ], ), ), @@ -149,14 +153,14 @@ class PlayerScreen extends HookConsumerWidget { class _MobileLayout extends StatelessWidget { final Player player; - final ValueNotifier showLyrics; + final ValueNotifier viewMode; final AsyncValue metadataAsync; final Media media; final String trackPath; const _MobileLayout({ required this.player, - required this.showLyrics, + required this.viewMode, required this.metadataAsync, required this.media, required this.trackPath, @@ -166,32 +170,37 @@ class _MobileLayout extends StatelessWidget { Widget build(BuildContext context) { return AnimatedSwitcher( duration: const Duration(milliseconds: 300), - child: showLyrics.value - ? _LyricsView( - key: const ValueKey('lyrics'), - trackPath: trackPath, - player: player, - ) - : _CoverView( - key: const ValueKey('cover'), - player: player, - metadataAsync: metadataAsync, - media: media, - ), + child: switch (viewMode.value) { + ViewMode.cover => _CoverView( + key: const ValueKey('cover'), + player: player, + metadataAsync: metadataAsync, + media: media, + ), + ViewMode.lyrics => _LyricsView( + key: const ValueKey('lyrics'), + trackPath: trackPath, + player: player, + ), + ViewMode.queue => _QueueView( + key: const ValueKey('queue'), + player: player, + ), + }, ); } } class _DesktopLayout extends StatelessWidget { final Player player; - final ValueNotifier showLyrics; + final ValueNotifier viewMode; final AsyncValue metadataAsync; final Media media; final String trackPath; const _DesktopLayout({ required this.player, - required this.showLyrics, + required this.viewMode, required this.metadataAsync, required this.media, required this.trackPath, @@ -201,112 +210,165 @@ class _DesktopLayout extends StatelessWidget { Widget build(BuildContext context) { return AnimatedSwitcher( duration: const Duration(milliseconds: 300), - child: showLyrics.value - ? Stack( - key: const ValueKey('lyrics_shown'), + child: switch (viewMode.value) { + ViewMode.cover => Center( + key: const ValueKey('cover'), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 480), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, children: [ - // Left Side: Cover + Controls - Positioned.fill( - child: Row( - children: [ - Expanded( - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 480), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.all(32.0), - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: 400, - ), - child: AspectRatio( - aspectRatio: 1, - child: _PlayerCoverArt( - metadataAsync: metadataAsync, - ), - ), + Expanded( + child: Padding( + padding: const EdgeInsets.all(32.0), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: AspectRatio( + aspectRatio: 1, + child: _PlayerCoverArt(metadataAsync: metadataAsync), + ), + ), + ), + ), + ), + _PlayerControls( + player: player, + metadataAsync: metadataAsync, + media: media, + ), + const SizedBox(height: 32), + ], + ), + ), + ), + ViewMode.lyrics => Stack( + key: const ValueKey('lyrics'), + children: [ + // Left Side: Cover + Controls + Positioned.fill( + child: Row( + children: [ + Expanded( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 480), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.all(32.0), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 400, + ), + child: AspectRatio( + aspectRatio: 1, + child: _PlayerCoverArt( + metadataAsync: metadataAsync, ), ), ), ), - _PlayerControls( - player: player, - metadataAsync: metadataAsync, - media: media, - ), - const SizedBox(height: 32), - ], - ), - ), - ), - ), - Expanded(child: const SizedBox.shrink()), - ], - ), - ), - // Overlaid Lyrics on the right - Positioned( - right: 0, - top: 0, - bottom: 0, - width: MediaQuery.sizeOf(context).width * 0.6, - child: Stack( - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 32), - child: _PlayerLyrics( - trackPath: trackPath, - player: player, - ), - ), - Positioned( - top: 16, - right: 16, - child: _LyricsRefreshButton(trackPath: trackPath), - ), - ], - ), - ), - ], - ) - : Center( - key: const ValueKey('lyrics_hidden'), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 480), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.all(32.0), - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 400), - child: AspectRatio( - aspectRatio: 1, - child: _PlayerCoverArt( - metadataAsync: metadataAsync, ), ), - ), + _PlayerControls( + player: player, + metadataAsync: metadataAsync, + media: media, + ), + const SizedBox(height: 32), + ], ), ), ), - _PlayerControls( - player: player, - metadataAsync: metadataAsync, - media: media, - ), - const SizedBox(height: 32), - ], - ), + ), + Expanded(child: const SizedBox.shrink()), + ], ), ), + // Overlaid Lyrics on the right + Positioned( + right: 0, + top: 0, + bottom: 0, + width: MediaQuery.sizeOf(context).width * 0.6, + child: Stack( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: _PlayerLyrics(trackPath: trackPath, player: player), + ), + Positioned( + top: 16, + right: 16, + child: _LyricsRefreshButton(trackPath: trackPath), + ), + ], + ), + ), + ], + ), + ViewMode.queue => Stack( + key: const ValueKey('queue'), + children: [ + // Left Side: Cover + Controls + Positioned.fill( + child: Row( + children: [ + Expanded( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 480), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.all(32.0), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 400, + ), + child: AspectRatio( + aspectRatio: 1, + child: _PlayerCoverArt( + metadataAsync: metadataAsync, + ), + ), + ), + ), + ), + ), + _PlayerControls( + player: player, + metadataAsync: metadataAsync, + media: media, + ), + const SizedBox(height: 32), + ], + ), + ), + ), + ), + Expanded(child: const SizedBox.shrink()), + ], + ), + ), + // Overlaid Queue on the right + Positioned( + right: 0, + top: 0, + bottom: 0, + width: MediaQuery.sizeOf(context).width * 0.5, + child: _QueueView(player: player), + ), + ], + ), + }, ); } } @@ -938,27 +1000,152 @@ class _LyricsRefreshButton extends HookConsumerWidget { } } -class _LyricsToggleButton extends StatelessWidget { - final ValueNotifier showLyrics; +class _ViewToggleButton extends StatelessWidget { + final ValueNotifier viewMode; - const _LyricsToggleButton({required this.showLyrics}); + const _ViewToggleButton({required this.viewMode}); @override Widget build(BuildContext context) { + IconData getIcon() { + switch (viewMode.value) { + case ViewMode.cover: + return Icons.album; + case ViewMode.lyrics: + return Icons.lyrics; + case ViewMode.queue: + return Icons.queue_music; + } + } + + String getTooltip() { + switch (viewMode.value) { + case ViewMode.cover: + return 'Show Lyrics'; + case ViewMode.lyrics: + return 'Show Queue'; + case ViewMode.queue: + return 'Show Cover'; + } + } + return Positioned( top: MediaQuery.of(context).padding.top + 16, right: 16, child: IconButton( - icon: Icon(showLyrics.value ? Icons.visibility_off : Icons.visibility), + icon: Icon(getIcon()), iconSize: 24, - tooltip: showLyrics.value ? 'Hide Lyrics' : 'Show Lyrics', - onPressed: () => showLyrics.value = !showLyrics.value, + tooltip: getTooltip(), + onPressed: () { + switch (viewMode.value) { + case ViewMode.cover: + viewMode.value = ViewMode.lyrics; + break; + case ViewMode.lyrics: + viewMode.value = ViewMode.queue; + break; + case ViewMode.queue: + viewMode.value = ViewMode.cover; + break; + } + }, padding: EdgeInsets.zero, ), ); } } +class _QueueView extends HookConsumerWidget { + final Player player; + + const _QueueView({super.key, required this.player}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isMobile = MediaQuery.sizeOf(context).width <= 800; + + return Column( + children: [ + 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 ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: playlist.medias.length, + 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: () => const SizedBox( + height: 72, + child: Center(child: CircularProgressIndicator()), + ), + error: (error, stack) => TrackTile( + 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) => TrackTile( + leading: Text( + (index + 1).toString().padLeft(2, '0'), + style: TextStyle(fontSize: 14), + ).padding(right: 12), + 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), + ), + ); + }, + ); + }, + ), + ), + if (isMobile) MiniPlayer(enableTapToOpen: false), + ], + ); + } +} + class _TimedLyricsView extends HookConsumerWidget { final LyricsData lyrics; final Player player; diff --git a/lib/ui/widgets/track_tile.dart b/lib/ui/widgets/track_tile.dart new file mode 100644 index 0000000..83ef552 --- /dev/null +++ b/lib/ui/widgets/track_tile.dart @@ -0,0 +1,105 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:groovybox/data/db.dart' as db; + +class TrackTile extends StatelessWidget { + final db.Track track; + final VoidCallback? onTap; + final bool isPlaying; + final bool showTrailingIcon; + final VoidCallback? onTrailingPressed; + final Widget? leading; + final EdgeInsets? padding; + + const TrackTile({ + super.key, + required this.track, + this.onTap, + this.isPlaying = false, + this.leading, + this.padding, + this.showTrailingIcon = false, + this.onTrailingPressed, + }); + + String _formatDuration(int? durationMs) { + if (durationMs == null) return '--:--'; + final d = Duration(milliseconds: durationMs); + final minutes = d.inMinutes; + final seconds = d.inSeconds % 60; + return '$minutes:${seconds.toString().padLeft(2, '0')}'; + } + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + color: isPlaying + ? Theme.of(context).colorScheme.primary.withOpacity(0.2) + : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + child: ListTile( + contentPadding: + padding ?? const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + leading: Row( + mainAxisSize: MainAxisSize.min, + children: [ + ?leading, + AspectRatio( + aspectRatio: 1, + child: Container( + decoration: BoxDecoration( + color: Colors.grey[800], + borderRadius: BorderRadius.circular(8), + image: track.artUri != null + ? DecorationImage( + image: FileImage(File(track.artUri!)), + fit: BoxFit.cover, + ) + : null, + ), + child: track.artUri == null + ? const Icon(Icons.music_note, color: Colors.white54) + : null, + ), + ), + ], + ), + title: Text( + track.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontWeight: isPlaying ? FontWeight.bold : FontWeight.normal, + color: isPlaying + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.onSurface, + ), + ), + subtitle: Text( + '${track.artist ?? 'Unknown Artist'} • ${_formatDuration(track.duration)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), + ), + ), + trailing: showTrailingIcon + ? IconButton( + icon: const Icon(Icons.more_vert), + onPressed: onTrailingPressed, + ) + : isPlaying + ? Icon( + Icons.play_arrow, + color: Theme.of(context).colorScheme.primary, + ) + : null, + onTap: onTap, + ), + ); + } +}