💄 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

@@ -212,6 +212,9 @@ class RemoteProviderService {
class RemoteUrlResolver { class RemoteUrlResolver {
final Ref ref; final Ref ref;
// Token cache: providerId -> {token: string, expiresAt: DateTime}
final Map<int, Map<String, dynamic>> _tokenCache = {};
RemoteUrlResolver(this.ref); RemoteUrlResolver(this.ref);
/// Resolves a groovybox protocol URL to an actual streaming URL /// Resolves a groovybox protocol URL to an actual streaming URL
@@ -245,7 +248,34 @@ class RemoteUrlResolver {
} }
try { try {
// Create Jellyfin client and authenticate // Get or refresh token
final token = await _getToken(providerId, provider);
if (token == null) {
return null;
}
// Return the actual streaming URL
return '${provider.serverUrl}/Audio/$itemId/stream.mp3?api_key=$token&static=true';
} catch (e) {
debugPrint('Error resolving URL $protocolUrl: $e');
return null;
}
}
/// Get cached token or authenticate to get a new one
Future<String?> _getToken(int providerId, RemoteProvider provider) async {
// Check if we have a cached token that's still valid
final cached = _tokenCache[providerId];
if (cached != null) {
final expiresAt = cached['expiresAt'] as DateTime?;
if (expiresAt != null && DateTime.now().isBefore(expiresAt)) {
return cached['token'] as String?;
}
}
// Token is expired or missing, authenticate
try {
final client = JellyfinDart(basePathOverride: provider.serverUrl); final client = JellyfinDart(basePathOverride: provider.serverUrl);
client.setDeviceId('groovybox-$providerId'); client.setDeviceId('groovybox-$providerId');
client.setVersion('1.0.0'); client.setVersion('1.0.0');
@@ -263,10 +293,17 @@ class RemoteUrlResolver {
return null; return null;
} }
// Return the actual streaming URL // Cache the token for 23 hours (tokens typically last 24 hours)
return '${provider.serverUrl}/Audio/$itemId/stream.mp3?api_key=$token&static=true'; _tokenCache[providerId] = {
'token': token,
'expiresAt': DateTime.now().add(const Duration(hours: 23)),
};
return token;
} catch (e) { } catch (e) {
debugPrint('Error resolving URL $protocolUrl: $e'); debugPrint('Error authenticating with provider ${provider.name}: $e');
// Clear any cached token on authentication failure
_tokenCache.remove(providerId);
return null; return null;
} }
} }

View File

@@ -1,4 +1,7 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.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/db.dart';
import 'package:groovybox/data/playlist_repository.dart'; import 'package:groovybox/data/playlist_repository.dart';
import 'package:groovybox/providers/audio_provider.dart'; import 'package:groovybox/providers/audio_provider.dart';
@@ -18,87 +21,142 @@ class AlbumDetailScreen extends HookConsumerWidget {
final repo = ref.watch(playlistRepositoryProvider.notifier); final repo = ref.watch(playlistRepositoryProvider.notifier);
final tracksAsync = repo.watchAlbumTracks(album.album); final tracksAsync = repo.watchAlbumTracks(album.album);
// Responsive breakpoints
final screenWidth = MediaQuery.sizeOf(context).width;
final isLargeScreen = screenWidth > 900;
return Scaffold( return Scaffold(
body: CustomScrollView( body: CustomScrollView(
slivers: [ slivers: [
SliverAppBar( // Full width app bar
expandedHeight: 300, SliverAppBar(pinned: true, title: Text(album.album)),
pinned: true, // Album cover section (full width on large screens, constrained on mobile)
flexibleSpace: FlexibleSpaceBar( SliverToBoxAdapter(
title: Text(album.album), child: Container(
background: album.artUri != null decoration: BoxDecoration(
? UniversalImage(uri: album.artUri!, fit: BoxFit.cover) borderRadius: const BorderRadius.all(Radius.circular(16)),
: Container( boxShadow: [BoxShadow(blurRadius: 4)],
color: Colors.grey[800], ),
child: const Icon( constraints: const BoxConstraints(maxWidth: 320),
Symbols.album, child: AspectRatio(
size: 100, aspectRatio: 1,
color: Colors.white54, 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>>( // Content section with constrained width
stream: tracksAsync, SliverToBoxAdapter(
builder: (context, snapshot) { child: Align(
if (!snapshot.hasData) { alignment: Alignment.topCenter,
return const SliverFillRemaining( child: ConstrainedBox(
child: Center(child: CircularProgressIndicator()), 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!; final tracks = snapshot.data!;
if (tracks.isEmpty) { if (tracks.isEmpty) {
return const SliverFillRemaining( return const SizedBox(
child: Center(child: Text('No tracks in this album')), height: 200,
); child: Center(child: Text('No tracks in this album')),
} );
}
return SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
if (index == 0) {
return Column( return Column(
children: [ children: [
Padding( // Action buttons
padding: const EdgeInsets.all(16.0), ConstrainedBox(
child: SizedBox( constraints: const BoxConstraints(maxWidth: 360),
width: double.infinity, child: Padding(
child: FilledButton.icon( padding: EdgeInsets.all(16),
onPressed: () { child: Column(
_playAlbum(ref, tracks); spacing: 12,
}, children: [
icon: const Icon(Symbols.play_arrow), SizedBox(
label: const Text('Play All'), 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]; final track = tracks[index];
return TrackTile( return TrackTile(
track: track, track: track,
leading: Text( leading: Text(
'${index + 1}'.padLeft(2, '0'), '${index + 1}'.padLeft(2, '0'),
style: const TextStyle(color: Colors.grey, fontSize: 16), style: const TextStyle(color: Colors.grey, fontSize: 16),
).padding(right: 16), ).padding(right: isLargeScreen ? 24 : 16),
showTrailingIcon: false, showTrailingIcon: false,
onTap: () { onTap: () {
_playAlbum(ref, tracks, initialIndex: index); _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); 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( return SingleChildScrollView(
shrinkWrap: true, child: Column(
itemCount: playlists.length, mainAxisSize: MainAxisSize.min,
itemBuilder: (context, index) { children: playlists.map((playlist) {
final playlist = playlists[index]; return ListTile(
return ListTile( title: Text(playlist.name),
title: Text(playlist.name), onTap: () {
onTap: () { ref
ref .read(playlistRepositoryProvider.notifier)
.read(playlistRepositoryProvider.notifier) .addToPlaylist(playlist.id, track.id);
.addToPlaylist(playlist.id, track.id); Navigator.pop(context);
Navigator.pop(context); ScaffoldMessenger.of(context).showSnackBar(
ScaffoldMessenger.of(context).showSnackBar( SnackBar(
SnackBar( content: Text('Added to ${playlist.name}'),
content: Text('Added to ${playlist.name}'), ),
), );
); },
}, );
); }).toList(),
}, ),
); );
}, },
); );
@@ -941,33 +941,33 @@ class LibraryScreen extends HookConsumerWidget {
return const Text('No playlists available.'); return const Text('No playlists available.');
} }
return ListView.builder( return SingleChildScrollView(
shrinkWrap: true, child: Column(
itemCount: playlists.length, mainAxisSize: MainAxisSize.min,
itemBuilder: (context, index) { children: playlists.map((playlist) {
final playlist = playlists[index]; return ListTile(
return ListTile( title: Text(playlist.name),
title: Text(playlist.name), onTap: () async {
onTap: () async { final repo = ref.read(
final repo = ref.read( playlistRepositoryProvider.notifier,
playlistRepositoryProvider.notifier, );
); for (final id in trackIds) {
for (final id in trackIds) { await repo.addToPlaylist(playlist.id, id);
await repo.addToPlaylist(playlist.id, id); }
} if (!context.mounted) return;
if (!context.mounted) return; Navigator.pop(context);
Navigator.pop(context); onSuccess();
onSuccess(); ScaffoldMessenger.of(context).showSnackBar(
ScaffoldMessenger.of(context).showSnackBar( SnackBar(
SnackBar( content: Text(
content: Text( 'Added ${trackIds.length} tracks to ${playlist.name}',
'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/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/db.dart';
import 'package:groovybox/data/playlist_repository.dart'; import 'package:groovybox/data/playlist_repository.dart';
import 'package:groovybox/providers/audio_provider.dart'; import 'package:groovybox/providers/audio_provider.dart';
import 'package:groovybox/ui/widgets/track_tile.dart'; import 'package:groovybox/ui/widgets/track_tile.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
class PlaylistDetailScreen extends HookConsumerWidget { class PlaylistDetailScreen extends HookConsumerWidget {
final Playlist playlist; final Playlist playlist;
@@ -16,96 +20,120 @@ class PlaylistDetailScreen extends HookConsumerWidget {
final repo = ref.watch(playlistRepositoryProvider.notifier); final repo = ref.watch(playlistRepositoryProvider.notifier);
final tracksAsync = repo.watchPlaylistTracks(playlist.id); final tracksAsync = repo.watchPlaylistTracks(playlist.id);
// Responsive breakpoints
final screenWidth = MediaQuery.sizeOf(context).width;
final isLargeScreen = screenWidth > 900;
return Scaffold( return Scaffold(
body: CustomScrollView( body: CustomScrollView(
slivers: [ slivers: [
SliverAppBar( SliverAppBar(pinned: true, title: Text(playlist.name)),
expandedHeight: 200, // Content section with constrained width
pinned: true, SliverToBoxAdapter(
flexibleSpace: FlexibleSpaceBar( child: Align(
title: Text(playlist.name), alignment: Alignment.topCenter,
background: Container( child: ConstrainedBox(
decoration: BoxDecoration( constraints: BoxConstraints(
gradient: LinearGradient( maxWidth: isLargeScreen ? 800 : double.infinity,
colors: [
Colors.purple.withOpacity(0.8),
Colors.blue.withOpacity(0.6),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
), ),
child: const Center( child: StreamBuilder<List<Track>>(
child: Icon( stream: tracksAsync,
Symbols.queue_music, builder: (context, snapshot) {
size: 80, if (!snapshot.hasData) {
color: Colors.white70, return const SizedBox(
), height: 200,
), child: Center(child: CircularProgressIndicator()),
), );
), }
),
StreamBuilder<List<Track>>(
stream: tracksAsync,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const SliverFillRemaining(
child: Center(child: CircularProgressIndicator()),
);
}
final tracks = snapshot.data!; final tracks = snapshot.data!;
if (tracks.isEmpty) { if (tracks.isEmpty) {
return const SliverFillRemaining( return const SizedBox(
child: Center(child: Text('No tracks in this playlist')), height: 200,
); child: Center(
} child: Text('No tracks in this playlist'),
),
);
}
return SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
if (index == 0) {
return Column( return Column(
children: [ children: [
Padding( // Action buttons
padding: const EdgeInsets.all(16.0), ConstrainedBox(
child: SizedBox( constraints: const BoxConstraints(maxWidth: 360),
width: double.infinity, child: Padding(
child: FilledButton.icon( padding: EdgeInsets.all(16),
onPressed: () { child: Column(
_playPlaylist(ref, tracks); spacing: 12,
}, children: [
icon: const Icon(Symbols.play_arrow), SizedBox(
label: const Text('Play All'), 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]; final track = tracks[index];
return TrackTile( return TrackTile(
track: track, track: track,
leading: Text( leading: Text(
'${index + 1}', '${index + 1}'.padLeft(2, '0'),
style: const TextStyle(color: Colors.grey, fontSize: 16), style: const TextStyle(color: Colors.grey, fontSize: 16),
), ).padding(right: isLargeScreen ? 24 : 16),
showTrailingIcon: false, showTrailingIcon: false,
onTap: () { onTap: () {
_playPlaylist(ref, tracks, initialIndex: index); _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); 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,
);
}
} }