💄 Better playlist and album detail screen

This commit is contained in:
2025-12-20 14:52:54 +08:00
parent 902f5589f5
commit 276d20b0f5
4 changed files with 394 additions and 163 deletions

View File

@@ -1,4 +1,7 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:gap/gap.dart';
import 'package:groovybox/data/db.dart';
import 'package:groovybox/data/playlist_repository.dart';
import 'package:groovybox/providers/audio_provider.dart';
@@ -18,87 +21,142 @@ class AlbumDetailScreen extends HookConsumerWidget {
final repo = ref.watch(playlistRepositoryProvider.notifier);
final tracksAsync = repo.watchAlbumTracks(album.album);
// Responsive breakpoints
final screenWidth = MediaQuery.sizeOf(context).width;
final isLargeScreen = screenWidth > 900;
return Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 300,
pinned: true,
flexibleSpace: FlexibleSpaceBar(
title: Text(album.album),
background: album.artUri != null
? UniversalImage(uri: album.artUri!, fit: BoxFit.cover)
: Container(
color: Colors.grey[800],
child: const Icon(
Symbols.album,
size: 100,
color: Colors.white54,
// Full width app bar
SliverAppBar(pinned: true, title: Text(album.album)),
// Album cover section (full width on large screens, constrained on mobile)
SliverToBoxAdapter(
child: Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(16)),
boxShadow: [BoxShadow(blurRadius: 4)],
),
constraints: const BoxConstraints(maxWidth: 320),
child: AspectRatio(
aspectRatio: 1,
child: album.artUri != null
? UniversalImage(uri: album.artUri!, fit: BoxFit.cover)
: Container(
color: Colors.grey[800],
child: const Icon(
Symbols.album,
size: 120,
color: Colors.white54,
),
),
),
),
).clipRRect(all: 16),
).center().padding(top: 16),
),
StreamBuilder<List<Track>>(
stream: tracksAsync,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const SliverFillRemaining(
child: Center(child: CircularProgressIndicator()),
);
}
// Content section with constrained width
SliverToBoxAdapter(
child: Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: isLargeScreen ? 800 : double.infinity,
),
child: StreamBuilder<List<Track>>(
stream: tracksAsync,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const SizedBox(
height: 200,
child: Center(child: CircularProgressIndicator()),
);
}
final tracks = snapshot.data!;
if (tracks.isEmpty) {
return const SliverFillRemaining(
child: Center(child: Text('No tracks in this album')),
);
}
final tracks = snapshot.data!;
if (tracks.isEmpty) {
return const SizedBox(
height: 200,
child: Center(child: Text('No tracks in this album')),
);
}
return SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
if (index == 0) {
return Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () {
_playAlbum(ref, tracks);
},
icon: const Icon(Symbols.play_arrow),
label: const Text('Play All'),
// Action buttons
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 360),
child: Padding(
padding: EdgeInsets.all(16),
child: Column(
spacing: 12,
children: [
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () {
_playAlbum(ref, tracks);
},
icon: const Icon(Symbols.play_arrow),
label: const Text('Play All'),
),
),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () {
_addToQueue(ref, tracks);
},
icon: const Icon(Symbols.queue_music),
label: const Text('Add to Queue'),
),
),
],
),
),
),
_buildTrackTile(ref, tracks, index),
// Track list
...List.generate(tracks.length, (index) {
return _buildTrackTile(
ref,
tracks,
index,
isLargeScreen,
);
}),
// Gap for mini player
const Gap(80),
],
);
}
return _buildTrackTile(ref, tracks, index);
}, childCount: tracks.length),
);
},
},
),
),
),
),
],
),
);
}
Widget _buildTrackTile(WidgetRef ref, List<Track> tracks, int index) {
Widget _buildTrackTile(
WidgetRef ref,
List<Track> tracks,
int index,
bool isLargeScreen,
) {
final track = tracks[index];
return TrackTile(
track: track,
leading: Text(
'${index + 1}'.padLeft(2, '0'),
style: const TextStyle(color: Colors.grey, fontSize: 16),
).padding(right: 16),
).padding(right: isLargeScreen ? 24 : 16),
showTrailingIcon: false,
onTap: () {
_playAlbum(ref, tracks, initialIndex: index);
},
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: EdgeInsets.symmetric(
horizontal: isLargeScreen ? 24 : 16,
vertical: 8,
),
);
}
@@ -110,4 +168,58 @@ class AlbumDetailScreen extends HookConsumerWidget {
loadingNotifier.setLoading(false);
});
}
void _addToQueue(WidgetRef ref, List<Track> tracks) async {
final audioHandler = ref.read(audioHandlerProvider);
// Add tracks one by one to avoid interrupting playback
for (final track in tracks) {
try {
final mediaItem = await _trackToMediaItem(track);
await audioHandler.addQueueItem(mediaItem);
} catch (e) {
debugPrint('Error adding track ${track.title} to queue: $e');
}
}
}
Future<MediaItem> _trackToMediaItem(Track track) async {
Uri? artUri;
if (track.artUri != null) {
// Check if it's a network URL or local file path
if (track.artUri!.startsWith('http://') ||
track.artUri!.startsWith('https://')) {
// For remote tracks, skip artwork to avoid authentication issues
// The artwork will be loaded separately when the track is played
if (!track.path.startsWith('groovybox://')) {
// Only try to cache artwork for non-remote tracks
try {
final cachedFile = await DefaultCacheManager().getSingleFile(
track.artUri!,
);
artUri = Uri.file(cachedFile.path);
} catch (e) {
// If caching fails, skip artwork
debugPrint('Failed to cache artwork for ${track.title}: $e');
}
}
// For remote tracks, don't set artUri to avoid authentication issues
} else {
// It's a local file path
artUri = Uri.file(track.artUri!);
}
}
return MediaItem(
id: track.path,
album: track.album,
title: track.title,
artist: track.artist,
duration: track.duration != null
? Duration(milliseconds: track.duration!)
: null,
artUri: artUri,
);
}
}

View File

@@ -713,26 +713,26 @@ class LibraryScreen extends HookConsumerWidget {
);
}
return ListView.builder(
shrinkWrap: true,
itemCount: playlists.length,
itemBuilder: (context, index) {
final playlist = playlists[index];
return ListTile(
title: Text(playlist.name),
onTap: () {
ref
.read(playlistRepositoryProvider.notifier)
.addToPlaylist(playlist.id, track.id);
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Added to ${playlist.name}'),
),
);
},
);
},
return SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: playlists.map((playlist) {
return ListTile(
title: Text(playlist.name),
onTap: () {
ref
.read(playlistRepositoryProvider.notifier)
.addToPlaylist(playlist.id, track.id);
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Added to ${playlist.name}'),
),
);
},
);
}).toList(),
),
);
},
);
@@ -941,33 +941,33 @@ class LibraryScreen extends HookConsumerWidget {
return const Text('No playlists available.');
}
return ListView.builder(
shrinkWrap: true,
itemCount: playlists.length,
itemBuilder: (context, index) {
final playlist = playlists[index];
return ListTile(
title: Text(playlist.name),
onTap: () async {
final repo = ref.read(
playlistRepositoryProvider.notifier,
);
for (final id in trackIds) {
await repo.addToPlaylist(playlist.id, id);
}
if (!context.mounted) return;
Navigator.pop(context);
onSuccess();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Added ${trackIds.length} tracks to ${playlist.name}',
return SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: playlists.map((playlist) {
return ListTile(
title: Text(playlist.name),
onTap: () async {
final repo = ref.read(
playlistRepositoryProvider.notifier,
);
for (final id in trackIds) {
await repo.addToPlaylist(playlist.id, id);
}
if (!context.mounted) return;
Navigator.pop(context);
onSuccess();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Added ${trackIds.length} tracks to ${playlist.name}',
),
),
),
);
},
);
},
);
},
);
}).toList(),
),
);
},
);

View File

@@ -1,10 +1,14 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:gap/gap.dart';
import 'package:groovybox/data/db.dart';
import 'package:groovybox/data/playlist_repository.dart';
import 'package:groovybox/providers/audio_provider.dart';
import 'package:groovybox/ui/widgets/track_tile.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
class PlaylistDetailScreen extends HookConsumerWidget {
final Playlist playlist;
@@ -16,96 +20,120 @@ class PlaylistDetailScreen extends HookConsumerWidget {
final repo = ref.watch(playlistRepositoryProvider.notifier);
final tracksAsync = repo.watchPlaylistTracks(playlist.id);
// Responsive breakpoints
final screenWidth = MediaQuery.sizeOf(context).width;
final isLargeScreen = screenWidth > 900;
return Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 200,
pinned: true,
flexibleSpace: FlexibleSpaceBar(
title: Text(playlist.name),
background: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.purple.withOpacity(0.8),
Colors.blue.withOpacity(0.6),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
SliverAppBar(pinned: true, title: Text(playlist.name)),
// Content section with constrained width
SliverToBoxAdapter(
child: Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: isLargeScreen ? 800 : double.infinity,
),
child: const Center(
child: Icon(
Symbols.queue_music,
size: 80,
color: Colors.white70,
),
),
),
),
),
StreamBuilder<List<Track>>(
stream: tracksAsync,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const SliverFillRemaining(
child: Center(child: CircularProgressIndicator()),
);
}
child: StreamBuilder<List<Track>>(
stream: tracksAsync,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const SizedBox(
height: 200,
child: Center(child: CircularProgressIndicator()),
);
}
final tracks = snapshot.data!;
if (tracks.isEmpty) {
return const SliverFillRemaining(
child: Center(child: Text('No tracks in this playlist')),
);
}
final tracks = snapshot.data!;
if (tracks.isEmpty) {
return const SizedBox(
height: 200,
child: Center(
child: Text('No tracks in this playlist'),
),
);
}
return SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
if (index == 0) {
return Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () {
_playPlaylist(ref, tracks);
},
icon: const Icon(Symbols.play_arrow),
label: const Text('Play All'),
// Action buttons
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 360),
child: Padding(
padding: EdgeInsets.all(16),
child: Column(
spacing: 12,
children: [
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () {
_playPlaylist(ref, tracks);
},
icon: const Icon(Symbols.play_arrow),
label: const Text('Play All'),
),
),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () {
_addToQueue(ref, tracks);
},
icon: const Icon(Symbols.queue_music),
label: const Text('Add to Queue'),
),
),
],
),
),
),
_buildTrackTile(ref, tracks, index),
// Track list
...List.generate(tracks.length, (index) {
return _buildTrackTile(
ref,
tracks,
index,
isLargeScreen,
);
}),
// Gap for mini player
const Gap(80),
],
);
}
return _buildTrackTile(ref, tracks, index);
}, childCount: tracks.length),
);
},
},
),
),
),
),
],
),
);
}
Widget _buildTrackTile(WidgetRef ref, List<Track> tracks, int index) {
Widget _buildTrackTile(
WidgetRef ref,
List<Track> tracks,
int index,
bool isLargeScreen,
) {
final track = tracks[index];
return TrackTile(
track: track,
leading: Text(
'${index + 1}',
'${index + 1}'.padLeft(2, '0'),
style: const TextStyle(color: Colors.grey, fontSize: 16),
),
).padding(right: isLargeScreen ? 24 : 16),
showTrailingIcon: false,
onTap: () {
_playPlaylist(ref, tracks, initialIndex: index);
},
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: EdgeInsets.symmetric(
horizontal: isLargeScreen ? 24 : 16,
vertical: 8,
),
);
}
@@ -121,4 +149,58 @@ class PlaylistDetailScreen extends HookConsumerWidget {
loadingNotifier.setLoading(false);
});
}
void _addToQueue(WidgetRef ref, List<Track> tracks) async {
final audioHandler = ref.read(audioHandlerProvider);
// Add tracks one by one to avoid interrupting playback
for (final track in tracks) {
try {
final mediaItem = await _trackToMediaItem(track);
await audioHandler.addQueueItem(mediaItem);
} catch (e) {
debugPrint('Error adding track ${track.title} to queue: $e');
}
}
}
Future<MediaItem> _trackToMediaItem(Track track) async {
Uri? artUri;
if (track.artUri != null) {
// Check if it's a network URL or local file path
if (track.artUri!.startsWith('http://') ||
track.artUri!.startsWith('https://')) {
// For remote tracks, skip artwork to avoid authentication issues
// The artwork will be loaded separately when the track is played
if (!track.path.startsWith('groovybox://')) {
// Only try to cache artwork for non-remote tracks
try {
final cachedFile = await DefaultCacheManager().getSingleFile(
track.artUri!,
);
artUri = Uri.file(cachedFile.path);
} catch (e) {
// If caching fails, skip artwork
debugPrint('Failed to cache artwork for ${track.title}: $e');
}
}
// For remote tracks, don't set artUri to avoid authentication issues
} else {
// It's a local file path
artUri = Uri.file(track.artUri!);
}
}
return MediaItem(
id: track.path,
album: track.album,
title: track.title,
artist: track.artist,
duration: track.duration != null
? Duration(milliseconds: track.duration!)
: null,
artUri: artUri,
);
}
}