mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-17 16:37:17 +02:00
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:
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+831
-465
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user