mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-02 09:08:35 +02:00
feat(library): add quality label display modes
This commit is contained in:
@@ -6,6 +6,8 @@ part 'settings.g.dart';
|
||||
@JsonSerializable()
|
||||
class AppSettings {
|
||||
static const String homeFeedProviderOff = '__off__';
|
||||
static const String libraryQualityLabelBitrate = 'bitrate';
|
||||
static const String libraryQualityLabelBitDepth = 'bit_depth';
|
||||
|
||||
final String defaultService;
|
||||
final String audioQuality;
|
||||
@@ -38,6 +40,9 @@ class AppSettings {
|
||||
/// Library view opened when switching to the Library tab:
|
||||
/// 'last' (keep last used), 'all', 'albums', 'singles', or 'playlists'.
|
||||
final String defaultLibraryView;
|
||||
|
||||
/// Library badge text: measured bitrate or the legacy bit depth/sample rate.
|
||||
final String libraryQualityLabelMode;
|
||||
final bool askQualityBeforeDownload;
|
||||
final bool enableLogging;
|
||||
final bool useExtensionProviders;
|
||||
@@ -137,6 +142,7 @@ class AppSettings {
|
||||
this.historyViewMode = 'grid',
|
||||
this.historyFilterMode = 'all',
|
||||
this.defaultLibraryView = 'last',
|
||||
this.libraryQualityLabelMode = libraryQualityLabelBitrate,
|
||||
this.askQualityBeforeDownload = true,
|
||||
this.enableLogging = false,
|
||||
this.useExtensionProviders = true,
|
||||
@@ -208,6 +214,7 @@ class AppSettings {
|
||||
String? historyViewMode,
|
||||
String? historyFilterMode,
|
||||
String? defaultLibraryView,
|
||||
String? libraryQualityLabelMode,
|
||||
bool? askQualityBeforeDownload,
|
||||
bool? enableLogging,
|
||||
bool? useExtensionProviders,
|
||||
@@ -286,6 +293,8 @@ class AppSettings {
|
||||
historyViewMode: historyViewMode ?? this.historyViewMode,
|
||||
historyFilterMode: historyFilterMode ?? this.historyFilterMode,
|
||||
defaultLibraryView: defaultLibraryView ?? this.defaultLibraryView,
|
||||
libraryQualityLabelMode:
|
||||
libraryQualityLabelMode ?? this.libraryQualityLabelMode,
|
||||
askQualityBeforeDownload:
|
||||
askQualityBeforeDownload ?? this.askQualityBeforeDownload,
|
||||
enableLogging: enableLogging ?? this.enableLogging,
|
||||
|
||||
@@ -34,6 +34,9 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
historyViewMode: json['historyViewMode'] as String? ?? 'grid',
|
||||
historyFilterMode: json['historyFilterMode'] as String? ?? 'all',
|
||||
defaultLibraryView: json['defaultLibraryView'] as String? ?? 'last',
|
||||
libraryQualityLabelMode:
|
||||
json['libraryQualityLabelMode'] as String? ??
|
||||
AppSettings.libraryQualityLabelBitrate,
|
||||
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
|
||||
enableLogging: json['enableLogging'] as bool? ?? false,
|
||||
useExtensionProviders: json['useExtensionProviders'] as bool? ?? true,
|
||||
@@ -124,6 +127,7 @@ Map<String, dynamic> _$AppSettingsToJson(
|
||||
'historyViewMode': instance.historyViewMode,
|
||||
'historyFilterMode': instance.historyFilterMode,
|
||||
'defaultLibraryView': instance.defaultLibraryView,
|
||||
'libraryQualityLabelMode': instance.libraryQualityLabelMode,
|
||||
'askQualityBeforeDownload': instance.askQualityBeforeDownload,
|
||||
'enableLogging': instance.enableLogging,
|
||||
'useExtensionProviders': instance.useExtensionProviders,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/providers/download_history_provider.dart';
|
||||
import 'package:spotiflac_android/services/library_database.dart';
|
||||
import 'package:spotiflac_android/utils/audio_quality_badge_policy.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
|
||||
enum LibraryItemSource { downloaded, local }
|
||||
@@ -103,6 +104,34 @@ class UnifiedLibraryItem {
|
||||
coverUrl != null ||
|
||||
(localCoverPath != null && localCoverPath!.isNotEmpty);
|
||||
|
||||
String? qualityForMode(String mode) {
|
||||
final history = historyItem;
|
||||
if (history != null) {
|
||||
return buildLibraryAudioQualityLabel(
|
||||
mode: mode,
|
||||
format: history.format,
|
||||
bitrateKbps: history.bitrate,
|
||||
bitDepth: history.bitDepth,
|
||||
sampleRate: history.sampleRate,
|
||||
storedQuality: history.quality ?? quality,
|
||||
);
|
||||
}
|
||||
|
||||
final local = localItem;
|
||||
if (local != null) {
|
||||
return buildLibraryAudioQualityLabel(
|
||||
mode: mode,
|
||||
format: local.format,
|
||||
bitrateKbps: local.bitrate,
|
||||
bitDepth: local.bitDepth,
|
||||
sampleRate: local.sampleRate,
|
||||
storedQuality: quality,
|
||||
);
|
||||
}
|
||||
|
||||
return quality;
|
||||
}
|
||||
|
||||
String? get albumArtist => historyItem?.albumArtist ?? localItem?.albumArtist;
|
||||
|
||||
String? get releaseDate => historyItem?.releaseDate ?? localItem?.releaseDate;
|
||||
|
||||
@@ -159,6 +159,9 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
defaultLibraryView: _normalizeDefaultLibraryView(
|
||||
loaded.defaultLibraryView,
|
||||
),
|
||||
libraryQualityLabelMode: _normalizeLibraryQualityLabelMode(
|
||||
loaded.libraryQualityLabelMode,
|
||||
),
|
||||
defaultService: loaded.defaultService,
|
||||
searchProvider: loaded.searchProvider,
|
||||
extensionVerificationBrowserMode:
|
||||
@@ -369,6 +372,12 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
return 'last';
|
||||
}
|
||||
|
||||
String _normalizeLibraryQualityLabelMode(String value) {
|
||||
return value == AppSettings.libraryQualityLabelBitDepth
|
||||
? AppSettings.libraryQualityLabelBitDepth
|
||||
: AppSettings.libraryQualityLabelBitrate;
|
||||
}
|
||||
|
||||
String _normalizeExtensionVerificationBrowserMode(String value) {
|
||||
final normalized = value.trim().toLowerCase();
|
||||
if (_extensionVerificationBrowserModeValues.contains(normalized)) {
|
||||
@@ -630,6 +639,13 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setLibraryQualityLabelMode(String mode) {
|
||||
state = state.copyWith(
|
||||
libraryQualityLabelMode: _normalizeLibraryQualityLabelMode(mode),
|
||||
);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setHomeFeedProvider(String? provider) {
|
||||
if (provider == null || provider.isEmpty) {
|
||||
state = state.copyWith(clearHomeFeedProvider: true);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,5 +1,44 @@
|
||||
import 'package:spotiflac_android/models/settings.dart';
|
||||
import 'package:spotiflac_android/utils/audio_format_utils.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
|
||||
const highQualityBadgeBitrateThresholdKbps = 900;
|
||||
|
||||
String normalizeLibraryQualityLabelMode(String? mode) {
|
||||
return mode == AppSettings.libraryQualityLabelBitDepth
|
||||
? AppSettings.libraryQualityLabelBitDepth
|
||||
: AppSettings.libraryQualityLabelBitrate;
|
||||
}
|
||||
|
||||
/// Builds a Library label from metadata already held in memory. Lossy formats
|
||||
/// keep their bitrate label because bit depth is not a useful quality signal
|
||||
/// for encoded MP3/AAC/Opus audio.
|
||||
String? buildLibraryAudioQualityLabel({
|
||||
required String mode,
|
||||
String? format,
|
||||
int? bitrateKbps,
|
||||
int? bitDepth,
|
||||
int? sampleRate,
|
||||
String? storedQuality,
|
||||
}) {
|
||||
final bitrateLabel = bitrateKbps != null && bitrateKbps > 0
|
||||
? buildDisplayAudioQuality(bitrateKbps: bitrateKbps, format: format)
|
||||
: null;
|
||||
final bitDepthLabel =
|
||||
bitDepth != null && bitDepth > 0 && sampleRate != null && sampleRate > 0
|
||||
? buildDisplayAudioQuality(bitDepth: bitDepth, sampleRate: sampleRate)
|
||||
: null;
|
||||
final fallback = normalizeOptionalString(storedQuality);
|
||||
|
||||
final useBitDepth =
|
||||
normalizeLibraryQualityLabelMode(mode) ==
|
||||
AppSettings.libraryQualityLabelBitDepth &&
|
||||
!isLossyAudioFormat(format);
|
||||
return useBitDepth
|
||||
? bitDepthLabel ?? bitrateLabel ?? fallback
|
||||
: bitrateLabel ?? bitDepthLabel ?? fallback;
|
||||
}
|
||||
|
||||
/// Preserves the highlighted color used by legacy 24-bit Library badges while
|
||||
/// also supporting newer labels that display a measured bitrate instead.
|
||||
bool shouldHighlightAudioQualityBadge(String quality) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/models/settings.dart';
|
||||
import 'package:spotiflac_android/utils/audio_quality_badge_policy.dart';
|
||||
|
||||
void main() {
|
||||
@@ -22,4 +23,68 @@ void main() {
|
||||
expect(shouldHighlightAudioQualityBadge('16-bit/44.1kHz'), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('Library audio quality label mode', () {
|
||||
test('uses measured bitrate by default', () {
|
||||
expect(
|
||||
buildLibraryAudioQualityLabel(
|
||||
mode: AppSettings.libraryQualityLabelBitrate,
|
||||
format: 'flac',
|
||||
bitrateKbps: 1760,
|
||||
bitDepth: 24,
|
||||
sampleRate: 48000,
|
||||
),
|
||||
'FLAC 1760kbps',
|
||||
);
|
||||
expect(
|
||||
normalizeLibraryQualityLabelMode('unsupported'),
|
||||
AppSettings.libraryQualityLabelBitrate,
|
||||
);
|
||||
});
|
||||
|
||||
test('restores legacy bit depth and sample rate labels', () {
|
||||
expect(
|
||||
buildLibraryAudioQualityLabel(
|
||||
mode: AppSettings.libraryQualityLabelBitDepth,
|
||||
format: 'flac',
|
||||
bitrateKbps: 1760,
|
||||
bitDepth: 24,
|
||||
sampleRate: 48000,
|
||||
),
|
||||
'24-bit/48kHz',
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps bitrate meaningful for lossy formats', () {
|
||||
expect(
|
||||
buildLibraryAudioQualityLabel(
|
||||
mode: AppSettings.libraryQualityLabelBitDepth,
|
||||
format: 'mp3',
|
||||
bitrateKbps: 320,
|
||||
bitDepth: 16,
|
||||
sampleRate: 44100,
|
||||
),
|
||||
'MP3 320kbps',
|
||||
);
|
||||
});
|
||||
|
||||
test('falls back when the preferred metadata is unavailable', () {
|
||||
expect(
|
||||
buildLibraryAudioQualityLabel(
|
||||
mode: AppSettings.libraryQualityLabelBitDepth,
|
||||
format: 'flac',
|
||||
bitrateKbps: 950,
|
||||
storedQuality: 'LOSSLESS',
|
||||
),
|
||||
'FLAC 950kbps',
|
||||
);
|
||||
expect(
|
||||
buildLibraryAudioQualityLabel(
|
||||
mode: AppSettings.libraryQualityLabelBitrate,
|
||||
storedQuality: '24-bit/96kHz',
|
||||
),
|
||||
'24-bit/96kHz',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -510,6 +510,10 @@ void main() {
|
||||
expect(settings.deduplicateDownloads, isTrue);
|
||||
expect(settings.allowQualityVariants, isFalse);
|
||||
expect(settings.nativeDownloadWorkerEnabled, isFalse);
|
||||
expect(
|
||||
settings.libraryQualityLabelMode,
|
||||
AppSettings.libraryQualityLabelBitrate,
|
||||
);
|
||||
});
|
||||
|
||||
test('copyWith updates values and can clear nullable provider fields', () {
|
||||
@@ -526,6 +530,7 @@ void main() {
|
||||
lyricsAppleElrcWordSync: true,
|
||||
deduplicateDownloads: false,
|
||||
allowQualityVariants: true,
|
||||
libraryQualityLabelMode: AppSettings.libraryQualityLabelBitDepth,
|
||||
clearDownloadFallbackExtensionIds: true,
|
||||
clearSearchProvider: true,
|
||||
clearHomeFeedProvider: true,
|
||||
@@ -537,6 +542,10 @@ void main() {
|
||||
expect(updated.lyricsAppleElrcWordSync, isTrue);
|
||||
expect(updated.deduplicateDownloads, isFalse);
|
||||
expect(updated.allowQualityVariants, isTrue);
|
||||
expect(
|
||||
updated.libraryQualityLabelMode,
|
||||
AppSettings.libraryQualityLabelBitDepth,
|
||||
);
|
||||
expect(updated.downloadFallbackExtensionIds, isNull);
|
||||
expect(updated.searchProvider, isNull);
|
||||
expect(updated.homeFeedProvider, isNull);
|
||||
@@ -563,6 +572,7 @@ void main() {
|
||||
deduplicateDownloads: false,
|
||||
allowQualityVariants: true,
|
||||
nativeDownloadWorkerEnabled: true,
|
||||
libraryQualityLabelMode: AppSettings.libraryQualityLabelBitDepth,
|
||||
);
|
||||
|
||||
final decoded = AppSettings.fromJson(settings.toJson());
|
||||
@@ -582,6 +592,10 @@ void main() {
|
||||
expect(decoded.musixmatchLanguage, 'id');
|
||||
expect(decoded.lyricsAppleElrcWordSync, isTrue);
|
||||
expect(decoded.lastSeenVersion, '4.5.0');
|
||||
expect(
|
||||
decoded.libraryQualityLabelMode,
|
||||
AppSettings.libraryQualityLabelBitDepth,
|
||||
);
|
||||
expect(decoded.deduplicateDownloads, isFalse);
|
||||
expect(decoded.allowQualityVariants, isTrue);
|
||||
expect(decoded.nativeDownloadWorkerEnabled, isTrue);
|
||||
|
||||
Reference in New Issue
Block a user