💄 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 {
final Ref ref;
// Token cache: providerId -> {token: string, expiresAt: DateTime}
final Map<int, Map<String, dynamic>> _tokenCache = {};
RemoteUrlResolver(this.ref);
/// Resolves a groovybox protocol URL to an actual streaming URL
@@ -245,7 +248,34 @@ class RemoteUrlResolver {
}
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);
client.setDeviceId('groovybox-$providerId');
client.setVersion('1.0.0');
@@ -263,10 +293,17 @@ class RemoteUrlResolver {
return null;
}
// Return the actual streaming URL
return '${provider.serverUrl}/Audio/$itemId/stream.mp3?api_key=$token&static=true';
// Cache the token for 23 hours (tokens typically last 24 hours)
_tokenCache[providerId] = {
'token': token,
'expiresAt': DateTime.now().add(const Duration(hours: 23)),
};
return token;
} 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;
}
}

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,50 +21,75 @@ 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
// 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: 100,
size: 120,
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,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const SliverFillRemaining(
return const SizedBox(
height: 200,
child: Center(child: CircularProgressIndicator()),
);
}
final tracks = snapshot.data!;
if (tracks.isEmpty) {
return const SliverFillRemaining(
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(
// 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: () {
@@ -71,34 +99,64 @@ class AlbumDetailScreen extends HookConsumerWidget {
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,11 +713,10 @@ class LibraryScreen extends HookConsumerWidget {
);
}
return ListView.builder(
shrinkWrap: true,
itemCount: playlists.length,
itemBuilder: (context, index) {
final playlist = playlists[index];
return SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: playlists.map((playlist) {
return ListTile(
title: Text(playlist.name),
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 ListView.builder(
shrinkWrap: true,
itemCount: playlists.length,
itemBuilder: (context, index) {
final playlist = playlists[index];
return SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: playlists.map((playlist) {
return ListTile(
title: Text(playlist.name),
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_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,59 +20,53 @@ 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>>(
child: StreamBuilder<List<Track>>(
stream: tracksAsync,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const SliverFillRemaining(
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')),
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(
// 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: () {
@@ -78,34 +76,64 @@ class PlaylistDetailScreen extends HookConsumerWidget {
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,
);
}
}