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
This commit is contained in:
Claude
2026-04-15 22:35:25 +00:00
committed by davotoula
parent 4596316817
commit 6afacfb747
10 changed files with 48 additions and 5 deletions
@@ -41,6 +41,7 @@ fun LoadThumbAndThenVideoView(
roundedCorner: Boolean, roundedCorner: Boolean,
contentScale: ContentScale, contentScale: ContentScale,
nostrUriCallback: String? = null, nostrUriCallback: String? = null,
isLiveStream: Boolean = false,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
onDialog: (() -> Unit)? = null, onDialog: (() -> Unit)? = null,
) { ) {
@@ -75,6 +76,7 @@ fun LoadThumbAndThenVideoView(
artworkUri = thumbUri, artworkUri = thumbUri,
authorName = authorName, authorName = authorName,
nostrUriCallback = nostrUriCallback, nostrUriCallback = nostrUriCallback,
isLiveStream = isLiveStream,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
onDialog = onDialog, onDialog = onDialog,
) )
@@ -89,6 +91,7 @@ fun LoadThumbAndThenVideoView(
artworkUri = thumbUri, artworkUri = thumbUri,
authorName = authorName, authorName = authorName,
nostrUriCallback = nostrUriCallback, nostrUriCallback = nostrUriCallback,
isLiveStream = isLiveStream,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
onDialog = onDialog, onDialog = onDialog,
) )
@@ -69,6 +69,7 @@ fun VideoView(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
alwaysShowVideo: Boolean = false, alwaysShowVideo: Boolean = false,
thumbhash: String? = null, thumbhash: String? = null,
isLiveStream: Boolean = false,
) { ) {
val borderModifier = val borderModifier =
if (roundedCorner) { if (roundedCorner) {
@@ -79,7 +80,7 @@ fun VideoView(
Modifier 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 @Composable
@@ -99,6 +100,7 @@ fun VideoView(
onDialog: (() -> Unit)? = null, onDialog: (() -> Unit)? = null,
alwaysShowVideo: Boolean = false, alwaysShowVideo: Boolean = false,
showControls: Boolean = true, showControls: Boolean = true,
isLiveStream: Boolean = false,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
thumbhash: String? = null, thumbhash: String? = null,
) { ) {
@@ -138,6 +140,7 @@ fun VideoView(
authorName = authorName, authorName = authorName,
nostrUriCallback = nostrUriCallback, nostrUriCallback = nostrUriCallback,
automaticallyStartPlayback = autoplay, automaticallyStartPlayback = autoplay,
isLiveStream = isLiveStream,
onZoom = onDialog, onZoom = onDialog,
hasBlurhash = false, hasBlurhash = false,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
@@ -186,6 +189,7 @@ fun VideoView(
authorName = authorName, authorName = authorName,
nostrUriCallback = nostrUriCallback, nostrUriCallback = nostrUriCallback,
automaticallyStartPlayback = autoplay, automaticallyStartPlayback = autoplay,
isLiveStream = isLiveStream,
onZoom = onDialog, onZoom = onDialog,
hasBlurhash = true, hasBlurhash = true,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
@@ -49,6 +49,7 @@ fun VideoViewInner(
authorName: String? = null, authorName: String? = null,
nostrUriCallback: String? = null, nostrUriCallback: String? = null,
automaticallyStartPlayback: Boolean, automaticallyStartPlayback: Boolean,
isLiveStream: Boolean = false,
controllerVisible: MutableState<Boolean> = mutableStateOf(false), controllerVisible: MutableState<Boolean> = mutableStateOf(false),
onZoom: (() -> Unit)? = null, onZoom: (() -> Unit)? = null,
hasBlurhash: Boolean = false, hasBlurhash: Boolean = false,
@@ -69,6 +70,7 @@ fun VideoViewInner(
proxyPort = accountViewModel.httpClientBuilder.proxyPortForVideo(videoUri), proxyPort = accountViewModel.httpClientBuilder.proxyPortForVideo(videoUri),
keepPlaying = true, keepPlaying = true,
waveformData = waveform, waveformData = waveform,
isLiveStream = isLiveStream,
) { mediaItem -> ) { mediaItem ->
GetVideoController( GetVideoController(
mediaItem = mediaItem, mediaItem = mediaItem,
@@ -40,6 +40,7 @@ fun GetMediaItem(
proxyPort: Int? = null, proxyPort: Int? = null,
keepPlaying: Boolean = false, keepPlaying: Boolean = false,
waveformData: WaveformData? = null, waveformData: WaveformData? = null,
isLiveStream: Boolean = false,
inner: @Composable (LoadedMediaItem) -> Unit, inner: @Composable (LoadedMediaItem) -> Unit,
) { ) {
val data = val data =
@@ -55,6 +56,7 @@ fun GetMediaItem(
proxyPort = proxyPort, proxyPort = proxyPort,
keepPlaying = keepPlaying, keepPlaying = keepPlaying,
waveformData = waveformData, waveformData = waveformData,
isLiveStream = isLiveStream,
) )
} }
@@ -30,6 +30,11 @@ import kotlinx.coroutines.withContext
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
class MediaItemCache : GenericBaseCache<MediaItemData, LoadedMediaItem>(20) { class MediaItemCache : GenericBaseCache<MediaItemData, LoadedMediaItem>(20) {
companion object {
const val EXTRA_CALLBACK_URI = "callbackUri"
const val EXTRA_IS_LIVE_STREAM = "isLiveStream"
}
override suspend fun compute(key: MediaItemData): LoadedMediaItem = override suspend fun compute(key: MediaItemData): LoadedMediaItem =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
LoadedMediaItem( LoadedMediaItem(
@@ -45,7 +50,8 @@ class MediaItemCache : GenericBaseCache<MediaItemData, LoadedMediaItem>(20) {
.setTitle(key.title?.ifBlank { null } ?: key.videoUri) .setTitle(key.title?.ifBlank { null } ?: key.videoUri)
.setExtras( .setExtras(
Bundle().apply { Bundle().apply {
putString("callbackUri", key.callbackUri) putString(EXTRA_CALLBACK_URI, key.callbackUri)
putBoolean(EXTRA_IS_LIVE_STREAM, key.isLiveStream)
}, },
).setArtworkUri( ).setArtworkUri(
try { try {
@@ -36,6 +36,10 @@ data class MediaItemData(
val proxyPort: Int? = null, val proxyPort: Int? = null,
val keepPlaying: Boolean = true, val keepPlaying: Boolean = true,
val waveformData: WaveformData? = null, 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 @Immutable
@@ -61,6 +61,7 @@ class IntentExtras {
proxyPort = if (port > 0) port else null, proxyPort = if (port > 0) port else null,
keepPlaying = intent.getBoolean("keepPlaying", true), keepPlaying = intent.getBoolean("keepPlaying", true),
waveformData = intent.getFloatArray("wavefrontData")?.toList()?.let { WaveformData(it) }, 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) } data.proxyPort?.let { putInt("proxyPort", it) }
putBoolean("keepPlaying", data.keepPlaying) putBoolean("keepPlaying", data.keepPlaying)
data.waveformData?.let { putFloatArray("wavefrontData", it.wave.toFloatArray()) } data.waveformData?.let { putFloatArray("wavefrontData", it.wave.toFloatArray()) }
putBoolean("isLiveStream", data.isLiveStream)
bounds?.let { putInt("boundLeft", it.left) } bounds?.let { putInt("boundLeft", it.left) }
bounds?.let { putInt("boundRight", it.right) } bounds?.let { putInt("boundRight", it.right) }
@@ -27,11 +27,19 @@ import androidx.media3.exoplayer.drm.DrmSessionManagerProvider
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.source.MediaSource import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy 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.VideoCache
import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming 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 @UnstableApi
class CustomMediaSourceFactory( class CustomMediaSourceFactory(
@@ -58,9 +66,19 @@ class CustomMediaSourceFactory(
override fun getSupportedTypes(): IntArray = nonCachingFactory.supportedTypes override fun getSupportedTypes(): IntArray = nonCachingFactory.supportedTypes
override fun createMediaSource(mediaItem: MediaItem): MediaSource { override fun createMediaSource(mediaItem: MediaItem): MediaSource {
if (isLiveStreaming(mediaItem.mediaId)) { if (isLiveStream(mediaItem)) {
return nonCachingFactory.createMediaSource(mediaItem) return nonCachingFactory.createMediaSource(mediaItem)
} }
return cachingFactory.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)
}
}
} }
@@ -35,6 +35,7 @@ import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaSession import androidx.media3.session.MediaSession
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 com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemCache
import com.vitorpamplona.amethyst.ui.MainActivity import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineExceptionHandler
@@ -198,7 +199,7 @@ class MediaSessionPool(
mediaItems: List<MediaItem>, mediaItems: List<MediaItem>,
): ListenableFuture<List<MediaItem>> { ): ListenableFuture<List<MediaItem>> {
// set up return call when clicking on the Notification bar // 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( mediaSession.setSessionActivity(
PendingIntent.getActivity( PendingIntent.getActivity(
appContext, appContext,
@@ -199,6 +199,7 @@ fun RenderLiveActivityEventInner(
contentScale = ContentScale.FillWidth, contentScale = ContentScale.FillWidth,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
nostrUriCallback = "nostr:${baseNote.toNEvent()}", nostrUriCallback = "nostr:${baseNote.toNEvent()}",
isLiveStream = true,
) )
} }
} else { } else {