casting: catch exceptions for playback control functions

# Conflicts:
#	app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt
#	app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt
This commit is contained in:
Marcus Hanestad 2025-08-14 11:24:13 +02:00
commit 0fd83cbd74
10 changed files with 104 additions and 79 deletions

View file

@ -128,7 +128,7 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
synchronized(ExpStateCasting.instance.devices) {
_devices.addAll(ExpStateCasting.instance.devices.values.map { it.device.name() })
}
_rememberedDevices.addAll(StateCasting.instance.getRememberedCastingDeviceNames())
_rememberedDevices.addAll(ExpStateCasting.instance.getRememberedCastingDeviceNames())
} else {
synchronized(StateCasting.instance.devices) {
_devices.addAll(StateCasting.instance.devices.values.mapNotNull { it.name })
@ -203,10 +203,17 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
override fun dismiss() {
super.dismiss()
(_imageLoader.drawable as Animatable?)?.stop()
StateCasting.instance.onDeviceAdded.remove(this)
StateCasting.instance.onDeviceChanged.remove(this)
StateCasting.instance.onDeviceRemoved.remove(this)
StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this)
if (Settings.instance.casting.experimentalCasting) {
ExpStateCasting.instance.onDeviceAdded.remove(this)
ExpStateCasting.instance.onDeviceChanged.remove(this)
ExpStateCasting.instance.onDeviceRemoved.remove(this)
ExpStateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this)
} else {
StateCasting.instance.onDeviceAdded.remove(this)
StateCasting.instance.onDeviceChanged.remove(this)
StateCasting.instance.onDeviceRemoved.remove(this)
StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this)
}
}
private fun updateUnifiedList() {

View file

@ -24,6 +24,7 @@ import com.futo.platformplayer.experimental_casting.ExpStateCasting
import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.views.adapters.GenericCastingDevice
import com.google.android.material.slider.Slider
import com.google.android.material.slider.Slider.OnChangeListener
import kotlinx.coroutines.Dispatchers
@ -49,7 +50,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
private lateinit var _buttonStop: ImageButton;
private lateinit var _buttonNext: ImageButton;
private var _device: CastingDevice? = null;
private var _device: GenericCastingDevice? = null;
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
@ -178,7 +179,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
}
ExpStateCasting.instance.onActiveDeviceTimeChanged.remove(this)
StateCasting.instance.onActiveDeviceTimeChanged.subscribe {
ExpStateCasting.instance.onActiveDeviceTimeChanged.subscribe {
_sliderPosition.value = it.toFloat().coerceAtLeast(0.0f).coerceAtMost(_sliderPosition.valueTo)
}
@ -189,16 +190,18 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
_sliderPosition.valueTo = dur
}
_device = StateCasting.instance.activeDevice
val d = _device
val isConnected = d != null && d.connectionState == CastConnectionState.CONNECTED
val ad = ExpStateCasting.instance.activeDevice
if (ad != null) {
_device = GenericCastingDevice.Experimental(ad)
}
val isConnected = ad != null && ad.connectionState == com.futo.platformplayer.experimental_casting.CastConnectionState.CONNECTED
setLoading(!isConnected)
ExpStateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, connectionState ->
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
setLoading(connectionState != com.futo.platformplayer.experimental_casting.CastConnectionState.CONNECTED)
}
updateDevice();
};
updateDevice()
}
} else {
StateCasting.instance.onActiveDeviceVolumeChanged.remove(this);
StateCasting.instance.onActiveDeviceVolumeChanged.subscribe {
@ -217,14 +220,16 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
_sliderPosition.valueTo = dur
};
_device = StateCasting.instance.activeDevice;
val d = _device;
val isConnected = d != null && d.connectionState == CastConnectionState.CONNECTED;
val ad = StateCasting.instance.activeDevice
if (ad != null) {
_device = GenericCastingDevice.Normal(ad)
}
val isConnected = ad != null && ad.connectionState == CastConnectionState.CONNECTED;
setLoading(!isConnected);
StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, connectionState ->
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { setLoading(connectionState != CastConnectionState.CONNECTED); };
updateDevice();
};
updateDevice()
}
}
updateDevice();
@ -235,12 +240,12 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
StateCasting.instance.onActiveDeviceVolumeChanged.remove(this);
StateCasting.instance.onActiveDeviceDurationChanged.remove(this);
StateCasting.instance.onActiveDeviceTimeChanged.remove(this);
_device = null;
StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this);
ExpStateCasting.instance.onActiveDeviceVolumeChanged.remove(this);
ExpStateCasting.instance.onActiveDeviceDurationChanged.remove(this);
ExpStateCasting.instance.onActiveDeviceTimeChanged.remove(this);
ExpStateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this);
_device = null;
}
private fun updateDevice() {

View file

@ -110,7 +110,7 @@ class CastingDeviceHandle {
) {
try {
device.loadVideo(contentType, contentId, resumePosition, speed)
} catch (e: Exception) {
} catch (e: Throwable) {
Logger.e("CastingDevice", "Failed to load video: $e")
}
}
@ -122,7 +122,11 @@ class CastingDeviceHandle {
duration: Double,
speed: Double?
) {
device.loadContent(contentType, content, resumePosition, duration, speed)
try {
device.loadContent(contentType, content, resumePosition, duration, speed)
} catch (e: Throwable) {
Logger.e("CastingDevice", "Failed to load content: $e")
}
}
}

View file

@ -128,7 +128,11 @@ class ExpStateCasting {
_resumeCastingDevice = ad.device.getDeviceInfo()
Log.i(TAG, "_resumeCastingDevice set to '${ad.device.name()}'")
Logger.i(TAG, "Stopping active device because of onStop.")
ad.device.disconnect()
try {
ad.device.disconnect()
} catch (e: Throwable) {
Logger.e(TAG, "Failed to disconnect from device: $e")
}
}
fun onResume() {
@ -248,7 +252,11 @@ class ExpStateCasting {
device.eventHandler.onTimeChanged.clear();
device.eventHandler.onVolumeChanged.clear();
device.eventHandler.onDurationChanged.clear();
ad.device.disconnect();
try {
ad.device.disconnect();
} catch (e: Throwable) {
Logger.e(TAG, "Failed to disconnect from device: $e")
}
}
device.eventHandler.onConnectionStateChanged.subscribe { castConnectionState ->
@ -441,6 +449,10 @@ class ExpStateCasting {
return Settings.instance.casting.alwaysProxyRequests || deviceHandle.device.castingProtocol() != ProtocolType.F_CAST || hasRequestModifier
}
fun cancel() {
_castId.incrementAndGet()
}
suspend fun castIfAvailable(
contentResolver: ContentResolver,
video: IPlatformVideoDetails,
@ -541,7 +553,7 @@ class ExpStateCasting {
resumePosition,
video.duration.toDouble(),
speed
);
)
} else if (audioSource is IAudioUrlSource) {
val audioPath = "/audio-${id}"
val audioUrl = if (proxyStreams) url + audioPath else audioSource.getAudioUrl();

View file

@ -30,6 +30,7 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.experimental_casting.ExpStateCasting
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.PlatformVideoWithTime
import com.futo.platformplayer.models.UrlVideoWithTime
@ -437,7 +438,7 @@ class VideoDetailFragment() : MainFragment() {
fun onUserLeaveHint() {
val viewDetail = _viewDetail;
Logger.i(TAG, "onUserLeaveHint preventPictureInPicture=${viewDetail?.preventPictureInPicture} isCasting=${StateCasting.instance.isCasting} isBackgroundPictureInPicture=${Settings.instance.playback.isBackgroundPictureInPicture()} allowBackground=${viewDetail?.isAudioOnlyUserAction}");
Logger.i(TAG, "onUserLeaveHint preventPictureInPicture=${viewDetail?.preventPictureInPicture} isCasting=${StateCasting.instance.isCasting} isBackgroundPictureInPicture=${Settings.instance.playback.isBackgroundPictureInPicture()} allowBackground=${viewDetail?.allowBackground}");
if (viewDetail === null) {
return
@ -446,7 +447,7 @@ class VideoDetailFragment() : MainFragment() {
if (viewDetail.shouldEnterPictureInPicture) {
_leavingPiP = false
}
if(Build.VERSION.SDK_INT < Build.VERSION_CODES.S && viewDetail.preventPictureInPicture == false && !StateCasting.instance.isCasting && Settings.instance.playback.isBackgroundPictureInPicture() && !viewDetail.isAudioOnlyUserAction) {
if(Build.VERSION.SDK_INT < Build.VERSION_CODES.S && viewDetail.preventPictureInPicture == false && !StateCasting.instance.isCasting && Settings.instance.playback.isBackgroundPictureInPicture() && !viewDetail.allowBackground) {
val params = _viewDetail?.getPictureInPictureParams();
if(params != null) {
Logger.i(TAG, "enterPictureInPictureMode")
@ -526,7 +527,7 @@ class VideoDetailFragment() : MainFragment() {
private fun stopIfRequired() {
var shouldStop = true;
if (_viewDetail?.isAudioOnlyUserAction == true) {
if (_viewDetail?.allowBackground == true) {
shouldStop = false;
} else if (Settings.instance.playback.isBackgroundPictureInPicture() && !_leavingPiP) {
shouldStop = false;

View file

@ -325,7 +325,7 @@ class VideoDetailView : ConstraintLayout {
val onEnterPictureInPicture = Event0();
val onVideoChanged = Event2<Int, Int>()
var isAudioOnlyUserAction: Boolean = false
var allowBackground: Boolean = false
private set(value) {
if (field != value) {
field = value
@ -337,7 +337,7 @@ class VideoDetailView : ConstraintLayout {
get() = !preventPictureInPicture &&
!StateCasting.instance.isCasting &&
Settings.instance.playback.isBackgroundPictureInPicture() &&
!isAudioOnlyUserAction &&
!allowBackground &&
isPlaying
val onShouldEnterPictureInPictureChanged = Event0();
@ -808,7 +808,7 @@ class VideoDetailView : ConstraintLayout {
MediaControlReceiver.onBackgroundReceived.subscribe(this) {
Logger.i(TAG, "MediaControlReceiver.onBackgroundReceived")
_player.switchToAudioMode(video);
isAudioOnlyUserAction = true;
allowBackground = true;
StateApp.instance.contextOrNull?.let {
try {
if (it is MainActivity) {
@ -1052,26 +1052,15 @@ class VideoDetailView : ConstraintLayout {
}
}
_slideUpOverlay?.hide();
} else if(video is JSVideoDetails && (video as JSVideoDetails).hasVODEvents())
RoundButton(context, R.drawable.ic_chat, context.getString(R.string.vod_chat), TAG_VODCHAT) {
video?.let {
try {
loadVODChat(it);
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to reopen vod chat", ex);
}
}
_slideUpOverlay?.hide();
} else null,
if (!isLimitedVersion) RoundButton(context, R.drawable.ic_screen_share, if (isAudioOnlyUserAction) context.getString(R.string.background_revert) else context.getString(R.string.background), TAG_BACKGROUND) {
if (!isAudioOnlyUserAction) {
if (!isLimitedVersion) RoundButton(context, R.drawable.ic_screen_share, if (allowBackground) context.getString(R.string.background_revert) else context.getString(R.string.background), TAG_BACKGROUND) {
if (!allowBackground) {
_player.switchToAudioMode(video);
isAudioOnlyUserAction = true;
allowBackground = true;
it.text.text = resources.getString(R.string.background_revert);
} else {
_player.switchToVideoMode();
isAudioOnlyUserAction = false;
allowBackground = false;
it.text.text = resources.getString(R.string.background);
}
_slideUpOverlay?.hide();
@ -1187,23 +1176,19 @@ class VideoDetailView : ConstraintLayout {
//Lifecycle
var isLoginStop = false; //TODO: This is a bit jank, but easiest solution for now without reworking flow. (Alternatively, fix MainActivity getting stopped/disposing video)
fun onResume() {
Logger.v(TAG, "onResume");
_onPauseCalled = false;
val wasLoginCall = isLoginStop;
isLoginStop = false;
Logger.i(TAG, "_video: ${video?.name ?: "no video"}");
Logger.i(TAG, "_didStop: $_didStop");
//Recover cancelled loads
if(video == null) {
val t = (lastPositionMilliseconds / 1000.0f).roundToLong();
if(_searchVideo != null && !wasLoginCall)
if(_searchVideo != null)
setVideoOverview(_searchVideo!!, true, t);
else if(_url != null && !wasLoginCall)
else if(_url != null)
setVideo(_url!!, t, _playWhenReady);
}
else if(_didStop) {
@ -1215,14 +1200,11 @@ class VideoDetailView : ConstraintLayout {
if(_player.isAudioMode) {
//Requested behavior to leave it in audio mode. leaving it commented if it causes issues, revert?
if(!isAudioOnlyUserAction) {
if(!allowBackground) {
_player.switchToVideoMode();
isAudioOnlyUserAction = false;
allowBackground = false;
_buttonPins.getButtonByTag(TAG_BACKGROUND)?.text?.text = resources.getString(R.string.background);
}
else {
_buttonPins.getButtonByTag(TAG_BACKGROUND)?.text?.text = resources.getString(R.string.video);
}
}
if(!_player.isFitMode && !_player.isFullScreen && !fragment.isInPictureInPicture)
_player.fitHeight();
@ -1243,7 +1225,7 @@ class VideoDetailView : ConstraintLayout {
return;
}
if(isAudioOnlyUserAction)
if(allowBackground)
StatePlayer.instance.startOrUpdateMediaSession(context, video);
else {
when (Settings.instance.playback.backgroundPlay) {
@ -1251,6 +1233,7 @@ class VideoDetailView : ConstraintLayout {
1 -> {
if(!(video?.isLive ?: false)) {
_player.switchToAudioMode(video);
allowBackground = true;
}
StatePlayer.instance.startOrUpdateMediaSession(context, video);
}
@ -1273,7 +1256,6 @@ class VideoDetailView : ConstraintLayout {
_taskLoadVideo.cancel();
handleStop();
_didStop = true;
onShouldEnterPictureInPictureChanged.emit()
Logger.i(TAG, "_didStop set to true");
StatePlayer.instance.rotationLock = false;
@ -2048,10 +2030,10 @@ class VideoDetailView : ConstraintLayout {
if (isLimitedVersion && _player.isAudioMode) {
_player.switchToVideoMode()
isAudioOnlyUserAction = false;
allowBackground = false;
} else {
val thumbnail = video.thumbnails.getHQThumbnail();
if ((videoSource == null) && !thumbnail.isNullOrBlank()) // || _player.isAudioMode
if ((videoSource == null || _player.isAudioMode) && !thumbnail.isNullOrBlank())
Glide.with(context).asBitmap().load(thumbnail)
.into(object: CustomTarget<Bitmap>() {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
@ -2626,7 +2608,6 @@ class VideoDetailView : ConstraintLayout {
_player.play();
}
}
onShouldEnterPictureInPictureChanged.emit()
//TODO: This was needed because handleLowerVolume was done.
//_player.setVolume(1.0f);
@ -2649,7 +2630,6 @@ class VideoDetailView : ConstraintLayout {
_player.pause()
}
}
onShouldEnterPictureInPictureChanged.emit()
}
private fun handleSeek(ms: Long) {
Logger.i(TAG, "handleSeek(ms=$ms)")
@ -3483,13 +3463,8 @@ class VideoDetailView : ConstraintLayout {
val id = e.config.let { if(it is SourcePluginConfig) it.id else null };
val didLogin = if(id == null)
false
else {
isLoginStop = true;
StatePlugins.instance.loginPlugin(context, id) {
fragment.lifecycleScope.launch(Dispatchers.Main) {
fetchVideo();
}
}
else StatePlugins.instance.loginPlugin(context, id) {
fetchVideo();
}
if(!didLogin)
UIDialogs.showDialogOk(context, R.drawable.ic_error_pred, "Failed to login");
@ -3667,7 +3642,6 @@ class VideoDetailView : ConstraintLayout {
const val TAG_SHARE = "share";
const val TAG_OVERLAY = "overlay";
const val TAG_LIVECHAT = "livechat";
const val TAG_VODCHAT = "vodchat";
const val TAG_CHAPTERS = "chapters";
const val TAG_OPEN = "open";
const val TAG_SEND_TO_DEVICE = "send_to_device";

View file

@ -37,6 +37,7 @@ import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.experimental_casting.ExpStateCasting
import com.futo.platformplayer.fragment.mainactivity.main.HomeFragment
import com.futo.platformplayer.fragment.mainactivity.main.SourceDetailFragment
import com.futo.platformplayer.logging.AndroidLogConsumer
@ -759,7 +760,11 @@ class StateApp {
_connectivityManager?.unregisterNetworkCallback(_connectivityEvents);
StatePlayer.instance.closeMediaSession();
StateCasting.instance.stop();
if (Settings.instance.casting.experimentalCasting) {
ExpStateCasting.instance.stop()
} else {
StateCasting.instance.stop()
}
StateSync.instance.stop();
StatePlayer.dispose();
Companion.dispose();

View file

@ -73,7 +73,11 @@ class DeviceViewHolder : ViewHolder {
is GenericCastingDevice.Experimental -> {
if (dev.handle.device.isReady()) {
// NOTE: we assume experimental casting is used
ExpStateCasting.instance.activeDevice?.device?.stopCasting()
try {
ExpStateCasting.instance.activeDevice?.device?.stopCasting()
} catch (e: Throwable) {
//Ignored
}
ExpStateCasting.instance.connectDevice(dev.handle)
onConnect.emit(dev)
} else {
@ -125,7 +129,7 @@ class DeviceViewHolder : ViewHolder {
_textNotReady.visibility = View.GONE;
val dev = StateCasting.instance.activeDevice;
if (dev == d) {
if (dev == d.device) {
if (dev.connectionState == CastConnectionState.CONNECTED) {
_imageLoader.visibility = View.GONE;
_textNotReady.visibility = View.GONE;
@ -180,9 +184,9 @@ class DeviceViewHolder : ViewHolder {
} else {
_textNotReady.visibility = View.GONE;
val dev = StateCasting.instance.activeDevice;
if (dev == d) {
if (dev.connectionState == CastConnectionState.CONNECTED) {
val dev = ExpStateCasting.instance.activeDevice;
if (dev == d.handle) {
if (dev.connectionState == com.futo.platformplayer.experimental_casting.CastConnectionState.CONNECTED) {
_imageLoader.visibility = View.GONE;
_textNotReady.visibility = View.GONE;
_imagePin.visibility = View.VISIBLE;

View file

@ -12,6 +12,7 @@ import androidx.core.content.ContextCompat
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.casting.CastConnectionState
import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.constructs.Event1
@ -81,7 +82,10 @@ class CastButton : androidx.appcompat.widget.AppCompatImageButton {
fun cleanup() {
setOnClickListener(null);
StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this);
ExpStateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this);
if (Settings.instance.casting.experimentalCasting) {
ExpStateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this);
} else {
StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this);
}
}
}

View file

@ -29,6 +29,7 @@ import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.experimental_casting.ExpStateCasting
import com.futo.platformplayer.formatDuration
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateHistory
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.views.TargetTapLoaderView
@ -103,9 +104,17 @@ class CastView : ConstraintLayout {
_speedHoldWasPlaying = d.isPlaying
_speedHoldPrevRate = d.speed
if (d.device.supportsFeature(DeviceFeature.SET_SPEED)) {
d.device.changeSpeed(Settings.instance.playback.getHoldPlaybackSpeed())
try {
d.device.changeSpeed(Settings.instance.playback.getHoldPlaybackSpeed())
} catch (e: Throwable) {
// Ignored
}
}
try {
d.device.resumePlayback()
} catch (e: Throwable) {
// Ignored
}
d.device.resumePlayback()
} else {
val d = StateCasting.instance.activeDevice ?: return@subscribe;
_speedHoldWasPlaying = d.isPlaying