Skip to content

Commit 9393f41

Browse files
committed
feat: add opt-in video caching for Android and iOS (#34)
Add CacheConfig API that enables disk-based caching of video data fetched via openUri(). On Android this uses Media3 SimpleCache with LRU eviction; on iOS it configures NSURLCache with increased disk capacity. Other platforms accept the config but treat it as a no-op. Closes #34
1 parent 3eeb27f commit 9393f41

10 files changed

Lines changed: 240 additions & 10 deletions

File tree

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: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package io.github.kdroidfilter.composemediaplayer
2+
3+
import io.github.kdroidfilter.composemediaplayer.util.TaggedLogger
4+
import platform.Foundation.NSURLCache
5+
6+
private val cacheLogger = TaggedLogger("iOSVideoCache")
7+
8+
/**
9+
* Manages the shared [NSURLCache] configuration for AVPlayer on iOS.
10+
*
11+
* AVPlayer uses the shared URL loading system under the hood. By configuring
12+
* [NSURLCache] with a generous disk capacity, HTTP responses (including partial
13+
* content / range requests used during seek) are stored on disk and served
14+
* from the cache on subsequent plays of the same URI.
15+
*
16+
* This works transparently with standard HTTP caching headers. Most CDNs and
17+
* video hosting services send appropriate `Cache-Control` / `ETag` headers
18+
* that allow caching.
19+
*/
20+
internal object IosVideoCache {
21+
private var configured = false
22+
private var previousMemoryCapacity: ULong = 0u
23+
private var previousDiskCapacity: ULong = 0u
24+
25+
@Synchronized
26+
fun configure(maxCacheSizeBytes: Long) {
27+
if (configured) 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+
configured = true
38+
cacheLogger.d {
39+
"NSURLCache configured: disk=${sharedCache.diskCapacity} bytes, memory=${sharedCache.memoryCapacity} bytes"
40+
}
41+
}
42+
43+
@Synchronized
44+
fun clear() {
45+
NSURLCache.sharedURLCache.removeAllCachedResponses()
46+
cacheLogger.d { "NSURLCache cleared" }
47+
}
48+
49+
@Synchronized
50+
fun release() {
51+
if (!configured) return
52+
53+
val sharedCache = NSURLCache.sharedURLCache
54+
sharedCache.memoryCapacity = previousMemoryCapacity
55+
sharedCache.diskCapacity = previousDiskCapacity
56+
configured = false
57+
cacheLogger.d { "NSURLCache restored to previous configuration" }
58+
}
59+
}

mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.ios.kt

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,17 @@ import platform.darwin.dispatch_async
5252
import platform.darwin.dispatch_get_global_queue
5353
import platform.darwin.dispatch_get_main_queue
5454

55-
actual fun createVideoPlayerState(audioMode: AudioMode): VideoPlayerState = DefaultVideoPlayerState(audioMode)
55+
actual fun createVideoPlayerState(
56+
audioMode: AudioMode,
57+
cacheConfig: CacheConfig,
58+
): VideoPlayerState = DefaultVideoPlayerState(audioMode, cacheConfig)
5659

5760
private val iosLogger = TaggedLogger("iOSVideoPlayerState")
5861

5962
@Stable
6063
open class DefaultVideoPlayerState(
6164
private val audioMode: AudioMode = AudioMode(),
65+
private val cacheConfig: CacheConfig = CacheConfig(),
6266
) : VideoPlayerState {
6367
// Base states
6468
private var _volume = mutableStateOf(1.0f)
@@ -166,6 +170,12 @@ open class DefaultVideoPlayerState(
166170
// Flag to track if the state has been disposed
167171
private var isDisposed = false
168172

173+
init {
174+
if (cacheConfig.enabled) {
175+
IosVideoCache.configure(cacheConfig.maxCacheSizeBytes)
176+
}
177+
}
178+
169179
// Internal time values (in seconds)
170180
private var _currentTime: Double = 0.0
171181
private var _duration: Double = 0.0
@@ -712,6 +722,12 @@ open class DefaultVideoPlayerState(
712722
iosLogger.d { "clearError called" }
713723
}
714724

725+
override fun clearCache() {
726+
if (cacheConfig.enabled) {
727+
IosVideoCache.clear()
728+
}
729+
}
730+
715731
/**
716732
* Toggles the fullscreen state of the video player
717733
*/

mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.jvm.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ import io.github.kdroidfilter.composemediaplayer.util.CurrentPlatform
99
import io.github.kdroidfilter.composemediaplayer.windows.WindowsVideoPlayerState
1010
import io.github.vinceglb.filekit.PlatformFile
1111

12-
actual fun createVideoPlayerState(audioMode: AudioMode): VideoPlayerState = DefaultVideoPlayerState()
12+
actual fun createVideoPlayerState(
13+
audioMode: AudioMode,
14+
cacheConfig: CacheConfig,
15+
): VideoPlayerState = DefaultVideoPlayerState()
1316

1417
/**
1518
* Represents the state and behavior of a video player. This class provides properties

mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.web.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,12 @@ import kotlinx.coroutines.delay
2222
import kotlinx.coroutines.launch
2323
import kotlinx.io.IOException
2424
import kotlin.time.Duration.Companion.milliseconds
25-
import kotlin.time.Duration.Companion.seconds
2625
import kotlin.time.TimeSource
2726

28-
actual fun createVideoPlayerState(audioMode: AudioMode): VideoPlayerState = DefaultVideoPlayerState()
27+
actual fun createVideoPlayerState(
28+
audioMode: AudioMode,
29+
cacheConfig: CacheConfig,
30+
): VideoPlayerState = DefaultVideoPlayerState()
2931

3032
/**
3133
* Implementation of VideoPlayerState for WebAssembly/JavaScript platform.

0 commit comments

Comments
 (0)