feat: support dynamic metadata for internet radio stations

- Implemented `onMetadata` in `BaseMediaService` to extract "Artist - Title" info from ICY, ID3, and Vorbis streams.
- Added a fallback mechanism to periodically check HTTP headers (e.g., `icy-name`, `StreamTitle`) for radio metadata.
- Updated `PlayerControllerFragment` and `TrackInfoDialog` to display the station name alongside dynamic track information.
- Enhanced `TrackInfoDialog` layout to include a dedicated "Station" field for radio tracks.
- Modified `MappingUtil` to preserve station names in media metadata extras.
This commit is contained in:
TrackArcher
2026-01-12 17:09:19 +01:00
parent ccea7674bd
commit 3599166699
8 changed files with 487 additions and 11 deletions

2
.idea/compiler.xml generated
View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
<bytecodeTargetLevel target="17" />
</component>
</project>

2
.idea/misc.xml generated
View File

@@ -192,7 +192,7 @@
</option>
</component>
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">

View File

@@ -23,6 +23,9 @@ import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.ShuffleOrder.DefaultShuffleOrder
import androidx.media3.session.*
import androidx.media3.session.MediaSession.ControllerInfo
import androidx.media3.extractor.metadata.icy.IcyInfo
import androidx.media3.extractor.metadata.id3.TextInformationFrame
import androidx.media3.extractor.metadata.vorbis.VorbisComment
import com.cappielloantonio.tempo.R
import com.cappielloantonio.tempo.repository.QueueRepository
import com.cappielloantonio.tempo.ui.activity.MainActivity
@@ -31,6 +34,12 @@ import com.cappielloantonio.tempo.widget.WidgetUpdateManager
import com.google.common.collect.ImmutableList
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import java.net.HttpURLConnection
import java.net.URL
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.ScheduledFuture
import java.util.concurrent.TimeUnit
@UnstableApi
open class BaseMediaService : MediaLibraryService() {
@@ -67,6 +76,13 @@ open class BaseMediaService : MediaLibraryService() {
}
}
private val radioHeaderCheckExecutor: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor()
private var radioHeaderCheckScheduled = false
private var radioHeaderCheckFuture: ScheduledFuture<*>? = null
private val radioHeaderCheckRunnable = Runnable {
checkRadioHttpHeaders()
}
private val binder = LocalBinder()
open fun playerInitHook() {
@@ -117,6 +133,9 @@ open class BaseMediaService : MediaLibraryService() {
updateWidget(player)
}
private var lastRadioArtist: String? = null
private var lastRadioTitle: String? = null
fun initializePlayerListener(player: Player) {
player.addListener(object : Player.Listener {
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
@@ -126,6 +145,16 @@ open class BaseMediaService : MediaLibraryService() {
if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK || reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) {
MediaManager.setLastPlayedTimestamp(mediaItem)
}
// Restart header checks for radio streams when media item changes
val mediaType = mediaItem.mediaMetadata.extras?.getString("type")
if (mediaType == Constants.MEDIA_TYPE_RADIO && player.isPlaying) {
stopRadioHeaderChecks()
scheduleRadioHeaderChecks()
} else if (mediaType != Constants.MEDIA_TYPE_RADIO) {
stopRadioHeaderChecks()
}
updateWidget(player)
}
@@ -162,6 +191,103 @@ open class BaseMediaService : MediaLibraryService() {
}
}
override fun onMetadata(metadata: Metadata) {
// Handle streaming metadata (ICY, ID3) for radio / streaming content
val currentItem = player.currentMediaItem ?: return
val extras = currentItem.mediaMetadata.extras
val mediaType = extras?.getString("type")
if (mediaType != Constants.MEDIA_TYPE_RADIO) return
var artist: String? = null
var title: String? = null
for (i in 0 until metadata.length()) {
when (val entry = metadata[i]) {
is IcyInfo -> {
// Common format: "Artist - Title"
val icyTitle = entry.title ?: continue
val parts = icyTitle.split(" - ", limit = 2)
if (parts.size == 2) {
artist = parts[0].trim().ifEmpty { null } ?: artist
title = parts[1].trim().ifEmpty { null } ?: title
} else {
title = icyTitle.trim().ifEmpty { null } ?: title
}
}
is TextInformationFrame -> {
@Suppress("DEPRECATION")
val value = entry.value
when (entry.id) {
"TPE1" -> if (!value.isNullOrBlank()) {
artist = value
}
"TIT2" -> if (!value.isNullOrBlank()) {
title = value
}
}
}
is VorbisComment -> {
// OGG Vorbis/Opus metadata
@Suppress("DEPRECATION")
val value = entry.value
when (entry.key) {
"ARTIST" -> if (!value.isNullOrBlank()) {
artist = value
}
"TITLE" -> if (!value.isNullOrBlank()) {
title = value
}
"ALBUM" -> {
// Store album if needed, but not used for radio display
}
}
}
}
}
if (artist.isNullOrBlank() && title.isNullOrBlank()) return
// Deduplicate consecutive identical metadata
if (artist == lastRadioArtist && title == lastRadioTitle) return
lastRadioArtist = artist
lastRadioTitle = title
val currentIndex = player.currentMediaItemIndex
if (currentIndex == C.INDEX_UNSET) return
val metadataBuilder = currentItem.mediaMetadata.buildUpon()
val newExtras = Bundle(currentItem.mediaMetadata.extras ?: Bundle())
artist?.let {
metadataBuilder.setArtist(it)
newExtras.putString("radioArtist", it)
}
title?.let {
metadataBuilder.setTitle(it)
newExtras.putString("radioTitle", it)
}
// Preserve station name separately (fallback to static title if needed)
if (!newExtras.containsKey("stationName")) {
val stationName =
currentItem.mediaMetadata.extras?.getString("stationName")
?: currentItem.mediaMetadata.title?.toString()
stationName?.let { newExtras.putString("stationName", it) }
}
metadataBuilder.setExtras(newExtras)
val updatedItem = currentItem.buildUpon()
.setMediaMetadata(metadataBuilder.build())
.build()
(player as? ExoPlayer)?.let { exo ->
exo.replaceMediaItem(currentIndex, updatedItem)
updateWidget(exo)
// Media3 notification will automatically update via MediaMetadata changes
}
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
Log.d(javaClass.toString(), "onIsPlayingChanged " + player.currentMediaItemIndex)
if (!isPlaying) {
@@ -174,8 +300,10 @@ open class BaseMediaService : MediaLibraryService() {
}
if (isPlaying) {
scheduleWidgetUpdates()
scheduleRadioHeaderChecks()
} else {
stopWidgetUpdates()
stopRadioHeaderChecks()
}
updateWidget(player)
}
@@ -279,6 +407,8 @@ open class BaseMediaService : MediaLibraryService() {
releaseNetworkCallback()
equalizerManager.release()
stopWidgetUpdates()
stopRadioHeaderChecks()
radioHeaderCheckExecutor.shutdown()
releasePlayers()
mediaLibrarySession.release()
super.onDestroy()
@@ -397,6 +527,226 @@ open class BaseMediaService : MediaLibraryService() {
widgetUpdateScheduled = false
}
private fun scheduleRadioHeaderChecks() {
val player = mediaLibrarySession.player
val currentItem = player.currentMediaItem ?: return
val mediaType = currentItem.mediaMetadata.extras?.getString("type")
if (mediaType != Constants.MEDIA_TYPE_RADIO) return
if (radioHeaderCheckScheduled) return
// Check immediately, then periodically
checkRadioHttpHeaders()
radioHeaderCheckFuture = radioHeaderCheckExecutor.scheduleWithFixedDelay(
radioHeaderCheckRunnable,
RADIO_HEADER_CHECK_INTERVAL_SECONDS,
RADIO_HEADER_CHECK_INTERVAL_SECONDS,
TimeUnit.SECONDS
)
radioHeaderCheckScheduled = true
}
private fun stopRadioHeaderChecks() {
if (!radioHeaderCheckScheduled) return
radioHeaderCheckFuture?.cancel(false)
radioHeaderCheckFuture = null
radioHeaderCheckScheduled = false
}
private fun checkRadioHttpHeaders() {
val player = mediaLibrarySession.player
val currentItem = player.currentMediaItem ?: return
val extras = currentItem.mediaMetadata.extras
val mediaType = extras?.getString("type")
if (mediaType != Constants.MEDIA_TYPE_RADIO) return
val streamUrl = extras?.getString("uri") ?: currentItem.requestMetadata.mediaUri?.toString()
if (streamUrl.isNullOrBlank()) return
try {
val url = URL(streamUrl)
val connection = url.openConnection() as? HttpURLConnection ?: run {
Log.d(javaClass.toString(), "Failed to create HTTP connection for: $streamUrl")
return
}
// Try HEAD request first (more efficient)
connection.requestMethod = "HEAD"
connection.setRequestProperty("Icy-MetaData", "1")
connection.setRequestProperty("User-Agent", "Tempus/1.0")
connection.connectTimeout = 5000
connection.readTimeout = 5000
try {
connection.connect()
} catch (e: Exception) {
Log.d(javaClass.toString(), "HEAD request failed, trying GET: ${e.message}")
connection.disconnect()
// Fallback to GET request with Range header (some servers don't support HEAD)
val getConnection = url.openConnection() as? HttpURLConnection ?: return
getConnection.requestMethod = "GET"
getConnection.setRequestProperty("Icy-MetaData", "1")
getConnection.setRequestProperty("User-Agent", "Tempus/1.0")
getConnection.setRequestProperty("Range", "bytes=0-1") // Request minimal data
getConnection.connectTimeout = 5000
getConnection.readTimeout = 5000
try {
getConnection.connect()
val responseCode = getConnection.responseCode
if (responseCode >= 400) {
Log.d(javaClass.toString(), "GET request failed with code: $responseCode")
getConnection.disconnect()
return
}
// Check for various HTTP header formats that contain metadata
val streamTitle = getConnection.getHeaderField("icy-name")
?: getConnection.getHeaderField("StreamTitle")
?: getConnection.getHeaderField("stream-title")
?: getConnection.getHeaderField("X-StreamTitle")
getConnection.inputStream?.close()
getConnection.disconnect()
if (streamTitle.isNullOrBlank()) {
Log.d(javaClass.toString(), "No HTTP header metadata found in GET response")
return
}
Log.d(javaClass.toString(), "Found HTTP header metadata via GET: $streamTitle")
processStreamTitle(streamTitle, player)
return
} catch (e2: Exception) {
Log.d(javaClass.toString(), "GET request also failed: ${e2.message}")
getConnection.disconnect()
return
}
}
val responseCode = connection.responseCode
if (responseCode >= 400) {
Log.d(javaClass.toString(), "HEAD request failed with code: $responseCode")
connection.disconnect()
return
}
// Check for various HTTP header formats that contain metadata
// Radio Bob and similar stations send metadata in HTTP headers like:
// - icy-name: "Artist - Song Title"
// - StreamTitle: "Artist - Song Title"
val streamTitle = connection.getHeaderField("icy-name")
?: connection.getHeaderField("StreamTitle")
?: connection.getHeaderField("stream-title")
?: connection.getHeaderField("X-StreamTitle")
connection.disconnect()
if (streamTitle.isNullOrBlank()) {
Log.d(javaClass.toString(), "No HTTP header metadata found for radio stream")
return
}
Log.d(javaClass.toString(), "Found HTTP header metadata via HEAD: $streamTitle")
processStreamTitle(streamTitle, player)
} catch (e: Exception) {
Log.d(javaClass.toString(), "Failed to fetch radio HTTP headers: ${e.message ?: e.javaClass.simpleName}", e)
// Silently fail - this is a fallback mechanism
}
}
private fun processStreamTitle(streamTitle: String, player: Player) {
// Parse the stream title (could be "Artist - Title" or just "Title")
// Radio Bob format: "Artist - Song Title"
var artist: String? = null
val title: String?
val parts = streamTitle.split(" - ", limit = 2)
if (parts.size == 2) {
artist = parts[0].trim().ifEmpty { null }
title = parts[1].trim().ifEmpty { null }
Log.d(javaClass.toString(), "Parsed HTTP metadata - Artist: $artist, Title: $title")
} else {
title = streamTitle.trim().ifEmpty { null }
Log.d(javaClass.toString(), "Parsed HTTP metadata - Title only: $title")
}
if (artist.isNullOrBlank() && title.isNullOrBlank()) return
// Deduplicate consecutive identical metadata
if (artist == lastRadioArtist && title == lastRadioTitle) {
Log.d(javaClass.toString(), "Skipping duplicate metadata")
return
}
// Update on main thread
widgetUpdateHandler.post {
val currentItemNow = player.currentMediaItem ?: return@post
val currentIndex = player.currentMediaItemIndex
if (currentIndex == C.INDEX_UNSET) return@post
val currentExtras = currentItemNow.mediaMetadata.extras
val currentMediaType = currentExtras?.getString("type")
if (currentMediaType != Constants.MEDIA_TYPE_RADIO) return@post
// Check if we already have metadata from embedded sources (ICY, ID3, etc.)
// HTTP headers are used as fallback when embedded metadata is not available
val hasEmbeddedMetadata = !currentItemNow.mediaMetadata.artist.isNullOrBlank() ||
!currentItemNow.mediaMetadata.title.isNullOrBlank() ||
(currentExtras != null && !currentExtras.getString("radioArtist").isNullOrBlank()) ||
(currentExtras != null && !currentExtras.getString("radioTitle").isNullOrBlank())
// Only use HTTP header metadata if we don't have embedded metadata
// This preserves the original way while adding HTTP header support as fallback
if (!hasEmbeddedMetadata) {
Log.d(javaClass.toString(), "Updating radio metadata from HTTP headers - Artist: $artist, Title: $title")
lastRadioArtist = artist
lastRadioTitle = title
val metadataBuilder = currentItemNow.mediaMetadata.buildUpon()
val newExtras = if (currentExtras != null) {
Bundle(currentExtras)
} else {
Bundle()
}
// Set artist and title in MediaMetadata
// The UI will read these and display "Artist - Title" in the main title label
artist?.let {
metadataBuilder.setArtist(it)
newExtras.putString("radioArtist", it)
}
title?.let {
metadataBuilder.setTitle(it)
newExtras.putString("radioTitle", it)
}
// Preserve station name separately (shown in artist label)
if (!newExtras.containsKey("stationName")) {
val stationName = currentExtras?.getString("stationName")
?: currentItemNow.mediaMetadata.title?.toString()
stationName?.let { newExtras.putString("stationName", it) }
}
metadataBuilder.setExtras(newExtras)
val updatedItem = currentItemNow.buildUpon()
.setMediaMetadata(metadataBuilder.build())
.build()
(player as? ExoPlayer)?.let { exo ->
// replaceMediaItem triggers onMediaMetadataChanged in UI listeners
// This will update the player display automatically
exo.replaceMediaItem(currentIndex, updatedItem)
updateWidget(exo)
Log.d(javaClass.toString(), "Radio metadata updated in player")
}
} else {
Log.d(javaClass.toString(), "Skipping HTTP header metadata - embedded metadata already exists")
}
}
}
private fun attachEqualizerIfPossible(audioSessionId: Int): Boolean {
if (audioSessionId == 0 || audioSessionId == -1) return false
val attached = equalizerManager.attachToSession(audioSessionId)
@@ -587,4 +937,5 @@ open class BaseMediaService : MediaLibraryService() {
}
private const val WIDGET_UPDATE_INTERVAL_MS = 1000L
private const val RADIO_HEADER_CHECK_INTERVAL_SECONDS = 10L

View File

@@ -61,13 +61,47 @@ public class TrackInfoDialog extends DialogFragment {
private void setTrackInfo() {
genreLink = null;
yearLink = null;
bind.trakTitleInfoTextView.setText(mediaMetadata.title);
bind.trakArtistInfoTextView.setText(
mediaMetadata.artist != null
? mediaMetadata.artist
: mediaMetadata.extras != null && Objects.equals(mediaMetadata.extras.getString("type"), Constants.MEDIA_TYPE_RADIO)
? mediaMetadata.extras.getString("uri", getString(R.string.label_placeholder))
: "");
String type = mediaMetadata.extras != null ? mediaMetadata.extras.getString("type") : null;
boolean isRadio = Objects.equals(type, Constants.MEDIA_TYPE_RADIO);
if (isRadio) {
// For radio: show "Artist - Title" or just title in header, station name as artist
String stationName = mediaMetadata.extras != null
? mediaMetadata.extras.getString("stationName",
mediaMetadata.title != null ? String.valueOf(mediaMetadata.title) : "")
: mediaMetadata.title != null ? String.valueOf(mediaMetadata.title) : "";
String artist = mediaMetadata.artist != null
? String.valueOf(mediaMetadata.artist)
: mediaMetadata.extras != null
? mediaMetadata.extras.getString("radioArtist", "")
: "";
String title = mediaMetadata.title != null
? String.valueOf(mediaMetadata.title)
: mediaMetadata.extras != null
? mediaMetadata.extras.getString("radioTitle", "")
: "";
String mainTitle;
if (!android.text.TextUtils.isEmpty(artist) && !android.text.TextUtils.isEmpty(title)) {
mainTitle = artist + " - " + title;
} else if (!android.text.TextUtils.isEmpty(title)) {
mainTitle = title;
} else {
mainTitle = stationName;
}
bind.trakTitleInfoTextView.setText(mainTitle);
bind.trakArtistInfoTextView.setText(stationName);
} else {
bind.trakTitleInfoTextView.setText(mediaMetadata.title);
bind.trakArtistInfoTextView.setText(
mediaMetadata.artist != null
? mediaMetadata.artist
: "");
}
if (mediaMetadata.extras != null) {
songLink = AssetLinkUtil.buildAssetLink(AssetLinkUtil.TYPE_SONG, mediaMetadata.extras.getString("id"));
@@ -90,6 +124,27 @@ public class TrackInfoDialog extends DialogFragment {
String artistValue = mediaMetadata.extras.getString("artist", getString(R.string.label_placeholder));
String genreValue = mediaMetadata.extras.getString("genre", getString(R.string.label_placeholder));
int yearValue = mediaMetadata.extras.getInt("year", 0);
// Handle radio-specific metadata
if (isRadio) {
String stationName = mediaMetadata.extras.getString("stationName", getString(R.string.label_placeholder));
String radioArtist = mediaMetadata.extras.getString("radioArtist", "");
String radioTitle = mediaMetadata.extras.getString("radioTitle", "");
// Show station name in station section
bind.stationInfoSector.setVisibility(android.view.View.VISIBLE);
bind.stationValueSector.setText(stationName);
// Use radio metadata for title/artist if available
if (!android.text.TextUtils.isEmpty(radioTitle)) {
titleValue = radioTitle;
}
if (!android.text.TextUtils.isEmpty(radioArtist)) {
artistValue = radioArtist;
}
} else {
bind.stationInfoSector.setVisibility(android.view.View.GONE);
}
if (genreLink == null && genreValue != null && !genreValue.isEmpty() && !getString(R.string.label_placeholder).contentEquals(genreValue)) {
genreLink = AssetLinkUtil.buildAssetLink(AssetLinkUtil.TYPE_GENRE, genreValue);

View File

@@ -213,12 +213,52 @@ public class PlayerControllerFragment extends Fragment {
}
private void setMetadata(MediaMetadata mediaMetadata) {
String type = mediaMetadata.extras != null ? mediaMetadata.extras.getString("type") : null;
if (Objects.equals(type, Constants.MEDIA_TYPE_RADIO)) {
String stationName = mediaMetadata.extras != null
? mediaMetadata.extras.getString("stationName",
mediaMetadata.title != null ? String.valueOf(mediaMetadata.title) : "")
: mediaMetadata.title != null ? String.valueOf(mediaMetadata.title) : "";
String artist = mediaMetadata.artist != null
? String.valueOf(mediaMetadata.artist)
: mediaMetadata.extras != null
? mediaMetadata.extras.getString("radioArtist", "")
: "";
String title = mediaMetadata.title != null
? String.valueOf(mediaMetadata.title)
: mediaMetadata.extras != null
? mediaMetadata.extras.getString("radioTitle", "")
: "";
String mainTitle;
if (!TextUtils.isEmpty(artist) && !TextUtils.isEmpty(title)) {
mainTitle = artist + " - " + title;
} else if (!TextUtils.isEmpty(title)) {
mainTitle = title;
} else {
mainTitle = stationName;
}
playerMediaTitleLabel.setText(mainTitle);
playerArtistNameLabel.setText(stationName);
playerMediaTitleLabel.setSelected(true);
playerArtistNameLabel.setSelected(true);
playerMediaTitleLabel.setVisibility(!TextUtils.isEmpty(mainTitle) ? View.VISIBLE : View.GONE);
playerArtistNameLabel.setVisibility(!TextUtils.isEmpty(stationName) ? View.VISIBLE : View.GONE);
updateAssetLinkChips(mediaMetadata);
return;
}
playerMediaTitleLabel.setText(String.valueOf(mediaMetadata.title));
playerArtistNameLabel.setText(
mediaMetadata.artist != null
? String.valueOf(mediaMetadata.artist)
: mediaMetadata.extras != null && Objects.equals(mediaMetadata.extras.getString("type"), Constants.MEDIA_TYPE_RADIO)
? mediaMetadata.extras.getString("uri", getString(R.string.label_placeholder))
: "");
playerMediaTitleLabel.setSelected(true);

View File

@@ -209,6 +209,7 @@ public class MappingUtil {
Bundle bundle = new Bundle();
bundle.putString("id", internetRadioStation.getId());
bundle.putString("title", internetRadioStation.getName());
bundle.putString("stationName", internetRadioStation.getName());
bundle.putString("uri", uri.toString());
bundle.putString("type", Constants.MEDIA_TYPE_RADIO);
@@ -217,6 +218,7 @@ public class MappingUtil {
.setMediaMetadata(
new MediaMetadata.Builder()
.setTitle(internetRadioStation.getName())
.setMediaType(MediaMetadata.MEDIA_TYPE_RADIO_STATION)
.setExtras(bundle)
.setIsBrowsable(false)
.setIsPlayable(true)

View File

@@ -131,6 +131,33 @@
android:text="@string/label_placeholder" />
</LinearLayout>
<View
style="@style/Divider"
android:layout_gravity="center_vertical"
android:layout_marginVertical="8dp" />
<LinearLayout
android:id="@+id/station_info_sector"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone">
<TextView
android:id="@+id/station_key_sector"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="4"
android:paddingEnd="8dp"
android:text="@string/track_info_station" />
<TextView
android:id="@+id/station_value_sector"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="7"
android:text="@string/label_placeholder" />
</LinearLayout>
<View
style="@style/Divider"
android:layout_gravity="center_vertical"

View File

@@ -499,6 +499,7 @@
<string name="track_info_summary_transcoding_codec">The application will request the server to transcode the file. The requested codec by the user is %1$s, while the bitrate will be the same as the source file. The potential transcoding of the file into the chosen format is dependent on the server, as it may or may not support the operation.</string>
<string name="track_info_title">Title</string>
<string name="track_info_track_number">Track number</string>
<string name="track_info_station">Station</string>
<string name="track_info_transcoded_content_type">Transcoded content type</string>
<string name="track_info_transcoded_suffix">Transcoded suffix</string>
<string name="track_info_year">Year</string>