Skip to content
Open
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
33 changes: 28 additions & 5 deletions README.MD
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
- [Fullscreen Mode](#️-fullscreen-mode)
- [Picture-in-Picture (PiP)](#-picture-in-picture-pip)
- [Audio Mode](#-audio-mode)
- [Background Playback Behavior](#-background-playback-behavior)
- [Video Caching](#-video-caching)
- [Metadata Support](#-metadata-support)
- [Example Usage](#example-usage)
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.
- **Background Playback Control**: Automatically pauses on background and resumes on foreground (Android & iOS), with an opt-out for background audio playback.
- **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.

Expand Down Expand Up @@ -214,11 +216,11 @@ VideoPlayerSurface(
horizontalArrangement = Arrangement.SpaceBetween
) {
// Your custom controls here
IconButton(onClick = {
if (playerState.isPlaying) playerState.pause() else playerState.play()
IconButton(onClick = {
if (playerState.isPlaying) playerState.pause() else playerState.play()
}) {
Icon(
imageVector = if (playerState.isPlaying)
imageVector = if (playerState.isPlaying)
Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = "Play/Pause",
tint = Color.White
Expand Down Expand Up @@ -374,7 +376,7 @@ playerState.error?.let { error ->

To detect if the video is buffering:

```kotlin
```kotlin
if (playerState.isLoading) {
CircularProgressIndicator()
}
Expand Down Expand Up @@ -545,6 +547,27 @@ val playerState = rememberVideoPlayerState(
> [!NOTE]
> Audio mode is only effective on **Android** and **iOS**. On desktop and web, the parameter is accepted but ignored.

### 📱 Background Playback Behavior

By default, the player automatically pauses when the app moves to the background and resumes when returning to the foreground. You can disable this to allow audio to keep playing in the background:

```kotlin
val playerState = rememberVideoPlayerState()

// Allow playback to continue in the background (audio will keep playing)
playerState.pauseOnBackground = false
```

| Platform | Default behavior | `pauseOnBackground = false` |
| :--- | :--- | :--- |
| **Android** | Pauses on `ON_STOP`, resumes on `ON_START` | Audio continues in background |
| **iOS** | Pauses on `didEnterBackground`, resumes on `willEnterForeground` | Audio continues in background (requires `AVAudioSessionCategoryPlayback`) |
| **Desktop** | No effect | No effect |
| **Web** | No effect | No effect |

> [!NOTE]
> When Picture-in-Picture is active, the player keeps playing regardless of the `pauseOnBackground` setting.

### 💾 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`.
Expand Down Expand Up @@ -670,7 +693,7 @@ fun App() {
) {
Text("Play Video")
}

Button(
onClick = {
val url = "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"
Expand Down
2 changes: 1 addition & 1 deletion mediaplayer/ComposeMediaPlayer.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -41,5 +41,5 @@ Pod::Spec.new do |spec|
SCRIPT
}
]
spec.resources = ['build\compose\cocoapods\compose-resources']
spec.resources = ['build/compose/cocoapods/compose-resources']
end
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@ open class DefaultVideoPlayerState(
private var screenLockReceiver: BroadcastReceiver? = null
private var wasPlayingBeforeScreenLock: Boolean = false

// Background detection
override var pauseOnBackground: Boolean = true
internal var wasPlayingBeforeBackground: Boolean = false

private var _hasMedia by mutableStateOf(false)
override val hasMedia: Boolean get() = _hasMedia

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,36 @@ private fun VideoPlayerSurfaceInternal(
}
}

DisposableEffect(playerState) {
// Pause video when app goes to background, resume when coming back
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(playerState, lifecycleOwner) {
val observer =
LifecycleEventObserver { _, event ->
if (playerState is DefaultVideoPlayerState && playerState.pauseOnBackground) {
when (event) {
Lifecycle.Event.ON_STOP -> {
// Skip if PiP is active — video should keep playing
if (!playerState.isPipActive) {
playerState.wasPlayingBeforeBackground = playerState.isPlaying
if (playerState.isPlaying) {
playerState.pause()
}
}
}
Lifecycle.Event.ON_START -> {
if (playerState.wasPlayingBeforeBackground && !playerState.isPipActive) {
playerState.play()
playerState.wasPlayingBeforeBackground = false
}
}
else -> {}
}
}
}
lifecycleOwner.lifecycle.addObserver(observer)

onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
try {
// Detach the view from the player
if (playerState is DefaultVideoPlayerState) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,17 @@ interface VideoPlayerState {
var isFullscreen: Boolean
val aspectRatio: Float

/**
* Controls whether playback automatically pauses when the app moves to the background
* and resumes when returning to the foreground.
*
* When `true` (default), the player pauses on background and resumes on foreground
* if it was playing before. When `false`, playback continues uninterrupted in the background
* (audio will keep playing on Android; iOS may still pause video rendering).
*/
var pauseOnBackground: Boolean get() = true
set(_) {}

val isPipSupported: Boolean get() = false
var isPipActive: Boolean get() = false
set(value) {}
Expand Down Expand Up @@ -309,6 +320,7 @@ data class PreviewableVideoPlayerState(
override var isPipEnabled: Boolean = false,
override var onPlaybackEnded: (() -> Unit)? = null,
override var onRestart: (() -> Unit)? = null,
override var pauseOnBackground: Boolean = true,
) : VideoPlayerState {
override fun play() {}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
package io.github.kdroidfilter.composemediaplayer

import io.github.kdroidfilter.composemediaplayer.util.TaggedLogger
import kotlin.concurrent.AtomicInt
import platform.Foundation.NSURLCache
import kotlin.concurrent.AtomicInt

private val cacheLogger = TaggedLogger("iOSVideoCache")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,9 @@ open class DefaultVideoPlayerState(
private var backgroundObserver: Any? = null
private var foregroundObserver: Any? = null

// Background behavior
override var pauseOnBackground: Boolean = true

// Flag to track if player was playing before going to background
private var wasPlayingBeforeBackground: Boolean = false

Expand Down Expand Up @@ -401,11 +404,13 @@ open class DefaultVideoPlayerState(
// Store current playing state before background
wasPlayingBeforeBackground = _isPlaying

// If player is paused by the system, update our state to match
player?.let { player ->
if (player.rate == 0.0f) {
iosLogger.d { "Player was paused by system, updating isPlaying state" }
_isPlaying = false
if (pauseOnBackground) {
// If player is paused by the system, update our state to match
player?.let { player ->
if (player.rate == 0.0f) {
iosLogger.d { "Player was paused by system, updating isPlaying state" }
_isPlaying = false
}
}
}
}
Expand All @@ -422,7 +427,7 @@ open class DefaultVideoPlayerState(
if (wasPlayingBeforeBackground) {
iosLogger.d { "Player was playing before background, resuming" }
player?.let { player ->
// Only resume if the player is overridely paused
// Only resume if the player is paused
if (player.rate == 0.0f) {
player.playImmediatelyAtRate(_playbackSpeed)
}
Expand Down