feat: add local library albums to Albums tab with unified grid and LocalAlbumScreen

- Add local library albums as clickable cards in Albums filter
- Merge downloaded and local albums into single unified grid (fix layout gaps)
- Create LocalAlbumScreen for viewing local album details with:
  - Cover art display with dominant color extraction
  - Album info card with Local badge and quality info
  - Track list with disc grouping support
  - Selection mode with delete functionality
  - UI consistent with DownloadedAlbumScreen (Card + ListTile layout)
- Add singles filter support for local library singles
- Add extractDominantColorFromFile to PaletteService
- Add delete(id) method to LibraryDatabase
- Add removeItem(id) method to LocalLibraryNotifier
- Update CHANGELOG.md for v3.4.0
This commit is contained in:
zarzet committed 2026-02-03 21:51:40 +07:00
1 parent 9c22f41a3e
commit 72d45746a5
14 files changed
+3255 -478

No files matched your search

+20 -2
View File
@@ -14,6 +14,7 @@ class LocalLibraryItem {
final String albumName;
final String? albumArtist;
final String filePath;
final String? coverPath; // Path to extracted cover art
final DateTime scannedAt;
final String? isrc;
final int? trackNumber;
@@ -32,6 +33,7 @@ class LocalLibraryItem {
required this.albumName,
this.albumArtist,
required this.filePath,
this.coverPath,
required this.scannedAt,
this.isrc,
this.trackNumber,
@@ -51,6 +53,7 @@ class LocalLibraryItem {
'albumName': albumName,
'albumArtist': albumArtist,
'filePath': filePath,
'coverPath': coverPath,
'scannedAt': scannedAt.toIso8601String(),
'isrc': isrc,
'trackNumber': trackNumber,
@@ -71,6 +74,7 @@ class LocalLibraryItem {
albumName: json['albumName'] as String,
albumArtist: json['albumArtist'] as String?,
filePath: json['filePath'] as String,
coverPath: json['coverPath'] as String?,
scannedAt: DateTime.parse(json['scannedAt'] as String),
isrc: json['isrc'] as String?,
trackNumber: json['trackNumber'] as int?,
@@ -109,7 +113,7 @@ class LibraryDatabase {
return await openDatabase(
path,
version: 1,
version: 2, // Bumped version for cover_path migration
onCreate: _createDB,
onUpgrade: _upgradeDB,
);
@@ -126,6 +130,7 @@ class LibraryDatabase {
album_name TEXT NOT NULL,
album_artist TEXT,
file_path TEXT NOT NULL UNIQUE,
cover_path TEXT,
scanned_at TEXT NOT NULL,
isrc TEXT,
track_number INTEGER,
@@ -150,7 +155,12 @@ class LibraryDatabase {
Future<void> _upgradeDB(Database db, int oldVersion, int newVersion) async {
_log.i('Upgrading library database from v$oldVersion to v$newVersion');
// Future migrations go here
if (oldVersion < 2) {
// Add cover_path column
await db.execute('ALTER TABLE library ADD COLUMN cover_path TEXT');
_log.i('Added cover_path column');
}
}
/// Convert JSON format (camelCase) to DB row (snake_case)
@@ -162,6 +172,7 @@ class LibraryDatabase {
'album_name': json['albumName'],
'album_artist': json['albumArtist'],
'file_path': json['filePath'],
'cover_path': json['coverPath'],
'scanned_at': json['scannedAt'],
'isrc': json['isrc'],
'track_number': json['trackNumber'],
@@ -184,6 +195,7 @@ class LibraryDatabase {
'albumName': row['album_name'],
'albumArtist': row['album_artist'],
'filePath': row['file_path'],
'coverPath': row['cover_path'],
'scannedAt': row['scanned_at'],
'isrc': row['isrc'],
'trackNumber': row['track_number'],
@@ -333,6 +345,12 @@ class LibraryDatabase {
await db.delete('library', where: 'file_path = ?', whereArgs: [filePath]);
}
/// Delete by ID
Future<void> delete(String id) async {
final db = await database;
await db.delete('library', where: 'id = ?', whereArgs: [id]);
}
/// Delete items where file no longer exists
Future<int> cleanupMissingFiles() async {
final db = await database;
+11 -3
View File
@@ -145,12 +145,20 @@ class NotificationService {
required String artistName,
int? completedCount,
int? totalCount,
bool alreadyInLibrary = false,
}) async {
if (!_isInitialized) await initialize();
final title = completedCount != null && totalCount != null
? 'Download Complete ($completedCount/$totalCount)'
: 'Download Complete';
String title;
if (alreadyInLibrary) {
title = completedCount != null && totalCount != null
? 'Already in Library ($completedCount/$totalCount)'
: 'Already in Library';
} else {
title = completedCount != null && totalCount != null
? 'Download Complete ($completedCount/$totalCount)'
: 'Download Complete';
}
const androidDetails = AndroidNotificationDetails(
channelId,
+35
View File
@@ -1,3 +1,4 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:palette_generator/palette_generator.dart';
@@ -46,6 +47,40 @@ class PaletteService {
}
}
/// Extract dominant color from a local file path
Future<Color?> extractDominantColorFromFile(String? filePath) async {
if (filePath == null || filePath.isEmpty) return null;
final cached = _colorCache[filePath];
if (cached != null) {
return cached;
}
try {
final file = File(filePath);
if (!await file.exists()) return null;
final paletteGenerator = await PaletteGenerator.fromImageProvider(
FileImage(file),
size: const Size(64, 64),
maximumColorCount: 8,
);
final color = paletteGenerator.dominantColor?.color ??
paletteGenerator.vibrantColor?.color ??
paletteGenerator.mutedColor?.color;
if (color != null) {
_colorCache[filePath] = color;
}
return color;
} catch (e) {
debugPrint('PaletteService file error: $e');
return null;
}
}
/// Clear the color cache
void clearCache() {
_colorCache.clear();
+8
View File
@@ -823,6 +823,14 @@ static Future<Map<String, dynamic>> downloadWithExtensions({
// ==================== LOCAL LIBRARY SCANNING ====================
/// Set the directory for caching extracted cover art
static Future<void> setLibraryCoverCacheDir(String cacheDir) async {
_log.i('setLibraryCoverCacheDir: $cacheDir');
await _channel.invokeMethod('setLibraryCoverCacheDir', {
'cache_dir': cacheDir,
});
}
/// Scan a folder for audio files and read their metadata
/// Returns a list of track metadata
static Future<List<Map<String, dynamic>>> scanLibraryFolder(String folderPath) async {