mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-12 20:22:22 +02:00
6443 lines
226 KiB
Dart
6443 lines
226 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:share_plus/share_plus.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
|
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
|
import 'package:spotiflac_android/utils/file_access.dart';
|
|
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
|
|
import 'package:spotiflac_android/models/download_item.dart';
|
|
import 'package:spotiflac_android/models/track.dart';
|
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
|
import 'package:spotiflac_android/providers/extension_provider.dart';
|
|
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
|
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
|
import 'package:spotiflac_android/providers/playback_provider.dart';
|
|
import 'package:spotiflac_android/services/library_database.dart';
|
|
import 'package:spotiflac_android/services/local_track_redownload_service.dart';
|
|
import 'package:spotiflac_android/services/history_database.dart';
|
|
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
|
|
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
|
import 'package:spotiflac_android/screens/favorite_artists_screen.dart';
|
|
import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
|
|
import 'package:spotiflac_android/widgets/re_enrich_field_dialog.dart';
|
|
import 'package:spotiflac_android/widgets/batch_progress_dialog.dart';
|
|
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
|
|
import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart';
|
|
import 'package:spotiflac_android/screens/local_album_screen.dart';
|
|
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
|
import 'package:spotiflac_android/utils/path_match_keys.dart';
|
|
import 'package:spotiflac_android/utils/string_utils.dart';
|
|
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
|
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
|
|
|
part 'queue_tab_helpers.dart';
|
|
part 'queue_tab_widgets.dart';
|
|
|
|
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> {
|
|
static const int _libraryPageSize = 300;
|
|
final _FileExistsListenableCache _fileExistsCache =
|
|
_FileExistsListenableCache();
|
|
static const int _maxSearchIndexCacheSize = 4000;
|
|
static const double _libraryGridMinExtent = 92;
|
|
static const double _libraryGridDefaultExtent = 126;
|
|
static const double _libraryGridMaxExtent = 190;
|
|
bool _embeddedCoverRefreshScheduled = false;
|
|
// Version counter to trigger targeted cover image rebuilds
|
|
// without rebuilding the entire widget tree via setState.
|
|
final ValueNotifier<int> _embeddedCoverVersion = ValueNotifier<int>(0);
|
|
|
|
bool _isSelectionMode = false;
|
|
final Set<String> _selectedIds = {};
|
|
OverlayEntry? _selectionOverlayEntry;
|
|
List<UnifiedLibraryItem> _selectionOverlayItems = const [];
|
|
double _selectionOverlayBottomPadding = 0;
|
|
|
|
bool _isPlaylistSelectionMode = false;
|
|
final Set<String> _selectedPlaylistIds = {};
|
|
OverlayEntry? _playlistSelectionOverlayEntry;
|
|
List<UserPlaylistCollection> _playlistSelectionOverlayItems = const [];
|
|
double _playlistSelectionOverlayBottomPadding = 0;
|
|
|
|
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, 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 = {};
|
|
List<DownloadHistoryItem>? _cachedUnifiedDownloadedSource;
|
|
List<UnifiedLibraryItem> _cachedUnifiedDownloaded = const [];
|
|
List<LocalLibraryItem>? _cachedUnifiedLocalSource;
|
|
List<UnifiedLibraryItem> _cachedUnifiedLocal = const [];
|
|
List<DownloadHistoryItem>? _cachedDownloadedPathKeysSource;
|
|
Set<String> _cachedDownloadedPathKeys = const <String>{};
|
|
final Map<String, List<String>> _localPathMatchKeysCache = {};
|
|
List<LocalLibraryItem>? _cachedLocalSinglesSource;
|
|
Map<String, int>? _cachedLocalSinglesAlbumCountsSource;
|
|
List<LocalLibraryItem> _cachedLocalSingles = const [];
|
|
final Map<String, _FilterContentData> _filterContentDataCache = {};
|
|
List<DownloadHistoryItem>? _filterCacheAllHistoryItems;
|
|
_HistoryStats? _filterCacheHistoryStats;
|
|
List<LocalLibraryItem>? _filterCacheLocalLibraryItems;
|
|
LibraryCollectionsState? _filterCacheCollectionState;
|
|
String _filterCacheSearchQuery = '';
|
|
String? _filterCacheSource;
|
|
String? _filterCacheQuality;
|
|
String? _filterCacheFormat;
|
|
String? _filterCacheMetadata;
|
|
String _filterCacheSortMode = 'latest';
|
|
String? _filterSource;
|
|
String? _filterQuality;
|
|
String? _filterFormat;
|
|
String? _filterMetadata;
|
|
String _sortMode = 'latest';
|
|
double _libraryGridExtent = _libraryGridDefaultExtent;
|
|
double? _libraryGridScaleStartExtent;
|
|
int _libraryPageLimit = _libraryPageSize;
|
|
bool _libraryPageLoadScheduled = false;
|
|
final Map<_QueueLibraryCountsRequest, QueueLibraryCounts>
|
|
_queueLibraryCountsCache = {};
|
|
final Map<_QueueLibraryPageRequest, _QueueLibraryPageData>
|
|
_queueLibraryPageDataCache = {};
|
|
|
|
double _effectiveTextScale() {
|
|
final textScale = MediaQuery.textScalerOf(context).scale(1.0);
|
|
if (textScale < 1.0) return 1.0;
|
|
if (textScale > 1.4) return 1.4;
|
|
return textScale;
|
|
}
|
|
|
|
double _queueCoverSize() {
|
|
final shortestSide = MediaQuery.sizeOf(context).shortestSide;
|
|
final scale = (shortestSide / 390).clamp(0.82, 1.0);
|
|
final textScale = _effectiveTextScale();
|
|
return (56 * scale * (1 + ((textScale - 1) * 0.12))).clamp(46.0, 56.0);
|
|
}
|
|
|
|
double get _libraryAlbumGridExtent =>
|
|
(_libraryGridExtent * 1.45).clamp(150.0, 300.0);
|
|
|
|
void _handleLibraryGridScaleStart(ScaleStartDetails details) {
|
|
if (details.pointerCount < 2) return;
|
|
_libraryGridScaleStartExtent = _libraryGridExtent;
|
|
}
|
|
|
|
void _handleLibraryGridScaleUpdate(ScaleUpdateDetails details) {
|
|
final startExtent = _libraryGridScaleStartExtent;
|
|
if (startExtent == null || details.pointerCount < 2) return;
|
|
|
|
final nextExtent = (startExtent * details.scale).clamp(
|
|
_libraryGridMinExtent,
|
|
_libraryGridMaxExtent,
|
|
);
|
|
if ((nextExtent - _libraryGridExtent).abs() < 0.5) return;
|
|
setState(() => _libraryGridExtent = nextExtent);
|
|
}
|
|
|
|
void _handleLibraryGridScaleEnd(ScaleEndDetails details) {
|
|
_libraryGridScaleStartExtent = null;
|
|
}
|
|
|
|
@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() {
|
|
_hideSelectionOverlay();
|
|
_hidePlaylistSelectionOverlay();
|
|
_fileExistsCache.dispose();
|
|
_embeddedCoverVersion.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;
|
|
_libraryPageLimit = _libraryPageSize;
|
|
});
|
|
_requestFilterRefresh();
|
|
});
|
|
}
|
|
|
|
void _clearSearch() {
|
|
_searchDebounce?.cancel();
|
|
if (_searchQuery.isEmpty) return;
|
|
setState(() {
|
|
_searchQuery = '';
|
|
_libraryPageLimit = _libraryPageSize;
|
|
});
|
|
_requestFilterRefresh();
|
|
}
|
|
|
|
void _loadMoreLibraryItems({required bool hasMoreLibrary}) {
|
|
if (_libraryPageLoadScheduled) return;
|
|
_libraryPageLoadScheduled = true;
|
|
setState(() {
|
|
if (hasMoreLibrary) _libraryPageLimit += _libraryPageSize;
|
|
});
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
_libraryPageLoadScheduled = false;
|
|
});
|
|
}
|
|
|
|
QueueLibraryCounts _resolveQueueLibraryCounts(
|
|
AsyncValue<QueueLibraryCounts> value,
|
|
_QueueLibraryCountsRequest request,
|
|
) {
|
|
return value.maybeWhen(
|
|
data: (counts) {
|
|
_queueLibraryCountsCache[request] = counts;
|
|
_trimQueueLibraryCaches();
|
|
return counts;
|
|
},
|
|
orElse: () =>
|
|
_queueLibraryCountsCache[request] ??
|
|
const QueueLibraryCounts(
|
|
allTrackCount: 0,
|
|
albumCount: 0,
|
|
singleTrackCount: 0,
|
|
),
|
|
);
|
|
}
|
|
|
|
_QueueLibraryPageData _resolveQueueLibraryPageData(
|
|
AsyncValue<_QueueLibraryPageData>? value,
|
|
_QueueLibraryPageRequest request,
|
|
) {
|
|
if (value == null) {
|
|
return _queueLibraryPageDataCache[request] ??
|
|
const _QueueLibraryPageData();
|
|
}
|
|
return value.maybeWhen(
|
|
data: (data) {
|
|
_queueLibraryPageDataCache[request] = data;
|
|
_trimQueueLibraryCaches();
|
|
return data;
|
|
},
|
|
orElse: () =>
|
|
_queueLibraryPageDataCache[request] ?? const _QueueLibraryPageData(),
|
|
);
|
|
}
|
|
|
|
void _trimQueueLibraryCaches() {
|
|
const maxEntries = 24;
|
|
while (_queueLibraryCountsCache.length > maxEntries) {
|
|
_queueLibraryCountsCache.remove(_queueLibraryCountsCache.keys.first);
|
|
}
|
|
while (_queueLibraryPageDataCache.length > maxEntries) {
|
|
_queueLibraryPageDataCache.remove(_queueLibraryPageDataCache.keys.first);
|
|
}
|
|
}
|
|
|
|
bool _handleLibraryScrollNotification({
|
|
required ScrollNotification notification,
|
|
required String filterMode,
|
|
required bool hasMoreLibrary,
|
|
required bool isPageLoading,
|
|
}) {
|
|
if (isPageLoading || !hasMoreLibrary || notification.depth != 0) {
|
|
return false;
|
|
}
|
|
|
|
final metrics = notification.metrics;
|
|
if (metrics.maxScrollExtent <= 0) return false;
|
|
final threshold = metrics.maxScrollExtent * 0.7;
|
|
final nearEnd =
|
|
metrics.pixels >= threshold ||
|
|
metrics.extentAfter <= metrics.viewportDimension * 1.5;
|
|
if (!nearEnd) return false;
|
|
|
|
_loadMoreLibraryItems(hasMoreLibrary: hasMoreLibrary);
|
|
return false;
|
|
}
|
|
|
|
void _invalidateFilterContentCache() {
|
|
_filterContentDataCache.clear();
|
|
_filterCacheAllHistoryItems = null;
|
|
_filterCacheHistoryStats = null;
|
|
_filterCacheLocalLibraryItems = null;
|
|
_filterCacheCollectionState = null;
|
|
}
|
|
|
|
// ignore: unused_element
|
|
void _prepareFilterContentCache({
|
|
required List<DownloadHistoryItem> allHistoryItems,
|
|
required _HistoryStats historyStats,
|
|
required List<LocalLibraryItem> localLibraryItems,
|
|
required LibraryCollectionsState collectionState,
|
|
}) {
|
|
final isCacheValid =
|
|
identical(_filterCacheAllHistoryItems, allHistoryItems) &&
|
|
identical(_filterCacheHistoryStats, historyStats) &&
|
|
identical(_filterCacheLocalLibraryItems, localLibraryItems) &&
|
|
identical(_filterCacheCollectionState, collectionState) &&
|
|
_filterCacheSearchQuery == _searchQuery &&
|
|
_filterCacheSource == _filterSource &&
|
|
_filterCacheQuality == _filterQuality &&
|
|
_filterCacheFormat == _filterFormat &&
|
|
_filterCacheMetadata == _filterMetadata &&
|
|
_filterCacheSortMode == _sortMode;
|
|
|
|
if (isCacheValid) {
|
|
return;
|
|
}
|
|
|
|
_filterContentDataCache.clear();
|
|
_filterCacheAllHistoryItems = allHistoryItems;
|
|
_filterCacheHistoryStats = historyStats;
|
|
_filterCacheLocalLibraryItems = localLibraryItems;
|
|
_filterCacheCollectionState = collectionState;
|
|
_filterCacheSearchQuery = _searchQuery;
|
|
_filterCacheSource = _filterSource;
|
|
_filterCacheQuality = _filterQuality;
|
|
_filterCacheFormat = _filterFormat;
|
|
_filterCacheMetadata = _filterMetadata;
|
|
_filterCacheSortMode = _sortMode;
|
|
}
|
|
|
|
// ignore: unused_element
|
|
void _ensureHistoryCaches(
|
|
List<DownloadHistoryItem> items,
|
|
List<LocalLibraryItem> localItems,
|
|
_HistoryStats historyStats,
|
|
) {
|
|
final historyChanged = !identical(items, _historyItemsCache);
|
|
final localChanged = !identical(localItems, _localLibraryItemsCache);
|
|
|
|
if (!historyChanged && !localChanged) return;
|
|
|
|
_historyItemsCache = items;
|
|
_localLibraryItemsCache = localItems;
|
|
_historyStatsCache = historyStats;
|
|
if (historyChanged) {
|
|
_searchIndexCache.clear();
|
|
_cachedUnifiedDownloadedSource = null;
|
|
_cachedUnifiedDownloaded = const [];
|
|
_cachedDownloadedPathKeysSource = null;
|
|
_cachedDownloadedPathKeys = const <String>{};
|
|
}
|
|
if (localChanged) {
|
|
_localSearchIndexCache.clear();
|
|
_localPathMatchKeysCache.clear();
|
|
_localFilterItemsCache = null;
|
|
_localFilterQueryCache = '';
|
|
_filteredLocalItemsCache = const [];
|
|
_cachedLocalSinglesSource = null;
|
|
_cachedLocalSinglesAlbumCountsSource = null;
|
|
_cachedLocalSingles = const [];
|
|
_cachedUnifiedLocalSource = null;
|
|
_cachedUnifiedLocal = const [];
|
|
}
|
|
_unifiedItemsCache.clear();
|
|
_invalidateFilterContentCache();
|
|
|
|
if (historyChanged) {
|
|
final validPaths = items
|
|
.map((item) => _cleanFilePath(item.filePath))
|
|
.where((path) => path.isNotEmpty)
|
|
.toSet();
|
|
DownloadedEmbeddedCoverResolver.invalidatePathsNotIn(validPaths);
|
|
}
|
|
_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();
|
|
}
|
|
|
|
String _historySearchKeyForItem(DownloadHistoryItem item) {
|
|
final cached = _searchIndexCache[item.id];
|
|
if (cached != null) return cached;
|
|
|
|
final searchKey = _buildSearchKey(item);
|
|
_searchIndexCache[item.id] = searchKey;
|
|
while (_searchIndexCache.length > _maxSearchIndexCacheSize) {
|
|
_searchIndexCache.remove(_searchIndexCache.keys.first);
|
|
}
|
|
return searchKey;
|
|
}
|
|
|
|
String _localSearchKeyForItem(LocalLibraryItem item) {
|
|
final cached = _localSearchIndexCache[item.id];
|
|
if (cached != null) return cached;
|
|
|
|
final searchKey = _buildLocalSearchKey(item);
|
|
_localSearchIndexCache[item.id] = searchKey;
|
|
while (_localSearchIndexCache.length > _maxSearchIndexCacheSize) {
|
|
_localSearchIndexCache.remove(_localSearchIndexCache.keys.first);
|
|
}
|
|
return searchKey;
|
|
}
|
|
|
|
List<UnifiedLibraryItem> _unifiedDownloadedItems(
|
|
List<DownloadHistoryItem> items,
|
|
) {
|
|
if (identical(items, _cachedUnifiedDownloadedSource)) {
|
|
return _cachedUnifiedDownloaded;
|
|
}
|
|
final unified = items
|
|
.map(UnifiedLibraryItem.fromDownloadHistory)
|
|
.toList(growable: false);
|
|
_cachedUnifiedDownloadedSource = items;
|
|
_cachedUnifiedDownloaded = unified;
|
|
return unified;
|
|
}
|
|
|
|
List<UnifiedLibraryItem> _unifiedLocalItems(List<LocalLibraryItem> items) {
|
|
if (identical(items, _cachedUnifiedLocalSource)) {
|
|
return _cachedUnifiedLocal;
|
|
}
|
|
final unified = items
|
|
.map(UnifiedLibraryItem.fromLocalLibrary)
|
|
.toList(growable: false);
|
|
_cachedUnifiedLocalSource = items;
|
|
_cachedUnifiedLocal = unified;
|
|
return unified;
|
|
}
|
|
|
|
Set<String> _downloadedPathKeys(List<DownloadHistoryItem> historyItems) {
|
|
if (identical(historyItems, _cachedDownloadedPathKeysSource)) {
|
|
return _cachedDownloadedPathKeys;
|
|
}
|
|
final keys = <String>{};
|
|
for (final item in historyItems) {
|
|
keys.addAll(buildPathMatchKeys(item.filePath));
|
|
}
|
|
_cachedDownloadedPathKeysSource = historyItems;
|
|
_cachedDownloadedPathKeys = Set<String>.unmodifiable(keys);
|
|
return _cachedDownloadedPathKeys;
|
|
}
|
|
|
|
List<String> _localPathMatchKeys(LocalLibraryItem item) {
|
|
final cached = _localPathMatchKeysCache[item.id];
|
|
if (cached != null) return cached;
|
|
final keys = buildPathMatchKeys(item.filePath).toList(growable: false);
|
|
_localPathMatchKeysCache[item.id] = keys;
|
|
return keys;
|
|
}
|
|
|
|
List<LocalLibraryItem> _localSingleItems(
|
|
List<LocalLibraryItem> items,
|
|
Map<String, int> localAlbumCounts,
|
|
) {
|
|
if (identical(items, _cachedLocalSinglesSource) &&
|
|
identical(localAlbumCounts, _cachedLocalSinglesAlbumCountsSource)) {
|
|
return _cachedLocalSingles;
|
|
}
|
|
|
|
final singles = items
|
|
.where((item) => (localAlbumCounts[item.albumKey] ?? 0) == 1)
|
|
.toList(growable: false);
|
|
_cachedLocalSinglesSource = items;
|
|
_cachedLocalSinglesAlbumCountsSource = localAlbumCounts;
|
|
_cachedLocalSingles = singles;
|
|
return singles;
|
|
}
|
|
|
|
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 = _localSearchKeyForItem(item);
|
|
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 includeSearchKey = query.isNotEmpty;
|
|
final entries = List<List<String>>.generate(items.length, (index) {
|
|
final item = items[index];
|
|
final albumKey =
|
|
'${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}';
|
|
if (!includeSearchKey) {
|
|
return [item.id, albumKey];
|
|
}
|
|
final searchKey = _historySearchKeyForItem(item);
|
|
return [item.id, albumKey, searchKey];
|
|
}, growable: false);
|
|
final payload = <String, Object>{
|
|
'entries': entries,
|
|
'albumCounts': albumCounts,
|
|
'query': query,
|
|
};
|
|
|
|
compute(_filterHistoryInIsolate, payload).then((result) {
|
|
if (!mounted || requestId != _filterRequestId) return;
|
|
final itemsById = {for (final item in items) item.id: item};
|
|
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 = _historySearchKeyForItem(item);
|
|
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) {
|
|
HapticFeedback.selectionClick();
|
|
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(() {
|
|
_isPlaylistSelectionMode = false;
|
|
_selectedPlaylistIds.clear();
|
|
_isSelectionMode = true;
|
|
_selectedIds.add(itemId);
|
|
});
|
|
_hidePlaylistSelectionOverlay();
|
|
}
|
|
|
|
void _exitSelectionMode() {
|
|
setState(() {
|
|
_isSelectionMode = false;
|
|
_selectedIds.clear();
|
|
});
|
|
_hideSelectionOverlay();
|
|
}
|
|
|
|
void _toggleSelection(String itemId) {
|
|
var shouldHideOverlay = false;
|
|
setState(() {
|
|
if (_selectedIds.contains(itemId)) {
|
|
_selectedIds.remove(itemId);
|
|
if (_selectedIds.isEmpty) {
|
|
_isSelectionMode = false;
|
|
shouldHideOverlay = true;
|
|
}
|
|
} else {
|
|
_selectedIds.add(itemId);
|
|
}
|
|
});
|
|
if (shouldHideOverlay) {
|
|
_hideSelectionOverlay();
|
|
}
|
|
}
|
|
|
|
void _selectAll(List<UnifiedLibraryItem> items) {
|
|
setState(() {
|
|
_selectedIds.addAll(items.map((e) => e.id));
|
|
});
|
|
}
|
|
|
|
void _hideSelectionOverlay() {
|
|
_selectionOverlayEntry?.remove();
|
|
_selectionOverlayEntry = null;
|
|
}
|
|
|
|
void _syncSelectionOverlay({
|
|
required List<UnifiedLibraryItem> items,
|
|
required double bottomPadding,
|
|
}) {
|
|
if (!mounted) return;
|
|
if (!_isSelectionMode || _isPlaylistSelectionMode) {
|
|
_hideSelectionOverlay();
|
|
return;
|
|
}
|
|
|
|
_selectionOverlayItems = items;
|
|
_selectionOverlayBottomPadding = bottomPadding;
|
|
|
|
if (_selectionOverlayEntry != null) {
|
|
_selectionOverlayEntry!.markNeedsBuild();
|
|
return;
|
|
}
|
|
|
|
final overlay = Overlay.of(context, rootOverlay: true);
|
|
_selectionOverlayEntry = OverlayEntry(
|
|
builder: (overlayContext) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
return Positioned(
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
child: _AnimatedOverlayBottomBar(
|
|
child: Material(
|
|
color: Colors.transparent,
|
|
child: _buildSelectionBottomBar(
|
|
context,
|
|
colorScheme,
|
|
_selectionOverlayItems,
|
|
_selectionOverlayBottomPadding,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
overlay.insert(_selectionOverlayEntry!);
|
|
}
|
|
|
|
void _hidePlaylistSelectionOverlay() {
|
|
_playlistSelectionOverlayEntry?.remove();
|
|
_playlistSelectionOverlayEntry = null;
|
|
}
|
|
|
|
void _syncPlaylistSelectionOverlay({
|
|
required List<UserPlaylistCollection> playlists,
|
|
required double bottomPadding,
|
|
}) {
|
|
if (!mounted) return;
|
|
if (!_isPlaylistSelectionMode || _isSelectionMode) {
|
|
_hidePlaylistSelectionOverlay();
|
|
return;
|
|
}
|
|
|
|
_playlistSelectionOverlayItems = playlists;
|
|
_playlistSelectionOverlayBottomPadding = bottomPadding;
|
|
|
|
if (_playlistSelectionOverlayEntry != null) {
|
|
_playlistSelectionOverlayEntry!.markNeedsBuild();
|
|
return;
|
|
}
|
|
|
|
final overlay = Overlay.of(context, rootOverlay: true);
|
|
_playlistSelectionOverlayEntry = OverlayEntry(
|
|
builder: (overlayContext) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
return Positioned(
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
child: _AnimatedOverlayBottomBar(
|
|
child: Material(
|
|
color: Colors.transparent,
|
|
child: _buildPlaylistSelectionBottomBar(
|
|
context,
|
|
colorScheme,
|
|
_playlistSelectionOverlayItems,
|
|
_playlistSelectionOverlayBottomPadding,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
overlay.insert(_playlistSelectionOverlayEntry!);
|
|
}
|
|
|
|
void _enterPlaylistSelectionMode(String playlistId) {
|
|
HapticFeedback.mediumImpact();
|
|
setState(() {
|
|
_isSelectionMode = false;
|
|
_selectedIds.clear();
|
|
_isPlaylistSelectionMode = true;
|
|
_selectedPlaylistIds.add(playlistId);
|
|
});
|
|
_hideSelectionOverlay();
|
|
}
|
|
|
|
void _exitPlaylistSelectionMode() {
|
|
setState(() {
|
|
_isPlaylistSelectionMode = false;
|
|
_selectedPlaylistIds.clear();
|
|
});
|
|
_hidePlaylistSelectionOverlay();
|
|
}
|
|
|
|
void _togglePlaylistSelection(String playlistId) {
|
|
var shouldHideOverlay = false;
|
|
setState(() {
|
|
if (_selectedPlaylistIds.contains(playlistId)) {
|
|
_selectedPlaylistIds.remove(playlistId);
|
|
if (_selectedPlaylistIds.isEmpty) {
|
|
_isPlaylistSelectionMode = false;
|
|
shouldHideOverlay = true;
|
|
}
|
|
} else {
|
|
_selectedPlaylistIds.add(playlistId);
|
|
}
|
|
});
|
|
if (shouldHideOverlay) {
|
|
_hidePlaylistSelectionOverlay();
|
|
}
|
|
}
|
|
|
|
void _selectAllPlaylists(List<UserPlaylistCollection> playlists) {
|
|
setState(() {
|
|
_selectedPlaylistIds.addAll(playlists.map((e) => e.id));
|
|
});
|
|
}
|
|
|
|
Future<void> _downloadAllSelectedPlaylists(BuildContext context) async {
|
|
final collectionsState = ref.read(libraryCollectionsProvider);
|
|
final selectedPlaylists = collectionsState.playlists
|
|
.where((p) => _selectedPlaylistIds.contains(p.id))
|
|
.toList();
|
|
|
|
final totalTracks = selectedPlaylists.fold<int>(
|
|
0,
|
|
(sum, p) => sum + p.tracks.length,
|
|
);
|
|
|
|
if (totalTracks == 0) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(context.l10n.snackbarSelectedPlaylistsEmpty)),
|
|
);
|
|
return;
|
|
}
|
|
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: Text(ctx.l10n.dialogDownloadAllTitle),
|
|
content: Text(
|
|
ctx.l10n.dialogDownloadPlaylistsMessage(
|
|
totalTracks,
|
|
selectedPlaylists.length,
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx, false),
|
|
child: Text(ctx.l10n.dialogCancel),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.pop(ctx, true),
|
|
child: Text(ctx.l10n.dialogDownload),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
if (confirmed != true || !context.mounted) return;
|
|
|
|
final settings = ref.read(settingsProvider);
|
|
final extensionState = ref.read(extensionProvider);
|
|
final queueNotifier = ref.read(downloadQueueProvider.notifier);
|
|
|
|
void enqueueAll({String? qualityOverride, String? service}) {
|
|
final svc =
|
|
service ??
|
|
resolveEffectiveDownloadService(
|
|
settings.defaultService,
|
|
extensionState,
|
|
);
|
|
if (svc.isEmpty) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(context.l10n.extensionsNoDownloadProvider)),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
for (final playlist in selectedPlaylists) {
|
|
final tracks = playlist.tracks.map((e) => e.track).toList();
|
|
queueNotifier.addMultipleToQueue(
|
|
tracks,
|
|
svc,
|
|
qualityOverride: qualityOverride,
|
|
playlistName: playlist.name,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (settings.askQualityBeforeDownload) {
|
|
DownloadServicePicker.show(
|
|
context,
|
|
trackName: context.l10n.tracksCount(totalTracks),
|
|
artistName: context.l10n.playlistsCount(selectedPlaylists.length),
|
|
onSelect: (quality, service) {
|
|
enqueueAll(qualityOverride: quality, service: service);
|
|
if (!mounted) return;
|
|
_exitPlaylistSelectionMode();
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
context.l10n.snackbarAddedTracksToQueue(totalTracks),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
} else {
|
|
enqueueAll();
|
|
_exitPlaylistSelectionMode();
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(context.l10n.snackbarAddedTracksToQueue(totalTracks)),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _deleteSelectedPlaylists(BuildContext context) async {
|
|
final count = _selectedPlaylistIds.length;
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: Text(ctx.l10n.collectionDeletePlaylist),
|
|
content: Text(
|
|
'Delete $count ${count == 1 ? 'playlist' : 'playlists'}?',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx, false),
|
|
child: Text(ctx.l10n.dialogCancel),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.pop(ctx, true),
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: Theme.of(context).colorScheme.error,
|
|
),
|
|
child: Text(ctx.l10n.dialogDelete),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
if (confirmed != true || !context.mounted) return;
|
|
|
|
final notifier = ref.read(libraryCollectionsProvider.notifier);
|
|
for (final id in _selectedPlaylistIds.toList()) {
|
|
await notifier.deletePlaylist(id);
|
|
}
|
|
|
|
if (!context.mounted) return;
|
|
_exitPlaylistSelectionMode();
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
'$count ${count == 1 ? 'playlist' : 'playlists'} deleted',
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildPlaylistSelectionBottomBar(
|
|
BuildContext context,
|
|
ColorScheme colorScheme,
|
|
List<UserPlaylistCollection> playlists,
|
|
double bottomPadding,
|
|
) {
|
|
final selectedCount = _selectedPlaylistIds.length;
|
|
final allSelected =
|
|
selectedCount == playlists.length && playlists.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: _exitPlaylistSelectionMode,
|
|
tooltip: MaterialLocalizations.of(
|
|
context,
|
|
).closeButtonTooltip,
|
|
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.selectionSelected(selectedCount),
|
|
style: Theme.of(context).textTheme.titleMedium
|
|
?.copyWith(fontWeight: FontWeight.bold),
|
|
),
|
|
Text(
|
|
allSelected
|
|
? context.l10n.selectionAllPlaylistsSelected
|
|
: context.l10n.selectionTapPlaylistsToSelect,
|
|
style: Theme.of(context).textTheme.bodySmall
|
|
?.copyWith(color: colorScheme.onSurfaceVariant),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
TextButton.icon(
|
|
onPressed: () {
|
|
if (allSelected) {
|
|
_exitPlaylistSelectionMode();
|
|
} else {
|
|
_selectAllPlaylists(playlists);
|
|
}
|
|
},
|
|
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: 12),
|
|
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: FilledButton.icon(
|
|
onPressed: selectedCount > 0
|
|
? () => _downloadAllSelectedPlaylists(context)
|
|
: null,
|
|
icon: const Icon(Icons.download_rounded),
|
|
label: Text(
|
|
selectedCount > 0
|
|
? context.l10n.bulkDownloadPlaylistsButton(
|
|
selectedCount,
|
|
)
|
|
: context.l10n.bulkDownloadSelectPlaylists,
|
|
),
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: selectedCount > 0
|
|
? colorScheme.primary
|
|
: colorScheme.surfaceContainerHighest,
|
|
foregroundColor: selectedCount > 0
|
|
? colorScheme.onPrimary
|
|
: colorScheme.onSurfaceVariant,
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: FilledButton.icon(
|
|
onPressed: selectedCount > 0
|
|
? () => _deleteSelectedPlaylists(context)
|
|
: null,
|
|
icon: const Icon(Icons.delete_outline),
|
|
label: Text(
|
|
selectedCount > 0
|
|
? 'Delete $selectedCount ${selectedCount == 1 ? 'playlist' : 'playlists'}'
|
|
: context.l10n.selectionSelectPlaylistsToDelete,
|
|
),
|
|
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),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
String _getQualityBadgeText(String quality) {
|
|
final q = quality.trim().toLowerCase();
|
|
if (q.contains('bit')) {
|
|
return quality.split('/').first;
|
|
}
|
|
|
|
final bitrateTextMatch = RegExp(
|
|
r'(\d+)\s*k(?:bps)?',
|
|
caseSensitive: false,
|
|
).firstMatch(quality);
|
|
if (bitrateTextMatch != null) {
|
|
return '${bitrateTextMatch.group(1)}k';
|
|
}
|
|
|
|
final bitrateIdMatch = RegExp(r'_(\d+)$').firstMatch(q);
|
|
if (bitrateIdMatch != null) {
|
|
return '${bitrateIdMatch.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;
|
|
final itemsById = {for (final item in allItems) item.id: item};
|
|
|
|
int deletedCount = 0;
|
|
for (final id in _selectedIds) {
|
|
final item = itemsById[id];
|
|
if (item != null) {
|
|
try {
|
|
final cleanPath = _cleanFilePath(item.filePath);
|
|
await deleteFile(cleanPath);
|
|
} catch (_) {}
|
|
|
|
if (item.source == LibraryItemSource.downloaded) {
|
|
historyNotifier.removeFromHistory(item.historyItem!.id);
|
|
} else {
|
|
await localLibraryDb.deleteByPath(item.filePath);
|
|
}
|
|
deletedCount++;
|
|
}
|
|
}
|
|
|
|
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)),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
String _cleanFilePath(String? filePath) {
|
|
return DownloadedEmbeddedCoverResolver.cleanFilePath(filePath);
|
|
}
|
|
|
|
Future<int?> _readFileModTimeMillis(String? filePath) async {
|
|
return DownloadedEmbeddedCoverResolver.readFileModTimeMillis(filePath);
|
|
}
|
|
|
|
void _onEmbeddedCoverChanged() {
|
|
if (!mounted || _embeddedCoverRefreshScheduled) return;
|
|
_embeddedCoverRefreshScheduled = true;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
_embeddedCoverRefreshScheduled = false;
|
|
if (mounted) {
|
|
// Increment version to trigger ValueListenableBuilder rebuilds
|
|
// on cover images only, instead of rebuilding the entire widget tree.
|
|
_embeddedCoverVersion.value++;
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> _scheduleDownloadedEmbeddedCoverRefreshForPath(
|
|
String? filePath, {
|
|
int? beforeModTime,
|
|
bool force = false,
|
|
}) async {
|
|
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
|
|
filePath,
|
|
beforeModTime: beforeModTime,
|
|
force: force,
|
|
onChanged: _onEmbeddedCoverChanged,
|
|
);
|
|
}
|
|
|
|
String? _resolveDownloadedEmbeddedCoverPath(String? filePath) {
|
|
return DownloadedEmbeddedCoverResolver.resolve(
|
|
filePath,
|
|
onChanged: _onEmbeddedCoverChanged,
|
|
);
|
|
}
|
|
|
|
ValueListenable<bool> _fileExistsListenable(String? filePath) {
|
|
return _fileExistsCache.listenable(filePath);
|
|
}
|
|
|
|
int get _activeFilterCount {
|
|
int count = 0;
|
|
if (_filterSource != null) count++;
|
|
if (_filterQuality != null) count++;
|
|
if (_filterFormat != null) count++;
|
|
if (_filterMetadata != null) count++;
|
|
return count;
|
|
}
|
|
|
|
void _resetFilters() {
|
|
setState(() {
|
|
_filterSource = null;
|
|
_filterQuality = null;
|
|
_filterFormat = null;
|
|
_filterMetadata = null;
|
|
_sortMode = 'latest';
|
|
_libraryPageLimit = _libraryPageSize;
|
|
_unifiedItemsCache.clear();
|
|
_invalidateFilterContentCache();
|
|
});
|
|
}
|
|
|
|
String _fileExtLower(String filePath) {
|
|
final dotIndex = filePath.lastIndexOf('.');
|
|
if (dotIndex < 0 || dotIndex == filePath.length - 1) {
|
|
return '';
|
|
}
|
|
return filePath.substring(dotIndex + 1).toLowerCase();
|
|
}
|
|
|
|
List<UnifiedLibraryItem> _applyAdvancedFilters(
|
|
List<UnifiedLibraryItem> items,
|
|
) {
|
|
List<UnifiedLibraryItem> filtered;
|
|
if (_activeFilterCount == 0) {
|
|
filtered = items;
|
|
} else {
|
|
filtered = 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 = _fileExtLower(item.filePath);
|
|
if (ext != _filterFormat) return false;
|
|
}
|
|
|
|
if (!_queueUnifiedItemMatchesMetadataFilter(
|
|
item,
|
|
_filterMetadata,
|
|
)) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
})
|
|
.toList(growable: false);
|
|
}
|
|
|
|
return _applySorting(filtered);
|
|
}
|
|
|
|
List<UnifiedLibraryItem> _applySorting(List<UnifiedLibraryItem> items) {
|
|
if (_sortMode == 'latest') {
|
|
return items;
|
|
}
|
|
final sorted = List<UnifiedLibraryItem>.of(items);
|
|
switch (_sortMode) {
|
|
case 'oldest':
|
|
sorted.sort((a, b) => a.addedAt.compareTo(b.addedAt));
|
|
case 'a-z':
|
|
sorted.sort(
|
|
(a, b) =>
|
|
a.trackName.toLowerCase().compareTo(b.trackName.toLowerCase()),
|
|
);
|
|
case 'z-a':
|
|
sorted.sort(
|
|
(a, b) =>
|
|
b.trackName.toLowerCase().compareTo(a.trackName.toLowerCase()),
|
|
);
|
|
case 'artist-asc':
|
|
sorted.sort((a, b) {
|
|
final comparison = _queueCompareOptionalText(
|
|
a.artistName,
|
|
b.artistName,
|
|
);
|
|
if (comparison != 0) {
|
|
return comparison;
|
|
}
|
|
return _queueCompareOptionalText(a.trackName, b.trackName);
|
|
});
|
|
case 'artist-desc':
|
|
sorted.sort((a, b) {
|
|
final comparison = _queueCompareOptionalText(
|
|
a.artistName,
|
|
b.artistName,
|
|
descending: true,
|
|
);
|
|
if (comparison != 0) {
|
|
return comparison;
|
|
}
|
|
return _queueCompareOptionalText(a.trackName, b.trackName);
|
|
});
|
|
case 'album-asc':
|
|
sorted.sort((a, b) {
|
|
final comparison = _queueCompareOptionalText(
|
|
a.albumName,
|
|
b.albumName,
|
|
);
|
|
if (comparison != 0) {
|
|
return comparison;
|
|
}
|
|
return _queueCompareOptionalText(a.trackName, b.trackName);
|
|
});
|
|
case 'album-desc':
|
|
sorted.sort((a, b) {
|
|
final comparison = _queueCompareOptionalText(
|
|
a.albumName,
|
|
b.albumName,
|
|
descending: true,
|
|
);
|
|
if (comparison != 0) {
|
|
return comparison;
|
|
}
|
|
return _queueCompareOptionalText(a.trackName, b.trackName);
|
|
});
|
|
case 'release-oldest':
|
|
sorted.sort((a, b) {
|
|
final comparison = _queueCompareOptionalDate(
|
|
_queueParseReleaseDate(a.releaseDate),
|
|
_queueParseReleaseDate(b.releaseDate),
|
|
);
|
|
if (comparison != 0) {
|
|
return comparison;
|
|
}
|
|
return _queueCompareOptionalText(a.trackName, b.trackName);
|
|
});
|
|
case 'release-newest':
|
|
sorted.sort((a, b) {
|
|
final comparison = _queueCompareOptionalDate(
|
|
_queueParseReleaseDate(a.releaseDate),
|
|
_queueParseReleaseDate(b.releaseDate),
|
|
descending: true,
|
|
);
|
|
if (comparison != 0) {
|
|
return comparison;
|
|
}
|
|
return _queueCompareOptionalText(a.trackName, b.trackName);
|
|
});
|
|
case 'genre-asc':
|
|
sorted.sort((a, b) {
|
|
final comparison = _queueCompareOptionalText(a.genre, b.genre);
|
|
if (comparison != 0) {
|
|
return comparison;
|
|
}
|
|
return _queueCompareOptionalText(a.trackName, b.trackName);
|
|
});
|
|
case 'genre-desc':
|
|
sorted.sort((a, b) {
|
|
final comparison = _queueCompareOptionalText(
|
|
a.genre,
|
|
b.genre,
|
|
descending: true,
|
|
);
|
|
if (comparison != 0) {
|
|
return comparison;
|
|
}
|
|
return _queueCompareOptionalText(a.trackName, b.trackName);
|
|
});
|
|
}
|
|
return sorted;
|
|
}
|
|
|
|
Set<String> _getAvailableFormats(List<UnifiedLibraryItem> items) {
|
|
final formats = <String>{};
|
|
for (final item in items) {
|
|
final ext = _fileExtLower(item.filePath);
|
|
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? tempMetadata = _filterMetadata;
|
|
String tempSortMode = _sortMode;
|
|
|
|
showModalBottomSheet<void>(
|
|
context: context,
|
|
useRootNavigator: true,
|
|
isScrollControlled: true,
|
|
backgroundColor: colorScheme.surfaceContainerLow,
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
|
),
|
|
builder: (context) => StatefulBuilder(
|
|
builder: (context, setSheetState) {
|
|
return SafeArea(
|
|
child: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final maxSheetHeight = constraints.maxHeight * 0.9;
|
|
return ConstrainedBox(
|
|
constraints: BoxConstraints(maxHeight: maxSheetHeight),
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
|
child: SingleChildScrollView(
|
|
physics: const ClampingScrollPhysics(),
|
|
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;
|
|
tempMetadata = null;
|
|
tempSortMode = 'latest';
|
|
});
|
|
},
|
|
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.libraryFilterMetadata,
|
|
style: Theme.of(context).textTheme.titleSmall
|
|
?.copyWith(fontWeight: FontWeight.w600),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: [
|
|
FilterChip(
|
|
label: Text(context.l10n.libraryFilterAll),
|
|
selected: tempMetadata == null,
|
|
onSelected: (_) =>
|
|
setSheetState(() => tempMetadata = null),
|
|
),
|
|
FilterChip(
|
|
label: Text(
|
|
context.l10n.libraryFilterMetadataComplete,
|
|
),
|
|
selected: tempMetadata == 'complete',
|
|
onSelected: (_) => setSheetState(
|
|
() => tempMetadata = 'complete',
|
|
),
|
|
),
|
|
FilterChip(
|
|
label: Text(
|
|
context.l10n.libraryFilterMetadataMissingAny,
|
|
),
|
|
selected: tempMetadata == 'missing-any',
|
|
onSelected: (_) => setSheetState(
|
|
() => tempMetadata = 'missing-any',
|
|
),
|
|
),
|
|
FilterChip(
|
|
label: Text(
|
|
context.l10n.libraryFilterMetadataMissingYear,
|
|
),
|
|
selected: tempMetadata == 'missing-year',
|
|
onSelected: (_) => setSheetState(
|
|
() => tempMetadata = 'missing-year',
|
|
),
|
|
),
|
|
FilterChip(
|
|
label: Text(
|
|
context
|
|
.l10n
|
|
.libraryFilterMetadataMissingGenre,
|
|
),
|
|
selected: tempMetadata == 'missing-genre',
|
|
onSelected: (_) => setSheetState(
|
|
() => tempMetadata = 'missing-genre',
|
|
),
|
|
),
|
|
FilterChip(
|
|
label: Text(
|
|
context
|
|
.l10n
|
|
.libraryFilterMetadataMissingAlbumArtist,
|
|
),
|
|
selected:
|
|
tempMetadata == 'missing-album-artist',
|
|
onSelected: (_) => setSheetState(
|
|
() => tempMetadata = 'missing-album-artist',
|
|
),
|
|
),
|
|
FilterChip(
|
|
label: const Text('Missing track number'),
|
|
selected:
|
|
tempMetadata == 'missing-track-number',
|
|
onSelected: (_) => setSheetState(
|
|
() => tempMetadata = 'missing-track-number',
|
|
),
|
|
),
|
|
FilterChip(
|
|
label: const Text('Missing disc number'),
|
|
selected: tempMetadata == 'missing-disc-number',
|
|
onSelected: (_) => setSheetState(
|
|
() => tempMetadata = 'missing-disc-number',
|
|
),
|
|
),
|
|
FilterChip(
|
|
label: const Text('Missing artist'),
|
|
selected: tempMetadata == 'missing-artist',
|
|
onSelected: (_) => setSheetState(
|
|
() => tempMetadata = 'missing-artist',
|
|
),
|
|
),
|
|
FilterChip(
|
|
label: const Text('Incorrect ISRC format'),
|
|
selected:
|
|
tempMetadata == 'incorrect-isrc-format',
|
|
onSelected: (_) => setSheetState(
|
|
() => tempMetadata = 'incorrect-isrc-format',
|
|
),
|
|
),
|
|
FilterChip(
|
|
label: const Text('Missing label'),
|
|
selected: tempMetadata == 'missing-label',
|
|
onSelected: (_) => setSheetState(
|
|
() => tempMetadata = 'missing-label',
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
Text(
|
|
context.l10n.libraryFilterSort,
|
|
style: Theme.of(context).textTheme.titleSmall
|
|
?.copyWith(fontWeight: FontWeight.w600),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Wrap(
|
|
spacing: 8,
|
|
children: [
|
|
FilterChip(
|
|
label: Text(
|
|
context.l10n.libraryFilterSortLatest,
|
|
),
|
|
selected: tempSortMode == 'latest',
|
|
onSelected: (_) => setSheetState(
|
|
() => tempSortMode = 'latest',
|
|
),
|
|
),
|
|
FilterChip(
|
|
label: Text(
|
|
context.l10n.libraryFilterSortOldest,
|
|
),
|
|
selected: tempSortMode == 'oldest',
|
|
onSelected: (_) => setSheetState(
|
|
() => tempSortMode = 'oldest',
|
|
),
|
|
),
|
|
FilterChip(
|
|
label: Text(context.l10n.searchSortTitleAZ),
|
|
selected: tempSortMode == 'a-z',
|
|
onSelected: (_) =>
|
|
setSheetState(() => tempSortMode = 'a-z'),
|
|
),
|
|
FilterChip(
|
|
label: Text(context.l10n.searchSortTitleZA),
|
|
selected: tempSortMode == 'z-a',
|
|
onSelected: (_) =>
|
|
setSheetState(() => tempSortMode = 'z-a'),
|
|
),
|
|
FilterChip(
|
|
label: Text(context.l10n.searchSortArtistAZ),
|
|
selected: tempSortMode == 'artist-asc',
|
|
onSelected: (_) => setSheetState(
|
|
() => tempSortMode = 'artist-asc',
|
|
),
|
|
),
|
|
FilterChip(
|
|
label: Text(context.l10n.searchSortArtistZA),
|
|
selected: tempSortMode == 'artist-desc',
|
|
onSelected: (_) => setSheetState(
|
|
() => tempSortMode = 'artist-desc',
|
|
),
|
|
),
|
|
FilterChip(
|
|
label: Text(
|
|
context.l10n.libraryFilterSortAlbumAsc,
|
|
),
|
|
selected: tempSortMode == 'album-asc',
|
|
onSelected: (_) => setSheetState(
|
|
() => tempSortMode = 'album-asc',
|
|
),
|
|
),
|
|
FilterChip(
|
|
label: Text(
|
|
context.l10n.libraryFilterSortAlbumDesc,
|
|
),
|
|
selected: tempSortMode == 'album-desc',
|
|
onSelected: (_) => setSheetState(
|
|
() => tempSortMode = 'album-desc',
|
|
),
|
|
),
|
|
FilterChip(
|
|
label: Text(context.l10n.searchSortDateNewest),
|
|
selected: tempSortMode == 'release-newest',
|
|
onSelected: (_) => setSheetState(
|
|
() => tempSortMode = 'release-newest',
|
|
),
|
|
),
|
|
FilterChip(
|
|
label: Text(context.l10n.searchSortDateOldest),
|
|
selected: tempSortMode == 'release-oldest',
|
|
onSelected: (_) => setSheetState(
|
|
() => tempSortMode = 'release-oldest',
|
|
),
|
|
),
|
|
FilterChip(
|
|
label: Text(
|
|
context.l10n.libraryFilterSortGenreAsc,
|
|
),
|
|
selected: tempSortMode == 'genre-asc',
|
|
onSelected: (_) => setSheetState(
|
|
() => tempSortMode = 'genre-asc',
|
|
),
|
|
),
|
|
FilterChip(
|
|
label: Text(
|
|
context.l10n.libraryFilterSortGenreDesc,
|
|
),
|
|
selected: tempSortMode == 'genre-desc',
|
|
onSelected: (_) => setSheetState(
|
|
() => tempSortMode = 'genre-desc',
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 24),
|
|
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: FilledButton(
|
|
onPressed: () {
|
|
setState(() {
|
|
_filterSource = tempSource;
|
|
_filterQuality = tempQuality;
|
|
_filterFormat = tempFormat;
|
|
_filterMetadata = tempMetadata;
|
|
_sortMode = tempSortMode;
|
|
_libraryPageLimit = _libraryPageSize;
|
|
_unifiedItemsCache.clear();
|
|
_invalidateFilterContentCache();
|
|
});
|
|
Navigator.pop(context);
|
|
},
|
|
child: Text(context.l10n.libraryFilterApply),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _openFile(
|
|
String filePath, {
|
|
String title = '',
|
|
String artist = '',
|
|
String album = '',
|
|
String coverUrl = '',
|
|
}) async {
|
|
final cleanPath = _cleanFilePath(filePath);
|
|
try {
|
|
final fallbackTitle = cleanPath.split('/').last.split('\\').last;
|
|
await ref
|
|
.read(playbackProvider.notifier)
|
|
.playLocalPath(
|
|
path: cleanPath,
|
|
title: title.isNotEmpty ? title : fallbackTitle,
|
|
artist: artist,
|
|
album: album,
|
|
coverUrl: coverUrl,
|
|
);
|
|
} 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;
|
|
}
|
|
final dpr = MediaQuery.devicePixelRatioOf(
|
|
context,
|
|
).clamp(1.0, 3.0).toDouble();
|
|
final targetSize = (360 * dpr).round().clamp(512, 1024).toInt();
|
|
precacheImage(
|
|
ResizeImage(
|
|
cachedCoverImageProvider(url),
|
|
width: targetSize,
|
|
height: targetSize,
|
|
),
|
|
context,
|
|
);
|
|
}
|
|
|
|
Future<void> _navigateToMetadataScreen(DownloadItem item) async {
|
|
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,
|
|
),
|
|
);
|
|
|
|
final navigator = Navigator.of(context);
|
|
_precacheCover(historyItem.coverUrl);
|
|
_searchFocusNode.unfocus();
|
|
final beforeModTime = await _readFileModTimeMillis(historyItem.filePath);
|
|
if (!mounted) return;
|
|
final result = await navigator.push(
|
|
slidePageRoute<bool>(page: TrackMetadataScreen(item: historyItem)),
|
|
);
|
|
_searchFocusNode.unfocus();
|
|
if (result == true) {
|
|
await _scheduleDownloadedEmbeddedCoverRefreshForPath(
|
|
historyItem.filePath,
|
|
beforeModTime: beforeModTime,
|
|
force: true,
|
|
);
|
|
return;
|
|
}
|
|
await _scheduleDownloadedEmbeddedCoverRefreshForPath(
|
|
historyItem.filePath,
|
|
beforeModTime: beforeModTime,
|
|
);
|
|
}
|
|
|
|
Future<void> _navigateToHistoryMetadataScreen(
|
|
DownloadHistoryItem item, {
|
|
List<DownloadHistoryItem>? navigationItems,
|
|
int? navigationIndex,
|
|
}) async {
|
|
final navigator = Navigator.of(context);
|
|
_precacheCover(item.coverUrl);
|
|
_searchFocusNode.unfocus();
|
|
final beforeModTime = await _readFileModTimeMillis(item.filePath);
|
|
if (!mounted) return;
|
|
final result = await navigator.push(
|
|
slidePageRoute<bool>(
|
|
page: TrackMetadataScreen(
|
|
item: item,
|
|
historyNavigationItems: navigationItems,
|
|
navigationIndex: navigationIndex,
|
|
coverHeroTag: 'cover_lib_dl_${item.id}',
|
|
),
|
|
),
|
|
);
|
|
_searchFocusNode.unfocus();
|
|
if (result == true) {
|
|
await _scheduleDownloadedEmbeddedCoverRefreshForPath(
|
|
item.filePath,
|
|
beforeModTime: beforeModTime,
|
|
force: true,
|
|
);
|
|
return;
|
|
}
|
|
await _scheduleDownloadedEmbeddedCoverRefreshForPath(
|
|
item.filePath,
|
|
beforeModTime: beforeModTime,
|
|
);
|
|
}
|
|
|
|
void _navigateToLocalMetadataScreen(
|
|
LocalLibraryItem item, {
|
|
List<LocalLibraryItem>? navigationItems,
|
|
int? navigationIndex,
|
|
}) {
|
|
_searchFocusNode.unfocus();
|
|
Navigator.push(
|
|
context,
|
|
slidePageRoute<void>(
|
|
page: TrackMetadataScreen(
|
|
localItem: item,
|
|
localNavigationItems: navigationItems,
|
|
navigationIndex: navigationIndex,
|
|
coverHeroTag: 'cover_lib_local_${item.id}',
|
|
),
|
|
),
|
|
).then((_) => _searchFocusNode.unfocus());
|
|
}
|
|
|
|
List<DownloadHistoryItem> _filterHistoryItems(
|
|
List<DownloadHistoryItem> items,
|
|
String filterMode,
|
|
Map<String, int> albumCounts, [
|
|
String searchQuery = '',
|
|
]) {
|
|
var filteredItems = items;
|
|
if (searchQuery.isNotEmpty) {
|
|
final query = searchQuery;
|
|
filteredItems = items.where((item) {
|
|
final searchKey = _historySearchKeyForItem(item);
|
|
return searchKey.contains(query);
|
|
}).toList();
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
void _navigateWithUnfocus(Route<dynamic> route) {
|
|
_searchFocusNode.unfocus();
|
|
Navigator.of(context).push(route).then((_) => _searchFocusNode.unfocus());
|
|
}
|
|
|
|
void _navigateToDownloadedAlbum(_GroupedAlbum album) {
|
|
_navigateWithUnfocus(
|
|
slidePageRoute(
|
|
page: DownloadedAlbumScreen(
|
|
albumName: album.albumName,
|
|
artistName: album.artistName,
|
|
coverUrl: album.coverUrl,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _navigateToLocalAlbum(_GroupedLocalAlbum album) async {
|
|
var tracks = album.tracks;
|
|
if (tracks.isEmpty && album.displayTrackCount > 0) {
|
|
final rows = await LibraryDatabase.instance.getQueueLocalAlbumTracks(
|
|
album.albumName,
|
|
album.artistName,
|
|
);
|
|
tracks = rows.map(LocalLibraryItem.fromJson).toList(growable: false);
|
|
if (!mounted) return;
|
|
}
|
|
_navigateWithUnfocus(
|
|
slidePageRoute(
|
|
page: LocalAlbumScreen(
|
|
albumName: album.albumName,
|
|
artistName: album.artistName,
|
|
coverPath: album.coverPath,
|
|
tracks: tracks,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _openWishlistFolder() {
|
|
_navigateWithUnfocus(
|
|
MaterialPageRoute(
|
|
builder: (_) => const LibraryTracksFolderScreen(
|
|
mode: LibraryTracksFolderMode.wishlist,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _openLovedFolder() {
|
|
_navigateWithUnfocus(
|
|
MaterialPageRoute(
|
|
builder: (_) => const LibraryTracksFolderScreen(
|
|
mode: LibraryTracksFolderMode.loved,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _openFavoriteArtistsFolder() {
|
|
_navigateWithUnfocus(
|
|
MaterialPageRoute(builder: (_) => const FavoriteArtistsScreen()),
|
|
);
|
|
}
|
|
|
|
void _openPlaylistById(String playlistId) {
|
|
_navigateWithUnfocus(
|
|
MaterialPageRoute(
|
|
builder: (_) => LibraryTracksFolderScreen(
|
|
mode: LibraryTracksFolderMode.playlist,
|
|
playlistId: playlistId,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _showCreatePlaylistDialog(BuildContext context) async {
|
|
final controller = TextEditingController();
|
|
final formKey = GlobalKey<FormState>();
|
|
|
|
final playlistName = await showDialog<String>(
|
|
context: context,
|
|
builder: (dialogContext) {
|
|
return AlertDialog(
|
|
title: Text(dialogContext.l10n.collectionCreatePlaylist),
|
|
content: Form(
|
|
key: formKey,
|
|
child: TextFormField(
|
|
controller: controller,
|
|
autofocus: true,
|
|
decoration: InputDecoration(
|
|
hintText: dialogContext.l10n.collectionPlaylistNameHint,
|
|
),
|
|
validator: (value) {
|
|
final trimmed = value?.trim() ?? '';
|
|
if (trimmed.isEmpty) {
|
|
return dialogContext.l10n.collectionPlaylistNameRequired;
|
|
}
|
|
return null;
|
|
},
|
|
onFieldSubmitted: (_) {
|
|
if (formKey.currentState?.validate() != true) return;
|
|
Navigator.of(dialogContext).pop(controller.text.trim());
|
|
},
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(dialogContext).pop(),
|
|
child: Text(dialogContext.l10n.dialogCancel),
|
|
),
|
|
FilledButton(
|
|
onPressed: () {
|
|
if (formKey.currentState?.validate() != true) return;
|
|
Navigator.of(dialogContext).pop(controller.text.trim());
|
|
},
|
|
child: Text(dialogContext.l10n.actionCreate),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
|
|
if (playlistName == null || playlistName.isEmpty) return;
|
|
await ref
|
|
.read(libraryCollectionsProvider.notifier)
|
|
.createPlaylist(playlistName);
|
|
}
|
|
|
|
/// Pass a finite [size] (e.g. 56) for list view, or `null` for grid view
|
|
/// where the widget should expand to fill its parent.
|
|
Widget _buildPlaylistCover(
|
|
BuildContext context,
|
|
UserPlaylistCollection playlist,
|
|
ColorScheme colorScheme, [
|
|
double? size,
|
|
]) {
|
|
final borderRadius = BorderRadius.circular(8);
|
|
final dpr = MediaQuery.devicePixelRatioOf(context);
|
|
final cacheExtent = size != null
|
|
? (size * dpr).round().clamp(64, 1024)
|
|
: 420;
|
|
final placeholder = _playlistIconFallback(colorScheme, size);
|
|
|
|
final customCoverPath = playlist.coverImagePath;
|
|
if (customCoverPath != null && customCoverPath.isNotEmpty) {
|
|
return ClipRRect(
|
|
borderRadius: borderRadius,
|
|
child: Image.file(
|
|
File(customCoverPath),
|
|
width: size,
|
|
height: size,
|
|
fit: BoxFit.cover,
|
|
cacheWidth: cacheExtent,
|
|
gaplessPlayback: true,
|
|
filterQuality: FilterQuality.low,
|
|
frameBuilder: (_, child, frame, wasSynchronouslyLoaded) {
|
|
if (wasSynchronouslyLoaded || frame != null) return child;
|
|
return placeholder;
|
|
},
|
|
errorBuilder: (_, _, _) => placeholder,
|
|
),
|
|
);
|
|
}
|
|
|
|
final firstCoverUrl = playlist.tracks
|
|
.where((e) => e.track.coverUrl != null && e.track.coverUrl!.isNotEmpty)
|
|
.map((e) => e.track.coverUrl!)
|
|
.firstOrNull;
|
|
|
|
if (firstCoverUrl != null) {
|
|
// Guard against local file paths that may have been stored as coverUrl
|
|
final isLocalPath =
|
|
!firstCoverUrl.startsWith('http://') &&
|
|
!firstCoverUrl.startsWith('https://');
|
|
if (isLocalPath) {
|
|
return ClipRRect(
|
|
borderRadius: borderRadius,
|
|
child: Image.file(
|
|
File(firstCoverUrl),
|
|
width: size,
|
|
height: size,
|
|
fit: BoxFit.cover,
|
|
cacheWidth: cacheExtent,
|
|
gaplessPlayback: true,
|
|
filterQuality: FilterQuality.low,
|
|
frameBuilder: (_, child, frame, wasSynchronouslyLoaded) {
|
|
if (wasSynchronouslyLoaded || frame != null) return child;
|
|
return placeholder;
|
|
},
|
|
errorBuilder: (_, _, _) => placeholder,
|
|
),
|
|
);
|
|
}
|
|
return CachedCoverImage(
|
|
imageUrl: firstCoverUrl,
|
|
width: size,
|
|
height: size,
|
|
memCacheWidth: cacheExtent,
|
|
borderRadius: borderRadius,
|
|
placeholder: (_, _) => placeholder,
|
|
errorWidget: (_, _, _) => placeholder,
|
|
);
|
|
}
|
|
|
|
return placeholder;
|
|
}
|
|
|
|
/// Icon fallback for playlists with no cover.
|
|
/// When [size] is null the container expands to fill its parent (grid view)
|
|
/// and uses a fixed icon size.
|
|
Widget _playlistIconFallback(ColorScheme colorScheme, [double? size]) {
|
|
return Container(
|
|
width: size,
|
|
height: size,
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFF5085A5),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Icon(
|
|
Icons.queue_music,
|
|
color: Colors.white,
|
|
size: size != null ? size * 0.5 : 40,
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Handle a track being dropped onto a playlist.
|
|
/// When selection mode is active and the dragged item is among the selected,
|
|
/// all selected tracks are added to the playlist.
|
|
Future<void> _onTrackDroppedOnPlaylist(
|
|
BuildContext context,
|
|
UnifiedLibraryItem item,
|
|
String playlistId,
|
|
String playlistName, {
|
|
List<UnifiedLibraryItem> allItems = const [],
|
|
}) async {
|
|
final notifier = ref.read(libraryCollectionsProvider.notifier);
|
|
|
|
if (_isSelectionMode &&
|
|
_selectedIds.isNotEmpty &&
|
|
_selectedIds.contains(item.id)) {
|
|
final selectedItems = allItems
|
|
.where((e) => _selectedIds.contains(e.id))
|
|
.toList();
|
|
if (selectedItems.isEmpty) {
|
|
selectedItems.add(item);
|
|
}
|
|
|
|
final batchResult = await notifier.addTracksToPlaylist(
|
|
playlistId,
|
|
selectedItems.map((selected) => selected.toTrack()),
|
|
);
|
|
final addedCount = batchResult.addedCount;
|
|
final alreadyCount = batchResult.alreadyInPlaylistCount;
|
|
|
|
if (!context.mounted) return;
|
|
final message = addedCount > 0
|
|
? 'Added $addedCount ${addedCount == 1 ? 'track' : 'tracks'} to $playlistName'
|
|
'${alreadyCount > 0 ? ' ($alreadyCount already in playlist)' : ''}'
|
|
: context.l10n.collectionAlreadyInPlaylist(playlistName);
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(message)));
|
|
_exitSelectionMode();
|
|
return;
|
|
}
|
|
|
|
final track = item.toTrack();
|
|
final added = await notifier.addTrackToPlaylist(playlistId, track);
|
|
|
|
if (!context.mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
added
|
|
? context.l10n.collectionAddedToPlaylist(playlistName)
|
|
: context.l10n.collectionAlreadyInPlaylist(playlistName),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildDragFeedback(
|
|
BuildContext context,
|
|
UnifiedLibraryItem item,
|
|
ColorScheme colorScheme,
|
|
) {
|
|
final isDraggingMultiple =
|
|
_isSelectionMode &&
|
|
_selectedIds.contains(item.id) &&
|
|
_selectedIds.length > 1;
|
|
final count = isDraggingMultiple ? _selectedIds.length : 1;
|
|
|
|
return Material(
|
|
elevation: 6,
|
|
borderRadius: BorderRadius.circular(12),
|
|
color: colorScheme.surfaceContainerHighest,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.playlist_add, size: 18, color: colorScheme.primary),
|
|
const SizedBox(width: 8),
|
|
ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 180),
|
|
child: Text(
|
|
isDraggingMultiple ? '$count tracks' : item.trackName,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
_initializePageController();
|
|
|
|
final hasQueueItems = ref.watch(
|
|
downloadQueueLookupProvider.select((lookup) => lookup.itemIds.isNotEmpty),
|
|
);
|
|
final historyTotalCount = ref.watch(
|
|
downloadHistoryProvider.select((state) => state.totalCount),
|
|
);
|
|
final localLibraryTotalCount = ref.watch(
|
|
localLibraryProvider.select((state) => state.totalCount),
|
|
);
|
|
final localLibraryEnabled = ref.watch(
|
|
settingsProvider.select((s) => s.localLibraryEnabled),
|
|
);
|
|
// Watch with selector on key fields to reduce unnecessary rebuilds.
|
|
// LibraryCollectionsState doesn't implement == so watching without
|
|
// selector rebuilds on every provider notification.
|
|
ref.watch(
|
|
libraryCollectionsProvider.select(
|
|
(s) => (
|
|
s.wishlistCount,
|
|
s.lovedCount,
|
|
s.favoriteArtistCount,
|
|
s.playlistCount,
|
|
s.hasPlaylistTracks,
|
|
s.isLoaded,
|
|
),
|
|
),
|
|
);
|
|
final collectionState = ref.read(libraryCollectionsProvider);
|
|
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 = normalizedHeaderTopPadding(context);
|
|
final countsRequest = _QueueLibraryCountsRequest(
|
|
searchQuery: _searchQuery,
|
|
filterSource: _filterSource,
|
|
filterQuality: _filterQuality,
|
|
filterFormat: _filterFormat,
|
|
filterMetadata: _filterMetadata,
|
|
localLibraryEnabled: localLibraryEnabled,
|
|
);
|
|
final countsValue = ref.watch(_queueLibraryCountsProvider(countsRequest));
|
|
final queueCounts = _resolveQueueLibraryCounts(countsValue, countsRequest);
|
|
|
|
_QueueLibraryPageRequest pageRequest(String filterMode) =>
|
|
_QueueLibraryPageRequest(
|
|
filterMode: filterMode,
|
|
limit: _libraryPageLimit,
|
|
searchQuery: _searchQuery,
|
|
filterSource: _filterSource,
|
|
filterQuality: _filterQuality,
|
|
filterFormat: _filterFormat,
|
|
filterMetadata: _filterMetadata,
|
|
sortMode: _sortMode,
|
|
localLibraryEnabled: localLibraryEnabled,
|
|
);
|
|
|
|
final pageRequests = <String, _QueueLibraryPageRequest>{
|
|
for (final mode in _filterModes) mode: pageRequest(mode),
|
|
};
|
|
final pageValues = <String, AsyncValue<_QueueLibraryPageData>>{
|
|
for (final entry in pageRequests.entries)
|
|
entry.key: ref.watch(_queueLibraryPageProvider(entry.value)),
|
|
};
|
|
|
|
_QueueLibraryPageData pageData(String filterMode) =>
|
|
_resolveQueueLibraryPageData(
|
|
pageValues[filterMode],
|
|
pageRequests[filterMode]!,
|
|
);
|
|
|
|
_FilterContentData getFilterData(String filterMode) {
|
|
return pageData(filterMode).toFilterContentData(
|
|
collectionState,
|
|
totalTrackCount: switch (filterMode) {
|
|
'singles' => queueCounts.singleTrackCount,
|
|
'albums' => 0,
|
|
_ => queueCounts.allTrackCount,
|
|
},
|
|
totalAlbumCount: filterMode == 'albums' ? queueCounts.albumCount : null,
|
|
);
|
|
}
|
|
|
|
final currentPageData = pageData(historyFilterMode);
|
|
final currentLoadedCount = historyFilterMode == 'albums'
|
|
? currentPageData.groupedAlbums.length +
|
|
currentPageData.groupedLocalAlbums.length
|
|
: currentPageData.items.length;
|
|
final currentTotalCount = switch (historyFilterMode) {
|
|
'albums' => queueCounts.albumCount,
|
|
'singles' => queueCounts.singleTrackCount,
|
|
_ => queueCounts.allTrackCount,
|
|
};
|
|
final hasMoreLibrary = currentLoadedCount < currentTotalCount;
|
|
final isLibraryPageLoading =
|
|
countsValue.isLoading ||
|
|
(pageValues[historyFilterMode]?.isLoading ?? false);
|
|
final hasAnyLibraryItems =
|
|
queueCounts.allTrackCount > 0 || queueCounts.albumCount > 0;
|
|
final hasLibraryContent =
|
|
historyTotalCount > 0 ||
|
|
(localLibraryEnabled && localLibraryTotalCount > 0);
|
|
final hasActiveSearch =
|
|
_searchQuery.isNotEmpty || _searchController.text.trim().isNotEmpty;
|
|
final shouldShowLibraryControls =
|
|
hasLibraryContent || hasAnyLibraryItems || hasActiveSearch;
|
|
|
|
final bottomPadding = MediaQuery.paddingOf(context).bottom;
|
|
final selectionItems = getFilterData(
|
|
historyFilterMode,
|
|
).filteredUnifiedItems;
|
|
if (_isSelectionMode || _isPlaylistSelectionMode) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (_isSelectionMode) {
|
|
_syncSelectionOverlay(
|
|
items: selectionItems,
|
|
bottomPadding: bottomPadding,
|
|
);
|
|
}
|
|
if (_isPlaylistSelectionMode) {
|
|
_syncPlaylistSelectionOverlay(
|
|
playlists: collectionState.playlists,
|
|
bottomPadding: bottomPadding,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
return PopScope(
|
|
canPop: !_isSelectionMode && !_isPlaylistSelectionMode,
|
|
onPopInvokedWithResult: (didPop, result) {
|
|
if (!didPop) {
|
|
if (_isPlaylistSelectionMode) {
|
|
_exitPlaylistSelectionMode();
|
|
} else if (_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,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
|
|
if (shouldShowLibraryControls || hasQueueItems)
|
|
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(
|
|
tooltip: context.l10n.dialogClear,
|
|
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,
|
|
),
|
|
),
|
|
enabledBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(28),
|
|
borderSide: BorderSide(
|
|
color: colorScheme.outlineVariant,
|
|
),
|
|
),
|
|
focusedBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(28),
|
|
borderSide: BorderSide(
|
|
color: colorScheme.primary,
|
|
width: 2,
|
|
),
|
|
),
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 20,
|
|
vertical: 16,
|
|
),
|
|
),
|
|
onChanged: _onSearchChanged,
|
|
onTapOutside: (_) {
|
|
FocusScope.of(context).unfocus();
|
|
},
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
if (hasQueueItems)
|
|
_buildQueueHeaderSliver(context, colorScheme),
|
|
|
|
if (hasQueueItems) _buildQueueItemsSliver(context, colorScheme),
|
|
|
|
if (shouldShowLibraryControls)
|
|
SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
|
child: Builder(
|
|
builder: (context) {
|
|
int filteredAllCount;
|
|
int filteredAlbumCount;
|
|
int filteredSingleCount;
|
|
|
|
filteredAllCount = queueCounts.allTrackCount;
|
|
filteredAlbumCount = queueCounts.albumCount;
|
|
filteredSingleCount = queueCounts.singleTrackCount;
|
|
|
|
return SingleChildScrollView(
|
|
scrollDirection: Axis.horizontal,
|
|
child: Row(
|
|
children: [
|
|
_FilterChip(
|
|
label: context.l10n.historyFilterAll,
|
|
count: filteredAllCount,
|
|
isSelected: historyFilterMode == 'all',
|
|
onTap: () {
|
|
_animateToFilterPage(0);
|
|
},
|
|
),
|
|
const SizedBox(width: 8),
|
|
_FilterChip(
|
|
label: context.l10n.historyFilterAlbums,
|
|
count: filteredAlbumCount,
|
|
isSelected: historyFilterMode == 'albums',
|
|
onTap: () {
|
|
_animateToFilterPage(1);
|
|
},
|
|
),
|
|
const SizedBox(width: 8),
|
|
_FilterChip(
|
|
label: context.l10n.historyFilterSingles,
|
|
count: filteredSingleCount,
|
|
isSelected: historyFilterMode == 'singles',
|
|
onTap: () {
|
|
_animateToFilterPage(2);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
],
|
|
body: PageView.builder(
|
|
controller: _filterPageController!,
|
|
physics: const ClampingScrollPhysics(),
|
|
onPageChanged: _onFilterPageChanged,
|
|
itemCount: _filterModes.length,
|
|
itemBuilder: (context, index) {
|
|
final filterMode = _filterModes[index];
|
|
final filterData = getFilterData(filterMode);
|
|
return _buildFilterContent(
|
|
context: context,
|
|
colorScheme: colorScheme,
|
|
filterMode: filterMode,
|
|
historyViewMode: historyViewMode,
|
|
hasQueueItems: hasQueueItems,
|
|
filterData: filterData,
|
|
collectionState: collectionState,
|
|
hasMoreLibrary: filterMode == historyFilterMode
|
|
? hasMoreLibrary
|
|
: false,
|
|
isPageLoading: isLibraryPageLoading,
|
|
);
|
|
},
|
|
),
|
|
),
|
|
), // ScrollConfiguration
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
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 = _unifiedDownloadedItems(historyItems);
|
|
|
|
List<LocalLibraryItem> localItemsForMerge;
|
|
if (filterMode == 'all') {
|
|
localItemsForMerge = _filterLocalItems(localLibraryItems, query);
|
|
} else {
|
|
final localSingles = _localSingleItems(
|
|
localLibraryItems,
|
|
localAlbumCounts,
|
|
);
|
|
localItemsForMerge = _filterLocalItems(localSingles, query);
|
|
}
|
|
|
|
final unifiedLocal = _unifiedLocalItems(localItemsForMerge);
|
|
final downloadedPathKeys = _downloadedPathKeys(historyItems);
|
|
|
|
final dedupedUnifiedLocal = <UnifiedLibraryItem>[];
|
|
for (final item in unifiedLocal) {
|
|
final localSource = item.localItem;
|
|
final localPathKeys = localSource != null
|
|
? _localPathMatchKeys(localSource)
|
|
: buildPathMatchKeys(item.filePath);
|
|
final overlapsDownloaded = localPathKeys.any(downloadedPathKeys.contains);
|
|
if (!overlapsDownloaded) {
|
|
dedupedUnifiedLocal.add(item);
|
|
}
|
|
}
|
|
|
|
final merged = <UnifiedLibraryItem>[
|
|
...unifiedDownloaded,
|
|
...dedupedUnifiedLocal,
|
|
]..sort((a, b) => b.addedAt.compareTo(a.addedAt));
|
|
|
|
_unifiedItemsCache[filterMode] = _UnifiedCacheEntry(
|
|
historyItems: historyItems,
|
|
localItems: localLibraryItems,
|
|
localAlbumCounts: localAlbumCounts,
|
|
query: query,
|
|
items: merged,
|
|
);
|
|
|
|
return merged;
|
|
}
|
|
|
|
// ignore: unused_element
|
|
_FilterContentData _computeFilterContentData({
|
|
required String filterMode,
|
|
required List<DownloadHistoryItem> allHistoryItems,
|
|
required List<_GroupedAlbum> filteredGroupedAlbums,
|
|
required List<_GroupedLocalAlbum> filteredGroupedLocalAlbums,
|
|
required Map<String, int> albumCounts,
|
|
required Map<String, int> localAlbumCounts,
|
|
required List<LocalLibraryItem> localLibraryItems,
|
|
required LibraryCollectionsState collectionState,
|
|
}) {
|
|
final historyItems = _resolveHistoryItems(
|
|
filterMode: filterMode,
|
|
allHistoryItems: allHistoryItems,
|
|
albumCounts: albumCounts,
|
|
);
|
|
final showFilteringIndicator = _shouldShowFilteringIndicator(
|
|
allHistoryItems: allHistoryItems,
|
|
filterMode: filterMode,
|
|
);
|
|
|
|
final unifiedItems = _getUnifiedItems(
|
|
filterMode: filterMode,
|
|
historyItems: historyItems,
|
|
localLibraryItems: localLibraryItems,
|
|
localAlbumCounts: localAlbumCounts,
|
|
);
|
|
final filtered = _applyAdvancedFilters(unifiedItems);
|
|
|
|
// Remove tracks that are already in any playlist so they don't appear
|
|
// in the main tracks list. When a track is removed from a playlist (or
|
|
// the playlist is deleted) it will automatically reappear here because it
|
|
// will no longer be in the set.
|
|
final filteredUnifiedItems = !collectionState.hasPlaylistTracks
|
|
? filtered
|
|
: filtered
|
|
.where(
|
|
(item) =>
|
|
!collectionState.isTrackInAnyPlaylist(item.collectionKey),
|
|
)
|
|
.toList(growable: false);
|
|
|
|
return _FilterContentData(
|
|
historyItems: historyItems,
|
|
unifiedItems: unifiedItems,
|
|
filteredUnifiedItems: filteredUnifiedItems,
|
|
filteredGroupedAlbums: filteredGroupedAlbums,
|
|
filteredGroupedLocalAlbums: filteredGroupedLocalAlbums,
|
|
showFilteringIndicator: showFilteringIndicator,
|
|
);
|
|
}
|
|
|
|
Widget _buildQueueHeaderSliver(
|
|
BuildContext context,
|
|
ColorScheme colorScheme,
|
|
) {
|
|
return Consumer(
|
|
builder: (context, ref, child) {
|
|
final queueCount = ref.watch(
|
|
downloadQueueLookupProvider.select((lookup) => lookup.itemIds.length),
|
|
);
|
|
if (queueCount == 0) {
|
|
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
|
}
|
|
return SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
|
child: Row(
|
|
children: [
|
|
Text(
|
|
context.l10n.queueDownloadingCount(queueCount),
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const Spacer(),
|
|
_buildPauseResumeButton(context, ref, colorScheme),
|
|
const SizedBox(width: 4),
|
|
_buildClearAllButton(context, ref, colorScheme),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildQueueItemsSliver(BuildContext context, ColorScheme colorScheme) {
|
|
return Consumer(
|
|
builder: (context, ref, child) {
|
|
final queueIdsSnapshot = ref.watch(
|
|
downloadQueueLookupProvider.select(
|
|
(lookup) => _QueueItemIdsSnapshot(lookup.itemIds),
|
|
),
|
|
);
|
|
if (queueIdsSnapshot.ids.isEmpty) {
|
|
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
|
}
|
|
return SliverList(
|
|
delegate: SliverChildBuilderDelegate((context, index) {
|
|
final itemId = queueIdsSnapshot.ids[index];
|
|
return _QueueItemSliverRow(
|
|
key: ValueKey(itemId),
|
|
itemId: itemId,
|
|
colorScheme: colorScheme,
|
|
itemBuilder: _buildQueueItem,
|
|
);
|
|
}, childCount: queueIdsSnapshot.ids.length),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildCollectionListItem({
|
|
required BuildContext context,
|
|
required ColorScheme colorScheme,
|
|
IconData? icon,
|
|
Color? iconColor,
|
|
Color? iconBgColor,
|
|
Widget? coverWidget,
|
|
required String title,
|
|
required String subtitle,
|
|
required VoidCallback onTap,
|
|
VoidCallback? onLongPress,
|
|
}) {
|
|
final cover =
|
|
coverWidget ??
|
|
Container(
|
|
width: 56,
|
|
height: 56,
|
|
decoration: BoxDecoration(
|
|
color: iconBgColor ?? colorScheme.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Icon(
|
|
icon ?? Icons.folder,
|
|
color: iconColor ?? Colors.white,
|
|
size: 28,
|
|
),
|
|
);
|
|
|
|
return Card(
|
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
onLongPress: onLongPress,
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Row(
|
|
children: [
|
|
SizedBox(width: 56, height: 56, child: cover),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
title,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
subtitle,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Icon(
|
|
Icons.chevron_right,
|
|
color: colorScheme.onSurfaceVariant,
|
|
size: 20,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildCollectionGridItem({
|
|
required BuildContext context,
|
|
required ColorScheme colorScheme,
|
|
IconData? icon,
|
|
Color? iconColor,
|
|
Color? iconBgColor,
|
|
Widget? coverWidget,
|
|
required String title,
|
|
required int count,
|
|
required VoidCallback onTap,
|
|
VoidCallback? onLongPress,
|
|
}) {
|
|
final cover =
|
|
coverWidget ??
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
color: iconBgColor ?? colorScheme.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Icon(
|
|
icon ?? Icons.folder,
|
|
color: iconColor ?? Colors.white,
|
|
size: 40,
|
|
),
|
|
);
|
|
|
|
return Semantics(
|
|
button: true,
|
|
label: context.l10n.a11yOpenItemCount(title, count),
|
|
child: GestureDetector(
|
|
onTap: onTap,
|
|
onLongPress: onLongPress,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
AspectRatio(
|
|
aspectRatio: 1,
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: cover,
|
|
),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
title,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500),
|
|
),
|
|
Text(
|
|
'$count ${count == 1 ? 'item' : 'items'}',
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
List<_CollectionEntry> _getVisibleCollectionEntries(
|
|
LibraryCollectionsState collectionState,
|
|
) {
|
|
final entries = <_CollectionEntry>[];
|
|
if (collectionState.wishlistCount > 0) {
|
|
entries.add(_CollectionEntry.wishlist);
|
|
}
|
|
if (collectionState.lovedCount > 0) {
|
|
entries.add(_CollectionEntry.loved);
|
|
}
|
|
if (collectionState.favoriteArtistCount > 0) {
|
|
entries.add(_CollectionEntry.favoriteArtists);
|
|
}
|
|
for (var i = 0; i < collectionState.playlists.length; i++) {
|
|
entries.add(_CollectionEntry.playlist(i));
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
Widget _buildAllTabGridCollectionItem({
|
|
required BuildContext context,
|
|
required ColorScheme colorScheme,
|
|
required _CollectionEntry entry,
|
|
required LibraryCollectionsState collectionState,
|
|
List<UnifiedLibraryItem> filteredUnifiedItems = const [],
|
|
}) {
|
|
switch (entry.type) {
|
|
case _CollectionEntryType.wishlist:
|
|
return _buildCollectionGridItem(
|
|
context: context,
|
|
colorScheme: colorScheme,
|
|
icon: Icons.add_circle_outline,
|
|
iconColor: Colors.white,
|
|
iconBgColor: const Color(0xFF1DB954),
|
|
title: context.l10n.collectionWishlist,
|
|
count: collectionState.wishlistCount,
|
|
onTap: _openWishlistFolder,
|
|
);
|
|
case _CollectionEntryType.loved:
|
|
return _buildCollectionGridItem(
|
|
context: context,
|
|
colorScheme: colorScheme,
|
|
icon: Icons.favorite,
|
|
iconColor: Colors.white,
|
|
iconBgColor: const Color(0xFF8C67AC),
|
|
title: context.l10n.collectionLoved,
|
|
count: collectionState.lovedCount,
|
|
onTap: _openLovedFolder,
|
|
);
|
|
case _CollectionEntryType.favoriteArtists:
|
|
return _buildCollectionGridItem(
|
|
context: context,
|
|
colorScheme: colorScheme,
|
|
icon: Icons.person,
|
|
iconColor: Colors.white,
|
|
iconBgColor: const Color(0xFFE91E63),
|
|
title: context.l10n.collectionFavoriteArtists,
|
|
count: collectionState.favoriteArtistCount,
|
|
onTap: _openFavoriteArtistsFolder,
|
|
);
|
|
case _CollectionEntryType.playlist:
|
|
final playlist = collectionState.playlists[entry.playlistIndex];
|
|
final isSelected = _selectedPlaylistIds.contains(playlist.id);
|
|
return DragTarget<UnifiedLibraryItem>(
|
|
onWillAcceptWithDetails: (_) => !_isPlaylistSelectionMode,
|
|
onAcceptWithDetails: (details) {
|
|
_onTrackDroppedOnPlaylist(
|
|
context,
|
|
details.data,
|
|
playlist.id,
|
|
playlist.name,
|
|
allItems: filteredUnifiedItems,
|
|
);
|
|
},
|
|
builder: (context, candidateData, rejectedData) {
|
|
final isHovering = candidateData.isNotEmpty;
|
|
return AnimatedContainer(
|
|
duration: const Duration(milliseconds: 150),
|
|
decoration: isHovering
|
|
? BoxDecoration(
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: colorScheme.primary, width: 2),
|
|
color: colorScheme.primary.withValues(alpha: 0.1),
|
|
)
|
|
: null,
|
|
child: Stack(
|
|
children: [
|
|
_buildCollectionGridItem(
|
|
context: context,
|
|
colorScheme: colorScheme,
|
|
coverWidget: _buildPlaylistCover(
|
|
context,
|
|
playlist,
|
|
colorScheme,
|
|
),
|
|
title: playlist.name,
|
|
count: playlist.tracks.length,
|
|
onTap: _isPlaylistSelectionMode
|
|
? () => _togglePlaylistSelection(playlist.id)
|
|
: () => _openPlaylistById(playlist.id),
|
|
onLongPress: _isPlaylistSelectionMode
|
|
? () => _togglePlaylistSelection(playlist.id)
|
|
: () => _enterPlaylistSelectionMode(playlist.id),
|
|
),
|
|
if (_isPlaylistSelectionMode)
|
|
Positioned(
|
|
left: 0,
|
|
top: 0,
|
|
right: 0,
|
|
child: IgnorePointer(
|
|
child: AspectRatio(
|
|
aspectRatio: 1,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: isSelected
|
|
? colorScheme.primary.withValues(alpha: 0.3)
|
|
: Colors.transparent,
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (_isPlaylistSelectionMode)
|
|
Positioned(
|
|
top: 4,
|
|
right: 4,
|
|
child: IgnorePointer(
|
|
child: AnimatedSelectionCheckbox(
|
|
visible: true,
|
|
selected: isSelected,
|
|
colorScheme: colorScheme,
|
|
size: 20,
|
|
unselectedColor: colorScheme.surface.withValues(
|
|
alpha: 0.85,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
Widget _buildAllTabListCollectionItem({
|
|
required BuildContext context,
|
|
required ColorScheme colorScheme,
|
|
required _CollectionEntry entry,
|
|
required LibraryCollectionsState collectionState,
|
|
List<UnifiedLibraryItem> filteredUnifiedItems = const [],
|
|
}) {
|
|
switch (entry.type) {
|
|
case _CollectionEntryType.wishlist:
|
|
return _buildCollectionListItem(
|
|
context: context,
|
|
colorScheme: colorScheme,
|
|
icon: Icons.add_circle_outline,
|
|
iconColor: Colors.white,
|
|
iconBgColor: const Color(0xFF1DB954),
|
|
title: context.l10n.collectionWishlist,
|
|
subtitle:
|
|
'${context.l10n.collectionFoldersTitle} • ${collectionState.wishlistCount} ${collectionState.wishlistCount == 1 ? 'track' : 'tracks'}',
|
|
onTap: _openWishlistFolder,
|
|
);
|
|
case _CollectionEntryType.loved:
|
|
return _buildCollectionListItem(
|
|
context: context,
|
|
colorScheme: colorScheme,
|
|
icon: Icons.favorite,
|
|
iconColor: Colors.white,
|
|
iconBgColor: const Color(0xFF8C67AC),
|
|
title: context.l10n.collectionLoved,
|
|
subtitle:
|
|
'${context.l10n.collectionFoldersTitle} • ${collectionState.lovedCount} ${collectionState.lovedCount == 1 ? 'track' : 'tracks'}',
|
|
onTap: _openLovedFolder,
|
|
);
|
|
case _CollectionEntryType.favoriteArtists:
|
|
return _buildCollectionListItem(
|
|
context: context,
|
|
colorScheme: colorScheme,
|
|
icon: Icons.person,
|
|
iconColor: Colors.white,
|
|
iconBgColor: const Color(0xFFE91E63),
|
|
title: context.l10n.collectionFavoriteArtists,
|
|
subtitle:
|
|
'${context.l10n.collectionFoldersTitle} • ${context.l10n.collectionArtistCount(collectionState.favoriteArtistCount)}',
|
|
onTap: _openFavoriteArtistsFolder,
|
|
);
|
|
case _CollectionEntryType.playlist:
|
|
final playlist = collectionState.playlists[entry.playlistIndex];
|
|
final isSelected = _selectedPlaylistIds.contains(playlist.id);
|
|
return DragTarget<UnifiedLibraryItem>(
|
|
onWillAcceptWithDetails: (_) => !_isPlaylistSelectionMode,
|
|
onAcceptWithDetails: (details) {
|
|
_onTrackDroppedOnPlaylist(
|
|
context,
|
|
details.data,
|
|
playlist.id,
|
|
playlist.name,
|
|
allItems: filteredUnifiedItems,
|
|
);
|
|
},
|
|
builder: (context, candidateData, rejectedData) {
|
|
final isHovering = candidateData.isNotEmpty;
|
|
return AnimatedContainer(
|
|
duration: const Duration(milliseconds: 150),
|
|
decoration: isHovering
|
|
? BoxDecoration(
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: colorScheme.primary, width: 2),
|
|
color: colorScheme.primary.withValues(alpha: 0.1),
|
|
)
|
|
: null,
|
|
child: Row(
|
|
children: [
|
|
if (_isPlaylistSelectionMode)
|
|
GestureDetector(
|
|
onTap: () => _togglePlaylistSelection(playlist.id),
|
|
behavior: HitTestBehavior.opaque,
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(left: 8),
|
|
child: AnimatedSelectionCheckbox(
|
|
visible: true,
|
|
selected: isSelected,
|
|
colorScheme: colorScheme,
|
|
size: 24,
|
|
),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: _buildCollectionListItem(
|
|
context: context,
|
|
colorScheme: colorScheme,
|
|
coverWidget: _buildPlaylistCover(
|
|
context,
|
|
playlist,
|
|
colorScheme,
|
|
56,
|
|
),
|
|
title: playlist.name,
|
|
subtitle:
|
|
'${playlist.tracks.length} ${playlist.tracks.length == 1 ? 'track' : 'tracks'}',
|
|
onTap: _isPlaylistSelectionMode
|
|
? () => _togglePlaylistSelection(playlist.id)
|
|
: () => _openPlaylistById(playlist.id),
|
|
onLongPress: _isPlaylistSelectionMode
|
|
? () => _togglePlaylistSelection(playlist.id)
|
|
: () => _enterPlaylistSelectionMode(playlist.id),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
Widget _buildFilterContent({
|
|
required BuildContext context,
|
|
required ColorScheme colorScheme,
|
|
required String filterMode,
|
|
required String historyViewMode,
|
|
required bool hasQueueItems,
|
|
required _FilterContentData filterData,
|
|
required LibraryCollectionsState collectionState,
|
|
required bool hasMoreLibrary,
|
|
required bool isPageLoading,
|
|
}) {
|
|
final historyItems = filterData.historyItems;
|
|
final showFilteringIndicator = filterData.showFilteringIndicator;
|
|
final filteredGroupedAlbums = filterData.filteredGroupedAlbums;
|
|
final filteredGroupedLocalAlbums = filterData.filteredGroupedLocalAlbums;
|
|
final unifiedItems = filterData.unifiedItems;
|
|
final filteredUnifiedItems = filterData.filteredUnifiedItems;
|
|
final totalTrackCount = filterData.totalTrackCount;
|
|
final totalAlbumCount = filterData.totalAlbumCount;
|
|
final downloadedNavigationItems = <DownloadHistoryItem>[];
|
|
final downloadedNavigationIndexByUnifiedId = <String, int>{};
|
|
final localNavigationItems = <LocalLibraryItem>[];
|
|
final localNavigationIndexByUnifiedId = <String, int>{};
|
|
|
|
for (final item in filteredUnifiedItems) {
|
|
final historyItem = item.historyItem;
|
|
if (historyItem != null) {
|
|
downloadedNavigationIndexByUnifiedId[item.id] =
|
|
downloadedNavigationItems.length;
|
|
downloadedNavigationItems.add(historyItem);
|
|
}
|
|
|
|
final localItem = item.localItem;
|
|
if (localItem != null) {
|
|
localNavigationIndexByUnifiedId[item.id] = localNavigationItems.length;
|
|
localNavigationItems.add(localItem);
|
|
}
|
|
}
|
|
|
|
final content = CustomScrollView(
|
|
slivers: [
|
|
if (totalTrackCount > 0 && filterMode == 'all')
|
|
SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
|
child: Row(
|
|
children: [
|
|
Text(
|
|
context.l10n.queueTrackCount(totalTrackCount),
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const Spacer(),
|
|
if (!_isSelectionMode)
|
|
_buildFilterButton(context, unifiedItems),
|
|
if (!_isSelectionMode && filteredUnifiedItems.isNotEmpty)
|
|
TextButton.icon(
|
|
onPressed: () => _showCreatePlaylistDialog(context),
|
|
icon: const Icon(Icons.add, size: 20),
|
|
label: Text(context.l10n.collectionCreatePlaylist),
|
|
style: TextButton.styleFrom(
|
|
visualDensity: VisualDensity.compact,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
|
|
if ((filteredGroupedAlbums.isNotEmpty ||
|
|
filteredGroupedLocalAlbums.isNotEmpty) &&
|
|
filterMode == 'albums')
|
|
SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
|
child: Row(
|
|
children: [
|
|
Text(
|
|
context.l10n.queueAlbumCount(totalAlbumCount),
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const Spacer(),
|
|
_buildFilterButton(context, unifiedItems),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
|
|
if (filteredGroupedAlbums.isEmpty &&
|
|
filteredGroupedLocalAlbums.isEmpty &&
|
|
filterMode == 'albums' &&
|
|
(historyItems.isNotEmpty || unifiedItems.isNotEmpty))
|
|
SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
|
child: Row(
|
|
children: [
|
|
const Spacer(),
|
|
_buildFilterButton(context, unifiedItems),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
|
|
if (filterMode == 'all' &&
|
|
totalTrackCount == 0 &&
|
|
!showFilteringIndicator &&
|
|
(_activeFilterCount > 0 || unifiedItems.isNotEmpty))
|
|
SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
|
child: Row(
|
|
children: [
|
|
const Spacer(),
|
|
if (!_isSelectionMode)
|
|
_buildFilterButton(context, unifiedItems),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
|
|
if (filterMode == 'singles' &&
|
|
totalTrackCount == 0 &&
|
|
!showFilteringIndicator &&
|
|
(_activeFilterCount > 0 || unifiedItems.isNotEmpty))
|
|
SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
|
child: Row(
|
|
children: [
|
|
const Spacer(),
|
|
if (!_isSelectionMode)
|
|
_buildFilterButton(context, unifiedItems),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
|
|
if (historyItems.isNotEmpty && hasQueueItems)
|
|
SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
|
child: Text(
|
|
context.l10n.queueDownloadedHeader,
|
|
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(
|
|
context.l10n.queueFilteringIndicator,
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
|
|
if (filterMode == 'albums' &&
|
|
(filteredGroupedAlbums.isNotEmpty ||
|
|
filteredGroupedLocalAlbums.isNotEmpty))
|
|
SliverPadding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
sliver: _AnimatedLibrarySliverGrid(
|
|
maxCrossAxisExtent: _libraryAlbumGridExtent,
|
|
mainAxisSpacing: 12,
|
|
crossAxisSpacing: 12,
|
|
childAspectRatio: 0.72,
|
|
delegate: SliverChildBuilderDelegate(
|
|
(context, index) {
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
|
|
if (filterMode == 'all') ...[
|
|
if (historyViewMode == 'grid')
|
|
SliverPadding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
sliver: _AnimatedLibrarySliverGrid(
|
|
maxCrossAxisExtent: _libraryGridExtent,
|
|
mainAxisSpacing: 8,
|
|
crossAxisSpacing: 8,
|
|
childAspectRatio: 0.66,
|
|
delegate: SliverChildBuilderDelegate(
|
|
(context, index) {
|
|
final collectionEntries = _getVisibleCollectionEntries(
|
|
collectionState,
|
|
);
|
|
final collectionCount = collectionEntries.length;
|
|
if (index < collectionCount) {
|
|
return _buildAllTabGridCollectionItem(
|
|
context: context,
|
|
colorScheme: colorScheme,
|
|
entry: collectionEntries[index],
|
|
collectionState: collectionState,
|
|
filteredUnifiedItems: filteredUnifiedItems,
|
|
);
|
|
}
|
|
final trackIndex = index - collectionCount;
|
|
if (trackIndex < filteredUnifiedItems.length) {
|
|
final item = filteredUnifiedItems[trackIndex];
|
|
return KeyedSubtree(
|
|
key: ValueKey(item.id),
|
|
child: LongPressDraggable<UnifiedLibraryItem>(
|
|
data: item,
|
|
feedback: _buildDragFeedback(
|
|
context,
|
|
item,
|
|
colorScheme,
|
|
),
|
|
childWhenDragging: Opacity(
|
|
opacity: 0.4,
|
|
child: _buildUnifiedGridItem(
|
|
context,
|
|
item,
|
|
colorScheme,
|
|
downloadedNavigationItems:
|
|
downloadedNavigationItems,
|
|
downloadedNavigationIndex:
|
|
downloadedNavigationIndexByUnifiedId[item.id],
|
|
localNavigationItems: localNavigationItems,
|
|
localNavigationIndex:
|
|
localNavigationIndexByUnifiedId[item.id],
|
|
),
|
|
),
|
|
child: _buildUnifiedGridItem(
|
|
context,
|
|
item,
|
|
colorScheme,
|
|
downloadedNavigationItems:
|
|
downloadedNavigationItems,
|
|
downloadedNavigationIndex:
|
|
downloadedNavigationIndexByUnifiedId[item.id],
|
|
localNavigationItems: localNavigationItems,
|
|
localNavigationIndex:
|
|
localNavigationIndexByUnifiedId[item.id],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
return const SizedBox.shrink();
|
|
},
|
|
childCount:
|
|
_getVisibleCollectionEntries(collectionState).length +
|
|
filteredUnifiedItems.length,
|
|
),
|
|
),
|
|
)
|
|
else
|
|
SliverList(
|
|
delegate: SliverChildBuilderDelegate(
|
|
(context, index) {
|
|
final collectionEntries = _getVisibleCollectionEntries(
|
|
collectionState,
|
|
);
|
|
final collectionCount = collectionEntries.length;
|
|
if (index < collectionCount) {
|
|
return _buildAllTabListCollectionItem(
|
|
context: context,
|
|
colorScheme: colorScheme,
|
|
entry: collectionEntries[index],
|
|
collectionState: collectionState,
|
|
filteredUnifiedItems: filteredUnifiedItems,
|
|
);
|
|
}
|
|
final trackIndex = index - collectionCount;
|
|
if (trackIndex < filteredUnifiedItems.length) {
|
|
final item = filteredUnifiedItems[trackIndex];
|
|
return KeyedSubtree(
|
|
key: ValueKey(item.id),
|
|
child: LongPressDraggable<UnifiedLibraryItem>(
|
|
data: item,
|
|
feedback: _buildDragFeedback(
|
|
context,
|
|
item,
|
|
colorScheme,
|
|
),
|
|
childWhenDragging: Opacity(
|
|
opacity: 0.4,
|
|
child: _buildUnifiedLibraryItem(
|
|
context,
|
|
item,
|
|
colorScheme,
|
|
downloadedNavigationItems:
|
|
downloadedNavigationItems,
|
|
downloadedNavigationIndex:
|
|
downloadedNavigationIndexByUnifiedId[item.id],
|
|
localNavigationItems: localNavigationItems,
|
|
localNavigationIndex:
|
|
localNavigationIndexByUnifiedId[item.id],
|
|
),
|
|
),
|
|
child: _buildUnifiedLibraryItem(
|
|
context,
|
|
item,
|
|
colorScheme,
|
|
downloadedNavigationItems: downloadedNavigationItems,
|
|
downloadedNavigationIndex:
|
|
downloadedNavigationIndexByUnifiedId[item.id],
|
|
localNavigationItems: localNavigationItems,
|
|
localNavigationIndex:
|
|
localNavigationIndexByUnifiedId[item.id],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
return const SizedBox.shrink();
|
|
},
|
|
childCount:
|
|
_getVisibleCollectionEntries(collectionState).length +
|
|
filteredUnifiedItems.length,
|
|
),
|
|
),
|
|
],
|
|
|
|
if (filterMode == 'singles')
|
|
SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
|
child: Row(
|
|
children: [
|
|
Text(
|
|
context.l10n.queueTrackCount(totalTrackCount),
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const Spacer(),
|
|
if (!_isSelectionMode)
|
|
_buildFilterButton(context, unifiedItems),
|
|
if (!_isSelectionMode && filteredUnifiedItems.isNotEmpty)
|
|
TextButton.icon(
|
|
onPressed: () => _showCreatePlaylistDialog(context),
|
|
icon: const Icon(Icons.add, size: 20),
|
|
label: Text(context.l10n.collectionCreatePlaylist),
|
|
style: TextButton.styleFrom(
|
|
visualDensity: VisualDensity.compact,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
|
|
if (filteredUnifiedItems.isNotEmpty && filterMode == 'singles')
|
|
historyViewMode == 'grid'
|
|
? SliverPadding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
sliver: _AnimatedLibrarySliverGrid(
|
|
maxCrossAxisExtent: _libraryGridExtent,
|
|
mainAxisSpacing: 8,
|
|
crossAxisSpacing: 8,
|
|
childAspectRatio: 0.66,
|
|
delegate: SliverChildBuilderDelegate((context, index) {
|
|
final item = filteredUnifiedItems[index];
|
|
return KeyedSubtree(
|
|
key: ValueKey(item.id),
|
|
child: _buildUnifiedGridItem(
|
|
context,
|
|
item,
|
|
colorScheme,
|
|
downloadedNavigationItems: downloadedNavigationItems,
|
|
downloadedNavigationIndex:
|
|
downloadedNavigationIndexByUnifiedId[item.id],
|
|
localNavigationItems: localNavigationItems,
|
|
localNavigationIndex:
|
|
localNavigationIndexByUnifiedId[item.id],
|
|
),
|
|
);
|
|
}, childCount: filteredUnifiedItems.length),
|
|
),
|
|
)
|
|
: SliverList(
|
|
delegate: SliverChildBuilderDelegate((context, index) {
|
|
final item = filteredUnifiedItems[index];
|
|
return KeyedSubtree(
|
|
key: ValueKey(item.id),
|
|
child: _buildUnifiedLibraryItem(
|
|
context,
|
|
item,
|
|
colorScheme,
|
|
downloadedNavigationItems: downloadedNavigationItems,
|
|
downloadedNavigationIndex:
|
|
downloadedNavigationIndexByUnifiedId[item.id],
|
|
localNavigationItems: localNavigationItems,
|
|
localNavigationIndex:
|
|
localNavigationIndexByUnifiedId[item.id],
|
|
),
|
|
);
|
|
}, childCount: filteredUnifiedItems.length),
|
|
),
|
|
|
|
if (!hasQueueItems &&
|
|
totalTrackCount == 0 &&
|
|
(filterMode != 'albums' ||
|
|
(filteredGroupedAlbums.isEmpty &&
|
|
filteredGroupedLocalAlbums.isEmpty)) &&
|
|
!showFilteringIndicator &&
|
|
!isPageLoading)
|
|
SliverFillRemaining(
|
|
hasScrollBody: false,
|
|
child: _buildEmptyState(context, colorScheme, filterMode),
|
|
)
|
|
else if (isPageLoading)
|
|
SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
|
child: Center(
|
|
child: SizedBox(
|
|
width: 22,
|
|
height: 22,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
color: colorScheme.primary,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
if (hasQueueItems ||
|
|
totalTrackCount > 0 ||
|
|
(filterMode == 'albums' &&
|
|
(filteredGroupedAlbums.isNotEmpty ||
|
|
filteredGroupedLocalAlbums.isNotEmpty)))
|
|
SliverToBoxAdapter(
|
|
child: SizedBox(height: _isSelectionMode ? 100 : 16),
|
|
),
|
|
],
|
|
);
|
|
|
|
final scrollAwareContent = NotificationListener<ScrollNotification>(
|
|
onNotification: (notification) => _handleLibraryScrollNotification(
|
|
notification: notification,
|
|
filterMode: filterMode,
|
|
hasMoreLibrary: hasMoreLibrary,
|
|
isPageLoading: isPageLoading,
|
|
),
|
|
child: content,
|
|
);
|
|
|
|
if (historyViewMode != 'grid') return scrollAwareContent;
|
|
return GestureDetector(
|
|
behavior: HitTestBehavior.translucent,
|
|
onScaleStart: _handleLibraryGridScaleStart,
|
|
onScaleUpdate: _handleLibraryGridScaleUpdate,
|
|
onScaleEnd: _handleLibraryGridScaleEnd,
|
|
child: scrollAwareContent,
|
|
);
|
|
}
|
|
|
|
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,
|
|
),
|
|
);
|
|
}
|
|
|
|
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 = context.l10n.queueEmptyAlbums;
|
|
subtitle = context.l10n.queueEmptyAlbumsSubtitle;
|
|
icon = Icons.album;
|
|
break;
|
|
case 'singles':
|
|
message = context.l10n.queueEmptySingles;
|
|
subtitle = context.l10n.queueEmptySinglesSubtitle;
|
|
icon = Icons.music_note;
|
|
break;
|
|
default:
|
|
message = context.l10n.queueEmptyHistory;
|
|
subtitle = context.l10n.queueEmptyHistorySubtitle;
|
|
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 ValueListenableBuilder<int>(
|
|
valueListenable: _embeddedCoverVersion,
|
|
builder: (context, _, child) {
|
|
final embeddedCoverPath = _resolveDownloadedEmbeddedCoverPath(
|
|
album.sampleFilePath,
|
|
);
|
|
return _buildAlbumGridItemCore(
|
|
context: context,
|
|
albumName: album.albumName,
|
|
artistName: album.artistName,
|
|
trackCount: album.displayTrackCount,
|
|
colorScheme: colorScheme,
|
|
coverWidget: embeddedCoverPath != null
|
|
? Image.file(
|
|
File(embeddedCoverPath),
|
|
fit: BoxFit.cover,
|
|
width: double.infinity,
|
|
height: double.infinity,
|
|
cacheWidth: 300,
|
|
cacheHeight: 300,
|
|
errorBuilder: (context, error, stackTrace) =>
|
|
_albumPlaceholder(colorScheme),
|
|
)
|
|
: album.coverUrl != null
|
|
? CachedCoverImage(
|
|
imageUrl: album.coverUrl!,
|
|
fit: BoxFit.cover,
|
|
width: double.infinity,
|
|
height: double.infinity,
|
|
memCacheWidth: 300,
|
|
memCacheHeight: 300,
|
|
)
|
|
: null,
|
|
badgeColor: colorScheme.primaryContainer,
|
|
badgeTextColor: colorScheme.onPrimaryContainer,
|
|
badgeIcon: Icons.music_note,
|
|
coverUrl: album.coverUrl,
|
|
onTap: () => _navigateToDownloadedAlbum(album),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildLocalAlbumGridItem(
|
|
BuildContext context,
|
|
_GroupedLocalAlbum album,
|
|
ColorScheme colorScheme,
|
|
) {
|
|
return _buildAlbumGridItemCore(
|
|
context: context,
|
|
albumName: album.albumName,
|
|
artistName: album.artistName,
|
|
trackCount: album.displayTrackCount,
|
|
colorScheme: colorScheme,
|
|
coverWidget: 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) =>
|
|
_albumPlaceholder(colorScheme),
|
|
)
|
|
: null,
|
|
badgeColor: colorScheme.tertiaryContainer,
|
|
badgeTextColor: colorScheme.onTertiaryContainer,
|
|
badgeIcon: Icons.folder,
|
|
onTap: () => _navigateToLocalAlbum(album),
|
|
);
|
|
}
|
|
|
|
Widget _albumPlaceholder(ColorScheme colorScheme) {
|
|
return Container(
|
|
color: colorScheme.surfaceContainerHighest,
|
|
child: Center(
|
|
child: Icon(Icons.album, color: colorScheme.onSurfaceVariant, size: 48),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildAlbumGridItemCore({
|
|
required BuildContext context,
|
|
required String albumName,
|
|
required String artistName,
|
|
required int trackCount,
|
|
required ColorScheme colorScheme,
|
|
required Widget? coverWidget,
|
|
required Color badgeColor,
|
|
required Color badgeTextColor,
|
|
required IconData badgeIcon,
|
|
required VoidCallback onTap,
|
|
String? coverUrl,
|
|
}) {
|
|
return Semantics(
|
|
button: true,
|
|
label: context.l10n.a11yOpenAlbumByArtistTrackCount(
|
|
albumName,
|
|
artistName,
|
|
trackCount,
|
|
),
|
|
child: GestureDetector(
|
|
onTap: onTap,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Expanded(
|
|
child: Stack(
|
|
children: [
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: coverWidget ?? _albumPlaceholder(colorScheme),
|
|
),
|
|
Positioned(
|
|
right: 8,
|
|
bottom: 8,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 8,
|
|
vertical: 4,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: badgeColor,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(badgeIcon, size: 12, color: badgeTextColor),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
'$trackCount',
|
|
style: TextStyle(
|
|
color: badgeTextColor,
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
albumName,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
|
),
|
|
ClickableArtistName(
|
|
artistName: artistName,
|
|
coverUrl: coverUrl,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
bool _hasTextValue(String? value) => value != null && value.trim().isNotEmpty;
|
|
|
|
List<UnifiedLibraryItem> _selectedItemsFromAll(
|
|
List<UnifiedLibraryItem> allItems,
|
|
) {
|
|
final itemsById = {for (final item in allItems) item.id: item};
|
|
return _selectedIds
|
|
.map((id) => itemsById[id])
|
|
.whereType<UnifiedLibraryItem>()
|
|
.toList(growable: false);
|
|
}
|
|
|
|
bool _isLocalOnlySelection(List<UnifiedLibraryItem> allItems) {
|
|
final selectedItems = _selectedItemsFromAll(allItems);
|
|
return selectedItems.isNotEmpty &&
|
|
selectedItems.every((item) => item.localItem != null);
|
|
}
|
|
|
|
Future<void> _safeDeleteTempFile(String path) async {
|
|
try {
|
|
final file = File(path);
|
|
if (await file.exists()) {
|
|
await file.delete();
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
Future<void> _cleanupTempFileAndParentDir(String path) async {
|
|
await _safeDeleteTempFile(path);
|
|
try {
|
|
final parent = File(path).parent;
|
|
if (await parent.exists()) {
|
|
await parent.delete();
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
Future<bool> _applyQueueFfmpegReEnrichResult(
|
|
LocalLibraryItem item,
|
|
Map<String, dynamic> result,
|
|
) async {
|
|
final tempPath = result['temp_path'] as String?;
|
|
final safUri = result['saf_uri'] as String?;
|
|
final ffmpegTarget = _hasTextValue(tempPath) ? tempPath! : item.filePath;
|
|
final downloadedCoverPath = result['cover_path'] as String?;
|
|
String? effectiveCoverPath = downloadedCoverPath;
|
|
String? extractedCoverPath;
|
|
|
|
if (!_hasTextValue(effectiveCoverPath)) {
|
|
try {
|
|
final tempDir = await Directory.systemTemp.createTemp(
|
|
'reenrich_cover_',
|
|
);
|
|
final coverOutput = '${tempDir.path}${Platform.pathSeparator}cover.jpg';
|
|
final extracted = await PlatformBridge.extractCoverToFile(
|
|
ffmpegTarget,
|
|
coverOutput,
|
|
);
|
|
if (extracted['error'] == null) {
|
|
effectiveCoverPath = coverOutput;
|
|
extractedCoverPath = coverOutput;
|
|
} else {
|
|
try {
|
|
await tempDir.delete(recursive: true);
|
|
} catch (_) {}
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
final metadata = (result['metadata'] as Map<String, dynamic>?)?.map(
|
|
(k, v) => MapEntry(k, v.toString()),
|
|
);
|
|
|
|
final format = item.format?.toLowerCase();
|
|
final lowerPath = item.filePath.toLowerCase();
|
|
final isMp3 = format == 'mp3' || lowerPath.endsWith('.mp3');
|
|
final isM4A =
|
|
format == 'm4a' ||
|
|
format == 'aac' ||
|
|
lowerPath.endsWith('.m4a') ||
|
|
lowerPath.endsWith('.aac');
|
|
final isOpus =
|
|
format == 'opus' ||
|
|
format == 'ogg' ||
|
|
lowerPath.endsWith('.opus') ||
|
|
lowerPath.endsWith('.ogg');
|
|
|
|
final artistTagMode = ref.read(settingsProvider).artistTagMode;
|
|
String? ffmpegResult;
|
|
if (isMp3) {
|
|
ffmpegResult = await FFmpegService.embedMetadataToMp3(
|
|
mp3Path: ffmpegTarget,
|
|
coverPath: effectiveCoverPath,
|
|
metadata: metadata,
|
|
preserveMetadata: true,
|
|
);
|
|
} else if (isM4A) {
|
|
ffmpegResult = await FFmpegService.embedMetadataToM4a(
|
|
m4aPath: ffmpegTarget,
|
|
coverPath: effectiveCoverPath,
|
|
metadata: metadata,
|
|
preserveMetadata: true,
|
|
);
|
|
} else if (isOpus) {
|
|
ffmpegResult = await FFmpegService.embedMetadataToOpus(
|
|
opusPath: ffmpegTarget,
|
|
coverPath: effectiveCoverPath,
|
|
metadata: metadata,
|
|
artistTagMode: artistTagMode,
|
|
preserveMetadata: true,
|
|
);
|
|
}
|
|
|
|
if (ffmpegResult != null &&
|
|
_hasTextValue(tempPath) &&
|
|
_hasTextValue(safUri)) {
|
|
final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri!);
|
|
if (!ok) {
|
|
if (_hasTextValue(downloadedCoverPath)) {
|
|
await _safeDeleteTempFile(downloadedCoverPath!);
|
|
}
|
|
if (_hasTextValue(extractedCoverPath)) {
|
|
await _cleanupTempFileAndParentDir(extractedCoverPath!);
|
|
}
|
|
await _safeDeleteTempFile(tempPath!);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (_hasTextValue(downloadedCoverPath)) {
|
|
await _safeDeleteTempFile(downloadedCoverPath!);
|
|
}
|
|
if (_hasTextValue(extractedCoverPath)) {
|
|
await _cleanupTempFileAndParentDir(extractedCoverPath!);
|
|
}
|
|
if (_hasTextValue(tempPath)) {
|
|
await _safeDeleteTempFile(tempPath!);
|
|
}
|
|
|
|
return ffmpegResult != null;
|
|
}
|
|
|
|
Future<bool> _reEnrichQueueLocalTrack(
|
|
LocalLibraryItem item, {
|
|
List<String>? updateFields,
|
|
}) async {
|
|
final durationMs = (item.duration ?? 0) * 1000;
|
|
final artistTagMode = ref.read(settingsProvider).artistTagMode;
|
|
final request = <String, dynamic>{
|
|
'file_path': item.filePath,
|
|
'cover_url': '',
|
|
'max_quality': true,
|
|
'embed_lyrics': true,
|
|
'artist_tag_mode': artistTagMode,
|
|
'spotify_id': '',
|
|
'track_name': item.trackName,
|
|
'artist_name': item.artistName,
|
|
'album_name': item.albumName,
|
|
'album_artist': item.albumArtist ?? '',
|
|
'track_number': item.trackNumber ?? 0,
|
|
'disc_number': item.discNumber ?? 0,
|
|
'release_date': item.releaseDate ?? '',
|
|
'isrc': item.isrc ?? '',
|
|
'genre': item.genre ?? '',
|
|
'label': '',
|
|
'copyright': '',
|
|
'duration_ms': durationMs,
|
|
'search_online': true,
|
|
// ignore: use_null_aware_elements
|
|
if (updateFields != null) 'update_fields': updateFields,
|
|
};
|
|
|
|
final result = await PlatformBridge.reEnrichFile(request);
|
|
final method = result['method'] as String?;
|
|
if (method == 'native') {
|
|
return true;
|
|
}
|
|
if (method == 'ffmpeg') {
|
|
return _applyQueueFfmpegReEnrichResult(item, result);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
List<LocalLibraryItem> _selectedFlacEligibleLocalItems(
|
|
List<UnifiedLibraryItem> allItems,
|
|
) {
|
|
final selectedItems = _selectedItemsFromAll(allItems);
|
|
return selectedItems
|
|
.map((item) => item.localItem)
|
|
.whereType<LocalLibraryItem>()
|
|
.where(LocalTrackRedownloadService.isFlacUpgradeEligible)
|
|
.toList(growable: false);
|
|
}
|
|
|
|
Future<void> _queueSelectedLocalAsFlac(
|
|
List<UnifiedLibraryItem> allItems,
|
|
) async {
|
|
final selectedLocalItems = _selectedFlacEligibleLocalItems(allItems);
|
|
|
|
if (selectedLocalItems.isEmpty) {
|
|
return;
|
|
}
|
|
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: Text(context.l10n.queueFlacAction),
|
|
content: Text(
|
|
context.l10n.queueFlacConfirmMessage(selectedLocalItems.length),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx, false),
|
|
child: Text(context.l10n.dialogCancel),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.pop(ctx, true),
|
|
child: Text(context.l10n.queueFlacAction),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
if (confirmed != true || !mounted) {
|
|
return;
|
|
}
|
|
|
|
final settings = ref.read(settingsProvider);
|
|
final extensionState = ref.read(extensionProvider);
|
|
final includeExtensions =
|
|
settings.useExtensionProviders &&
|
|
extensionState.extensions.any(
|
|
(ext) => ext.enabled && ext.hasMetadataProvider,
|
|
);
|
|
final targetService = LocalTrackRedownloadService.preferredFlacService(
|
|
settings,
|
|
extensionState,
|
|
);
|
|
if (targetService.isEmpty) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(context.l10n.extensionsNoDownloadProvider)),
|
|
);
|
|
return;
|
|
}
|
|
final targetQuality =
|
|
LocalTrackRedownloadService.preferredFlacQualityForService(
|
|
targetService,
|
|
extensionState,
|
|
);
|
|
|
|
final matchedTracks = <Track>[];
|
|
var skippedCount = 0;
|
|
final total = selectedLocalItems.length;
|
|
|
|
var cancelled = false;
|
|
BatchProgressDialog.show(
|
|
context: context,
|
|
title: context.l10n.queueFlacAction,
|
|
total: total,
|
|
icon: Icons.queue_music,
|
|
onCancel: () {
|
|
cancelled = true;
|
|
BatchProgressDialog.dismiss(context);
|
|
},
|
|
);
|
|
|
|
for (var i = 0; i < total; i++) {
|
|
if (!mounted || cancelled) break;
|
|
|
|
BatchProgressDialog.update(
|
|
current: i + 1,
|
|
detail: selectedLocalItems[i].trackName,
|
|
);
|
|
|
|
try {
|
|
final resolution = await LocalTrackRedownloadService.resolveBestMatch(
|
|
selectedLocalItems[i],
|
|
includeExtensions: includeExtensions,
|
|
);
|
|
if (resolution.canQueue && resolution.match != null) {
|
|
matchedTracks.add(resolution.match!);
|
|
} else {
|
|
skippedCount++;
|
|
}
|
|
} catch (_) {
|
|
skippedCount++;
|
|
}
|
|
}
|
|
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
if (!cancelled) {
|
|
BatchProgressDialog.dismiss(context);
|
|
}
|
|
|
|
if (matchedTracks.isEmpty) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(context.l10n.queueFlacNoReliableMatches)),
|
|
);
|
|
return;
|
|
}
|
|
|
|
ref
|
|
.read(downloadQueueProvider.notifier)
|
|
.addMultipleToQueue(
|
|
matchedTracks,
|
|
targetService,
|
|
qualityOverride: targetQuality,
|
|
);
|
|
|
|
final summary = skippedCount == 0
|
|
? context.l10n.snackbarAddedTracksToQueue(matchedTracks.length)
|
|
: context.l10n.queueFlacQueuedWithSkipped(
|
|
matchedTracks.length,
|
|
skippedCount,
|
|
);
|
|
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(summary)));
|
|
setState(() {
|
|
_selectedIds.clear();
|
|
_isSelectionMode = false;
|
|
});
|
|
}
|
|
|
|
Future<void> _reEnrichSelectedLocalFromQueue(
|
|
List<UnifiedLibraryItem> allItems,
|
|
) async {
|
|
final selectedItems = _selectedItemsFromAll(allItems);
|
|
final selectedLocalItems = selectedItems
|
|
.map((item) => item.localItem)
|
|
.whereType<LocalLibraryItem>()
|
|
.toList(growable: false);
|
|
|
|
if (selectedLocalItems.isEmpty) {
|
|
return;
|
|
}
|
|
|
|
// Hide the selection overlay: set the flag (prevents build() from
|
|
// re-inserting via postFrameCallback) and remove the entry immediately.
|
|
setState(() => _isSelectionMode = false);
|
|
_hideSelectionOverlay();
|
|
|
|
final selection = await showReEnrichFieldDialog(
|
|
context,
|
|
selectedCount: selectedLocalItems.length,
|
|
);
|
|
|
|
if (selection == null || !mounted) {
|
|
// Cancelled — restore selection mode; the next build cycle will
|
|
// re-create the overlay via _syncSelectionOverlay in postFrameCallback.
|
|
if (mounted) setState(() => _isSelectionMode = true);
|
|
return;
|
|
}
|
|
|
|
final updateFields = selection.isAll ? null : selection.fields;
|
|
|
|
var successCount = 0;
|
|
final total = selectedLocalItems.length;
|
|
|
|
var cancelled = false;
|
|
BatchProgressDialog.show(
|
|
context: context,
|
|
title: context.l10n.trackReEnrichProgress,
|
|
total: total,
|
|
icon: Icons.auto_fix_high,
|
|
onCancel: () {
|
|
cancelled = true;
|
|
BatchProgressDialog.dismiss(context);
|
|
},
|
|
);
|
|
|
|
for (var i = 0; i < total; i++) {
|
|
if (!mounted || cancelled) break;
|
|
final item = selectedLocalItems[i];
|
|
|
|
BatchProgressDialog.update(
|
|
current: i + 1,
|
|
detail: '${item.trackName} - ${item.artistName}',
|
|
);
|
|
|
|
try {
|
|
final ok = await _reEnrichQueueLocalTrack(
|
|
item,
|
|
updateFields: updateFields,
|
|
);
|
|
if (ok) {
|
|
successCount++;
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
final settings = ref.read(settingsProvider);
|
|
final localLibraryPath = settings.localLibraryPath.trim();
|
|
final iosBookmark = settings.localLibraryBookmark;
|
|
try {
|
|
if (localLibraryPath.isNotEmpty &&
|
|
!ref.read(localLibraryProvider).isScanning) {
|
|
await ref
|
|
.read(localLibraryProvider.notifier)
|
|
.startScan(
|
|
localLibraryPath,
|
|
iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null,
|
|
);
|
|
} else {
|
|
await ref.read(localLibraryProvider.notifier).reloadFromStorage();
|
|
}
|
|
} catch (_) {
|
|
await ref.read(localLibraryProvider.notifier).reloadFromStorage();
|
|
}
|
|
|
|
_exitSelectionMode();
|
|
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
if (!cancelled) {
|
|
BatchProgressDialog.dismiss(context);
|
|
}
|
|
ScaffoldMessenger.of(context).clearSnackBars();
|
|
final failedCount = total - successCount;
|
|
final summary = failedCount <= 0
|
|
? '${context.l10n.trackReEnrichSuccess} ($successCount/$total)'
|
|
: '${context.l10n.trackReEnrichSuccess} ($successCount/$total) • Failed: $failedCount';
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(summary)));
|
|
}
|
|
|
|
/// Share selected tracks via system share sheet
|
|
Future<void> _shareSelected(List<UnifiedLibraryItem> allItems) async {
|
|
final itemsById = {for (final item in allItems) item.id: item};
|
|
final safUris = <String>[];
|
|
final filesToShare = <XFile>[];
|
|
|
|
for (final id in _selectedIds) {
|
|
final item = itemsById[id];
|
|
if (item == null) continue;
|
|
final path = item.filePath;
|
|
if (isContentUri(path)) {
|
|
if (await fileExists(path)) safUris.add(path);
|
|
} else if (await fileExists(path)) {
|
|
filesToShare.add(XFile(path));
|
|
}
|
|
}
|
|
|
|
if (safUris.isEmpty && filesToShare.isEmpty) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(context.l10n.selectionShareNoFiles)),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (safUris.isNotEmpty) {
|
|
try {
|
|
if (safUris.length == 1) {
|
|
await PlatformBridge.shareContentUri(safUris.first);
|
|
} else {
|
|
await PlatformBridge.shareMultipleContentUris(safUris);
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
if (filesToShare.isNotEmpty) {
|
|
await SharePlus.instance.share(ShareParams(files: filesToShare));
|
|
}
|
|
}
|
|
|
|
Future<void> _showBatchConvertSheet(
|
|
BuildContext context,
|
|
List<UnifiedLibraryItem> allItems,
|
|
) async {
|
|
final itemsById = {for (final item in allItems) item.id: item};
|
|
final sourceFormats = <String>{};
|
|
for (final id in _selectedIds) {
|
|
final item = itemsById[id];
|
|
if (item == null) continue;
|
|
String nameToCheck;
|
|
if (item.historyItem?.safFileName != null &&
|
|
item.historyItem!.safFileName!.isNotEmpty) {
|
|
nameToCheck = item.historyItem!.safFileName!.toLowerCase();
|
|
} else if (item.localItem?.format != null &&
|
|
item.localItem!.format!.isNotEmpty) {
|
|
nameToCheck = '.${item.localItem!.format!.toLowerCase()}';
|
|
} else {
|
|
nameToCheck = item.filePath.toLowerCase();
|
|
}
|
|
final ext = nameToCheck.endsWith('.flac')
|
|
? 'FLAC'
|
|
: nameToCheck.endsWith('.m4a')
|
|
? 'M4A'
|
|
: nameToCheck.endsWith('.mp3')
|
|
? 'MP3'
|
|
: (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg'))
|
|
? 'Opus'
|
|
: null;
|
|
if (ext != null) sourceFormats.add(ext);
|
|
}
|
|
|
|
final formats = ['ALAC', 'FLAC', 'MP3', 'Opus'].where((target) {
|
|
return sourceFormats.any((src) {
|
|
if (src == target) return false;
|
|
final isLosslessTarget = target == 'ALAC' || target == 'FLAC';
|
|
final isLosslessSource = src == 'FLAC' || src == 'M4A';
|
|
if (isLosslessTarget && !isLosslessSource) return false;
|
|
return true;
|
|
});
|
|
}).toList();
|
|
|
|
if (formats.isEmpty) return;
|
|
|
|
String selectedFormat = formats.first;
|
|
bool isLosslessTarget =
|
|
selectedFormat == 'ALAC' || selectedFormat == 'FLAC';
|
|
String selectedBitrate = isLosslessTarget
|
|
? '320k'
|
|
: (selectedFormat == 'Opus' ? '128k' : '320k');
|
|
var didStartConversion = false;
|
|
|
|
_hideSelectionOverlay();
|
|
_hidePlaylistSelectionOverlay();
|
|
|
|
await showModalBottomSheet<void>(
|
|
context: context,
|
|
useRootNavigator: true,
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
|
),
|
|
builder: (sheetContext) {
|
|
return StatefulBuilder(
|
|
builder: (context, setSheetState) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final bitrates = ['128k', '192k', '256k', '320k'];
|
|
|
|
return SafeArea(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Center(
|
|
child: Container(
|
|
width: 40,
|
|
height: 4,
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.onSurfaceVariant.withValues(
|
|
alpha: 0.4,
|
|
),
|
|
borderRadius: BorderRadius.circular(2),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
context.l10n.selectionBatchConvertConfirmTitle,
|
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(height: 20),
|
|
Text(
|
|
context.l10n.trackConvertTargetFormat,
|
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Wrap(
|
|
spacing: 8,
|
|
children: formats.map((format) {
|
|
final isSelected = format == selectedFormat;
|
|
return ChoiceChip(
|
|
label: Text(format),
|
|
selected: isSelected,
|
|
onSelected: (selected) {
|
|
if (selected) {
|
|
setSheetState(() {
|
|
selectedFormat = format;
|
|
isLosslessTarget =
|
|
format == 'ALAC' || format == 'FLAC';
|
|
if (!isLosslessTarget) {
|
|
selectedBitrate = format == 'Opus'
|
|
? '128k'
|
|
: '320k';
|
|
}
|
|
});
|
|
}
|
|
},
|
|
);
|
|
}).toList(),
|
|
),
|
|
if (!isLosslessTarget) ...[
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
context.l10n.trackConvertBitrate,
|
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Wrap(
|
|
spacing: 8,
|
|
children: bitrates.map((br) {
|
|
final isSelected = br == selectedBitrate;
|
|
return ChoiceChip(
|
|
label: Text(br),
|
|
selected: isSelected,
|
|
onSelected: (selected) {
|
|
if (selected) {
|
|
setSheetState(() => selectedBitrate = br);
|
|
}
|
|
},
|
|
);
|
|
}).toList(),
|
|
),
|
|
],
|
|
if (isLosslessTarget) ...[
|
|
const SizedBox(height: 16),
|
|
Row(
|
|
children: [
|
|
Icon(
|
|
Icons.verified,
|
|
size: 16,
|
|
color: colorScheme.primary,
|
|
),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
context.l10n.trackConvertLosslessHint,
|
|
style: Theme.of(context).textTheme.bodySmall
|
|
?.copyWith(color: colorScheme.primary),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
const SizedBox(height: 24),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: FilledButton(
|
|
onPressed: () {
|
|
didStartConversion = true;
|
|
Navigator.pop(context);
|
|
_performBatchConversion(
|
|
allItems: allItems,
|
|
targetFormat: selectedFormat,
|
|
bitrate: selectedBitrate,
|
|
);
|
|
},
|
|
style: FilledButton.styleFrom(
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
),
|
|
child: Text(
|
|
context.l10n.selectionConvertCount(
|
|
_selectedIds.length,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
|
|
if (!mounted || didStartConversion) return;
|
|
if (_isSelectionMode) {
|
|
_syncSelectionOverlay(
|
|
items: allItems,
|
|
bottomPadding: MediaQuery.of(this.context).padding.bottom,
|
|
);
|
|
} else if (_isPlaylistSelectionMode) {
|
|
_syncPlaylistSelectionOverlay(
|
|
playlists: ref.read(libraryCollectionsProvider).playlists,
|
|
bottomPadding: MediaQuery.of(this.context).padding.bottom,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Perform batch conversion on selected tracks
|
|
Future<void> _performBatchConversion({
|
|
required List<UnifiedLibraryItem> allItems,
|
|
required String targetFormat,
|
|
required String bitrate,
|
|
}) async {
|
|
final itemsById = {for (final item in allItems) item.id: item};
|
|
final selectedItems = <UnifiedLibraryItem>[];
|
|
for (final id in _selectedIds) {
|
|
final item = itemsById[id];
|
|
if (item == null) continue;
|
|
String nameToCheck;
|
|
if (item.historyItem?.safFileName != null &&
|
|
item.historyItem!.safFileName!.isNotEmpty) {
|
|
nameToCheck = item.historyItem!.safFileName!.toLowerCase();
|
|
} else if (item.localItem?.format != null &&
|
|
item.localItem!.format!.isNotEmpty) {
|
|
nameToCheck = '.${item.localItem!.format!.toLowerCase()}';
|
|
} else {
|
|
nameToCheck = item.filePath.toLowerCase();
|
|
}
|
|
final ext = nameToCheck.endsWith('.flac')
|
|
? 'FLAC'
|
|
: nameToCheck.endsWith('.m4a')
|
|
? 'M4A'
|
|
: nameToCheck.endsWith('.mp3')
|
|
? 'MP3'
|
|
: (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg'))
|
|
? 'Opus'
|
|
: null;
|
|
if (ext == null || ext == targetFormat) continue;
|
|
final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC';
|
|
final isLosslessSource = ext == 'FLAC' || ext == 'M4A';
|
|
if (isLosslessTarget && !isLosslessSource) continue;
|
|
selectedItems.add(item);
|
|
}
|
|
|
|
if (selectedItems.isEmpty) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(context.l10n.selectionConvertNoConvertible)),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC';
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: Text(context.l10n.selectionBatchConvertConfirmTitle),
|
|
content: Text(
|
|
isLossless
|
|
? context.l10n.selectionBatchConvertConfirmMessageLossless(
|
|
selectedItems.length,
|
|
targetFormat,
|
|
)
|
|
: context.l10n.selectionBatchConvertConfirmMessage(
|
|
selectedItems.length,
|
|
targetFormat,
|
|
bitrate,
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx, false),
|
|
child: Text(context.l10n.dialogCancel),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.pop(ctx, true),
|
|
child: Text(context.l10n.trackConvertFormat),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
if (confirmed != true || !mounted) return;
|
|
|
|
int successCount = 0;
|
|
final total = selectedItems.length;
|
|
final historyDb = HistoryDatabase.instance;
|
|
final newQuality =
|
|
(targetFormat.toUpperCase() == 'ALAC' ||
|
|
targetFormat.toUpperCase() == 'FLAC')
|
|
? '${targetFormat.toUpperCase()} Lossless'
|
|
: '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}';
|
|
final settings = ref.read(settingsProvider);
|
|
final shouldEmbedLyrics =
|
|
settings.embedLyrics && settings.lyricsMode != 'external';
|
|
|
|
var cancelled = false;
|
|
BatchProgressDialog.show(
|
|
context: context,
|
|
title: context.l10n.trackConvertConverting,
|
|
total: total,
|
|
icon: Icons.transform,
|
|
onCancel: () {
|
|
cancelled = true;
|
|
BatchProgressDialog.dismiss(context);
|
|
},
|
|
);
|
|
|
|
for (int i = 0; i < total; i++) {
|
|
if (!mounted || cancelled) break;
|
|
final item = selectedItems[i];
|
|
|
|
BatchProgressDialog.update(current: i + 1, detail: item.trackName);
|
|
|
|
try {
|
|
final metadata = <String, String>{
|
|
'TITLE': item.trackName,
|
|
'ARTIST': item.artistName,
|
|
'ALBUM': item.albumName,
|
|
};
|
|
try {
|
|
final result = await PlatformBridge.readFileMetadata(item.filePath);
|
|
if (result['error'] == null) {
|
|
mergePlatformMetadataForTagEmbed(target: metadata, source: result);
|
|
}
|
|
} catch (_) {}
|
|
await ensureLyricsMetadataForConversion(
|
|
metadata: metadata,
|
|
sourcePath: item.filePath,
|
|
shouldEmbedLyrics: shouldEmbedLyrics,
|
|
trackName: item.trackName,
|
|
artistName: item.artistName,
|
|
spotifyId: item.historyItem?.spotifyId ?? '',
|
|
durationMs:
|
|
((item.historyItem?.duration ?? item.localItem?.duration) ?? 0) *
|
|
1000,
|
|
);
|
|
|
|
String? coverPath;
|
|
try {
|
|
final tempDir = await getTemporaryDirectory();
|
|
final coverOutput =
|
|
'${tempDir.path}${Platform.pathSeparator}batch_cover_${DateTime.now().millisecondsSinceEpoch}.jpg';
|
|
final coverResult = await PlatformBridge.extractCoverToFile(
|
|
item.filePath,
|
|
coverOutput,
|
|
);
|
|
if (coverResult['error'] == null) {
|
|
coverPath = coverOutput;
|
|
}
|
|
} catch (_) {}
|
|
|
|
String workingPath = item.filePath;
|
|
final isSaf = isContentUri(item.filePath);
|
|
String? safTempPath;
|
|
|
|
if (isSaf) {
|
|
safTempPath = await PlatformBridge.copyContentUriToTemp(
|
|
item.filePath,
|
|
);
|
|
if (safTempPath == null) continue;
|
|
workingPath = safTempPath;
|
|
}
|
|
|
|
final newPath = await FFmpegService.convertAudioFormat(
|
|
inputPath: workingPath,
|
|
targetFormat: targetFormat.toLowerCase(),
|
|
bitrate: bitrate,
|
|
metadata: metadata,
|
|
coverPath: coverPath,
|
|
artistTagMode: settings.artistTagMode,
|
|
deleteOriginal: !isSaf,
|
|
);
|
|
|
|
if (coverPath != null) {
|
|
try {
|
|
await File(coverPath).delete();
|
|
} catch (_) {}
|
|
}
|
|
|
|
if (newPath == null) {
|
|
if (safTempPath != null) {
|
|
try {
|
|
await File(safTempPath).delete();
|
|
} catch (_) {}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (isSaf && item.historyItem != null) {
|
|
final hi = item.historyItem!;
|
|
final treeUri = hi.downloadTreeUri;
|
|
final relativeDir = hi.safRelativeDir ?? '';
|
|
if (treeUri != null && treeUri.isNotEmpty) {
|
|
final oldFileName = hi.safFileName ?? '';
|
|
final dotIdx = oldFileName.lastIndexOf('.');
|
|
final baseName = dotIdx > 0
|
|
? oldFileName.substring(0, dotIdx)
|
|
: oldFileName;
|
|
String newExt;
|
|
String mimeType;
|
|
switch (targetFormat.toLowerCase()) {
|
|
case 'opus':
|
|
newExt = '.opus';
|
|
mimeType = 'audio/opus';
|
|
break;
|
|
case 'alac':
|
|
newExt = '.m4a';
|
|
mimeType = 'audio/mp4';
|
|
break;
|
|
case 'flac':
|
|
newExt = '.flac';
|
|
mimeType = 'audio/flac';
|
|
break;
|
|
default:
|
|
newExt = '.mp3';
|
|
mimeType = 'audio/mpeg';
|
|
break;
|
|
}
|
|
final newFileName = '$baseName$newExt';
|
|
|
|
final safUri = await PlatformBridge.createSafFileFromPath(
|
|
treeUri: treeUri,
|
|
relativeDir: relativeDir,
|
|
fileName: newFileName,
|
|
mimeType: mimeType,
|
|
srcPath: newPath,
|
|
);
|
|
|
|
if (safUri == null || safUri.isEmpty) {
|
|
try {
|
|
await File(newPath).delete();
|
|
} catch (_) {}
|
|
if (safTempPath != null) {
|
|
try {
|
|
await File(safTempPath).delete();
|
|
} catch (_) {}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
await PlatformBridge.safDelete(item.filePath);
|
|
} catch (_) {}
|
|
|
|
await historyDb.updateFilePath(
|
|
hi.id,
|
|
safUri,
|
|
newSafFileName: newFileName,
|
|
newQuality: newQuality,
|
|
clearAudioSpecs: true,
|
|
);
|
|
}
|
|
try {
|
|
await File(newPath).delete();
|
|
} catch (_) {}
|
|
if (safTempPath != null) {
|
|
try {
|
|
await File(safTempPath).delete();
|
|
} catch (_) {}
|
|
}
|
|
} else if (isSaf && item.localItem != null) {
|
|
final uri = Uri.parse(item.filePath);
|
|
final pathSegments = uri.pathSegments;
|
|
|
|
String? treeUri;
|
|
String relativeDir = '';
|
|
String oldFileName = '';
|
|
|
|
final treeIdx = pathSegments.indexOf('tree');
|
|
final docIdx = pathSegments.indexOf('document');
|
|
if (treeIdx >= 0 && treeIdx + 1 < pathSegments.length) {
|
|
final treeId = pathSegments[treeIdx + 1];
|
|
treeUri =
|
|
'content://${uri.authority}/tree/${Uri.encodeComponent(treeId)}';
|
|
}
|
|
if (docIdx >= 0 && docIdx + 1 < pathSegments.length) {
|
|
final docPath = Uri.decodeFull(pathSegments[docIdx + 1]);
|
|
final slashIdx = docPath.lastIndexOf('/');
|
|
if (slashIdx >= 0) {
|
|
oldFileName = docPath.substring(slashIdx + 1);
|
|
final treeId = treeIdx >= 0 && treeIdx + 1 < pathSegments.length
|
|
? Uri.decodeFull(pathSegments[treeIdx + 1])
|
|
: '';
|
|
if (treeId.isNotEmpty && docPath.startsWith(treeId)) {
|
|
final afterTree = docPath.substring(treeId.length);
|
|
final trimmed = afterTree.startsWith('/')
|
|
? afterTree.substring(1)
|
|
: afterTree;
|
|
final lastSlash = trimmed.lastIndexOf('/');
|
|
relativeDir = lastSlash >= 0
|
|
? trimmed.substring(0, lastSlash)
|
|
: '';
|
|
}
|
|
} else {
|
|
oldFileName = docPath;
|
|
}
|
|
}
|
|
|
|
if (treeUri != null && oldFileName.isNotEmpty) {
|
|
final dotIdx = oldFileName.lastIndexOf('.');
|
|
final baseName = dotIdx > 0
|
|
? oldFileName.substring(0, dotIdx)
|
|
: oldFileName;
|
|
String newExt;
|
|
String mimeType;
|
|
switch (targetFormat.toLowerCase()) {
|
|
case 'opus':
|
|
newExt = '.opus';
|
|
mimeType = 'audio/opus';
|
|
break;
|
|
case 'alac':
|
|
newExt = '.m4a';
|
|
mimeType = 'audio/mp4';
|
|
break;
|
|
case 'flac':
|
|
newExt = '.flac';
|
|
mimeType = 'audio/flac';
|
|
break;
|
|
default:
|
|
newExt = '.mp3';
|
|
mimeType = 'audio/mpeg';
|
|
break;
|
|
}
|
|
final newFileName = '$baseName$newExt';
|
|
|
|
final safUri = await PlatformBridge.createSafFileFromPath(
|
|
treeUri: treeUri,
|
|
relativeDir: relativeDir,
|
|
fileName: newFileName,
|
|
mimeType: mimeType,
|
|
srcPath: newPath,
|
|
);
|
|
|
|
if (safUri == null || safUri.isEmpty) {
|
|
try {
|
|
await File(newPath).delete();
|
|
} catch (_) {}
|
|
if (safTempPath != null) {
|
|
try {
|
|
await File(safTempPath).delete();
|
|
} catch (_) {}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
await PlatformBridge.safDelete(item.filePath);
|
|
} catch (_) {}
|
|
await LibraryDatabase.instance.replaceWithConvertedItem(
|
|
item: item.localItem!,
|
|
newFilePath: safUri,
|
|
targetFormat: targetFormat,
|
|
bitrate: bitrate,
|
|
);
|
|
}
|
|
|
|
try {
|
|
await File(newPath).delete();
|
|
} catch (_) {}
|
|
if (safTempPath != null) {
|
|
try {
|
|
await File(safTempPath).delete();
|
|
} catch (_) {}
|
|
}
|
|
} else if (item.historyItem != null) {
|
|
await historyDb.updateFilePath(
|
|
item.historyItem!.id,
|
|
newPath,
|
|
newQuality: newQuality,
|
|
clearAudioSpecs: true,
|
|
);
|
|
} else if (item.localItem != null) {
|
|
await LibraryDatabase.instance.replaceWithConvertedItem(
|
|
item: item.localItem!,
|
|
newFilePath: newPath,
|
|
targetFormat: targetFormat,
|
|
bitrate: bitrate,
|
|
);
|
|
}
|
|
|
|
successCount++;
|
|
} catch (_) {}
|
|
}
|
|
|
|
ref.read(downloadHistoryProvider.notifier).reloadFromStorage();
|
|
ref.read(localLibraryProvider.notifier).reloadFromStorage();
|
|
|
|
_exitSelectionMode();
|
|
|
|
if (mounted) {
|
|
if (!cancelled) {
|
|
BatchProgressDialog.dismiss(context);
|
|
}
|
|
ScaffoldMessenger.of(context).clearSnackBars();
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
context.l10n.selectionBatchConvertSuccess(
|
|
successCount,
|
|
total,
|
|
targetFormat,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
Widget _buildSelectionBottomBar(
|
|
BuildContext context,
|
|
ColorScheme colorScheme,
|
|
List<UnifiedLibraryItem> unifiedItems,
|
|
double bottomPadding,
|
|
) {
|
|
final selectedCount = _selectedIds.length;
|
|
final allSelected =
|
|
selectedCount == unifiedItems.length && unifiedItems.isNotEmpty;
|
|
final localOnlySelection = _isLocalOnlySelection(unifiedItems);
|
|
final flacEligibleCount = _selectedFlacEligibleLocalItems(
|
|
unifiedItems,
|
|
).length;
|
|
|
|
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,
|
|
tooltip: MaterialLocalizations.of(
|
|
context,
|
|
).closeButtonTooltip,
|
|
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.selectionSelected(selectedCount),
|
|
style: Theme.of(context).textTheme.titleMedium
|
|
?.copyWith(fontWeight: FontWeight.bold),
|
|
),
|
|
Text(
|
|
allSelected
|
|
? context.l10n.selectionAllSelected
|
|
: context.l10n.downloadedAlbumTapToSelect,
|
|
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
|
|
? context.l10n.actionDeselect
|
|
: context.l10n.actionSelectAll,
|
|
),
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: colorScheme.primary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
Row(
|
|
children: [
|
|
if (localOnlySelection && flacEligibleCount > 0) ...[
|
|
Expanded(
|
|
child: _SelectionActionButton(
|
|
icon: Icons.download_for_offline_outlined,
|
|
label:
|
|
'${context.l10n.queueFlacAction} ($flacEligibleCount)',
|
|
onPressed: () =>
|
|
_queueSelectedLocalAsFlac(unifiedItems),
|
|
colorScheme: colorScheme,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
],
|
|
Expanded(
|
|
child: _SelectionActionButton(
|
|
icon: localOnlySelection
|
|
? Icons.auto_fix_high_outlined
|
|
: Icons.share_outlined,
|
|
label: localOnlySelection
|
|
? '${context.l10n.trackReEnrich} ($selectedCount)'
|
|
: context.l10n.selectionShareCount(selectedCount),
|
|
onPressed: selectedCount > 0
|
|
? () => localOnlySelection
|
|
? _reEnrichSelectedLocalFromQueue(unifiedItems)
|
|
: _shareSelected(unifiedItems)
|
|
: null,
|
|
colorScheme: colorScheme,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: _SelectionActionButton(
|
|
icon: Icons.swap_horiz,
|
|
label: context.l10n.selectionConvertCount(selectedCount),
|
|
onPressed: selectedCount > 0
|
|
? () => _showBatchConvertSheet(context, unifiedItems)
|
|
: null,
|
|
colorScheme: colorScheme,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
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'}'
|
|
: context.l10n.selectionSelectToDelete,
|
|
),
|
|
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;
|
|
final isActive =
|
|
item.status == DownloadStatus.queued ||
|
|
item.status == DownloadStatus.downloading ||
|
|
item.status == DownloadStatus.finalizing;
|
|
|
|
return Dismissible(
|
|
key: ValueKey('dismiss_${item.id}'),
|
|
direction: DismissDirection.endToStart,
|
|
confirmDismiss: isActive
|
|
? (_) async {
|
|
return await showDialog<bool>(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: Text(context.l10n.cancelDownloadTitle),
|
|
content: Text(
|
|
context.l10n.cancelDownloadContent(item.track.name),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(ctx).pop(false),
|
|
child: Text(context.l10n.cancelDownloadKeep),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.of(ctx).pop(true),
|
|
child: Text(context.l10n.dialogCancel),
|
|
),
|
|
],
|
|
),
|
|
) ??
|
|
false;
|
|
}
|
|
: null,
|
|
onDismissed: (_) {
|
|
ref.read(downloadQueueProvider.notifier).dismissItem(item.id);
|
|
},
|
|
background: Container(
|
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.errorContainer,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
alignment: Alignment.centerRight,
|
|
padding: const EdgeInsets.only(right: 20),
|
|
child: Icon(Icons.delete_outline, color: colorScheme.onErrorContainer),
|
|
),
|
|
child: DownloadSuccessOverlay(
|
|
showSuccess: isCompleted,
|
|
child: 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),
|
|
ClickableArtistName(
|
|
artistName: item.track.artistName,
|
|
artistId: item.track.artistId,
|
|
coverUrl: item.track.coverUrl,
|
|
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.bytesTotal > 0
|
|
? '${(item.progress * 100).toStringAsFixed(0)}%'
|
|
: (item.bytesReceived > 0
|
|
? '${(item.bytesReceived / (1024 * 1024)).toStringAsFixed(1)} MB${item.speedMBps > 0 ? ' • ${item.speedMBps.toStringAsFixed(1)} MB/s' : ''}'
|
|
: (item.progress > 0
|
|
? (item.speedMBps > 0
|
|
? '${(item.progress * 100).toStringAsFixed(0)}% • ${item.speedMBps.toStringAsFixed(1)} MB/s'
|
|
: '${(item.progress * 100).toStringAsFixed(0)}%')
|
|
: (item.speedMBps > 0
|
|
? 'Downloading • ${item.speedMBps.toStringAsFixed(1)} MB/s'
|
|
: 'Starting...'))),
|
|
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) {
|
|
final coverSize = _queueCoverSize();
|
|
|
|
return item.track.coverUrl != null
|
|
? CachedCoverImage(
|
|
imageUrl: item.track.coverUrl!,
|
|
width: coverSize,
|
|
height: coverSize,
|
|
borderRadius: BorderRadius.circular(8),
|
|
fadeInDuration: const Duration(milliseconds: 180),
|
|
fadeOutDuration: const Duration(milliseconds: 90),
|
|
)
|
|
: Container(
|
|
width: coverSize,
|
|
height: coverSize,
|
|
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: context.l10n.dialogCancel,
|
|
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: context.l10n.actionStop,
|
|
style: IconButton.styleFrom(
|
|
backgroundColor: colorScheme.errorContainer.withValues(alpha: 0.3),
|
|
),
|
|
);
|
|
case DownloadStatus.finalizing:
|
|
return Semantics(
|
|
label: context.l10n.queueFinalizingDownload,
|
|
child: SizedBox(
|
|
width: 40,
|
|
height: 40,
|
|
child: Stack(
|
|
alignment: Alignment.center,
|
|
children: [
|
|
CircularProgressIndicator(
|
|
strokeWidth: 3,
|
|
color: colorScheme.tertiary,
|
|
),
|
|
ExcludeSemantics(
|
|
child: Icon(
|
|
Icons.edit_note,
|
|
color: colorScheme.tertiary,
|
|
size: 16,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
case DownloadStatus.completed:
|
|
return ValueListenableBuilder<bool>(
|
|
valueListenable: _fileExistsListenable(item.filePath),
|
|
builder: (context, fileExists, child) {
|
|
return Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (fileExists)
|
|
IconButton(
|
|
onPressed: () => _openFile(
|
|
item.filePath!,
|
|
title: item.track.name,
|
|
artist: item.track.artistName,
|
|
album: item.track.albumName,
|
|
coverUrl: item.track.coverUrl ?? '',
|
|
),
|
|
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
|
|
tooltip: context.l10n.tooltipPlay,
|
|
style: IconButton.styleFrom(
|
|
backgroundColor: colorScheme.primaryContainer.withValues(
|
|
alpha: 0.3,
|
|
),
|
|
),
|
|
)
|
|
else
|
|
Semantics(
|
|
label: context.l10n.queueDownloadedFileMissing,
|
|
child: ExcludeSemantics(
|
|
child: Icon(
|
|
Icons.error_outline,
|
|
color: colorScheme.error,
|
|
size: 20,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 4),
|
|
Semantics(
|
|
label: context.l10n.queueDownloadCompleted,
|
|
child: ExcludeSemantics(
|
|
child: 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: context.l10n.dialogRetry,
|
|
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: context.l10n.dialogRemove,
|
|
style: item.status == DownloadStatus.failed
|
|
? IconButton.styleFrom(
|
|
backgroundColor: colorScheme.errorContainer.withValues(
|
|
alpha: 0.3,
|
|
),
|
|
)
|
|
: null,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
Widget _buildFilterButton(
|
|
BuildContext context,
|
|
List<UnifiedLibraryItem> unifiedItems,
|
|
) {
|
|
return 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),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// When [size] is provided, renders at fixed dimensions (list mode).
|
|
/// When [size] is null, fills the parent container (grid mode).
|
|
Widget _buildUnifiedCoverImage(
|
|
UnifiedLibraryItem item,
|
|
ColorScheme colorScheme, [
|
|
double? size,
|
|
]) {
|
|
final isDownloaded = item.source == LibraryItemSource.downloaded;
|
|
|
|
// For downloaded items, listen to embedded cover version so the cover
|
|
// updates after async extraction completes.
|
|
if (isDownloaded) {
|
|
return ValueListenableBuilder<int>(
|
|
valueListenable: _embeddedCoverVersion,
|
|
builder: (context, _, child) =>
|
|
_buildUnifiedCoverImageInner(item, colorScheme, isDownloaded, size),
|
|
);
|
|
}
|
|
|
|
return _buildUnifiedCoverImageInner(item, colorScheme, isDownloaded, size);
|
|
}
|
|
|
|
Widget _buildUnifiedCoverImageInner(
|
|
UnifiedLibraryItem item,
|
|
ColorScheme colorScheme,
|
|
bool isDownloaded, [
|
|
double? size,
|
|
]) {
|
|
final cacheSize = size != null ? (size * 2).toInt() : 200;
|
|
final iconSize = size != null ? size * 0.4 : 32.0;
|
|
|
|
Widget buildPlaceholder({bool isLocal = false}) {
|
|
final bgColor = (isDownloaded && !isLocal)
|
|
? colorScheme.surfaceContainerHighest
|
|
: colorScheme.secondaryContainer;
|
|
final fgColor = (isDownloaded && !isLocal)
|
|
? colorScheme.onSurfaceVariant
|
|
: colorScheme.onSecondaryContainer;
|
|
return Container(
|
|
width: size,
|
|
height: size,
|
|
decoration: size != null
|
|
? BoxDecoration(
|
|
color: bgColor,
|
|
borderRadius: BorderRadius.circular(8),
|
|
)
|
|
: null,
|
|
color: size != null ? null : bgColor,
|
|
child: Center(
|
|
child: Icon(Icons.music_note, color: fgColor, size: iconSize),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget fadeInFileImage(Widget child, int? frame, bool wasSync) {
|
|
if (wasSync) return child;
|
|
final animated = Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
buildPlaceholder(isLocal: !isDownloaded),
|
|
AnimatedOpacity(
|
|
opacity: frame == null ? 0.0 : 1.0,
|
|
duration: const Duration(milliseconds: 180),
|
|
curve: Curves.easeOutCubic,
|
|
child: child,
|
|
),
|
|
],
|
|
);
|
|
if (size == null) return animated;
|
|
return SizedBox(width: size, height: size, child: animated);
|
|
}
|
|
|
|
if (isDownloaded) {
|
|
final embeddedCoverPath = _resolveDownloadedEmbeddedCoverPath(
|
|
item.filePath,
|
|
);
|
|
if (embeddedCoverPath != null) {
|
|
return ClipRRect(
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: Image.file(
|
|
File(embeddedCoverPath),
|
|
width: size,
|
|
height: size,
|
|
fit: BoxFit.cover,
|
|
cacheWidth: cacheSize,
|
|
cacheHeight: cacheSize,
|
|
gaplessPlayback: true,
|
|
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) =>
|
|
fadeInFileImage(child, frame, wasSynchronouslyLoaded),
|
|
errorBuilder: (context, error, stackTrace) => buildPlaceholder(),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
if (item.coverUrl != null) {
|
|
return CachedCoverImage(
|
|
imageUrl: item.coverUrl!,
|
|
width: size,
|
|
height: size,
|
|
memCacheWidth: cacheSize,
|
|
memCacheHeight: cacheSize,
|
|
borderRadius: BorderRadius.circular(8),
|
|
placeholder: (context, url) => buildPlaceholder(),
|
|
errorWidget: (context, url, error) => buildPlaceholder(),
|
|
fadeInDuration: const Duration(milliseconds: 180),
|
|
fadeOutDuration: const Duration(milliseconds: 90),
|
|
);
|
|
}
|
|
|
|
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: cacheSize,
|
|
cacheHeight: cacheSize,
|
|
gaplessPlayback: true,
|
|
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) =>
|
|
fadeInFileImage(child, frame, wasSynchronouslyLoaded),
|
|
errorBuilder: (context, error, stackTrace) =>
|
|
buildPlaceholder(isLocal: true),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (size != null) {
|
|
return buildPlaceholder();
|
|
}
|
|
return ClipRRect(
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: buildPlaceholder(),
|
|
);
|
|
}
|
|
|
|
Widget _buildUnifiedLibraryItem(
|
|
BuildContext context,
|
|
UnifiedLibraryItem item,
|
|
ColorScheme colorScheme, {
|
|
required List<DownloadHistoryItem> downloadedNavigationItems,
|
|
required int? downloadedNavigationIndex,
|
|
required List<LocalLibraryItem> localNavigationItems,
|
|
required int? localNavigationIndex,
|
|
}) {
|
|
final fileExistsListenable = _fileExistsListenable(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 Semantics(
|
|
label: context.l10n.a11yTrackByArtist(item.trackName, item.artistName),
|
|
selected: isSelected,
|
|
child: 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!,
|
|
navigationItems: downloadedNavigationItems,
|
|
navigationIndex: downloadedNavigationIndex,
|
|
)
|
|
: item.localItem != null
|
|
? () => _navigateToLocalMetadataScreen(
|
|
item.localItem!,
|
|
navigationItems: localNavigationItems,
|
|
navigationIndex: localNavigationIndex,
|
|
)
|
|
: () => _openFile(
|
|
item.filePath,
|
|
title: item.trackName,
|
|
artist: item.artistName,
|
|
album: item.albumName,
|
|
coverUrl: item.coverUrl ?? item.localCoverPath ?? '',
|
|
),
|
|
onLongPress: _isSelectionMode
|
|
? null
|
|
: () => _enterSelectionMode(item.id),
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Row(
|
|
children: [
|
|
if (_isSelectionMode) ...[
|
|
Semantics(
|
|
checked: isSelected,
|
|
label: isSelected ? 'Deselect track' : 'Select track',
|
|
child: AnimatedSelectionCheckbox(
|
|
visible: true,
|
|
selected: isSelected,
|
|
colorScheme: colorScheme,
|
|
size: 24,
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
],
|
|
Hero(
|
|
tag: 'cover_lib_${item.id}',
|
|
child: _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),
|
|
ClickableArtistName(
|
|
artistName: item.artistName,
|
|
coverUrl: item.coverUrl,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Row(
|
|
children: [
|
|
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),
|
|
Flexible(
|
|
child: Text(
|
|
dateStr,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
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)
|
|
ValueListenableBuilder<bool>(
|
|
valueListenable: fileExistsListenable,
|
|
builder: (context, fileExists, child) {
|
|
return Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (fileExists)
|
|
IconButton(
|
|
onPressed: () => _openFile(
|
|
item.filePath,
|
|
title: item.trackName,
|
|
artist: item.artistName,
|
|
album: item.albumName,
|
|
coverUrl:
|
|
item.coverUrl ?? item.localCoverPath ?? '',
|
|
),
|
|
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,
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildUnifiedGridItem(
|
|
BuildContext context,
|
|
UnifiedLibraryItem item,
|
|
ColorScheme colorScheme, {
|
|
required List<DownloadHistoryItem> downloadedNavigationItems,
|
|
required int? downloadedNavigationIndex,
|
|
required List<LocalLibraryItem> localNavigationItems,
|
|
required int? localNavigationIndex,
|
|
}) {
|
|
final fileExistsListenable = _fileExistsListenable(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!,
|
|
navigationItems: downloadedNavigationItems,
|
|
navigationIndex: downloadedNavigationIndex,
|
|
)
|
|
: item.localItem != null
|
|
? () => _navigateToLocalMetadataScreen(
|
|
item.localItem!,
|
|
navigationItems: localNavigationItems,
|
|
navigationIndex: localNavigationIndex,
|
|
)
|
|
: () => _openFile(
|
|
item.filePath,
|
|
title: item.trackName,
|
|
artist: item.artistName,
|
|
album: item.albumName,
|
|
coverUrl: item.coverUrl ?? item.localCoverPath ?? '',
|
|
),
|
|
onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(item.id),
|
|
child: Stack(
|
|
children: [
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Stack(
|
|
children: [
|
|
AspectRatio(
|
|
aspectRatio: 1,
|
|
child: Hero(
|
|
tag: 'cover_lib_${item.id}',
|
|
child: _buildUnifiedCoverImage(item, colorScheme),
|
|
),
|
|
),
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
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 (!_isSelectionMode)
|
|
Positioned(
|
|
right: 4,
|
|
bottom: 4,
|
|
child: ValueListenableBuilder<bool>(
|
|
valueListenable: fileExistsListenable,
|
|
builder: (context, fileExists, child) {
|
|
return fileExists
|
|
? Semantics(
|
|
button: true,
|
|
label:
|
|
'Play ${item.trackName} by ${item.artistName}',
|
|
child: GestureDetector(
|
|
onTap: () => _openFile(
|
|
item.filePath,
|
|
title: item.trackName,
|
|
artist: item.artistName,
|
|
album: item.albumName,
|
|
coverUrl:
|
|
item.coverUrl ??
|
|
item.localCoverPath ??
|
|
'',
|
|
),
|
|
child: Container(
|
|
padding: const EdgeInsets.all(6),
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.primary,
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: ExcludeSemantics(
|
|
child: Icon(
|
|
Icons.play_arrow,
|
|
color: colorScheme.onPrimary,
|
|
size: 16,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
: 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),
|
|
),
|
|
ClickableArtistName(
|
|
artistName: item.artistName,
|
|
coverUrl: item.coverUrl,
|
|
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 _AnimatedLibrarySliverGrid extends StatefulWidget {
|
|
final double maxCrossAxisExtent;
|
|
final double mainAxisSpacing;
|
|
final double crossAxisSpacing;
|
|
final double childAspectRatio;
|
|
final SliverChildDelegate delegate;
|
|
|
|
const _AnimatedLibrarySliverGrid({
|
|
required this.maxCrossAxisExtent,
|
|
required this.mainAxisSpacing,
|
|
required this.crossAxisSpacing,
|
|
required this.childAspectRatio,
|
|
required this.delegate,
|
|
});
|
|
|
|
@override
|
|
State<_AnimatedLibrarySliverGrid> createState() =>
|
|
_AnimatedLibrarySliverGridState();
|
|
}
|
|
|
|
class _AnimatedLibrarySliverGridState extends State<_AnimatedLibrarySliverGrid>
|
|
with SingleTickerProviderStateMixin {
|
|
late final AnimationController _controller;
|
|
late final CurvedAnimation _curve;
|
|
late double _beginExtent;
|
|
late double _endExtent;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_beginExtent = widget.maxCrossAxisExtent;
|
|
_endExtent = widget.maxCrossAxisExtent;
|
|
_controller = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 130),
|
|
)..value = 1;
|
|
_curve = CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic);
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(covariant _AnimatedLibrarySliverGrid oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if ((widget.maxCrossAxisExtent - _endExtent).abs() < 0.1) return;
|
|
_beginExtent = _currentExtent;
|
|
_endExtent = widget.maxCrossAxisExtent;
|
|
_controller.forward(from: 0);
|
|
}
|
|
|
|
double get _currentExtent =>
|
|
_beginExtent + ((_endExtent - _beginExtent) * _curve.value);
|
|
|
|
@override
|
|
void dispose() {
|
|
_curve.dispose();
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AnimatedBuilder(
|
|
animation: _controller,
|
|
builder: (context, child) {
|
|
return SliverGrid(
|
|
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
|
|
maxCrossAxisExtent: _currentExtent,
|
|
mainAxisSpacing: widget.mainAxisSpacing,
|
|
crossAxisSpacing: widget.crossAxisSpacing,
|
|
childAspectRatio: widget.childAspectRatio,
|
|
),
|
|
delegate: widget.delegate,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|