/* * Copyright (c) Double Symmetry GmbH * Commercial use requires a license. See https://rntp.dev/pricing */ package com.doublesymmetry.trackplayer.models import androidx.media3.common.MediaMetadata import androidx.media3.common.Metadata import androidx.media3.common.util.UnstableApi import androidx.media3.extractor.metadata.icy.IcyInfo /** * Pure helper that turns a media3 [Metadata] bundle (delivered via * [androidx.media3.common.Player.Listener.onMetadata]) into a * [MetadataReceivedEvent] suitable for emitting to JS. * * Lives outside [com.doublesymmetry.trackplayer.TrackPlayerModule] so it can be * unit-tested without spinning up a player. */ @UnstableApi internal object StreamMetadataExtractor { /** * Returns a [MetadataReceivedEvent] aggregated from all entries in [metadata], * or `null` if none of the entries contributed any user-visible fields. * * Behavior: * - Each entry's [Metadata.Entry.populateMediaMetadata] is invoked. media3's * built-in handlers cover ID3 ([androidx.media3.extractor.metadata.id3.TextInformationFrame] * `TIT2`/`TPE1`/`TALB`/`TCON`/…), ICY ([IcyInfo] → title), Vorbis * comments, MP4 MDTA, and more. * - ICY's `StreamTitle` is conventionally formatted as `"Artist - Title"` * (Shoutcast/Icecast practice, not a spec). When [populateMediaMetadata] * leaves `artist` unset, we attempt to split on the first ` - ` to recover * artist + title separately. Same heuristic V4 and the iOS engine use. * - [IcyInfo.url] is mapped into `artworkUri` to mirror the iOS behavior of * surfacing ICY `StreamUrl` (which some stations use to ship the current * track's artwork). */ fun extract(metadata: Metadata): MetadataReceivedEvent? { val builder = MediaMetadata.Builder() var icyUrl: String? = null for (i in 0 until metadata.length()) { val entry = metadata.get(i) entry.populateMediaMetadata(builder) if (entry is IcyInfo) { entry.url?.takeIf { it.isNotEmpty() }?.let { icyUrl = it } } } val built = builder.build() var title = built.title?.toString() var artist = built.artist?.toString() if (artist == null && title != null) { val (parsedTitle, parsedArtist) = parseStreamTitle(title!!) title = parsedTitle artist = parsedArtist } val albumTitle = built.albumTitle?.toString() val artworkUri = built.artworkUri?.toString() ?: icyUrl val genre = built.genre?.toString() if (title == null && artist == null && albumTitle == null && artworkUri == null && genre == null) { return null } return MetadataReceivedEvent( title = title, artist = artist, albumTitle = albumTitle, artworkUri = artworkUri, genre = genre, ) } /** * Splits an ICY `StreamTitle` payload into `(title, artist)` using the * de-facto Shoutcast convention `"Artist - Title"`. * * Returns `(trimmed, null)` when no ` - ` separator is present (e.g. talk * radio with a single show title), and `(null, null)` for blank input. * Only the first occurrence of ` - ` is treated as the separator, so a * title containing additional `" - "` substrings (e.g. `"Artist - Song - Remix"`) * yields `artist="Artist"`, `title="Song - Remix"`. */ fun parseStreamTitle(raw: String): Pair { val trimmed = raw.trim() if (trimmed.isEmpty()) return null to null val sep = trimmed.indexOf(" - ") if (sep >= 0) { val artist = trimmed.substring(0, sep).trim() val title = trimmed.substring(sep + 3).trim() return title.ifEmpty { null } to artist.ifEmpty { null } } return trimmed to null } }