mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-26 05:51:18 +02:00
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:
+23
-11
@@ -163,17 +163,26 @@ func (c *DeezerClient) maybeCleanupCachesLocked(now time.Time) {
|
||||
}
|
||||
|
||||
type deezerTrack struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Duration int `json:"duration"`
|
||||
TrackPosition int `json:"track_position"`
|
||||
DiskNumber int `json:"disk_number"`
|
||||
ISRC string `json:"isrc"`
|
||||
Link string `json:"link"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
Artist deezerArtist `json:"artist"`
|
||||
Album deezerAlbumSimple `json:"album"`
|
||||
Contributors []deezerArtist `json:"contributors"`
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Duration int `json:"duration"`
|
||||
TrackPosition int `json:"track_position"`
|
||||
DiskNumber int `json:"disk_number"`
|
||||
ISRC string `json:"isrc"`
|
||||
Link string `json:"link"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
ExplicitLyrics bool `json:"explicit_lyrics"`
|
||||
ExplicitContentLyrics int `json:"explicit_content_lyrics"`
|
||||
Artist deezerArtist `json:"artist"`
|
||||
Album deezerAlbumSimple `json:"album"`
|
||||
Contributors []deezerArtist `json:"contributors"`
|
||||
}
|
||||
|
||||
// deezerTrackIsExplicit maps Deezer's parental-advisory fields to a boolean:
|
||||
// explicit_lyrics is the boolean flag, explicit_content_lyrics uses 1 to mean
|
||||
// explicit (0 = clean, 2 = unknown).
|
||||
func deezerTrackIsExplicit(track deezerTrack) bool {
|
||||
return track.ExplicitLyrics || track.ExplicitContentLyrics == 1
|
||||
}
|
||||
|
||||
type deezerArtist struct {
|
||||
@@ -245,6 +254,7 @@ func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
|
||||
ISRC: track.ISRC,
|
||||
AlbumID: fmt.Sprintf("deezer:%d", track.Album.ID),
|
||||
ArtistID: fmt.Sprintf("deezer:%d", track.Artist.ID),
|
||||
Explicit: deezerTrackIsExplicit(track),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -670,6 +680,7 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
||||
ISRC: isrc,
|
||||
AlbumID: fmt.Sprintf("deezer:%d", album.ID),
|
||||
AlbumType: albumType,
|
||||
Explicit: deezerTrackIsExplicit(track),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -979,6 +990,7 @@ func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*Pla
|
||||
ExternalURL: track.Link,
|
||||
ISRC: isrc,
|
||||
AlbumID: fmt.Sprintf("deezer:%d", track.Album.ID),
|
||||
Explicit: deezerTrackIsExplicit(track),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -734,6 +734,7 @@ func extTrackFromTrackMetadata(track *TrackMetadata, providerID string) *ExtTrac
|
||||
DeezerID: deezerID,
|
||||
SpotifyID: track.SpotifyID,
|
||||
Composer: track.Composer,
|
||||
Explicit: track.Explicit,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ type ExtTrackMetadata struct {
|
||||
ProviderID string `json:"provider_id"`
|
||||
ItemType string `json:"item_type,omitempty"`
|
||||
AlbumType string `json:"album_type,omitempty"`
|
||||
Explicit bool `json:"explicit,omitempty"`
|
||||
|
||||
TidalID string `json:"tidal_id,omitempty"`
|
||||
QobuzID string `json:"qobuz_id,omitempty"`
|
||||
@@ -809,6 +810,7 @@ func parseExtensionTrackValue(vm *goja.Runtime, value goja.Value) ExtTrackMetada
|
||||
ProviderID: gojaObjectString(obj, "provider_id", "providerId"),
|
||||
ItemType: gojaObjectString(obj, "item_type", "itemType"),
|
||||
AlbumType: gojaObjectString(obj, "album_type", "albumType"),
|
||||
Explicit: gojaObjectBool(obj, "explicit", "is_explicit", "isExplicit"),
|
||||
TidalID: gojaObjectString(obj, "tidal_id", "tidalId"),
|
||||
QobuzID: gojaObjectString(obj, "qobuz_id", "qobuzId"),
|
||||
DeezerID: gojaObjectString(obj, "deezer_id", "deezerId"),
|
||||
|
||||
@@ -178,6 +178,49 @@ func TestExtensionRuntimeAuthAndPolyfills(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExtensionTrackValueExplicit(t *testing.T) {
|
||||
vm := goja.New()
|
||||
|
||||
value, err := vm.RunString(`({name: "Song", explicit: true})`)
|
||||
if err != nil {
|
||||
t.Fatalf("RunString: %v", err)
|
||||
}
|
||||
if track := parseExtensionTrackValue(vm, value); !track.Explicit {
|
||||
t.Fatalf("expected explicit track, got %#v", track)
|
||||
}
|
||||
|
||||
value, err = vm.RunString(`({name: "Song", isExplicit: true})`)
|
||||
if err != nil {
|
||||
t.Fatalf("RunString: %v", err)
|
||||
}
|
||||
if track := parseExtensionTrackValue(vm, value); !track.Explicit {
|
||||
t.Fatalf("expected explicit track via isExplicit, got %#v", track)
|
||||
}
|
||||
|
||||
value, err = vm.RunString(`({name: "Song"})`)
|
||||
if err != nil {
|
||||
t.Fatalf("RunString: %v", err)
|
||||
}
|
||||
if track := parseExtensionTrackValue(vm, value); track.Explicit {
|
||||
t.Fatalf("expected clean track, got %#v", track)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeezerTrackIsExplicit(t *testing.T) {
|
||||
if deezerTrackIsExplicit(deezerTrack{}) {
|
||||
t.Fatal("expected clean track by default")
|
||||
}
|
||||
if !deezerTrackIsExplicit(deezerTrack{ExplicitLyrics: true}) {
|
||||
t.Fatal("expected explicit via explicit_lyrics")
|
||||
}
|
||||
if !deezerTrackIsExplicit(deezerTrack{ExplicitContentLyrics: 1}) {
|
||||
t.Fatal("expected explicit via explicit_content_lyrics == 1")
|
||||
}
|
||||
if deezerTrackIsExplicit(deezerTrack{ExplicitContentLyrics: 2}) {
|
||||
t.Fatal("expected unknown (2) to be treated as clean")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionStoreDiskCacheSurvivesRestart(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
registryURL := "https://registry.example.com/registry.json"
|
||||
|
||||
@@ -30,6 +30,7 @@ type TrackMetadata struct {
|
||||
ArtistID string `json:"artist_id,omitempty"`
|
||||
AlbumType string `json:"album_type,omitempty"`
|
||||
Composer string `json:"composer,omitempty"`
|
||||
Explicit bool `json:"explicit,omitempty"`
|
||||
}
|
||||
|
||||
type AlbumTrackMetadata struct {
|
||||
@@ -51,6 +52,7 @@ type AlbumTrackMetadata struct {
|
||||
AlbumURL string `json:"album_url,omitempty"`
|
||||
AlbumType string `json:"album_type,omitempty"`
|
||||
Composer string `json:"composer,omitempty"`
|
||||
Explicit bool `json:"explicit,omitempty"`
|
||||
}
|
||||
|
||||
type AlbumInfoMetadata struct {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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']),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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']),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -231,6 +231,7 @@ class _SearchTrackTile extends ConsumerWidget {
|
||||
audioQuality: track.audioQuality,
|
||||
audioModes: track.audioModes,
|
||||
colorScheme: colorScheme,
|
||||
explicit: track.isExplicit,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user