Merge pull request #2480 from davotoula/claude/cache-hls-videos-evictor
Cache on-demand HLS videos + adaptive cache sizing
This commit is contained in:
+3
@@ -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,
|
||||
)
|
||||
|
||||
+5
-1
@@ -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,
|
||||
|
||||
+2
@@ -49,6 +49,7 @@ fun VideoViewInner(
|
||||
authorName: String? = null,
|
||||
nostrUriCallback: String? = null,
|
||||
automaticallyStartPlayback: Boolean,
|
||||
isLiveStream: Boolean = false,
|
||||
controllerVisible: MutableState<Boolean> = 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,
|
||||
|
||||
+18
@@ -28,6 +28,7 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.Tracks
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
|
||||
/**
|
||||
* Which HLS rendition to pick automatically the first time tracks become available for a media
|
||||
@@ -96,13 +97,26 @@ private fun applyInitialQuality(
|
||||
// No point forcing a choice when there's only one rendition, and no future update will
|
||||
// change that for this media id, so mark it as settled.
|
||||
if (videoGroup.length <= 1) {
|
||||
Log.d("VideoQuality") {
|
||||
val f = videoGroup.getTrackFormat(0)
|
||||
"policy=$policy mediaId=$mediaId SINGLE rendition ${f.width}x${f.height} (no choice)"
|
||||
}
|
||||
appliedForMediaId.value = mediaId
|
||||
return
|
||||
}
|
||||
|
||||
val renditions =
|
||||
(0 until videoGroup.length).joinToString(", ") {
|
||||
val f = videoGroup.getTrackFormat(it)
|
||||
"${f.width}x${f.height}"
|
||||
}
|
||||
|
||||
when (policy) {
|
||||
VideoQualityPolicy.AUTO -> {
|
||||
if (hasVideoOverride(player)) clearVideoOverride(player)
|
||||
Log.d("VideoQuality") {
|
||||
"policy=AUTO mediaId=$mediaId cleared override (from ${videoGroup.length} renditions: [$renditions])"
|
||||
}
|
||||
appliedForMediaId.value = mediaId
|
||||
}
|
||||
|
||||
@@ -111,6 +125,10 @@ private fun applyInitialQuality(
|
||||
// retry on the next onTracksChanged when real video dimensions arrive.
|
||||
val lowestIndex = findLowestResolutionTrackIndex(videoGroup) ?: return
|
||||
selectVideoTrack(player, videoGroup, lowestIndex)
|
||||
Log.d("VideoQuality") {
|
||||
val f = videoGroup.getTrackFormat(lowestIndex)
|
||||
"policy=LOWEST mediaId=$mediaId selected=${f.width}x${f.height} (from ${videoGroup.length} renditions: [$renditions])"
|
||||
}
|
||||
appliedForMediaId.value = mediaId
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+7
-1
@@ -30,6 +30,11 @@ import kotlinx.coroutines.withContext
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
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 =
|
||||
withContext(Dispatchers.IO) {
|
||||
LoadedMediaItem(
|
||||
@@ -45,7 +50,8 @@ class MediaItemCache : GenericBaseCache<MediaItemData, LoadedMediaItem>(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 {
|
||||
|
||||
+4
@@ -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
|
||||
|
||||
+40
-3
@@ -22,18 +22,27 @@ package com.vitorpamplona.amethyst.service.playback.diskCache
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.os.StatFs
|
||||
import androidx.media3.database.StandaloneDatabaseProvider
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.datasource.cache.CacheDataSource
|
||||
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
|
||||
import androidx.media3.datasource.cache.SimpleCache
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import java.io.File
|
||||
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
class VideoCache {
|
||||
var exoPlayerCacheSize: Long = 1000 * 1024 * 1024 // 1GB
|
||||
companion object {
|
||||
// Target fraction of currently-available disk space.
|
||||
private const val CACHE_SIZE_PERCENT = 0.20
|
||||
|
||||
var leastRecentlyUsedCacheEvictor = LeastRecentlyUsedCacheEvictor(exoPlayerCacheSize)
|
||||
// Hard cap so we never consume more than this even on large devices.
|
||||
private const val CACHE_SIZE_MAX_BYTES = 4L * 1024 * 1024 * 1024 // 4 GB
|
||||
|
||||
// Floor so the cache is still useful on low-storage devices.
|
||||
private const val CACHE_SIZE_MIN_BYTES = 256L * 1024 * 1024 // 256 MB
|
||||
}
|
||||
|
||||
lateinit var exoDatabaseProvider: StandaloneDatabaseProvider
|
||||
lateinit var simpleCache: SimpleCache
|
||||
@@ -46,14 +55,42 @@ class VideoCache {
|
||||
) {
|
||||
exoDatabaseProvider = StandaloneDatabaseProvider(context)
|
||||
|
||||
val cacheSize = calculateCacheSize(cachePath)
|
||||
|
||||
simpleCache =
|
||||
SimpleCache(
|
||||
cachePath,
|
||||
leastRecentlyUsedCacheEvictor,
|
||||
LeastRecentlyUsedCacheEvictor(cacheSize),
|
||||
exoDatabaseProvider,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adaptive cache sizing: target [CACHE_SIZE_PERCENT] of currently-available
|
||||
* disk, clamped between [CACHE_SIZE_MIN_BYTES] and [CACHE_SIZE_MAX_BYTES].
|
||||
*
|
||||
* A Nostr timeline is append-only — users rarely rewatch older videos — so
|
||||
* LRU approximates FIFO here. That's actually fine as long as the budget is
|
||||
* large enough to cover a useful scroll window. With multi-rendition HLS
|
||||
* renditions at ~20-100 MB each, 1 GB held only a handful of videos and
|
||||
* evicted anything not visible on screen. 4 GB keeps roughly an order of
|
||||
* magnitude more without meaningfully impacting disk pressure on modern
|
||||
* devices (Android can reclaim cache dirs when storage gets tight).
|
||||
*/
|
||||
private fun calculateCacheSize(cachePath: File): Long {
|
||||
cachePath.mkdirs()
|
||||
val statFs = runCatching { StatFs(cachePath.absolutePath) }.getOrNull()
|
||||
val availableBytes = statFs?.availableBytes ?: 0L
|
||||
val target = (availableBytes * CACHE_SIZE_PERCENT).toLong()
|
||||
val cacheSize = target.coerceIn(CACHE_SIZE_MIN_BYTES, CACHE_SIZE_MAX_BYTES)
|
||||
Log.d("VideoCache") {
|
||||
"VideoCache size: ${cacheSize / (1024 * 1024)} MB " +
|
||||
"(available: ${availableBytes / (1024 * 1024)} MB / " +
|
||||
"total: ${(statFs?.totalBytes ?: 0L) / (1024 * 1024)} MB)"
|
||||
}
|
||||
return cacheSize
|
||||
}
|
||||
|
||||
// This method should be called when proxy setting changes.
|
||||
fun renewCacheFactory(dataSourceFactory: DataSource.Factory) {
|
||||
cacheDataSourceFactory =
|
||||
|
||||
@@ -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) }
|
||||
|
||||
+28
-4
@@ -27,11 +27,20 @@ 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
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
|
||||
/**
|
||||
* 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 +67,24 @@ class CustomMediaSourceFactory(
|
||||
override fun getSupportedTypes(): IntArray = nonCachingFactory.supportedTypes
|
||||
|
||||
override fun createMediaSource(mediaItem: MediaItem): MediaSource {
|
||||
if (isLiveStreaming(mediaItem.mediaId)) {
|
||||
return nonCachingFactory.createMediaSource(mediaItem)
|
||||
val live = isLiveStream(mediaItem)
|
||||
Log.d("CustomMediaSourceFactory") {
|
||||
"createMediaSource(${if (live) "BYPASS" else "CACHE"}): ${mediaItem.mediaId}"
|
||||
}
|
||||
return if (live) {
|
||||
nonCachingFactory.createMediaSource(mediaItem)
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
return cachingFactory.createMediaSource(mediaItem)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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<MediaItem>,
|
||||
): ListenableFuture<List<MediaItem>> {
|
||||
// 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,
|
||||
|
||||
@@ -203,6 +203,7 @@ fun ZoomableContentView(
|
||||
onDialog = { dialogOpen = true },
|
||||
accountViewModel = accountViewModel,
|
||||
thumbhash = content.thumbhash,
|
||||
isLiveStream = content.isLiveStream,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,6 +199,7 @@ fun RenderLiveActivityEventInner(
|
||||
contentScale = ContentScale.FillWidth,
|
||||
accountViewModel = accountViewModel,
|
||||
nostrUriCallback = "nostr:${baseNote.toNEvent()}",
|
||||
isLiveStream = true,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
|
||||
+1
@@ -53,6 +53,7 @@ fun ShowVideoStreaming(
|
||||
artworkUri = event.image(),
|
||||
authorName = baseChannel.creatorName(),
|
||||
uri = baseChannel.toNAddr(),
|
||||
isLiveStream = true,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -97,6 +97,7 @@ open class MediaUrlVideo(
|
||||
val contentWarning: String? = null,
|
||||
mimeType: String? = null,
|
||||
thumbhash: String? = null,
|
||||
val isLiveStream: Boolean = false,
|
||||
) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType, thumbhash)
|
||||
|
||||
@Immutable
|
||||
|
||||
Reference in New Issue
Block a user