mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-19 14:07:26 +02:00
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:
1 parent
61410f29fb
commit
e975cae99f
9 files changed
+200
-76
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'],
|
||||
};
|
||||
@@ -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'));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user