Skip to content

Commit 5ee4dc0

Browse files
authored
Merge pull request #197 from kdroidFilter/feat/video-cache-config
feat: add opt-in video caching for Android and iOS
2 parents 3eeb27f + c234aec commit 5ee4dc0

11 files changed

Lines changed: 272 additions & 10 deletions

File tree

README.MD

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
- [Fullscreen Mode](#️-fullscreen-mode)
4949
- [Picture-in-Picture (PiP)](#-picture-in-picture-pip)
5050
- [Audio Mode](#-audio-mode)
51+
- [Video Caching](#-video-caching)
5152
- [Metadata Support](#-metadata-support)
5253
- [Example Usage](#example-usage)
5354
- [Basic Example](#-basic-example)
@@ -71,6 +72,7 @@ Try the online demo here : [🎥 Live Demo](https://kdroidfilter.github.io/Compo
7172
- **Fullscreen Mode**: Toggle between windowed and fullscreen playback modes.
7273
- **Picture-in-Picture (PiP)**: Continue watching in a floating window on Android (8.0+) and iOS.
7374
- **Audio Mode**: Configure audio interruption behavior and iOS silent switch handling.
75+
- **Video Caching**: Opt-in disk caching for video data on Android and iOS, ideal for scroll-based UIs.
7476
- **Error handling** Simple error handling for network or playback issues.
7577

7678
## ✨ Supported Video Formats
@@ -549,6 +551,40 @@ val playerState = rememberVideoPlayerState(
549551
> [!NOTE]
550552
> Audio mode is only effective on **Android** and **iOS**. On desktop and web, the parameter is accepted but ignored.
551553
554+
### 💾 Video Caching
555+
556+
You can enable disk-based caching so that video data fetched via `openUri()` is stored locally. Subsequent plays of the same URL load from the cache instead of re-downloading, which is especially useful for scroll-based UIs like TikTok/Reels-style `VerticalPager`.
557+
558+
```kotlin
559+
val playerState = rememberVideoPlayerState(
560+
cacheConfig = CacheConfig(
561+
enabled = true,
562+
maxCacheSizeBytes = 200L * 1024L * 1024L // 200 MB
563+
)
564+
)
565+
```
566+
567+
| Parameter | Description | Default |
568+
| :--- | :--- | :---: |
569+
| `enabled` | Whether caching is active | `false` |
570+
| `maxCacheSizeBytes` | Maximum disk space for the cache (LRU eviction) | `100 MB` |
571+
572+
To clear the cache programmatically:
573+
574+
```kotlin
575+
playerState.clearCache()
576+
```
577+
578+
| Platform | Status | Implementation |
579+
| :--- | :---: | :--- |
580+
| **Android** || Media3 `SimpleCache` with `LeastRecentlyUsedCacheEvictor` |
581+
| **iOS** || `NSURLCache` with increased disk capacity |
582+
| **Desktop** || No-op (config accepted but ignored) |
583+
| **Web** || No-op (browser manages its own HTTP cache) |
584+
585+
> [!NOTE]
586+
> Caching only applies to URIs opened via `openUri()`. Local files and assets are not cached. The cache is shared across all player instances, so multiple players benefit from the same cached data.
587+
552588
## 🔍 Metadata Support
553589

554590
> [!WARNING]

gradle/libs.versions.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ android-minSdk="23"
2222

2323
androidcontextprovider = { module = "io.github.kdroidfilter:androidcontextprovider", version.ref = "androidcontextprovider" }
2424
androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3Exoplayer" }
25+
androidx-media3-datasource = { module = "androidx.media3:media3-datasource", version.ref = "media3Exoplayer" }
26+
androidx-media3-database = { module = "androidx.media3:media3-database", version.ref = "media3Exoplayer" }
2527
androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3Exoplayer" }
2628
filekit-core = { module = "io.github.vinceglb:filekit-core", version.ref = "filekit" }
2729
filekit-dialogs-compose = { module = "io.github.vinceglb:filekit-dialogs-compose", version.ref = "filekit" }

mediaplayer/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ kotlin {
8787
implementation(libs.androidcontextprovider)
8888
implementation(libs.kotlinx.coroutines.android)
8989
implementation(libs.androidx.media3.exoplayer)
90+
implementation(libs.androidx.media3.datasource)
91+
implementation(libs.androidx.media3.database)
9092
implementation(libs.androidx.media3.ui)
9193
implementation(libs.androidx.activityCompose)
9294
implementation(libs.androidx.core)
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package io.github.kdroidfilter.composemediaplayer
2+
3+
import android.content.Context
4+
import androidx.annotation.OptIn
5+
import androidx.media3.common.util.UnstableApi
6+
import androidx.media3.database.StandaloneDatabaseProvider
7+
import androidx.media3.datasource.DataSource
8+
import androidx.media3.datasource.DefaultDataSource
9+
import androidx.media3.datasource.cache.CacheDataSource
10+
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
11+
import androidx.media3.datasource.cache.SimpleCache
12+
import java.io.File
13+
14+
/**
15+
* Singleton managing the shared [SimpleCache] instance for ExoPlayer.
16+
*
17+
* The cache is lazily initialized on first access and is shared across all
18+
* player instances so that video data downloaded by one player is available
19+
* to every other player without a second network round-trip.
20+
*/
21+
@UnstableApi
22+
internal object VideoCache {
23+
private var simpleCache: SimpleCache? = null
24+
private var currentMaxBytes: Long = 0L
25+
26+
@Synchronized
27+
fun getCache(
28+
context: Context,
29+
maxCacheSizeBytes: Long,
30+
): SimpleCache {
31+
val existing = simpleCache
32+
if (existing != null && currentMaxBytes == maxCacheSizeBytes) return existing
33+
34+
// Release the previous cache if the size changed
35+
existing?.release()
36+
37+
val cacheDir = File(context.cacheDir, "compose_media_player_cache")
38+
val evictor = LeastRecentlyUsedCacheEvictor(maxCacheSizeBytes)
39+
val dbProvider = StandaloneDatabaseProvider(context)
40+
41+
return SimpleCache(cacheDir, evictor, dbProvider).also {
42+
simpleCache = it
43+
currentMaxBytes = maxCacheSizeBytes
44+
}
45+
}
46+
47+
@Synchronized
48+
fun release() {
49+
simpleCache?.release()
50+
simpleCache = null
51+
currentMaxBytes = 0L
52+
}
53+
54+
@Synchronized
55+
fun clear(
56+
context: Context,
57+
maxCacheSizeBytes: Long,
58+
) {
59+
val cache = getCache(context, maxCacheSizeBytes)
60+
cache.keys.toList().forEach { key ->
61+
cache.removeResource(key)
62+
}
63+
}
64+
}
65+
66+
@OptIn(UnstableApi::class)
67+
internal fun buildCachingDataSourceFactory(
68+
context: Context,
69+
maxCacheSizeBytes: Long,
70+
): DataSource.Factory {
71+
val upstreamFactory = DefaultDataSource.Factory(context)
72+
return CacheDataSource
73+
.Factory()
74+
.setCache(VideoCache.getCache(context, maxCacheSizeBytes))
75+
.setUpstreamDataSourceFactory(upstreamFactory)
76+
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
77+
}

mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.android.kt

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import androidx.media3.exoplayer.ExoPlayer
3030
import androidx.media3.exoplayer.audio.AudioSink
3131
import androidx.media3.exoplayer.audio.DefaultAudioSink
3232
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
33+
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
3334
import androidx.media3.ui.CaptionStyleCompat
3435
import androidx.media3.ui.PlayerView
3536
import com.kdroid.androidcontextprovider.ContextProvider
@@ -42,9 +43,12 @@ import kotlinx.coroutines.*
4243
import java.lang.ref.WeakReference
4344

4445
@OptIn(UnstableApi::class)
45-
actual fun createVideoPlayerState(audioMode: AudioMode): VideoPlayerState =
46+
actual fun createVideoPlayerState(
47+
audioMode: AudioMode,
48+
cacheConfig: CacheConfig,
49+
): VideoPlayerState =
4650
try {
47-
DefaultVideoPlayerState(audioMode)
51+
DefaultVideoPlayerState(audioMode, cacheConfig)
4852
} catch (e: IllegalStateException) {
4953
PreviewableVideoPlayerState(
5054
hasMedia = false,
@@ -80,6 +84,7 @@ internal val androidVideoLogger = TaggedLogger("AndroidVideoPlayerSurface")
8084
@Stable
8185
open class DefaultVideoPlayerState(
8286
private val audioMode: AudioMode = AudioMode(),
87+
private val cacheConfig: CacheConfig = CacheConfig(),
8388
) : VideoPlayerState {
8489
companion object {
8590
var activity: WeakReference<Activity> = WeakReference(null)
@@ -411,7 +416,7 @@ open class DefaultVideoPlayerState(
411416
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
412417
.build()
413418

414-
exoPlayer =
419+
val playerBuilder =
415420
ExoPlayer
416421
.Builder(context)
417422
.setRenderersFactory(renderersFactory)
@@ -420,6 +425,14 @@ open class DefaultVideoPlayerState(
420425
.setAudioAttributes(audioAttributes, manageFocus)
421426
.setPauseAtEndOfMediaItems(false)
422427
.setReleaseTimeoutMs(2000) // Increase the release timeout
428+
429+
if (cacheConfig.enabled) {
430+
val cacheDataSourceFactory = buildCachingDataSourceFactory(context, cacheConfig.maxCacheSizeBytes)
431+
playerBuilder.setMediaSourceFactory(DefaultMediaSourceFactory(cacheDataSourceFactory))
432+
}
433+
434+
exoPlayer =
435+
playerBuilder
423436
.build()
424437
.apply {
425438
playerListener = createPlayerListener()
@@ -782,6 +795,12 @@ open class DefaultVideoPlayerState(
782795
_error = null
783796
}
784797

798+
override fun clearCache() {
799+
if (cacheConfig.enabled) {
800+
VideoCache.clear(context, cacheConfig.maxCacheSizeBytes)
801+
}
802+
}
803+
785804
override fun toggleFullscreen() {
786805
_isFullscreen = !_isFullscreen
787806
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package io.github.kdroidfilter.composemediaplayer
2+
3+
/**
4+
* Configuration for video caching. When enabled, downloaded video data is stored
5+
* on disk so that subsequent plays of the same URI load from the local cache
6+
* instead of re-downloading.
7+
*
8+
* The cache is shared across all [VideoPlayerState] instances that use the same
9+
* configuration, which makes it ideal for scroll-based UIs (e.g. VerticalPager)
10+
* where multiple player instances may load the same URLs.
11+
*
12+
* Caching only applies to URIs opened via [VideoPlayerState.openUri]; local files
13+
* and assets are not cached.
14+
*
15+
* Currently supported on **Android** and **iOS** only. On other platforms the
16+
* configuration is accepted but has no effect.
17+
*
18+
* @param enabled Whether caching is active. Default is `false`.
19+
* @param maxCacheSizeBytes Maximum disk space the cache may use, in bytes.
20+
* When the limit is reached, the least-recently-used entries are evicted.
21+
* Default is 100 MB.
22+
*/
23+
data class CacheConfig(
24+
val enabled: Boolean = false,
25+
val maxCacheSizeBytes: Long = 100L * 1024L * 1024L,
26+
)

mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.kt

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,16 @@ interface VideoPlayerState {
195195

196196
fun disableSubtitles()
197197

198+
// Cache management
199+
200+
/**
201+
* Clears the shared video cache, removing all cached media data from disk.
202+
*
203+
* This is a no-op on platforms that do not support caching or when caching
204+
* is not enabled.
205+
*/
206+
fun clearCache() {}
207+
198208
// Cleanup
199209

200210
/**
@@ -223,8 +233,16 @@ interface VideoPlayerState {
223233
/**
224234
* Create platform-specific video player state. Supported platforms include Windows,
225235
* macOS, and Linux.
236+
*
237+
* @param audioMode The audio mode configuration for the player.
238+
* @param cacheConfig Optional caching configuration. When [CacheConfig.enabled] is `true`,
239+
* video data fetched via [VideoPlayerState.openUri] is cached on disk so that subsequent
240+
* plays of the same URI avoid a full re-download. Currently only effective on Android and iOS.
226241
*/
227-
expect fun createVideoPlayerState(audioMode: AudioMode = AudioMode()): VideoPlayerState
242+
expect fun createVideoPlayerState(
243+
audioMode: AudioMode = AudioMode(),
244+
cacheConfig: CacheConfig = CacheConfig(),
245+
): VideoPlayerState
228246

229247
/**
230248
* Creates and remembers a [VideoPlayerState], automatically releasing all player resources
@@ -242,11 +260,17 @@ expect fun createVideoPlayerState(audioMode: AudioMode = AudioMode()): VideoPlay
242260
* ```
243261
*
244262
* @param audioMode The audio mode configuration for the player.
263+
* @param cacheConfig Optional caching configuration. When [CacheConfig.enabled] is `true`,
264+
* video data fetched via [VideoPlayerState.openUri] is cached on disk so that subsequent
265+
* plays of the same URI avoid a full re-download. Currently only effective on Android and iOS.
245266
* @return The remembered instance of [VideoPlayerState].
246267
*/
247268
@Composable
248-
fun rememberVideoPlayerState(audioMode: AudioMode = AudioMode()): VideoPlayerState {
249-
val playerState = remember(audioMode) { createVideoPlayerState(audioMode) }
269+
fun rememberVideoPlayerState(
270+
audioMode: AudioMode = AudioMode(),
271+
cacheConfig: CacheConfig = CacheConfig(),
272+
): VideoPlayerState {
273+
val playerState = remember(audioMode, cacheConfig) { createVideoPlayerState(audioMode, cacheConfig) }
250274
DisposableEffect(Unit) {
251275
onDispose {
252276
playerState.dispose()
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package io.github.kdroidfilter.composemediaplayer
2+
3+
import io.github.kdroidfilter.composemediaplayer.util.TaggedLogger
4+
import kotlin.concurrent.AtomicInt
5+
import platform.Foundation.NSURLCache
6+
7+
private val cacheLogger = TaggedLogger("iOSVideoCache")
8+
9+
/**
10+
* Manages the shared [NSURLCache] configuration for AVPlayer on iOS.
11+
*
12+
* AVPlayer uses the shared URL loading system under the hood. By configuring
13+
* [NSURLCache] with a generous disk capacity, HTTP responses (including partial
14+
* content / range requests used during seek) are stored on disk and served
15+
* from the cache on subsequent plays of the same URI.
16+
*
17+
* This works transparently with standard HTTP caching headers. Most CDNs and
18+
* video hosting services send appropriate `Cache-Control` / `ETag` headers
19+
* that allow caching.
20+
*/
21+
internal object IosVideoCache {
22+
private val configuredFlag = AtomicInt(0)
23+
private var previousMemoryCapacity: ULong = 0u
24+
private var previousDiskCapacity: ULong = 0u
25+
26+
fun configure(maxCacheSizeBytes: Long) {
27+
if (!configuredFlag.compareAndSet(0, 1)) return
28+
29+
val sharedCache = NSURLCache.sharedURLCache
30+
previousMemoryCapacity = sharedCache.memoryCapacity
31+
previousDiskCapacity = sharedCache.diskCapacity
32+
33+
// Set disk capacity to the requested size; keep a reasonable memory cache (10 MB)
34+
sharedCache.memoryCapacity = maxOf(sharedCache.memoryCapacity, (10L * 1024 * 1024).toULong())
35+
sharedCache.diskCapacity = maxOf(sharedCache.diskCapacity, maxCacheSizeBytes.toULong())
36+
37+
cacheLogger.d {
38+
"NSURLCache configured: disk=${sharedCache.diskCapacity} bytes, memory=${sharedCache.memoryCapacity} bytes"
39+
}
40+
}
41+
42+
fun clear() {
43+
NSURLCache.sharedURLCache.removeAllCachedResponses()
44+
cacheLogger.d { "NSURLCache cleared" }
45+
}
46+
47+
fun release() {
48+
if (!configuredFlag.compareAndSet(1, 0)) return
49+
50+
val sharedCache = NSURLCache.sharedURLCache
51+
sharedCache.memoryCapacity = previousMemoryCapacity
52+
sharedCache.diskCapacity = previousDiskCapacity
53+
cacheLogger.d { "NSURLCache restored to previous configuration" }
54+
}
55+
}

0 commit comments

Comments
 (0)