From cd3cea58a445c5e71d324b2ce5a3eba8702ec6f3 Mon Sep 17 00:00:00 2001 From: Koen J Date: Mon, 7 Jul 2025 10:52:42 +0200 Subject: [PATCH 1/2] Fixed race condition when awaiting and changing video source.. --- .../views/video/FutoVideoPlayerBase.kt | 35 ++++++++++++++++--- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt index 14740c1a..023c79fc 100644 --- a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt +++ b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt @@ -3,10 +3,12 @@ package com.futo.platformplayer.views.video import android.content.Context import android.net.Uri import android.util.AttributeSet +import android.view.LayoutInflater import android.widget.RelativeLayout import androidx.annotation.OptIn import androidx.lifecycle.coroutineScope import androidx.lifecycle.findViewTreeLifecycleOwner +import androidx.lifecycle.lifecycleScope import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackException @@ -29,6 +31,8 @@ import androidx.media3.exoplayer.source.ProgressiveMediaSource import androidx.media3.exoplayer.source.SingleSampleMediaSource import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import com.futo.platformplayer.Settings +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.api.media.models.PlatformAuthorLink import com.futo.platformplayer.api.media.models.chapters.IChapter import com.futo.platformplayer.api.media.models.streams.VideoMuxedSourceDescriptor import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource @@ -52,9 +56,14 @@ import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManif import com.futo.platformplayer.api.media.platforms.js.models.sources.JSHLSManifestAudioSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSVideoUrlRangeSource +import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.constructs.TaskHandler +import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException import com.futo.platformplayer.engine.exceptions.ScriptReloadRequiredException +import com.futo.platformplayer.fragment.mainactivity.main.ChannelFragment +import com.futo.platformplayer.fragment.mainactivity.main.CreatorSearchResultsFragment import com.futo.platformplayer.helpers.VideoHelper import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateApp @@ -71,6 +80,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.ByteArrayInputStream import java.io.File +import java.util.concurrent.atomic.AtomicInteger import kotlin.math.abs abstract class FutoVideoPlayerBase : RelativeLayout { @@ -117,7 +127,8 @@ abstract class FutoVideoPlayerBase : RelativeLayout { private var _didCallSourceChange = false; private var _lastState: Int = -1; - + private val _swapIdAudio = AtomicInteger(0) + private val _swapIdVideo = AtomicInteger(0) var targetTrackVideoHeight = -1 private set @@ -436,13 +447,15 @@ abstract class FutoVideoPlayerBase : RelativeLayout { private fun swapSourceInternal(videoSource: IVideoSource?, play: Boolean, resume: Boolean): Boolean { + setLoading(false) + val swapId = _swapIdVideo.incrementAndGet() _lastGeneratedDash = null; val didSet = when(videoSource) { is LocalVideoSource -> { swapVideoSourceLocal(videoSource); true; } is JSVideoUrlRangeSource -> { swapVideoSourceUrlRange(videoSource); true; } is IDashManifestWidevineSource -> { swapVideoSourceDashWidevine(videoSource); true } is IDashManifestSource -> { swapVideoSourceDash(videoSource); true;} - is JSDashManifestRawSource -> swapVideoSourceDashRaw(videoSource, play, resume); + is JSDashManifestRawSource -> swapVideoSourceDashRaw(videoSource, play, resume, swapId); is IHLSManifestSource -> { swapVideoSourceHLS(videoSource); true; } is IVideoUrlWidevineSource -> { swapVideoSourceUrlWidevine(videoSource); true; } is IVideoUrlSource -> { swapVideoSourceUrl(videoSource); true; } @@ -453,11 +466,13 @@ abstract class FutoVideoPlayerBase : RelativeLayout { return didSet; } private fun swapSourceInternal(audioSource: IAudioSource?, play: Boolean, resume: Boolean): Boolean { + setLoading(false) + val swapId = _swapIdAudio.incrementAndGet() val didSet = when(audioSource) { is LocalAudioSource -> {swapAudioSourceLocal(audioSource); true; } is JSAudioUrlRangeSource -> { swapAudioSourceUrlRange(audioSource); true; } is JSHLSManifestAudioSource -> { swapAudioSourceHLS(audioSource); true; } - is JSDashManifestRawAudioSource -> swapAudioSourceDashRaw(audioSource, play, resume); + is JSDashManifestRawAudioSource -> swapAudioSourceDashRaw(audioSource, play, resume, swapId); is IAudioUrlWidevineSource -> { swapAudioSourceUrlWidevine(audioSource); true; } is IAudioUrlSource -> { swapAudioSourceUrl(audioSource); true; } null -> { _lastAudioMediaSource = null; true; } @@ -564,7 +579,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout { }.createMediaSource(MediaItem.fromUri(videoSource.url)) } @OptIn(UnstableApi::class) - private fun swapVideoSourceDashRaw(videoSource: JSDashManifestRawSource, play: Boolean, resume: Boolean): Boolean { + private fun swapVideoSourceDashRaw(videoSource: JSDashManifestRawSource, play: Boolean, resume: Boolean, swapId: Int): Boolean { Logger.i(TAG, "Loading VideoSource [Dash]"); if(videoSource.hasGenerate) { @@ -583,6 +598,10 @@ abstract class FutoVideoPlayerBase : RelativeLayout { } } val generated = generatedDef.await(); + if (_swapIdVideo.get() != swapId) { + return@launch + } + withContext(Dispatchers.Main) { setLoading(false) } @@ -708,7 +727,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout { } @OptIn(UnstableApi::class) - private fun swapAudioSourceDashRaw(audioSource: JSDashManifestRawAudioSource, play: Boolean, resume: Boolean): Boolean { + private fun swapAudioSourceDashRaw(audioSource: JSDashManifestRawAudioSource, play: Boolean, resume: Boolean, swapId: Int): Boolean { Logger.i(TAG, "Loading AudioSource [DashRaw]"); if(audioSource.hasGenerate) { findViewTreeLifecycleOwner()?.lifecycle?.coroutineScope?.launch(Dispatchers.IO) { @@ -726,6 +745,9 @@ abstract class FutoVideoPlayerBase : RelativeLayout { } } val generated = generatedDef.await(); + if (_swapIdAudio.get() != swapId) { + return@launch + } withContext(Dispatchers.Main) { setLoading(false) } @@ -889,6 +911,9 @@ abstract class FutoVideoPlayerBase : RelativeLayout { fun clear() { exoPlayer?.player?.stop(); exoPlayer?.player?.clearMediaItems(); + setLoading(false) + _swapIdVideo.incrementAndGet() + _swapIdAudio.incrementAndGet() _lastVideoMediaSource = null; _lastAudioMediaSource = null; _lastSubtitleMediaSource = null; From 37dc7780099d5afb4f4d2ecdeca4138bc14dd33d Mon Sep 17 00:00:00 2001 From: Koen J Date: Mon, 7 Jul 2025 12:45:45 +0200 Subject: [PATCH 2/2] Fixed casting. --- .../platformplayer/casting/StateCasting.kt | 55 +++++++++++++++---- .../mainactivity/main/VideoDetailView.kt | 13 ++++- .../platformplayer/views/casting/CastView.kt | 28 ++++++++++ app/src/main/res/layout/view_cast.xml | 10 ++++ 4 files changed, 93 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt index 1e8e1830..a4084383 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt @@ -64,6 +64,7 @@ import java.net.URLDecoder import java.net.URLEncoder import java.util.Collections import java.util.UUID +import java.util.concurrent.atomic.AtomicInteger class StateCasting { private val _scopeIO = CoroutineScope(Dispatchers.IO); @@ -89,6 +90,7 @@ class StateCasting { var _resumeCastingDevice: CastingDeviceInfo? = null; private var _nsdManager: NsdManager? = null val isCasting: Boolean get() = activeDevice != null; + private val _castId = AtomicInteger(0) private val _discoveryListeners = mapOf( "_googlecast._tcp" to createDiscoveryListener(::addOrUpdateChromeCastDevice), @@ -432,13 +434,18 @@ class StateCasting { action(); } - fun castIfAvailable(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, ms: Long = -1, speed: Double?): Boolean { + fun cancel() { + _castId.incrementAndGet() + } + + fun castIfAvailable(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, ms: Long = -1, speed: Double?, onLoadingEstimate: ((Int) -> Unit)? = null, onLoading: ((Boolean) -> Unit)? = null): Boolean { val ad = activeDevice ?: return false; if (ad.connectionState != CastConnectionState.CONNECTED) { return false; } val resumePosition = if (ms > 0L) (ms.toDouble() / 1000.0) else 0.0; + val castId = _castId.incrementAndGet() var sourceCount = 0; if (videoSource != null) sourceCount++; @@ -466,7 +473,7 @@ class StateCasting { Logger.i(TAG, "Casting as raw DASH"); try { - castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, audioSource as JSDashManifestRawAudioSource?, subtitleSource, resumePosition, speed); + castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, audioSource as JSDashManifestRawAudioSource?, subtitleSource, resumePosition, speed, castId, onLoadingEstimate, onLoading); } catch (e: Throwable) { Logger.e(TAG, "Failed to start casting DASH raw videoSource=${videoSource} audioSource=${audioSource}.", e); } @@ -529,7 +536,7 @@ class StateCasting { StateApp.instance.scope.launch(Dispatchers.IO) { try { - castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, null, null, resumePosition, speed); + castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, null, null, resumePosition, speed, castId, onLoadingEstimate, onLoading); } catch (e: Throwable) { Logger.e(TAG, "Failed to start casting DASH raw videoSource=${videoSource}.", e); } @@ -539,7 +546,7 @@ class StateCasting { StateApp.instance.scope.launch(Dispatchers.IO) { try { - castDashRaw(contentResolver, video, null, audioSource as JSDashManifestRawAudioSource?, null, resumePosition, speed); + castDashRaw(contentResolver, video, null, audioSource as JSDashManifestRawAudioSource?, null, resumePosition, speed, castId, onLoadingEstimate, onLoading); } catch (e: Throwable) { Logger.e(TAG, "Failed to start casting DASH raw audioSource=${audioSource}.", e); } @@ -1236,7 +1243,7 @@ class StateCasting { } @OptIn(UnstableApi::class) - private suspend fun castDashRaw(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: JSDashManifestRawSource?, audioSource: JSDashManifestRawAudioSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List { + private suspend fun castDashRaw(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: JSDashManifestRawSource?, audioSource: JSDashManifestRawAudioSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?, castId: Int, onLoadingEstimate: ((Int) -> Unit)? = null, onLoading: ((Boolean) -> Unit)? = null) : List { val ad = activeDevice ?: return listOf(); cleanExecutors() @@ -1283,20 +1290,48 @@ class StateCasting { } } - var dashContent = withContext(Dispatchers.IO) { + var dashContent: String = withContext(Dispatchers.IO) { + stopVideo() + //TODO: Include subtitlesURl in the future - return@withContext if (audioSource != null && videoSource != null) { - JSDashManifestMergingRawSource(videoSource, audioSource).generate() + val deferred = if (audioSource != null && videoSource != null) { + JSDashManifestMergingRawSource(videoSource, audioSource).generateAsync(_scopeIO) } else if (audioSource != null) { - audioSource.generate() + audioSource.generateAsync(_scopeIO) } else if (videoSource != null) { - videoSource.generate() + videoSource.generateAsync(_scopeIO) } else { Logger.e(TAG, "Expected at least audio or video to be set") null } + + if (deferred != null) { + try { + withContext(Dispatchers.Main) { + if (deferred.estDuration >= 0) { + onLoadingEstimate?.invoke(deferred.estDuration) + } else { + onLoading?.invoke(true) + } + } + deferred.await() + } finally { + if (castId == _castId.get()) { + withContext(Dispatchers.Main) { + onLoading?.invoke(false) + } + } + } + } else { + return@withContext null + } } ?: throw Exception("Dash is null") + if (castId != _castId.get()) { + Log.i(TAG, "Get DASH cancelled.") + return emptyList() + } + for (representation in representationRegex.findAll(dashContent)) { val mediaType = representation.groups[1]?.value ?: throw Exception("Media type should be found") dashContent = mediaInitializationRegex.replace(dashContent) { diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt index e9be9fd6..d7ca2f7d 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt @@ -806,6 +806,8 @@ class VideoDetailView : ConstraintLayout { _lastVideoSource = null; _lastAudioSource = null; _lastSubtitleSource = null; + _cast.cancel() + StateCasting.instance.cancel() video = null; _container_content_liveChat?.close(); _player.clear(); @@ -1899,7 +1901,13 @@ class VideoDetailView : ConstraintLayout { private fun loadCurrentVideoCast(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, resumePositionMs: Long, speed: Double?) { Logger.i(TAG, "loadCurrentVideoCast(video=$video, videoSource=$videoSource, audioSource=$audioSource, resumePositionMs=$resumePositionMs)") - if(StateCasting.instance.castIfAvailable(context.contentResolver, video, videoSource, audioSource, subtitleSource, resumePositionMs, speed)) { + val castSucceeded = StateCasting.instance.castIfAvailable(context.contentResolver, video, videoSource, audioSource, subtitleSource, resumePositionMs, speed, onLoading = { + _cast.setLoading(it) + }, onLoadingEstimate = { + _cast.setLoading(it) + }) + + if (castSucceeded) { _cast.setVideoDetails(video, resumePositionMs / 1000); setCastEnabled(true); } else throw IllegalStateException("Disconnected cast during loading"); @@ -2553,8 +2561,7 @@ class VideoDetailView : ConstraintLayout { _cast.visibility = View.VISIBLE; } else { StateCasting.instance.stopVideo(); - _cast.stopTimeJob(); - _cast.visibility = View.GONE; + _cast.cancel() if (video?.isLive == false) { _player.setPlaybackRate(Settings.instance.playback.getDefaultPlaybackSpeed()); diff --git a/app/src/main/java/com/futo/platformplayer/views/casting/CastView.kt b/app/src/main/java/com/futo/platformplayer/views/casting/CastView.kt index fe941fea..161f3dc3 100644 --- a/app/src/main/java/com/futo/platformplayer/views/casting/CastView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/casting/CastView.kt @@ -30,6 +30,7 @@ import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.formatDuration import com.futo.platformplayer.states.StateHistory import com.futo.platformplayer.states.StatePlayer +import com.futo.platformplayer.views.TargetTapLoaderView import com.futo.platformplayer.views.behavior.GestureControlView import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -54,6 +55,7 @@ class CastView : ConstraintLayout { private val _timeBar: DefaultTimeBar; private val _background: FrameLayout; private val _gestureControlView: GestureControlView; + private val _loaderGame: TargetTapLoaderView private var _scope: CoroutineScope = CoroutineScope(Dispatchers.Main); private var _updateTimeJob: Job? = null; private var _inPictureInPicture: Boolean = false; @@ -88,6 +90,9 @@ class CastView : ConstraintLayout { _timeBar = findViewById(R.id.time_progress); _background = findViewById(R.id.layout_background); _gestureControlView = findViewById(R.id.gesture_control); + _loaderGame = findViewById(R.id.loader_overlay) + _loaderGame.visibility = View.GONE + _gestureControlView.fullScreenGestureEnabled = false _gestureControlView.setupTouchArea(); _gestureControlView.onSpeedHoldStart.subscribe { @@ -197,6 +202,12 @@ class CastView : ConstraintLayout { _updateTimeJob = null; } + fun cancel() { + stopTimeJob() + setLoading(false) + visibility = View.GONE + } + fun stopAllGestures() { _gestureControlView.stopAllGestures(); } @@ -279,6 +290,7 @@ class CastView : ConstraintLayout { _textDuration.text = (video.duration * 1000).formatDuration(); _timeBar.setPosition(position); _timeBar.setDuration(video.duration); + setLoading(false) } @OptIn(UnstableApi::class) @@ -295,6 +307,7 @@ class CastView : ConstraintLayout { _updateTimeJob?.cancel(); _updateTimeJob = null; _scope.cancel(); + setLoading(false) } private fun getPlaybackStateCompat(): Int { @@ -305,4 +318,19 @@ class CastView : ConstraintLayout { else -> PlaybackStateCompat.STATE_PAUSED; } } + + fun setLoading(isLoading: Boolean) { + if (isLoading) { + _loaderGame.visibility = View.VISIBLE + _loaderGame.startLoader() + } else { + _loaderGame.visibility = View.GONE + _loaderGame.stopAndResetLoader() + } + } + + fun setLoading(expectedDurationMs: Int) { + _loaderGame.visibility = View.VISIBLE + _loaderGame.startLoader(expectedDurationMs.toLong()) + } } \ No newline at end of file diff --git a/app/src/main/res/layout/view_cast.xml b/app/src/main/res/layout/view_cast.xml index 7c82c130..7b0779cd 100644 --- a/app/src/main/res/layout/view_cast.xml +++ b/app/src/main/res/layout/view_cast.xml @@ -189,4 +189,14 @@ app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" /> + + \ No newline at end of file