From 19591bdf19240ee4e82a55224625a536923a4466 Mon Sep 17 00:00:00 2001 From: zarzet Date: Sat, 22 Aug 2026 23:08:40 +0700 Subject: [PATCH] feat(metadata): show explicit badges in track titles --- .../zarz/spotiflac/NativeDownloadFinalizer.kt | 13 ++- lib/providers/download_history_models.dart | 6 ++ lib/providers/download_history_provider.dart | 8 +- .../download_queue_provider_finalization.dart | 3 + lib/providers/music_player_provider.dart | 2 + lib/providers/playback_provider.dart | 3 + lib/screens/now_playing_screen.dart | 14 ++- lib/screens/track_metadata_actions.dart | 1 + lib/screens/track_metadata_cards.dart | 6 +- lib/screens/track_metadata_lyrics.dart | 24 +++++- lib/screens/track_metadata_screen.dart | 17 +++- lib/screens/track_metadata_screen_cover.dart | 3 +- lib/services/history_database.dart | 14 ++- lib/services/library_database.dart | 22 ++++- lib/services/library_database_models.dart | 6 ++ lib/services/music_player_service.dart | 8 ++ lib/widgets/audio_quality_badges.dart | 86 +++++++++++++++---- lib/widgets/mini_player.dart | 11 ++- test/download_history_logic_test.dart | 10 +++ test/models_and_utils_test.dart | 3 + test/music_player_media_metadata_test.dart | 3 + test/track_metadata_header_contrast_test.dart | 3 + 22 files changed, 232 insertions(+), 34 deletions(-) diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt index 6125d6c5..f2f1469e 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt @@ -39,7 +39,7 @@ object NativeDownloadFinalizer { const val NATIVE_WORKER_CONTRACT_VERSION = 1 // Native finalizer owns background-safe history writes while Flutter may be suspended. // Keep this schema contract in sync with Dart HistoryDatabase before bumping either side. - const val HISTORY_SCHEMA_VERSION = 11 + const val HISTORY_SCHEMA_VERSION = 12 internal val activeFFmpegSessionIds = mutableSetOf() internal val nativeFFmpegSessionIds = mutableSetOf() internal val activeFFmpegSessionLock = Any() @@ -89,6 +89,7 @@ object NativeDownloadFinalizer { "composer", "label", "copyright", + "explicit", "spotify_id_norm", "isrc_norm", "match_key", @@ -1304,6 +1305,14 @@ object NativeDownloadFinalizer { values.put("composer", normalizeOptional(resultString(input, "composer").ifBlank { trackString(input, "composer", requestString(input, "composer")) })) values.put("label", normalizeOptional(result.optString("label", "").ifBlank { input.request.optString("label", "") })) values.put("copyright", normalizeOptional(result.optString("copyright", "").ifBlank { input.request.optString("copyright", "") })) + values.put( + "explicit", + if ( + result.optBoolean("explicit", false) || + input.track.optBoolean("explicit", false) || + input.request.optBoolean("explicit", false) + ) 1 else 0, + ) putNormalizedHistoryColumns(values) return values } @@ -1366,6 +1375,7 @@ object NativeDownloadFinalizer { composer TEXT, label TEXT, copyright TEXT, + explicit INTEGER NOT NULL DEFAULT 0, spotify_id_norm TEXT, isrc_norm TEXT, match_key TEXT, @@ -1403,6 +1413,7 @@ object NativeDownloadFinalizer { ensureHistoryColumn(db, "sort_genre", "ALTER TABLE history ADD COLUMN sort_genre TEXT") ensureHistoryColumn(db, "sort_release", "ALTER TABLE history ADD COLUMN sort_release TEXT") ensureHistoryColumn(db, "sort_added", "ALTER TABLE history ADD COLUMN sort_added INTEGER") + ensureHistoryColumn(db, "explicit", "ALTER TABLE history ADD COLUMN explicit INTEGER NOT NULL DEFAULT 0") ensureHistoryPathKeyTable(db) if (needsBackfill) { backfillNormalizedHistoryColumns(db) diff --git a/lib/providers/download_history_models.dart b/lib/providers/download_history_models.dart index aaa9a7e3..027436e3 100644 --- a/lib/providers/download_history_models.dart +++ b/lib/providers/download_history_models.dart @@ -32,6 +32,7 @@ class DownloadHistoryItem { final String? composer; final String? label; final String? copyright; + final bool explicit; const DownloadHistoryItem({ required this.id, @@ -65,6 +66,7 @@ class DownloadHistoryItem { this.composer, this.label, this.copyright, + this.explicit = false, }); Map toJson() => { @@ -99,6 +101,7 @@ class DownloadHistoryItem { 'composer': composer, 'label': label, 'copyright': copyright, + 'explicit': explicit, }; factory DownloadHistoryItem.fromJson(Map json) => @@ -134,6 +137,7 @@ class DownloadHistoryItem { composer: json['composer'] as String?, label: json['label'] as String?, copyright: json['copyright'] as String?, + explicit: parseExplicitFlag(json['explicit']) == true, ); DownloadHistoryItem copyWith({ @@ -165,6 +169,7 @@ class DownloadHistoryItem { String? composer, String? label, String? copyright, + bool? explicit, }) { return DownloadHistoryItem( id: id, @@ -198,6 +203,7 @@ class DownloadHistoryItem { composer: composer ?? this.composer, label: label ?? this.label, copyright: copyright ?? this.copyright, + explicit: explicit ?? this.explicit, ); } } diff --git a/lib/providers/download_history_provider.dart b/lib/providers/download_history_provider.dart index f20966da..287e4724 100644 --- a/lib/providers/download_history_provider.dart +++ b/lib/providers/download_history_provider.dart @@ -201,6 +201,7 @@ class DownloadHistoryNotifier extends Notifier { copyright: normalizeOptionalString(item.copyright) ?? normalizeOptionalString(existing.copyright), + explicit: item.explicit || existing.explicit, ); return (item: mergedItem, existingId: existing?.id); } @@ -477,6 +478,7 @@ class DownloadHistoryNotifier extends Notifier { int? totalDiscs, int? duration, String? composer, + bool? explicit, }) async { final target = await _historyItemForUpdate(id); if (target == null) { @@ -499,6 +501,7 @@ class DownloadHistoryNotifier extends Notifier { totalDiscs: totalDiscs, duration: duration, composer: composer, + explicit: explicit, ); if (updated.quality == current.quality && @@ -511,7 +514,8 @@ class DownloadHistoryNotifier extends Notifier { updated.discNumber == current.discNumber && updated.totalDiscs == current.totalDiscs && updated.duration == current.duration && - updated.composer == current.composer) { + updated.composer == current.composer && + updated.explicit == current.explicit) { return; } @@ -545,6 +549,7 @@ class DownloadHistoryNotifier extends Notifier { String? composer, String? label, String? copyright, + bool? explicit, }) async { final target = await _historyItemForUpdate(id); if (target == null) { @@ -568,6 +573,7 @@ class DownloadHistoryNotifier extends Notifier { composer: composer, label: label, copyright: copyright, + explicit: explicit, ); final updatedItems = target.index >= 0 diff --git a/lib/providers/download_queue_provider_finalization.dart b/lib/providers/download_queue_provider_finalization.dart index 7c665069..045e5222 100644 --- a/lib/providers/download_queue_provider_finalization.dart +++ b/lib/providers/download_queue_provider_finalization.dart @@ -340,6 +340,9 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier { composer: historyComposer, label: label, copyright: copyright, + explicit: + trackToDownload.isExplicit || + parseExplicitFlag(result['explicit']) == true, ); } diff --git a/lib/providers/music_player_provider.dart b/lib/providers/music_player_provider.dart index ac803ff2..14013b86 100644 --- a/lib/providers/music_player_provider.dart +++ b/lib/providers/music_player_provider.dart @@ -168,6 +168,7 @@ PlayableMedia playableFromHistory(DownloadHistoryItem item) { sampleRate: item.sampleRate, bitrate: item.bitrate, format: item.format, + explicit: item.explicit, ); } @@ -193,5 +194,6 @@ PlayableMedia playableFromLocal(LocalLibraryItem item) { sampleRate: item.sampleRate, bitrate: item.bitrate, format: item.format, + explicit: item.explicit, ); } diff --git a/lib/providers/playback_provider.dart b/lib/providers/playback_provider.dart index 2447cfc3..e28f0c26 100644 --- a/lib/providers/playback_provider.dart +++ b/lib/providers/playback_provider.dart @@ -44,6 +44,7 @@ class PlaybackController extends Notifier { String album = '', String coverUrl = '', Track? track, + bool? explicit, }) async { if (isCueVirtualPath(path)) { throw Exception(cueVirtualTrackRequiresSplitMessage); @@ -64,6 +65,7 @@ class PlaybackController extends Notifier { duration: (track != null && track.duration > 0) ? Duration(seconds: track.duration) : null, + explicit: explicit ?? track?.isExplicit ?? false, ), ); return; @@ -168,6 +170,7 @@ class PlaybackController extends Notifier { duration: track.duration > 0 ? Duration(seconds: track.duration) : null, + explicit: track.isExplicit, ), ); } diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index 6a281343..bd8336f0 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -19,6 +19,7 @@ import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/utils/synced_lyrics_scroll.dart'; import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; +import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; import 'package:spotiflac_android/widgets/player_artwork.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; @@ -327,6 +328,14 @@ class _NowPlayingScreenState extends ConsumerState { return parts.join(' ยท '); } + bool _isExplicit(MediaItem mediaItem) { + final source = mediaItem.extras?['source']?.toString(); + final loadedMetadata = source == _loadedSource ? _metadata : null; + return parseExplicitFlag(loadedMetadata?['explicit']) ?? + parseExplicitFlag(mediaItem.extras?['explicit']) ?? + false; + } + @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; @@ -624,8 +633,9 @@ class _NowPlayingScreenState extends ConsumerState { child: Column( key: ValueKey(mediaItem.id), children: [ - Text( - mediaItem.title, + ExplicitTrackTitle( + title: mediaItem.title, + explicit: _isExplicit(mediaItem), style: Theme.of(context).textTheme.headlineSmall?.copyWith( fontWeight: FontWeight.bold, color: colorScheme.onSurface, diff --git a/lib/screens/track_metadata_actions.dart b/lib/screens/track_metadata_actions.dart index 0cdfcac3..7e7eedcf 100644 --- a/lib/screens/track_metadata_actions.dart +++ b/lib/screens/track_metadata_actions.dart @@ -217,6 +217,7 @@ extension _TrackMetadataFileActions on _TrackMetadataScreenState { artist: artistName, album: albumName, coverUrl: playbackCover, + explicit: isExplicit, ); } catch (e) { if (context.mounted) { diff --git a/lib/screens/track_metadata_cards.dart b/lib/screens/track_metadata_cards.dart index ffbe9113..7ba07e87 100644 --- a/lib/screens/track_metadata_cards.dart +++ b/lib/screens/track_metadata_cards.dart @@ -195,8 +195,9 @@ extension _TrackMetadataCards on _TrackMetadataScreenState { ), ), const SizedBox(height: 20), - Text( - trackName, + ExplicitTrackTitle( + title: trackName, + explicit: isExplicit, style: const TextStyle( color: Colors.white, fontSize: 24, @@ -206,6 +207,7 @@ extension _TrackMetadataCards on _TrackMetadataScreenState { textAlign: TextAlign.center, maxLines: 3, overflow: TextOverflow.ellipsis, + colorScheme: _trackMetadataHeroScheme, ), const SizedBox(height: 6), Text( diff --git a/lib/screens/track_metadata_lyrics.dart b/lib/screens/track_metadata_lyrics.dart index 9a034eee..719d12c5 100644 --- a/lib/screens/track_metadata_lyrics.dart +++ b/lib/screens/track_metadata_lyrics.dart @@ -1196,7 +1196,28 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState { } Future _syncDownloadHistoryMetadata() async { - if (_isLocalItem || _downloadItem == null) return; + if (_isLocalItem) { + final item = _localLibraryItem; + if (item == null) return; + try { + await LibraryDatabase.instance.updateAudioMetadata( + item.id, + explicit: isExplicit, + ); + if (mounted) { + _setState(() { + _currentLocalLibraryItem = item.withAudioMetadata( + explicit: isExplicit, + ); + }); + } + await ref.read(localLibraryProvider.notifier).reloadFromStorage(); + } catch (e) { + _log.w('Failed to sync local library explicit metadata: $e'); + } + return; + } + if (_downloadItem == null) return; String? normalizedOrNull(String? value) { if (value == null) return null; @@ -1224,6 +1245,7 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState { composer: normalizedOrNull(composer), label: normalizedOrNull(label), copyright: normalizedOrNull(copyright), + explicit: isExplicit, ); } catch (e) { _log.w('Failed to sync download history metadata: $e'); diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 73e38bf1..b2de5d3c 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -44,6 +44,7 @@ import 'package:spotiflac_android/theme/cover_palette.dart' show HeaderPalette; import 'package:spotiflac_android/widgets/album_detail_header.dart' show HeaderMetaRow, HeaderMetaItem; import 'package:spotiflac_android/widgets/audio_analysis_widget.dart'; +import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; import 'package:spotiflac_android/widgets/batch_convert_sheet.dart'; import 'package:spotiflac_android/widgets/cached_cover_image.dart'; import 'package:spotiflac_android/widgets/open_on_platform_sheet.dart'; @@ -361,6 +362,8 @@ class _TrackMetadataScreenState extends ConsumerState final resolvedUPC = (metadata['upc'] ?? metadata['barcode'])?.toString(); final resolvedComment = metadata['comment']?.toString(); final resolvedExplicit = parseExplicitFlag(metadata['explicit']); + final needsExplicit = + resolvedExplicit != null && resolvedExplicit != isExplicit; final needsTrackNumber = resolvedTrackNumber != null && resolvedTrackNumber > 0 && @@ -400,7 +403,6 @@ class _TrackMetadataScreenState extends ConsumerState final fileHasAlbumType = present(resolvedAlbumType); final fileHasUPC = present(resolvedUPC); final fileHasComment = present(resolvedComment); - final fileHasExplicit = resolvedExplicit == true; final fileHasTrackNumber = resolvedTrackNumber != null && resolvedTrackNumber > 0; final fileHasTotalTracks = @@ -422,6 +424,7 @@ class _TrackMetadataScreenState extends ConsumerState needsTotalDiscs || needsDuration || needsComposer || + needsExplicit || (isPlaceholderQualityLabel(_quality) && resolvedQuality != null)); final localItem = _localLibraryItem; final localAudioMetadataChanged = @@ -432,6 +435,7 @@ class _TrackMetadataScreenState extends ConsumerState resolvedSampleRate != localItem.sampleRate) || (resolvedBitrate != null && resolvedBitrate != localItem.bitrate) || + needsExplicit || needsDuration || formatChanged); @@ -456,7 +460,7 @@ class _TrackMetadataScreenState extends ConsumerState fileHasAlbumType || fileHasUPC || fileHasComment || - fileHasExplicit || + needsExplicit || isPlaceholderQualityLabel(_quality)) && mounted) { setState(() { @@ -506,6 +510,7 @@ class _TrackMetadataScreenState extends ConsumerState totalDiscs: needsTotalDiscs ? resolvedTotalDiscs : null, duration: needsDuration ? resolvedDuration : null, composer: needsComposer ? resolvedComposer : null, + explicit: needsExplicit ? resolvedExplicit : null, ); if (mounted && _downloadItem != null) { setState(() { @@ -521,6 +526,7 @@ class _TrackMetadataScreenState extends ConsumerState totalDiscs: needsTotalDiscs ? resolvedTotalDiscs : null, duration: needsDuration ? resolvedDuration : null, composer: needsComposer ? resolvedComposer : null, + explicit: needsExplicit ? resolvedExplicit : null, ); }); } @@ -531,6 +537,7 @@ class _TrackMetadataScreenState extends ConsumerState bitDepth: resolvedBitDepth, sampleRate: resolvedSampleRate, bitrate: resolvedBitrate, + explicit: needsExplicit ? resolvedExplicit : null, format: formatChanged ? resolvedFormat : null, ); if (mounted && @@ -542,6 +549,7 @@ class _TrackMetadataScreenState extends ConsumerState bitDepth: resolvedBitDepth, sampleRate: resolvedSampleRate, bitrate: resolvedBitrate, + explicit: needsExplicit ? resolvedExplicit : null, format: resolvedFormat, ); }); @@ -649,8 +657,9 @@ class _TrackMetadataScreenState extends ConsumerState title: AnimatedOpacity( duration: const Duration(milliseconds: 200), opacity: showTitleInAppBar ? 1.0 : 0.0, - child: Text( - trackName, + child: ExplicitTrackTitle( + title: trackName, + explicit: isExplicit, style: TextStyle( color: colorScheme.onSurface, fontWeight: FontWeight.w600, diff --git a/lib/screens/track_metadata_screen_cover.dart b/lib/screens/track_metadata_screen_cover.dart index 56678098..c47112e2 100644 --- a/lib/screens/track_metadata_screen_cover.dart +++ b/lib/screens/track_metadata_screen_cover.dart @@ -305,7 +305,8 @@ extension _TrackMetadataCover on _TrackMetadataScreenState { String? get comment => normalizeOptionalString(_editedMetadata?['comment']?.toString()); bool get isExplicit => - parseExplicitFlag(_editedMetadata?['explicit']) == true; + parseExplicitFlag(_editedMetadata?['explicit']) ?? + (_isLocalItem ? _localLibraryItem!.explicit : _downloadItem!.explicit); int? get duration => readPositiveInt(_editedMetadata?['duration']) ?? (_isLocalItem ? _localLibraryItem!.duration : _downloadItem!.duration); diff --git a/lib/services/history_database.dart b/lib/services/history_database.dart index af2217e3..cf3c7056 100644 --- a/lib/services/history_database.dart +++ b/lib/services/history_database.dart @@ -65,7 +65,7 @@ class HistoryBatchLookupRequest { } class HistoryDatabase { - static const int schemaVersion = 11; + static const int schemaVersion = 12; static final HistoryDatabase instance = HistoryDatabase._init(); static final sqlite.SingleFlightInitializer _database = sqlite.SingleFlightInitializer(); @@ -119,6 +119,7 @@ class HistoryDatabase { composer TEXT, label TEXT, copyright TEXT, + explicit INTEGER NOT NULL DEFAULT 0, spotify_id_norm TEXT, isrc_norm TEXT, match_key TEXT, @@ -229,6 +230,15 @@ class HistoryDatabase { await _createQueueIndexes(db); _log.i('Added persisted queue sort columns'); } + if (oldVersion < 12) { + await sqlite.addColumnIfMissing( + db, + 'history', + 'explicit', + 'INTEGER NOT NULL DEFAULT 0', + ); + _log.i('Added explicit-content metadata'); + } } static String normalizeLookupText(String? value) => @@ -617,6 +627,7 @@ class HistoryDatabase { 'composer': json['composer'], 'label': json['label'], 'copyright': json['copyright'], + 'explicit': json['explicit'] == true ? 1 : 0, }; row.addAll( _queueSortColumns( @@ -675,6 +686,7 @@ class HistoryDatabase { 'composer': row['composer'], 'label': row['label'], 'copyright': row['copyright'], + 'explicit': row['explicit'] == 1 || row['explicit'] == true, }; } diff --git a/lib/services/library_database.dart b/lib/services/library_database.dart index 0aa4c162..9eb4c871 100644 --- a/lib/services/library_database.dart +++ b/lib/services/library_database.dart @@ -16,10 +16,10 @@ final _log = AppLogger('LibraryDatabase'); class LibraryDatabase { static final LibraryDatabase instance = LibraryDatabase._init(); - static const int schemaVersion = 11; + static const int schemaVersion = 12; static const String legacySourceId = LocalLibraryItem.legacySourceId; static const String visibleLibraryView = 'library_visible'; - static const int audioMetadataScanVersion = 1; + static const int audioMetadataScanVersion = 2; static final sqlite.SingleFlightInitializer _database = sqlite.SingleFlightInitializer(); bool _historyAttached = false; @@ -83,8 +83,9 @@ class LibraryDatabase { composer TEXT, label TEXT, copyright TEXT, + explicit INTEGER NOT NULL DEFAULT 0, format TEXT, - audio_metadata_scan_version INTEGER NOT NULL DEFAULT 1, + audio_metadata_scan_version INTEGER NOT NULL DEFAULT 2, track_name_norm TEXT, artist_name_norm TEXT, album_name_norm TEXT, @@ -201,6 +202,15 @@ class LibraryDatabase { await _createLibrarySources(db); _log.i('Added multiple local library sources'); } + if (oldVersion < 12) { + await sqlite.addColumnIfMissing( + db, + 'library', + 'explicit', + 'INTEGER NOT NULL DEFAULT 0', + ); + _log.i('Added explicit-content metadata'); + } } Future _createLibrarySources(DatabaseExecutor db) async { @@ -459,6 +469,7 @@ class LibraryDatabase { 'composer': json['composer'], 'label': json['label'], 'copyright': json['copyright'], + 'explicit': json['explicit'] == true || json['explicit'] == 1 ? 1 : 0, 'format': json['format'], 'audio_metadata_scan_version': (json['audioMetadataScanVersion'] as num?)?.toInt() ?? @@ -513,6 +524,7 @@ class LibraryDatabase { 'composer': row['composer'], 'label': row['label'], 'copyright': row['copyright'], + 'explicit': row['explicit'] == 1 || row['explicit'] == true, 'format': row['format'], }; } @@ -1473,6 +1485,7 @@ class LibraryDatabase { int? bitDepth, int? sampleRate, int? bitrate, + bool? explicit, String? format, }) async { final values = {}; @@ -1488,6 +1501,9 @@ class LibraryDatabase { if (bitrate != null && bitrate > 0) { values['bitrate'] = bitrate; } + if (explicit != null) { + values['explicit'] = explicit ? 1 : 0; + } final normalizedFormat = normalizeAudioFormatValue(format); if (normalizedFormat != null) { values['format'] = normalizedFormat; diff --git a/lib/services/library_database_models.dart b/lib/services/library_database_models.dart index f7f69501..a750d000 100644 --- a/lib/services/library_database_models.dart +++ b/lib/services/library_database_models.dart @@ -38,6 +38,7 @@ class LocalLibraryItem { final String? composer; final String? label; final String? copyright; + final bool explicit; final String? format; // flac, alac, eac3, ac3, ac4, mp3, opus, m4a const LocalLibraryItem({ @@ -65,6 +66,7 @@ class LocalLibraryItem { this.composer, this.label, this.copyright, + this.explicit = false, this.format, }); @@ -93,6 +95,7 @@ class LocalLibraryItem { 'composer': composer, 'label': label, 'copyright': copyright, + 'explicit': explicit, 'format': format, }; @@ -122,6 +125,7 @@ class LocalLibraryItem { composer: json['composer'] as String?, label: json['label'] as String?, copyright: json['copyright'] as String?, + explicit: json['explicit'] == true || json['explicit'] == 1, format: json['format'] as String?, ); @@ -130,6 +134,7 @@ class LocalLibraryItem { int? bitDepth, int? sampleRate, int? bitrate, + bool? explicit, String? format, }) { return LocalLibraryItem( @@ -157,6 +162,7 @@ class LocalLibraryItem { composer: composer, label: label, copyright: copyright, + explicit: explicit ?? this.explicit, format: format ?? this.format, ); } diff --git a/lib/services/music_player_service.dart b/lib/services/music_player_service.dart index 830bf5c0..80ca2f43 100644 --- a/lib/services/music_player_service.dart +++ b/lib/services/music_player_service.dart @@ -10,6 +10,7 @@ import 'package:spotiflac_android/services/app_state_database.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/int_utils.dart'; import 'package:spotiflac_android/utils/logger.dart'; +import 'package:spotiflac_android/utils/string_utils.dart'; final _log = AppLogger('MusicPlayer'); @@ -56,6 +57,7 @@ class PlayableMedia { final int? sampleRate; final int? bitrate; final String? format; + final bool explicit; const PlayableMedia({ required this.id, @@ -69,6 +71,7 @@ class PlayableMedia { this.sampleRate, this.bitrate, this.format, + this.explicit = false, }); bool get isContentUri => source.startsWith('content://'); @@ -85,6 +88,7 @@ class PlayableMedia { if (sampleRate != null && sampleRate! > 0) 'sampleRate': sampleRate, if (bitrate != null && bitrate! > 0) 'bitrate': bitrate, if (format != null && format!.trim().isNotEmpty) 'format': format, + if (explicit) 'explicit': true, }; static PlayableMedia? fromJson(Map json) { @@ -108,6 +112,7 @@ class PlayableMedia { sampleRate: readPositiveInt(json['sampleRate']), bitrate: readPositiveInt(json['bitrate']), format: json['format']?.toString(), + explicit: parseExplicitFlag(json['explicit']) == true, ); } @@ -130,6 +135,7 @@ class PlayableMedia { if (bitrate != null && bitrate! > 0) 'bitrate': bitrate, if (format != null && format!.trim().isNotEmpty) 'format': format!.trim(), + if (explicit) 'explicit': true, }, ); } @@ -146,10 +152,12 @@ Map playbackAudioMetadataFromMediaItem(MediaItem item) { final sampleRate = readPositiveInt(extras['sample_rate']); final bitrate = readPositiveInt(extras['bitrate']); final format = extras['format']?.toString().trim(); + final explicit = parseExplicitFlag(extras['explicit']); if (bitDepth != null) metadata['bit_depth'] = bitDepth; if (sampleRate != null) metadata['sample_rate'] = sampleRate; if (bitrate != null) metadata['bitrate'] = bitrate; if (format != null && format.isNotEmpty) metadata['format'] = format; + if (explicit != null) metadata['explicit'] = explicit; return metadata; } diff --git a/lib/widgets/audio_quality_badges.dart b/lib/widgets/audio_quality_badges.dart index c07a7624..1cee2837 100644 --- a/lib/widgets/audio_quality_badges.dart +++ b/lib/widgets/audio_quality_badges.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; class AudioQualityBadge extends StatelessWidget { @@ -39,23 +40,30 @@ class ExplicitBadge extends StatelessWidget { @override Widget build(BuildContext context) { + final label = context.l10n.metadataExplicitValue; return Tooltip( - message: 'Explicit', - child: Container( - width: 18, - height: 18, - alignment: Alignment.center, - decoration: BoxDecoration( - color: colorScheme.onSurfaceVariant.withValues(alpha: 0.85), - borderRadius: context.tokens.borderRadiusBadge, - ), - child: Text( - 'E', - style: TextStyle( - fontSize: context.tokens.badgeFontSize, - fontWeight: FontWeight.w700, - color: colorScheme.surface, - height: 1.0, + message: label, + excludeFromSemantics: true, + child: Semantics( + label: label, + child: ExcludeSemantics( + child: Container( + width: 18, + height: 18, + alignment: Alignment.center, + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.85), + borderRadius: context.tokens.borderRadiusBadge, + ), + child: Text( + 'E', + style: TextStyle( + fontSize: context.tokens.badgeFontSize, + fontWeight: FontWeight.w700, + color: colorScheme.surface, + height: 1.0, + ), + ), ), ), ), @@ -63,6 +71,52 @@ class ExplicitBadge extends StatelessWidget { } } +class ExplicitTrackTitle extends StatelessWidget { + final String title; + final bool explicit; + final TextStyle? style; + final TextAlign? textAlign; + final int? maxLines; + final TextOverflow overflow; + final ColorScheme? colorScheme; + + const ExplicitTrackTitle({ + super.key, + required this.title, + required this.explicit, + this.style, + this.textAlign, + this.maxLines, + this.overflow = TextOverflow.clip, + this.colorScheme, + }); + + @override + Widget build(BuildContext context) { + return Text.rich( + TextSpan( + children: [ + TextSpan(text: title), + if (explicit) + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Padding( + padding: const EdgeInsets.only(left: 6), + child: ExplicitBadge( + colorScheme: colorScheme ?? Theme.of(context).colorScheme, + ), + ), + ), + ], + ), + style: style, + textAlign: textAlign, + maxLines: maxLines, + overflow: overflow, + ); + } +} + class DolbyAtmosBadge extends StatelessWidget { final ColorScheme colorScheme; diff --git a/lib/widgets/mini_player.dart b/lib/widgets/mini_player.dart index 17032ddd..e206bee6 100644 --- a/lib/widgets/mini_player.dart +++ b/lib/widgets/mini_player.dart @@ -3,6 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/music_player_provider.dart'; import 'package:spotiflac_android/screens/now_playing_screen.dart'; +import 'package:spotiflac_android/utils/string_utils.dart'; +import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; import 'package:spotiflac_android/widgets/player_artwork.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; @@ -94,8 +96,13 @@ class _MiniPlayerState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Text( - mediaItem.title, + ExplicitTrackTitle( + title: mediaItem.title, + explicit: + parseExplicitFlag( + mediaItem.extras?['explicit'], + ) == + true, style: Theme.of(context).textTheme.titleSmall ?.copyWith(fontWeight: FontWeight.w600), maxLines: 1, diff --git a/test/download_history_logic_test.dart b/test/download_history_logic_test.dart index b93cf9ca..63e9bdec 100644 --- a/test/download_history_logic_test.dart +++ b/test/download_history_logic_test.dart @@ -114,6 +114,16 @@ void main() { expect(restored.format, 'flac'); }); + test('explicit metadata survives history serialization', () { + final item = _historyItem( + id: 'explicit', + filePath: '/music/Album/Explicit Song.flac', + downloadedAt: DateTime.utc(2026, 7, 23), + ).copyWith(explicit: true); + + expect(DownloadHistoryItem.fromJson(item.toJson()).explicit, isTrue); + }); + test('placeholder refresh cannot erase an existing measured quality', () { expect( resolvePersistedHistoryQuality( diff --git a/test/models_and_utils_test.dart b/test/models_and_utils_test.dart index a2a8ed36..72733495 100644 --- a/test/models_and_utils_test.dart +++ b/test/models_and_utils_test.dart @@ -128,6 +128,7 @@ void main() { scannedAt: DateTime(2026), bitDepth: 24, sampleRate: 96000, + explicit: true, ); final updated = item.withAudioMetadata(bitrate: 1840); @@ -135,6 +136,7 @@ void main() { expect(updated.bitrate, 1840); expect(updated.bitDepth, 24); expect(updated.sampleRate, 96000); + expect(updated.explicit, isTrue); expect(updated.trackName, 'Song'); expect(updated.filePath, '/music/song.flac'); expect(updated.sourceId, 'external-ssd'); @@ -142,6 +144,7 @@ void main() { LocalLibraryItem.fromJson(updated.toJson()).sourceId, 'external-ssd', ); + expect(LocalLibraryItem.fromJson(updated.toJson()).explicit, isTrue); }); test('recognizes a retained external source index', () { diff --git a/test/music_player_media_metadata_test.dart b/test/music_player_media_metadata_test.dart index 47a5bc17..ceb95901 100644 --- a/test/music_player_media_metadata_test.dart +++ b/test/music_player_media_metadata_test.dart @@ -11,6 +11,7 @@ void main() { sampleRate: 96000, bitrate: 2860, format: 'flac', + explicit: true, ); test('queue media exposes technical quality to Now Playing immediately', () { @@ -21,6 +22,7 @@ void main() { 'sample_rate': 96000, 'bitrate': 2860, 'format': 'flac', + 'explicit': true, }); }); @@ -32,6 +34,7 @@ void main() { expect(restored.sampleRate, 96000); expect(restored.bitrate, 2860); expect(restored.format, 'flac'); + expect(restored.explicit, isTrue); }); test('file probe cannot erase valid queue quality with empty values', () { diff --git a/test/track_metadata_header_contrast_test.dart b/test/track_metadata_header_contrast_test.dart index e206dc1c..452bf7fe 100644 --- a/test/track_metadata_header_contrast_test.dart +++ b/test/track_metadata_header_contrast_test.dart @@ -5,6 +5,7 @@ import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/download_history_provider.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/widgets/album_detail_header.dart'; +import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; void main() { testWidgets('metadata hero keeps technical text legible in light theme', ( @@ -25,6 +26,7 @@ void main() { bitDepth: 16, sampleRate: 44100, format: 'flac', + explicit: true, ); await tester.pumpWidget( @@ -41,6 +43,7 @@ void main() { final headerMeta = find.byType(HeaderMetaRow); expect(headerMeta, findsOneWidget); + expect(find.byType(ExplicitBadge), findsNWidgets(2)); for (final label in const ['16-bit/44.1kHz', '4:10', 'Tidal-web']) { final text = tester.widget( find.descendant(of: headerMeta, matching: find.text(label)),