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.
This commit is contained in:
zarzet committed 2026-10-08 22:30:04 +07:00
1 parent 61410f29fb
commit e975cae99f
9 files changed
+200 -76

No files matched your search

+4
View File
@@ -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"
+4
View File
@@ -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"
+20 -51
View File
@@ -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<QueueTab> {
});
}
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<String> _getAvailableFormats(List<UnifiedLibraryItem> items) {
final formats = <String>{};
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<UnifiedLibraryItem> 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<QueueTab> {
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
AppChoiceChip(
label: Text(context.l10n.libraryFilterAll),
@@ -926,9 +880,13 @@ class _QueueTabState extends ConsumerState<QueueTab> {
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<QueueTab> {
() => tempMetadata = 'missing-lyrics',
),
),
AppChoiceChip(
label: Text(
context
.l10n
.libraryFilterMetadataHasReplayGain,
),
selected: tempMetadata == 'has-replaygain',
onSelected: (_) => setSheetState(
() => tempMetadata = 'has-replaygain',
),
),
AppChoiceChip(
label: Text(
context
+6 -13
View File
@@ -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),
+2 -5
View File
@@ -578,14 +578,11 @@ extension _QueueTabItemWidgets on _QueueTabState {
}
}
Widget _buildFilterButton(
BuildContext context,
List<UnifiedLibraryItem> 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'),
+16 -7
View File
@@ -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 = <String>[];
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
+21
View File
@@ -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 = <String, List<String>>{
'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'],
};
+35
View File
@@ -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'));
+92
View File
@@ -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 {