feat(library): add quality label display modes

This commit is contained in:
zarzet
2026-07-27 17:25:39 +07:00
parent e4cd52f29f
commit 454ef53977
13 changed files with 334 additions and 44 deletions
+28 -6
View File
@@ -10,12 +10,14 @@ import 'package:spotiflac_android/services/batch_track_actions.dart';
import 'package:spotiflac_android/models/unified_library_item.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/adaptive_layout.dart';
import 'package:spotiflac_android/utils/audio_quality_badge_policy.dart';
import 'package:spotiflac_android/utils/confirm_and_delete_tracks.dart';
import 'package:spotiflac_android/utils/cover_art_utils.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/image_cache_utils.dart';
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/playback_provider.dart';
import 'package:spotiflac_android/providers/music_player_provider.dart';
import 'package:spotiflac_android/screens/collapsing_header_scroll_mixin.dart';
@@ -61,6 +63,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen>
List<int>? _sortedDiscNumbersCache;
List<DownloadHistoryItem>? _commonQualitySourceCache;
String? _commonQualityCache;
String? _commonQualityModeCache;
List<DownloadHistoryItem>? _embeddedCoverSourceCache;
String? _embeddedCoverPathCache;
bool _embeddedCoverPathResolved = false;
@@ -118,6 +121,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen>
_sortedDiscNumbersCache = null;
_commonQualitySourceCache = null;
_commonQualityCache = null;
_commonQualityModeCache = null;
_embeddedCoverSourceCache = null;
_embeddedCoverPathCache = null;
_embeddedCoverPathResolved = false;
@@ -233,6 +237,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen>
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final qualityLabelMode = ref.watch(
settingsProvider.select((s) => s.libraryQualityLabelMode),
);
final bottomPadding = MediaQuery.paddingOf(context).bottom;
final bottomInset = context.navBarBottomInset;
@@ -269,7 +276,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen>
scrollController: scrollController,
isSelectionMode: isSelectionMode,
onExitSelectionMode: exitSelectionMode,
appBar: _buildAppBar(context, colorScheme, tracks),
appBar: _buildAppBar(context, colorScheme, tracks, qualityLabelMode),
trackList: _buildTrackList(context, colorScheme, tracks),
bottomBar: _buildSelectionBottomBar(
context,
@@ -307,10 +314,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen>
BuildContext context,
ColorScheme colorScheme,
List<DownloadHistoryItem> tracks,
String qualityLabelMode,
) {
final expandedHeight = calculateExpandedHeight(context);
final embeddedCoverPath = _resolveAlbumEmbeddedCoverPath(tracks);
final commonQuality = _getCommonQuality(tracks);
final commonQuality = _getCommonQuality(tracks, qualityLabelMode);
final cacheWidth = coverCacheWidthForViewport(context);
final Widget background = embeddedCoverPath != null
@@ -458,30 +466,44 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen>
);
}
String? _getCommonQuality(List<DownloadHistoryItem> tracks) {
if (identical(tracks, _commonQualitySourceCache)) {
String? _getCommonQuality(List<DownloadHistoryItem> tracks, String mode) {
if (identical(tracks, _commonQualitySourceCache) &&
mode == _commonQualityModeCache) {
return _commonQualityCache;
}
if (tracks.isEmpty) {
_commonQualitySourceCache = tracks;
_commonQualityModeCache = mode;
_commonQualityCache = null;
return null;
}
final firstQuality = tracks.first.quality;
String? label(DownloadHistoryItem track) => buildLibraryAudioQualityLabel(
mode: mode,
format: track.format,
bitrateKbps: track.bitrate,
bitDepth: track.bitDepth,
sampleRate: track.sampleRate,
storedQuality: track.quality,
);
final firstQuality = label(tracks.first);
if (firstQuality == null) {
_commonQualitySourceCache = tracks;
_commonQualityModeCache = mode;
_commonQualityCache = null;
return null;
}
for (final track in tracks) {
if (track.quality != firstQuality) {
if (label(track) != firstQuality) {
_commonQualitySourceCache = tracks;
_commonQualityModeCache = mode;
_commonQualityCache = null;
return null;
}
}
_commonQualitySourceCache = tracks;
_commonQualityModeCache = mode;
_commonQualityCache = firstQuality;
return firstQuality;
}
+34 -31
View File
@@ -8,6 +8,7 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/utils/adaptive_layout.dart';
import 'package:spotiflac_android/utils/audio_quality_badge_policy.dart';
import 'package:spotiflac_android/utils/confirm_and_delete_tracks.dart';
import 'package:spotiflac_android/utils/ffmpeg_reenrich.dart';
import 'package:spotiflac_android/utils/file_access.dart';
@@ -70,6 +71,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen>
late List<int> _sortedDiscNumbersCache;
late bool _hasMultipleDiscsCache;
String? _commonQualityCache;
String? _commonQualityModeCache;
@override
void initState() {
@@ -105,7 +107,8 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen>
_discGroupsCache = _groupTracksByDisc(_sortedTracksCache);
_sortedDiscNumbersCache = _discGroupsCache.keys.toList()..sort();
_hasMultipleDiscsCache = _discGroupsCache.length > 1;
_commonQualityCache = _computeCommonQuality(_sortedTracksCache);
_commonQualityCache = null;
_commonQualityModeCache = null;
}
Map<int, List<LocalLibraryItem>> _groupTracksByDisc(
@@ -167,6 +170,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen>
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final qualityLabelMode = ref.watch(
settingsProvider.select((s) => s.libraryQualityLabelMode),
);
final bottomPadding = MediaQuery.paddingOf(context).bottom;
final bottomInset = context.navBarBottomInset;
final tracks = _sortedTracksCache;
@@ -184,7 +190,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen>
scrollController: scrollController,
isSelectionMode: isSelectionMode,
onExitSelectionMode: exitSelectionMode,
appBar: _buildAppBar(context, colorScheme),
appBar: _buildAppBar(context, colorScheme, qualityLabelMode),
trackList: _buildTrackList(context, colorScheme, tracks),
bottomBar: _buildSelectionBottomBar(
context,
@@ -197,7 +203,11 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen>
);
}
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
Widget _buildAppBar(
BuildContext context,
ColorScheme colorScheme,
String qualityLabelMode,
) {
final expandedHeight = calculateExpandedHeight(context);
final cacheWidth = coverCacheWidthForViewport(context);
@@ -261,7 +271,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen>
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
meta: _buildLocalHeaderMeta(context),
meta: _buildLocalHeaderMeta(context, qualityLabelMode),
actions: AlbumPlayActions(
playLabel: context.l10n.tooltipPlay,
shuffleTooltip: context.l10n.actionShuffle,
@@ -271,14 +281,14 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen>
);
}
Widget _buildLocalHeaderMeta(BuildContext context) {
Widget _buildLocalHeaderMeta(BuildContext context, String qualityLabelMode) {
final tracks = _sortedTracksCache;
final totalSeconds = tracks.fold<int>(
0,
(sum, t) => sum + ((t.duration ?? 0) > 0 ? t.duration! : 0),
);
final totalMinutes = (totalSeconds / 60).round();
final quality = _commonQualityCache;
final quality = _getCommonQuality(qualityLabelMode);
return HeaderMetaRow(
items: [
@@ -306,34 +316,27 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen>
await _openFile(tracks[Random().nextInt(tracks.length)]);
}
String? _computeCommonQuality(List<LocalLibraryItem> tracks) {
String? _getCommonQuality(String mode) {
if (_commonQualityModeCache == mode) return _commonQualityCache;
_commonQualityModeCache = mode;
_commonQualityCache = _computeCommonQuality(_sortedTracksCache, mode);
return _commonQualityCache;
}
String? _computeCommonQuality(List<LocalLibraryItem> tracks, String mode) {
if (tracks.isEmpty) return null;
final first = tracks.first;
String? label(LocalLibraryItem track) => buildLibraryAudioQualityLabel(
mode: mode,
format: track.format,
bitrateKbps: track.bitrate,
bitDepth: track.bitDepth,
sampleRate: track.sampleRate,
);
if (first.bitrate != null && first.bitrate! > 0) {
final fmt = first.format?.toUpperCase() ?? '';
final firstBitrate = first.bitrate;
for (final track in tracks) {
if (track.bitrate != firstBitrate) {
return null;
}
}
return '$fmt ${firstBitrate}kbps'.trim();
}
if (first.bitDepth == null ||
first.bitDepth == 0 ||
first.sampleRate == null) {
return null;
}
final firstQuality =
'${first.bitDepth}/${(first.sampleRate! / 1000).round()}kHz';
final firstQuality = label(tracks.first);
if (firstQuality == null) return null;
for (final track in tracks) {
if (track.bitDepth != first.bitDepth ||
track.sampleRate != first.sampleRate) {
return null;
}
if (label(track) != firstQuality) return null;
}
return firstQuality;
}
+7
View File
@@ -17,6 +17,7 @@ import 'package:spotiflac_android/utils/ffmpeg_reenrich.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/settings.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/models/unified_library_item.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
@@ -270,6 +271,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
String? _filterFormat;
String? _filterMetadata;
String _sortMode = 'latest';
String _libraryQualityLabelMode = AppSettings.libraryQualityLabelBitrate;
double _libraryGridExtent = _libraryGridDefaultExtent;
double? _libraryGridScaleStartExtent;
final Map<String, int> _libraryPageOffsetByFilter = {};
@@ -1237,6 +1239,11 @@ class _QueueTabState extends ConsumerState<QueueTab> {
final historyFilterMode = ref.watch(
settingsProvider.select((s) => s.historyFilterMode),
);
// Keep this mode out of the page-provider request: changing only badge
// text must not re-query the database or reset Library pagination.
_libraryQualityLabelMode = ref.watch(
settingsProvider.select((s) => s.libraryQualityLabelMode),
);
final colorScheme = Theme.of(context).colorScheme;
final topPadding = normalizedHeaderTopPadding(context);
final countsRequest = _QueueLibraryCountsRequest(
+6 -2
View File
@@ -156,7 +156,9 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState {
final unifiedItem = historyItem == null
? null
: UnifiedLibraryItem.fromDownloadHistory(historyItem);
final quality = unifiedItem?.quality ?? track.audioQuality;
final quality =
unifiedItem?.qualityForMode(_libraryQualityLabelMode) ??
track.audioQuality;
final cover = unifiedItem != null
? _buildUnifiedCoverImage(unifiedItem, colorScheme)
: track.coverUrl != null
@@ -246,7 +248,9 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState {
final unifiedItem = historyItem == null
? null
: UnifiedLibraryItem.fromDownloadHistory(historyItem);
final quality = unifiedItem?.quality ?? track.audioQuality;
final quality =
unifiedItem?.qualityForMode(_libraryQualityLabelMode) ??
track.audioQuality;
final cover = unifiedItem != null
? _buildUnifiedCoverImage(unifiedItem, colorScheme, coverSize)
: track.coverUrl != null
+6 -5
View File
@@ -877,6 +877,7 @@ extension _QueueTabItemWidgets on _QueueTabState {
'${_QueueTabState._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 quality = item.qualityForMode(_libraryQualityLabelMode);
final sourceLabel = isDownloaded
? context.l10n.librarySourceDownloaded
: context.l10n.librarySourceLocal;
@@ -1004,13 +1005,12 @@ extension _QueueTabItemWidgets on _QueueTabState {
),
),
),
if (item.quality != null &&
item.quality!.isNotEmpty) ...[
if (quality != null && quality.isNotEmpty) ...[
const SizedBox(width: 8),
_buildLibraryQualityBadge(
context,
colorScheme,
item.quality!,
quality,
listStyle: true,
),
],
@@ -1073,6 +1073,7 @@ extension _QueueTabItemWidgets on _QueueTabState {
final fileExistsListenable = _fileExistsListenable(item.filePath);
final isSelected = _selectedIds.contains(item.id);
final isDownloaded = item.source == LibraryItemSource.downloaded;
final quality = item.qualityForMode(_libraryQualityLabelMode);
return GestureDetector(
onTap: _isSelectionMode
@@ -1134,14 +1135,14 @@ extension _QueueTabItemWidgets on _QueueTabState {
),
),
),
if (item.quality != null && item.quality!.isNotEmpty)
if (quality != null && quality.isNotEmpty)
Positioned(
left: 4,
top: 4,
child: _buildLibraryQualityBadge(
context,
colorScheme,
item.quality!,
quality,
),
),
if (!_isSelectionMode)
@@ -5,6 +5,7 @@ import 'package:file_picker/file_picker.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
@@ -402,6 +403,70 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
);
}
String _getQualityLabelModeLabel(BuildContext context, String mode) {
if (mode == AppSettings.libraryQualityLabelBitDepth) {
return '${context.l10n.audioAnalysisBitDepth} & '
'${context.l10n.audioAnalysisSampleRate}';
}
return context.l10n.trackConvertBitrate;
}
void _showQualityLabelModePicker(BuildContext context, String current) {
final colorScheme = Theme.of(context).colorScheme;
final options = [
(
AppSettings.libraryQualityLabelBitrate,
Icons.speed_rounded,
context.l10n.trackConvertBitrate,
),
(
AppSettings.libraryQualityLabelBitDepth,
Icons.graphic_eq_rounded,
'${context.l10n.audioAnalysisBitDepth} & '
'${context.l10n.audioAnalysisSampleRate}',
),
];
showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 16),
child: Text(
context.l10n.trackAudioQuality,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
for (final (value, icon, label) in options)
_AutoScanOption(
icon: icon,
title: label,
selected: current == value,
colorScheme: colorScheme,
onTap: () {
ref
.read(settingsProvider.notifier)
.setLibraryQualityLabelMode(value);
Navigator.pop(context);
},
),
const SizedBox(height: 16),
],
),
),
);
}
@override
Widget build(BuildContext context) {
final settings = ref.watch(settingsProvider);
@@ -448,6 +513,18 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
context,
settings.defaultLibraryView,
),
),
SettingsItem(
icon: Icons.graphic_eq_rounded,
title: context.l10n.trackAudioQuality,
subtitle: _getQualityLabelModeLabel(
context,
settings.libraryQualityLabelMode,
),
onTap: () => _showQualityLabelModePicker(
context,
settings.libraryQualityLabelMode,
),
showDivider: false,
),
],