diff --git a/lib/providers/remote_provider.dart b/lib/providers/remote_provider.dart index 95961f8..ec65dec 100644 --- a/lib/providers/remote_provider.dart +++ b/lib/providers/remote_provider.dart @@ -212,6 +212,9 @@ class RemoteProviderService { class RemoteUrlResolver { final Ref ref; + // Token cache: providerId -> {token: string, expiresAt: DateTime} + final Map> _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 _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; } } diff --git a/lib/ui/screens/album_detail_screen.dart b/lib/ui/screens/album_detail_screen.dart index f4433c9..64d7e17 100644 --- a/lib/ui/screens/album_detail_screen.dart +++ b/lib/ui/screens/album_detail_screen.dart @@ -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>( - 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>( + 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 tracks, int index) { + Widget _buildTrackTile( + WidgetRef ref, + List 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 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 _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, + ); + } } diff --git a/lib/ui/screens/library_screen.dart b/lib/ui/screens/library_screen.dart index 99c19e8..4f76b8f 100644 --- a/lib/ui/screens/library_screen.dart +++ b/lib/ui/screens/library_screen.dart @@ -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(), + ), ); }, ); diff --git a/lib/ui/screens/playlist_detail_screen.dart b/lib/ui/screens/playlist_detail_screen.dart index 1cb41fc..870fa90 100644 --- a/lib/ui/screens/playlist_detail_screen.dart +++ b/lib/ui/screens/playlist_detail_screen.dart @@ -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>( - stream: tracksAsync, - builder: (context, snapshot) { - if (!snapshot.hasData) { - return const SliverFillRemaining( - child: Center(child: CircularProgressIndicator()), - ); - } + child: StreamBuilder>( + 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 tracks, int index) { + Widget _buildTrackTile( + WidgetRef ref, + List 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 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 _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, + ); + } }