Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions README.MD
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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]
Expand Down
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
2 changes: 2 additions & 0 deletions mediaplayer/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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<Activity> = WeakReference(null)
Expand Down Expand Up @@ -411,7 +416,7 @@ open class DefaultVideoPlayerState(
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
.build()

exoPlayer =
val playerBuilder =
ExoPlayer
.Builder(context)
.setRenderersFactory(renderersFactory)
Expand All @@ -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()
Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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" }
}
}
Loading