From e975cae99f4ba484fc99552859d00f19ca3f93b7 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:30:04 +0700 Subject: [PATCH] feat(library): expand audio format and ReplayGain filters Keep format choices available independently of loaded pages, match codec and extension aliases, and add a filter for indexed ReplayGain tags. Preserve source, quality, search, paging, and existing missing-ReplayGain behavior. Validation: 13 targeted database and widget tests; Flutter localization generation and targeted analysis. --- lib/l10n/arb/app_en.arb | 4 + lib/l10n/arb/app_id.arb | 4 + lib/screens/queue_tab.dart | 71 +++++------------ lib/screens/queue_tab_filter_widgets.dart | 19 ++--- lib/screens/queue_tab_item_widgets.dart | 7 +- lib/services/library_queue_store.dart | 23 ++++-- lib/utils/library_format_filters.dart | 21 ++++++ test/album_completeness_query_test.dart | 35 +++++++++ test/library_queue_store_test.dart | 92 +++++++++++++++++++++++ 9 files changed, 200 insertions(+), 76 deletions(-) create mode 100644 lib/utils/library_format_filters.dart diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 387c632a..bdeb8530 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -6995,6 +6995,10 @@ "@libraryFilterMetadataMissingReplayGain": { "description": "Filter option for scanned tracks without ReplayGain gain tags" }, + "libraryFilterMetadataHasReplayGain": "Has ReplayGain", + "@libraryFilterMetadataHasReplayGain": { + "description": "Filter option for tracks with indexed ReplayGain track or album gain tags" + }, "libraryFilterIncompleteAlbums": "Incomplete albums", "@libraryFilterIncompleteAlbums": { "description": "Library filter for albums with confirmed missing track positions" diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 62260109..8dc5b123 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -7096,6 +7096,10 @@ "@libraryFilterMetadataMissingReplayGain": { "description": "Filter option for scanned tracks without ReplayGain gain tags" }, + "libraryFilterMetadataHasReplayGain": "Memiliki ReplayGain", + "@libraryFilterMetadataHasReplayGain": { + "description": "Filter option for tracks with indexed ReplayGain track or album gain tags" + }, "trackOptionCopyTrackName": "Salin nama lagu", "@trackOptionCopyTrackName": { "description": "Track menu action that copies the track title" diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 5effb814..1450188e 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -27,6 +27,7 @@ import 'package:spotiflac_android/utils/adaptive_layout.dart'; import 'package:spotiflac_android/utils/audio_quality_badge_policy.dart'; import 'package:spotiflac_android/utils/nav_bar_inset.dart'; import 'package:spotiflac_android/utils/file_access.dart'; +import 'package:spotiflac_android/utils/library_format_filters.dart'; import 'package:spotiflac_android/utils/ordered_range_selection.dart'; import 'package:spotiflac_android/models/download_item.dart'; import 'package:spotiflac_android/models/settings.dart'; @@ -729,56 +730,8 @@ class _QueueTabState extends ConsumerState { }); } - String _fileExtLower(String filePath) { - final dotIndex = filePath.lastIndexOf('.'); - if (dotIndex < 0 || dotIndex == filePath.length - 1) { - return ''; - } - return filePath.substring(dotIndex + 1).toLowerCase(); - } - - String _itemFormatLower(UnifiedLibraryItem item) { - final localFormat = normalizeOptionalString(item.localItem?.format); - if (localFormat != null) { - return localFormat.toLowerCase().replaceAll('-', '_'); - } - final historyFormat = normalizeOptionalString(item.historyItem?.format); - if (historyFormat != null) { - return historyFormat.toLowerCase().replaceAll('-', '_'); - } - return _fileExtLower(item.filePath); - } - - Set _getAvailableFormats(List items) { - final formats = {}; - for (final item in items) { - final ext = _itemFormatLower(item); - if ([ - 'flac', - 'alac', - 'mp3', - 'm4a', - 'aac', - 'eac3', - 'ac3', - 'ac4', - 'opus', - 'ogg', - 'wav', - 'aiff', - ].contains(ext)) { - formats.add(ext); - } - } - return formats; - } - - void _showFilterSheet( - BuildContext context, - List allItems, - ) { + void _showFilterSheet(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - final availableFormats = _getAvailableFormats(allItems); String? tempSource = _filterSource; String? tempQuality = _filterQuality; @@ -918,6 +871,7 @@ class _QueueTabState extends ConsumerState { const SizedBox(height: 8), Wrap( spacing: 8, + runSpacing: 8, children: [ AppChoiceChip( label: Text(context.l10n.libraryFilterAll), @@ -926,9 +880,13 @@ class _QueueTabState extends ConsumerState { setSheetState(() => tempFormat = null), ), for (final format - in availableFormats.toList()..sort()) + in libraryFormatFilterAliases.keys) AppChoiceChip( - label: Text(format.toUpperCase()), + label: Text(switch (format) { + 'm4a' => 'M4A / MP4', + 'wv' => 'WavPack', + _ => format.toUpperCase(), + }), selected: tempFormat == format, onSelected: (_) => setSheetState(() => tempFormat = format), @@ -1080,6 +1038,17 @@ class _QueueTabState extends ConsumerState { () => tempMetadata = 'missing-lyrics', ), ), + AppChoiceChip( + label: Text( + context + .l10n + .libraryFilterMetadataHasReplayGain, + ), + selected: tempMetadata == 'has-replaygain', + onSelected: (_) => setSheetState( + () => tempMetadata = 'has-replaygain', + ), + ), AppChoiceChip( label: Text( context diff --git a/lib/screens/queue_tab_filter_widgets.dart b/lib/screens/queue_tab_filter_widgets.dart index 26293af0..8351f30f 100644 --- a/lib/screens/queue_tab_filter_widgets.dart +++ b/lib/screens/queue_tab_filter_widgets.dart @@ -307,8 +307,7 @@ extension _QueueTabFilterWidgets on _QueueTabState { context, context.l10n.queueTrackCount(totalTrackCount), [ - if (!_isSelectionMode) - _buildFilterButton(context, unifiedItems), + if (!_isSelectionMode) _buildFilterButton(context), if (!_isSelectionMode && filteredUnifiedItems.isNotEmpty) TextButton.icon( onPressed: () => _showCreatePlaylistDialog(context), @@ -329,7 +328,7 @@ extension _QueueTabFilterWidgets on _QueueTabState { child: _countHeaderRow( context, context.l10n.queueAlbumCount(totalAlbumCount), - [_buildFilterButton(context, unifiedItems)], + [_buildFilterButton(context)], ), ), @@ -344,10 +343,7 @@ extension _QueueTabFilterWidgets on _QueueTabState { child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), child: Row( - children: [ - const Spacer(), - _buildFilterButton(context, unifiedItems), - ], + children: [const Spacer(), _buildFilterButton(context)], ), ), ), @@ -362,8 +358,7 @@ extension _QueueTabFilterWidgets on _QueueTabState { child: Row( children: [ const Spacer(), - if (!_isSelectionMode) - _buildFilterButton(context, unifiedItems), + if (!_isSelectionMode) _buildFilterButton(context), ], ), ), @@ -379,8 +374,7 @@ extension _QueueTabFilterWidgets on _QueueTabState { child: Row( children: [ const Spacer(), - if (!_isSelectionMode) - _buildFilterButton(context, unifiedItems), + if (!_isSelectionMode) _buildFilterButton(context), ], ), ), @@ -621,8 +615,7 @@ extension _QueueTabFilterWidgets on _QueueTabState { context, context.l10n.queueTrackCount(totalTrackCount), [ - if (!_isSelectionMode) - _buildFilterButton(context, unifiedItems), + if (!_isSelectionMode) _buildFilterButton(context), if (!_isSelectionMode && filteredUnifiedItems.isNotEmpty) TextButton.icon( onPressed: () => _showCreatePlaylistDialog(context), diff --git a/lib/screens/queue_tab_item_widgets.dart b/lib/screens/queue_tab_item_widgets.dart index b35bea4c..cd8190ef 100644 --- a/lib/screens/queue_tab_item_widgets.dart +++ b/lib/screens/queue_tab_item_widgets.dart @@ -578,14 +578,11 @@ extension _QueueTabItemWidgets on _QueueTabState { } } - Widget _buildFilterButton( - BuildContext context, - List unifiedItems, - ) { + Widget _buildFilterButton(BuildContext context) { return GestureDetector( onLongPress: _activeFilterCount > 0 ? _resetFilters : null, child: TextButton.icon( - onPressed: () => _showFilterSheet(context, unifiedItems), + onPressed: () => _showFilterSheet(context), icon: Badge( isLabelVisible: _activeFilterCount > 0, label: Text('$_activeFilterCount'), diff --git a/lib/services/library_queue_store.dart b/lib/services/library_queue_store.dart index 684a569b..3412d68b 100644 --- a/lib/services/library_queue_store.dart +++ b/lib/services/library_queue_store.dart @@ -3,6 +3,7 @@ import 'package:spotiflac_android/services/album_completeness.dart'; import 'package:spotiflac_android/services/library_database_models.dart'; import 'package:spotiflac_android/services/library_schema.dart'; import 'package:spotiflac_android/services/sqlite_helpers.dart' as sqlite; +import 'package:spotiflac_android/utils/library_format_filters.dart'; // SQL builders for the queue tab's history+local union queries. @@ -867,15 +868,20 @@ class LibraryQueueStore { final format = request.format?.trim().toLowerCase(); if (format != null && format.isNotEmpty) { - if (formatExpr == null) { - where.add('LOWER($filePathExpr) LIKE ?'); - args.add('%.$format'); - } else { - where.add( - '(LOWER(COALESCE($formatExpr, \'\')) = ? OR LOWER($filePathExpr) LIKE ?)', + final aliases = libraryFormatFilterAliases[format] ?? [format]; + final matches = []; + if (formatExpr != null) { + matches.add( + 'LOWER(REPLACE(TRIM(COALESCE($formatExpr, \'\')), \'-\', \'_\')) ' + 'IN (${List.filled(aliases.length, '?').join(', ')})', ); - args.addAll([format, '%.$format']); + args.addAll(aliases); } + for (final alias in aliases) { + matches.add("LOWER($filePathExpr) LIKE ? ESCAPE '\\'"); + args.add('%.${_escapeLikePattern(alias)}'); + } + where.add('(${matches.join(' OR ')})'); } final metadata = request.metadata?.trim(); @@ -939,6 +945,9 @@ class LibraryQueueStore { ), ); break; + case 'has-replaygain': + where.add('COALESCE($hasReplayGainExpr, 0) != 0'); + break; case 'missing-replaygain': // Include legacy rows without indexed ReplayGain as candidates until // a metadata refresh confirms their tags. Requiring a new scan version diff --git a/lib/utils/library_format_filters.dart b/lib/utils/library_format_filters.dart new file mode 100644 index 00000000..7a73ae7b --- /dev/null +++ b/lib/utils/library_format_filters.dart @@ -0,0 +1,21 @@ +/// Stable filter choices, independent of the currently loaded library page. +/// Aliases match both indexed formats/codecs and filename extensions. +const libraryFormatFilterAliases = >{ + 'flac': ['flac'], + 'mp3': ['mp3'], + 'm4a': ['m4a', 'mp4'], + 'aac': ['aac', 'mp4a'], + 'alac': ['alac'], + 'opus': ['opus'], + 'ogg': ['ogg', 'vorbis'], + 'wav': ['wav', 'wave'], + 'aiff': ['aiff', 'aif', 'aifc'], + 'ape': ['ape'], + 'wv': ['wv', 'wavpack'], + 'dsf': ['dsf'], + 'dff': ['dff'], + 'mpc': ['mpc', 'musepack'], + 'eac3': ['eac3', 'ec_3'], + 'ac3': ['ac3', 'ac_3'], + 'ac4': ['ac4', 'ac_4'], +}; diff --git a/test/album_completeness_query_test.dart b/test/album_completeness_query_test.dart index 3687208c..f3ceb646 100644 --- a/test/album_completeness_query_test.dart +++ b/test/album_completeness_query_test.dart @@ -322,6 +322,41 @@ void main() { await _waitFor(tester, find.text('Song 1').hitTestable()); await tester.tap(find.text('Filters').hitTestable()); await tester.pump(const Duration(milliseconds: 400)); + // Every fixture file is FLAC. Other formats must still be selectable, + // rather than disappearing when they are absent from the loaded page. + for (final label in [ + 'M4A / MP4', + 'AAC', + 'ALAC', + 'OPUS', + 'OGG', + 'WAV', + 'AIFF', + 'WavPack', + 'DSF', + 'DFF', + ]) { + expect(find.text(label), findsOneWidget); + } + expect(find.text('Has ReplayGain'), findsOneWidget); + await tester.ensureVisible(find.text('M4A / MP4')); + await _waitFor(tester, find.text('M4A / MP4').hitTestable()); + await tester.tap(find.text('M4A / MP4')); + await tester.ensureVisible(find.text('Has ReplayGain')); + await _waitFor(tester, find.text('Has ReplayGain').hitTestable()); + await tester.tap(find.text('Has ReplayGain')); + await tester.ensureVisible(find.text('Apply')); + await _waitFor(tester, find.text('Apply').hitTestable()); + await tester.tap(find.text('Apply')); + await tester.pump(const Duration(milliseconds: 400)); + await _waitFor(tester, find.text('No download history')); + expect(find.text('Song 1'), findsNothing); + await tester.tap(find.text('Filters').hitTestable()); + await tester.pump(const Duration(milliseconds: 400)); + expect(find.text('M4A / MP4'), findsOneWidget); + expect(find.text('Has ReplayGain'), findsOneWidget); + await _waitFor(tester, find.text('Reset').hitTestable()); + await tester.tap(find.text('Reset')); await tester.ensureVisible(find.text('Incomplete albums')); await _waitFor(tester, find.text('Incomplete albums').hitTestable()); await tester.tap(find.text('Incomplete albums')); diff --git a/test/library_queue_store_test.dart b/test/library_queue_store_test.dart index 5b1812b6..edcf5c65 100644 --- a/test/library_queue_store_test.dart +++ b/test/library_queue_store_test.dart @@ -414,6 +414,98 @@ void main() { } }); + test( + 'format aliases find indexed and filename-only tracks in both sources', + () async { + for (final sample in [ + (filter: 'm4a', format: 'MP4', extension: 'MP4'), + (filter: 'aac', format: 'mp4a', extension: 'AAC'), + (filter: 'alac', format: 'alac', extension: 'ALAC'), + (filter: 'opus', format: 'opus', extension: 'OPUS'), + (filter: 'ogg', format: 'vorbis', extension: 'OGG'), + (filter: 'wav', format: 'wave', extension: 'WAVE'), + (filter: 'aiff', format: 'aifc', extension: 'AIF'), + (filter: 'ape', format: 'ape', extension: 'APE'), + (filter: 'wv', format: 'wavpack', extension: 'WV'), + (filter: 'dsf', format: 'dsf', extension: 'DSF'), + (filter: 'dff', format: 'dff', extension: 'DFF'), + (filter: 'mpc', format: 'musepack', extension: 'MPC'), + (filter: 'eac3', format: 'ec-3', extension: 'EAC3'), + (filter: 'ac3', format: 'ac_3', extension: 'AC3'), + (filter: 'ac4', format: 'ac-4', extension: 'AC4'), + ]) { + // SAF may expose an opaque URI, so the indexed format must also work. + for (final table in ['history_db.history', 'library']) { + await db.update(table, { + 'format': ' ${sample.format} ', + 'file_path': 'content://example/document/123', + }, where: table == 'library' ? "id = 'l1'" : "id = 'h1'"); + await db.update(table, { + 'format': null, + 'file_path': '/track.${sample.extension}', + }, where: table == 'library' ? "id = 'l2'" : "id = 'h2'"); + } + final request = QueueLibraryDbQuery(format: sample.filter); + expect(_tracks(await store.trackPage(request)).toSet(), { + 'downloaded:h1', + 'downloaded:h2', + 'local:l1', + 'local:l2', + }, reason: sample.filter); + expect((await store.counts(request)).allTrackCount, 4); + expect((await store.albumPage(request)).rows, hasLength(2)); + final tagged = QueueLibraryDbQuery( + format: sample.filter, + metadata: 'has-replaygain', + source: 'local', + quality: 'cd', + searchQuery: 'Artist', + ); + expect(_tracks(await store.trackPage(tagged)), ['local:l1']); + expect((await store.counts(tagged)).allTrackCount, 1); + } + }, + ); + + test( + 'ReplayGain presence follows tag updates, paging and source deduplication', + () async { + const request = QueueLibraryDbQuery(metadata: 'has-replaygain', limit: 1); + final first = await store.trackPage(request); + final second = await store.trackPage( + QueueLibraryDbQuery( + metadata: 'has-replaygain', + limit: 1, + cursor: first.nextCursor, + ), + ); + expect( + {..._tracks(first), ..._tracks(second)}, + {'downloaded:h1', 'local:l1'}, + ); + expect((await store.counts(request)).allTrackCount, 2); + expect((await store.albumPage(request)).rows.single['track_count'], 1); + + await db.update('history_db.history', { + 'has_replaygain': 0, + }, where: "id = 'h1'"); + await db.update('library', {'has_replaygain': 1}, where: "id = 'l2'"); + final updated = await store.trackPage( + const QueueLibraryDbQuery(metadata: 'has-replaygain'), + ); + expect(_tracks(updated).toSet(), {'local:l1', 'local:l2'}); + expect((await store.counts(request)).allTrackCount, 2); + final missing = await store.trackPage( + const QueueLibraryDbQuery(metadata: 'missing-replaygain'), + ); + expect(_tracks(missing).toSet(), { + 'downloaded:h1', + 'downloaded:h2', + 'downloaded:single', + }); + }, + ); + test( 'independent FTS inputs match LIKE fallback and escape literal search', () async {