diff --git a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/DefaultPillarbox.kt b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/DefaultPillarbox.kt index 989afdb19..8ccdf93b2 100644 --- a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/DefaultPillarbox.kt +++ b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/DefaultPillarbox.kt @@ -11,6 +11,7 @@ import androidx.media3.exoplayer.LoadControl import ch.srgssr.pillarbox.core.business.akamai.AkamaiTokenDataSource import ch.srgssr.pillarbox.core.business.integrationlayer.service.DefaultMediaCompositionDataSource import ch.srgssr.pillarbox.core.business.tracker.DefaultMediaItemTrackerRepository +import ch.srgssr.pillarbox.player.PillarboxLoadControl import ch.srgssr.pillarbox.player.PillarboxPlayer import ch.srgssr.pillarbox.player.SeekIncrement import ch.srgssr.pillarbox.player.data.MediaItemSource @@ -42,7 +43,7 @@ object DefaultPillarbox { mediaCompositionDataSource = DefaultMediaCompositionDataSource(), ), dataSourceFactory: DataSource.Factory = AkamaiTokenDataSource.Factory(), - loadControl: LoadControl = DefaultLoadControl(), + loadControl: LoadControl = PillarboxLoadControl(), ): PillarboxPlayer { return PillarboxPlayer( context = context, diff --git a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/di/PlayerModule.kt b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/di/PlayerModule.kt index 02811c8dd..cc23f3f03 100644 --- a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/di/PlayerModule.kt +++ b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/di/PlayerModule.kt @@ -5,7 +5,6 @@ package ch.srgssr.pillarbox.demo.shared.di import android.content.Context -import androidx.media3.exoplayer.SeekParameters import ch.srg.dataProvider.integrationlayer.dependencies.modules.IlServiceModule import ch.srg.dataProvider.integrationlayer.dependencies.modules.OkHttpModule import ch.srgssr.dataprovider.paging.DataProviderPaging @@ -16,7 +15,6 @@ import ch.srgssr.pillarbox.core.business.integrationlayer.service.IlHost import ch.srgssr.pillarbox.core.business.integrationlayer.service.Vector.getVector import ch.srgssr.pillarbox.demo.shared.data.MixedMediaItemSource import ch.srgssr.pillarbox.demo.shared.ui.integrationLayer.data.ILRepository -import ch.srgssr.pillarbox.player.PillarboxLoadControl import ch.srgssr.pillarbox.player.PillarboxPlayer import java.net.URL @@ -47,10 +45,7 @@ object PlayerModule { return DefaultPillarbox( context = context, mediaItemSource = provideMixedItemSource(context, ilHost), - loadControl = PillarboxLoadControl(smoothSeeking = true) - ).apply { - setSeekParameters(SeekParameters.CLOSEST_SYNC) - } + ) } /** diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/PlayerView.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/PlayerView.kt index c6c5666b2..3c599820b 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/PlayerView.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/PlayerView.kt @@ -96,7 +96,7 @@ fun PlayerView( player = player, scaleMode = scaleMode ) { - if (isBuffering) { + if (isBuffering && !isSliderDragged) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center), color = Color.White) } diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/controls/PlayerTimeSlider.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/controls/PlayerTimeSlider.kt index 697c53930..2d86bfc2a 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/controls/PlayerTimeSlider.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/controls/PlayerTimeSlider.kt @@ -17,6 +17,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.media3.common.Player +import ch.srgssr.pillarbox.player.PillarboxExoPlayer import ch.srgssr.pillarbox.player.extension.canSeek import ch.srgssr.pillarbox.ui.ProgressTrackerState import ch.srgssr.pillarbox.ui.SimpleProgressTrackerState @@ -41,7 +42,7 @@ fun rememberProgressTrackerState( coroutineScope: CoroutineScope = rememberCoroutineScope() ): ProgressTrackerState { return remember(player, smoothTracker) { - if (smoothTracker) { + if (smoothTracker && player is PillarboxExoPlayer) { SmoothProgressTrackerState(player, coroutineScope) } else { SimpleProgressTrackerState(player, coroutineScope) diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/SmoothSeekingShowcase.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/SmoothSeekingShowcase.kt index 764878215..b6a03c1a7 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/SmoothSeekingShowcase.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/SmoothSeekingShowcase.kt @@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -17,6 +16,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -24,11 +24,14 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.LifecycleStartEffect -import androidx.lifecycle.viewmodel.compose.viewModel import androidx.media3.common.Player +import ch.srgssr.pillarbox.core.business.DefaultPillarbox import ch.srgssr.pillarbox.demo.R +import ch.srgssr.pillarbox.demo.shared.data.DemoItem +import ch.srgssr.pillarbox.demo.shared.di.PlayerModule import ch.srgssr.pillarbox.demo.ui.player.controls.PlayerPlaybackRow import ch.srgssr.pillarbox.demo.ui.player.controls.PlayerTimeSlider import ch.srgssr.pillarbox.demo.ui.player.controls.rememberProgressTrackerState @@ -42,17 +45,35 @@ import ch.srgssr.pillarbox.ui.widget.player.PlayerSurface */ @Composable fun SmoothSeekingShowcase() { - val smoothSeekingViewModel: SmoothSeekingViewModel = viewModel() - val player = smoothSeekingViewModel.player + val context = LocalContext.current + val player = remember { + DefaultPillarbox( + context = context, + mediaItemSource = PlayerModule.provideMixedItemSource(context) + ).apply { + addMediaItem(DemoItem.UnifiedStreamingOnDemand_Dash_TrickPlay.toMediaItem()) + addMediaItem(DemoItem.UnifiedStreamingOnDemandTrickplay.toMediaItem()) + addMediaItem(DemoItem.UnifiedStreamingOnDemand_Dash_FragmentedMP4.toMediaItem()) + addMediaItem(DemoItem.OnDemandHLS.toMediaItem()) + addMediaItem(DemoItem.GoogleDashH265.toMediaItem()) + } + } + DisposableEffect(Unit) { + player.prepare() + player.play() + onDispose { + player.release() + } + } var smoothSeekingEnabled by remember { mutableStateOf(false) } Column { - Box(modifier = Modifier.aspectRatio(16 / 9f)) { + Box { val playbackState by player.playbackStateAsState() val isBuffering = playbackState == Player.STATE_BUFFERING - PlayerSurface(player = player) { + PlayerSurface(player = player, defaultAspectRatio = 16 / 9f) { if (isBuffering) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center), color = Color.White) @@ -86,7 +107,6 @@ fun SmoothSeekingShowcase() { checked = smoothSeekingEnabled, onCheckedChange = { enabled -> smoothSeekingEnabled = enabled - smoothSeekingViewModel.setSmoothSeekingEnabled(enabled) } ) diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/SmoothSeekingViewModel.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/SmoothSeekingViewModel.kt deleted file mode 100644 index 7da3ecfd0..000000000 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/SmoothSeekingViewModel.kt +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright (c) SRG SSR. All rights reserved. - * License information is available from the LICENSE file. - */ -package ch.srgssr.pillarbox.demo.ui.showcases.misc - -import android.app.Application -import androidx.lifecycle.AndroidViewModel -import androidx.media3.exoplayer.SeekParameters -import ch.srgssr.pillarbox.core.business.DefaultPillarbox -import ch.srgssr.pillarbox.demo.shared.data.DemoItem -import ch.srgssr.pillarbox.demo.shared.di.PlayerModule -import ch.srgssr.pillarbox.player.PillarboxLoadControl - -/** - * Smooth seeking view model - * - * @param application - */ -class SmoothSeekingViewModel(application: Application) : AndroidViewModel(application) { - private val loadControl = PillarboxLoadControl() - - /** - * Player - */ - val player = DefaultPillarbox( - context = application, - loadControl = loadControl, - mediaItemSource = PlayerModule.provideMixedItemSource(application) - ) - - init { - player.prepare() - player.play() - player.setMediaItem(DemoItem.UnifiedStreamingOnDemand_Dash_TrickPlay.toMediaItem()) - } - - /** - * Set smooth seeking enabled - * - * @param smoothSeeking true to enable smoothSeeking. - */ - fun setSmoothSeekingEnabled(smoothSeeking: Boolean) { - loadControl.smoothSeeking = smoothSeeking - player.setSeekParameters(if (smoothSeeking) SeekParameters.CLOSEST_SYNC else SeekParameters.DEFAULT) - } - - override fun onCleared() { - player.release() - } -} diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt new file mode 100644 index 000000000..77327ef66 --- /dev/null +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player + +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.SeekParameters + +/** + * Pillarbox [ExoPlayer] interface extension. + */ +interface PillarboxExoPlayer : ExoPlayer { + + /** + * Listener + */ + interface Listener : Player.Listener { + /** + * On smooth seeking enabled changed + * + * @param smoothSeekingEnabled The new value of [smoothSeekingEnabled] + */ + fun onSmoothSeekingEnabledChanged(smoothSeekingEnabled: Boolean) + } + + /** + * Smooth seeking enabled + * + * When [smoothSeekingEnabled] is true, next seek events is send only after the current is done. + * + * To have the best result it is important to + * 1) Pause the player while seeking. + * 2) Set the [ExoPlayer.setSeekParameters] to [SeekParameters.CLOSEST_SYNC]. + */ + var smoothSeekingEnabled: Boolean +} diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxLoadControl.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxLoadControl.kt index 9fbc55f98..c229c58a6 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxLoadControl.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxLoadControl.kt @@ -19,45 +19,37 @@ import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds /** - * Pillarbox [LoadControl] implementation that optimize content loading for smooth seeking. + * Pillarbox [LoadControl] implementation that optimize content loading. * - * @param bufferDurations Buffer duration when [smoothSeeking] is not enabled. - * @property smoothSeeking If enabled, use an optimized [LoadControl]. + * @param bufferDurations Buffer durations to set [DefaultLoadControl.Builder.setBufferDurationsMs]. * @param allocator The [DefaultAllocator] to use in the internal [DefaultLoadControl]. */ class PillarboxLoadControl( - bufferDurations: BufferDurations = BufferDurations(), - var smoothSeeking: Boolean = false, + bufferDurations: BufferDurations = DEFAULT_BUFFER_DURATIONS, private val allocator: DefaultAllocator = DefaultAllocator(true, C.DEFAULT_BUFFER_SEGMENT_SIZE), ) : LoadControl { - private val fastSeekLoadControl: DefaultLoadControl = DefaultLoadControl.Builder() - .setAllocator(allocator) - .setDurations(FAST_SEEK_DURATIONS) - .setPrioritizeTimeOverSizeThresholds(true) - .build() private val defaultLoadControl: DefaultLoadControl = DefaultLoadControl.Builder() .setAllocator(allocator) - .setDurations(bufferDurations) + .setBufferDurationsMs( + bufferDurations.minBufferDuration.inWholeMilliseconds.toInt(), + bufferDurations.maxBufferDuration.inWholeMilliseconds.toInt(), + bufferDurations.bufferForPlayback.inWholeMilliseconds.toInt(), + bufferDurations.bufferForPlaybackAfterRebuffer.inWholeMilliseconds.toInt(), + ) .setPrioritizeTimeOverSizeThresholds(true) + .setBackBuffer(BACK_BUFFER_DURATION_MS, true) .build() - private val activeLoadControl: LoadControl - get() { - return if (smoothSeeking) fastSeekLoadControl else defaultLoadControl - } override fun onPrepared() { - fastSeekLoadControl.onPrepared() defaultLoadControl.onPrepared() } override fun onStopped() { - fastSeekLoadControl.onStopped() defaultLoadControl.onStopped() } override fun onReleased() { - fastSeekLoadControl.onReleased() defaultLoadControl.onReleased() } @@ -66,11 +58,11 @@ class PillarboxLoadControl( } override fun getBackBufferDurationUs(): Long { - return BACK_BUFFER_DURATION_MS + return defaultLoadControl.backBufferDurationUs } override fun retainBackBufferFromKeyframe(): Boolean { - return true + return defaultLoadControl.retainBackBufferFromKeyframe() } override fun shouldContinueLoading( @@ -78,7 +70,7 @@ class PillarboxLoadControl( bufferedDurationUs: Long, playbackSpeed: Float ): Boolean { - return activeLoadControl.shouldContinueLoading(playbackPositionUs, bufferedDurationUs, playbackSpeed) + return defaultLoadControl.shouldContinueLoading(playbackPositionUs, bufferedDurationUs, playbackSpeed) } override fun onTracksSelected( @@ -88,20 +80,9 @@ class PillarboxLoadControl( trackGroups: TrackGroupArray, trackSelections: Array ) { - fastSeekLoadControl.onTracksSelected(timeline, mediaPeriodId, renderers, trackGroups, trackSelections) defaultLoadControl.onTracksSelected(timeline, mediaPeriodId, renderers, trackGroups, trackSelections) } - @Deprecated("Deprecated in Java") - override fun onTracksSelected( - renderers: Array, - trackGroups: TrackGroupArray, - trackSelections: Array - ) { - fastSeekLoadControl.onTracksSelected(renderers, trackGroups, trackSelections) - defaultLoadControl.onTracksSelected(renderers, trackGroups, trackSelections) - } - override fun shouldStartPlayback( timeline: Timeline, mediaPeriodId: MediaSource.MediaPeriodId, @@ -110,17 +91,7 @@ class PillarboxLoadControl( rebuffering: Boolean, targetLiveOffsetUs: Long ): Boolean { - return activeLoadControl.shouldStartPlayback(timeline, mediaPeriodId, bufferedDurationUs, playbackSpeed, rebuffering, targetLiveOffsetUs) - } - - @Deprecated("Deprecated in Java") - override fun shouldStartPlayback( - bufferedDurationUs: Long, - playbackSpeed: Float, - rebuffering: Boolean, - targetLiveOffsetUs: Long - ): Boolean { - return activeLoadControl.shouldStartPlayback(bufferedDurationUs, playbackSpeed, rebuffering, targetLiveOffsetUs) + return defaultLoadControl.shouldStartPlayback(timeline, mediaPeriodId, bufferedDurationUs, playbackSpeed, rebuffering, targetLiveOffsetUs) } /** @@ -140,22 +111,12 @@ class PillarboxLoadControl( val bufferForPlaybackAfterRebuffer: Duration = DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS.milliseconds, ) - private companion object SmoothLoadControl { - private const val BACK_BUFFER_DURATION_MS = 6_000L - private val FAST_SEEK_DURATIONS = BufferDurations( - minBufferDuration = 2.seconds, - maxBufferDuration = 2.seconds, - bufferForPlayback = 2.seconds, - bufferForPlaybackAfterRebuffer = 2.seconds, + private companion object { + private const val BACK_BUFFER_DURATION_MS = 4_000 + private val DEFAULT_BUFFER_DURATIONS = BufferDurations( + bufferForPlayback = 500.milliseconds, + bufferForPlaybackAfterRebuffer = 1.seconds, + minBufferDuration = 1.seconds ) - - private fun DefaultLoadControl.Builder.setDurations(durations: BufferDurations): DefaultLoadControl.Builder { - return setBufferDurationsMs( - durations.minBufferDuration.inWholeMilliseconds.toInt(), - durations.maxBufferDuration.inWholeMilliseconds.toInt(), - durations.bufferForPlayback.inWholeMilliseconds.toInt(), - durations.bufferForPlaybackAfterRebuffer.inWholeMilliseconds.toInt(), - ) - } } } diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxPlayer.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxPlayer.kt index f008a174b..e0172e54a 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxPlayer.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxPlayer.kt @@ -5,6 +5,7 @@ package ch.srgssr.pillarbox.player import android.content.Context +import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackException import androidx.media3.common.PlaybackParameters import androidx.media3.common.Player @@ -12,7 +13,6 @@ import androidx.media3.common.Timeline.Window import androidx.media3.common.TrackSelectionParameters import androidx.media3.datasource.DataSource import androidx.media3.datasource.DefaultHttpDataSource -import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.LoadControl @@ -41,9 +41,26 @@ class PillarboxPlayer internal constructor( private val exoPlayer: ExoPlayer, mediaItemTrackerProvider: MediaItemTrackerProvider? ) : - ExoPlayer by exoPlayer { + ExoPlayer by exoPlayer, PillarboxExoPlayer { + private val listeners = HashSet() private val itemTracker: CurrentMediaItemTracker? private val window = Window() + override var smoothSeekingEnabled: Boolean = false + set(value) { + if (value != field) { + field = value + if (!value) { + seekEnd() + } + clearSeeking() + val listeners = HashSet(listeners) + for (listener in listeners) { + listener.onSmoothSeekingEnabledChanged(value) + } + } + } + private var pendingSeek: Long? = null + private var isSeeking: Boolean = false /** * Enable or disable MediaItem tracking @@ -53,7 +70,7 @@ class PillarboxPlayer internal constructor( get() = itemTracker?.enabled ?: false init { - addListener(ComponentListener()) + exoPlayer.addListener(ComponentListener()) itemTracker = mediaItemTrackerProvider?.let { CurrentMediaItemTracker(this, it) } @@ -66,7 +83,7 @@ class PillarboxPlayer internal constructor( context: Context, mediaItemSource: MediaItemSource, dataSourceFactory: DataSource.Factory = DefaultHttpDataSource.Factory(), - loadControl: LoadControl = DefaultLoadControl(), + loadControl: LoadControl = PillarboxLoadControl(), mediaItemTrackerProvider: MediaItemTrackerProvider = MediaItemTrackerRepository(), seekIncrement: SeekIncrement = SeekIncrement() ) : this( @@ -99,6 +116,98 @@ class PillarboxPlayer internal constructor( mediaItemTrackerProvider = mediaItemTrackerProvider ) + override fun addListener(listener: Player.Listener) { + exoPlayer.addListener(listener) + if (listener is PillarboxExoPlayer.Listener) { + listeners.add(listener) + } + } + + override fun removeListener(listener: Player.Listener) { + exoPlayer.removeListener(listener) + if (listener is PillarboxExoPlayer.Listener) { + listeners.remove(listener) + } + } + + override fun seekTo(positionMs: Long) { + if (!smoothSeekingEnabled) { + exoPlayer.seekTo(positionMs) + return + } + smoothSeekTo(positionMs) + } + + private fun smoothSeekTo(positionMs: Long) { + if (isSeeking) { + pendingSeek = positionMs + return + } + isSeeking = true + exoPlayer.seekTo(positionMs) + } + + override fun seekTo(mediaItemIndex: Int, positionMs: Long) { + if (!smoothSeekingEnabled) { + exoPlayer.seekTo(mediaItemIndex, positionMs) + return + } + smoothSeekTo(mediaItemIndex, positionMs) + } + + private fun smoothSeekTo(mediaItemIndex: Int, positionMs: Long) { + if (mediaItemIndex != currentMediaItemIndex) { + clearSeeking() + exoPlayer.seekTo(mediaItemIndex, positionMs) + return + } + if (isSeeking) { + pendingSeek = positionMs + return + } + exoPlayer.seekTo(mediaItemIndex, positionMs) + } + + override fun seekToDefaultPosition() { + clearSeeking() + exoPlayer.seekToDefaultPosition() + } + + override fun seekToDefaultPosition(mediaItemIndex: Int) { + clearSeeking() + exoPlayer.seekToDefaultPosition(mediaItemIndex) + } + + override fun seekBack() { + clearSeeking() + exoPlayer.seekBack() + } + + override fun seekForward() { + clearSeeking() + exoPlayer.seekForward() + } + + override fun seekToNext() { + clearSeeking() + exoPlayer.seekToNext() + } + + override fun seekToPrevious() { + clearSeeking() + exoPlayer.seekToPrevious() + } + + override fun seekToNextMediaItem() { + clearSeeking() + exoPlayer.seekToNextMediaItem() + } + + override fun seekToPreviousMediaItem() { + clearSeeking() + exoPlayer.seekToPreviousMediaItem() + } + /** * Releases the player. * This method must be called when the player is no longer required. The player must not be used after calling this method. @@ -106,6 +215,7 @@ class PillarboxPlayer internal constructor( * Release call automatically [stop] if the player is not in [Player.STATE_IDLE]. */ override fun release() { + clearSeeking() if (playbackState != Player.STATE_IDLE) { stop() } @@ -132,10 +242,50 @@ class PillarboxPlayer internal constructor( playbackParameters = playbackParameters.withSpeed(speed) } + private fun seekEnd() { + isSeeking = false + pendingSeek?.let { pendingPosition -> + pendingSeek = null + seekTo(pendingPosition) + } + } + + private fun clearSeeking() { + isSeeking = false + pendingSeek = null + } + private inner class ComponentListener : Player.Listener { private val window = Window() + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + clearSeeking() + } + + override fun onRenderedFirstFrame() { + seekEnd() + } + + override fun onPlaybackStateChanged(playbackState: Int) { + when (playbackState) { + Player.STATE_READY -> { + if (isSeeking) { + seekEnd() + } + } + + Player.STATE_IDLE, Player.STATE_ENDED -> { + clearSeeking() + } + + Player.STATE_BUFFERING -> { + // Do nothing + } + } + } + override fun onPlayerError(error: PlaybackException) { + clearSeeking() if (error.errorCode == PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW) { setPlaybackSpeed(NormalSpeed) seekToDefaultPosition() diff --git a/pillarbox-ui/build.gradle.kts b/pillarbox-ui/build.gradle.kts index c59803905..b39b8fd19 100644 --- a/pillarbox-ui/build.gradle.kts +++ b/pillarbox-ui/build.gradle.kts @@ -49,7 +49,7 @@ android { } dependencies { - implementation(project(":pillarbox-player")) + api(project(":pillarbox-player")) implementation(libs.androidx.annotation) api(libs.androidx.compose.animation) @@ -68,6 +68,7 @@ dependencies { implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.guava) api(libs.androidx.media3.common) + implementation(libs.androidx.media3.exoplayer) api(libs.androidx.media3.ui) implementation(libs.kotlinx.coroutines.core) diff --git a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/SmoothProgressTrackerState.kt b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/SmoothProgressTrackerState.kt index 3ef099ba7..c0bcbf467 100644 --- a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/SmoothProgressTrackerState.kt +++ b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/SmoothProgressTrackerState.kt @@ -6,12 +6,10 @@ package ch.srgssr.pillarbox.ui import androidx.media3.common.C import androidx.media3.common.Player -import ch.srgssr.pillarbox.player.extension.getPlaybackSpeed +import androidx.media3.exoplayer.SeekParameters +import ch.srgssr.pillarbox.player.PillarboxExoPlayer import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.callbackFlow -import kotlinx.coroutines.flow.launchIn import kotlin.time.Duration /** @@ -21,94 +19,45 @@ import kotlin.time.Duration * @param coroutineScope */ class SmoothProgressTrackerState( - private val player: Player, + private val player: PillarboxExoPlayer, coroutineScope: CoroutineScope ) : ProgressTrackerState { - private val playerSeekState = callbackFlow { - val listener = object : Player.Listener { - override fun onPositionDiscontinuity( - oldPosition: Player.PositionInfo, - newPosition: Player.PositionInfo, - reason: Int - ) { - if (reason == Player.DISCONTINUITY_REASON_SEEK || reason == Player.DISCONTINUITY_REASON_SEEK_ADJUSTMENT) { - isSeeking = true - } - } - - override fun onPlaybackStateChanged(playbackState: Int) { - if (playbackState == Player.STATE_READY && isSeeking) { - seekToPending() - isSeeking = false - } - } - } - player.addListener(listener) - awaitClose { - player.removeListener(listener) - } - } - - private val simpleProgressTrackerState = SimpleProgressTrackerState(player, coroutineScope) - - private var isSeeking = false - private var pendingSeek: Duration? = null - private var startChanging = false - - private var storedPlaybackSpeed = player.getPlaybackSpeed() + private var storedSeekParameters = player.seekParameters private var storedPlayWhenReady = player.playWhenReady + private var storedSmoothSeeking = player.smoothSeekingEnabled private var storedTrackSelectionParameters = player.trackSelectionParameters - + private val simpleProgressTrackerState = SimpleProgressTrackerState(player, coroutineScope) + private var startChanging = false override val progress: StateFlow = simpleProgressTrackerState.progress - init { - playerSeekState.launchIn(coroutineScope) - } - override fun onChanged(progress: Duration) { simpleProgressTrackerState.onChanged(progress) - if (isSeeking) { - pendingSeek = progress - return - } - if (!startChanging) { startChanging = true storedPlayWhenReady = player.playWhenReady + storedSmoothSeeking = player.smoothSeekingEnabled + storedSeekParameters = player.seekParameters storedTrackSelectionParameters = player.trackSelectionParameters - + player.setSeekParameters(SeekParameters.CLOSEST_SYNC) + player.smoothSeekingEnabled = true player.playWhenReady = false player.trackSelectionParameters = player.trackSelectionParameters.buildUpon() .setPreferredVideoRoleFlags(C.ROLE_FLAG_TRICK_PLAY) .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true) .setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, true) + .setTrackTypeDisabled(C.TRACK_TYPE_METADATA, true) + .setTrackTypeDisabled(C.TRACK_TYPE_IMAGE, true) .build() - player.setPlaybackSpeed(SEEKING_PLAYBACK_SPEED) } - player.seekTo(progress.inWholeMilliseconds) } override fun onFinished() { + startChanging = false simpleProgressTrackerState.onFinished() - - player.playWhenReady = storedPlayWhenReady player.trackSelectionParameters = storedTrackSelectionParameters - player.setPlaybackSpeed(storedPlaybackSpeed) - - isSeeking = false - pendingSeek = null - startChanging = false - } - - private fun seekToPending() { - pendingSeek?.let { - player.seekTo(it.inWholeMilliseconds) - pendingSeek = null - } - } - - private companion object { - private const val SEEKING_PLAYBACK_SPEED = Float.MAX_VALUE + player.smoothSeekingEnabled = storedSmoothSeeking + player.setSeekParameters(storedSeekParameters) + player.playWhenReady = storedPlayWhenReady } }