♻️ Unified the track tile widget

This commit is contained in:
2025-12-20 00:40:36 +08:00
parent a86e8b1cab
commit aaba0382cf
11 changed files with 353 additions and 186 deletions

View File

@@ -369,4 +369,42 @@ class TrackRepository extends _$TrackRepository {
} }
} }
} }
/// Clear all tracks from the database and delete associated files/art.
Future<void> 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();
}
} }

View File

@@ -1,9 +1,11 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.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/universal_image.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:styled_widget/styled_widget.dart';
class AlbumDetailScreen extends HookConsumerWidget { class AlbumDetailScreen extends HookConsumerWidget {
final AlbumData album; final AlbumData album;
@@ -24,7 +26,7 @@ class AlbumDetailScreen extends HookConsumerWidget {
flexibleSpace: FlexibleSpaceBar( flexibleSpace: FlexibleSpaceBar(
title: Text(album.album), title: Text(album.album),
background: album.artUri != null background: album.artUri != null
? Image.file(File(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(
@@ -85,17 +87,17 @@ class AlbumDetailScreen extends HookConsumerWidget {
Widget _buildTrackTile(WidgetRef ref, List<Track> tracks, int index) { Widget _buildTrackTile(WidgetRef ref, List<Track> tracks, int index) {
final track = tracks[index]; final track = tracks[index];
return ListTile( return TrackTile(
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: 16),
title: Text(track.title), showTrailingIcon: false,
subtitle: Text(_formatDuration(track.duration)),
onTap: () { onTap: () {
_playAlbum(ref, tracks, initialIndex: index); _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); final audioHandler = ref.read(audioHandlerProvider);
audioHandler.playTracks(tracks, initialIndex: initialIndex); 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')}';
}
} }

View File

@@ -1,5 +1,4 @@
import 'dart:io'; import 'dart:io';
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.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/data/track_repository.dart';
import 'package:groovybox/logic/lyrics_parser.dart'; import 'package:groovybox/logic/lyrics_parser.dart';
import 'package:groovybox/providers/audio_provider.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/providers/watch_folder_provider.dart';
import 'package:groovybox/ui/screens/settings_screen.dart'; import 'package:groovybox/ui/screens/settings_screen.dart';
import 'package:groovybox/ui/tabs/albums_tab.dart'; import 'package:groovybox/ui/tabs/albums_tab.dart';
import 'package:groovybox/ui/tabs/playlists_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:hooks_riverpod/hooks_riverpod.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
@@ -483,37 +481,19 @@ class LibraryScreen extends HookConsumerWidget {
SnackBar(content: Text('Deleted "${track.title}"')), SnackBar(content: Text('Deleted "${track.title}"')),
); );
}, },
child: ListTile( child: TrackTile(
leading: AspectRatio( track: track,
aspectRatio: 1, showTrailingIcon: true,
child: _buildAlbumArt(track, ref), onTrailingPressed: () =>
), _showTrackOptions(context, ref, track),
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);
},
),
onTap: () { onTap: () {
final audio = ref.read(audioHandlerProvider); final audio = ref.read(audioHandlerProvider);
audio.playTrack(track); audio.playTrack(track);
}, },
onLongPress: () { padding: const EdgeInsets.symmetric(
// Enter selection mode horizontal: 16,
toggleSelection(track.id); 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<Uint8List?>(
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<Color>(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<Uint8List?> _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) { String _formatDuration(int? durationMs) {
if (durationMs == null) return '--:--'; if (durationMs == null) return '--:--';
final d = Duration(milliseconds: durationMs); final d = Duration(milliseconds: durationMs);

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.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:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
class PlaylistDetailScreen extends HookConsumerWidget { class PlaylistDetailScreen extends HookConsumerWidget {
@@ -93,17 +94,17 @@ class PlaylistDetailScreen extends HookConsumerWidget {
Widget _buildTrackTile(WidgetRef ref, List<Track> tracks, int index) { Widget _buildTrackTile(WidgetRef ref, List<Track> tracks, int index) {
final track = tracks[index]; final track = tracks[index];
return ListTile( return TrackTile(
track: track,
leading: Text( leading: Text(
'${index + 1}', '${index + 1}',
style: const TextStyle(color: Colors.grey, fontSize: 16), style: const TextStyle(color: Colors.grey, fontSize: 16),
), ),
title: Text(track.title), showTrailingIcon: false,
subtitle: Text(track.artist ?? 'Unknown Artist'),
trailing: Text(_formatDuration(track.duration)),
onTap: () { onTap: () {
_playPlaylist(ref, tracks, initialIndex: index); _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); final audioHandler = ref.read(audioHandlerProvider);
audioHandler.playTracks(tracks, initialIndex: initialIndex); 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')}';
}
} }

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:groovybox/data/track_repository.dart';
import 'package:groovybox/providers/settings_provider.dart'; import 'package:groovybox/providers/settings_provider.dart';
import 'package:groovybox/providers/watch_folder_provider.dart'; import 'package:groovybox/providers/watch_folder_provider.dart';
import 'package:groovybox/providers/remote_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'),
),
],
),
);
}
} }

View File

@@ -1,8 +1,8 @@
import 'dart:io';
import 'package:animations/animations.dart'; import 'package:animations/animations.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:groovybox/data/playlist_repository.dart'; import 'package:groovybox/data/playlist_repository.dart';
import 'package:groovybox/ui/screens/album_detail_screen.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'; import 'package:hooks_riverpod/hooks_riverpod.dart';
class AlbumsTab extends HookConsumerWidget { class AlbumsTab extends HookConsumerWidget {
@@ -49,15 +49,11 @@ class AlbumsTab extends HookConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Expanded( Expanded(
child: album.artUri != null child: UniversalImage(
? Image.file(File(album.artUri!), fit: BoxFit.cover) uri: album.artUri,
: Container( fit: BoxFit.cover,
color: Colors.grey[800], fallbackIcon: Icons.album,
child: const Icon( fallbackIconSize: 48,
Icons.album,
size: 48,
color: Colors.white54,
),
), ),
), ),
Padding( Padding(

View File

@@ -355,7 +355,8 @@ class _DesktopMiniPlayer extends HookConsumerWidget {
), ),
const Gap(8), const Gap(8),
// Title & Artist // Title & Artist
Padding( Flexible(
child: Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 12.0, horizontal: 12.0,
), ),
@@ -366,20 +367,25 @@ class _DesktopMiniPlayer extends HookConsumerWidget {
Text( Text(
currentMetadata?.title ?? currentMetadata?.title ??
Uri.parse(media.uri).pathSegments.last, Uri.parse(media.uri).pathSegments.last,
style: Theme.of(context).textTheme.bodyMedium style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(fontWeight: FontWeight.bold), ?.copyWith(fontWeight: FontWeight.bold),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
Text( Text(
currentMetadata?.artist ?? 'Unknown Artist', currentMetadata?.artist ?? 'Unknown Artist',
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(
context,
).textTheme.bodySmall,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
], ],
), ),
), ),
),
], ],
), ),
), ),

View File

@@ -1,7 +1,7 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:groovybox/data/db.dart' as db; 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 { class TrackTile extends StatelessWidget {
final db.Track track; final db.Track track;
@@ -41,29 +41,20 @@ class TrackTile extends StatelessWidget {
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: ListTile( child: ListTile(
contentPadding: contentPadding: padding ?? const EdgeInsets.symmetric(horizontal: 16),
padding ?? const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
leading: Row( leading: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
?leading, ?leading,
AspectRatio( AspectRatio(
aspectRatio: 1, aspectRatio: 1,
child: Container( child: UniversalImage(
decoration: BoxDecoration( uri: track.artUri,
color: Colors.grey[800],
borderRadius: BorderRadius.circular(8),
image: track.artUri != null
? DecorationImage(
image: FileImage(File(track.artUri!)),
fit: BoxFit.cover, fit: BoxFit.cover,
) borderRadius: BorderRadius.circular(8),
: null, fallbackIcon: Icons.music_note,
), fallbackIconSize: 24,
child: track.artUri == null ).clipRRect(all: 8),
? const Icon(Icons.music_note, color: Colors.white54)
: null,
),
), ),
], ],
), ),

View File

@@ -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();
}
}
}

View File

@@ -161,6 +161,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "8.12.1" 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: characters:
dependency: transitive dependency: transitive
description: description:
@@ -752,6 +776,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.2" 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: package_config:
dependency: transitive dependency: transitive
description: description:

View File

@@ -57,6 +57,7 @@ dependencies:
watcher: ^1.2.0 watcher: ^1.2.0
shared_preferences: ^2.3.5 shared_preferences: ^2.3.5
jellyfin_dart: ^0.1.2 jellyfin_dart: ^0.1.2
cached_network_image: ^3.4.1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: