Files
SpotiFLAC-Mobile/lib/screens/queue_tab.dart
T

3392 lines
118 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
import 'package:spotiflac_android/screens/local_album_screen.dart';
/// Represents the source of a library item
enum LibraryItemSource { downloaded, local }
/// Unified library item that can come from download history or local library
class UnifiedLibraryItem {
final String id;
final String trackName;
final String artistName;
final String albumName;
final String? coverUrl;
final String? localCoverPath;
final String filePath;
final String? quality;
final DateTime addedAt;
final LibraryItemSource source;
final DownloadHistoryItem? historyItem;
final LocalLibraryItem? localItem;
UnifiedLibraryItem({
required this.id,
required this.trackName,
required this.artistName,
required this.albumName,
this.coverUrl,
this.localCoverPath,
required this.filePath,
this.quality,
required this.addedAt,
required this.source,
this.historyItem,
this.localItem,
});
factory UnifiedLibraryItem.fromDownloadHistory(DownloadHistoryItem item) {
return UnifiedLibraryItem(
id: 'dl_${item.id}',
trackName: item.trackName,
artistName: item.artistName,
albumName: item.albumName,
coverUrl: item.coverUrl,
filePath: item.filePath,
quality: item.quality,
addedAt: item.downloadedAt,
source: LibraryItemSource.downloaded,
historyItem: item,
);
}
factory UnifiedLibraryItem.fromLocalLibrary(LocalLibraryItem item) {
String? quality;
if (item.bitDepth != null && item.sampleRate != null) {
quality =
'${item.bitDepth}bit/${(item.sampleRate! / 1000).toStringAsFixed(1)}kHz';
}
return UnifiedLibraryItem(
id: 'local_${item.id}',
trackName: item.trackName,
artistName: item.artistName,
albumName: item.albumName,
coverUrl: null, // Local library doesn't have cover URLs
localCoverPath: item.coverPath, // Use extracted cover path
filePath: item.filePath,
quality: quality,
addedAt: item.scannedAt,
source: LibraryItemSource.local,
localItem: item,
);
}
/// Returns true if this item has a cover (either URL or local path)
bool get hasCover =>
coverUrl != null ||
(localCoverPath != null && localCoverPath!.isNotEmpty);
String get searchKey =>
'${trackName.toLowerCase()}|${artistName.toLowerCase()}|${albumName.toLowerCase()}';
String get albumKey =>
'${albumName.toLowerCase()}|${artistName.toLowerCase()}';
}
class _GroupedAlbum {
final String albumName;
final String artistName;
final String? coverUrl;
final List<DownloadHistoryItem> tracks;
final DateTime latestDownload;
final String searchKey;
_GroupedAlbum({
required this.albumName,
required this.artistName,
this.coverUrl,
required this.tracks,
required this.latestDownload,
}) : searchKey = '${albumName.toLowerCase()}|${artistName.toLowerCase()}';
String get key => '$albumName|$artistName';
}
/// Grouped album from local library
class _GroupedLocalAlbum {
final String albumName;
final String artistName;
final String? coverPath; // Local cover file path
final List<LocalLibraryItem> tracks;
final DateTime latestScanned;
final String searchKey;
_GroupedLocalAlbum({
required this.albumName,
required this.artistName,
this.coverPath,
required this.tracks,
required this.latestScanned,
}) : searchKey = '${albumName.toLowerCase()}|${artistName.toLowerCase()}';
String get key => '$albumName|$artistName';
}
class _HistoryStats {
final Map<String, int> albumCounts;
final Map<String, int> localAlbumCounts; // For identifying local singles
final List<_GroupedAlbum> groupedAlbums;
final List<_GroupedLocalAlbum> groupedLocalAlbums; // Local library albums
final int albumCount;
final int singleTracks;
// Local library stats
final int localAlbumCount;
final int localSingleTracks;
const _HistoryStats({
required this.albumCounts,
this.localAlbumCounts = const {},
required this.groupedAlbums,
this.groupedLocalAlbums = const [],
required this.albumCount,
required this.singleTracks,
this.localAlbumCount = 0,
this.localSingleTracks = 0,
});
/// Total album count including local library
int get totalAlbumCount => albumCount + localAlbumCount;
/// Total singles count including local library
int get totalSingleTracks => singleTracks + localSingleTracks;
}
class _UnifiedCacheEntry {
final List<DownloadHistoryItem> historyItems;
final List<LocalLibraryItem> localItems;
final Map<String, int> localAlbumCounts;
final String query;
final List<UnifiedLibraryItem> items;
const _UnifiedCacheEntry({
required this.historyItems,
required this.localItems,
required this.localAlbumCounts,
required this.query,
required this.items,
});
}
Map<String, List<String>> _filterHistoryInIsolate(Map<String, Object> payload) {
final entries = (payload['entries'] as List).cast<List>();
final albumCounts = (payload['albumCounts'] as Map).cast<String, int>();
final query = (payload['query'] as String?) ?? '';
final allIds = <String>[];
final albumIds = <String>[];
final singleIds = <String>[];
for (final entry in entries) {
final id = entry[0] as String;
final albumKey = entry[1] as String;
final searchKey = entry[2] as String;
if (query.isNotEmpty && !searchKey.contains(query)) {
continue;
}
allIds.add(id);
final count = albumCounts[albumKey] ?? 0;
if (count > 1) {
albumIds.add(id);
} else if (count == 1) {
singleIds.add(id);
}
}
return {'all': allIds, 'albums': albumIds, 'singles': singleIds};
}
class QueueTab extends ConsumerStatefulWidget {
final PageController? parentPageController;
final int parentPageIndex;
final int? nextPageIndex;
const QueueTab({
super.key,
this.parentPageController,
this.parentPageIndex = 1,
this.nextPageIndex,
});
@override
ConsumerState<QueueTab> createState() => _QueueTabState();
}
class _QueueTabState extends ConsumerState<QueueTab> {
final Map<String, bool> _fileExistsCache = {};
final Set<String> _pendingChecks = {};
static const int _maxCacheSize = 500;
bool _fileExistsUpdateScheduled = false;
bool _isSelectionMode = false;
final Set<String> _selectedIds = {};
PageController? _filterPageController;
final List<String> _filterModes = ['all', 'albums', 'singles'];
bool _isPageControllerInitialized = false;
static const List<String> _months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
final TextEditingController _searchController = TextEditingController();
final FocusNode _searchFocusNode = FocusNode();
String _searchQuery = '';
Timer? _searchDebounce;
List<DownloadHistoryItem>? _historyItemsCache;
List<LocalLibraryItem>? _localLibraryItemsCache;
_HistoryStats? _historyStatsCache;
final Map<String, String> _searchIndexCache = {};
final Map<String, String> _localSearchIndexCache = {};
Map<String, DownloadHistoryItem> _historyItemsById = {};
List<List<String>> _historyFilterEntries = const [];
Map<String, List<DownloadHistoryItem>> _filteredHistoryCache = const {};
List<DownloadHistoryItem>? _filterItemsCache;
String _filterQueryCache = '';
bool _filterRefreshScheduled = false;
bool _isFilteringHistory = false;
int _filterRequestId = 0;
static const int _filterIsolateThreshold = 800;
List<LocalLibraryItem>? _localFilterItemsCache;
String _localFilterQueryCache = '';
List<LocalLibraryItem> _filteredLocalItemsCache = const [];
final Map<String, _UnifiedCacheEntry> _unifiedItemsCache = {};
// Advanced filters
String? _filterSource; // null = all, 'downloaded', 'local'
String? _filterQuality; // null = all, 'hires', 'cd', 'lossy'
String? _filterFormat; // null = all, 'flac', 'mp3', 'm4a', 'opus', 'ogg'
String? _filterDateRange; // null = all, 'today', 'week', 'month', 'year'
@override
void initState() {
super.initState();
}
void _initializePageController() {
if (_isPageControllerInitialized) return;
_isPageControllerInitialized = true;
final currentFilter = ref.read(settingsProvider).historyFilterMode;
final initialPage = _filterModes.indexOf(currentFilter).clamp(0, 2);
_filterPageController = PageController(initialPage: initialPage);
}
@override
void dispose() {
_filterPageController?.dispose();
_searchController.dispose();
_searchFocusNode.dispose();
_searchDebounce?.cancel();
super.dispose();
}
void _onSearchChanged(String value) {
_searchDebounce?.cancel();
final normalized = value.trim().toLowerCase();
_searchDebounce = Timer(const Duration(milliseconds: 180), () {
if (!mounted || _searchQuery == normalized) return;
setState(() => _searchQuery = normalized);
_requestFilterRefresh();
});
}
void _clearSearch() {
_searchDebounce?.cancel();
if (_searchQuery.isEmpty) return;
setState(() => _searchQuery = '');
_requestFilterRefresh();
}
void _ensureHistoryCaches(
List<DownloadHistoryItem> items,
List<LocalLibraryItem> localItems,
) {
final historyChanged = !identical(items, _historyItemsCache);
final localChanged = !identical(localItems, _localLibraryItemsCache);
if (!historyChanged && !localChanged) return;
_historyItemsCache = items;
_localLibraryItemsCache = localItems;
_historyStatsCache = _buildHistoryStats(items, localItems);
_searchIndexCache
..clear()
..addEntries(
items.map((item) => MapEntry(item.id, _buildSearchKey(item))),
);
if (localChanged) {
_localSearchIndexCache
..clear()
..addEntries(
localItems.map(
(item) => MapEntry(item.id, _buildLocalSearchKey(item)),
),
);
_localFilterItemsCache = null;
_localFilterQueryCache = '';
_filteredLocalItemsCache = const [];
}
_unifiedItemsCache.clear();
_historyItemsById = {for (final item in items) item.id: item};
_historyFilterEntries = List<List<String>>.generate(items.length, (index) {
final item = items[index];
final searchKey = _searchIndexCache[item.id] ?? _buildSearchKey(item);
final albumKey =
'${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}';
return [item.id, albumKey, searchKey];
}, growable: false);
_requestFilterRefresh();
}
String _buildSearchKey(DownloadHistoryItem item) {
return '${item.trackName} ${item.artistName} ${item.albumName}'
.toLowerCase();
}
String _buildLocalSearchKey(LocalLibraryItem item) {
return '${item.trackName} ${item.artistName} ${item.albumName}'
.toLowerCase();
}
List<LocalLibraryItem> _filterLocalItems(
List<LocalLibraryItem> items,
String query,
) {
if (query.isEmpty) return items;
if (identical(items, _localFilterItemsCache) &&
query == _localFilterQueryCache) {
return _filteredLocalItemsCache;
}
final filtered = items
.where((item) {
final searchKey =
_localSearchIndexCache[item.id] ?? _buildLocalSearchKey(item);
if (!_localSearchIndexCache.containsKey(item.id)) {
_localSearchIndexCache[item.id] = searchKey;
}
return searchKey.contains(query);
})
.toList(growable: false);
_localFilterItemsCache = items;
_localFilterQueryCache = query;
_filteredLocalItemsCache = filtered;
return filtered;
}
bool _isFilterCacheValid(List<DownloadHistoryItem> items, String query) {
return identical(items, _filterItemsCache) && query == _filterQueryCache;
}
void _requestFilterRefresh() {
if (_filterRefreshScheduled) return;
_filterRefreshScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_filterRefreshScheduled = false;
if (!mounted) return;
_scheduleHistoryFilterUpdate();
});
}
void _scheduleHistoryFilterUpdate() {
final items = _historyItemsCache;
if (items == null) return;
final query = _searchQuery;
if (_isFilterCacheValid(items, query)) return;
final albumCounts =
_historyStatsCache?.albumCounts ?? const <String, int>{};
if (items.isEmpty) {
setState(() {
_filteredHistoryCache = const {};
_filterItemsCache = items;
_filterQueryCache = query;
_isFilteringHistory = false;
});
return;
}
if (items.length <= _filterIsolateThreshold) {
final filteredAll = _applyHistorySearchFilter(items, query);
final filteredAlbums = _filterHistoryByAlbumCount(
filteredAll,
albumCounts,
2,
);
final filteredSingles = _filterHistoryByAlbumCount(
filteredAll,
albumCounts,
1,
);
setState(() {
_filteredHistoryCache = {
'all': filteredAll,
'albums': filteredAlbums,
'singles': filteredSingles,
};
_filterItemsCache = items;
_filterQueryCache = query;
_isFilteringHistory = false;
});
return;
}
if (!_isFilteringHistory) {
setState(() => _isFilteringHistory = true);
}
final requestId = ++_filterRequestId;
final payload = <String, Object>{
'entries': _historyFilterEntries,
'albumCounts': albumCounts,
'query': query,
};
compute(_filterHistoryInIsolate, payload).then((result) {
if (!mounted || requestId != _filterRequestId) return;
final itemsById = _historyItemsById;
final filtered = <String, List<DownloadHistoryItem>>{};
for (final entry in result.entries) {
filtered[entry.key] = entry.value
.map((id) => itemsById[id])
.whereType<DownloadHistoryItem>()
.toList(growable: false);
}
setState(() {
_filteredHistoryCache = filtered;
_filterItemsCache = items;
_filterQueryCache = query;
_isFilteringHistory = false;
});
});
}
List<DownloadHistoryItem> _resolveHistoryItems({
required String filterMode,
required List<DownloadHistoryItem> allHistoryItems,
required Map<String, int> albumCounts,
}) {
final query = _searchQuery;
if (_isFilterCacheValid(allHistoryItems, query)) {
final cached = _filteredHistoryCache[filterMode];
if (cached != null) return cached;
}
if (allHistoryItems.isEmpty) return const [];
if (query.isEmpty && filterMode == 'all') return allHistoryItems;
if (allHistoryItems.length <= _filterIsolateThreshold) {
return _filterHistoryItems(
allHistoryItems,
filterMode,
albumCounts,
query,
);
}
return const [];
}
List<DownloadHistoryItem> _applyHistorySearchFilter(
List<DownloadHistoryItem> items,
String searchQuery,
) {
if (searchQuery.isEmpty) return items;
final query = searchQuery;
return items
.where((item) {
final searchKey = _searchIndexCache[item.id] ?? _buildSearchKey(item);
if (!_searchIndexCache.containsKey(item.id)) {
_searchIndexCache[item.id] = searchKey;
}
return searchKey.contains(query);
})
.toList(growable: false);
}
List<DownloadHistoryItem> _filterHistoryByAlbumCount(
List<DownloadHistoryItem> items,
Map<String, int> albumCounts,
int targetCount,
) {
return items
.where((item) {
final key =
'${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}';
final count = albumCounts[key] ?? 0;
return targetCount == 1 ? count == 1 : count >= targetCount;
})
.toList(growable: false);
}
bool _shouldShowFilteringIndicator({
required List<DownloadHistoryItem> allHistoryItems,
required String filterMode,
}) {
if (allHistoryItems.isEmpty) return false;
if (_searchQuery.isEmpty && filterMode == 'all') return false;
if (allHistoryItems.length <= _filterIsolateThreshold) return false;
return !_isFilterCacheValid(allHistoryItems, _searchQuery) ||
_isFilteringHistory;
}
void _onFilterPageChanged(int index) {
final filterMode = _filterModes[index];
ref.read(settingsProvider.notifier).setHistoryFilterMode(filterMode);
}
void _animateToFilterPage(int index) {
_filterPageController?.animateToPage(
index,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
);
}
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<UnifiedLibraryItem> items) {
setState(() {
_selectedIds.addAll(items.map((e) => e.id));
});
}
String _getQualityBadgeText(String quality) {
if (quality.contains('bit')) {
return quality.split('/').first;
}
final bitrateMatch = RegExp(r'(\d+)kbps').firstMatch(quality);
if (bitrateMatch != null) {
return '${bitrateMatch.group(1)}k';
}
return quality.split(' ').first;
}
Future<void> _deleteSelected(List<UnifiedLibraryItem> allItems) async {
final count = _selectedIds.length;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(context.l10n.dialogDeleteSelectedTitle),
content: Text(context.l10n.dialogDeleteSelectedMessage(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 historyNotifier = ref.read(downloadHistoryProvider.notifier);
final localLibraryDb = LibraryDatabase.instance;
int deletedCount = 0;
for (final id in _selectedIds) {
final item = allItems.where((e) => e.id == id).firstOrNull;
if (item != null) {
try {
final cleanPath = _cleanFilePath(item.filePath);
await deleteFile(cleanPath);
} catch (_) {}
// Remove from appropriate database
if (item.source == LibraryItemSource.downloaded) {
historyNotifier.removeFromHistory(item.historyItem!.id);
} else {
// Remove from local library database
await localLibraryDb.deleteByPath(item.filePath);
}
deletedCount++;
}
}
// Reload local library if we deleted any local items
if (allItems.any(
(i) =>
_selectedIds.contains(i.id) && i.source == LibraryItemSource.local,
)) {
ref.read(localLibraryProvider.notifier).reloadFromStorage();
}
_exitSelectionMode();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarDeletedTracks(deletedCount)),
),
);
}
}
}
/// Strip EXISTS: prefix from file path (legacy history items)
String _cleanFilePath(String? filePath) {
if (filePath == null) return '';
if (filePath.startsWith('EXISTS:')) {
return filePath.substring(7);
}
return filePath;
}
bool _checkFileExists(String? filePath) {
if (filePath == null) return false;
final cleanPath = _cleanFilePath(filePath);
if (cleanPath.isEmpty) return false;
if (_fileExistsCache.containsKey(cleanPath)) {
return _fileExistsCache[cleanPath]!;
}
if (_pendingChecks.contains(cleanPath)) {
return true;
}
if (_fileExistsCache.length >= _maxCacheSize) {
_fileExistsCache.remove(_fileExistsCache.keys.first);
}
_pendingChecks.add(cleanPath);
Future.microtask(() async {
final exists = await fileExists(cleanPath);
_pendingChecks.remove(cleanPath);
final previous = _fileExistsCache[cleanPath];
_fileExistsCache[cleanPath] = exists;
if (mounted && previous != exists) {
_scheduleFileExistsUpdate();
}
});
return true;
}
void _scheduleFileExistsUpdate() {
if (_fileExistsUpdateScheduled) return;
_fileExistsUpdateScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) {
_fileExistsUpdateScheduled = false;
return;
}
_fileExistsUpdateScheduled = false;
setState(() {});
});
}
int get _activeFilterCount {
int count = 0;
if (_filterSource != null) count++;
if (_filterQuality != null) count++;
if (_filterFormat != null) count++;
if (_filterDateRange != null) count++;
return count;
}
void _resetFilters() {
setState(() {
_filterSource = null;
_filterQuality = null;
_filterFormat = null;
_filterDateRange = null;
_unifiedItemsCache.clear();
});
}
List<UnifiedLibraryItem> _applyAdvancedFilters(
List<UnifiedLibraryItem> items,
) {
if (_activeFilterCount == 0) return items;
return items
.where((item) {
if (_filterSource != null) {
if (_filterSource == 'downloaded' &&
item.source != LibraryItemSource.downloaded) {
return false;
}
if (_filterSource == 'local' &&
item.source != LibraryItemSource.local) {
return false;
}
}
if (_filterQuality != null && item.quality != null) {
final quality = item.quality!.toLowerCase();
switch (_filterQuality) {
case 'hires':
if (!quality.startsWith('24')) return false;
case 'cd':
if (!quality.startsWith('16')) return false;
case 'lossy':
if (quality.startsWith('24') || quality.startsWith('16')) {
return false;
}
}
} else if (_filterQuality != null && item.quality == null) {
if (_filterQuality != 'lossy') return false;
}
if (_filterFormat != null) {
final ext = item.filePath.split('.').last.toLowerCase();
if (ext != _filterFormat) return false;
}
if (_filterDateRange != null) {
final now = DateTime.now();
final itemDate = item.addedAt;
switch (_filterDateRange) {
case 'today':
if (itemDate.year != now.year ||
itemDate.month != now.month ||
itemDate.day != now.day) {
return false;
}
case 'week':
final weekAgo = now.subtract(const Duration(days: 7));
if (itemDate.isBefore(weekAgo)) return false;
case 'month':
final monthAgo = DateTime(now.year, now.month - 1, now.day);
if (itemDate.isBefore(monthAgo)) return false;
case 'year':
if (itemDate.year != now.year) return false;
}
}
return true;
})
.toList(growable: false);
}
Set<String> _getAvailableFormats(List<UnifiedLibraryItem> items) {
final formats = <String>{};
for (final item in items) {
final ext = item.filePath.split('.').last.toLowerCase();
if (['flac', 'mp3', 'm4a', 'opus', 'ogg', 'wav', 'aiff'].contains(ext)) {
formats.add(ext);
}
}
return formats;
}
void _showFilterSheet(
BuildContext context,
List<UnifiedLibraryItem> allItems,
) {
final colorScheme = Theme.of(context).colorScheme;
final availableFormats = _getAvailableFormats(allItems);
String? tempSource = _filterSource;
String? tempQuality = _filterQuality;
String? tempFormat = _filterFormat;
String? tempDateRange = _filterDateRange;
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: colorScheme.surfaceContainerLow,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (context) => StatefulBuilder(
builder: (context, setSheetState) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 32,
height: 4,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: colorScheme.outlineVariant,
borderRadius: BorderRadius.circular(2),
),
),
),
Row(
children: [
Text(
context.l10n.libraryFilterTitle,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const Spacer(),
TextButton(
onPressed: () {
setSheetState(() {
tempSource = null;
tempQuality = null;
tempFormat = null;
tempDateRange = null;
});
},
child: Text(context.l10n.libraryFilterReset),
),
],
),
const SizedBox(height: 16),
Text(
context.l10n.libraryFilterSource,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: [
FilterChip(
label: Text(context.l10n.libraryFilterAll),
selected: tempSource == null,
onSelected: (_) =>
setSheetState(() => tempSource = null),
),
FilterChip(
label: Text(context.l10n.libraryFilterDownloaded),
selected: tempSource == 'downloaded',
onSelected: (_) =>
setSheetState(() => tempSource = 'downloaded'),
),
FilterChip(
label: Text(context.l10n.libraryFilterLocal),
selected: tempSource == 'local',
onSelected: (_) =>
setSheetState(() => tempSource = 'local'),
),
],
),
const SizedBox(height: 16),
Text(
context.l10n.libraryFilterQuality,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: [
FilterChip(
label: Text(context.l10n.libraryFilterAll),
selected: tempQuality == null,
onSelected: (_) =>
setSheetState(() => tempQuality = null),
),
FilterChip(
label: Text(context.l10n.libraryFilterQualityHiRes),
selected: tempQuality == 'hires',
onSelected: (_) =>
setSheetState(() => tempQuality = 'hires'),
),
FilterChip(
label: Text(context.l10n.libraryFilterQualityCD),
selected: tempQuality == 'cd',
onSelected: (_) =>
setSheetState(() => tempQuality = 'cd'),
),
FilterChip(
label: Text(context.l10n.libraryFilterQualityLossy),
selected: tempQuality == 'lossy',
onSelected: (_) =>
setSheetState(() => tempQuality = 'lossy'),
),
],
),
const SizedBox(height: 16),
Text(
context.l10n.libraryFilterFormat,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: [
FilterChip(
label: Text(context.l10n.libraryFilterAll),
selected: tempFormat == null,
onSelected: (_) =>
setSheetState(() => tempFormat = null),
),
for (final format in availableFormats.toList()..sort())
FilterChip(
label: Text(format.toUpperCase()),
selected: tempFormat == format,
onSelected: (_) =>
setSheetState(() => tempFormat = format),
),
],
),
const SizedBox(height: 16),
Text(
context.l10n.libraryFilterDate,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: [
FilterChip(
label: Text(context.l10n.libraryFilterAll),
selected: tempDateRange == null,
onSelected: (_) =>
setSheetState(() => tempDateRange = null),
),
FilterChip(
label: Text(context.l10n.libraryFilterDateToday),
selected: tempDateRange == 'today',
onSelected: (_) =>
setSheetState(() => tempDateRange = 'today'),
),
FilterChip(
label: Text(context.l10n.libraryFilterDateWeek),
selected: tempDateRange == 'week',
onSelected: (_) =>
setSheetState(() => tempDateRange = 'week'),
),
FilterChip(
label: Text(context.l10n.libraryFilterDateMonth),
selected: tempDateRange == 'month',
onSelected: (_) =>
setSheetState(() => tempDateRange = 'month'),
),
FilterChip(
label: Text(context.l10n.libraryFilterDateYear),
selected: tempDateRange == 'year',
onSelected: (_) =>
setSheetState(() => tempDateRange = 'year'),
),
],
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () {
setState(() {
_filterSource = tempSource;
_filterQuality = tempQuality;
_filterFormat = tempFormat;
_filterDateRange = tempDateRange;
_unifiedItemsCache.clear();
});
Navigator.pop(context);
},
child: Text(context.l10n.libraryFilterApply),
),
),
],
),
),
);
},
),
);
}
Future<void> _openFile(String filePath) async {
final cleanPath = _cleanFilePath(filePath);
try {
await openFile(cleanPath);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarCannotOpenFile(e.toString())),
),
);
}
}
}
void _precacheCover(String? url) {
if (url == null || url.isEmpty) return;
if (!url.startsWith('http://') && !url.startsWith('https://')) {
return;
}
precacheImage(
CachedNetworkImageProvider(url, cacheManager: CoverCacheManager.instance),
context,
);
}
void _navigateToMetadataScreen(DownloadItem item) {
final historyItem = ref
.read(downloadHistoryProvider)
.items
.firstWhere(
(h) => h.filePath == item.filePath,
orElse: () => DownloadHistoryItem(
id: item.id,
trackName: item.track.name,
artistName: item.track.artistName,
albumName: item.track.albumName,
coverUrl: item.track.coverUrl,
filePath: item.filePath ?? '',
downloadedAt: DateTime.now(),
service: item.service,
),
);
_precacheCover(historyItem.coverUrl);
_searchFocusNode.unfocus();
Navigator.push(
context,
PageRouteBuilder(
transitionDuration: const Duration(milliseconds: 300),
reverseTransitionDuration: const Duration(milliseconds: 250),
pageBuilder: (context, animation, secondaryAnimation) =>
TrackMetadataScreen(item: historyItem),
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(opacity: animation, child: child),
),
).then((_) => _searchFocusNode.unfocus());
}
void _navigateToHistoryMetadataScreen(DownloadHistoryItem item) {
_precacheCover(item.coverUrl);
_searchFocusNode.unfocus();
Navigator.push(
context,
PageRouteBuilder(
transitionDuration: const Duration(milliseconds: 300),
reverseTransitionDuration: const Duration(milliseconds: 250),
pageBuilder: (context, animation, secondaryAnimation) =>
TrackMetadataScreen(item: item),
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(opacity: animation, child: child),
),
).then((_) => _searchFocusNode.unfocus());
}
void _navigateToLocalMetadataScreen(LocalLibraryItem item) {
_searchFocusNode.unfocus();
Navigator.push(
context,
PageRouteBuilder(
transitionDuration: const Duration(milliseconds: 300),
reverseTransitionDuration: const Duration(milliseconds: 250),
pageBuilder: (context, animation, secondaryAnimation) =>
TrackMetadataScreen(localItem: item),
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(opacity: animation, child: child),
),
).then((_) => _searchFocusNode.unfocus());
}
List<DownloadHistoryItem> _filterHistoryItems(
List<DownloadHistoryItem> items,
String filterMode,
Map<String, int> albumCounts, [
String searchQuery = '',
]) {
// First apply search filter
var filteredItems = items;
if (searchQuery.isNotEmpty) {
final query = searchQuery;
filteredItems = items.where((item) {
final searchKey = _searchIndexCache[item.id] ?? _buildSearchKey(item);
if (!_searchIndexCache.containsKey(item.id)) {
_searchIndexCache[item.id] = searchKey;
}
return searchKey.contains(query);
}).toList();
}
// Then apply filter mode
if (filterMode == 'all') return filteredItems;
switch (filterMode) {
case 'albums':
return filteredItems.where((item) {
final key =
'${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}';
return (albumCounts[key] ?? 0) > 1;
}).toList();
case 'singles':
return filteredItems.where((item) {
final key =
'${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}';
return (albumCounts[key] ?? 0) == 1;
}).toList();
default:
return filteredItems;
}
}
_HistoryStats _buildHistoryStats(
List<DownloadHistoryItem> items, [
List<LocalLibraryItem> localItems = const [],
]) {
final albumCounts = <String, int>{};
final albumMap = <String, List<DownloadHistoryItem>>{};
for (final item in items) {
// Use lowercase key for case-insensitive grouping
final key =
'${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}';
albumCounts[key] = (albumCounts[key] ?? 0) + 1;
albumMap.putIfAbsent(key, () => []).add(item);
}
int singleTracks = 0;
for (final item in items) {
final key =
'${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}';
if ((albumCounts[key] ?? 0) <= 1) {
singleTracks++;
}
}
final groupedAlbums = <_GroupedAlbum>[];
albumMap.forEach((_, tracks) {
if (tracks.length <= 1) return;
tracks.sort((a, b) {
final aNum = a.trackNumber ?? 999;
final bNum = b.trackNumber ?? 999;
return aNum.compareTo(bNum);
});
groupedAlbums.add(
_GroupedAlbum(
albumName: tracks.first.albumName,
artistName: tracks.first.albumArtist ?? tracks.first.artistName,
coverUrl: tracks.first.coverUrl,
tracks: tracks,
latestDownload: tracks
.map((t) => t.downloadedAt)
.reduce((a, b) => a.isAfter(b) ? a : b),
),
);
});
groupedAlbums.sort((a, b) => b.latestDownload.compareTo(a.latestDownload));
int albumCount = 0;
for (final count in albumCounts.values) {
if (count > 1) albumCount++;
}
// Calculate local library stats
final localAlbumCounts = <String, int>{};
final localAlbumMap = <String, List<LocalLibraryItem>>{};
for (final item in localItems) {
final key =
'${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}';
localAlbumCounts[key] = (localAlbumCounts[key] ?? 0) + 1;
localAlbumMap.putIfAbsent(key, () => []).add(item);
}
int localAlbumCount = 0;
int localSingleTracks = 0;
for (final count in localAlbumCounts.values) {
if (count > 1) {
localAlbumCount++;
} else {
localSingleTracks++;
}
}
// Build grouped local albums
final groupedLocalAlbums = <_GroupedLocalAlbum>[];
localAlbumMap.forEach((_, tracks) {
if (tracks.length <= 1) return;
tracks.sort((a, b) {
final aNum = a.trackNumber ?? 999;
final bNum = b.trackNumber ?? 999;
return aNum.compareTo(bNum);
});
groupedLocalAlbums.add(
_GroupedLocalAlbum(
albumName: tracks.first.albumName,
artistName: tracks.first.albumArtist ?? tracks.first.artistName,
coverPath: tracks
.firstWhere(
(t) => t.coverPath != null && t.coverPath!.isNotEmpty,
orElse: () => tracks.first,
)
.coverPath,
tracks: tracks,
latestScanned: tracks
.map((t) => t.scannedAt)
.reduce((a, b) => a.isAfter(b) ? a : b),
),
);
});
groupedLocalAlbums.sort(
(a, b) => b.latestScanned.compareTo(a.latestScanned),
);
return _HistoryStats(
albumCounts: albumCounts,
localAlbumCounts: localAlbumCounts,
groupedAlbums: groupedAlbums,
groupedLocalAlbums: groupedLocalAlbums,
albumCount: albumCount,
singleTracks: singleTracks,
localAlbumCount: localAlbumCount,
localSingleTracks: localSingleTracks,
);
}
void _navigateToDownloadedAlbum(_GroupedAlbum album) {
_searchFocusNode.unfocus();
Navigator.push(
context,
PageRouteBuilder(
transitionDuration: const Duration(milliseconds: 300),
reverseTransitionDuration: const Duration(milliseconds: 250),
pageBuilder: (context, animation, secondaryAnimation) =>
DownloadedAlbumScreen(
albumName: album.albumName,
artistName: album.artistName,
coverUrl: album.coverUrl,
),
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(opacity: animation, child: child),
),
).then((_) => _searchFocusNode.unfocus());
}
void _navigateToLocalAlbum(_GroupedLocalAlbum album) {
_searchFocusNode.unfocus();
Navigator.push(
context,
PageRouteBuilder(
transitionDuration: const Duration(milliseconds: 300),
reverseTransitionDuration: const Duration(milliseconds: 250),
pageBuilder: (context, animation, secondaryAnimation) =>
LocalAlbumScreen(
albumName: album.albumName,
artistName: album.artistName,
coverPath: album.coverPath,
tracks: album.tracks,
),
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(opacity: animation, child: child),
),
).then((_) => _searchFocusNode.unfocus());
}
@override
Widget build(BuildContext context) {
_initializePageController();
final queueItems = ref.watch(downloadQueueProvider.select((s) => s.items));
final allHistoryItems = ref.watch(
downloadHistoryProvider.select((s) => s.items),
);
// Watch local library items
final localLibraryEnabled = ref.watch(
settingsProvider.select((s) => s.localLibraryEnabled),
);
final localLibraryItems = localLibraryEnabled
? ref.watch(localLibraryProvider.select((s) => s.items))
: const <LocalLibraryItem>[];
_ensureHistoryCaches(allHistoryItems, localLibraryItems);
final historyViewMode = ref.watch(
settingsProvider.select((s) => s.historyViewMode),
);
final historyFilterMode = ref.watch(
settingsProvider.select((s) => s.historyFilterMode),
);
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
final historyStats =
_historyStatsCache ??
_buildHistoryStats(allHistoryItems, localLibraryItems);
final groupedAlbums = historyStats.groupedAlbums;
final groupedLocalAlbums = historyStats.groupedLocalAlbums;
final albumCount = historyStats.totalAlbumCount;
final singleCount = historyStats.totalSingleTracks;
final bottomPadding = MediaQuery.of(context).padding.bottom;
return PopScope(
canPop: !_isSelectionMode,
onPopInvokedWithResult: (didPop, result) {
if (!didPop && _isSelectionMode) {
_exitSelectionMode();
}
},
child: Stack(
children: [
// ScrollConfiguration disables stretch overscroll to fix _StretchController exception
// This is a known Flutter issue with NestedScrollView + Material 3 stretch indicator
ScrollConfiguration(
behavior: ScrollConfiguration.of(
context,
).copyWith(overscroll: false),
child: NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) => [
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
automaticallyImplyLeading: false,
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio =
((constraints.maxHeight - minHeight) /
(maxHeight - minHeight))
.clamp(0.0, 1.0);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: const EdgeInsets.only(
left: 24,
bottom: 16,
),
title: Text(
context.l10n.navLibrary,
style: TextStyle(
fontSize: 20 + (14 * expandRatio),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
);
},
),
),
// Search bar - always at top
if (allHistoryItems.isNotEmpty || queueItems.isNotEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: GestureDetector(
onTap: () {},
child: TextField(
controller: _searchController,
focusNode: _searchFocusNode,
autofocus: false,
canRequestFocus: true,
decoration: InputDecoration(
hintText: context.l10n.historySearchHint,
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
_clearSearch();
FocusScope.of(context).unfocus();
},
)
: null,
filled: true,
fillColor: colorScheme.surfaceContainerHighest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(28),
borderSide: BorderSide(
color: colorScheme.outlineVariant,
width: 1,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(28),
borderSide: BorderSide(
color: colorScheme.outlineVariant,
width: 1.5,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(28),
borderSide: BorderSide(
color: colorScheme.primary,
width: 2.5,
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 12,
),
),
onChanged: _onSearchChanged,
onTapOutside: (_) {
FocusScope.of(context).unfocus();
},
),
),
),
),
if (queueItems.isNotEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Row(
children: [
Text(
'Downloading (${queueItems.length})',
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
const Spacer(),
_buildExportFailedButton(context, ref, colorScheme),
const SizedBox(width: 4),
_buildPauseResumeButton(context, ref, colorScheme),
const SizedBox(width: 4),
_buildClearAllButton(context, ref, colorScheme),
],
),
),
),
if (queueItems.isNotEmpty)
SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final item = queueItems[index];
return KeyedSubtree(
key: ValueKey(item.id),
child: _buildQueueItem(context, item, colorScheme),
);
}, childCount: queueItems.length),
),
if (allHistoryItems.isNotEmpty || localLibraryItems.isNotEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_FilterChip(
label: context.l10n.historyFilterAll,
count:
allHistoryItems.length +
localLibraryItems.length,
isSelected: historyFilterMode == 'all',
onTap: () {
_animateToFilterPage(0);
},
),
const SizedBox(width: 8),
_FilterChip(
label: context.l10n.historyFilterAlbums,
count: albumCount,
isSelected: historyFilterMode == 'albums',
onTap: () {
_animateToFilterPage(1);
},
),
const SizedBox(width: 8),
_FilterChip(
label: context.l10n.historyFilterSingles,
count: singleCount,
isSelected: historyFilterMode == 'singles',
onTap: () {
_animateToFilterPage(2);
},
),
],
),
),
),
),
],
body: NotificationListener<ScrollNotification>(
onNotification: (notification) {
final parentController = widget.parentPageController;
if (parentController == null ||
!parentController.hasClients) {
return false;
}
final page = _filterPageController!.page?.round() ?? 0;
if (notification is OverscrollNotification) {
final overscroll = notification.overscroll;
if (page == 0 && overscroll < 0) {
final currentOffset = parentController.offset;
final targetOffset = (currentOffset + overscroll).clamp(
0.0,
parentController.position.maxScrollExtent,
);
parentController.jumpTo(targetOffset);
return true;
}
if (page == 2 && overscroll > 0) {
final currentOffset = parentController.offset;
final targetOffset = (currentOffset + overscroll).clamp(
0.0,
parentController.position.maxScrollExtent,
);
parentController.jumpTo(targetOffset);
return true;
}
}
if (notification is ScrollEndNotification) {
if (page == 0 || page == 2) {
final currentPage =
parentController.page ??
widget.parentPageIndex.toDouble();
final historyPage = widget.parentPageIndex.toDouble();
final offset = currentPage - historyPage;
if (offset.abs() > 0.01) {
if (offset < -0.3) {
parentController.animateToPage(
widget.parentPageIndex - 1,
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
);
} else if (offset > 0.3) {
parentController.animateToPage(
widget.nextPageIndex ??
(widget.parentPageIndex + 1),
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
);
} else {
parentController.jumpToPage(widget.parentPageIndex);
}
}
}
}
return false;
},
child: PageView.builder(
controller: _filterPageController!,
physics: const ClampingScrollPhysics(),
onPageChanged: _onFilterPageChanged,
itemCount: _filterModes.length,
itemBuilder: (context, index) {
final filterMode = _filterModes[index];
return _buildFilterContent(
context: context,
colorScheme: colorScheme,
filterMode: filterMode,
allHistoryItems: allHistoryItems,
historyViewMode: historyViewMode,
queueItems: queueItems,
groupedAlbums: groupedAlbums,
groupedLocalAlbums: groupedLocalAlbums,
albumCounts: historyStats.albumCounts,
localAlbumCounts: historyStats.localAlbumCounts,
localLibraryItems: localLibraryItems,
);
},
),
),
),
), // ScrollConfiguration
AnimatedPositioned(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
left: 0,
right: 0,
bottom: _isSelectionMode ? 0 : -(200 + bottomPadding),
child: _buildSelectionBottomBar(
context,
colorScheme,
_buildUnifiedItemsForSelection(
filterMode: historyFilterMode,
allHistoryItems: allHistoryItems,
albumCounts: historyStats.albumCounts,
localLibraryItems: localLibraryItems,
localAlbumCounts: historyStats.localAlbumCounts,
),
bottomPadding,
),
),
],
),
);
}
/// Build unified items list for selection mode
List<UnifiedLibraryItem> _buildUnifiedItemsForSelection({
required String filterMode,
required List<DownloadHistoryItem> allHistoryItems,
required Map<String, int> albumCounts,
required List<LocalLibraryItem> localLibraryItems,
required Map<String, int> localAlbumCounts,
}) {
final historyItems = _resolveHistoryItems(
filterMode: filterMode,
allHistoryItems: allHistoryItems,
albumCounts: albumCounts,
);
final unifiedItems = _getUnifiedItems(
filterMode: filterMode,
historyItems: historyItems,
localLibraryItems: localLibraryItems,
localAlbumCounts: localAlbumCounts,
);
// Apply advanced filters to match what's displayed
return _applyAdvancedFilters(unifiedItems);
}
List<UnifiedLibraryItem> _getUnifiedItems({
required String filterMode,
required List<DownloadHistoryItem> historyItems,
required List<LocalLibraryItem> localLibraryItems,
required Map<String, int> localAlbumCounts,
}) {
if (filterMode == 'albums') return const [];
final query = _searchQuery;
final cached = _unifiedItemsCache[filterMode];
if (cached != null &&
identical(cached.historyItems, historyItems) &&
identical(cached.localItems, localLibraryItems) &&
identical(cached.localAlbumCounts, localAlbumCounts) &&
cached.query == query) {
return cached.items;
}
final unifiedDownloaded = historyItems
.map((item) => UnifiedLibraryItem.fromDownloadHistory(item))
.toList(growable: false);
List<LocalLibraryItem> localItemsForMerge;
if (filterMode == 'all') {
localItemsForMerge = _filterLocalItems(localLibraryItems, query);
} else {
final localSingles = localLibraryItems
.where((item) {
final count = localAlbumCounts[item.albumKey] ?? 0;
return count == 1;
})
.toList(growable: false);
localItemsForMerge = _filterLocalItems(localSingles, query);
}
final unifiedLocal = localItemsForMerge
.map((item) => UnifiedLibraryItem.fromLocalLibrary(item))
.toList(growable: false);
final merged = <UnifiedLibraryItem>[...unifiedDownloaded, ...unifiedLocal]
..sort((a, b) => b.addedAt.compareTo(a.addedAt));
_unifiedItemsCache[filterMode] = _UnifiedCacheEntry(
historyItems: historyItems,
localItems: localLibraryItems,
localAlbumCounts: localAlbumCounts,
query: query,
items: merged,
);
return merged;
}
Widget _buildFilterContent({
required BuildContext context,
required ColorScheme colorScheme,
required String filterMode,
required List<DownloadHistoryItem> allHistoryItems,
required String historyViewMode,
required List<DownloadItem> queueItems,
required List<_GroupedAlbum> groupedAlbums,
required List<_GroupedLocalAlbum> groupedLocalAlbums,
required Map<String, int> albumCounts,
required Map<String, int> localAlbumCounts,
required List<LocalLibraryItem> localLibraryItems,
}) {
final historyItems = _resolveHistoryItems(
filterMode: filterMode,
allHistoryItems: allHistoryItems,
albumCounts: albumCounts,
);
final showFilteringIndicator = _shouldShowFilteringIndicator(
allHistoryItems: allHistoryItems,
filterMode: filterMode,
);
// Filter grouped albums based on search query
final searchQuery = _searchQuery;
final filteredGroupedAlbums = searchQuery.isEmpty
? groupedAlbums
: groupedAlbums
.where((album) => album.searchKey.contains(searchQuery))
.toList();
// Filter local library albums based on search query
final filteredGroupedLocalAlbums = searchQuery.isEmpty
? groupedLocalAlbums
: groupedLocalAlbums
.where((album) => album.searchKey.contains(searchQuery))
.toList();
// Total album count for display
final totalAlbumCount =
filteredGroupedAlbums.length + filteredGroupedLocalAlbums.length;
final unifiedItems = _getUnifiedItems(
filterMode: filterMode,
historyItems: historyItems,
localLibraryItems: localLibraryItems,
localAlbumCounts: localAlbumCounts,
);
// Apply advanced filters
final filteredUnifiedItems = _applyAdvancedFilters(unifiedItems);
// Total count for display
final totalTrackCount = filteredUnifiedItems.length;
return CustomScrollView(
slivers: [
if (totalTrackCount > 0 && queueItems.isEmpty && filterMode != 'albums')
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Row(
children: [
Text(
'$totalTrackCount ${totalTrackCount == 1 ? 'track' : 'tracks'}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const Spacer(),
// Filter button with long-press to reset
if (!_isSelectionMode)
GestureDetector(
onLongPress: _activeFilterCount > 0
? _resetFilters
: null,
child: TextButton.icon(
onPressed: () =>
_showFilterSheet(context, unifiedItems),
icon: Badge(
isLabelVisible: _activeFilterCount > 0,
label: Text('$_activeFilterCount'),
child: const Icon(Icons.filter_list, size: 18),
),
label: Text(context.l10n.libraryFilterTitle),
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
),
),
),
if (!_isSelectionMode && filteredUnifiedItems.isNotEmpty)
TextButton.icon(
onPressed: () =>
_enterSelectionMode(filteredUnifiedItems.first.id),
icon: const Icon(Icons.checklist, size: 18),
label: Text(context.l10n.actionSelect),
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
),
),
],
),
),
),
if ((filteredGroupedAlbums.isNotEmpty ||
filteredGroupedLocalAlbums.isNotEmpty) &&
queueItems.isEmpty &&
filterMode == 'albums')
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text(
'$totalAlbumCount ${totalAlbumCount == 1 ? 'album' : 'albums'}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
),
if (historyItems.isNotEmpty && queueItems.isNotEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
'Downloaded',
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
),
if (showFilteringIndicator)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Row(
children: [
SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colorScheme.primary,
),
),
const SizedBox(width: 12),
Text(
'Filtering...',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
),
// Combined albums grid (downloaded + local in single grid)
if (filterMode == 'albums' &&
(filteredGroupedAlbums.isNotEmpty ||
filteredGroupedLocalAlbums.isNotEmpty))
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 0.75,
),
delegate: SliverChildBuilderDelegate(
(context, index) {
// First render downloaded albums, then local albums
if (index < filteredGroupedAlbums.length) {
final album = filteredGroupedAlbums[index];
return KeyedSubtree(
key: ValueKey(album.key),
child: _buildAlbumGridItem(context, album, colorScheme),
);
} else {
final localIndex = index - filteredGroupedAlbums.length;
final album = filteredGroupedLocalAlbums[localIndex];
return KeyedSubtree(
key: ValueKey('local_${album.key}'),
child: _buildLocalAlbumGridItem(
context,
album,
colorScheme,
),
);
}
},
childCount:
filteredGroupedAlbums.length +
filteredGroupedLocalAlbums.length,
),
),
),
// Unified list for 'all' filter (merged downloaded + local)
if (filteredUnifiedItems.isNotEmpty && filterMode == 'all')
historyViewMode == 'grid'
? SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverGrid(
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
childAspectRatio: 0.75,
),
delegate: SliverChildBuilderDelegate((context, index) {
final item = filteredUnifiedItems[index];
return KeyedSubtree(
key: ValueKey(item.id),
child: _buildUnifiedGridItem(
context,
item,
colorScheme,
),
);
}, childCount: filteredUnifiedItems.length),
),
)
: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final item = filteredUnifiedItems[index];
return KeyedSubtree(
key: ValueKey(item.id),
child: _buildUnifiedLibraryItem(
context,
item,
colorScheme,
),
);
}, childCount: filteredUnifiedItems.length),
),
// Singles filter - show unified items (downloaded + local singles)
if (filteredUnifiedItems.isNotEmpty && filterMode == 'singles')
historyViewMode == 'grid'
? SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverGrid(
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
childAspectRatio: 0.75,
),
delegate: SliverChildBuilderDelegate((context, index) {
final item = filteredUnifiedItems[index];
return KeyedSubtree(
key: ValueKey(item.id),
child: _buildUnifiedGridItem(
context,
item,
colorScheme,
),
);
}, childCount: filteredUnifiedItems.length),
),
)
: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final item = filteredUnifiedItems[index];
return KeyedSubtree(
key: ValueKey(item.id),
child: _buildUnifiedLibraryItem(
context,
item,
colorScheme,
),
);
}, childCount: filteredUnifiedItems.length),
),
if (queueItems.isEmpty &&
totalTrackCount == 0 &&
(filterMode != 'albums' ||
(filteredGroupedAlbums.isEmpty &&
filteredGroupedLocalAlbums.isEmpty)) &&
!showFilteringIndicator)
SliverFillRemaining(
hasScrollBody: false,
child: _buildEmptyState(context, colorScheme, filterMode),
)
else
SliverToBoxAdapter(
child: SizedBox(height: _isSelectionMode ? 100 : 16),
),
],
);
}
Widget _buildPauseResumeButton(
BuildContext context,
WidgetRef ref,
ColorScheme colorScheme,
) {
final isPaused = ref.watch(downloadQueueProvider.select((s) => s.isPaused));
return TextButton.icon(
onPressed: () {
ref.read(downloadQueueProvider.notifier).togglePause();
},
icon: Icon(isPaused ? Icons.play_arrow : Icons.pause, size: 18),
label: Text(
isPaused ? context.l10n.actionResume : context.l10n.actionPause,
),
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
foregroundColor: isPaused
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
);
}
Widget _buildClearAllButton(
BuildContext context,
WidgetRef ref,
ColorScheme colorScheme,
) {
return TextButton.icon(
onPressed: () => _showClearAllDialog(context, ref, colorScheme),
icon: const Icon(Icons.clear_all, size: 18),
label: Text(context.l10n.queueClearAll),
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
foregroundColor: colorScheme.error,
),
);
}
Widget _buildExportFailedButton(
BuildContext context,
WidgetRef ref,
ColorScheme colorScheme,
) {
final queueState = ref.watch(downloadQueueProvider);
final failedCount = queueState.failedCount;
if (failedCount == 0) {
return const SizedBox.shrink();
}
return TextButton.icon(
onPressed: () => _exportFailedDownloads(context, ref),
icon: const Icon(Icons.file_download, size: 18),
label: Text(context.l10n.queueExportFailed),
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
foregroundColor: colorScheme.tertiary,
),
);
}
Future<void> _exportFailedDownloads(
BuildContext context,
WidgetRef ref,
) async {
final filePath = await ref
.read(downloadQueueProvider.notifier)
.exportFailedDownloads();
if (!context.mounted) return;
if (filePath != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.queueExportFailedSuccess),
action: SnackBarAction(
label: context.l10n.queueExportFailedClear,
onPressed: () {
ref.read(downloadQueueProvider.notifier).clearFailedDownloads();
},
),
duration: const Duration(seconds: 5),
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.queueExportFailedError),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
}
Future<void> _showClearAllDialog(
BuildContext context,
WidgetRef ref,
ColorScheme colorScheme,
) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(context.l10n.queueClearAll),
content: Text(context.l10n.queueClearAllMessage),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(backgroundColor: colorScheme.error),
child: Text(context.l10n.dialogClear),
),
],
),
);
if (confirmed == true && context.mounted) {
ref.read(downloadQueueProvider.notifier).clearAll();
}
}
Widget _buildEmptyState(
BuildContext context,
ColorScheme colorScheme,
String filterMode,
) {
String message;
String subtitle;
IconData icon;
switch (filterMode) {
case 'albums':
message = 'No album downloads';
subtitle = 'Download multiple tracks from an album to see them here';
icon = Icons.album;
break;
case 'singles':
message = 'No single downloads';
subtitle = 'Single track downloads will appear here';
icon = Icons.music_note;
break;
default:
message = 'No download history';
subtitle = 'Downloaded tracks will appear here';
icon = Icons.history;
}
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 64, color: colorScheme.onSurfaceVariant),
const SizedBox(height: 16),
Text(
message,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Text(
subtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
),
),
],
),
);
}
Widget _buildAlbumGridItem(
BuildContext context,
_GroupedAlbum album,
ColorScheme colorScheme,
) {
return GestureDetector(
onTap: () => _navigateToDownloadedAlbum(album),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: album.coverUrl != null
? CachedNetworkImage(
imageUrl: album.coverUrl!,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
memCacheWidth: 300,
memCacheHeight: 300,
cacheManager: CoverCacheManager.instance,
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Center(
child: Icon(
Icons.album,
color: colorScheme.onSurfaceVariant,
size: 48,
),
),
),
),
Positioned(
right: 8,
bottom: 8,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.music_note,
size: 12,
color: colorScheme.onPrimaryContainer,
),
const SizedBox(width: 4),
Text(
'${album.tracks.length}',
style: TextStyle(
color: colorScheme.onPrimaryContainer,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
],
),
),
const SizedBox(height: 8),
Text(
album.albumName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
),
Text(
album.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
);
}
/// Album grid item for local library albums
Widget _buildLocalAlbumGridItem(
BuildContext context,
_GroupedLocalAlbum album,
ColorScheme colorScheme,
) {
return GestureDetector(
onTap: () => _navigateToLocalAlbum(album),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: album.coverPath != null
? Image.file(
File(album.coverPath!),
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
cacheWidth: 300,
cacheHeight: 300,
errorBuilder: (context, error, stackTrace) =>
Container(
color: colorScheme.surfaceContainerHighest,
child: Center(
child: Icon(
Icons.album,
color: colorScheme.onSurfaceVariant,
size: 48,
),
),
),
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Center(
child: Icon(
Icons.album,
color: colorScheme.onSurfaceVariant,
size: 48,
),
),
),
),
// "Local" badge instead of track count
Positioned(
right: 8,
bottom: 8,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.folder,
size: 12,
color: colorScheme.onTertiaryContainer,
),
const SizedBox(width: 4),
Text(
'${album.tracks.length}',
style: TextStyle(
color: colorScheme.onTertiaryContainer,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
],
),
),
const SizedBox(height: 8),
Text(
album.albumName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
),
Text(
album.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
);
}
/// Bottom action bar for selection mode (Material Design 3 style)
Widget _buildSelectionBottomBar(
BuildContext context,
ColorScheme colorScheme,
List<UnifiedLibraryItem> unifiedItems,
double bottomPadding,
) {
final selectedCount = _selectedIds.length;
final allSelected =
selectedCount == unifiedItems.length && unifiedItems.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(
'$selectedCount selected',
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
Text(
allSelected
? 'All tracks selected'
: 'Tap tracks to select',
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: colorScheme.onSurfaceVariant),
),
],
),
),
TextButton.icon(
onPressed: () {
if (allSelected) {
_exitSelectionMode();
} else {
_selectAll(unifiedItems);
}
},
icon: Icon(
allSelected ? Icons.deselect : Icons.select_all,
size: 20,
),
label: Text(allSelected ? 'Deselect' : 'Select All'),
style: TextButton.styleFrom(
foregroundColor: colorScheme.primary,
),
),
],
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: selectedCount > 0
? () => _deleteSelected(unifiedItems)
: null,
icon: const Icon(Icons.delete_outline),
label: Text(
selectedCount > 0
? 'Delete $selectedCount ${selectedCount == 1 ? 'track' : 'tracks'}'
: 'Select tracks to delete',
),
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),
),
),
),
),
],
),
),
),
);
}
Widget _buildQueueItem(
BuildContext context,
DownloadItem item,
ColorScheme colorScheme,
) {
final isCompleted = item.status == DownloadStatus.completed;
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: InkWell(
onTap: isCompleted ? () => _navigateToMetadataScreen(item) : null,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
isCompleted
? Hero(
tag: 'cover_${item.id}',
child: _buildCoverArt(item, colorScheme),
)
: _buildCoverArt(item, colorScheme),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.track.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
item.track.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
if (item.status == DownloadStatus.downloading) ...[
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: item.progress > 0 ? item.progress : null,
backgroundColor:
colorScheme.surfaceContainerHighest,
color: colorScheme.primary,
minHeight: 6,
),
),
),
const SizedBox(width: 8),
Text(
item.speedMBps > 0
? '${(item.progress * 100).toStringAsFixed(0)}% • ${item.speedMBps.toStringAsFixed(1)} MB/s'
: '${(item.progress * 100).toStringAsFixed(0)}%',
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
],
),
],
if (item.status == DownloadStatus.failed) ...[
const SizedBox(height: 4),
Text(
item.errorMessage,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.error,
),
),
],
],
),
),
const SizedBox(width: 8),
_buildActionButtons(context, item, colorScheme),
],
),
),
),
);
}
Widget _buildCoverArt(DownloadItem item, ColorScheme colorScheme) {
return item.track.coverUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: item.track.coverUrl!,
width: 56,
height: 56,
fit: BoxFit.cover,
memCacheWidth: 112,
memCacheHeight: 112,
cacheManager: CoverCacheManager.instance,
),
)
: Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
);
}
Widget _buildActionButtons(
BuildContext context,
DownloadItem item,
ColorScheme colorScheme,
) {
switch (item.status) {
case DownloadStatus.queued:
return IconButton(
onPressed: () =>
ref.read(downloadQueueProvider.notifier).cancelItem(item.id),
icon: Icon(Icons.close, color: colorScheme.error),
tooltip: 'Cancel',
style: IconButton.styleFrom(
backgroundColor: colorScheme.errorContainer.withValues(alpha: 0.3),
),
);
case DownloadStatus.downloading:
return IconButton(
onPressed: () =>
ref.read(downloadQueueProvider.notifier).cancelItem(item.id),
icon: Icon(Icons.stop, color: colorScheme.error),
tooltip: 'Stop',
style: IconButton.styleFrom(
backgroundColor: colorScheme.errorContainer.withValues(alpha: 0.3),
),
);
case DownloadStatus.finalizing:
return SizedBox(
width: 40,
height: 40,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(
strokeWidth: 3,
color: colorScheme.tertiary,
),
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16),
],
),
);
case DownloadStatus.completed:
final fileExists = _checkFileExists(item.filePath);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
if (fileExists)
IconButton(
onPressed: () => _openFile(item.filePath!),
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
tooltip: 'Play',
style: IconButton.styleFrom(
backgroundColor: colorScheme.primaryContainer.withValues(
alpha: 0.3,
),
),
)
else
Icon(Icons.error_outline, color: colorScheme.error, size: 20),
const SizedBox(width: 4),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
shape: BoxShape.circle,
),
child: Icon(
Icons.check,
color: colorScheme.onPrimaryContainer,
size: 20,
),
),
],
);
case DownloadStatus.failed:
case DownloadStatus.skipped:
return Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
onPressed: () =>
ref.read(downloadQueueProvider.notifier).retryItem(item.id),
icon: Icon(Icons.refresh, color: colorScheme.primary),
tooltip: 'Retry',
style: IconButton.styleFrom(
backgroundColor: colorScheme.primaryContainer.withValues(
alpha: 0.3,
),
),
),
const SizedBox(width: 4),
IconButton(
onPressed: () =>
ref.read(downloadQueueProvider.notifier).removeItem(item.id),
icon: Icon(
Icons.close,
color: item.status == DownloadStatus.failed
? colorScheme.error
: colorScheme.onSurfaceVariant,
),
tooltip: 'Remove',
style: item.status == DownloadStatus.failed
? IconButton.styleFrom(
backgroundColor: colorScheme.errorContainer.withValues(
alpha: 0.3,
),
)
: null,
),
],
);
}
}
/// Build cover image widget for unified library item
/// Supports network URLs (from downloads) and local file paths (from library scan)
Widget _buildUnifiedCoverImage(
UnifiedLibraryItem item,
ColorScheme colorScheme,
double size,
) {
final isDownloaded = item.source == LibraryItemSource.downloaded;
// Network URL cover (downloaded items)
if (item.coverUrl != null) {
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: item.coverUrl!,
width: size,
height: size,
fit: BoxFit.cover,
memCacheWidth: (size * 2).toInt(),
memCacheHeight: (size * 2).toInt(),
cacheManager: CoverCacheManager.instance,
placeholder: (context, url) => Container(
width: size,
height: size,
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
),
errorWidget: (context, url, error) => Container(
width: size,
height: size,
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
),
),
);
}
// Local file cover (from library scan)
if (item.localCoverPath != null && item.localCoverPath!.isNotEmpty) {
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.file(
File(item.localCoverPath!),
width: size,
height: size,
fit: BoxFit.cover,
cacheWidth: (size * 2).toInt(),
cacheHeight: (size * 2).toInt(),
errorBuilder: (context, error, stackTrace) =>
_buildPlaceholderCover(colorScheme, size, isDownloaded),
),
);
}
// Placeholder (no cover)
return _buildPlaceholderCover(colorScheme, size, isDownloaded);
}
/// Build placeholder cover image
Widget _buildPlaceholderCover(
ColorScheme colorScheme,
double size,
bool isDownloaded,
) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: isDownloaded
? colorScheme.surfaceContainerHighest
: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.music_note,
color: isDownloaded
? colorScheme.onSurfaceVariant
: colorScheme.onSecondaryContainer,
size: size * 0.4,
),
);
}
/// Build cover image for unified grid item (fills container)
Widget _buildUnifiedGridCoverImage(
UnifiedLibraryItem item,
ColorScheme colorScheme,
) {
final isDownloaded = item.source == LibraryItemSource.downloaded;
// Network URL cover (downloaded items)
if (item.coverUrl != null) {
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: item.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: 200,
memCacheHeight: 200,
cacheManager: CoverCacheManager.instance,
placeholder: (context, url) => Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note,
color: colorScheme.onSurfaceVariant,
size: 32,
),
),
errorWidget: (context, url, error) => Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note,
color: colorScheme.onSurfaceVariant,
size: 32,
),
),
),
);
}
// Local file cover (from library scan)
if (item.localCoverPath != null && item.localCoverPath!.isNotEmpty) {
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.file(
File(item.localCoverPath!),
fit: BoxFit.cover,
cacheWidth: 200,
cacheHeight: 200,
errorBuilder: (context, error, stackTrace) => Container(
color: colorScheme.secondaryContainer,
child: Icon(
Icons.music_note,
color: colorScheme.onSecondaryContainer,
size: 32,
),
),
),
);
}
// Placeholder (no cover)
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
color: isDownloaded
? colorScheme.surfaceContainerHighest
: colorScheme.secondaryContainer,
child: Icon(
Icons.music_note,
color: isDownloaded
? colorScheme.onSurfaceVariant
: colorScheme.onSecondaryContainer,
size: 32,
),
),
);
}
/// Build a unified library item (merged downloaded + local)
Widget _buildUnifiedLibraryItem(
BuildContext context,
UnifiedLibraryItem item,
ColorScheme colorScheme,
) {
final fileExists = _checkFileExists(item.filePath);
final isSelected = _selectedIds.contains(item.id);
final date = item.addedAt;
final dateStr =
'${_months[date.month - 1]} ${date.day}, ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
final isDownloaded = item.source == LibraryItemSource.downloaded;
final sourceLabel = isDownloaded
? context.l10n.librarySourceDownloaded
: context.l10n.librarySourceLocal;
final sourceColor = isDownloaded
? colorScheme.primaryContainer
: colorScheme.secondaryContainer;
final sourceTextColor = isDownloaded
? colorScheme.onPrimaryContainer
: colorScheme.onSecondaryContainer;
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
color: isSelected
? colorScheme.primaryContainer.withValues(alpha: 0.3)
: null,
child: InkWell(
onTap: _isSelectionMode
? () => _toggleSelection(item.id)
: isDownloaded
? () => _navigateToHistoryMetadataScreen(item.historyItem!)
: item.localItem != null
? () => _navigateToLocalMetadataScreen(item.localItem!)
: () => _openFile(item.filePath),
onLongPress: _isSelectionMode
? null
: () => _enterSelectionMode(item.id),
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
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),
],
// Cover image - supports network URL and local file path
_buildUnifiedCoverImage(item, colorScheme, 56),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.trackName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
item.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 2),
Row(
children: [
// Source badge
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: sourceColor,
borderRadius: BorderRadius.circular(4),
),
child: Text(
sourceLabel,
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
color: sourceTextColor,
fontSize: 10,
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(width: 8),
Text(
dateStr,
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
color: colorScheme.onSurfaceVariant.withValues(
alpha: 0.7,
),
),
),
if (item.quality != null &&
item.quality!.isNotEmpty) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: item.quality!.startsWith('24')
? colorScheme.tertiaryContainer
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4),
),
child: Text(
item.quality!,
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
color: item.quality!.startsWith('24')
? colorScheme.onTertiaryContainer
: colorScheme.onSurfaceVariant,
fontSize: 10,
fontWeight: FontWeight.w500,
),
),
),
],
],
),
],
),
),
const SizedBox(width: 8),
if (!_isSelectionMode)
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (fileExists)
IconButton(
onPressed: () => _openFile(item.filePath),
icon: Icon(
Icons.play_arrow,
color: colorScheme.primary,
),
tooltip: context.l10n.tooltipPlay,
style: IconButton.styleFrom(
backgroundColor: colorScheme.primaryContainer
.withValues(alpha: 0.3),
),
)
else
Icon(
Icons.error_outline,
color: colorScheme.error,
size: 20,
),
],
),
],
),
),
),
);
}
/// Build unified grid item for grid view mode
Widget _buildUnifiedGridItem(
BuildContext context,
UnifiedLibraryItem item,
ColorScheme colorScheme,
) {
final fileExists = _checkFileExists(item.filePath);
final isSelected = _selectedIds.contains(item.id);
final isDownloaded = item.source == LibraryItemSource.downloaded;
return GestureDetector(
onTap: _isSelectionMode
? () => _toggleSelection(item.id)
: isDownloaded
? () => _navigateToHistoryMetadataScreen(item.historyItem!)
: item.localItem != null
? () => _navigateToLocalMetadataScreen(item.localItem!)
: () => _openFile(item.filePath),
onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(item.id),
child: Stack(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Stack(
children: [
AspectRatio(
aspectRatio: 1,
child: _buildUnifiedGridCoverImage(item, colorScheme),
),
// Source badge (top-right)
Positioned(
right: 4,
top: 4,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 2,
),
decoration: BoxDecoration(
color: isDownloaded
? colorScheme.primaryContainer
: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(4),
),
child: Icon(
isDownloaded ? Icons.download_done : Icons.folder,
size: 12,
color: isDownloaded
? colorScheme.onPrimaryContainer
: colorScheme.onSecondaryContainer,
),
),
),
// Quality badge (top-left)
if (item.quality != null && item.quality!.isNotEmpty)
Positioned(
left: 4,
top: 4,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 2,
),
decoration: BoxDecoration(
color: item.quality!.startsWith('24')
? colorScheme.tertiary
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4),
),
child: Text(
_getQualityBadgeText(item.quality!),
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
color: item.quality!.startsWith('24')
? colorScheme.onTertiary
: colorScheme.onSurfaceVariant,
fontSize: 9,
fontWeight: FontWeight.w600,
),
),
),
),
if (fileExists && !_isSelectionMode)
Positioned(
right: 4,
bottom: 4,
child: GestureDetector(
onTap: () => _openFile(item.filePath),
child: Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: colorScheme.primary,
shape: BoxShape.circle,
),
child: Icon(
Icons.play_arrow,
color: colorScheme.onPrimary,
size: 16,
),
),
),
),
if (!fileExists && !_isSelectionMode)
Positioned(
right: 4,
bottom: 4,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: colorScheme.errorContainer,
shape: BoxShape.circle,
),
child: Icon(
Icons.error_outline,
color: colorScheme.error,
size: 14,
),
),
),
if (_isSelectionMode)
Positioned.fill(
child: Container(
decoration: BoxDecoration(
color: isSelected
? colorScheme.primary.withValues(alpha: 0.3)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
),
),
],
),
const SizedBox(height: 6),
Text(
item.trackName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500),
),
Text(
item.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
if (_isSelectionMode)
Positioned(
right: 4,
top: 4,
child: Container(
decoration: BoxDecoration(
color: isSelected ? colorScheme.primary : colorScheme.surface,
shape: BoxShape.circle,
border: Border.all(
color: isSelected
? colorScheme.primary
: colorScheme.outline,
width: 2,
),
),
child: isSelected
? Icon(Icons.check, color: colorScheme.onPrimary, size: 16)
: const SizedBox(width: 16, height: 16),
),
),
],
),
);
}
}
class _FilterChip extends StatelessWidget {
final String label;
final int count;
final bool isSelected;
final VoidCallback onTap;
const _FilterChip({
required this.label,
required this.count,
required this.isSelected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Material(
color: isSelected
? colorScheme.primaryContainer
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(20),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: TextStyle(
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
),
),
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: isSelected
? colorScheme.primary.withValues(alpha: 0.2)
: colorScheme.outline.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(10),
),
child: Text(
count.toString(),
style: TextStyle(
fontSize: 11,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
),
);
}
}