💄 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,50 +21,75 @@ 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(
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) ? UniversalImage(uri: album.artUri!, fit: BoxFit.cover)
: Container( : Container(
color: Colors.grey[800], color: Colors.grey[800],
child: const Icon( child: const Icon(
Symbols.album, Symbols.album,
size: 100, size: 120,
color: Colors.white54, color: Colors.white54,
), ),
), ),
).clipRRect(all: 16),
).center().padding(top: 16),
), ),
// Content section with constrained width
SliverToBoxAdapter(
child: Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: isLargeScreen ? 800 : double.infinity,
), ),
StreamBuilder<List<Track>>( child: StreamBuilder<List<Track>>(
stream: tracksAsync, stream: tracksAsync,
builder: (context, snapshot) { builder: (context, snapshot) {
if (!snapshot.hasData) { if (!snapshot.hasData) {
return const SliverFillRemaining( return const SizedBox(
height: 200,
child: Center(child: CircularProgressIndicator()), child: Center(child: CircularProgressIndicator()),
); );
} }
final tracks = snapshot.data!; final tracks = snapshot.data!;
if (tracks.isEmpty) { if (tracks.isEmpty) {
return const SliverFillRemaining( return const SizedBox(
height: 200,
child: Center(child: Text('No tracks in this album')), 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),
child: Padding(
padding: EdgeInsets.all(16),
child: Column(
spacing: 12,
children: [
SizedBox(
width: double.infinity, width: double.infinity,
child: FilledButton.icon( child: FilledButton.icon(
onPressed: () { onPressed: () {
@@ -71,34 +99,64 @@ class AlbumDetailScreen extends HookConsumerWidget {
label: const Text('Play All'), 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,11 +713,10 @@ 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: () {
@@ -732,7 +731,8 @@ class LibraryScreen extends HookConsumerWidget {
); );
}, },
); );
}, }).toList(),
),
); );
}, },
); );
@@ -941,11 +941,10 @@ 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 {
@@ -967,7 +966,8 @@ class LibraryScreen extends HookConsumerWidget {
); );
}, },
); );
}, }).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,59 +20,53 @@ 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: StreamBuilder<List<Track>>(
child: const Center(
child: Icon(
Symbols.queue_music,
size: 80,
color: Colors.white70,
),
),
),
),
),
StreamBuilder<List<Track>>(
stream: tracksAsync, stream: tracksAsync,
builder: (context, snapshot) { builder: (context, snapshot) {
if (!snapshot.hasData) { if (!snapshot.hasData) {
return const SliverFillRemaining( return const SizedBox(
height: 200,
child: Center(child: CircularProgressIndicator()), 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),
child: Padding(
padding: EdgeInsets.all(16),
child: Column(
spacing: 12,
children: [
SizedBox(
width: double.infinity, width: double.infinity,
child: FilledButton.icon( child: FilledButton.icon(
onPressed: () { onPressed: () {
@@ -78,34 +76,64 @@ class PlaylistDetailScreen extends HookConsumerWidget {
label: const Text('Play All'), 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,
);
}
} }