feat(metadata): show explicit badges in track titles

This commit is contained in:
zarzet
2026-08-22 23:08:40 +07:00
parent 75e1cf17b8
commit 19591bdf19
22 changed files with 232 additions and 34 deletions
@@ -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<Long>()
internal val nativeFFmpegSessionIds = mutableSetOf<Long>()
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)
@@ -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<String, dynamic> toJson() => {
@@ -99,6 +101,7 @@ class DownloadHistoryItem {
'composer': composer,
'label': label,
'copyright': copyright,
'explicit': explicit,
};
factory DownloadHistoryItem.fromJson(Map<String, dynamic> 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,
);
}
}
+7 -1
View File
@@ -201,6 +201,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
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<DownloadHistoryState> {
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<DownloadHistoryState> {
totalDiscs: totalDiscs,
duration: duration,
composer: composer,
explicit: explicit,
);
if (updated.quality == current.quality &&
@@ -511,7 +514,8 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
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<DownloadHistoryState> {
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<DownloadHistoryState> {
composer: composer,
label: label,
copyright: copyright,
explicit: explicit,
);
final updatedItems = target.index >= 0
@@ -340,6 +340,9 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier {
composer: historyComposer,
label: label,
copyright: copyright,
explicit:
trackToDownload.isExplicit ||
parseExplicitFlag(result['explicit']) == true,
);
}
+2
View File
@@ -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,
);
}
+3
View File
@@ -44,6 +44,7 @@ class PlaybackController extends Notifier<PlaybackState> {
String album = '',
String coverUrl = '',
Track? track,
bool? explicit,
}) async {
if (isCueVirtualPath(path)) {
throw Exception(cueVirtualTrackRequiresSplitMessage);
@@ -64,6 +65,7 @@ class PlaybackController extends Notifier<PlaybackState> {
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<PlaybackState> {
duration: track.duration > 0
? Duration(seconds: track.duration)
: null,
explicit: track.isExplicit,
),
);
}
+12 -2
View File
@@ -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<NowPlayingScreen> {
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<NowPlayingScreen> {
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,
+1
View File
@@ -217,6 +217,7 @@ extension _TrackMetadataFileActions on _TrackMetadataScreenState {
artist: artistName,
album: albumName,
coverUrl: playbackCover,
explicit: isExplicit,
);
} catch (e) {
if (context.mounted) {
+4 -2
View File
@@ -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(
+23 -1
View File
@@ -1196,7 +1196,28 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState {
}
Future<void> _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');
+13 -4
View File
@@ -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<TrackMetadataScreen>
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<TrackMetadataScreen>
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<TrackMetadataScreen>
needsTotalDiscs ||
needsDuration ||
needsComposer ||
needsExplicit ||
(isPlaceholderQualityLabel(_quality) && resolvedQuality != null));
final localItem = _localLibraryItem;
final localAudioMetadataChanged =
@@ -432,6 +435,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen>
resolvedSampleRate != localItem.sampleRate) ||
(resolvedBitrate != null &&
resolvedBitrate != localItem.bitrate) ||
needsExplicit ||
needsDuration ||
formatChanged);
@@ -456,7 +460,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen>
fileHasAlbumType ||
fileHasUPC ||
fileHasComment ||
fileHasExplicit ||
needsExplicit ||
isPlaceholderQualityLabel(_quality)) &&
mounted) {
setState(() {
@@ -506,6 +510,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen>
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<TrackMetadataScreen>
totalDiscs: needsTotalDiscs ? resolvedTotalDiscs : null,
duration: needsDuration ? resolvedDuration : null,
composer: needsComposer ? resolvedComposer : null,
explicit: needsExplicit ? resolvedExplicit : null,
);
});
}
@@ -531,6 +537,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen>
bitDepth: resolvedBitDepth,
sampleRate: resolvedSampleRate,
bitrate: resolvedBitrate,
explicit: needsExplicit ? resolvedExplicit : null,
format: formatChanged ? resolvedFormat : null,
);
if (mounted &&
@@ -542,6 +549,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen>
bitDepth: resolvedBitDepth,
sampleRate: resolvedSampleRate,
bitrate: resolvedBitrate,
explicit: needsExplicit ? resolvedExplicit : null,
format: resolvedFormat,
);
});
@@ -649,8 +657,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen>
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,
+2 -1
View File
@@ -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);
+13 -1
View File
@@ -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> _database =
sqlite.SingleFlightInitializer<Database>();
@@ -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,
};
}
+19 -3
View File
@@ -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> _database =
sqlite.SingleFlightInitializer<Database>();
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<void> _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 = <String, dynamic>{};
@@ -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;
@@ -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,
);
}
+8
View File
@@ -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<String, dynamic> 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<String, dynamic> 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;
}
+70 -16
View File
@@ -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;
+9 -2
View File
@@ -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<MiniPlayer> {
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,
+10
View File
@@ -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(
+3
View File
@@ -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', () {
@@ -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', () {
@@ -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<Text>(
find.descendant(of: headerMeta, matching: find.text(label)),