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:
+3
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
+5
-1
@@ -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,
|
||||||
|
|||||||
+2
@@ -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,
|
||||||
|
|||||||
+2
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -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 {
|
||||||
|
|||||||
+4
@@ -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) }
|
||||||
|
|||||||
+20
-2
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user