From 6afacfb747d1e1fab0f21cb7a9f07dca6e0b97e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Apr 2026 22:35:25 +0000 Subject: [PATCH 1/4] 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 { From 0218653a9c018085ea1a853e81fd83b57b071bca Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Apr 2026 06:07:56 +0000 Subject: [PATCH 2/4] perf(playback): size the video cache adaptively instead of fixed 1 GB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With multi-rendition HLS renditions in the tens-to-low-hundreds of MB each, the previous 1 GB fixed budget held only ~10-20 videos and evicted everything not currently on screen. A Nostr timeline is append-only so LRU degenerates to FIFO here, which is actually fine — the bottleneck was the budget, not the policy. Mirror the image cache pattern: target 20% of currently-available disk, clamped between a 256 MB floor (low-storage devices) and a 4 GB cap. That typically lets the evictor keep an order of magnitude more videos without notably affecting disk pressure on modern devices. https://claude.ai/code/session_01W8yGieuEyMKeKY9vU9zjKH --- .../service/playback/diskCache/VideoCache.kt | 38 +++++++++++++++++-- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/diskCache/VideoCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/diskCache/VideoCache.kt index d006d3f9a..c4f342d51 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/diskCache/VideoCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/diskCache/VideoCache.kt @@ -22,6 +22,7 @@ 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 @@ -31,9 +32,14 @@ import java.io.File @SuppressLint("UnsafeOptInUsageError") class VideoCache { - var exoPlayerCacheSize: Long = 1000 * 1024 * 1024 // 1GB - - var leastRecentlyUsedCacheEvictor = LeastRecentlyUsedCacheEvictor(exoPlayerCacheSize) + companion object { + // Target fraction of currently-available disk space. + private const val CACHE_SIZE_PERCENT = 0.20 + // 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 +52,38 @@ 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 availableBytes = + runCatching { + StatFs(cachePath.absolutePath).availableBytes + }.getOrDefault(0L) + val target = (availableBytes * CACHE_SIZE_PERCENT).toLong() + return target.coerceIn(CACHE_SIZE_MIN_BYTES, CACHE_SIZE_MAX_BYTES) + } + // This method should be called when proxy setting changes. fun renewCacheFactory(dataSourceFactory: DataSource.Factory) { cacheDataSourceFactory = From 63454761efd1339243a7cc5e8721acd71a3ff63d Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 21 Apr 2026 19:42:33 +0100 Subject: [PATCH 3/4] fix(playback): bypass cache for live streams opened via ZoomableContentView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live streams routed through the Live Streams tab go through ShowVideoStreaming → ZoomableContentView, not LiveActivity.kt. ZoomableContentView is generic and was calling VideoView without isLiveStream=true, so live stream segments ended up in SimpleCache. The cached HLS manifest then went stale (live manifests roll constantly) and playback stopped after ~30 seconds. Plumb the flag through the data model: add isLiveStream to MediaUrlVideo, set it true when ShowVideoStreaming constructs the model, and pass it from ZoomableContentView into VideoView. URL heuristic (.m3u8) wasn't viable — that would also bypass cache for on-demand NIP-71 multi-rendition videos and defeat the original feature. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../vitorpamplona/amethyst/ui/components/ZoomableContentView.kt | 1 + .../publicChannels/nip53LiveActivities/ShowVideoStreaming.kt | 1 + .../amethyst/commons/richtext/MediaContentModels.kt | 1 + 3 files changed, 3 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index e9d03260e..61d9add09 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -203,6 +203,7 @@ fun ZoomableContentView( onDialog = { dialogOpen = true }, accountViewModel = accountViewModel, thumbhash = content.thumbhash, + isLiveStream = content.isLiveStream, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ShowVideoStreaming.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ShowVideoStreaming.kt index bad5dbdfd..edfa6decc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ShowVideoStreaming.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ShowVideoStreaming.kt @@ -53,6 +53,7 @@ fun ShowVideoStreaming( artworkUri = event.image(), authorName = baseChannel.creatorName(), uri = baseChannel.toNAddr(), + isLiveStream = true, ) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt index e439378ba..0ab08e726 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt @@ -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 From 2ed1b17f03af7cf2ccd3fc8819f71bec66f21943 Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 21 Apr 2026 19:42:49 +0100 Subject: [PATCH 4/4] chore(playback): add diagnostic logs for cache + quality selection Three debug-level log lines for verifying the HLS caching and resolution-policy plumbing on a real device. Easy to revert as a single commit before merging if the noise is unwanted. - VideoCache: one line at app start with computed size, available, total disk - CustomMediaSourceFactory: one line per MediaSource creation, CACHE or BYPASS - InitialVideoQualitySelector: one line per applied policy, with selected resolution and the rendition list Tail with: adb logcat -s VideoCache CustomMediaSourceFactory VideoQuality Co-Authored-By: Claude Opus 4.7 (1M context) --- .../controls/InitialVideoQualitySelector.kt | 18 ++++++++++++++++++ .../service/playback/diskCache/VideoCache.kt | 17 ++++++++++++----- .../playerPool/CustomMediaSourceFactory.kt | 12 +++++++++--- 3 files changed, 39 insertions(+), 8 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/InitialVideoQualitySelector.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/InitialVideoQualitySelector.kt index 0b5f200ca..a99a04218 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/InitialVideoQualitySelector.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/InitialVideoQualitySelector.kt @@ -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 } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/diskCache/VideoCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/diskCache/VideoCache.kt index c4f342d51..170a48912 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/diskCache/VideoCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/diskCache/VideoCache.kt @@ -28,6 +28,7 @@ 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") @@ -35,8 +36,10 @@ class VideoCache { companion object { // Target fraction of currently-available disk space. private const val CACHE_SIZE_PERCENT = 0.20 + // 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 } @@ -76,12 +79,16 @@ class VideoCache { */ private fun calculateCacheSize(cachePath: File): Long { cachePath.mkdirs() - val availableBytes = - runCatching { - StatFs(cachePath.absolutePath).availableBytes - }.getOrDefault(0L) + val statFs = runCatching { StatFs(cachePath.absolutePath) }.getOrNull() + val availableBytes = statFs?.availableBytes ?: 0L val target = (availableBytes * CACHE_SIZE_PERCENT).toLong() - return target.coerceIn(CACHE_SIZE_MIN_BYTES, CACHE_SIZE_MAX_BYTES) + 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. 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 20cc9516d..43212d657 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 @@ -30,6 +30,7 @@ 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 /** * True live streams (kind 30311) must not be cached. On-demand HLS @@ -66,10 +67,15 @@ class CustomMediaSourceFactory( override fun getSupportedTypes(): IntArray = nonCachingFactory.supportedTypes override fun createMediaSource(mediaItem: MediaItem): MediaSource { - if (isLiveStream(mediaItem)) { - 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) } - return cachingFactory.createMediaSource(mediaItem) } private fun isLiveStream(mediaItem: MediaItem): Boolean {