🐛 Bug fixes of libraries
This commit is contained in:
@@ -71,16 +71,22 @@ class PlaylistRepository extends _$PlaylistRepository {
|
|||||||
|
|
||||||
Stream<List<AlbumData>> watchAllAlbums() {
|
Stream<List<AlbumData>> watchAllAlbums() {
|
||||||
final db = ref.watch(databaseProvider);
|
final db = ref.watch(databaseProvider);
|
||||||
// Distinct albums by grouping
|
// Distinct albums by grouping - group by album name only to prevent duplicates
|
||||||
|
// when the same album has inconsistent artist metadata across tracks
|
||||||
final query = db.selectOnly(db.tracks)
|
final query = db.selectOnly(db.tracks)
|
||||||
..addColumns([db.tracks.album, db.tracks.artist, db.tracks.artUri])
|
..addColumns([
|
||||||
..groupBy([db.tracks.album, db.tracks.artist]);
|
db.tracks.album,
|
||||||
|
db.tracks.artist.min(), // Get the first non-null artist
|
||||||
|
db.tracks.artUri.min(), // Get the first non-null art URI
|
||||||
|
])
|
||||||
|
..where(db.tracks.album.isNotNull())
|
||||||
|
..groupBy([db.tracks.album]);
|
||||||
|
|
||||||
return query.map((row) {
|
return query.map((row) {
|
||||||
return AlbumData(
|
return AlbumData(
|
||||||
album: row.read(db.tracks.album) ?? 'Unknown Album',
|
album: row.read(db.tracks.album)!,
|
||||||
artist: row.read(db.tracks.artist) ?? 'Unknown Artist',
|
artist: row.read(db.tracks.artist.min()) ?? 'Various Artists',
|
||||||
artUri: row.read(db.tracks.artUri),
|
artUri: row.read(db.tracks.artUri.min()),
|
||||||
);
|
);
|
||||||
}).watch();
|
}).watch();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,13 +24,28 @@ class TrackRepository extends _$TrackRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> importFiles(List<String> filePaths) async {
|
Future<void> importFiles(List<String> filePaths) async {
|
||||||
|
final db = ref.read(databaseProvider);
|
||||||
final settings = ref.read(settingsProvider).value;
|
final settings = ref.read(settingsProvider).value;
|
||||||
final importMode = settings?.importMode ?? ImportMode.copy;
|
final importMode = settings?.importMode ?? ImportMode.copy;
|
||||||
|
|
||||||
|
// Filter out files that are already indexed
|
||||||
|
final existingPaths = await (db.select(
|
||||||
|
db.tracks,
|
||||||
|
)..where((t) => t.path.isIn(filePaths))).map((t) => t.path).get();
|
||||||
|
|
||||||
|
final existingPathsSet = existingPaths.toSet();
|
||||||
|
final newFilePaths = filePaths
|
||||||
|
.where((path) => !existingPathsSet.contains(path))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (newFilePaths.isEmpty) {
|
||||||
|
return; // All files already indexed
|
||||||
|
}
|
||||||
|
|
||||||
if (importMode == ImportMode.copy) {
|
if (importMode == ImportMode.copy) {
|
||||||
await _importFilesWithCopy(filePaths);
|
await _importFilesWithCopy(newFilePaths);
|
||||||
} else {
|
} else {
|
||||||
await _importFilesInPlace(filePaths);
|
await _importFilesInPlace(newFilePaths);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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/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';
|
||||||
@@ -597,6 +598,14 @@ class LibraryScreen extends HookConsumerWidget {
|
|||||||
_showAddToPlaylistDialog(context, ref, track);
|
_showAddToPlaylistDialog(context, ref, track);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.info_outline),
|
||||||
|
title: const Text('View Details'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
_showTrackDetails(context, ref, track);
|
||||||
|
},
|
||||||
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.edit),
|
leading: const Icon(Icons.edit),
|
||||||
title: const Text('Edit Metadata'),
|
title: const Text('Edit Metadata'),
|
||||||
@@ -708,6 +717,99 @@ class LibraryScreen extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showTrackDetails(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
Track track,
|
||||||
|
) async {
|
||||||
|
// Try to get file info
|
||||||
|
String fileSize = 'Unknown';
|
||||||
|
String libraryName = 'Unknown';
|
||||||
|
String dateAdded = 'Unknown';
|
||||||
|
|
||||||
|
try {
|
||||||
|
final file = File(track.path);
|
||||||
|
if (await file.exists()) {
|
||||||
|
final stat = await file.stat();
|
||||||
|
final sizeInMB = (stat.size / (1024 * 1024)).toStringAsFixed(2);
|
||||||
|
fileSize = '${sizeInMB} MB';
|
||||||
|
dateAdded = stat.modified.toString().split(
|
||||||
|
' ',
|
||||||
|
)[0]; // Just the date part
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore file access errors
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find which library this track belongs to
|
||||||
|
final watchFoldersAsync = ref.read(watchFoldersProvider);
|
||||||
|
watchFoldersAsync.whenData((folders) {
|
||||||
|
for (final folder in folders) {
|
||||||
|
if (track.path.startsWith(folder.path)) {
|
||||||
|
libraryName = folder.name;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
final screenSize = MediaQuery.of(context).size;
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Track Details'),
|
||||||
|
content: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(maxWidth: screenSize.width * 0.8),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_buildDetailRow('Title', track.title),
|
||||||
|
_buildDetailRow('Artist', track.artist ?? 'Unknown'),
|
||||||
|
_buildDetailRow('Album', track.album ?? 'Unknown'),
|
||||||
|
_buildDetailRow('Duration', _formatDuration(track.duration)),
|
||||||
|
_buildDetailRow('File Size', fileSize),
|
||||||
|
_buildDetailRow('Library', libraryName),
|
||||||
|
_buildDetailRow('File Path', track.path),
|
||||||
|
_buildDetailRow('Date Added', dateAdded),
|
||||||
|
if (track.artUri != null)
|
||||||
|
_buildDetailRow('Album Art', 'Present'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Close'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDetailRow(String label, String value) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 100,
|
||||||
|
child: Text(
|
||||||
|
'$label:',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(value, style: const TextStyle(color: Colors.grey)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _showEditDialog(BuildContext context, WidgetRef ref, Track track) {
|
void _showEditDialog(BuildContext context, WidgetRef ref, Track track) {
|
||||||
final titleController = TextEditingController(text: track.title);
|
final titleController = TextEditingController(text: track.title);
|
||||||
final artistController = TextEditingController(text: track.artist);
|
final artistController = TextEditingController(text: track.artist);
|
||||||
|
|||||||
@@ -20,85 +20,6 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Import Mode Section
|
|
||||||
Card(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16.0),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'Import Mode',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Column(
|
|
||||||
children: [
|
|
||||||
ListTile(
|
|
||||||
title: Text(ImportMode.copy.displayName),
|
|
||||||
subtitle: const Text(
|
|
||||||
'Copy music files to internal storage',
|
|
||||||
),
|
|
||||||
leading: RadioGroup<ImportMode>(
|
|
||||||
groupValue: settings.importMode,
|
|
||||||
onChanged: (value) {
|
|
||||||
if (value != null) {
|
|
||||||
ref
|
|
||||||
.read(importModeProvider.notifier)
|
|
||||||
.update(value);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: Radio<ImportMode>(value: ImportMode.copy),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
title: Text(ImportMode.inplace.displayName),
|
|
||||||
subtitle: const Text(
|
|
||||||
'Index music files in their original location',
|
|
||||||
),
|
|
||||||
leading: RadioGroup<ImportMode>(
|
|
||||||
groupValue: settings.importMode,
|
|
||||||
onChanged: (value) {
|
|
||||||
if (value != null) {
|
|
||||||
ref
|
|
||||||
.read(importModeProvider.notifier)
|
|
||||||
.update(value);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: Radio<ImportMode>(
|
|
||||||
value: ImportMode.inplace,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
title: Text(ImportMode.mixed.displayName),
|
|
||||||
subtitle: const Text(
|
|
||||||
'Use internal storage and add folder libraries',
|
|
||||||
),
|
|
||||||
leading: RadioGroup<ImportMode>(
|
|
||||||
groupValue: settings.importMode,
|
|
||||||
onChanged: (value) {
|
|
||||||
if (value != null) {
|
|
||||||
ref
|
|
||||||
.read(importModeProvider.notifier)
|
|
||||||
.update(value);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: Radio<ImportMode>(value: ImportMode.mixed),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// Auto Scan Section
|
// Auto Scan Section
|
||||||
Card(
|
Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -160,8 +81,6 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (settings.importMode == ImportMode.inplace ||
|
|
||||||
settings.importMode == ImportMode.mixed)
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
@@ -170,8 +89,7 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
tooltip: 'Scan Libraries',
|
tooltip: 'Scan Libraries',
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: () =>
|
onPressed: () => _addMusicLibrary(context, ref),
|
||||||
_addMusicLibrary(context, ref),
|
|
||||||
icon: const Icon(Icons.add),
|
icon: const Icon(Icons.add),
|
||||||
tooltip: 'Add Music Library',
|
tooltip: 'Add Music Library',
|
||||||
),
|
),
|
||||||
@@ -180,21 +98,11 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
if (settings.importMode == ImportMode.inplace ||
|
|
||||||
settings.importMode == ImportMode.mixed) ...[
|
|
||||||
const Text(
|
const Text(
|
||||||
'Add folder libraries to index music files in their original location.',
|
'Add folder libraries to index music files. Files will be copied to internal storage for playback.',
|
||||||
style: TextStyle(color: Colors.grey, fontSize: 14),
|
style: TextStyle(color: Colors.grey, fontSize: 14),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
],
|
|
||||||
if (settings.importMode == ImportMode.copy)
|
|
||||||
const Text(
|
|
||||||
'Folder libraries are available in in-place and mixed modes.',
|
|
||||||
style: TextStyle(color: Colors.grey, fontSize: 14),
|
|
||||||
),
|
|
||||||
if (settings.importMode == ImportMode.inplace ||
|
|
||||||
settings.importMode == ImportMode.mixed) ...[
|
|
||||||
watchFoldersAsync.when(
|
watchFoldersAsync.when(
|
||||||
data: (folders) => folders.isEmpty
|
data: (folders) => folders.isEmpty
|
||||||
? const Text(
|
? const Text(
|
||||||
@@ -249,7 +157,6 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
Text('Error loading libraries: $error'),
|
Text('Error loading libraries: $error'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user