diff --git a/README.MD b/README.MD index 8ceefc84..22aa096a 100644 --- a/README.MD +++ b/README.MD @@ -48,6 +48,7 @@ - [Fullscreen Mode](#️-fullscreen-mode) - [Picture-in-Picture (PiP)](#-picture-in-picture-pip) - [Audio Mode](#-audio-mode) +- [Video Caching](#-video-caching) - [Metadata Support](#-metadata-support) - [Example Usage](#example-usage) - [Basic Example](#-basic-example) @@ -71,6 +72,7 @@ Try the online demo here : [🎥 Live Demo](https://kdroidfilter.github.io/Compo - **Fullscreen Mode**: Toggle between windowed and fullscreen playback modes. - **Picture-in-Picture (PiP)**: Continue watching in a floating window on Android (8.0+) and iOS. - **Audio Mode**: Configure audio interruption behavior and iOS silent switch handling. +- **Video Caching**: Opt-in disk caching for video data on Android and iOS, ideal for scroll-based UIs. - **Error handling** Simple error handling for network or playback issues. ## ✨ Supported Video Formats @@ -549,6 +551,40 @@ val playerState = rememberVideoPlayerState( > [!NOTE] > Audio mode is only effective on **Android** and **iOS**. On desktop and web, the parameter is accepted but ignored. +### 💾 Video Caching + +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`. + +```kotlin +val playerState = rememberVideoPlayerState( + cacheConfig = CacheConfig( + enabled = true, + maxCacheSizeBytes = 200L * 1024L * 1024L // 200 MB + ) +) +``` + +| Parameter | Description | Default | +| :--- | :--- | :---: | +| `enabled` | Whether caching is active | `false` | +| `maxCacheSizeBytes` | Maximum disk space for the cache (LRU eviction) | `100 MB` | + +To clear the cache programmatically: + +```kotlin +playerState.clearCache() +``` + +| Platform | Status | Implementation | +| :--- | :---: | :--- | +| **Android** | ✅ | Media3 `SimpleCache` with `LeastRecentlyUsedCacheEvictor` | +| **iOS** | ✅ | `NSURLCache` with increased disk capacity | +| **Desktop** | ❌ | No-op (config accepted but ignored) | +| **Web** | ❌ | No-op (browser manages its own HTTP cache) | + +> [!NOTE] +> 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. + ## 🔍 Metadata Support > [!WARNING] diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 332ea4ea..c9e66f60 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,6 +22,8 @@ android-minSdk="23" androidcontextprovider = { module = "io.github.kdroidfilter:androidcontextprovider", version.ref = "androidcontextprovider" } androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3Exoplayer" } +androidx-media3-datasource = { module = "androidx.media3:media3-datasource", version.ref = "media3Exoplayer" } +androidx-media3-database = { module = "androidx.media3:media3-database", version.ref = "media3Exoplayer" } androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3Exoplayer" } filekit-core = { module = "io.github.vinceglb:filekit-core", version.ref = "filekit" } filekit-dialogs-compose = { module = "io.github.vinceglb:filekit-dialogs-compose", version.ref = "filekit" } diff --git a/mediaplayer/build.gradle.kts b/mediaplayer/build.gradle.kts index e6526b13..0db306d9 100644 --- a/mediaplayer/build.gradle.kts +++ b/mediaplayer/build.gradle.kts @@ -87,6 +87,8 @@ kotlin { implementation(libs.androidcontextprovider) implementation(libs.kotlinx.coroutines.android) implementation(libs.androidx.media3.exoplayer) + implementation(libs.androidx.media3.datasource) + implementation(libs.androidx.media3.database) implementation(libs.androidx.media3.ui) implementation(libs.androidx.activityCompose) implementation(libs.androidx.core) diff --git a/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoCache.kt b/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoCache.kt new file mode 100644 index 00000000..702122b8 --- /dev/null +++ b/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoCache.kt @@ -0,0 +1,77 @@ +package io.github.kdroidfilter.composemediaplayer + +import android.content.Context +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import androidx.media3.database.StandaloneDatabaseProvider +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.datasource.cache.CacheDataSource +import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor +import androidx.media3.datasource.cache.SimpleCache +import java.io.File + +/** + * Singleton managing the shared [SimpleCache] instance for ExoPlayer. + * + * The cache is lazily initialized on first access and is shared across all + * player instances so that video data downloaded by one player is available + * to every other player without a second network round-trip. + */ +@UnstableApi +internal object VideoCache { + private var simpleCache: SimpleCache? = null + private var currentMaxBytes: Long = 0L + + @Synchronized + fun getCache( + context: Context, + maxCacheSizeBytes: Long, + ): SimpleCache { + val existing = simpleCache + if (existing != null && currentMaxBytes == maxCacheSizeBytes) return existing + + // Release the previous cache if the size changed + existing?.release() + + val cacheDir = File(context.cacheDir, "compose_media_player_cache") + val evictor = LeastRecentlyUsedCacheEvictor(maxCacheSizeBytes) + val dbProvider = StandaloneDatabaseProvider(context) + + return SimpleCache(cacheDir, evictor, dbProvider).also { + simpleCache = it + currentMaxBytes = maxCacheSizeBytes + } + } + + @Synchronized + fun release() { + simpleCache?.release() + simpleCache = null + currentMaxBytes = 0L + } + + @Synchronized + fun clear( + context: Context, + maxCacheSizeBytes: Long, + ) { + val cache = getCache(context, maxCacheSizeBytes) + cache.keys.toList().forEach { key -> + cache.removeResource(key) + } + } +} + +@OptIn(UnstableApi::class) +internal fun buildCachingDataSourceFactory( + context: Context, + maxCacheSizeBytes: Long, +): DataSource.Factory { + val upstreamFactory = DefaultDataSource.Factory(context) + return CacheDataSource + .Factory() + .setCache(VideoCache.getCache(context, maxCacheSizeBytes)) + .setUpstreamDataSourceFactory(upstreamFactory) + .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR) +} diff --git a/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.android.kt b/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.android.kt index 89f6b00a..d4b530d6 100644 --- a/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.android.kt +++ b/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.android.kt @@ -30,6 +30,7 @@ import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.audio.AudioSink import androidx.media3.exoplayer.audio.DefaultAudioSink import androidx.media3.exoplayer.mediacodec.MediaCodecSelector +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.ui.CaptionStyleCompat import androidx.media3.ui.PlayerView import com.kdroid.androidcontextprovider.ContextProvider @@ -42,9 +43,12 @@ import kotlinx.coroutines.* import java.lang.ref.WeakReference @OptIn(UnstableApi::class) -actual fun createVideoPlayerState(audioMode: AudioMode): VideoPlayerState = +actual fun createVideoPlayerState( + audioMode: AudioMode, + cacheConfig: CacheConfig, +): VideoPlayerState = try { - DefaultVideoPlayerState(audioMode) + DefaultVideoPlayerState(audioMode, cacheConfig) } catch (e: IllegalStateException) { PreviewableVideoPlayerState( hasMedia = false, @@ -80,6 +84,7 @@ internal val androidVideoLogger = TaggedLogger("AndroidVideoPlayerSurface") @Stable open class DefaultVideoPlayerState( private val audioMode: AudioMode = AudioMode(), + private val cacheConfig: CacheConfig = CacheConfig(), ) : VideoPlayerState { companion object { var activity: WeakReference = WeakReference(null) @@ -411,7 +416,7 @@ open class DefaultVideoPlayerState( .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE) .build() - exoPlayer = + val playerBuilder = ExoPlayer .Builder(context) .setRenderersFactory(renderersFactory) @@ -420,6 +425,14 @@ open class DefaultVideoPlayerState( .setAudioAttributes(audioAttributes, manageFocus) .setPauseAtEndOfMediaItems(false) .setReleaseTimeoutMs(2000) // Increase the release timeout + + if (cacheConfig.enabled) { + val cacheDataSourceFactory = buildCachingDataSourceFactory(context, cacheConfig.maxCacheSizeBytes) + playerBuilder.setMediaSourceFactory(DefaultMediaSourceFactory(cacheDataSourceFactory)) + } + + exoPlayer = + playerBuilder .build() .apply { playerListener = createPlayerListener() @@ -782,6 +795,12 @@ open class DefaultVideoPlayerState( _error = null } + override fun clearCache() { + if (cacheConfig.enabled) { + VideoCache.clear(context, cacheConfig.maxCacheSizeBytes) + } + } + override fun toggleFullscreen() { _isFullscreen = !_isFullscreen } diff --git a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/CacheConfig.kt b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/CacheConfig.kt new file mode 100644 index 00000000..cd10090b --- /dev/null +++ b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/CacheConfig.kt @@ -0,0 +1,26 @@ +package io.github.kdroidfilter.composemediaplayer + +/** + * Configuration for video caching. When enabled, downloaded video data is stored + * on disk so that subsequent plays of the same URI load from the local cache + * instead of re-downloading. + * + * The cache is shared across all [VideoPlayerState] instances that use the same + * configuration, which makes it ideal for scroll-based UIs (e.g. VerticalPager) + * where multiple player instances may load the same URLs. + * + * Caching only applies to URIs opened via [VideoPlayerState.openUri]; local files + * and assets are not cached. + * + * Currently supported on **Android** and **iOS** only. On other platforms the + * configuration is accepted but has no effect. + * + * @param enabled Whether caching is active. Default is `false`. + * @param maxCacheSizeBytes Maximum disk space the cache may use, in bytes. + * When the limit is reached, the least-recently-used entries are evicted. + * Default is 100 MB. + */ +data class CacheConfig( + val enabled: Boolean = false, + val maxCacheSizeBytes: Long = 100L * 1024L * 1024L, +) diff --git a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.kt b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.kt index 6523380b..fc46cdf9 100644 --- a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.kt +++ b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.kt @@ -195,6 +195,16 @@ interface VideoPlayerState { fun disableSubtitles() + // Cache management + + /** + * Clears the shared video cache, removing all cached media data from disk. + * + * This is a no-op on platforms that do not support caching or when caching + * is not enabled. + */ + fun clearCache() {} + // Cleanup /** @@ -223,8 +233,16 @@ interface VideoPlayerState { /** * Create platform-specific video player state. Supported platforms include Windows, * macOS, and Linux. + * + * @param audioMode The audio mode configuration for the player. + * @param cacheConfig Optional caching configuration. When [CacheConfig.enabled] is `true`, + * video data fetched via [VideoPlayerState.openUri] is cached on disk so that subsequent + * plays of the same URI avoid a full re-download. Currently only effective on Android and iOS. */ -expect fun createVideoPlayerState(audioMode: AudioMode = AudioMode()): VideoPlayerState +expect fun createVideoPlayerState( + audioMode: AudioMode = AudioMode(), + cacheConfig: CacheConfig = CacheConfig(), +): VideoPlayerState /** * Creates and remembers a [VideoPlayerState], automatically releasing all player resources @@ -242,11 +260,17 @@ expect fun createVideoPlayerState(audioMode: AudioMode = AudioMode()): VideoPlay * ``` * * @param audioMode The audio mode configuration for the player. + * @param cacheConfig Optional caching configuration. When [CacheConfig.enabled] is `true`, + * video data fetched via [VideoPlayerState.openUri] is cached on disk so that subsequent + * plays of the same URI avoid a full re-download. Currently only effective on Android and iOS. * @return The remembered instance of [VideoPlayerState]. */ @Composable -fun rememberVideoPlayerState(audioMode: AudioMode = AudioMode()): VideoPlayerState { - val playerState = remember(audioMode) { createVideoPlayerState(audioMode) } +fun rememberVideoPlayerState( + audioMode: AudioMode = AudioMode(), + cacheConfig: CacheConfig = CacheConfig(), +): VideoPlayerState { + val playerState = remember(audioMode, cacheConfig) { createVideoPlayerState(audioMode, cacheConfig) } DisposableEffect(Unit) { onDispose { playerState.dispose() diff --git a/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoCache.ios.kt b/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoCache.ios.kt new file mode 100644 index 00000000..ec49a9ac --- /dev/null +++ b/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoCache.ios.kt @@ -0,0 +1,55 @@ +package io.github.kdroidfilter.composemediaplayer + +import io.github.kdroidfilter.composemediaplayer.util.TaggedLogger +import kotlin.concurrent.AtomicInt +import platform.Foundation.NSURLCache + +private val cacheLogger = TaggedLogger("iOSVideoCache") + +/** + * Manages the shared [NSURLCache] configuration for AVPlayer on iOS. + * + * AVPlayer uses the shared URL loading system under the hood. By configuring + * [NSURLCache] with a generous disk capacity, HTTP responses (including partial + * content / range requests used during seek) are stored on disk and served + * from the cache on subsequent plays of the same URI. + * + * This works transparently with standard HTTP caching headers. Most CDNs and + * video hosting services send appropriate `Cache-Control` / `ETag` headers + * that allow caching. + */ +internal object IosVideoCache { + private val configuredFlag = AtomicInt(0) + private var previousMemoryCapacity: ULong = 0u + private var previousDiskCapacity: ULong = 0u + + fun configure(maxCacheSizeBytes: Long) { + if (!configuredFlag.compareAndSet(0, 1)) return + + val sharedCache = NSURLCache.sharedURLCache + previousMemoryCapacity = sharedCache.memoryCapacity + previousDiskCapacity = sharedCache.diskCapacity + + // Set disk capacity to the requested size; keep a reasonable memory cache (10 MB) + sharedCache.memoryCapacity = maxOf(sharedCache.memoryCapacity, (10L * 1024 * 1024).toULong()) + sharedCache.diskCapacity = maxOf(sharedCache.diskCapacity, maxCacheSizeBytes.toULong()) + + cacheLogger.d { + "NSURLCache configured: disk=${sharedCache.diskCapacity} bytes, memory=${sharedCache.memoryCapacity} bytes" + } + } + + fun clear() { + NSURLCache.sharedURLCache.removeAllCachedResponses() + cacheLogger.d { "NSURLCache cleared" } + } + + fun release() { + if (!configuredFlag.compareAndSet(1, 0)) return + + val sharedCache = NSURLCache.sharedURLCache + sharedCache.memoryCapacity = previousMemoryCapacity + sharedCache.diskCapacity = previousDiskCapacity + cacheLogger.d { "NSURLCache restored to previous configuration" } + } +} diff --git a/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.ios.kt b/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.ios.kt index 25efc669..e5f3689f 100644 --- a/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.ios.kt +++ b/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.ios.kt @@ -52,13 +52,17 @@ import platform.darwin.dispatch_async import platform.darwin.dispatch_get_global_queue import platform.darwin.dispatch_get_main_queue -actual fun createVideoPlayerState(audioMode: AudioMode): VideoPlayerState = DefaultVideoPlayerState(audioMode) +actual fun createVideoPlayerState( + audioMode: AudioMode, + cacheConfig: CacheConfig, +): VideoPlayerState = DefaultVideoPlayerState(audioMode, cacheConfig) private val iosLogger = TaggedLogger("iOSVideoPlayerState") @Stable open class DefaultVideoPlayerState( private val audioMode: AudioMode = AudioMode(), + private val cacheConfig: CacheConfig = CacheConfig(), ) : VideoPlayerState { // Base states private var _volume = mutableStateOf(1.0f) @@ -166,6 +170,12 @@ open class DefaultVideoPlayerState( // Flag to track if the state has been disposed private var isDisposed = false + init { + if (cacheConfig.enabled) { + IosVideoCache.configure(cacheConfig.maxCacheSizeBytes) + } + } + // Internal time values (in seconds) private var _currentTime: Double = 0.0 private var _duration: Double = 0.0 @@ -712,6 +722,12 @@ open class DefaultVideoPlayerState( iosLogger.d { "clearError called" } } + override fun clearCache() { + if (cacheConfig.enabled) { + IosVideoCache.clear() + } + } + /** * Toggles the fullscreen state of the video player */ diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.jvm.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.jvm.kt index 89813226..e0323d2e 100644 --- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.jvm.kt +++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.jvm.kt @@ -9,7 +9,10 @@ import io.github.kdroidfilter.composemediaplayer.util.CurrentPlatform import io.github.kdroidfilter.composemediaplayer.windows.WindowsVideoPlayerState import io.github.vinceglb.filekit.PlatformFile -actual fun createVideoPlayerState(audioMode: AudioMode): VideoPlayerState = DefaultVideoPlayerState() +actual fun createVideoPlayerState( + audioMode: AudioMode, + cacheConfig: CacheConfig, +): VideoPlayerState = DefaultVideoPlayerState() /** * Represents the state and behavior of a video player. This class provides properties diff --git a/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.web.kt b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.web.kt index 0ed52315..6a88271d 100644 --- a/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.web.kt +++ b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.web.kt @@ -22,10 +22,12 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.io.IOException import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds import kotlin.time.TimeSource -actual fun createVideoPlayerState(audioMode: AudioMode): VideoPlayerState = DefaultVideoPlayerState() +actual fun createVideoPlayerState( + audioMode: AudioMode, + cacheConfig: CacheConfig, +): VideoPlayerState = DefaultVideoPlayerState() /** * Implementation of VideoPlayerState for WebAssembly/JavaScript platform.