Skip to content

Commit 5048cd9

Browse files
authored
Merge pull request #145 from kdroidFilter/preview-support
Add support for preview mode in VideoPlayerState and VideoPlayerSurface.
2 parents f930b34 + a396981 commit 5048cd9

8 files changed

Lines changed: 373 additions & 91 deletions

File tree

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

Lines changed: 161 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,11 @@ internal val androidVideoLogger = Logger.withTag("AndroidVideoPlayerSurface")
4141

4242
@UnstableApi
4343
@Stable
44-
actual open class VideoPlayerState {
45-
private val context: Context = ContextProvider.getContext()
44+
actual open class VideoPlayerState internal constructor(isInPreview: Boolean) {
45+
actual constructor() : this(false)
46+
47+
private var appContext: Context? = null
48+
internal var previewMode: Boolean = isInPreview
4649
internal var exoPlayer: ExoPlayer? = null
4750
private var updateJob: Job? = null
4851
private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
@@ -214,12 +217,29 @@ actual open class VideoPlayerState {
214217

215218

216219
init {
217-
audioProcessor.setOnAudioLevelUpdateListener { left, right ->
218-
_leftLevel = left
219-
_rightLevel = right
220+
if (!previewMode) {
221+
audioProcessor.setOnAudioLevelUpdateListener { left, right ->
222+
_leftLevel = left
223+
_rightLevel = right
224+
}
225+
ensureInitialized()
226+
}
227+
}
228+
229+
private fun ensureInitialized(): Boolean {
230+
synchronized(playerInitializationLock) {
231+
if (isPlayerReleased) return false
232+
if (exoPlayer != null) return true
233+
234+
val context = appContext ?: runCatching { ContextProvider.getContext().applicationContext }
235+
.getOrNull()
236+
?: return false
237+
238+
appContext = context
239+
initializePlayer(context)
240+
registerScreenLockReceiver(context)
241+
return exoPlayer != null
220242
}
221-
initializePlayer()
222-
registerScreenLockReceiver()
223243
}
224244

225245
private fun shouldUseConservativeCodecHandling(): Boolean {
@@ -239,8 +259,8 @@ actual open class VideoPlayerState {
239259
manufacturer.equals("mediatek", ignoreCase = true)
240260
}
241261

242-
private fun registerScreenLockReceiver() {
243-
unregisterScreenLockReceiver()
262+
private fun registerScreenLockReceiver(context: Context) {
263+
unregisterScreenLockReceiver(context)
244264

245265
screenLockReceiver = object : BroadcastReceiver() {
246266
override fun onReceive(context: Context?, intent: Intent?) {
@@ -288,11 +308,16 @@ actual open class VideoPlayerState {
288308
addAction(Intent.ACTION_SCREEN_OFF)
289309
addAction(Intent.ACTION_SCREEN_ON)
290310
}
291-
context.registerReceiver(screenLockReceiver, filter)
292-
androidVideoLogger.d { "Screen lock receiver registered" }
311+
try {
312+
context.registerReceiver(screenLockReceiver, filter)
313+
androidVideoLogger.d { "Screen lock receiver registered" }
314+
} catch (e: Exception) {
315+
androidVideoLogger.e { "Error registering screen lock receiver: ${e.message}" }
316+
screenLockReceiver = null
317+
}
293318
}
294319

295-
private fun unregisterScreenLockReceiver() {
320+
private fun unregisterScreenLockReceiver(context: Context) {
296321
screenLockReceiver?.let {
297322
try {
298323
context.unregisterReceiver(it)
@@ -304,45 +329,50 @@ actual open class VideoPlayerState {
304329
}
305330
}
306331

307-
private fun initializePlayer() {
332+
private fun initializePlayer(context: Context) {
308333
synchronized(playerInitializationLock) {
309-
if (isPlayerReleased) return
310-
311-
val audioSink = DefaultAudioSink.Builder(context)
312-
.setAudioProcessors(arrayOf(audioProcessor))
313-
.build()
334+
if (isPlayerReleased || exoPlayer != null) return
314335

315-
val renderersFactory = object : DefaultRenderersFactory(context) {
316-
override fun buildAudioSink(
317-
context: Context,
318-
enableFloatOutput: Boolean,
319-
enableAudioTrackPlaybackParams: Boolean
320-
): AudioSink = audioSink
321-
}.apply {
322-
setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER)
323-
// Activer le fallback du décodeur pour une meilleure stabilité
324-
setEnableDecoderFallback(true)
325-
326-
// Sur les appareils problématiques, utiliser des paramètres plus conservateurs
327-
if (shouldUseConservativeCodecHandling()) {
328-
// On ne peut pas désactiver l'async queueing car la méthode n'existe pas
329-
// Mais on peut utiliser le MediaCodecSelector par défaut
330-
setMediaCodecSelector(MediaCodecSelector.DEFAULT)
336+
try {
337+
val audioSink = DefaultAudioSink.Builder(context)
338+
.setAudioProcessors(arrayOf(audioProcessor))
339+
.build()
340+
341+
val renderersFactory = object : DefaultRenderersFactory(context) {
342+
override fun buildAudioSink(
343+
context: Context,
344+
enableFloatOutput: Boolean,
345+
enableAudioTrackPlaybackParams: Boolean
346+
): AudioSink = audioSink
347+
}.apply {
348+
setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER)
349+
// Activer le fallback du décodeur pour une meilleure stabilité
350+
setEnableDecoderFallback(true)
351+
352+
// Sur les appareils problématiques, utiliser des paramètres plus conservateurs
353+
if (shouldUseConservativeCodecHandling()) {
354+
// On ne peut pas désactiver l'async queueing car la méthode n'existe pas
355+
// Mais on peut utiliser le MediaCodecSelector par défaut
356+
setMediaCodecSelector(MediaCodecSelector.DEFAULT)
357+
}
331358
}
332-
}
333359

334-
exoPlayer = ExoPlayer.Builder(context)
335-
.setRenderersFactory(renderersFactory)
336-
.setHandleAudioBecomingNoisy(true)
337-
.setWakeMode(C.WAKE_MODE_LOCAL)
338-
.setPauseAtEndOfMediaItems(false)
339-
.setReleaseTimeoutMs(2000) // Augmenter le timeout de libération
340-
.build()
341-
.apply {
342-
playerListener = createPlayerListener()
343-
addListener(playerListener!!)
344-
volume = _volume
345-
}
360+
exoPlayer = ExoPlayer.Builder(context)
361+
.setRenderersFactory(renderersFactory)
362+
.setHandleAudioBecomingNoisy(true)
363+
.setWakeMode(C.WAKE_MODE_LOCAL)
364+
.setPauseAtEndOfMediaItems(false)
365+
.setReleaseTimeoutMs(2000) // Augmenter le timeout de libération
366+
.build()
367+
.apply {
368+
playerListener = createPlayerListener()
369+
addListener(playerListener!!)
370+
volume = _volume
371+
}
372+
} catch (e: Exception) {
373+
androidVideoLogger.e { "Error initializing player: ${e.message}" }
374+
exoPlayer = null
375+
}
346376
}
347377
}
348378

@@ -460,7 +490,12 @@ actual open class VideoPlayerState {
460490
player.release()
461491

462492
// Réinitialiser
463-
initializePlayer()
493+
exoPlayer = null
494+
playerListener = null
495+
appContext?.let { context ->
496+
initializePlayer(context)
497+
registerScreenLockReceiver(context)
498+
}
464499

465500
// Restaurer l'élément média et la position
466501
currentMediaItem?.let {
@@ -509,12 +544,32 @@ actual open class VideoPlayerState {
509544
}
510545

511546
actual fun openUri(uri: String, initializeplayerState: InitialPlayerState) {
547+
if (previewMode) {
548+
_error = null
549+
_hasMedia = true
550+
_isPlaying = initializeplayerState == InitialPlayerState.PLAY
551+
return
552+
}
553+
if (!ensureInitialized()) {
554+
_error = VideoPlayerError.UnknownError("Android context is not available (preview or missing ContextProvider initialization).")
555+
return
556+
}
512557
val mediaItemBuilder = MediaItem.Builder().setUri(uri)
513558
val mediaItem = mediaItemBuilder.build()
514559
openFromMediaItem(mediaItem, initializeplayerState)
515560
}
516561

517562
actual fun openFile(file: PlatformFile, initializeplayerState: InitialPlayerState) {
563+
if (previewMode) {
564+
_error = null
565+
_hasMedia = true
566+
_isPlaying = initializeplayerState == InitialPlayerState.PLAY
567+
return
568+
}
569+
if (!ensureInitialized()) {
570+
_error = VideoPlayerError.UnknownError("Android context is not available (preview or missing ContextProvider initialization).")
571+
return
572+
}
518573
val mediaItemBuilder = MediaItem.Builder()
519574
val videoUri: Uri = when (val androidFile = file.androidFile) {
520575
is AndroidFile.UriWrapper -> androidFile.uri
@@ -529,43 +584,55 @@ actual open class VideoPlayerState {
529584
synchronized(playerInitializationLock) {
530585
if (isPlayerReleased) return
531586

532-
exoPlayer?.let { player ->
533-
player.stop()
534-
player.clearMediaItems()
535-
try {
536-
_error = null
537-
resetStates(keepMedia = true)
587+
val player = exoPlayer ?: run {
588+
_isPlaying = false
589+
_hasMedia = false
590+
_error = VideoPlayerError.UnknownError("Video player is not initialized.")
591+
return
592+
}
538593

539-
// Extraire les métadonnées avant de préparer le lecteur
540-
extractMediaItemMetadata(mediaItem)
541-
542-
player.setMediaItem(mediaItem)
543-
player.prepare()
544-
player.volume = volume
545-
player.repeatMode = if (loop) Player.REPEAT_MODE_ALL else Player.REPEAT_MODE_OFF
546-
547-
// Contrôler l'état de lecture initial
548-
if (initializeplayerState == InitialPlayerState.PLAY) {
549-
player.play()
550-
_hasMedia = true
551-
} else {
552-
player.pause()
553-
_isPlaying = false
554-
_hasMedia = true
555-
}
556-
} catch (e: Exception) {
557-
androidVideoLogger.d { "Error opening media: ${e.message}" }
594+
player.stop()
595+
player.clearMediaItems()
596+
try {
597+
_error = null
598+
resetStates(keepMedia = true)
599+
600+
// Extraire les métadonnées avant de préparer le lecteur
601+
extractMediaItemMetadata(mediaItem)
602+
603+
player.setMediaItem(mediaItem)
604+
player.prepare()
605+
player.volume = volume
606+
player.repeatMode = if (loop) Player.REPEAT_MODE_ALL else Player.REPEAT_MODE_OFF
607+
608+
// Contrôler l'état de lecture initial
609+
if (initializeplayerState == InitialPlayerState.PLAY) {
610+
player.play()
611+
_hasMedia = true
612+
} else {
613+
player.pause()
558614
_isPlaying = false
559-
_hasMedia = false
560-
_error = VideoPlayerError.SourceError("Failed to load media: ${e.message}")
615+
_hasMedia = true
561616
}
617+
} catch (e: Exception) {
618+
androidVideoLogger.d { "Error opening media: ${e.message}" }
619+
_isPlaying = false
620+
_hasMedia = false
621+
_error = VideoPlayerError.SourceError("Failed to load media: ${e.message}")
562622
}
563623
}
564624
}
565625

566626
actual fun play() {
567627
synchronized(playerInitializationLock) {
568628
if (!isPlayerReleased) {
629+
if (previewMode && exoPlayer == null) {
630+
_hasMedia = true
631+
_isPlaying = true
632+
return
633+
}
634+
635+
ensureInitialized()
569636
exoPlayer?.let { player ->
570637
if (player.playbackState == Player.STATE_IDLE) {
571638
player.prepare()
@@ -580,6 +647,12 @@ actual open class VideoPlayerState {
580647
actual fun pause() {
581648
synchronized(playerInitializationLock) {
582649
if (!isPlayerReleased) {
650+
if (previewMode && exoPlayer == null) {
651+
_isPlaying = false
652+
return
653+
}
654+
655+
ensureInitialized()
583656
exoPlayer?.pause()
584657
}
585658
}
@@ -588,6 +661,14 @@ actual open class VideoPlayerState {
588661
actual fun stop() {
589662
synchronized(playerInitializationLock) {
590663
if (!isPlayerReleased) {
664+
if (previewMode && exoPlayer == null) {
665+
_hasMedia = false
666+
_isPlaying = false
667+
resetStates(keepMedia = true)
668+
return
669+
}
670+
671+
ensureInitialized()
591672
exoPlayer?.let { player ->
592673
player.stop()
593674
player.seekTo(0)
@@ -711,8 +792,11 @@ actual open class VideoPlayerState {
711792

712793
playerListener = null
713794
exoPlayer = null
714-
unregisterScreenLockReceiver()
795+
appContext?.let { unregisterScreenLockReceiver(it) }
715796
resetStates()
716797
}
717798
}
718-
}
799+
}
800+
801+
internal actual fun createVideoPlayerState(isInPreview: Boolean): VideoPlayerState =
802+
VideoPlayerState(isInPreview)

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import androidx.compose.ui.Alignment
1313
import androidx.compose.ui.Modifier
1414
import androidx.compose.ui.graphics.Color
1515
import androidx.compose.ui.layout.ContentScale
16+
import androidx.compose.ui.platform.LocalInspectionMode
1617
import androidx.compose.ui.viewinterop.AndroidView
1718
import androidx.media3.common.util.UnstableApi
1819
import androidx.media3.ui.AspectRatioFrameLayout
@@ -66,6 +67,10 @@ private fun VideoPlayerSurfaceInternal(
6667
surfaceType: SurfaceType,
6768
overlay: @Composable () -> Unit
6869
) {
70+
if (LocalInspectionMode.current) {
71+
VideoPlayerSurfacePreview(modifier = modifier, overlay = overlay)
72+
return
73+
}
6974
// Use rememberSaveable to preserve fullscreen state across configuration changes
7075
var isFullscreen by rememberSaveable {
7176
mutableStateOf(playerState.isFullscreen)
@@ -297,4 +302,4 @@ private fun createPlayerViewWithSurfaceType(context: Context, surfaceType: Surfa
297302
throw RuntimeException("Unable to create PlayerView", e2)
298303
}
299304
}
300-
}
305+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package io.github.kdroidfilter.composemediaplayer
2+
3+
import kotlin.test.AfterTest
4+
import kotlin.test.BeforeTest
5+
import kotlin.test.Test
6+
import kotlinx.coroutines.Dispatchers
7+
import kotlinx.coroutines.ExperimentalCoroutinesApi
8+
import kotlinx.coroutines.test.UnconfinedTestDispatcher
9+
import kotlinx.coroutines.test.resetMain
10+
import kotlinx.coroutines.test.setMain
11+
12+
@OptIn(ExperimentalCoroutinesApi::class)
13+
class VideoPlayerStatePreviewTest {
14+
private val mainDispatcher = UnconfinedTestDispatcher()
15+
16+
@BeforeTest
17+
fun setUp() {
18+
Dispatchers.setMain(mainDispatcher)
19+
}
20+
21+
@AfterTest
22+
fun tearDown() {
23+
Dispatchers.resetMain()
24+
}
25+
26+
@Test
27+
fun createStateWithoutInitializedContextProviderDoesNotThrow() {
28+
val playerState = VideoPlayerState()
29+
playerState.dispose()
30+
}
31+
}
32+

0 commit comments

Comments
 (0)