diff --git a/app/src/main/java/com/cappielloantonio/tempo/service/BaseMediaService.kt b/app/src/main/java/com/cappielloantonio/tempo/service/BaseMediaService.kt index b21bf75d..653507ee 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/service/BaseMediaService.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/service/BaseMediaService.kt @@ -24,6 +24,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 @@ -32,6 +35,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 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() open fun playerInitHook() { @@ -120,6 +136,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) { @@ -129,6 +148,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) } @@ -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) { Log.d(TAG, "onIsPlayingChanged " + player.currentMediaItemIndex) if (!isPlaying) { @@ -182,8 +301,10 @@ open class BaseMediaService : MediaLibraryService() { } if (isPlaying) { scheduleWidgetUpdates() + scheduleRadioHeaderChecks() } else { stopWidgetUpdates() + stopRadioHeaderChecks() } updateWidget(player) } @@ -287,6 +408,8 @@ open class BaseMediaService : MediaLibraryService() { releaseNetworkCallback() equalizerManager.release() stopWidgetUpdates() + stopRadioHeaderChecks() + radioHeaderCheckExecutor.shutdown() releasePlayers() mediaLibrarySession.release() super.onDestroy() @@ -405,6 +528,148 @@ 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 + + // 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 { if (audioSessionId == 0 || audioSessionId == -1) return false val attached = equalizerManager.attachToSession(audioSessionId) @@ -595,4 +860,5 @@ open class BaseMediaService : MediaLibraryService() { } private const val WIDGET_UPDATE_INTERVAL_MS = 1000L +private const val RADIO_HEADER_CHECK_INTERVAL_SECONDS = 30L // Reduced frequency - only fallback when ICY fails diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/TrackInfoDialog.java b/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/TrackInfoDialog.java index e6b91f01..72368555 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/TrackInfoDialog.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/TrackInfoDialog.java @@ -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: 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.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); diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerBottomSheetFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerBottomSheetFragment.java index e2bca343..52e796ae 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerBottomSheetFragment.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerBottomSheetFragment.java @@ -3,6 +3,7 @@ package com.cappielloantonio.tempo.ui.fragment; import android.content.ComponentName; import android.os.Bundle; import android.os.Handler; +import android.text.TextUtils; import android.view.LayoutInflater; import android.view.View; 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.setLiveDescription(mediaMetadata.extras.getString("description", null)); - bind.playerHeaderLayout.playerHeaderMediaTitleLabel.setText(mediaMetadata.extras.getString("title")); - bind.playerHeaderLayout.playerHeaderMediaArtistLabel.setText( - mediaMetadata.artist != null - ? mediaMetadata.artist - : Objects.equals(mediaMetadata.extras.getString("type"), Constants.MEDIA_TYPE_RADIO) - ? mediaMetadata.extras.getString("uri", getString(R.string.label_placeholder)) - : ""); + 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.playerHeaderMediaArtistLabel.setText( + mediaMetadata.artist != null + ? mediaMetadata.artist + : "" + ); + + 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 .from(requireContext(), mediaMetadata.extras.getString("coverArtId"), CustomGlideRequest.ResourceType.Song) .build() .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); } } diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerControllerFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerControllerFragment.java index 7f99b0d9..3bba6183 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerControllerFragment.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerControllerFragment.java @@ -215,12 +215,53 @@ 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)) { + // 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)); 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); diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/DownloadUtil.java b/app/src/main/java/com/cappielloantonio/tempo/util/DownloadUtil.java index 6df73eb6..7892455f 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/util/DownloadUtil.java +++ b/app/src/main/java/com/cappielloantonio/tempo/util/DownloadUtil.java @@ -29,6 +29,8 @@ import java.net.CookieHandler; import java.net.CookieManager; import java.net.CookiePolicy; import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; import java.util.concurrent.Executors; @UnstableApi @@ -78,12 +80,33 @@ public final class DownloadUtil { 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 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) { DefaultDataSource.Factory upstreamFactory = new DefaultDataSource.Factory(context, getHttpDataSourceFactory()); dataSourceFactory = buildReadOnlyCacheDataSource(upstreamFactory, getDownloadCache(context)); 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) { CacheDataSource.Factory streamCacheFactory = new CacheDataSource.Factory() .setCache(getStreamingCache(context)) diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/DynamicMediaSourceFactory.kt b/app/src/main/java/com/cappielloantonio/tempo/util/DynamicMediaSourceFactory.kt index fe962121..f4109550 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/util/DynamicMediaSourceFactory.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/util/DynamicMediaSourceFactory.kt @@ -3,7 +3,6 @@ package com.cappielloantonio.tempo.util import android.content.Context import androidx.media3.common.C import androidx.media3.common.MediaItem -import androidx.media3.common.MediaMetadata import androidx.media3.common.MimeTypes import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.DataSource @@ -21,10 +20,15 @@ class DynamicMediaSourceFactory( ) : MediaSource.Factory { 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 bypassCache = mediaId.startsWith("ir-") + val bypassCache = isRadio val useUpstream = when { streamingCacheSize.toInt() == 0 -> true @@ -33,7 +37,10 @@ class DynamicMediaSourceFactory( 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) } else { DownloadUtil.getCacheDataSourceFactory(context) diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/MappingUtil.java b/app/src/main/java/com/cappielloantonio/tempo/util/MappingUtil.java index 1cad9e36..f26ba207 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/util/MappingUtil.java +++ b/app/src/main/java/com/cappielloantonio/tempo/util/MappingUtil.java @@ -211,6 +211,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); @@ -219,6 +220,7 @@ public class MappingUtil { .setMediaMetadata( new MediaMetadata.Builder() .setTitle(internetRadioStation.getName()) + .setMediaType(MediaMetadata.MEDIA_TYPE_RADIO_STATION) .setExtras(bundle) .setIsBrowsable(false) .setIsPlayable(true) diff --git a/app/src/main/res/layout/dialog_track_info.xml b/app/src/main/res/layout/dialog_track_info.xml index ae72877f..4d3d0727 100644 --- a/app/src/main/res/layout/dialog_track_info.xml +++ b/app/src/main/res/layout/dialog_track_info.xml @@ -131,6 +131,33 @@ android:text="@string/label_placeholder" /> + + + + + + + + + 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. Title Track number + Station Transcoded content type Transcoded suffix Year