feat(metadata): surface explicit content flag with [E] badge

Port of the explicit-content indicator from SpotiFLAC-Web (issue #456):

- Go: add explicit to ExtTrackMetadata (parsed generically from any
  extension via explicit/is_explicit/isExplicit), TrackMetadata, and
  AlbumTrackMetadata; built-in Deezer maps explicit_lyrics and
  explicit_content_lyrics == 1
- Dart: add explicit to the Track model with a tolerant
  parseExplicitFlag helper wired into all backend track parsers
- UI: new ExplicitBadge rendered through buildQualityBadges on album,
  playlist, search, and home track rows
This commit is contained in:
zarzet
2026-07-10 10:12:14 +07:00
parent 8abb9d119b
commit 4c83aeafb5
14 changed files with 137 additions and 11 deletions
+4
View File
@@ -28,6 +28,7 @@ class Track {
final String? itemType;
final String? audioQuality;
final String? audioModes;
final bool? explicit;
const Track({
required this.id,
@@ -54,6 +55,7 @@ class Track {
this.itemType,
this.audioQuality,
this.audioModes,
this.explicit,
});
bool get isSingle {
@@ -82,6 +84,8 @@ class Track {
bool get isDolbyAtmos =>
audioModes != null && audioModes!.contains('DOLBY_ATMOS');
bool get isExplicit => explicit == true;
bool get hasAudioQuality => audioQuality != null && audioQuality!.isNotEmpty;
bool get hasPreview => previewUrl != null && previewUrl!.isNotEmpty;
+2
View File
@@ -35,6 +35,7 @@ Track _$TrackFromJson(Map<String, dynamic> json) => Track(
itemType: json['itemType'] as String?,
audioQuality: json['audioQuality'] as String?,
audioModes: json['audioModes'] as String?,
explicit: json['explicit'] as bool?,
);
Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
@@ -62,6 +63,7 @@ Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
'itemType': instance.itemType,
'audioQuality': instance.audioQuality,
'audioModes': instance.audioModes,
'explicit': instance.explicit,
};
ServiceAvailability _$ServiceAvailabilityFromJson(Map<String, dynamic> json) =>
+2
View File
@@ -750,6 +750,7 @@ class TrackNotifier extends Notifier<TrackState> {
itemType: track.itemType,
audioQuality: track.audioQuality,
audioModes: track.audioModes,
explicit: track.explicit,
availability: ServiceAvailability(
tidal: availability['tidal'] as bool? ?? false,
qobuz: availability['qobuz'] as bool? ?? false,
@@ -846,6 +847,7 @@ class TrackNotifier extends Notifier<TrackState> {
audioQuality: data['audio_quality']?.toString(),
audioModes: data['audio_modes']?.toString(),
previewUrl: data['preview_url']?.toString(),
explicit: parseExplicitFlag(data['explicit']),
);
}
+2
View File
@@ -471,6 +471,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
audioQuality: data['audio_quality']?.toString(),
audioModes: data['audio_modes']?.toString(),
previewUrl: data['preview_url']?.toString(),
explicit: parseExplicitFlag(data['explicit']),
);
}
@@ -1294,6 +1295,7 @@ class _AlbumTrackItem extends ConsumerWidget {
audioQuality: track.audioQuality,
audioModes: track.audioModes,
colorScheme: colorScheme,
explicit: track.isExplicit,
),
if (isInLocalLibrary || isInHistory) ...[
const SizedBox(width: 6),
+3
View File
@@ -338,6 +338,7 @@ class _TrackItemWithStatus extends ConsumerWidget {
audioQuality: track.audioQuality,
audioModes: track.audioModes,
colorScheme: colorScheme,
explicit: track.isExplicit,
),
if (isInLocalLibrary) ...[
const SizedBox(width: 6),
@@ -1126,6 +1127,7 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
audioQuality: data['audio_quality']?.toString(),
audioModes: data['audio_modes']?.toString(),
previewUrl: data['preview_url']?.toString(),
explicit: parseExplicitFlag(data['explicit']),
);
}
@@ -1296,6 +1298,7 @@ class _ExtensionPlaylistScreenState
audioQuality: data['audio_quality']?.toString(),
audioModes: data['audio_modes']?.toString(),
previewUrl: data['preview_url']?.toString(),
explicit: parseExplicitFlag(data['explicit']),
);
}
+2
View File
@@ -226,6 +226,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
audioQuality: data['audio_quality']?.toString(),
audioModes: data['audio_modes']?.toString(),
previewUrl: data['preview_url']?.toString(),
explicit: parseExplicitFlag(data['explicit']),
);
}
@@ -1064,6 +1065,7 @@ class _PlaylistTrackItem extends ConsumerWidget {
audioQuality: track.audioQuality,
audioModes: track.audioModes,
colorScheme: colorScheme,
explicit: track.isExplicit,
),
if (isInLocalLibrary || isInHistory) ...[
const SizedBox(width: 6),
+1
View File
@@ -231,6 +231,7 @@ class _SearchTrackTile extends ConsumerWidget {
audioQuality: track.audioQuality,
audioModes: track.audioModes,
colorScheme: colorScheme,
explicit: track.isExplicit,
),
],
),
+14
View File
@@ -6,6 +6,20 @@ String? normalizeOptionalString(String? value) {
return trimmed;
}
/// Parses the parental-advisory flag from backend/extension payloads, which
/// may arrive as a bool, a 0/1 number, or a "true"/"1" string.
bool? parseExplicitFlag(dynamic value) {
if (value == null) return null;
if (value is bool) return value;
if (value is num) return value == 1;
if (value is String) {
final normalized = value.trim().toLowerCase();
if (normalized.isEmpty || normalized == 'null') return null;
return normalized == 'true' || normalized == '1';
}
return null;
}
final RegExp _windowsAbsolutePathPattern = RegExp(r'^[A-Za-z]:[\\/]');
bool _looksLikeLocalReference(String value) {
+36
View File
@@ -31,6 +31,37 @@ class AudioQualityBadge extends StatelessWidget {
}
}
class ExplicitBadge extends StatelessWidget {
final ColorScheme colorScheme;
const ExplicitBadge({super.key, required this.colorScheme});
@override
Widget build(BuildContext context) {
return Tooltip(
message: 'Explicit',
child: Container(
width: 14,
height: 14,
alignment: Alignment.center,
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.85),
borderRadius: BorderRadius.circular(3),
),
child: Text(
'E',
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w700,
color: colorScheme.surface,
height: 1.0,
),
),
),
);
}
}
class DolbyAtmosBadge extends StatelessWidget {
final ColorScheme colorScheme;
@@ -118,8 +149,13 @@ List<Widget> buildQualityBadges({
required String? audioQuality,
required String? audioModes,
required ColorScheme colorScheme,
bool explicit = false,
}) {
final badges = <Widget>[];
if (explicit) {
badges.add(const SizedBox(width: 6));
badges.add(ExplicitBadge(colorScheme: colorScheme));
}
if (audioQuality != null && audioQuality.isNotEmpty) {
badges.add(const SizedBox(width: 6));
badges.add(