feat: radio metadata (#352)

* 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.

* fix crashing issue

* radio bob metadata works now. fix crashing issue

* Fixing unchecked operation warnings in SongHorizontalAdapter.java.

* optimizing a bit and better format for notification

* removed xml files affecting build and enviroment

* removed xml files affecting build and enviroment

* fix ui internet radio bottomview

* Revert "fix ui internet radio bottomview"

This reverts commit c237ed451ff436f4be964084b144d94f1ad37668.

* rebased to upstream/development and fixed metadata to show up for radio after the rebase

* misc.xml restored

* Apply suggestion from @eddyizm

---------

Co-authored-by: eddyizm <wtfisup@hotmail.com>
Co-authored-by: eddyizm <eddyizm@gmail.com>
This commit is contained in:
TrackArcher 2026-02-15 17:03:00 +01:00 committed by GitHub
parent dbd32baa12
commit 661346ca3a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 479 additions and 27 deletions

View file

@ -24,6 +24,9 @@ import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.ShuffleOrder.DefaultShuffleOrder import androidx.media3.exoplayer.source.ShuffleOrder.DefaultShuffleOrder
import androidx.media3.session.* import androidx.media3.session.*
import androidx.media3.session.MediaSession.ControllerInfo 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.R
import com.cappielloantonio.tempo.repository.QueueRepository import com.cappielloantonio.tempo.repository.QueueRepository
import com.cappielloantonio.tempo.ui.activity.MainActivity import com.cappielloantonio.tempo.ui.activity.MainActivity
@ -32,6 +35,12 @@ import com.cappielloantonio.tempo.widget.WidgetUpdateManager
import com.google.common.collect.ImmutableList import com.google.common.collect.ImmutableList
import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture 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
private const val TAG = "BaseMediaService" private const val TAG = "BaseMediaService"
@ -70,6 +79,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() private val binder = LocalBinder()
open fun playerInitHook() { open fun playerInitHook() {
@ -120,6 +136,9 @@ open class BaseMediaService : MediaLibraryService() {
updateWidget(player) updateWidget(player)
} }
private var lastRadioArtist: String? = null
private var lastRadioTitle: String? = null
fun initializePlayerListener(player: Player) { fun initializePlayerListener(player: Player) {
player.addListener(object : Player.Listener { player.addListener(object : Player.Listener {
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
@ -129,6 +148,16 @@ open class BaseMediaService : MediaLibraryService() {
if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK || reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) { if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK || reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) {
MediaManager.setLastPlayedTimestamp(mediaItem) 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) updateWidget(player)
} }
@ -170,6 +199,96 @@ 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
if (extras?.getString("type") != Constants.MEDIA_TYPE_RADIO) return
var artist: String? = null
var title: String? = null
// Extract metadata from ICY/ID3/Vorbis
for (i in 0 until metadata.length()) {
when (val entry = metadata[i]) {
is IcyInfo -> {
entry.title?.let { icyTitle ->
val parts = icyTitle.split(" - ", limit = 2)
if (parts.size == 2) {
artist = parts[0].trim().ifEmpty { null }
title = parts[1].trim().ifEmpty { null }
} else {
title = icyTitle.trim().ifEmpty { null }
}
}
}
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 -> {
@Suppress("DEPRECATION")
val value = entry.value
when (entry.key) {
"ARTIST" -> if (!value.isNullOrBlank()) artist = value
"TITLE" -> if (!value.isNullOrBlank()) title = value
}
}
}
}
if (artist.isNullOrBlank() && title.isNullOrBlank()) return
if (artist == lastRadioArtist && title == lastRadioTitle) return // Deduplicate
lastRadioArtist = artist
lastRadioTitle = title
// Stop HTTP header checks since we have embedded metadata
stopRadioHeaderChecks()
val currentIndex = player.currentMediaItemIndex
if (currentIndex == C.INDEX_UNSET) return
val metadataBuilder = currentItem.mediaMetadata.buildUpon()
val newExtras = Bundle(extras ?: Bundle())
// Store individual values in extras for UI
artist?.let { newExtras.putString("radioArtist", it) }
title?.let { newExtras.putString("radioTitle", it) }
// Get station name (preserve if already set)
val stationName = extras?.getString("stationName")
?: currentItem.mediaMetadata.title?.toString()
?: ""
if (stationName.isNotBlank()) {
newExtras.putString("stationName", stationName)
}
// Format for notification/player: Title = "Artist - Song", Artist = "Station Name"
val formattedTitle = when {
!artist.isNullOrBlank() && !title.isNullOrBlank() -> "$artist - $title"
!title.isNullOrBlank() -> title
!artist.isNullOrBlank() -> artist
else -> stationName
}
metadataBuilder.setTitle(formattedTitle)
if (stationName.isNotBlank()) {
metadataBuilder.setArtist(stationName)
}
(player as? ExoPlayer)?.let { exo ->
exo.replaceMediaItem(currentIndex, currentItem.buildUpon()
.setMediaMetadata(metadataBuilder.setExtras(newExtras).build())
.build())
updateWidget(exo)
}
}
override fun onIsPlayingChanged(isPlaying: Boolean) { override fun onIsPlayingChanged(isPlaying: Boolean) {
Log.d(TAG, "onIsPlayingChanged " + player.currentMediaItemIndex) Log.d(TAG, "onIsPlayingChanged " + player.currentMediaItemIndex)
if (!isPlaying) { if (!isPlaying) {
@ -182,8 +301,10 @@ open class BaseMediaService : MediaLibraryService() {
} }
if (isPlaying) { if (isPlaying) {
scheduleWidgetUpdates() scheduleWidgetUpdates()
scheduleRadioHeaderChecks()
} else { } else {
stopWidgetUpdates() stopWidgetUpdates()
stopRadioHeaderChecks()
} }
updateWidget(player) updateWidget(player)
} }
@ -287,6 +408,8 @@ open class BaseMediaService : MediaLibraryService() {
releaseNetworkCallback() releaseNetworkCallback()
equalizerManager.release() equalizerManager.release()
stopWidgetUpdates() stopWidgetUpdates()
stopRadioHeaderChecks()
radioHeaderCheckExecutor.shutdown()
releasePlayers() releasePlayers()
mediaLibrarySession.release() mediaLibrarySession.release()
super.onDestroy() super.onDestroy()
@ -405,6 +528,148 @@ open class BaseMediaService : MediaLibraryService() {
widgetUpdateScheduled = false 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
// Skip if we already have embedded metadata (ICY/ID3) - HTTP headers are only fallback
val hasEmbeddedMetadata = !currentItem.mediaMetadata.artist.isNullOrBlank() ||
!currentItem.mediaMetadata.title.isNullOrBlank() ||
(extras != null && !extras.getString("radioArtist").isNullOrBlank()) ||
(extras != null && !extras.getString("radioTitle").isNullOrBlank())
if (hasEmbeddedMetadata) 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 ?: return
// Only try HEAD request (lightweight) - skip GET fallback as it's unreliable
connection.requestMethod = "HEAD"
connection.setRequestProperty("Icy-MetaData", "1")
connection.setRequestProperty("User-Agent", "Tempus/1.0")
connection.connectTimeout = 3000 // Reduced timeout
connection.readTimeout = 3000
connection.connect()
if (connection.responseCode >= 400) {
connection.disconnect()
return
}
// Check for metadata in HTTP headers
val streamTitle = connection.getHeaderField("icy-name")
?: connection.getHeaderField("StreamTitle")
?: connection.getHeaderField("stream-title")
connection.disconnect()
if (!streamTitle.isNullOrBlank()) {
processStreamTitle(streamTitle, player)
}
} catch (e: Exception) {
// Silently fail - this is a fallback mechanism, ICY metadata is primary
}
}
private fun processStreamTitle(streamTitle: String, player: Player) {
// Parse "Artist - Title" format
val parts = streamTitle.split(" - ", limit = 2)
val artist = if (parts.size == 2) parts[0].trim().ifEmpty { null } else null
val title = if (parts.size == 2) parts[1].trim().ifEmpty { null } else streamTitle.trim().ifEmpty { null }
if (artist.isNullOrBlank() && title.isNullOrBlank()) return
if (artist == lastRadioArtist && title == lastRadioTitle) return // Deduplicate
lastRadioArtist = artist
lastRadioTitle = title
// 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
if (currentExtras?.getString("type") != Constants.MEDIA_TYPE_RADIO) return@post
// Double-check we still don't have embedded metadata (might have arrived since check)
val hasEmbeddedMetadata = !currentItemNow.mediaMetadata.artist.isNullOrBlank() ||
!currentItemNow.mediaMetadata.title.isNullOrBlank() ||
(currentExtras != null && !currentExtras.getString("radioArtist").isNullOrBlank()) ||
(currentExtras != null && !currentExtras.getString("radioTitle").isNullOrBlank())
if (hasEmbeddedMetadata) return@post
val metadataBuilder = currentItemNow.mediaMetadata.buildUpon()
val newExtras = Bundle(currentExtras ?: Bundle())
// Store individual values in extras for UI
artist?.let { newExtras.putString("radioArtist", it) }
title?.let { newExtras.putString("radioTitle", it) }
// Get station name (preserve if already set)
val stationName = currentExtras?.getString("stationName")
?: currentItemNow.mediaMetadata.title?.toString()
?: ""
if (stationName.isNotBlank()) {
newExtras.putString("stationName", stationName)
}
// Format for notification/player: Title = "Artist - Song", Artist = "Station Name"
val formattedTitle = when {
!artist.isNullOrBlank() && !title.isNullOrBlank() -> "$artist - $title"
!title.isNullOrBlank() -> title
!artist.isNullOrBlank() -> artist
else -> stationName
}
metadataBuilder.setTitle(formattedTitle)
if (stationName.isNotBlank()) {
metadataBuilder.setArtist(stationName)
}
metadataBuilder.setExtras(newExtras)
(player as? ExoPlayer)?.let { exo ->
exo.replaceMediaItem(currentIndex, currentItemNow.buildUpon()
.setMediaMetadata(metadataBuilder.build())
.build())
updateWidget(exo)
}
}
}
private fun attachEqualizerIfPossible(audioSessionId: Int): Boolean { private fun attachEqualizerIfPossible(audioSessionId: Int): Boolean {
if (audioSessionId == 0 || audioSessionId == -1) return false if (audioSessionId == 0 || audioSessionId == -1) return false
val attached = equalizerManager.attachToSession(audioSessionId) val attached = equalizerManager.attachToSession(audioSessionId)
@ -595,4 +860,5 @@ open class BaseMediaService : MediaLibraryService() {
} }
private const val WIDGET_UPDATE_INTERVAL_MS = 1000L private const val WIDGET_UPDATE_INTERVAL_MS = 1000L
private const val RADIO_HEADER_CHECK_INTERVAL_SECONDS = 30L // Reduced frequency - only fallback when ICY fails

View file

@ -61,13 +61,47 @@ public class TrackInfoDialog extends DialogFragment {
private void setTrackInfo() { private void setTrackInfo() {
genreLink = null; genreLink = null;
yearLink = null; yearLink = null;
String type = mediaMetadata.extras != null ? mediaMetadata.extras.getString("type") : null;
boolean isRadio = Objects.equals(type, Constants.MEDIA_TYPE_RADIO);
if (isRadio) {
// For radio: always read from extras first (radioArtist, radioTitle, stationName)
// MediaMetadata.title/artist are formatted for notification
String stationName = mediaMetadata.extras != null
? mediaMetadata.extras.getString("stationName",
mediaMetadata.artist != null ? String.valueOf(mediaMetadata.artist) : "")
: mediaMetadata.artist != null ? String.valueOf(mediaMetadata.artist) : "";
String artist = mediaMetadata.extras != null
? mediaMetadata.extras.getString("radioArtist", "")
: "";
String title = mediaMetadata.extras != null
? mediaMetadata.extras.getString("radioTitle", "")
: "";
// Format: "Artist - Song" or fallback to title or station name
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 if (!android.text.TextUtils.isEmpty(artist)) {
mainTitle = artist;
} else {
mainTitle = stationName;
}
bind.trakTitleInfoTextView.setText(mainTitle);
bind.trakArtistInfoTextView.setText(stationName);
} else {
bind.trakTitleInfoTextView.setText(mediaMetadata.title); bind.trakTitleInfoTextView.setText(mediaMetadata.title);
bind.trakArtistInfoTextView.setText( bind.trakArtistInfoTextView.setText(
mediaMetadata.artist != null mediaMetadata.artist != null
? mediaMetadata.artist ? mediaMetadata.artist
: mediaMetadata.extras != null && Objects.equals(mediaMetadata.extras.getString("type"), Constants.MEDIA_TYPE_RADIO)
? mediaMetadata.extras.getString("uri", getString(R.string.label_placeholder))
: ""); : "");
}
if (mediaMetadata.extras != null) { if (mediaMetadata.extras != null) {
songLink = AssetLinkUtil.buildAssetLink(AssetLinkUtil.TYPE_SONG, mediaMetadata.extras.getString("id")); songLink = AssetLinkUtil.buildAssetLink(AssetLinkUtil.TYPE_SONG, mediaMetadata.extras.getString("id"));
@ -91,6 +125,27 @@ public class TrackInfoDialog extends DialogFragment {
String genreValue = mediaMetadata.extras.getString("genre", getString(R.string.label_placeholder)); String genreValue = mediaMetadata.extras.getString("genre", getString(R.string.label_placeholder));
int yearValue = mediaMetadata.extras.getInt("year", 0); 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)) { if (genreLink == null && genreValue != null && !genreValue.isEmpty() && !getString(R.string.label_placeholder).contentEquals(genreValue)) {
genreLink = AssetLinkUtil.buildAssetLink(AssetLinkUtil.TYPE_GENRE, genreValue); genreLink = AssetLinkUtil.buildAssetLink(AssetLinkUtil.TYPE_GENRE, genreValue);
} }

View file

@ -3,6 +3,7 @@ package com.cappielloantonio.tempo.ui.fragment;
import android.content.ComponentName; import android.content.ComponentName;
import android.os.Bundle; import android.os.Bundle;
import android.os.Handler; import android.os.Handler;
import android.text.TextUtils;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
@ -173,25 +174,54 @@ public class PlayerBottomSheetFragment extends Fragment {
playerBottomSheetViewModel.setLiveArtist(getViewLifecycleOwner(), mediaMetadata.extras.getString("type"), mediaMetadata.extras.getString("artistId")); playerBottomSheetViewModel.setLiveArtist(getViewLifecycleOwner(), mediaMetadata.extras.getString("type"), mediaMetadata.extras.getString("artistId"));
playerBottomSheetViewModel.setLiveDescription(mediaMetadata.extras.getString("description", null)); playerBottomSheetViewModel.setLiveDescription(mediaMetadata.extras.getString("description", null));
String type = mediaMetadata.extras.getString("type");
if (Objects.equals(type, Constants.MEDIA_TYPE_RADIO)) {
// For radio: keep header consistent with full player
String stationName = mediaMetadata.extras.getString(
"stationName",
mediaMetadata.artist != null ? String.valueOf(mediaMetadata.artist) : ""
);
String artist = mediaMetadata.extras.getString("radioArtist", "");
String title = mediaMetadata.extras.getString("radioTitle", "");
String mainTitle;
if (!TextUtils.isEmpty(artist) && !TextUtils.isEmpty(title)) {
mainTitle = artist + " - " + title;
} else if (!TextUtils.isEmpty(title)) {
mainTitle = title;
} else if (!TextUtils.isEmpty(artist)) {
mainTitle = artist;
} else {
mainTitle = stationName;
}
bind.playerHeaderLayout.playerHeaderMediaTitleLabel.setText(mainTitle);
bind.playerHeaderLayout.playerHeaderMediaArtistLabel.setText(stationName);
bind.playerHeaderLayout.playerHeaderMediaTitleLabel.setVisibility(!TextUtils.isEmpty(mainTitle) ? View.VISIBLE : View.GONE);
bind.playerHeaderLayout.playerHeaderMediaArtistLabel.setVisibility(!TextUtils.isEmpty(stationName) ? View.VISIBLE : View.GONE);
} else {
// Default (music, podcast, etc.)
bind.playerHeaderLayout.playerHeaderMediaTitleLabel.setText(mediaMetadata.extras.getString("title")); bind.playerHeaderLayout.playerHeaderMediaTitleLabel.setText(mediaMetadata.extras.getString("title"));
bind.playerHeaderLayout.playerHeaderMediaArtistLabel.setText( bind.playerHeaderLayout.playerHeaderMediaArtistLabel.setText(
mediaMetadata.artist != null mediaMetadata.artist != null
? mediaMetadata.artist ? mediaMetadata.artist
: Objects.equals(mediaMetadata.extras.getString("type"), Constants.MEDIA_TYPE_RADIO) : ""
? mediaMetadata.extras.getString("uri", getString(R.string.label_placeholder)) );
: "");
bind.playerHeaderLayout.playerHeaderMediaTitleLabel.setVisibility(mediaMetadata.extras.getString("title") != null && !Objects.equals(mediaMetadata.extras.getString("title"), "") ? View.VISIBLE : View.GONE);
bind.playerHeaderLayout.playerHeaderMediaArtistLabel.setVisibility(
mediaMetadata.extras.getString("artist") != null && !Objects.equals(mediaMetadata.extras.getString("artist"), "")
? View.VISIBLE
: View.GONE);
}
CustomGlideRequest.Builder CustomGlideRequest.Builder
.from(requireContext(), mediaMetadata.extras.getString("coverArtId"), CustomGlideRequest.ResourceType.Song) .from(requireContext(), mediaMetadata.extras.getString("coverArtId"), CustomGlideRequest.ResourceType.Song)
.build() .build()
.into(bind.playerHeaderLayout.playerHeaderMediaCoverImage); .into(bind.playerHeaderLayout.playerHeaderMediaCoverImage);
bind.playerHeaderLayout.playerHeaderMediaTitleLabel.setVisibility(mediaMetadata.extras.getString("title") != null && !Objects.equals(mediaMetadata.extras.getString("title"), "") ? View.VISIBLE : View.GONE);
bind.playerHeaderLayout.playerHeaderMediaArtistLabel.setVisibility(
(mediaMetadata.extras.getString("artist") != null && !Objects.equals(mediaMetadata.extras.getString("artist"), ""))
|| (Objects.equals(mediaMetadata.extras.getString("type"), Constants.MEDIA_TYPE_RADIO) && mediaMetadata.extras.getString("uri") != null)
? View.VISIBLE
: View.GONE);
} }
} }

View file

@ -215,12 +215,53 @@ public class PlayerControllerFragment extends Fragment {
} }
private void setMetadata(MediaMetadata mediaMetadata) { private void setMetadata(MediaMetadata mediaMetadata) {
String type = mediaMetadata.extras != null ? mediaMetadata.extras.getString("type") : null;
if (Objects.equals(type, Constants.MEDIA_TYPE_RADIO)) {
// For radio: always read from extras first (radioArtist, radioTitle, stationName)
// MediaMetadata.title/artist are formatted for notification
String stationName = mediaMetadata.extras != null
? mediaMetadata.extras.getString("stationName",
mediaMetadata.artist != null ? String.valueOf(mediaMetadata.artist) : "")
: mediaMetadata.artist != null ? String.valueOf(mediaMetadata.artist) : "";
String artist = mediaMetadata.extras != null
? mediaMetadata.extras.getString("radioArtist", "")
: "";
String title = mediaMetadata.extras != null
? mediaMetadata.extras.getString("radioTitle", "")
: "";
// Format: "Artist - Song" or fallback to title or station name
String mainTitle;
if (!TextUtils.isEmpty(artist) && !TextUtils.isEmpty(title)) {
mainTitle = artist + " - " + title;
} else if (!TextUtils.isEmpty(title)) {
mainTitle = title;
} else if (!TextUtils.isEmpty(artist)) {
mainTitle = artist;
} 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)); playerMediaTitleLabel.setText(String.valueOf(mediaMetadata.title));
playerArtistNameLabel.setText( playerArtistNameLabel.setText(
mediaMetadata.artist != null mediaMetadata.artist != null
? String.valueOf(mediaMetadata.artist) ? 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); playerMediaTitleLabel.setSelected(true);

View file

@ -29,6 +29,8 @@ import java.net.CookieHandler;
import java.net.CookieManager; import java.net.CookieManager;
import java.net.CookiePolicy; import java.net.CookiePolicy;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
@UnstableApi @UnstableApi
@ -78,12 +80,33 @@ public final class DownloadUtil {
return httpDataSourceFactory; return httpDataSourceFactory;
} }
public static synchronized DataSource.Factory getHttpDataSourceFactoryForRadio() {
CookieManager cookieManager = new CookieManager();
cookieManager.setCookiePolicy(CookiePolicy.ACCEPT_ORIGINAL_SERVER);
CookieHandler.setDefault(cookieManager);
// Create a factory with ICY metadata support for radio streams
Map<String, String> defaultRequestProperties = new HashMap<>();
defaultRequestProperties.put("Icy-MetaData", "1");
defaultRequestProperties.put("User-Agent", "Tempus/1.0");
return new DefaultHttpDataSource
.Factory()
.setAllowCrossProtocolRedirects(true)
.setDefaultRequestProperties(defaultRequestProperties);
}
public static synchronized DataSource.Factory getUpstreamDataSourceFactory(Context context) { public static synchronized DataSource.Factory getUpstreamDataSourceFactory(Context context) {
DefaultDataSource.Factory upstreamFactory = new DefaultDataSource.Factory(context, getHttpDataSourceFactory()); DefaultDataSource.Factory upstreamFactory = new DefaultDataSource.Factory(context, getHttpDataSourceFactory());
dataSourceFactory = buildReadOnlyCacheDataSource(upstreamFactory, getDownloadCache(context)); dataSourceFactory = buildReadOnlyCacheDataSource(upstreamFactory, getDownloadCache(context));
return dataSourceFactory; return dataSourceFactory;
} }
public static synchronized DataSource.Factory getUpstreamDataSourceFactoryForRadio(Context context) {
DefaultDataSource.Factory upstreamFactory = new DefaultDataSource.Factory(context, getHttpDataSourceFactoryForRadio());
return buildReadOnlyCacheDataSource(upstreamFactory, getDownloadCache(context));
}
public static synchronized DataSource.Factory getCacheDataSourceFactory(Context context) { public static synchronized DataSource.Factory getCacheDataSourceFactory(Context context) {
CacheDataSource.Factory streamCacheFactory = new CacheDataSource.Factory() CacheDataSource.Factory streamCacheFactory = new CacheDataSource.Factory()
.setCache(getStreamingCache(context)) .setCache(getStreamingCache(context))

View file

@ -3,7 +3,6 @@ package com.cappielloantonio.tempo.util
import android.content.Context import android.content.Context
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.MimeTypes import androidx.media3.common.MimeTypes
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSource import androidx.media3.datasource.DataSource
@ -21,10 +20,15 @@ class DynamicMediaSourceFactory(
) : MediaSource.Factory { ) : MediaSource.Factory {
override fun createMediaSource(mediaItem: MediaItem): MediaSource { override fun createMediaSource(mediaItem: MediaItem): MediaSource {
val mediaId = mediaItem.mediaId // Detect radio streams in a backwards-compatible way.
// Older Tempus versions tagged radio items via MediaMetadata extras
// (`type == MEDIA_TYPE_RADIO`), while newer upstream changes use an
// "ir-" mediaId prefix. Support BOTH so radio works after rebases.
val mediaType = mediaItem.mediaMetadata.extras?.getString("type", "")
val isRadio = mediaType == Constants.MEDIA_TYPE_RADIO || mediaItem.mediaId.startsWith("ir-")
val streamingCacheSize = Preferences.getStreamingCacheSize() val streamingCacheSize = Preferences.getStreamingCacheSize()
val bypassCache = mediaId.startsWith("ir-") val bypassCache = isRadio
val useUpstream = when { val useUpstream = when {
streamingCacheSize.toInt() == 0 -> true streamingCacheSize.toInt() == 0 -> true
@ -33,7 +37,10 @@ class DynamicMediaSourceFactory(
else -> true else -> true
} }
val dataSourceFactory: DataSource.Factory = if (useUpstream) { val dataSourceFactory: DataSource.Factory = if (bypassCache) {
// For radio streams, use a DataSourceFactory with ICY metadata support
DownloadUtil.getUpstreamDataSourceFactoryForRadio(context)
} else if (useUpstream) {
DownloadUtil.getUpstreamDataSourceFactory(context) DownloadUtil.getUpstreamDataSourceFactory(context)
} else { } else {
DownloadUtil.getCacheDataSourceFactory(context) DownloadUtil.getCacheDataSourceFactory(context)

View file

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

View file

@ -131,6 +131,33 @@
android:text="@string/label_placeholder" /> android:text="@string/label_placeholder" />
</LinearLayout> </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 <View
style="@style/Divider" style="@style/Divider"
android:layout_gravity="center_vertical" android:layout_gravity="center_vertical"

View file

@ -517,6 +517,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_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_title">Title</string>
<string name="track_info_track_number">Track number</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_content_type">Transcoded content type</string>
<string name="track_info_transcoded_suffix">Transcoded suffix</string> <string name="track_info_transcoded_suffix">Transcoded suffix</string>
<string name="track_info_year">Year</string> <string name="track_info_year">Year</string>