From 6afacfb747d1e1fab0f21cb7a9f07dca6e0b97e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Apr 2026 22:35:25 +0000 Subject: [PATCH] feat(playback): cache on-demand HLS videos in SimpleCache Previously any URL containing ".m3u8" was routed through the non-caching MediaSource factory under the assumption that HLS meant a live stream. That was correct for kind-30311 live events but wrong for on-demand NIP-71 videos with multi-rendition fMP4 segments: their .m4s files are immutable and a perfect fit for SimpleCache's byte-range caching, yet they were being re-downloaded on every scroll. Plumb an explicit `isLiveStream` flag from the caller (LiveActivity sets it to true; everything else keeps the default of false) down through GetMediaItem / MediaItemData and stash it in MediaItem.mediaMetadata extras. CustomMediaSourceFactory now reads that flag to decide whether to use the caching or non-caching factory, falling back to the old URL heuristic only if the flag is absent (e.g. a MediaItem built outside the MediaItemCache path). Also persist the flag through PiP's IntentExtras bundle and use the new constants in MediaSessionPool for the callback URI key. https://claude.ai/code/session_01W8yGieuEyMKeKY9vU9zjKH --- .../composable/LoadThumbAndThenVideoView.kt | 3 +++ .../service/playback/composable/VideoView.kt | 6 ++++- .../playback/composable/VideoViewInner.kt | 2 ++ .../composable/mediaitem/GetMediaItem.kt | 2 ++ .../composable/mediaitem/MediaItemCache.kt | 8 ++++++- .../composable/mediaitem/MediaItemData.kt | 4 ++++ .../service/playback/pip/IntentExtras.kt | 2 ++ .../playerPool/CustomMediaSourceFactory.kt | 22 +++++++++++++++++-- .../playback/playerPool/MediaSessionPool.kt | 3 ++- .../amethyst/ui/note/types/LiveActivity.kt | 1 + 10 files changed, 48 insertions(+), 5 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/LoadThumbAndThenVideoView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/LoadThumbAndThenVideoView.kt index d8f4f68d3..9bcabaf0d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/LoadThumbAndThenVideoView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/LoadThumbAndThenVideoView.kt @@ -41,6 +41,7 @@ fun LoadThumbAndThenVideoView( roundedCorner: Boolean, contentScale: ContentScale, nostrUriCallback: String? = null, + isLiveStream: Boolean = false, accountViewModel: AccountViewModel, onDialog: (() -> Unit)? = null, ) { @@ -75,6 +76,7 @@ fun LoadThumbAndThenVideoView( artworkUri = thumbUri, authorName = authorName, nostrUriCallback = nostrUriCallback, + isLiveStream = isLiveStream, accountViewModel = accountViewModel, onDialog = onDialog, ) @@ -89,6 +91,7 @@ fun LoadThumbAndThenVideoView( artworkUri = thumbUri, authorName = authorName, nostrUriCallback = nostrUriCallback, + isLiveStream = isLiveStream, accountViewModel = accountViewModel, onDialog = onDialog, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt index 0a219cd6d..885ecaaba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt @@ -69,6 +69,7 @@ fun VideoView( accountViewModel: AccountViewModel, alwaysShowVideo: Boolean = false, thumbhash: String? = null, + isLiveStream: Boolean = false, ) { val borderModifier = if (roundedCorner) { @@ -79,7 +80,7 @@ fun VideoView( Modifier } - VideoView(videoUri, mimeType, title, thumb, borderModifier, contentScale, waveform, artworkUri, authorName, dimensions, blurhash, nostrUriCallback, onDialog, alwaysShowVideo, accountViewModel = accountViewModel, thumbhash = thumbhash) + VideoView(videoUri, mimeType, title, thumb, borderModifier, contentScale, waveform, artworkUri, authorName, dimensions, blurhash, nostrUriCallback, onDialog, alwaysShowVideo, accountViewModel = accountViewModel, thumbhash = thumbhash, isLiveStream = isLiveStream) } @Composable @@ -99,6 +100,7 @@ fun VideoView( onDialog: (() -> Unit)? = null, alwaysShowVideo: Boolean = false, showControls: Boolean = true, + isLiveStream: Boolean = false, accountViewModel: AccountViewModel, thumbhash: String? = null, ) { @@ -138,6 +140,7 @@ fun VideoView( authorName = authorName, nostrUriCallback = nostrUriCallback, automaticallyStartPlayback = autoplay, + isLiveStream = isLiveStream, onZoom = onDialog, hasBlurhash = false, accountViewModel = accountViewModel, @@ -186,6 +189,7 @@ fun VideoView( authorName = authorName, nostrUriCallback = nostrUriCallback, automaticallyStartPlayback = autoplay, + isLiveStream = isLiveStream, onZoom = onDialog, hasBlurhash = true, accountViewModel = accountViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt index e2c164850..8923783f0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt @@ -49,6 +49,7 @@ fun VideoViewInner( authorName: String? = null, nostrUriCallback: String? = null, automaticallyStartPlayback: Boolean, + isLiveStream: Boolean = false, controllerVisible: MutableState = mutableStateOf(false), onZoom: (() -> Unit)? = null, hasBlurhash: Boolean = false, @@ -69,6 +70,7 @@ fun VideoViewInner( proxyPort = accountViewModel.httpClientBuilder.proxyPortForVideo(videoUri), keepPlaying = true, waveformData = waveform, + isLiveStream = isLiveStream, ) { mediaItem -> GetVideoController( mediaItem = mediaItem, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/GetMediaItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/GetMediaItem.kt index e3cc334c3..11e6c1fde 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/GetMediaItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/GetMediaItem.kt @@ -40,6 +40,7 @@ fun GetMediaItem( proxyPort: Int? = null, keepPlaying: Boolean = false, waveformData: WaveformData? = null, + isLiveStream: Boolean = false, inner: @Composable (LoadedMediaItem) -> Unit, ) { val data = @@ -55,6 +56,7 @@ fun GetMediaItem( proxyPort = proxyPort, keepPlaying = keepPlaying, waveformData = waveformData, + isLiveStream = isLiveStream, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemCache.kt index c1cb800de..a690ee159 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemCache.kt @@ -30,6 +30,11 @@ import kotlinx.coroutines.withContext import kotlin.coroutines.cancellation.CancellationException class MediaItemCache : GenericBaseCache(20) { + companion object { + const val EXTRA_CALLBACK_URI = "callbackUri" + const val EXTRA_IS_LIVE_STREAM = "isLiveStream" + } + override suspend fun compute(key: MediaItemData): LoadedMediaItem = withContext(Dispatchers.IO) { LoadedMediaItem( @@ -45,7 +50,8 @@ class MediaItemCache : GenericBaseCache(20) { .setTitle(key.title?.ifBlank { null } ?: key.videoUri) .setExtras( Bundle().apply { - putString("callbackUri", key.callbackUri) + putString(EXTRA_CALLBACK_URI, key.callbackUri) + putBoolean(EXTRA_IS_LIVE_STREAM, key.isLiveStream) }, ).setArtworkUri( try { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemData.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemData.kt index 6b3fcf4c6..44e6441ab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemData.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemData.kt @@ -36,6 +36,10 @@ data class MediaItemData( val proxyPort: Int? = null, val keepPlaying: Boolean = true, val waveformData: WaveformData? = null, + // True only for true live streams (e.g. kind 30311). On-demand HLS + // (e.g. multi-rendition NIP-71 videos) must leave this false so that + // ExoPlayer's SimpleCache can cache their immutable segments. + val isLiveStream: Boolean = false, ) @Immutable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/IntentExtras.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/IntentExtras.kt index a98c8232c..cb2248312 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/IntentExtras.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/IntentExtras.kt @@ -61,6 +61,7 @@ class IntentExtras { proxyPort = if (port > 0) port else null, keepPlaying = intent.getBoolean("keepPlaying", true), waveformData = intent.getFloatArray("wavefrontData")?.toList()?.let { WaveformData(it) }, + isLiveStream = intent.getBoolean("isLiveStream", false), ) } @@ -79,6 +80,7 @@ class IntentExtras { data.proxyPort?.let { putInt("proxyPort", it) } putBoolean("keepPlaying", data.keepPlaying) data.waveformData?.let { putFloatArray("wavefrontData", it.wave.toFloatArray()) } + putBoolean("isLiveStream", data.isLiveStream) bounds?.let { putInt("boundLeft", it.left) } bounds?.let { putInt("boundRight", it.right) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/CustomMediaSourceFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/CustomMediaSourceFactory.kt index f04bb625c..20cc9516d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/CustomMediaSourceFactory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/CustomMediaSourceFactory.kt @@ -27,11 +27,19 @@ import androidx.media3.exoplayer.drm.DrmSessionManagerProvider import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.source.MediaSource import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy +import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemCache import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming /** - * HLS LiveStreams cannot use cache. + * True live streams (kind 30311) must not be cached. On-demand HLS + * (e.g. multi-rendition NIP-71 videos) is cached like any other video, + * because its segments are immutable. + * + * The `isLiveStream` flag is carried on [MediaItem.mediaMetadata] extras + * and set by [MediaItemCache] from [com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData]. + * If the flag is absent (e.g. a [MediaItem] built outside the cache path), + * we fall back to the URL-based heuristic. */ @UnstableApi class CustomMediaSourceFactory( @@ -58,9 +66,19 @@ class CustomMediaSourceFactory( override fun getSupportedTypes(): IntArray = nonCachingFactory.supportedTypes override fun createMediaSource(mediaItem: MediaItem): MediaSource { - if (isLiveStreaming(mediaItem.mediaId)) { + if (isLiveStream(mediaItem)) { return nonCachingFactory.createMediaSource(mediaItem) } return cachingFactory.createMediaSource(mediaItem) } + + private fun isLiveStream(mediaItem: MediaItem): Boolean { + val extras = mediaItem.mediaMetadata.extras + return if (extras != null && extras.containsKey(MediaItemCache.EXTRA_IS_LIVE_STREAM)) { + extras.getBoolean(MediaItemCache.EXTRA_IS_LIVE_STREAM, false) + } else { + // Fallback for MediaItems that weren't built via MediaItemCache. + isLiveStreaming(mediaItem.mediaId) + } + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt index 4515554e6..501354b11 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt @@ -35,6 +35,7 @@ import androidx.media3.exoplayer.ExoPlayer import androidx.media3.session.MediaSession import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.ListenableFuture +import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemCache import com.vitorpamplona.amethyst.ui.MainActivity import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CoroutineExceptionHandler @@ -198,7 +199,7 @@ class MediaSessionPool( mediaItems: List, ): ListenableFuture> { // set up return call when clicking on the Notification bar - mediaItems.firstOrNull()?.mediaMetadata?.extras?.getString("callbackUri")?.let { + mediaItems.firstOrNull()?.mediaMetadata?.extras?.getString(MediaItemCache.EXTRA_CALLBACK_URI)?.let { mediaSession.setSessionActivity( PendingIntent.getActivity( appContext, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt index 423726a97..cca216f9d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt @@ -199,6 +199,7 @@ fun RenderLiveActivityEventInner( contentScale = ContentScale.FillWidth, accountViewModel = accountViewModel, nostrUriCallback = "nostr:${baseNote.toNEvent()}", + isLiveStream = true, ) } } else {