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 2026-02-03 21:51:40 +07:00
parent 9c22f41a3e
commit 72d45746a5
14 changed files with 3255 additions and 478 deletions

View file

@ -18,6 +18,34 @@
- Source badge on each item (Downloaded/Local) to identify the source
- Local Library items shown in a separate section when enabled
- Play button to open local library tracks directly
- **Selection mode works for both downloaded and local files**
- **Select All now selects all visible items (downloaded + local)**
- **Delete selected works for local library files** (removes from database and deletes file)
- **"All" count now includes local library items**
- **Local Library Albums in Albums Tab**: Local library albums now appear as clickable cards in the Albums filter
- Albums grid combines downloaded and local albums in a single unified grid
- Local albums display folder icon badge to distinguish from downloaded albums
- Tapping a local album opens the Local Album Screen with full track listing
- **Local Album Screen**: Dedicated screen for viewing local library album details
- Cover art display with dominant color extraction for header gradient
- Album info card with "Local" badge, track count, and quality info
- Track list with disc grouping support (multi-disc albums)
- Selection mode with delete functionality (removes files from storage and database)
- UI consistent with DownloadedAlbumScreen (Card + ListTile layout, same bottom bar)
- **Singles Filter with Local Library**: Singles filter now includes local library singles
- Shows tracks from albums with only 1 track (both downloaded and local)
- Search filter works across both sources
- Local items show "Local" badge
- **Cover Art Extraction for Local Library**: Embedded cover art is extracted and cached during scan
- Supports FLAC (PICTURE block), MP3 (APIC frames), Opus/Ogg (METADATA_BLOCK_PICTURE)
- Cover cached to app's cache directory with hash-based filenames
- Cache key includes file size + mtime to detect stale covers
- Cover art displayed in Library tab for local items
- **Dominant color extraction from local cover files** for album screen gradients
- **"Already in Library" Notification**: When downloading a track that already exists
- Shows "Already in Library" instead of "Download complete"
- Skips adding duplicate entry if track already in download history
- Reads actual quality from existing file (bit depth, sample rate)
- **Cloud Upload with WebDAV & SFTP**: Automatically upload downloaded files to your NAS or cloud storage
- Full WebDAV support (Synology DSM, Nextcloud, QNAP, ownCloud)
- Full SFTP support (any SSH server with SFTP enabled)
@ -42,6 +70,24 @@
- Extension file sandbox now validates paths using boundary-safe checks
- WebDAV now defaults to HTTPS; insecure HTTP requires explicit opt-in
- WebDAV error messages are now localized in the UI
- **Albums grid now unified**: Downloaded and local albums render in a single grid (no layout gaps)
- **LocalAlbumScreen UI consistency**: Track items, disc separators, and selection bottom bar now match DownloadedAlbumScreen
### Fixed
- **MP3 Metadata Parsing**: Improved ID3v2 handling (extended headers, unsync, footer, frame flags) for more reliable tag reads
- **Ogg/Opus Metadata Parsing**: Reassembled Ogg packets and detect stream type from headers for accurate tags/quality/cover extraction
- **Library Scan Metadata**: MP3 scans now include ISRC and disc number; release date prefers full TDRC/TYER when available
- **Cover Cache Robustness**: Cache key now includes file size + mtime to reduce stale cover art when files change in place
- **Cover Extraction Overhead**: Skip cover extraction for M4A during library scan to avoid guaranteed errors
- **Library Scan Thread Safety**: Cover cache directory reads/writes are now synchronized to avoid potential data races
- **Go Mobile Bind Compatibility**: Cover art helper functions are now unexported to avoid gomobile "too many return values" errors
- **Local Library Selection**: Selection checkbox now shows correctly for local library items in both list and grid views
- **Local Library Delete**: Local library files can now be selected and deleted (removes from database and deletes file)
- **Albums/Singles Count**: Filter chip counts now include local library items (albums with 2+ tracks, singles with 1 track)
- **Duplicate Download Detection**: Uses `already_exists` field from Go backend instead of file path prefix detection
- **Albums Tab Layout Gap**: Fixed issue where downloaded and local albums rendered in separate grids causing empty spaces
- **Singles Filter Missing Local Items**: Singles filter now correctly includes local library singles (not just downloaded)
---

View file

@ -893,6 +893,13 @@ class MainActivity: FlutterActivity() {
result.success(response)
}
// Local Library Scanning
"setLibraryCoverCacheDir" -> {
val cacheDir = call.argument<String>("cache_dir") ?: ""
withContext(Dispatchers.IO) {
Gobackend.setLibraryCoverCacheDirJSON(cacheDir)
}
result.success(null)
}
"scanLibraryFolder" -> {
val folderPath = call.argument<String>("folder_path") ?: ""
val response = withContext(Dispatchers.IO) {

1411
go_backend/audio_metadata.go Normal file

File diff suppressed because it is too large Load diff

View file

@ -2092,6 +2092,11 @@ func GetExtensionBrowseCategoriesJSON(extensionID string) (string, error) {
// ==================== LOCAL LIBRARY SCANNING ====================
// SetLibraryCoverCacheDirJSON sets the directory for caching extracted cover art
func SetLibraryCoverCacheDirJSON(cacheDir string) {
SetLibraryCoverCacheDir(cacheDir)
}
// ScanLibraryFolderJSON scans a folder for audio files and returns metadata
func ScanLibraryFolderJSON(folderPath string) (string, error) {
return ScanLibraryFolder(folderPath)

View file

@ -18,6 +18,7 @@ type LibraryScanResult struct {
AlbumName string `json:"albumName"`
AlbumArtist string `json:"albumArtist,omitempty"`
FilePath string `json:"filePath"`
CoverPath string `json:"coverPath,omitempty"`
ScannedAt string `json:"scannedAt"`
ISRC string `json:"isrc,omitempty"`
TrackNumber int `json:"trackNumber,omitempty"`
@ -45,6 +46,8 @@ var (
libraryScanProgressMu sync.RWMutex
libraryScanCancel chan struct{}
libraryScanCancelMu sync.Mutex
libraryCoverCacheDir string // Directory to cache extracted cover art
libraryCoverCacheMu sync.RWMutex
)
// supportedAudioFormats lists file extensions we can read metadata from
@ -56,6 +59,13 @@ var supportedAudioFormats = map[string]bool{
".ogg": true,
}
// SetLibraryCoverCacheDir sets the directory to cache extracted cover art
func SetLibraryCoverCacheDir(cacheDir string) {
libraryCoverCacheMu.Lock()
libraryCoverCacheDir = cacheDir
libraryCoverCacheMu.Unlock()
}
// ScanLibraryFolder scans a folder recursively for audio files and reads their metadata
// Returns JSON array of LibraryScanResult
func ScanLibraryFolder(folderPath string) (string, error) {
@ -183,6 +193,17 @@ func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
Format: strings.TrimPrefix(ext, "."),
}
// Try to extract and cache cover art
libraryCoverCacheMu.RLock()
coverCacheDir := libraryCoverCacheDir
libraryCoverCacheMu.RUnlock()
if coverCacheDir != "" && ext != ".m4a" {
coverPath, err := SaveCoverToCache(filePath, coverCacheDir)
if err == nil && coverPath != "" {
result.CoverPath = coverPath
}
}
// Try to read metadata based on format
switch ext {
case ".flac":
@ -257,14 +278,86 @@ func scanM4AFile(filePath string, result *LibraryScanResult) (*LibraryScanResult
// scanMP3File reads metadata from MP3 file (ID3 tags)
func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
// We don't have ID3 parsing in Go backend yet, use filename
return scanFromFilename(filePath, result)
metadata, err := ReadID3Tags(filePath)
if err != nil {
GoLog("[LibraryScan] ID3 read error for %s: %v\n", filePath, err)
return scanFromFilename(filePath, result)
}
result.TrackName = metadata.Title
result.ArtistName = metadata.Artist
result.AlbumName = metadata.Album
result.AlbumArtist = metadata.AlbumArtist
result.TrackNumber = metadata.TrackNumber
result.DiscNumber = metadata.DiscNumber
result.Genre = metadata.Genre
if metadata.Date != "" {
result.ReleaseDate = metadata.Date
} else {
result.ReleaseDate = metadata.Year
}
result.ISRC = metadata.ISRC
// Get audio quality info
quality, err := GetMP3Quality(filePath)
if err == nil {
result.SampleRate = quality.SampleRate
result.BitDepth = quality.BitDepth
result.Duration = quality.Duration
}
// Ensure we have at least a title
if result.TrackName == "" {
result.TrackName = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
}
if result.ArtistName == "" {
result.ArtistName = "Unknown Artist"
}
if result.AlbumName == "" {
result.AlbumName = "Unknown Album"
}
return result, nil
}
// scanOggFile reads metadata from Ogg Vorbis/Opus file
// scanOggFile reads metadata from Ogg Vorbis/Opus file (Vorbis comments)
func scanOggFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
// Limited support, use filename
return scanFromFilename(filePath, result)
metadata, err := ReadOggVorbisComments(filePath)
if err != nil {
GoLog("[LibraryScan] Ogg/Opus read error for %s: %v\n", filePath, err)
return scanFromFilename(filePath, result)
}
result.TrackName = metadata.Title
result.ArtistName = metadata.Artist
result.AlbumName = metadata.Album
result.AlbumArtist = metadata.AlbumArtist
result.ISRC = metadata.ISRC
result.TrackNumber = metadata.TrackNumber
result.DiscNumber = metadata.DiscNumber
result.Genre = metadata.Genre
result.ReleaseDate = metadata.Date
// Get audio quality info
quality, err := GetOggQuality(filePath)
if err == nil {
result.SampleRate = quality.SampleRate
result.BitDepth = quality.BitDepth
result.Duration = quality.Duration
}
// Ensure we have at least a title
if result.TrackName == "" {
result.TrackName = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
}
if result.ArtistName == "" {
result.ArtistName = "Unknown Artist"
}
if result.AlbumName == "" {
result.AlbumName = "Unknown Album"
}
return result, nil
}
// scanFromFilename extracts title/artist from filename pattern

View file

@ -688,6 +688,12 @@ import Gobackend // Import Go framework
return response
// Local Library Scanning
case "setLibraryCoverCacheDir":
let args = call.arguments as! [String: Any]
let cacheDir = args["cache_dir"] as! String
GobackendSetLibraryCoverCacheDirJSON(cacheDir)
return nil
case "scanLibraryFolder":
let args = call.arguments as! [String: Any]
let folderPath = args["folder_path"] as! String

View file

@ -2131,10 +2131,10 @@ result = await PlatformBridge.downloadWithExtensions(
if (result['success'] == true) {
var filePath = result['file_path'] as String?;
final wasExisting = filePath != null && filePath.startsWith('EXISTS:');
// Check if file already existed (detected via ISRC match in Go backend)
final wasExisting = result['already_exists'] == true;
if (wasExisting) {
filePath = filePath.substring(7); // Remove "EXISTS:" prefix
_log.i('Using existing file: $filePath');
_log.i('File already exists in library: $filePath');
}
_log.i('Download success, file: $filePath');
@ -2363,11 +2363,31 @@ result = await PlatformBridge.downloadWithExtensions(
_completedInSession++;
// Check if this track is already in download history
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
final existingInHistory = historyNotifier.getBySpotifyId(trackToDownload.id) ??
(trackToDownload.isrc != null ? historyNotifier.getByIsrc(trackToDownload.isrc!) : null);
if (wasExisting && existingInHistory != null) {
// File exists and is already in download history - skip adding
_log.i('Track already in library, skipping history update');
await _notificationService.showDownloadComplete(
trackName: item.track.name,
artistName: item.track.artistName,
completedCount: _completedInSession,
totalCount: _totalQueuedAtStart,
alreadyInLibrary: true,
);
removeItem(item.id);
return;
}
await _notificationService.showDownloadComplete(
trackName: item.track.name,
artistName: item.track.artistName,
completedCount: _completedInSession,
totalCount: _totalQueuedAtStart,
alreadyInLibrary: wasExisting,
);
if (filePath != null) {

View file

@ -1,5 +1,6 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart';
@ -145,6 +146,16 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
scanErrorCount: 0,
);
// Set cover cache directory before scanning
try {
final cacheDir = await getApplicationCacheDirectory();
final coverCacheDir = '${cacheDir.path}/library_covers';
await PlatformBridge.setLibraryCoverCacheDir(coverCacheDir);
_log.i('Cover cache directory set to: $coverCacheDir');
} catch (e) {
_log.w('Failed to set cover cache directory: $e');
}
// Start progress polling
_startProgressPolling();
@ -229,6 +240,14 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
_log.i('Library cleared');
}
/// Remove a single item from library by ID
Future<void> removeItem(String id) async {
await _db.delete(id);
state = state.copyWith(
items: state.items.where((item) => item.id != id).toList(),
);
}
/// Check if a track exists in library
bool existsInLibrary({String? isrc, String? trackName, String? artistName}) {
return state.existsInLibrary(

View file

@ -0,0 +1,735 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:open_filex/open_filex.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/mime_utils.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/palette_service.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
/// Screen to display tracks from a local library album
class LocalAlbumScreen extends ConsumerStatefulWidget {
final String albumName;
final String artistName;
final String? coverPath;
final List<LocalLibraryItem> tracks;
const LocalAlbumScreen({
super.key,
required this.albumName,
required this.artistName,
this.coverPath,
required this.tracks,
});
@override
ConsumerState<LocalAlbumScreen> createState() => _LocalAlbumScreenState();
}
class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
bool _isSelectionMode = false;
final Set<String> _selectedIds = {};
Color? _dominantColor;
bool _showTitleInAppBar = false;
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_extractDominantColor();
}
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
final shouldShow = _scrollController.offset > 280;
if (shouldShow != _showTitleInAppBar) {
setState(() => _showTitleInAppBar = shouldShow);
}
}
Future<void> _extractDominantColor() async {
if (widget.coverPath == null || widget.coverPath!.isEmpty) return;
// Extract color from local file
final color = await PaletteService.instance.extractDominantColorFromFile(widget.coverPath!);
if (mounted && color != null && color != _dominantColor) {
setState(() {
_dominantColor = color;
});
}
}
List<LocalLibraryItem> get _sortedTracks {
final tracks = List<LocalLibraryItem>.from(widget.tracks);
tracks.sort((a, b) {
// Sort by disc number first, then by track number
final aDisc = a.discNumber ?? 1;
final bDisc = b.discNumber ?? 1;
if (aDisc != bDisc) return aDisc.compareTo(bDisc);
final aNum = a.trackNumber ?? 999;
final bNum = b.trackNumber ?? 999;
if (aNum != bNum) return aNum.compareTo(bNum);
return a.trackName.compareTo(b.trackName);
});
return tracks;
}
Map<int, List<LocalLibraryItem>> _groupTracksByDisc(List<LocalLibraryItem> tracks) {
final discMap = <int, List<LocalLibraryItem>>{};
for (final track in tracks) {
final discNumber = track.discNumber ?? 1;
discMap.putIfAbsent(discNumber, () => []).add(track);
}
return discMap;
}
void _enterSelectionMode(String itemId) {
HapticFeedback.mediumImpact();
setState(() {
_isSelectionMode = true;
_selectedIds.add(itemId);
});
}
void _exitSelectionMode() {
setState(() {
_isSelectionMode = false;
_selectedIds.clear();
});
}
void _toggleSelection(String itemId) {
setState(() {
if (_selectedIds.contains(itemId)) {
_selectedIds.remove(itemId);
if (_selectedIds.isEmpty) {
_isSelectionMode = false;
}
} else {
_selectedIds.add(itemId);
}
});
}
void _selectAll(List<LocalLibraryItem> tracks) {
setState(() {
_selectedIds.addAll(tracks.map((e) => e.id));
});
}
Future<void> _deleteSelected(List<LocalLibraryItem> currentTracks) async {
final count = _selectedIds.length;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(context.l10n.downloadedAlbumDeleteSelected),
content: Text(context.l10n.downloadedAlbumDeleteMessage(count)),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
),
child: Text(context.l10n.dialogDelete),
),
],
),
);
if (confirmed == true && mounted) {
final libraryNotifier = ref.read(localLibraryProvider.notifier);
final idsToDelete = _selectedIds.toList();
int deletedCount = 0;
for (final id in idsToDelete) {
final item = currentTracks.where((e) => e.id == id).firstOrNull;
if (item != null) {
try {
final file = File(item.filePath);
if (await file.exists()) {
await file.delete();
}
} catch (_) {}
libraryNotifier.removeItem(id);
deletedCount++;
}
}
_exitSelectionMode();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarDeletedTracks(deletedCount))),
);
// Go back if all tracks were deleted
if (deletedCount == currentTracks.length) {
Navigator.pop(context);
}
}
}
}
Future<void> _openFile(String filePath) async {
try {
final mimeType = audioMimeTypeForPath(filePath);
await OpenFilex.open(filePath, type: mimeType);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarCannotOpenFile(e.toString()))),
);
}
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final bottomPadding = MediaQuery.of(context).padding.bottom;
final tracks = _sortedTracks;
// Show empty state if no tracks found
if (tracks.isEmpty) {
return Scaffold(
appBar: AppBar(
title: Text(widget.albumName),
),
body: const Center(
child: Text('No tracks found for this album'),
),
);
}
final validIds = tracks.map((t) => t.id).toSet();
_selectedIds.removeWhere((id) => !validIds.contains(id));
if (_selectedIds.isEmpty && _isSelectionMode) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() => _isSelectionMode = false);
});
}
return PopScope(
canPop: !_isSelectionMode,
onPopInvokedWithResult: (didPop, result) {
if (!didPop && _isSelectionMode) {
_exitSelectionMode();
}
},
child: Scaffold(
body: Stack(
children: [
CustomScrollView(
controller: _scrollController,
slivers: [
_buildAppBar(context, colorScheme),
_buildInfoCard(context, colorScheme, tracks),
_buildTrackListHeader(context, colorScheme, tracks),
_buildTrackList(context, colorScheme, tracks),
SliverToBoxAdapter(child: SizedBox(height: _isSelectionMode ? 120 : 32)),
],
),
AnimatedPositioned(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
left: 0,
right: 0,
bottom: _isSelectionMode ? 0 : -(200 + bottomPadding),
child: _buildSelectionBottomBar(context, colorScheme, tracks, bottomPadding),
),
],
),
),
);
}
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final screenWidth = MediaQuery.of(context).size.width;
final coverSize = screenWidth * 0.5;
final bgColor = _dominantColor ?? colorScheme.surface;
return SliverAppBar(
expandedHeight: 320,
pinned: true,
stretch: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
title: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: _showTitleInAppBar ? 1.0 : 0.0,
child: Text(
widget.albumName,
style: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.w600,
fontSize: 16,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight);
final showContent = collapseRatio > 0.3;
return FlexibleSpaceBar(
collapseMode: CollapseMode.none,
background: Stack(
fit: StackFit.expand,
children: [
// Background with dominant color
AnimatedContainer(
duration: const Duration(milliseconds: 500),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
bgColor,
bgColor.withValues(alpha: 0.8),
colorScheme.surface,
],
stops: const [0.0, 0.6, 1.0],
),
),
),
// Cover image centered
AnimatedOpacity(
duration: const Duration(milliseconds: 150),
opacity: showContent ? 1.0 : 0.0,
child: Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
child: Container(
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.4),
blurRadius: 30,
offset: const Offset(0, 15),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: widget.coverPath != null
? Image.file(
File(widget.coverPath!),
fit: BoxFit.cover,
cacheWidth: (coverSize * 2).toInt(),
errorBuilder: (context, error, stackTrace) =>
Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.album, size: 64, color: colorScheme.onSurfaceVariant),
),
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.album, size: 64, color: colorScheme.onSurfaceVariant),
),
),
),
),
),
),
],
),
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
);
},
),
leading: IconButton(
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(color: colorScheme.surface.withValues(alpha: 0.8), shape: BoxShape.circle),
child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
),
onPressed: () => Navigator.pop(context),
),
);
}
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme, List<LocalLibraryItem> tracks) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Card(
elevation: 0,
color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.albumName,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface),
),
const SizedBox(height: 4),
Text(
widget.artistName,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: colorScheme.onSurfaceVariant),
),
const SizedBox(height: 12),
Row(
children: [
// "Local" badge
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(color: colorScheme.tertiaryContainer, borderRadius: BorderRadius.circular(20)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.folder, size: 14, color: colorScheme.onTertiaryContainer),
const SizedBox(width: 4),
Text('Local', style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
],
),
),
const SizedBox(width: 8),
// Track count
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(20)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.music_note, size: 14, color: colorScheme.onSurfaceVariant),
const SizedBox(width: 4),
Text('${tracks.length} tracks', style: TextStyle(color: colorScheme.onSurfaceVariant, fontWeight: FontWeight.w600, fontSize: 12)),
],
),
),
const SizedBox(width: 8),
// Quality badge if all tracks have the same quality
if (_getCommonQuality(tracks) != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: _getCommonQuality(tracks)!.contains('24')
? colorScheme.primaryContainer
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: Text(
_getCommonQuality(tracks)!,
style: TextStyle(
color: _getCommonQuality(tracks)!.contains('24')
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
),
],
),
],
),
),
),
),
);
}
String? _getCommonQuality(List<LocalLibraryItem> tracks) {
if (tracks.isEmpty) return null;
final first = tracks.first;
if (first.bitDepth == null || first.sampleRate == null) return null;
final firstQuality = '${first.bitDepth}/${(first.sampleRate! / 1000).round()}kHz';
for (final track in tracks) {
if (track.bitDepth != first.bitDepth || track.sampleRate != first.sampleRate) {
return null;
}
}
return firstQuality;
}
Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme, List<LocalLibraryItem> tracks) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
child: Row(
children: [
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
const SizedBox(width: 8),
Text(context.l10n.downloadedAlbumTracksHeader, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
const Spacer(),
if (!_isSelectionMode)
TextButton.icon(
onPressed: tracks.isNotEmpty ? () => _enterSelectionMode(tracks.first.id) : null,
icon: const Icon(Icons.checklist, size: 18),
label: Text(context.l10n.actionSelect),
style: TextButton.styleFrom(visualDensity: VisualDensity.compact),
),
],
),
),
);
}
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List<LocalLibraryItem> tracks) {
final discGroups = _groupTracksByDisc(tracks);
final hasMultipleDiscs = discGroups.length > 1;
final slivers = <Widget>[];
final sortedDiscNumbers = discGroups.keys.toList()..sort();
for (final discNumber in sortedDiscNumbers) {
final discTracks = discGroups[discNumber]!;
if (hasMultipleDiscs) {
slivers.add(
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(16),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.album, size: 16, color: colorScheme.onSecondaryContainer),
const SizedBox(width: 6),
Text(
context.l10n.downloadedAlbumDiscHeader(discNumber),
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w600,
),
),
],
),
),
const SizedBox(width: 12),
Expanded(
child: Container(
height: 1,
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
),
),
],
),
),
),
);
}
slivers.add(
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => _buildTrackItem(context, colorScheme, discTracks[index]),
childCount: discTracks.length,
),
),
);
}
return SliverMainAxisGroup(slivers: slivers);
}
Widget _buildTrackItem(BuildContext context, ColorScheme colorScheme, LocalLibraryItem track) {
final isSelected = _selectedIds.contains(track.id);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Card(
elevation: 0,
color: isSelected ? colorScheme.primaryContainer.withValues(alpha: 0.3) : Colors.transparent,
margin: const EdgeInsets.symmetric(vertical: 2),
child: ListTile(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
onTap: _isSelectionMode
? () => _toggleSelection(track.id)
: () => _openFile(track.filePath),
onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(track.id),
leading: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_isSelectionMode) ...[
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: isSelected ? colorScheme.primary : Colors.transparent,
shape: BoxShape.circle,
border: Border.all(color: isSelected ? colorScheme.primary : colorScheme.outline, width: 2),
),
child: isSelected
? Icon(Icons.check, color: colorScheme.onPrimary, size: 16)
: null,
),
const SizedBox(width: 12),
],
SizedBox(
width: 24,
child: Text(
track.trackNumber?.toString() ?? '-',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
),
],
),
title: Text(
track.trackName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
),
subtitle: Row(
children: [
Flexible(
child: Text(
track.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
),
if (track.format != null) ...[
Text('', style: TextStyle(color: colorScheme.onSurfaceVariant, fontSize: 12)),
Text(
track.format!.toUpperCase(),
style: TextStyle(color: colorScheme.onSurfaceVariant, fontSize: 12),
),
],
],
),
trailing: _isSelectionMode ? null : IconButton(
onPressed: () => _openFile(track.filePath),
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
style: IconButton.styleFrom(
backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3),
),
),
),
),
);
}
Widget _buildSelectionBottomBar(BuildContext context, ColorScheme colorScheme, List<LocalLibraryItem> tracks, double bottomPadding) {
final selectedCount = _selectedIds.length;
final allSelected = selectedCount == tracks.length && tracks.isNotEmpty;
return Container(
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHigh,
borderRadius: const BorderRadius.vertical(top: Radius.circular(28)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.15),
blurRadius: 12,
offset: const Offset(0, -4),
),
],
),
child: SafeArea(
top: false,
child: Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, bottomPadding > 0 ? 8 : 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 32,
height: 4,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: colorScheme.outlineVariant,
borderRadius: BorderRadius.circular(2),
),
),
Row(
children: [
IconButton.filledTonal(
onPressed: _exitSelectionMode,
icon: const Icon(Icons.close),
style: IconButton.styleFrom(
backgroundColor: colorScheme.surfaceContainerHighest,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.downloadedAlbumSelectedCount(selectedCount),
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
Text(
allSelected ? context.l10n.downloadedAlbumAllSelected : context.l10n.downloadedAlbumTapToSelect,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
),
],
),
),
TextButton.icon(
onPressed: () {
if (allSelected) {
_exitSelectionMode();
} else {
_selectAll(tracks);
}
},
icon: Icon(allSelected ? Icons.deselect : Icons.select_all, size: 20),
label: Text(allSelected ? context.l10n.actionDeselect : context.l10n.actionSelectAll),
style: TextButton.styleFrom(foregroundColor: colorScheme.primary),
),
],
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: selectedCount > 0 ? () => _deleteSelected(tracks) : null,
icon: const Icon(Icons.delete_outline),
label: Text(
selectedCount > 0
? context.l10n.downloadedAlbumDeleteCount(selectedCount)
: context.l10n.downloadedAlbumSelectToDelete,
),
style: FilledButton.styleFrom(
backgroundColor: selectedCount > 0 ? colorScheme.error : colorScheme.surfaceContainerHighest,
foregroundColor: selectedCount > 0 ? colorScheme.onError : colorScheme.onSurfaceVariant,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
),
),
],
),
),
),
);
}
}

File diff suppressed because it is too large Load diff

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;

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,

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

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 {