diff --git a/lib/data/track_repository.dart b/lib/data/track_repository.dart index da5ed97..2635354 100644 --- a/lib/data/track_repository.dart +++ b/lib/data/track_repository.dart @@ -369,4 +369,42 @@ class TrackRepository extends _$TrackRepository { } } } + + /// Clear all tracks from the database and delete associated files/art. + Future clearAllTracks() async { + final db = ref.read(databaseProvider); + final appDir = await getApplicationDocumentsDirectory(); + final musicDir = p.join(appDir.path, 'music'); + + // Get all tracks first + final allTracks = await db.select(db.tracks).get(); + + // Delete associated files and art for each track + for (final track in allTracks) { + // Delete file only if it's a copied file (in internal music directory) + final file = File(track.path); + if (await file.exists() && track.path.startsWith(musicDir)) { + try { + await file.delete(); + } catch (e) { + debugPrint("Error deleting file: $e"); + } + } + + // Delete album art if exists (always stored internally) + if (track.artUri != null) { + final artFile = File(track.artUri!); + if (await artFile.exists()) { + try { + await artFile.delete(); + } catch (e) { + debugPrint("Error deleting art: $e"); + } + } + } + } + + // Clear all tracks from database (cascade will handle playlist entries) + await db.delete(db.tracks).go(); + } } diff --git a/lib/ui/screens/album_detail_screen.dart b/lib/ui/screens/album_detail_screen.dart index e2a0547..f685ae0 100644 --- a/lib/ui/screens/album_detail_screen.dart +++ b/lib/ui/screens/album_detail_screen.dart @@ -1,9 +1,11 @@ -import 'dart:io'; import 'package:flutter/material.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:groovybox/ui/widgets/universal_image.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:styled_widget/styled_widget.dart'; class AlbumDetailScreen extends HookConsumerWidget { final AlbumData album; @@ -24,7 +26,7 @@ class AlbumDetailScreen extends HookConsumerWidget { flexibleSpace: FlexibleSpaceBar( title: Text(album.album), background: album.artUri != null - ? Image.file(File(album.artUri!), fit: BoxFit.cover) + ? UniversalImage(uri: album.artUri!, fit: BoxFit.cover) : Container( color: Colors.grey[800], child: const Icon( @@ -85,17 +87,17 @@ class AlbumDetailScreen extends HookConsumerWidget { Widget _buildTrackTile(WidgetRef ref, List tracks, int index) { final track = tracks[index]; - return ListTile( + return TrackTile( + track: track, leading: Text( - '${index + 1}', + '${index + 1}'.padLeft(2, '0'), style: const TextStyle(color: Colors.grey, fontSize: 16), - ), - title: Text(track.title), - subtitle: Text(_formatDuration(track.duration)), + ).padding(right: 16), + showTrailingIcon: false, onTap: () { _playAlbum(ref, tracks, initialIndex: index); }, - trailing: const Icon(Icons.play_circle_outline), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), ); } @@ -103,12 +105,4 @@ class AlbumDetailScreen extends HookConsumerWidget { final audioHandler = ref.read(audioHandlerProvider); audioHandler.playTracks(tracks, initialIndex: initialIndex); } - - String _formatDuration(int? durationMs) { - if (durationMs == null) return '--:--'; - final d = Duration(milliseconds: durationMs); - final minutes = d.inMinutes; - final seconds = d.inSeconds % 60; - return '$minutes:${seconds.toString().padLeft(2, '0')}'; - } } diff --git a/lib/ui/screens/library_screen.dart b/lib/ui/screens/library_screen.dart index 43186d3..16df425 100644 --- a/lib/ui/screens/library_screen.dart +++ b/lib/ui/screens/library_screen.dart @@ -1,5 +1,4 @@ import 'dart:io'; -import 'dart:typed_data'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -9,12 +8,11 @@ import 'package:groovybox/data/playlist_repository.dart'; import 'package:groovybox/data/track_repository.dart'; import 'package:groovybox/logic/lyrics_parser.dart'; import 'package:groovybox/providers/audio_provider.dart'; -import 'package:groovybox/providers/remote_provider.dart'; import 'package:groovybox/providers/watch_folder_provider.dart'; import 'package:groovybox/ui/screens/settings_screen.dart'; import 'package:groovybox/ui/tabs/albums_tab.dart'; import 'package:groovybox/ui/tabs/playlists_tab.dart'; -import 'package:http/http.dart' as http; +import 'package:groovybox/ui/widgets/track_tile.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:path/path.dart' as p; import 'package:styled_widget/styled_widget.dart'; @@ -483,37 +481,19 @@ class LibraryScreen extends HookConsumerWidget { SnackBar(content: Text('Deleted "${track.title}"')), ); }, - child: ListTile( - leading: AspectRatio( - aspectRatio: 1, - child: _buildAlbumArt(track, ref), - ), - title: Text( - track.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - subtitle: Text( - '${track.artist ?? 'Unknown Artist'} • ${_formatDuration(track.duration)}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - trailing: isSelectionMode - ? null - : IconButton( - icon: const Icon(Icons.more_vert), - onPressed: () { - _showTrackOptions(context, ref, track); - }, - ), + child: TrackTile( + track: track, + showTrailingIcon: true, + onTrailingPressed: () => + _showTrackOptions(context, ref, track), onTap: () { final audio = ref.read(audioHandlerProvider); audio.playTrack(track); }, - onLongPress: () { - // Enter selection mode - toggleSelection(track.id); - }, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), ), ); }, @@ -853,81 +833,6 @@ class LibraryScreen extends HookConsumerWidget { ); } - Widget _buildAlbumArt(Track track, WidgetRef ref) { - // Check if this is a remote track - final urlResolver = ref.watch(remoteUrlResolverProvider); - final isRemote = urlResolver.isProtocolUrl(track.path); - - if (isRemote && track.artUri != null) { - // For remote tracks, fetch album art directly - return FutureBuilder( - future: _fetchRemoteAlbumArt(track.artUri!), - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return Container( - color: Colors.grey[800], - child: const Center( - child: SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Colors.white54), - ), - ), - ), - ); - } else if (snapshot.hasData && snapshot.data != null) { - return Container( - decoration: BoxDecoration( - color: Colors.grey[800], - borderRadius: BorderRadius.circular(8), - image: DecorationImage( - image: MemoryImage(snapshot.data!), - fit: BoxFit.cover, - ), - ), - ); - } else { - return Container( - color: Colors.grey[800], - child: const Icon(Icons.music_note, color: Colors.white54), - ); - } - }, - ); - } else { - // For local tracks, use existing logic - return Container( - decoration: BoxDecoration( - color: Colors.grey[800], - borderRadius: BorderRadius.circular(8), - image: track.artUri != null - ? DecorationImage( - image: FileImage(File(track.artUri!)), - fit: BoxFit.cover, - ) - : null, - ), - child: track.artUri == null - ? const Icon(Icons.music_note, color: Colors.white54) - : null, - ); - } - } - - Future _fetchRemoteAlbumArt(String url) async { - try { - final response = await http.get(Uri.parse(url)); - if (response.statusCode == 200) { - return response.bodyBytes; - } - } catch (e) { - // Ignore errors - } - return null; - } - String _formatDuration(int? durationMs) { if (durationMs == null) return '--:--'; final d = Duration(milliseconds: durationMs); diff --git a/lib/ui/screens/playlist_detail_screen.dart b/lib/ui/screens/playlist_detail_screen.dart index 9982e13..c8c0d6f 100644 --- a/lib/ui/screens/playlist_detail_screen.dart +++ b/lib/ui/screens/playlist_detail_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.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'; class PlaylistDetailScreen extends HookConsumerWidget { @@ -93,17 +94,17 @@ class PlaylistDetailScreen extends HookConsumerWidget { Widget _buildTrackTile(WidgetRef ref, List tracks, int index) { final track = tracks[index]; - return ListTile( + return TrackTile( + track: track, leading: Text( '${index + 1}', style: const TextStyle(color: Colors.grey, fontSize: 16), ), - title: Text(track.title), - subtitle: Text(track.artist ?? 'Unknown Artist'), - trailing: Text(_formatDuration(track.duration)), + showTrailingIcon: false, onTap: () { _playPlaylist(ref, tracks, initialIndex: index); }, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), ); } @@ -115,12 +116,4 @@ class PlaylistDetailScreen extends HookConsumerWidget { final audioHandler = ref.read(audioHandlerProvider); audioHandler.playTracks(tracks, initialIndex: initialIndex); } - - String _formatDuration(int? durationMs) { - if (durationMs == null) return '--:--'; - final d = Duration(milliseconds: durationMs); - final minutes = d.inMinutes; - final seconds = d.inSeconds % 60; - return '$minutes:${seconds.toString().padLeft(2, '0')}'; - } } diff --git a/lib/ui/screens/settings_screen.dart b/lib/ui/screens/settings_screen.dart index fd28f7d..61877ec 100644 --- a/lib/ui/screens/settings_screen.dart +++ b/lib/ui/screens/settings_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:groovybox/data/track_repository.dart'; import 'package:groovybox/providers/settings_provider.dart'; import 'package:groovybox/providers/watch_folder_provider.dart'; import 'package:groovybox/providers/remote_provider.dart'; @@ -305,6 +306,42 @@ class SettingsScreen extends ConsumerWidget { ], ), ), + + // Database Management Section + Card( + margin: EdgeInsets.zero, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Database Management', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ).padding(horizontal: 16, bottom: 8, top: 16), + const Text( + 'Manage your music database and cached files.', + style: TextStyle(color: Colors.grey, fontSize: 14), + ).padding(horizontal: 16, bottom: 8), + ListTile( + title: const Text('Reset Track Database'), + subtitle: const Text( + 'Remove all tracks from database and delete cached files. This action cannot be undone.', + ), + trailing: ElevatedButton( + onPressed: () => _resetTrackDatabase(context, ref), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + ), + child: const Text('Reset'), + ), + ), + const SizedBox(height: 8), + ], + ), + ), ], ), ), @@ -491,4 +528,48 @@ class SettingsScreen extends ConsumerWidget { ), ); } + + void _resetTrackDatabase(BuildContext context, WidgetRef ref) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Reset Track Database'), + content: const Text( + 'This will permanently delete all tracks from the database and remove all cached music files and album art. This action cannot be undone.\n\nAre you sure you want to continue?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () async { + Navigator.of(context).pop(); // Close confirmation dialog + + try { + final repository = ref.read(trackRepositoryProvider.notifier); + await repository.clearAllTracks(); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Track database has been reset'), + ), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error resetting database: $e')), + ); + } + } + }, + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Reset'), + ), + ], + ), + ); + } } diff --git a/lib/ui/tabs/albums_tab.dart b/lib/ui/tabs/albums_tab.dart index 440c736..20ca5a0 100644 --- a/lib/ui/tabs/albums_tab.dart +++ b/lib/ui/tabs/albums_tab.dart @@ -1,8 +1,8 @@ -import 'dart:io'; import 'package:animations/animations.dart'; import 'package:flutter/material.dart'; import 'package:groovybox/data/playlist_repository.dart'; import 'package:groovybox/ui/screens/album_detail_screen.dart'; +import 'package:groovybox/ui/widgets/universal_image.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; class AlbumsTab extends HookConsumerWidget { @@ -49,16 +49,12 @@ class AlbumsTab extends HookConsumerWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Expanded( - child: album.artUri != null - ? Image.file(File(album.artUri!), fit: BoxFit.cover) - : Container( - color: Colors.grey[800], - child: const Icon( - Icons.album, - size: 48, - color: Colors.white54, - ), - ), + child: UniversalImage( + uri: album.artUri, + fit: BoxFit.cover, + fallbackIcon: Icons.album, + fallbackIconSize: 48, + ), ), Padding( padding: const EdgeInsets.all(8.0), diff --git a/lib/ui/widgets/mini_player.dart b/lib/ui/widgets/mini_player.dart index 86b2c3d..aea0e41 100644 --- a/lib/ui/widgets/mini_player.dart +++ b/lib/ui/widgets/mini_player.dart @@ -355,29 +355,35 @@ class _DesktopMiniPlayer extends HookConsumerWidget { ), const Gap(8), // Title & Artist - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12.0, - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - currentMetadata?.title ?? - Uri.parse(media.uri).pathSegments.last, - style: Theme.of(context).textTheme.bodyMedium - ?.copyWith(fontWeight: FontWeight.bold), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - Text( - currentMetadata?.artist ?? 'Unknown Artist', - style: Theme.of(context).textTheme.bodySmall, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], + Flexible( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + currentMetadata?.title ?? + Uri.parse(media.uri).pathSegments.last, + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(fontWeight: FontWeight.bold), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + currentMetadata?.artist ?? 'Unknown Artist', + style: Theme.of( + context, + ).textTheme.bodySmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), ), ), ], diff --git a/lib/ui/widgets/track_tile.dart b/lib/ui/widgets/track_tile.dart index 7e5ff29..413981e 100644 --- a/lib/ui/widgets/track_tile.dart +++ b/lib/ui/widgets/track_tile.dart @@ -1,7 +1,7 @@ -import 'dart:io'; - import 'package:flutter/material.dart'; import 'package:groovybox/data/db.dart' as db; +import 'package:groovybox/ui/widgets/universal_image.dart'; +import 'package:styled_widget/styled_widget.dart'; class TrackTile extends StatelessWidget { final db.Track track; @@ -41,29 +41,20 @@ class TrackTile extends StatelessWidget { borderRadius: BorderRadius.circular(8), ), child: ListTile( - contentPadding: - padding ?? const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + contentPadding: padding ?? const EdgeInsets.symmetric(horizontal: 16), leading: Row( mainAxisSize: MainAxisSize.min, children: [ ?leading, AspectRatio( aspectRatio: 1, - child: Container( - decoration: BoxDecoration( - color: Colors.grey[800], - borderRadius: BorderRadius.circular(8), - image: track.artUri != null - ? DecorationImage( - image: FileImage(File(track.artUri!)), - fit: BoxFit.cover, - ) - : null, - ), - child: track.artUri == null - ? const Icon(Icons.music_note, color: Colors.white54) - : null, - ), + child: UniversalImage( + uri: track.artUri, + fit: BoxFit.cover, + borderRadius: BorderRadius.circular(8), + fallbackIcon: Icons.music_note, + fallbackIconSize: 24, + ).clipRRect(all: 8), ), ], ), diff --git a/lib/ui/widgets/universal_image.dart b/lib/ui/widgets/universal_image.dart new file mode 100644 index 0000000..b4912c7 --- /dev/null +++ b/lib/ui/widgets/universal_image.dart @@ -0,0 +1,130 @@ +import 'dart:io'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; + +class UniversalImage extends StatelessWidget { + final String? uri; + final BoxFit? fit; + final double? width; + final double? height; + final Widget? fallback; + final IconData? fallbackIcon; + final double? fallbackIconSize; + final Color? fallbackIconColor; + final BorderRadius? borderRadius; + final bool useDecorationImage; + + const UniversalImage({ + super.key, + this.uri, + this.fit = BoxFit.cover, + this.width, + this.height, + this.fallback, + this.fallbackIcon = Icons.image, + this.fallbackIconSize = 48, + this.fallbackIconColor = Colors.white54, + this.borderRadius, + this.useDecorationImage = false, + }); + + bool _isNetworkUri(String uri) { + return uri.startsWith('http://') || uri.startsWith('https://'); + } + + Widget _buildFallback() { + if (fallback != null) { + return fallback!; + } + + final icon = Icon( + fallbackIcon, + size: fallbackIconSize, + color: fallbackIconColor, + ); + + if (borderRadius != null) { + return Container( + width: width, + height: height, + decoration: BoxDecoration( + color: Colors.grey[800], + borderRadius: borderRadius, + ), + child: icon, + ); + } + + return Container( + width: width, + height: height, + color: Colors.grey[800], + child: icon, + ); + } + + Widget _buildNetworkImage() { + if (useDecorationImage) { + return CachedNetworkImage( + imageUrl: uri!, + fit: fit, + width: width, + height: height, + placeholder: (context, url) => Container( + width: width, + height: height, + color: Colors.grey[800], + child: const CircularProgressIndicator(), + ), + errorWidget: (context, url, error) => _buildFallback(), + ); + } + + return CachedNetworkImage( + imageUrl: uri!, + fit: fit, + width: width, + height: height, + placeholder: (context, url) => Container( + width: width, + height: height, + color: Colors.grey[800], + child: const CircularProgressIndicator(), + ), + errorWidget: (context, url, error) => _buildFallback(), + ); + } + + Widget _buildFileImage() { + if (useDecorationImage) { + return Image.file( + File(uri!), + fit: fit, + width: width, + height: height, + errorBuilder: (context, error, stackTrace) => _buildFallback(), + ); + } + + return Image.file( + File(uri!), + fit: fit, + width: width, + height: height, + errorBuilder: (context, error, stackTrace) => _buildFallback(), + ); + } + + @override + Widget build(BuildContext context) { + if (uri == null || uri!.isEmpty) { + return _buildFallback(); + } + + if (_isNetworkUri(uri!)) { + return _buildNetworkImage(); + } else { + return _buildFileImage(); + } + } +} diff --git a/pubspec.lock b/pubspec.lock index e1d8bcd..89b4e27 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -161,6 +161,30 @@ packages: url: "https://pub.dev" source: hosted version: "8.12.1" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" + url: "https://pub.dev" + source: hosted + version: "1.3.1" characters: dependency: transitive description: @@ -752,6 +776,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" package_config: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 558b8b3..f1b3302 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -57,6 +57,7 @@ dependencies: watcher: ^1.2.0 shared_preferences: ^2.3.5 jellyfin_dart: ^0.1.2 + cached_network_image: ^3.4.1 dev_dependencies: flutter_test: