From 1176cee40bacc25c38cc6818d489d41b4562375f Mon Sep 17 00:00:00 2001 From: Marcus Hanestad Date: Tue, 12 Aug 2025 10:19:33 +0200 Subject: [PATCH 01/30] casting: add experimental support for the FCast sender SDK --- app/build.gradle | 3 + .../java/com/futo/platformplayer/Settings.kt | 5 + .../java/com/futo/platformplayer/UIDialogs.kt | 68 +- .../platformplayer/activities/MainActivity.kt | 13 +- .../dialogs/CastingAddDialog.kt | 8 +- .../dialogs/ConnectCastingDialog.kt | 206 +- .../dialogs/ConnectedCastingDialog.kt | 290 ++- .../experimental_casting/CastingDevice.kt | 156 ++ .../experimental_casting/StateCasting.kt | 2021 +++++++++++++++++ .../mainactivity/main/VideoDetailView.kt | 376 ++- .../models/CastingDeviceInfo.kt | 18 +- .../views/adapters/DeviceAdapter.kt | 26 +- .../views/adapters/DeviceViewHolder.kt | 204 +- .../views/casting/CastButton.kt | 52 +- .../platformplayer/views/casting/CastView.kt | 134 +- app/src/main/res/values/strings.xml | 2 + 16 files changed, 3226 insertions(+), 356 deletions(-) create mode 100644 app/src/main/java/com/futo/platformplayer/experimental_casting/CastingDevice.kt create mode 100644 app/src/main/java/com/futo/platformplayer/experimental_casting/StateCasting.kt diff --git a/app/build.gradle b/app/build.gradle index 65f600c6..e708fa1e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -231,4 +231,7 @@ dependencies { testImplementation "org.mockito:mockito-core:5.4.0" androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' + + //Rust casting SDK + implementation "net.java.dev.jna:jna:5.12.0@aar" } diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt index 4a536855..d67a531a 100644 --- a/app/src/main/java/com/futo/platformplayer/Settings.kt +++ b/app/src/main/java/com/futo/platformplayer/Settings.kt @@ -719,6 +719,11 @@ class Settings : FragmentedStorageFileJson() { @Serializable(with = FlexibleBooleanSerializer::class) var allowLinkLocalIpv4: Boolean = false; + @AdvancedField + @FormField(R.string.experimental_cast, FieldForm.TOGGLE, R.string.experimental_cast_description, 6) + @Serializable(with = FlexibleBooleanSerializer::class) + var experimentalCasting: Boolean = false + /*TODO: Should we have a different casting quality? @FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3) @DropdownFieldOptionsId(R.array.preferred_quality_array) diff --git a/app/src/main/java/com/futo/platformplayer/UIDialogs.kt b/app/src/main/java/com/futo/platformplayer/UIDialogs.kt index 59917e75..babac1ef 100644 --- a/app/src/main/java/com/futo/platformplayer/UIDialogs.kt +++ b/app/src/main/java/com/futo/platformplayer/UIDialogs.kt @@ -38,6 +38,7 @@ import com.futo.platformplayer.dialogs.MigrateDialog import com.futo.platformplayer.dialogs.PluginUpdateDialog import com.futo.platformplayer.dialogs.ProgressDialog import com.futo.platformplayer.engine.exceptions.PluginException +import com.futo.platformplayer.experimental_casting.ExpStateCasting import com.futo.platformplayer.fragment.mainactivity.main.MainFragment import com.futo.platformplayer.fragment.mainactivity.main.SourceDetailFragment import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment @@ -437,29 +438,56 @@ class UIDialogs { fun showCastingDialog(context: Context, ownerActivity: Activity? = null) { - val d = StateCasting.instance.activeDevice; - if (d != null) { - val dialog = ConnectedCastingDialog(context); - if (context is Activity) { - dialog.setOwnerActivity(context) + if (Settings.instance.casting.experimentalCasting) { + val d = ExpStateCasting.instance.activeDevice; + if (d != null) { + val dialog = ConnectedCastingDialog(context); + if (context is Activity) { + dialog.setOwnerActivity(context) + } + registerDialogOpened(dialog); + ownerActivity?.let { dialog.setOwnerActivity(it) } + dialog.setOnDismissListener { registerDialogClosed(dialog) }; + dialog.show(); + } else { + val dialog = ConnectCastingDialog(context); + if (context is Activity) { + dialog.setOwnerActivity(context) + } + registerDialogOpened(dialog); + val c = context + if (c is Activity) { + dialog.setOwnerActivity(c); + } + ownerActivity?.let { dialog.setOwnerActivity(it) } + dialog.setOnDismissListener { registerDialogClosed(dialog) }; + dialog.show(); } - registerDialogOpened(dialog); - ownerActivity?.let { dialog.setOwnerActivity(it) } - dialog.setOnDismissListener { registerDialogClosed(dialog) }; - dialog.show(); } else { - val dialog = ConnectCastingDialog(context); - if (context is Activity) { - dialog.setOwnerActivity(context) + val d = StateCasting.instance.activeDevice; + if (d != null) { + val dialog = ConnectedCastingDialog(context); + if (context is Activity) { + dialog.setOwnerActivity(context) + } + registerDialogOpened(dialog); + ownerActivity?.let { dialog.setOwnerActivity(it) } + dialog.setOnDismissListener { registerDialogClosed(dialog) }; + dialog.show(); + } else { + val dialog = ConnectCastingDialog(context); + if (context is Activity) { + dialog.setOwnerActivity(context) + } + registerDialogOpened(dialog); + val c = context + if (c is Activity) { + dialog.setOwnerActivity(c); + } + ownerActivity?.let { dialog.setOwnerActivity(it) } + dialog.setOnDismissListener { registerDialogClosed(dialog) }; + dialog.show(); } - registerDialogOpened(dialog); - val c = context - if (c is Activity) { - dialog.setOwnerActivity(c); - } - ownerActivity?.let { dialog.setOwnerActivity(it) } - dialog.setOnDismissListener { registerDialogClosed(dialog) }; - dialog.show(); } } diff --git a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt index 0d5bf8d9..d664ae9f 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt @@ -42,6 +42,7 @@ import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.dp +import com.futo.platformplayer.experimental_casting.ExpStateCasting import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment import com.futo.platformplayer.fragment.mainactivity.main.ArticleDetailFragment import com.futo.platformplayer.fragment.mainactivity.main.BrowserFragment @@ -507,7 +508,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { handleIntent(intent); if (Settings.instance.casting.enabled) { - StateCasting.instance.start(this); + if (Settings.instance.casting.experimentalCasting) { + ExpStateCasting.instance.start(this) + } else { + StateCasting.instance.start(this) + } } StatePlatform.instance.onDevSourceChanged.subscribe { @@ -1046,7 +1051,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { Logger.i(TAG, "handleFCast"); try { - StateCasting.instance.handleUrl(this, url) + if (Settings.instance.casting.experimentalCasting) { + ExpStateCasting.instance.handleUrl(this, url) + } else { + StateCasting.instance.handleUrl(this, url) + } return true; } catch (e: Throwable) { Log.e(TAG, "Failed to parse FCast URL '${url}'.", e) diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/CastingAddDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/CastingAddDialog.kt index 9eb71145..1c975547 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/CastingAddDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/CastingAddDialog.kt @@ -8,9 +8,11 @@ import android.view.View import android.view.WindowManager import android.widget.* import com.futo.platformplayer.R +import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.casting.CastProtocolType import com.futo.platformplayer.casting.StateCasting +import com.futo.platformplayer.experimental_casting.ExpStateCasting import com.futo.platformplayer.models.CastingDeviceInfo import com.futo.platformplayer.toInetAddress @@ -101,7 +103,11 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) { _textError.visibility = View.GONE; val castingDeviceInfo = CastingDeviceInfo(name, castProtocolType, arrayOf(ip), port.toInt()); - StateCasting.instance.addRememberedDevice(castingDeviceInfo); + if (Settings.instance.casting.experimentalCasting) { + ExpStateCasting.instance.addRememberedDevice(castingDeviceInfo) + } else { + StateCasting.instance.addRememberedDevice(castingDeviceInfo) + } performDismiss(); }; diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt index 2bb87111..204b7df6 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt @@ -15,15 +15,18 @@ import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.futo.platformplayer.R +import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.casting.CastConnectionState import com.futo.platformplayer.casting.CastingDevice import com.futo.platformplayer.casting.StateCasting +import com.futo.platformplayer.experimental_casting.ExpStateCasting import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.views.adapters.DeviceAdapter import com.futo.platformplayer.views.adapters.DeviceAdapterEntry +import com.futo.platformplayer.views.adapters.GenericCastingDevice import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -55,17 +58,33 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) { _recyclerDevices.layoutManager = LinearLayoutManager(context); _adapter.onPin.subscribe { d -> - val isRemembered = _rememberedDevices.contains(d.name) - val newIsRemembered = !isRemembered - if (newIsRemembered) { - StateCasting.instance.addRememberedDevice(d) - val name = d.name - if (name != null) { - _rememberedDevices.add(name) + when (d) { + is GenericCastingDevice.Experimental -> { + val isRemembered = _rememberedDevices.contains(d.handle.device.name()) + val newIsRemembered = !isRemembered + if (newIsRemembered) { + ExpStateCasting.instance.addRememberedDevice(d.handle) + val name = d.handle.device.name() + _rememberedDevices.add(name) + } else { + ExpStateCasting.instance.removeRememberedDevice(d.handle) + _rememberedDevices.remove(d.handle.device.name()) + } + } + is GenericCastingDevice.Normal -> { + val isRemembered = _rememberedDevices.contains(d.device.name) + val newIsRemembered = !isRemembered + if (newIsRemembered) { + StateCasting.instance.addRememberedDevice(d.device) + val name = d.device.name + if (name != null) { + _rememberedDevices.add(name) + } + } else { + StateCasting.instance.removeRememberedDevice(d.device) + _rememberedDevices.remove(d.device.name) + } } - } else { - StateCasting.instance.removeRememberedDevice(d) - _rememberedDevices.remove(d.name) } updateUnifiedList() } @@ -105,37 +124,77 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) { (_imageLoader.drawable as Animatable?)?.start(); - synchronized(StateCasting.instance.devices) { - _devices.addAll(StateCasting.instance.devices.values.mapNotNull { it.name }) + if (Settings.instance.casting.experimentalCasting) { + synchronized(ExpStateCasting.instance.devices) { + _devices.addAll(ExpStateCasting.instance.devices.values.map { it.device.name() }) + } + _rememberedDevices.addAll(StateCasting.instance.getRememberedCastingDeviceNames()) + } else { + synchronized(StateCasting.instance.devices) { + _devices.addAll(StateCasting.instance.devices.values.mapNotNull { it.name }) + } + _rememberedDevices.addAll(StateCasting.instance.getRememberedCastingDeviceNames()) } - _rememberedDevices.addAll(StateCasting.instance.getRememberedCastingDeviceNames()) updateUnifiedList() - StateCasting.instance.onDeviceAdded.subscribe(this) { d -> - val name = d.name - if (name != null) - _devices.add(name) - updateUnifiedList() - } - - StateCasting.instance.onDeviceChanged.subscribe(this) { d -> - val index = _unifiedDevices.indexOfFirst { it.castingDevice.name == d.name } - if (index != -1) { - _unifiedDevices[index] = DeviceAdapterEntry(d, _unifiedDevices[index].isPinnedDevice, _unifiedDevices[index].isOnlineDevice) - _adapter.notifyItemChanged(index) + if (Settings.instance.casting.experimentalCasting) { + ExpStateCasting.instance.onDeviceAdded.subscribe(this) { d -> + _devices.add(d.name()) + updateUnifiedList() } - } - StateCasting.instance.onDeviceRemoved.subscribe(this) { d -> - _devices.remove(d.name) - updateUnifiedList() - } + ExpStateCasting.instance.onDeviceChanged.subscribe(this) { d -> + val index = _unifiedDevices.indexOfFirst { it.castingDevice.name() == d.device.name() } + if (index != -1) { + val dev = GenericCastingDevice.Experimental(d) + _unifiedDevices[index] = DeviceAdapterEntry(dev, _unifiedDevices[index].isPinnedDevice, _unifiedDevices[index].isOnlineDevice) + _adapter.notifyItemChanged(index) + } + } - StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, connectionState -> - if (connectionState == CastConnectionState.CONNECTED) { - StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { - dismiss() + ExpStateCasting.instance.onDeviceRemoved.subscribe(this) { deviceName -> + _devices.remove(deviceName) + updateUnifiedList() + } + + ExpStateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, connectionState -> + if (connectionState == com.futo.platformplayer.experimental_casting.CastConnectionState.CONNECTED) { + StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { + dismiss() + } + } + } + } else { + StateCasting.instance.onDeviceAdded.subscribe(this) { d -> + val name = d.name + if (name != null) + _devices.add(name) + updateUnifiedList() + } + + StateCasting.instance.onDeviceChanged.subscribe(this) { d -> + val index = _unifiedDevices.indexOfFirst { it.castingDevice.name() == d.name } + if (index != -1) { + _unifiedDevices[index] = DeviceAdapterEntry( + GenericCastingDevice.Normal(d), + _unifiedDevices[index].isPinnedDevice, + _unifiedDevices[index].isOnlineDevice + ) + _adapter.notifyItemChanged(index) + } + } + + StateCasting.instance.onDeviceRemoved.subscribe(this) { d -> + _devices.remove(d.name) + updateUnifiedList() + } + + StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, connectionState -> + if (connectionState == CastConnectionState.CONNECTED) { + StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { + dismiss() + } } } } @@ -160,16 +219,16 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) { override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { val oldItem = oldList[oldItemPosition] val newItem = newList[newItemPosition] - return oldItem.castingDevice.name == newItem.castingDevice.name - && oldItem.castingDevice.isReady == newItem.castingDevice.isReady + return oldItem.castingDevice.name() == newItem.castingDevice.name() + && oldItem.castingDevice.isReady() == newItem.castingDevice.isReady() && oldItem.isOnlineDevice == newItem.isOnlineDevice && oldItem.isPinnedDevice == newItem.isPinnedDevice } override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { val oldItem = oldList[oldItemPosition] val newItem = newList[newItemPosition] - return oldItem.castingDevice.name == newItem.castingDevice.name - && oldItem.castingDevice.isReady == newItem.castingDevice.isReady + return oldItem.castingDevice.name() == newItem.castingDevice.name() + && oldItem.castingDevice.isReady() == newItem.castingDevice.isReady() && oldItem.isOnlineDevice == newItem.isOnlineDevice && oldItem.isPinnedDevice == newItem.isPinnedDevice } @@ -184,24 +243,67 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) { } private fun buildUnifiedList(): List { - val onlineDevices = StateCasting.instance.devices.values.associateBy { it.name } - val rememberedDevices = StateCasting.instance.getRememberedCastingDevices().associateBy { it.name } - val unifiedList = mutableListOf() - val intersectionNames = _devices.intersect(_rememberedDevices) - for (name in intersectionNames) { - onlineDevices[name]?.let { unifiedList.add(DeviceAdapterEntry(it, true, true)) } - } + if (Settings.instance.casting.experimentalCasting) { + val onlineDevices = ExpStateCasting.instance.devices.values.associateBy { it.device.name() } + val rememberedDevices = ExpStateCasting.instance.getRememberedCastingDevices().associateBy { it.device.name() } - val onlineOnlyNames = _devices - _rememberedDevices - for (name in onlineOnlyNames) { - onlineDevices[name]?.let { unifiedList.add(DeviceAdapterEntry(it, false, true)) } - } + val intersectionNames = _devices.intersect(_rememberedDevices) + for (name in intersectionNames) { + onlineDevices[name]?.let { + unifiedList.add(DeviceAdapterEntry( + GenericCastingDevice.Experimental(it), true, true) + ) + } + } - val rememberedOnlyNames = _rememberedDevices - _devices - for (name in rememberedOnlyNames) { - rememberedDevices[name]?.let { unifiedList.add(DeviceAdapterEntry(it, true, false)) } + val onlineOnlyNames = _devices - _rememberedDevices + for (name in onlineOnlyNames) { + onlineDevices[name]?.let { + unifiedList.add(DeviceAdapterEntry( + GenericCastingDevice.Experimental(it), false, true) + ) + } + } + + val rememberedOnlyNames = _rememberedDevices - _devices + for (name in rememberedOnlyNames) { + rememberedDevices[name]?.let { + unifiedList.add(DeviceAdapterEntry( + GenericCastingDevice.Experimental(it), true, false) + ) + } + } + } else { + val onlineDevices = StateCasting.instance.devices.values.associateBy { it.name } + val rememberedDevices = StateCasting.instance.getRememberedCastingDevices().associateBy { it.name } + + val intersectionNames = _devices.intersect(_rememberedDevices) + for (name in intersectionNames) { + onlineDevices[name]?.let { + unifiedList.add(DeviceAdapterEntry( + GenericCastingDevice.Normal(it), true, true) + ) + } + } + + val onlineOnlyNames = _devices - _rememberedDevices + for (name in onlineOnlyNames) { + onlineDevices[name]?.let { + unifiedList.add(DeviceAdapterEntry( + GenericCastingDevice.Normal( it), false, true)) + } + } + + val rememberedOnlyNames = _rememberedDevices - _devices + for (name in rememberedOnlyNames) { + rememberedDevices[name]?.let { + unifiedList.add(DeviceAdapterEntry( + GenericCastingDevice.Normal(it), true, false) + ) + } + } } return unifiedList diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt index 862f8333..042885ed 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt @@ -12,6 +12,7 @@ import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView import com.futo.platformplayer.R +import com.futo.platformplayer.Settings import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.casting.AirPlayCastingDevice import com.futo.platformplayer.casting.CastConnectionState @@ -19,6 +20,7 @@ import com.futo.platformplayer.casting.CastingDevice import com.futo.platformplayer.casting.ChromecastCastingDevice import com.futo.platformplayer.casting.FCastCastingDevice import com.futo.platformplayer.casting.StateCasting +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 @@ -26,6 +28,8 @@ import com.google.android.material.slider.Slider import com.google.android.material.slider.Slider.OnChangeListener import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import org.fcast.sender_sdk.DeviceFeature +import org.fcast.sender_sdk.ProtocolType class ConnectedCastingDialog(context: Context?) : AlertDialog(context) { private lateinit var _buttonClose: Button; @@ -69,18 +73,30 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) { _buttonPlay = findViewById(R.id.button_play); _buttonPlay.setOnClickListener { - StateCasting.instance.activeDevice?.resumeVideo() + if (Settings.instance.casting.experimentalCasting) { + ExpStateCasting.instance.activeDevice?.device?.resumePlayback() + } else { + StateCasting.instance.activeDevice?.resumeVideo() + } } _buttonPause = findViewById(R.id.button_pause); _buttonPause.setOnClickListener { - StateCasting.instance.activeDevice?.pauseVideo() + if (Settings.instance.casting.experimentalCasting) { + ExpStateCasting.instance.activeDevice?.device?.pausePlayback() + } else { + StateCasting.instance.activeDevice?.pauseVideo() + } } _buttonStop = findViewById(R.id.button_stop); _buttonStop.setOnClickListener { (ownerActivity as MainActivity?)?.getFragment()?.closeVideoDetails() - StateCasting.instance.activeDevice?.stopVideo() + if (Settings.instance.casting.experimentalCasting) { + ExpStateCasting.instance.activeDevice?.device?.stopPlayback() + } else { + StateCasting.instance.activeDevice?.stopVideo() + } } _buttonNext = findViewById(R.id.button_next); @@ -90,7 +106,11 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) { _buttonClose.setOnClickListener { dismiss(); }; _buttonDisconnect.setOnClickListener { - StateCasting.instance.activeDevice?.stopCasting(); + if (Settings.instance.casting.experimentalCasting) { + ExpStateCasting.instance.activeDevice?.device?.stopCasting() + } else { + StateCasting.instance.activeDevice?.stopCasting(); + } dismiss(); }; @@ -99,11 +119,20 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) { return@OnChangeListener } - val activeDevice = StateCasting.instance.activeDevice ?: return@OnChangeListener; - try { - activeDevice.seekVideo(value.toDouble()); - } catch (e: Throwable) { - Logger.e(TAG, "Failed to change volume.", e); + if (Settings.instance.casting.experimentalCasting) { + val activeDevice = ExpStateCasting.instance.activeDevice ?: return@OnChangeListener; + try { + activeDevice.device.seek(value.toDouble()); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to seek.", e); + } + } else { + val activeDevice = StateCasting.instance.activeDevice ?: return@OnChangeListener; + try { + activeDevice.seekVideo(value.toDouble()); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to seek.", e); + } } }); @@ -113,12 +142,23 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) { return@OnChangeListener } - val activeDevice = StateCasting.instance.activeDevice ?: return@OnChangeListener; - if (activeDevice.canSetVolume) { - try { - activeDevice.changeVolume(value.toDouble()); - } catch (e: Throwable) { - Logger.e(TAG, "Failed to change volume.", e); + if (Settings.instance.casting.experimentalCasting) { + val activeDevice = ExpStateCasting.instance.activeDevice ?: return@OnChangeListener; + if (activeDevice.device.supportsFeature(DeviceFeature.SET_VOLUME)) { + try { + activeDevice.device.changeVolume(value.toDouble()); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to change volume.", e); + } + } + } else { + val activeDevice = StateCasting.instance.activeDevice ?: return@OnChangeListener; + if (activeDevice.canSetVolume) { + try { + activeDevice.changeVolume(value.toDouble()); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to change volume.", e); + } } } }); @@ -131,31 +171,61 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) { super.show(); Logger.i(TAG, "Dialog shown."); - StateCasting.instance.onActiveDeviceVolumeChanged.remove(this); - StateCasting.instance.onActiveDeviceVolumeChanged.subscribe { - _sliderVolume.value = it.toFloat().coerceAtLeast(0.0f).coerceAtMost(_sliderVolume.valueTo); - }; + if (Settings.instance.casting.experimentalCasting) { + ExpStateCasting.instance.onActiveDeviceVolumeChanged.remove(this) + ExpStateCasting.instance.onActiveDeviceVolumeChanged.subscribe { + _sliderVolume.value = it.toFloat().coerceAtLeast(0.0f).coerceAtMost(_sliderVolume.valueTo) + } - StateCasting.instance.onActiveDeviceTimeChanged.remove(this); - StateCasting.instance.onActiveDeviceTimeChanged.subscribe { - _sliderPosition.value = it.toFloat().coerceAtLeast(0.0f).coerceAtMost(_sliderPosition.valueTo); - }; + ExpStateCasting.instance.onActiveDeviceTimeChanged.remove(this) + StateCasting.instance.onActiveDeviceTimeChanged.subscribe { + _sliderPosition.value = it.toFloat().coerceAtLeast(0.0f).coerceAtMost(_sliderPosition.valueTo) + } - StateCasting.instance.onActiveDeviceDurationChanged.remove(this); - StateCasting.instance.onActiveDeviceDurationChanged.subscribe { - val dur = it.toFloat().coerceAtLeast(1.0f) - _sliderPosition.value = _sliderPosition.value.coerceAtLeast(0.0f).coerceAtMost(dur); - _sliderPosition.valueTo = dur - }; + ExpStateCasting.instance.onActiveDeviceDurationChanged.remove(this) + ExpStateCasting.instance.onActiveDeviceDurationChanged.subscribe { + val dur = it.toFloat().coerceAtLeast(1.0f) + _sliderPosition.value = _sliderPosition.value.coerceAtLeast(0.0f).coerceAtMost(dur) + _sliderPosition.valueTo = dur + } - _device = StateCasting.instance.activeDevice; - val d = _device; - val isConnected = d != null && d.connectionState == CastConnectionState.CONNECTED; - setLoading(!isConnected); - StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, connectionState -> - StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { setLoading(connectionState != CastConnectionState.CONNECTED); }; - updateDevice(); - }; + _device = StateCasting.instance.activeDevice + val d = _device + val isConnected = d != null && d.connectionState == 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(); + }; + } else { + StateCasting.instance.onActiveDeviceVolumeChanged.remove(this); + StateCasting.instance.onActiveDeviceVolumeChanged.subscribe { + _sliderVolume.value = it.toFloat().coerceAtLeast(0.0f).coerceAtMost(_sliderVolume.valueTo); + }; + + StateCasting.instance.onActiveDeviceTimeChanged.remove(this); + StateCasting.instance.onActiveDeviceTimeChanged.subscribe { + _sliderPosition.value = it.toFloat().coerceAtLeast(0.0f).coerceAtMost(_sliderPosition.valueTo); + }; + + StateCasting.instance.onActiveDeviceDurationChanged.remove(this); + StateCasting.instance.onActiveDeviceDurationChanged.subscribe { + val dur = it.toFloat().coerceAtLeast(1.0f) + _sliderPosition.value = _sliderPosition.value.coerceAtLeast(0.0f).coerceAtMost(dur); + _sliderPosition.valueTo = dur + }; + + _device = StateCasting.instance.activeDevice; + val d = _device; + val isConnected = d != null && d.connectionState == CastConnectionState.CONNECTED; + setLoading(!isConnected); + StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, connectionState -> + StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { setLoading(connectionState != CastConnectionState.CONNECTED); }; + updateDevice(); + }; + } updateDevice(); } @@ -167,56 +237,112 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) { 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); } private fun updateDevice() { - val d = StateCasting.instance.activeDevice ?: return; + if (Settings.instance.casting.experimentalCasting) { + val d = ExpStateCasting.instance.activeDevice ?: return; - if (d is ChromecastCastingDevice) { - _imageDevice.setImageResource(R.drawable.ic_chromecast); - _textType.text = "Chromecast"; - } else if (d is AirPlayCastingDevice) { - _imageDevice.setImageResource(R.drawable.ic_airplay); - _textType.text = "AirPlay"; - } else if (d is FCastCastingDevice) { - _imageDevice.setImageResource(R.drawable.ic_fc); - _textType.text = "FastCast"; - } - - _textName.text = d.name; - _sliderPosition.valueFrom = 0.0f; - _sliderVolume.valueFrom = 0.0f; - _sliderVolume.value = d.volume.toFloat().coerceAtLeast(0.0f).coerceAtMost(_sliderVolume.valueTo); - - val dur = d.duration.toFloat().coerceAtLeast(1.0f) - _sliderPosition.value = d.time.toFloat().coerceAtLeast(0.0f).coerceAtMost(dur) - _sliderPosition.valueTo = dur - - if (d.canSetVolume) { - _layoutVolumeAdjustable.visibility = View.VISIBLE; - _layoutVolumeFixed.visibility = View.GONE; - } else { - _layoutVolumeAdjustable.visibility = View.GONE; - _layoutVolumeFixed.visibility = View.VISIBLE; - } - - val interactiveControls = listOf( - _sliderPosition, - _sliderVolume, - _buttonPrevious, - _buttonPlay, - _buttonPause, - _buttonStop, - _buttonNext - ) - - when (d.connectionState) { - CastConnectionState.CONNECTED -> { - enableControls(interactiveControls) + when (d.device.castingProtocol()) { + ProtocolType.CHROMECAST -> { + _imageDevice.setImageResource(R.drawable.ic_chromecast); + _textType.text = "Chromecast"; + } + ProtocolType.F_CAST -> { + _imageDevice.setImageResource(R.drawable.ic_fc); + _textType.text = "FCast"; + } } - CastConnectionState.CONNECTING, - CastConnectionState.DISCONNECTED -> { - disableControls(interactiveControls) + + _textName.text = d.device.name(); + _sliderPosition.valueFrom = 0.0f; + _sliderVolume.valueFrom = 0.0f; + _sliderVolume.value = d.volume.toFloat().coerceAtLeast(0.0f).coerceAtMost(_sliderVolume.valueTo); + + val dur = d.duration.toFloat().coerceAtLeast(1.0f) + _sliderPosition.value = d.time.toFloat().coerceAtLeast(0.0f).coerceAtMost(dur) + _sliderPosition.valueTo = dur + + if (d.device.supportsFeature(DeviceFeature.SET_VOLUME)) { + _layoutVolumeAdjustable.visibility = View.VISIBLE; + _layoutVolumeFixed.visibility = View.GONE; + } else { + _layoutVolumeAdjustable.visibility = View.GONE; + _layoutVolumeFixed.visibility = View.VISIBLE; + } + + val interactiveControls = listOf( + _sliderPosition, + _sliderVolume, + _buttonPrevious, + _buttonPlay, + _buttonPause, + _buttonStop, + _buttonNext + ) + + when (d.connectionState) { + com.futo.platformplayer.experimental_casting.CastConnectionState.CONNECTED -> { + enableControls(interactiveControls) + } + com.futo.platformplayer.experimental_casting.CastConnectionState.CONNECTING, + com.futo.platformplayer.experimental_casting.CastConnectionState.DISCONNECTED -> { + disableControls(interactiveControls) + } + } + } else { + val d = StateCasting.instance.activeDevice ?: return; + + if (d is ChromecastCastingDevice) { + _imageDevice.setImageResource(R.drawable.ic_chromecast); + _textType.text = "Chromecast"; + } else if (d is AirPlayCastingDevice) { + _imageDevice.setImageResource(R.drawable.ic_airplay); + _textType.text = "AirPlay"; + } else if (d is FCastCastingDevice) { + _imageDevice.setImageResource(R.drawable.ic_fc); + _textType.text = "FCast"; + } + + _textName.text = d.name; + _sliderPosition.valueFrom = 0.0f; + _sliderVolume.valueFrom = 0.0f; + _sliderVolume.value = d.volume.toFloat().coerceAtLeast(0.0f).coerceAtMost(_sliderVolume.valueTo); + + val dur = d.duration.toFloat().coerceAtLeast(1.0f) + _sliderPosition.value = d.time.toFloat().coerceAtLeast(0.0f).coerceAtMost(dur) + _sliderPosition.valueTo = dur + + if (d.canSetVolume) { + _layoutVolumeAdjustable.visibility = View.VISIBLE; + _layoutVolumeFixed.visibility = View.GONE; + } else { + _layoutVolumeAdjustable.visibility = View.GONE; + _layoutVolumeFixed.visibility = View.VISIBLE; + } + + val interactiveControls = listOf( + _sliderPosition, + _sliderVolume, + _buttonPrevious, + _buttonPlay, + _buttonPause, + _buttonStop, + _buttonNext + ) + + when (d.connectionState) { + CastConnectionState.CONNECTED -> { + enableControls(interactiveControls) + } + CastConnectionState.CONNECTING, + CastConnectionState.DISCONNECTED -> { + disableControls(interactiveControls) + } } } } diff --git a/app/src/main/java/com/futo/platformplayer/experimental_casting/CastingDevice.kt b/app/src/main/java/com/futo/platformplayer/experimental_casting/CastingDevice.kt new file mode 100644 index 00000000..e24a9ec8 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/experimental_casting/CastingDevice.kt @@ -0,0 +1,156 @@ +package com.futo.platformplayer.experimental_casting + +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.logging.Logger +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import org.fcast.sender_sdk.GenericKeyEvent +import org.fcast.sender_sdk.GenericMediaEvent +import org.fcast.sender_sdk.PlaybackState +import org.fcast.sender_sdk.Source +import java.net.InetAddress +import org.fcast.sender_sdk.CastingDevice as RsCastingDevice; +import org.fcast.sender_sdk.DeviceEventHandler as RsDeviceEventHandler; +import org.fcast.sender_sdk.DeviceConnectionState + +class CastingDeviceHandle { + class EventHandler : RsDeviceEventHandler { + var onConnectionStateChanged = Event1(); + var onPlayChanged = Event1() + var onTimeChanged = Event1() + var onDurationChanged = Event1() + var onVolumeChanged = Event1() + var onSpeedChanged = Event1() + + override fun connectionStateChanged(state: DeviceConnectionState) { + onConnectionStateChanged.emit(state) + } + + override fun volumeChanged(volume: Double) { + onVolumeChanged.emit(volume) + } + + override fun timeChanged(time: Double) { + onTimeChanged.emit(time) + } + + override fun playbackStateChanged(state: PlaybackState) { + onPlayChanged.emit(state == PlaybackState.PLAYING) + } + + override fun durationChanged(duration: Double) { + onDurationChanged.emit(duration) + } + + override fun speedChanged(speed: Double) { + onSpeedChanged.emit(speed) + } + + override fun sourceChanged(source: Source) { + // TODO + } + + override fun keyEvent(event: GenericKeyEvent) { + // Unreachable + } + + override fun mediaEvent(event: GenericMediaEvent) { + // Unreachable + } + } + + val eventHandler = EventHandler() + val device: RsCastingDevice + + var usedRemoteAddress: InetAddress? = null + var localAddress: InetAddress? = null + var connectionState = CastConnectionState.DISCONNECTED + var volume: Double = 0.0 + var duration: Double = 0.0 + var lastTimeChangeTime_ms: Long = 0 + var time: Double = 0.0 + var speed: Double = 0.0 + var isPlaying: Boolean = false + + val expectedCurrentTime: Double + get() { + val diff = + if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0; + return time + diff; + }; + + constructor(newDevice: RsCastingDevice) { + device = newDevice + eventHandler.onConnectionStateChanged.subscribe { newState -> + if (newState == DeviceConnectionState.Disconnected) { + try { + Logger.i("CastingDeviceHandle", "Stopping device") + device.disconnect() + } catch (e: Throwable) { + Logger.e("CastingDeviceHandle", "Failed to stop device: $e") + } + } + } + } + + fun loadVideo( + streamType: String, + contentType: String, + contentId: String, + resumePosition: Double, + duration: Double, + speed: Double? + ) { + try { + device.loadVideo(contentType, contentId, resumePosition, speed) + } catch (e: Exception) { + Logger.e("CastingDevice", "Failed to load video: $e") + } + } + + fun loadContent( + contentType: String, + content: String, + resumePosition: Double, + duration: Double, + speed: Double? + ) { + device.loadContent(contentType, content, resumePosition, duration, speed) + } +} + +enum class CastConnectionState { + DISCONNECTED, + CONNECTING, + CONNECTED +} + +@Serializable(with = ExpCastProtocolType.CastProtocolTypeSerializer::class) +enum class ExpCastProtocolType { + CHROMECAST, + FCAST; + + object CastProtocolTypeSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("CastProtocolType", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: ExpCastProtocolType) { + encoder.encodeString(value.name) + } + + override fun deserialize(decoder: Decoder): ExpCastProtocolType { + val name = decoder.decodeString() + return when (name) { + "FASTCAST" -> FCAST // Handle the renamed case + else -> ExpCastProtocolType.valueOf(name) + } + } + } +} diff --git a/app/src/main/java/com/futo/platformplayer/experimental_casting/StateCasting.kt b/app/src/main/java/com/futo/platformplayer/experimental_casting/StateCasting.kt new file mode 100644 index 00000000..5edbcfe4 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/experimental_casting/StateCasting.kt @@ -0,0 +1,2021 @@ +package com.futo.platformplayer.experimental_casting + +import android.app.AlertDialog +import android.content.ContentResolver +import android.content.Context +import android.os.Looper +import android.util.Log +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import com.futo.platformplayer.R +import com.futo.platformplayer.Settings +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.api.http.ManagedHttpClient +import com.futo.platformplayer.api.http.server.HttpHeaders +import com.futo.platformplayer.api.http.server.ManagedHttpServer +import com.futo.platformplayer.api.http.server.handlers.HttpConstantHandler +import com.futo.platformplayer.api.http.server.handlers.HttpFileHandler +import com.futo.platformplayer.api.http.server.handlers.HttpFunctionHandler +import com.futo.platformplayer.api.http.server.handlers.HttpProxyHandler +import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource +import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource +import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudioSource +import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource +import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource +import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource +import com.futo.platformplayer.api.media.models.streams.sources.LocalAudioSource +import com.futo.platformplayer.api.media.models.streams.sources.LocalSubtitleSource +import com.futo.platformplayer.api.media.models.streams.sources.LocalVideoSource +import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource +import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails +import com.futo.platformplayer.api.media.platforms.js.models.JSRequestExecutor +import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestMergingRawSource +import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource +import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource +import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource +import com.futo.platformplayer.builders.DashBuilder +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.constructs.Event2 +import com.futo.platformplayer.exceptions.UnsupportedCastException +import com.futo.platformplayer.findPreferredAddress +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.models.CastingDeviceInfo +import com.futo.platformplayer.parsers.HLS +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.stores.CastingDeviceInfoStorage +import com.futo.platformplayer.stores.FragmentedStorage +import com.futo.platformplayer.toUrlAddress +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.net.Inet6Address +import java.net.InetAddress +import java.net.URLDecoder +import java.net.URLEncoder +import java.util.UUID +import org.fcast.sender_sdk.DeviceInfo as RsDeviceInfo +import org.fcast.sender_sdk.CastingDevice as RsCastingDevice +import org.fcast.sender_sdk.ProtocolType +import org.fcast.sender_sdk.CastContext +import org.fcast.sender_sdk.DeviceConnectionState +import org.fcast.sender_sdk.NsdDeviceDiscoverer +import org.fcast.sender_sdk.urlFormatIpAddr +import java.util.concurrent.atomic.AtomicInteger + +class ExpStateCasting { + private val _scopeIO = CoroutineScope(Dispatchers.IO) + private val _scopeMain = CoroutineScope(Dispatchers.Main) + private val _storage: CastingDeviceInfoStorage = FragmentedStorage.get() + + private val _castServer = ManagedHttpServer() + private var _started = false + + val onDeviceAdded = Event1() + val onDeviceChanged = Event1() + val onDeviceRemoved = Event1() + val onActiveDeviceTimeChanged = Event1() + val onActiveDeviceDurationChanged = Event1() + val onActiveDeviceVolumeChanged = Event1() + val onActiveDevicePlayChanged = Event1() + val onActiveDeviceConnectionStateChanged = Event2() + private var _videoExecutor: JSRequestExecutor? = null + private var _audioExecutor: JSRequestExecutor? = null + private val _client = ManagedHttpClient() + val isCasting: Boolean get() = activeDevice != null + private val _context = CastContext() + var activeDevice: CastingDeviceHandle? = null + var devices: HashMap = hashMapOf() + var _resumeCastingDevice: RsDeviceInfo? = null + var _deviceDiscoverer: NsdDeviceDiscoverer? = null + private val _castId = AtomicInteger(0) + + class DiscoveryEventHandler( + private val onDeviceAdded: (RsDeviceInfo) -> Unit, + private val onDeviceRemoved: (String) -> Unit, + private val onDeviceUpdated: (RsDeviceInfo) -> Unit, + ) : org.fcast.sender_sdk.DeviceDiscovererEventHandler { + override fun deviceAvailable(deviceInfo: RsDeviceInfo) { + onDeviceAdded(deviceInfo) + } + + override fun deviceChanged(deviceInfo: RsDeviceInfo) { + onDeviceUpdated(deviceInfo) + } + + override fun deviceRemoved(deviceName: String) { + onDeviceRemoved(deviceName) + } + } + + init { + org.fcast.sender_sdk.initLogger(org.fcast.sender_sdk.LogLevelFilter.DEBUG) + onDeviceAdded.subscribe { device -> + invokeInMainScopeIfRequired { + val deviceHandle = CastingDeviceHandle(device) + devices[deviceHandle.device.name()] = deviceHandle + Log.i(TAG, "Device added: ${deviceHandle.device.name()}") + } + } + + onDeviceRemoved.subscribe { deviceName -> + invokeInMainScopeIfRequired { + if (devices.containsKey(deviceName)) { + devices.remove(deviceName) + } + } + } + } + + fun handleUrl(context: Context, url: String) { + try { + val foundDeviceInfo = org.fcast.sender_sdk.deviceInfoFromUrl(url)!! + val foundDevice = _context.createDeviceFromInfo(foundDeviceInfo) + connectDevice(CastingDeviceHandle(foundDevice)) + } catch (e: Throwable) { + Logger.e(TAG, "Failed to handle URL: $e") + } + } + + fun onStop() { + val ad = activeDevice ?: return + _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() + } + + fun onResume() { + val ad = activeDevice + if (ad != null) { + // TODO: needed? + // if (ad is FCastCastingDevice) { + // ad.ensureThreadStarted() + // } else if (ad is ChromecastCastingDevice) { + // ad.ensureThreadsStarted() + // } + } else { + val resumeCastingDevice = _resumeCastingDevice + if (resumeCastingDevice != null) { + try { + connectDevice( + CastingDeviceHandle(_context.createDeviceFromInfo(resumeCastingDevice)) + ) + _resumeCastingDevice = null + Log.i(TAG, "_resumeCastingDevice set to null onResume") + } catch (e: Throwable) { + Logger.e(TAG, "Failed to resume: $e") + } + } + } + } + + @Synchronized + fun start(context: Context) { + if (_started) + return; + _started = true; + + Log.i(TAG, "_resumeCastingDevice set null start") + _resumeCastingDevice = null; + + Logger.i(TAG, "CastingService starting..."); + + _castServer.start(); + enableDeveloper(true); + + Logger.i(TAG, "CastingService started."); + + _deviceDiscoverer = NsdDeviceDiscoverer( + context, + DiscoveryEventHandler( + { deviceInfo -> // Added + val device = _context.createDeviceFromInfo(deviceInfo) + invokeInMainScopeIfRequired { + onDeviceAdded.emit(device) + } + }, + { deviceName -> // Removed + invokeInMainScopeIfRequired { + onDeviceRemoved.emit(deviceName) + } + }, + { deviceInfo -> // Updated + val handle = devices[deviceInfo.name] + if (handle != null) { + handle.device.setPort(deviceInfo.port) + handle.device.setAddresses(deviceInfo.addresses) + invokeInMainScopeIfRequired { + onDeviceChanged.emit(handle) + } + } + }, + ) + ) + } + + @Synchronized + fun stop() { + if (!_started) + return; + + _started = false; + + Logger.i(TAG, "CastingService stopping.") + + _scopeIO.cancel(); + _scopeMain.cancel(); + + Logger.i(TAG, "Stopping active device because StateCasting is being stopped.") + val d = activeDevice; + activeDevice = null; + d?.device?.disconnect(); + + _castServer.stop(); + _castServer.removeAllHandlers(); + + Logger.i(TAG, "CastingService stopped.") + + _deviceDiscoverer = null + } + + private val _castingDialogLock = Any(); + private var _currentDialog: AlertDialog? = null; + + @Synchronized + fun connectDevice(device: CastingDeviceHandle) { + if (activeDevice == device) + return; + + val ad = activeDevice; + if (ad != null) { + Logger.i(TAG, "Stopping previous device because a new one is being connected.") + device.eventHandler.onConnectionStateChanged.clear(); + device.eventHandler.onPlayChanged.clear(); + device.eventHandler.onTimeChanged.clear(); + device.eventHandler.onVolumeChanged.clear(); + device.eventHandler.onDurationChanged.clear(); + ad.device.disconnect(); + } + + device.eventHandler.onConnectionStateChanged.subscribe { castConnectionState -> + Logger.i(TAG, "Active device connection state changed: $castConnectionState"); + + if (castConnectionState == DeviceConnectionState.Disconnected) { + Logger.i(TAG, "Clearing events: $castConnectionState"); + device.eventHandler.onConnectionStateChanged.clear(); + device.eventHandler.onPlayChanged.clear(); + device.eventHandler.onTimeChanged.clear(); + device.eventHandler.onVolumeChanged.clear(); + device.eventHandler.onDurationChanged.clear(); + activeDevice = null; + } + + invokeInMainScopeIfRequired { + StateApp.withContext(false) { context -> + context.let { + Logger.i(TAG, "Casting state changed to ${castConnectionState}"); + when (castConnectionState) { + is DeviceConnectionState.Connected -> { + device.connectionState = CastConnectionState.CONNECTED + val localAddrOctets = + org.fcast.sender_sdk.octetsFromIpAddr(castConnectionState.localAddr) + val remoteAddrOctets = + org.fcast.sender_sdk.octetsFromIpAddr(castConnectionState.usedRemoteAddr) + device.localAddress = InetAddress.getByAddress(localAddrOctets) + device.usedRemoteAddress = + InetAddress.getByAddress(remoteAddrOctets) + Logger.i(TAG, "Casting connected to [${device.device.name()}]"); + UIDialogs.appToast("Connected to device") + synchronized(_castingDialogLock) { + if (_currentDialog != null) { + _currentDialog?.hide(); + _currentDialog = null; + } + } + onActiveDeviceConnectionStateChanged.emit( + device, + CastConnectionState.CONNECTED + ); + } + + DeviceConnectionState.Connecting -> { + device.connectionState = CastConnectionState.CONNECTING + Logger.i(TAG, "Casting connecting to [${device.device.name()}]"); + UIDialogs.toast(it, "Connecting to device...") + synchronized(_castingDialogLock) { + if (_currentDialog == null) { + _currentDialog = UIDialogs.showDialog( + context, + R.drawable.ic_loader_animated, + true, + "Connecting to [${device.device.name()}]", + "Make sure you are on the same network\n\nVPNs and guest networks can cause issues", + null, + -2, + UIDialogs.Action("Disconnect", { + try { + device.device.disconnect(); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to stop device: $e") + } + }) + ); + } + } + onActiveDeviceConnectionStateChanged.emit( + device, + CastConnectionState.CONNECTING + ); + } + + DeviceConnectionState.Disconnected -> { + device.connectionState = CastConnectionState.DISCONNECTED + UIDialogs.toast(it, "Disconnected from device") + synchronized(_castingDialogLock) { + if (_currentDialog != null) { + _currentDialog?.hide(); + _currentDialog = null; + } + } + onActiveDeviceConnectionStateChanged.emit( + device, + CastConnectionState.DISCONNECTED + ); + } + } + } + }; + }; + }; + + device.eventHandler.onPlayChanged.subscribe { + invokeInMainScopeIfRequired { + device.isPlaying = it + onActiveDevicePlayChanged.emit(it) + } + } + device.eventHandler.onDurationChanged.subscribe { + invokeInMainScopeIfRequired { + device.duration = it + onActiveDeviceDurationChanged.emit(it) + } + } + device.eventHandler.onVolumeChanged.subscribe { + invokeInMainScopeIfRequired { + device.volume = it + onActiveDeviceVolumeChanged.emit(it) + } + } + device.eventHandler.onTimeChanged.subscribe { + invokeInMainScopeIfRequired { + device.time = it + device.lastTimeChangeTime_ms = System.currentTimeMillis() + onActiveDeviceTimeChanged.emit(it) + } + } + device.eventHandler.onSpeedChanged.subscribe { + invokeInMainScopeIfRequired { + device.speed = it + } + } + + try { + device.device.connect(device.eventHandler) + Logger.i(TAG, "Requested manager to start device") + } catch (e: Throwable) { + Logger.w(TAG, "Failed to connect to device."); + device.eventHandler.onConnectionStateChanged.clear(); + device.eventHandler.onPlayChanged.clear(); + device.eventHandler.onTimeChanged.clear(); + device.eventHandler.onVolumeChanged.clear(); + device.eventHandler.onDurationChanged.clear(); + return; + } + + activeDevice = device; + Logger.i(TAG, "Started device `${device.device.name()}`"); + } + + fun addRememberedDevice(deviceInfo: CastingDeviceInfo): CastingDeviceInfo { + val device = deviceFromCastingDeviceInfo(deviceInfo); + return addRememberedDevice(device); + } + + fun getRememberedCastingDevices(): List { + return _storage.getDevices().map { deviceFromCastingDeviceInfo(it) } + } + + fun getRememberedCastingDeviceNames(): List { + return _storage.getDeviceNames() + } + + fun addRememberedDevice(device: CastingDeviceHandle): CastingDeviceInfo { + val rsDeviceInfo = device.device.getDeviceInfo() + val deviceInfo = CastingDeviceInfo( + name = device.device.name(), + type = when (rsDeviceInfo.type) { + ProtocolType.CHROMECAST -> com.futo.platformplayer.casting.CastProtocolType.CHROMECAST + ProtocolType.F_CAST -> com.futo.platformplayer.casting.CastProtocolType.FCAST + }, + addresses = rsDeviceInfo.addresses.map { urlFormatIpAddr(it) }.toTypedArray(), + port = rsDeviceInfo.port.toInt(), + ) + return _storage.addDevice(deviceInfo) + } + + fun removeRememberedDevice(device: CastingDeviceHandle) { + val name = device.device.name() + _storage.removeDevice(name) + } + + private fun invokeInMainScopeIfRequired(action: () -> Unit) { + if (Looper.getMainLooper().thread != Thread.currentThread()) { + _scopeMain.launch { action(); } + return; + } + + action(); + } + + private fun shouldProxyStreams( + deviceHandle: CastingDeviceHandle, + videoSource: IVideoSource?, + audioSource: IAudioSource? + ): Boolean { + val hasRequestModifier = + (videoSource as? JSSource)?.hasRequestModifier == true || (audioSource as? JSSource)?.hasRequestModifier == true + return Settings.instance.casting.alwaysProxyRequests || deviceHandle.device.castingProtocol() != ProtocolType.F_CAST || hasRequestModifier + } + + suspend 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 { + return withContext(Dispatchers.IO) { + val ad = activeDevice ?: return@withContext false; + if (ad.connectionState != CastConnectionState.CONNECTED) { + return@withContext false; + } + + val resumePosition = if (ms > 0L) (ms.toDouble() / 1000.0) else 0.0; + val castId = _castId.incrementAndGet() + + var sourceCount = 0; + if (videoSource != null) sourceCount++; + if (audioSource != null) sourceCount++; + if (subtitleSource != null) sourceCount++; + + if (sourceCount < 1) { + throw Exception("At least one source should be specified."); + } + + if (sourceCount > 1) { + if (videoSource is LocalVideoSource || audioSource is LocalAudioSource || subtitleSource is LocalSubtitleSource) { + Logger.i(TAG, "Casting as local DASH"); + castLocalDash( + video, + videoSource as LocalVideoSource?, + audioSource as LocalAudioSource?, + subtitleSource as LocalSubtitleSource?, + resumePosition, + speed + ); + } else { + val isRawDash = + videoSource is JSDashManifestRawSource || audioSource is JSDashManifestRawAudioSource + if (isRawDash) { + Logger.i(TAG, "Casting as raw DASH"); + + castDashRaw( + contentResolver, + video, + videoSource as JSDashManifestRawSource?, + audioSource as JSDashManifestRawAudioSource?, + subtitleSource, + resumePosition, + speed, + castId, + onLoadingEstimate, + onLoading + ); + } else { + if (ad.device.castingProtocol() == ProtocolType.F_CAST) { + Logger.i(TAG, "Casting as DASH direct"); + castDashDirect( + contentResolver, + video, + videoSource as IVideoUrlSource?, + audioSource as IAudioUrlSource?, + subtitleSource, + resumePosition, + speed + ); + } else { + Logger.i(TAG, "Casting as DASH indirect"); + castDashIndirect( + contentResolver, + video, + videoSource as IVideoUrlSource?, + audioSource as IAudioUrlSource?, + subtitleSource, + resumePosition, + speed + ); + } + } + } + } else { + val proxyStreams = shouldProxyStreams(ad, videoSource, audioSource) + val url = getLocalUrl(ad); + val id = UUID.randomUUID(); + + if (videoSource is IVideoUrlSource) { + val videoPath = "/video-${id}" + val videoUrl = if (proxyStreams) url + videoPath else videoSource.getVideoUrl(); + Logger.i(TAG, "Casting as singular video"); + ad.loadVideo( + if (video.isLive) "LIVE" else "BUFFERED", + videoSource.container, + videoUrl, + resumePosition, + video.duration.toDouble(), + speed + ); + } else if (audioSource is IAudioUrlSource) { + val audioPath = "/audio-${id}" + val audioUrl = if (proxyStreams) url + audioPath else audioSource.getAudioUrl(); + Logger.i(TAG, "Casting as singular audio"); + ad.loadVideo( + if (video.isLive) "LIVE" else "BUFFERED", + audioSource.container, + audioUrl, + resumePosition, + video.duration.toDouble(), + speed + ); + } else if (videoSource is IHLSManifestSource) { + if (proxyStreams || ad.device.castingProtocol() == ProtocolType.CHROMECAST) { + Logger.i(TAG, "Casting as proxied HLS"); + castProxiedHls( + video, + videoSource.url, + videoSource.codec, + resumePosition, + speed + ); + } else { + Logger.i(TAG, "Casting as non-proxied HLS"); + ad.loadVideo( + if (video.isLive) "LIVE" else "BUFFERED", + videoSource.container, + videoSource.url, + resumePosition, + video.duration.toDouble(), + speed + ); + } + } else if (audioSource is IHLSManifestAudioSource) { + if (proxyStreams || ad.device.castingProtocol() == ProtocolType.CHROMECAST) { + Logger.i(TAG, "Casting as proxied audio HLS"); + castProxiedHls( + video, + audioSource.url, + audioSource.codec, + resumePosition, + speed + ); + } else { + Logger.i(TAG, "Casting as non-proxied audio HLS"); + ad.loadVideo( + if (video.isLive) "LIVE" else "BUFFERED", + audioSource.container, + audioSource.url, + resumePosition, + video.duration.toDouble(), + speed + ); + } + } else if (videoSource is LocalVideoSource) { + Logger.i(TAG, "Casting as local video"); + castLocalVideo(video, videoSource, resumePosition, speed); + } else if (audioSource is LocalAudioSource) { + Logger.i(TAG, "Casting as local audio"); + castLocalAudio(video, audioSource, resumePosition, speed); + } else if (videoSource is JSDashManifestRawSource) { + Logger.i(TAG, "Casting as JSDashManifestRawSource video"); + castDashRaw( + contentResolver, + video, + videoSource as JSDashManifestRawSource?, + null, + null, + resumePosition, + speed, + castId, + onLoadingEstimate, + onLoading + ); + } else if (audioSource is JSDashManifestRawAudioSource) { + Logger.i(TAG, "Casting as JSDashManifestRawSource audio"); + castDashRaw( + contentResolver, + video, + null, + audioSource as JSDashManifestRawAudioSource?, + null, + resumePosition, + speed, + castId, + onLoadingEstimate, + onLoading + ); + } else { + var str = listOf( + if (videoSource != null) "Video: ${videoSource::class.java.simpleName}" else null, + if (audioSource != null) "Audio: ${audioSource::class.java.simpleName}" else null, + if (subtitleSource != null) "Subtitles: ${subtitleSource::class.java.simpleName}" else null + ).filterNotNull().joinToString(", "); + throw UnsupportedCastException(str); + } + } + + return@withContext true; + } + } + + fun resumeVideo(): Boolean { + val ad = activeDevice ?: return false; + try { + ad.device.resumePlayback() + } catch (e: Throwable) { + Logger.e(TAG, "Failed to resume playback: $e") + } + return true; + } + + fun pauseVideo(): Boolean { + val ad = activeDevice ?: return false; + try { + ad.device.pausePlayback() + } catch (e: Throwable) { + Logger.e(TAG, "Failed to pause playback: $e") + } + return true; + } + + fun stopVideo(): Boolean { + val ad = activeDevice ?: return false; + try { + ad.device.stopPlayback() + } catch (e: Throwable) { + Logger.e(TAG, "Failed to stop playback: $e") + } + return true; + } + + fun videoSeekTo(timeSeconds: Double): Boolean { + val ad = activeDevice ?: return false; + try { + ad.device.seek(timeSeconds) + } catch (e: Throwable) { + Logger.e(TAG, "Failed to seek: $e") + } + return true; + } + + private fun castLocalVideo( + video: IPlatformVideoDetails, + videoSource: LocalVideoSource, + resumePosition: Double, + speed: Double? + ): List { + val ad = activeDevice ?: return listOf(); + + val url = getLocalUrl(ad); + val id = UUID.randomUUID(); + val videoPath = "/video-${id}" + val videoUrl = url + videoPath; + + _castServer.addHandlerWithAllowAllOptions( + HttpFileHandler("GET", videoPath, videoSource.container, videoSource.filePath) + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("cast"); + + Logger.i(TAG, "Casting local video (videoUrl: $videoUrl)."); + ad.loadVideo( + "BUFFERED", + videoSource.container, + videoUrl, + resumePosition, + video.duration.toDouble(), + speed + ); + + return listOf(videoUrl); + } + + private fun castLocalAudio( + video: IPlatformVideoDetails, + audioSource: LocalAudioSource, + resumePosition: Double, + speed: Double? + ): List { + val ad = activeDevice ?: return listOf(); + + val url = getLocalUrl(ad); + val id = UUID.randomUUID(); + val audioPath = "/audio-${id}" + val audioUrl = url + audioPath; + + _castServer.addHandlerWithAllowAllOptions( + HttpFileHandler("GET", audioPath, audioSource.container, audioSource.filePath) + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("cast"); + + Logger.i(TAG, "Casting local audio (audioUrl: $audioUrl)."); + ad.loadVideo( + "BUFFERED", + audioSource.container, + audioUrl, + resumePosition, + video.duration.toDouble(), + speed + ); + + return listOf(audioUrl); + } + + private fun castLocalHls( + video: IPlatformVideoDetails, + videoSource: LocalVideoSource?, + audioSource: LocalAudioSource?, + subtitleSource: LocalSubtitleSource?, + resumePosition: Double, + speed: Double? + ): List { + val ad = activeDevice ?: return listOf() + + val url = getLocalUrl(ad) + val id = UUID.randomUUID() + + val hlsPath = "/hls-${id}" + val videoPath = "/video-${id}" + val audioPath = "/audio-${id}" + val subtitlePath = "/subtitle-${id}" + + val hlsUrl = url + hlsPath + val videoUrl = url + videoPath + val audioUrl = url + audioPath + val subtitleUrl = url + subtitlePath + + val mediaRenditions = arrayListOf() + val variantPlaylistReferences = arrayListOf() + + if (videoSource != null) { + _castServer.addHandlerWithAllowAllOptions( + HttpFileHandler("GET", videoPath, videoSource.container, videoSource.filePath) + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("castLocalHls") + + val duration = videoSource.duration + val videoVariantPlaylistPath = "/video-playlist-${id}" + val videoVariantPlaylistUrl = url + videoVariantPlaylistPath + val videoVariantPlaylistSegments = + listOf(HLS.MediaSegment(duration.toDouble(), videoUrl)) + val videoVariantPlaylist = HLS.VariantPlaylist( + 3, + duration.toInt(), + 0, + 0, + null, + null, + null, + videoVariantPlaylistSegments + ) + + _castServer.addHandlerWithAllowAllOptions( + HttpConstantHandler( + "GET", videoVariantPlaylistPath, videoVariantPlaylist.buildM3U8(), + "application/vnd.apple.mpegurl" + ) + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("castLocalHls") + + variantPlaylistReferences.add( + HLS.VariantPlaylistReference( + videoVariantPlaylistUrl, HLS.StreamInfo( + videoSource.bitrate, + "${videoSource.width}x${videoSource.height}", + videoSource.codec, + null, + null, + if (audioSource != null) "audio" else null, + if (subtitleSource != null) "subtitles" else null, + null, + null + ) + ) + ) + } + + if (audioSource != null) { + _castServer.addHandlerWithAllowAllOptions( + HttpFileHandler("GET", audioPath, audioSource.container, audioSource.filePath) + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("castLocalHls") + + val duration = + audioSource.duration ?: videoSource?.duration ?: throw Exception("Duration unknown") + val audioVariantPlaylistPath = "/audio-playlist-${id}" + val audioVariantPlaylistUrl = url + audioVariantPlaylistPath + val audioVariantPlaylistSegments = + listOf(HLS.MediaSegment(duration.toDouble(), audioUrl)) + val audioVariantPlaylist = HLS.VariantPlaylist( + 3, + duration.toInt(), + 0, + 0, + null, + null, + null, + audioVariantPlaylistSegments + ) + + _castServer.addHandlerWithAllowAllOptions( + HttpConstantHandler( + "GET", audioVariantPlaylistPath, audioVariantPlaylist.buildM3U8(), + "application/vnd.apple.mpegurl" + ) + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("castLocalHls") + + mediaRenditions.add( + HLS.MediaRendition( + "AUDIO", + audioVariantPlaylistUrl, + "audio", + "df", + "default", + true, + true, + true + ) + ) + } + + if (subtitleSource != null) { + _castServer.addHandlerWithAllowAllOptions( + HttpFileHandler( + "GET", + subtitlePath, + subtitleSource.format ?: "text/vtt", + subtitleSource.filePath + ) + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("castLocalHls") + + val duration = videoSource?.duration ?: audioSource?.duration + ?: throw Exception("Duration unknown") + val subtitleVariantPlaylistPath = "/subtitle-playlist-${id}" + val subtitleVariantPlaylistUrl = url + subtitleVariantPlaylistPath + val subtitleVariantPlaylistSegments = + listOf(HLS.MediaSegment(duration.toDouble(), subtitleUrl)) + val subtitleVariantPlaylist = HLS.VariantPlaylist( + 3, + duration.toInt(), + 0, + 0, + null, + null, + null, + subtitleVariantPlaylistSegments + ) + + _castServer.addHandlerWithAllowAllOptions( + HttpConstantHandler( + "GET", subtitleVariantPlaylistPath, subtitleVariantPlaylist.buildM3U8(), + "application/vnd.apple.mpegurl" + ) + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("castLocalHls") + + mediaRenditions.add( + HLS.MediaRendition( + "SUBTITLES", + subtitleVariantPlaylistUrl, + "subtitles", + "df", + "default", + true, + true, + true + ) + ) + } + + val masterPlaylist = + HLS.MasterPlaylist(variantPlaylistReferences, mediaRenditions, listOf(), true) + _castServer.addHandlerWithAllowAllOptions( + HttpConstantHandler( + "GET", hlsPath, masterPlaylist.buildM3U8(), + "application/vnd.apple.mpegurl" + ) + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("castLocalHls") + + Logger.i( + TAG, + "added new castLocalHls handlers (hlsPath: $hlsPath, videoPath: $videoPath, audioPath: $audioPath, subtitlePath: $subtitlePath)." + ) + ad.loadVideo( + "BUFFERED", + "application/vnd.apple.mpegurl", + hlsUrl, + resumePosition, + video.duration.toDouble(), + speed + ) + + return listOf(hlsUrl, videoUrl, audioUrl, subtitleUrl) + } + + private fun castLocalDash( + video: IPlatformVideoDetails, + videoSource: LocalVideoSource?, + audioSource: LocalAudioSource?, + subtitleSource: LocalSubtitleSource?, + resumePosition: Double, + speed: Double? + ): List { + val ad = activeDevice ?: return listOf(); + + val url = getLocalUrl(ad); + val id = UUID.randomUUID(); + + val dashPath = "/dash-${id}" + val videoPath = "/video-${id}" + val audioPath = "/audio-${id}" + val subtitlePath = "/subtitle-${id}" + + val dashUrl = url + dashPath; + val videoUrl = url + videoPath; + val audioUrl = url + audioPath; + val subtitleUrl = url + subtitlePath; + + val dashContent = DashBuilder.generateOnDemandDash( + videoSource, + videoUrl, + audioSource, + audioUrl, + subtitleSource, + subtitleUrl + ); + Logger.v(TAG) { "Dash manifest: $dashContent" }; + + _castServer.addHandlerWithAllowAllOptions( + HttpConstantHandler( + "GET", dashPath, dashContent, + "application/dash+xml" + ) + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("cast"); + if (videoSource != null) { + _castServer.addHandlerWithAllowAllOptions( + HttpFileHandler("GET", videoPath, videoSource.container, videoSource.filePath) + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("cast"); + } + if (audioSource != null) { + _castServer.addHandlerWithAllowAllOptions( + HttpFileHandler("GET", audioPath, audioSource.container, audioSource.filePath) + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("cast"); + } + if (subtitleSource != null) { + _castServer.addHandlerWithAllowAllOptions( + HttpFileHandler( + "GET", + subtitlePath, + subtitleSource.format ?: "text/vtt", + subtitleSource.filePath + ) + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("cast"); + } + + Logger.i( + TAG, + "added new castLocalDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath, subtitlePath: $subtitlePath)." + ); + ad.loadVideo( + "BUFFERED", + "application/dash+xml", + dashUrl, + resumePosition, + video.duration.toDouble(), + speed + ); + + return listOf(dashUrl, videoUrl, audioUrl, subtitleUrl); + } + + private suspend fun castDashDirect( + contentResolver: ContentResolver, + video: IPlatformVideoDetails, + videoSource: IVideoUrlSource?, + audioSource: IAudioUrlSource?, + subtitleSource: ISubtitleSource?, + resumePosition: Double, + speed: Double? + ): List { + val ad = activeDevice ?: return listOf(); + val proxyStreams = + Settings.instance.casting.alwaysProxyRequests || ad.device.castingProtocol() != ProtocolType.F_CAST + + val url = getLocalUrl(ad); + val id = UUID.randomUUID(); + + val videoPath = "/video-${id}" + val audioPath = "/audio-${id}" + val subtitlePath = "/subtitle-${id}" + + val videoUrl = if (proxyStreams) url + videoPath else videoSource?.getVideoUrl(); + val audioUrl = if (proxyStreams) url + audioPath else audioSource?.getAudioUrl(); + + val subtitlesUri = if (subtitleSource != null) withContext(Dispatchers.IO) { + return@withContext subtitleSource.getSubtitlesURI(); + } else null; + + var subtitlesUrl: String? = null; + if (subtitlesUri != null) { + if (subtitlesUri.scheme == "file") { + var content: String? = null; + val inputStream = contentResolver.openInputStream(subtitlesUri); + inputStream?.use { stream -> + val reader = stream.bufferedReader(); + content = reader.use { it.readText() }; + } + + if (content != null) { + _castServer.addHandlerWithAllowAllOptions( + HttpConstantHandler( + "GET", + subtitlePath, + content!!, + subtitleSource?.format ?: "text/vtt" + ) + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("cast"); + } + + subtitlesUrl = url + subtitlePath; + } else { + subtitlesUrl = subtitlesUri.toString(); + } + } + + if (videoSource != null) { + _castServer.addHandlerWithAllowAllOptions( + HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true) + .withInjectedHost() + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("cast"); + } + if (audioSource != null) { + _castServer.addHandlerWithAllowAllOptions( + HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true) + .withInjectedHost() + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("cast"); + } + + val content = DashBuilder.generateOnDemandDash( + videoSource, + videoUrl, + audioSource, + audioUrl, + subtitleSource, + subtitlesUrl + ); + + Logger.i( + TAG, + "Direct dash cast to casting device (videoUrl: $videoUrl, audioUrl: $audioUrl)." + ); + Logger.v(TAG) { "Dash manifest: $content" }; + ad.loadContent( + "application/dash+xml", + content, + resumePosition, + video.duration.toDouble(), + speed + ); + + return listOf( + videoUrl ?: "", + audioUrl ?: "", + subtitlesUrl ?: "", + videoSource?.getVideoUrl() ?: "", + audioSource?.getAudioUrl() ?: "", + subtitlesUri.toString() + ); + } + + private fun castProxiedHls( + video: IPlatformVideoDetails, + sourceUrl: String, + codec: String?, + resumePosition: Double, + speed: Double? + ): List { + _castServer.removeAllHandlers("castProxiedHlsMaster") + + val ad = activeDevice ?: return listOf(); + val url = getLocalUrl(ad); + + val id = UUID.randomUUID(); + val hlsPath = "/hls-${id}" + val hlsUrl = url + hlsPath + Logger.i(TAG, "HLS url: $hlsUrl"); + + _castServer.addHandlerWithAllowAllOptions( + HttpFunctionHandler( + "GET", + hlsPath + ) { masterContext -> + _castServer.removeAllHandlers("castProxiedHlsVariant") + + val headers = masterContext.headers.clone() + headers["Content-Type"] = "application/vnd.apple.mpegurl"; + + val masterPlaylistResponse = _client.get(sourceUrl) + check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" } + + val masterPlaylistContent = masterPlaylistResponse.body?.string() + ?: throw Exception("Master playlist content is empty") + + val masterPlaylist: HLS.MasterPlaylist + try { + masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, sourceUrl) + } catch (e: Throwable) { + if (masterPlaylistContent.lines().any { it.startsWith("#EXTINF:") }) { + //This is a variant playlist, not a master playlist + Logger.i(TAG, "HLS casting as variant playlist (codec: $codec): $hlsUrl"); + + val vpHeaders = masterContext.headers.clone() + vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl"; + + val variantPlaylist = + HLS.parseVariantPlaylist(masterPlaylistContent, sourceUrl) + val proxiedVariantPlaylist = + proxyVariantPlaylist(url, id, variantPlaylist, video.isLive) + val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8() + masterContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8); + return@HttpFunctionHandler + } else { + throw e + } + } + + Logger.i(TAG, "HLS casting as master playlist: $hlsUrl"); + + val newVariantPlaylistRefs = arrayListOf() + val newMediaRenditions = arrayListOf() + val newMasterPlaylist = HLS.MasterPlaylist( + newVariantPlaylistRefs, + newMediaRenditions, + masterPlaylist.sessionDataList, + masterPlaylist.independentSegments + ) + + for (variantPlaylistRef in masterPlaylist.variantPlaylistsRefs) { + val playlistId = UUID.randomUUID(); + val newPlaylistPath = "/hls-playlist-${playlistId}" + val newPlaylistUrl = url + newPlaylistPath; + + _castServer.addHandlerWithAllowAllOptions( + HttpFunctionHandler( + "GET", + newPlaylistPath + ) { vpContext -> + val vpHeaders = vpContext.headers.clone() + vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl"; + + val response = _client.get(variantPlaylistRef.url) + check(response.isOk) { "Failed to get variant playlist: ${response.code}" } + + val vpContent = response.body?.string() + ?: throw Exception("Variant playlist content is empty") + + val variantPlaylist = + HLS.parseVariantPlaylist(vpContent, variantPlaylistRef.url) + val proxiedVariantPlaylist = + proxyVariantPlaylist(url, playlistId, variantPlaylist, video.isLive) + val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8() + vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8); + }.withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("castProxiedHlsVariant") + + newVariantPlaylistRefs.add( + HLS.VariantPlaylistReference( + newPlaylistUrl, + variantPlaylistRef.streamInfo + ) + ) + } + + for (mediaRendition in masterPlaylist.mediaRenditions) { + val playlistId = UUID.randomUUID() + + var newPlaylistUrl: String? = null + if (mediaRendition.uri != null) { + val newPlaylistPath = "/hls-playlist-${playlistId}" + newPlaylistUrl = url + newPlaylistPath + + _castServer.addHandlerWithAllowAllOptions( + HttpFunctionHandler( + "GET", + newPlaylistPath + ) { vpContext -> + val vpHeaders = vpContext.headers.clone() + vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl"; + + val response = _client.get(mediaRendition.uri) + check(response.isOk) { "Failed to get variant playlist: ${response.code}" } + + val vpContent = response.body?.string() + ?: throw Exception("Variant playlist content is empty") + + val variantPlaylist = + HLS.parseVariantPlaylist(vpContent, mediaRendition.uri) + val proxiedVariantPlaylist = proxyVariantPlaylist( + url, + playlistId, + variantPlaylist, + video.isLive + ) + val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8() + vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8); + }.withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("castProxiedHlsVariant") + } + + newMediaRenditions.add( + HLS.MediaRendition( + mediaRendition.type, + newPlaylistUrl, + mediaRendition.groupID, + mediaRendition.language, + mediaRendition.name, + mediaRendition.isDefault, + mediaRendition.isAutoSelect, + mediaRendition.isForced + ) + ) + } + + masterContext.respondCode(200, headers, newMasterPlaylist.buildM3U8()); + }.withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("castProxiedHlsMaster") + + Logger.i(TAG, "added new castHlsIndirect handlers (hlsPath: $hlsPath)."); + + //ChromeCast is sometimes funky with resume position 0 + val hackfixResumePosition = + if (ad.device.castingProtocol() == ProtocolType.CHROMECAST && !video.isLive && resumePosition == 0.0) 0.1 else resumePosition; + ad.loadVideo( + if (video.isLive) "LIVE" else "BUFFERED", + "application/vnd.apple.mpegurl", + hlsUrl, + hackfixResumePosition, + video.duration.toDouble(), + speed + ); + + return listOf(hlsUrl); + } + + private fun proxyVariantPlaylist( + url: String, + playlistId: UUID, + variantPlaylist: HLS.VariantPlaylist, + isLive: Boolean, + proxySegments: Boolean = true + ): HLS.VariantPlaylist { + val newSegments = arrayListOf() + + if (proxySegments) { + variantPlaylist.segments.forEachIndexed { index, segment -> + val sequenceNumber = (variantPlaylist.mediaSequence ?: 0) + index.toLong() + newSegments.add(proxySegment(url, playlistId, segment, sequenceNumber)) + } + } else { + newSegments.addAll(variantPlaylist.segments) + } + + return HLS.VariantPlaylist( + variantPlaylist.version, + variantPlaylist.targetDuration, + variantPlaylist.mediaSequence, + variantPlaylist.discontinuitySequence, + variantPlaylist.programDateTime, + variantPlaylist.playlistType, + variantPlaylist.streamInfo, + newSegments + ) + } + + private fun proxySegment( + url: String, + playlistId: UUID, + segment: HLS.Segment, + index: Long + ): HLS.Segment { + if (segment is HLS.MediaSegment) { + val newSegmentPath = "/hls-playlist-${playlistId}-segment-${index}" + val newSegmentUrl = url + newSegmentPath; + + if (_castServer.getHandler("GET", newSegmentPath) == null) { + _castServer.addHandlerWithAllowAllOptions( + HttpProxyHandler("GET", newSegmentPath, segment.uri, true) + .withInjectedHost() + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("castProxiedHlsVariant") + } + + return HLS.MediaSegment( + segment.duration, + newSegmentUrl + ) + } else { + return segment + } + } + + private suspend fun castHlsIndirect( + contentResolver: ContentResolver, + video: IPlatformVideoDetails, + videoSource: IVideoUrlSource?, + audioSource: IAudioUrlSource?, + subtitleSource: ISubtitleSource?, + resumePosition: Double, + speed: Double? + ): List { + val ad = activeDevice ?: return listOf(); + val url = getLocalUrl(ad); + val id = UUID.randomUUID(); + + val hlsPath = "/hls-${id}" + + val hlsUrl = url + hlsPath; + Logger.i(TAG, "HLS url: $hlsUrl"); + + val mediaRenditions = arrayListOf() + val variantPlaylistReferences = arrayListOf() + + if (audioSource != null) { + val audioPath = "/audio-${id}" + val audioUrl = url + audioPath + + val duration = + audioSource.duration ?: videoSource?.duration ?: throw Exception("Duration unknown") + val audioVariantPlaylistPath = "/audio-playlist-${id}" + val audioVariantPlaylistUrl = url + audioVariantPlaylistPath + val audioVariantPlaylistSegments = + listOf(HLS.MediaSegment(duration.toDouble(), audioUrl)) + val audioVariantPlaylist = HLS.VariantPlaylist( + 3, + duration.toInt(), + 0, + 0, + null, + null, + null, + audioVariantPlaylistSegments + ) + + _castServer.addHandlerWithAllowAllOptions( + HttpConstantHandler( + "GET", audioVariantPlaylistPath, audioVariantPlaylist.buildM3U8(), + "application/vnd.apple.mpegurl" + ) + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("castHlsIndirectVariant"); + + mediaRenditions.add( + HLS.MediaRendition( + "AUDIO", + audioVariantPlaylistUrl, + "audio", + "df", + "default", + true, + true, + true + ) + ) + + _castServer.addHandlerWithAllowAllOptions( + HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true) + .withInjectedHost() + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("castHlsIndirectVariant"); + } + + val subtitlesUri = if (subtitleSource != null) withContext(Dispatchers.IO) { + return@withContext subtitleSource.getSubtitlesURI(); + } else null; + + var subtitlesUrl: String? = null; + if (subtitlesUri != null) { + val subtitlePath = "/subtitles-${id}" + if (subtitlesUri.scheme == "file") { + var content: String? = null; + val inputStream = contentResolver.openInputStream(subtitlesUri); + inputStream?.use { stream -> + val reader = stream.bufferedReader(); + content = reader.use { it.readText() }; + } + + if (content != null) { + _castServer.addHandlerWithAllowAllOptions( + HttpConstantHandler( + "GET", + subtitlePath, + content!!, + subtitleSource?.format ?: "text/vtt" + ) + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("castHlsIndirectVariant"); + } + + subtitlesUrl = url + subtitlePath; + } else { + subtitlesUrl = subtitlesUri.toString(); + } + } + + if (subtitlesUrl != null) { + val duration = videoSource?.duration ?: audioSource?.duration + ?: throw Exception("Duration unknown") + val subtitleVariantPlaylistPath = "/subtitle-playlist-${id}" + val subtitleVariantPlaylistUrl = url + subtitleVariantPlaylistPath + val subtitleVariantPlaylistSegments = + listOf(HLS.MediaSegment(duration.toDouble(), subtitlesUrl)) + val subtitleVariantPlaylist = HLS.VariantPlaylist( + 3, + duration.toInt(), + 0, + 0, + null, + null, + null, + subtitleVariantPlaylistSegments + ) + + _castServer.addHandlerWithAllowAllOptions( + HttpConstantHandler( + "GET", subtitleVariantPlaylistPath, subtitleVariantPlaylist.buildM3U8(), + "application/vnd.apple.mpegurl" + ) + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("castHlsIndirectVariant"); + + mediaRenditions.add( + HLS.MediaRendition( + "SUBTITLES", + subtitleVariantPlaylistUrl, + "subtitles", + "df", + "default", + true, + true, + true + ) + ) + } + + if (videoSource != null) { + val videoPath = "/video-${id}" + val videoUrl = url + videoPath + + val duration = videoSource.duration + val videoVariantPlaylistPath = "/video-playlist-${id}" + val videoVariantPlaylistUrl = url + videoVariantPlaylistPath + val videoVariantPlaylistSegments = + listOf(HLS.MediaSegment(duration.toDouble(), videoUrl)) + val videoVariantPlaylist = HLS.VariantPlaylist( + 3, + duration.toInt(), + 0, + 0, + null, + null, + null, + videoVariantPlaylistSegments + ) + + _castServer.addHandlerWithAllowAllOptions( + HttpConstantHandler( + "GET", videoVariantPlaylistPath, videoVariantPlaylist.buildM3U8(), + "application/vnd.apple.mpegurl" + ) + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("castHlsIndirectVariant"); + + variantPlaylistReferences.add( + HLS.VariantPlaylistReference( + videoVariantPlaylistUrl, HLS.StreamInfo( + videoSource.bitrate ?: 0, + "${videoSource.width}x${videoSource.height}", + videoSource.codec, + null, + null, + if (audioSource != null) "audio" else null, + if (subtitleSource != null) "subtitles" else null, + null, null + ) + ) + ) + + _castServer.addHandlerWithAllowAllOptions( + HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true) + .withInjectedHost() + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("castHlsIndirectVariant"); + } + + val masterPlaylist = + HLS.MasterPlaylist(variantPlaylistReferences, mediaRenditions, listOf(), true) + _castServer.addHandlerWithAllowAllOptions( + HttpConstantHandler( + "GET", hlsPath, masterPlaylist.buildM3U8(), + "application/vnd.apple.mpegurl" + ) + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("castHlsIndirectMaster") + + Logger.i(TAG, "added new castHls handlers (hlsPath: $hlsPath)."); + ad.loadVideo( + if (video.isLive) "LIVE" else "BUFFERED", + "application/vnd.apple.mpegurl", + hlsUrl, + resumePosition, + video.duration.toDouble(), + speed + ); + + return listOf( + hlsUrl, + videoSource?.getVideoUrl() ?: "", + audioSource?.getAudioUrl() ?: "", + subtitlesUri.toString() + ); + } + + private suspend fun castDashIndirect( + contentResolver: ContentResolver, + video: IPlatformVideoDetails, + videoSource: IVideoUrlSource?, + audioSource: IAudioUrlSource?, + subtitleSource: ISubtitleSource?, + resumePosition: Double, + speed: Double? + ): List { + val ad = activeDevice ?: return listOf(); + val proxyStreams = + Settings.instance.casting.alwaysProxyRequests || ad.device.castingProtocol() != ProtocolType.F_CAST + + val url = getLocalUrl(ad); + val id = UUID.randomUUID(); + + val dashPath = "/dash-${id}" + val videoPath = "/video-${id}" + val audioPath = "/audio-${id}" + val subtitlePath = "/subtitle-${id}" + + val dashUrl = url + dashPath; + Logger.i(TAG, "DASH url: $dashUrl"); + + val videoUrl = if (proxyStreams) url + videoPath else videoSource?.getVideoUrl(); + val audioUrl = if (proxyStreams) url + audioPath else audioSource?.getAudioUrl(); + + val subtitlesUri = if (subtitleSource != null) withContext(Dispatchers.IO) { + return@withContext subtitleSource.getSubtitlesURI(); + } else null; + + //_castServer.removeAllHandlers("cast"); + //Logger.i(TAG, "removed all old castDash handlers."); + + var subtitlesUrl: String? = null; + if (subtitlesUri != null) { + if (subtitlesUri.scheme == "file") { + var content: String? = null; + val inputStream = contentResolver.openInputStream(subtitlesUri); + inputStream?.use { stream -> + val reader = stream.bufferedReader(); + content = reader.use { it.readText() }; + } + + if (content != null) { + _castServer.addHandlerWithAllowAllOptions( + HttpConstantHandler( + "GET", + subtitlePath, + content!!, + subtitleSource?.format ?: "text/vtt" + ) + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("cast"); + } + + subtitlesUrl = url + subtitlePath; + } else { + subtitlesUrl = subtitlesUri.toString(); + } + } + + val dashContent = DashBuilder.generateOnDemandDash( + videoSource, + videoUrl, + audioSource, + audioUrl, + subtitleSource, + subtitlesUrl + ); + Logger.v(TAG) { "Dash manifest: $dashContent" }; + + _castServer.addHandlerWithAllowAllOptions( + HttpConstantHandler( + "GET", dashPath, dashContent, + "application/dash+xml" + ) + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("cast"); + + if (videoSource != null) { + _castServer.addHandlerWithAllowAllOptions( + HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true) + .withInjectedHost() + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("cast"); + } + if (audioSource != null) { + _castServer.addHandlerWithAllowAllOptions( + HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true) + .withInjectedHost() + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("cast"); + } + + Logger.i( + TAG, + "added new castDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath)." + ); + ad.loadVideo( + if (video.isLive) "LIVE" else "BUFFERED", + "application/dash+xml", + dashUrl, + resumePosition, + video.duration.toDouble(), + speed + ); + + return listOf( + dashUrl, + videoUrl ?: "", + audioUrl ?: "", + subtitlesUrl ?: "", + videoSource?.getVideoUrl() ?: "", + audioSource?.getAudioUrl() ?: "", + subtitlesUri.toString() + ); + } + + private fun cleanExecutors() { + if (_videoExecutor != null) { + _videoExecutor?.cleanup() + _videoExecutor = null + } + + if (_audioExecutor != null) { + _audioExecutor?.cleanup() + _audioExecutor = null + } + } + + private fun getLocalUrl(ad: CastingDeviceHandle): String { + var address = ad.localAddress!! + if (Settings.instance.casting.allowLinkLocalIpv4) { + if (address.isLinkLocalAddress && address is Inet6Address) { + address = findPreferredAddress() ?: address + Logger.i(TAG, "Selected casting address: $address") + } + } else { + if (address.isLinkLocalAddress) { + address = findPreferredAddress() ?: address + Logger.i(TAG, "Selected casting address: $address") + } + } + return "http://${address.toUrlAddress().trim('/')}:${_castServer.port}"; + } + + @OptIn(UnstableApi::class) + 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() + _castServer.removeAllHandlers("castDashRaw") + + val url = getLocalUrl(ad); + val id = UUID.randomUUID(); + + val dashPath = "/dash-${id}" + val videoPath = "/video-${id}" + val audioPath = "/audio-${id}" + val subtitlePath = "/subtitle-${id}" + + val dashUrl = url + dashPath; + Logger.i(TAG, "DASH url: $dashUrl"); + + val videoUrl = url + videoPath + val audioUrl = url + audioPath + + val subtitlesUri = if (subtitleSource != null) withContext(Dispatchers.IO) { + return@withContext subtitleSource.getSubtitlesURI(); + } else null; + + var subtitlesUrl: String? = null; + if (subtitlesUri != null) { + if (subtitlesUri.scheme == "file") { + var content: String? = null; + val inputStream = contentResolver.openInputStream(subtitlesUri); + inputStream?.use { stream -> + val reader = stream.bufferedReader(); + content = reader.use { it.readText() }; + } + + if (content != null) { + _castServer.addHandlerWithAllowAllOptions( + HttpConstantHandler( + "GET", + subtitlePath, + content!!, + subtitleSource?.format ?: "text/vtt" + ) + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("cast"); + } + + subtitlesUrl = url + subtitlePath; + } else { + subtitlesUrl = subtitlesUri.toString(); + } + } + + var dashContent: String = withContext(Dispatchers.IO) { + stopVideo() + + //TODO: Include subtitlesURl in the future + val deferred = if (audioSource != null && videoSource != null) { + JSDashManifestMergingRawSource(videoSource, audioSource).generateAsync(_scopeIO) + } else if (audioSource != null) { + audioSource.generateAsync(_scopeIO) + } else if (videoSource != null) { + 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) { + if (it.range.first < representation.range.first || it.range.last > representation.range.last) { + return@replace it.value + } + + if (mediaType.startsWith("video/")) { + return@replace "${it.groups[1]!!.value}=\"${videoUrl}?url=${ + URLEncoder.encode( + it.groups[2]!!.value, + "UTF-8" + ).replace("%24Number%24", "\$Number\$") + }&mediaType=${URLEncoder.encode(mediaType, "UTF-8")}\"" + } else if (mediaType.startsWith("audio/")) { + return@replace "${it.groups[1]!!.value}=\"${audioUrl}?url=${ + URLEncoder.encode( + it.groups[2]!!.value, + "UTF-8" + ).replace("%24Number%24", "\$Number\$") + }&mediaType=${URLEncoder.encode(mediaType, "UTF-8")}\"" + } else { + throw Exception("Expected audio or video") + } + } + } + + if (videoSource != null && !videoSource.hasRequestExecutor) { + throw Exception("Video source without request executor not supported") + } + + if (audioSource != null && !audioSource.hasRequestExecutor) { + throw Exception("Audio source without request executor not supported") + } + + if (audioSource != null && audioSource.hasRequestExecutor) { + _audioExecutor = audioSource.getRequestExecutor() + } + + if (videoSource != null && videoSource.hasRequestExecutor) { + _videoExecutor = videoSource.getRequestExecutor() + } + + //TOOD: Else also handle the non request executor case, perhaps add ?url=$originalUrl to the query parameters, ... propagate this for all other flows also + + Logger.v(TAG) { "Dash manifest: $dashContent" }; + + _castServer.addHandlerWithAllowAllOptions( + HttpConstantHandler( + "GET", dashPath, dashContent, + "application/dash+xml" + ) + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("castDashRaw"); + + if (videoSource != null) { + _castServer.addHandlerWithAllowAllOptions( + HttpFunctionHandler("GET", videoPath) { httpContext -> + val originalUrl = + httpContext.query["url"]?.let { URLDecoder.decode(it, "UTF-8") } + ?: return@HttpFunctionHandler + val mediaType = + httpContext.query["mediaType"]?.let { URLDecoder.decode(it, "UTF-8") } + ?: return@HttpFunctionHandler + + val videoExecutor = _videoExecutor; + if (videoExecutor != null) { + val data = videoExecutor.executeRequest( + "GET", + originalUrl, + null, + httpContext.headers + ) + httpContext.respondBytes(200, HttpHeaders().apply { + put("Content-Type", mediaType) + }, data); + } else { + throw NotImplementedError() + } + }.withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("castDashRaw"); + } + if (audioSource != null) { + _castServer.addHandlerWithAllowAllOptions( + HttpFunctionHandler("GET", audioPath) { httpContext -> + val originalUrl = + httpContext.query["url"]?.let { URLDecoder.decode(it, "UTF-8") } + ?: return@HttpFunctionHandler + val mediaType = + httpContext.query["mediaType"]?.let { URLDecoder.decode(it, "UTF-8") } + ?: return@HttpFunctionHandler + + val audioExecutor = _audioExecutor; + if (audioExecutor != null) { + val data = audioExecutor.executeRequest( + "GET", + originalUrl, + null, + httpContext.headers + ) + httpContext.respondBytes(200, HttpHeaders().apply { + put("Content-Type", mediaType) + }, data); + } else { + throw NotImplementedError() + } + }.withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("castDashRaw"); + } + + Logger.i( + TAG, + "added new castDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath)." + ); + ad.loadVideo( + if (video.isLive) "LIVE" else "BUFFERED", + "application/dash+xml", + dashUrl, + resumePosition, + video.duration.toDouble(), + speed + ); + + return listOf() + } + + private fun deviceFromCastingDeviceInfo(deviceInfo: com.futo.platformplayer.models.CastingDeviceInfo): CastingDeviceHandle { + val rsAddrs = + deviceInfo.addresses.map { org.fcast.sender_sdk.tryIpAddrFromStr(it) } // Throws! + val rsDeviceInfo = RsDeviceInfo( + name = deviceInfo.name, + type = when (deviceInfo.type) { + com.futo.platformplayer.casting.CastProtocolType.CHROMECAST -> ProtocolType.CHROMECAST + com.futo.platformplayer.casting.CastProtocolType.FCAST -> ProtocolType.F_CAST + else -> throw IllegalArgumentException() + }, + addresses = rsAddrs, + port = deviceInfo.port.toUShort(), + ) + + return CastingDeviceHandle(_context.createDeviceFromInfo(rsDeviceInfo)) + } + + fun enableDeveloper(enableDev: Boolean) { + _castServer.removeAllHandlers("dev"); + if (enableDev) { + _castServer.addHandler(HttpFunctionHandler("GET", "/dashPlayer") { context -> + if (context.query.containsKey("dashUrl")) { + val dashUrl = context.query["dashUrl"]; + val html = "
\n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + "
"; + context.respondCode(200, html, "text/html"); + } + }).withTag("dev"); + } + } + + companion object { + val instance: ExpStateCasting = ExpStateCasting(); + + private val representationRegex = Regex( + "(.*?)<\\/Representation>", + RegexOption.DOT_MATCHES_ALL + ) + private val mediaInitializationRegex = + Regex("(media|initiali[sz]ation)=\"([^\"]+)\"", RegexOption.DOT_MATCHES_ALL); + + private val TAG = "ExperimentalStateCasting"; + } +} \ No newline at end of file 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 0818f9ed..594a7c46 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 @@ -98,6 +98,7 @@ import com.futo.platformplayer.engine.exceptions.ScriptLoginRequiredException import com.futo.platformplayer.engine.exceptions.ScriptReloadRequiredException import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException import com.futo.platformplayer.exceptions.UnsupportedCastException +import com.futo.platformplayer.experimental_casting.ExpStateCasting import com.futo.platformplayer.fixHtmlLinks import com.futo.platformplayer.fixHtmlWhitespace import com.futo.platformplayer.getNowDiffSeconds @@ -175,6 +176,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import org.fcast.sender_sdk.DeviceFeature import userpackage.Protocol import java.time.OffsetDateTime import java.util.Locale @@ -664,51 +666,96 @@ class VideoDetailView : ConstraintLayout { } if (!isInEditMode) { - StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { device, connectionState -> - if (_onPauseCalled) { - return@subscribe; + if (Settings.instance.casting.experimentalCasting) { + ExpStateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { device, connectionState -> + if (_onPauseCalled) { + return@subscribe; + } + + when (connectionState) { + com.futo.platformplayer.experimental_casting.CastConnectionState.CONNECTED -> { + loadCurrentVideo(lastPositionMilliseconds); + updatePillButtonVisibilities(); + setCastEnabled(true); + } + com.futo.platformplayer.experimental_casting.CastConnectionState.DISCONNECTED -> { + loadCurrentVideo(lastPositionMilliseconds, playWhenReady = device.isPlaying); + updatePillButtonVisibilities(); + setCastEnabled(false); + + } + else -> {} + } } - when (connectionState) { - CastConnectionState.CONNECTED -> { - loadCurrentVideo(lastPositionMilliseconds); - updatePillButtonVisibilities(); - setCastEnabled(true); - } - CastConnectionState.DISCONNECTED -> { - loadCurrentVideo(lastPositionMilliseconds, playWhenReady = device.isPlaying); - updatePillButtonVisibilities(); - setCastEnabled(false); + ExpStateCasting.instance.onActiveDevicePlayChanged.subscribe(this) { + val activeDevice = StateCasting.instance.activeDevice; + if (activeDevice != null) { + handlePlayChanged(it); + val v = video; + if (!it && v != null && v.duration - activeDevice.time.toLong() < 2L) { + nextVideo(); + } + } + }; + + ExpStateCasting.instance.onActiveDeviceTimeChanged.subscribe(this) { + if (_isCasting) { + setLastPositionMilliseconds((it * 1000.0).toLong(), true); + _cast.setTime(lastPositionMilliseconds); + _timeBar.setPosition(it.toLong()); + _timeBar.setBufferedPosition(0); + _timeBar.setDuration(video?.duration ?: 0); + } + }; + } else { + StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { device, connectionState -> + if (_onPauseCalled) { + return@subscribe; + } + + when (connectionState) { + CastConnectionState.CONNECTED -> { + loadCurrentVideo(lastPositionMilliseconds); + updatePillButtonVisibilities(); + setCastEnabled(true); + } + CastConnectionState.DISCONNECTED -> { + loadCurrentVideo(lastPositionMilliseconds, playWhenReady = device.isPlaying); + updatePillButtonVisibilities(); + setCastEnabled(false); + + } + else -> {} } - else -> {} } - }; + + StateCasting.instance.onActiveDevicePlayChanged.subscribe(this) { + val activeDevice = StateCasting.instance.activeDevice; + if (activeDevice != null) { + handlePlayChanged(it); + + val v = video; + if (!it && v != null && v.duration - activeDevice.time.toLong() < 2L) { + nextVideo(); + } + } + }; + + StateCasting.instance.onActiveDeviceTimeChanged.subscribe(this) { + if (_isCasting) { + setLastPositionMilliseconds((it * 1000.0).toLong(), true); + _cast.setTime(lastPositionMilliseconds); + _timeBar.setPosition(it.toLong()); + _timeBar.setBufferedPosition(0); + _timeBar.setDuration(video?.duration ?: 0); + } + }; + } updatePillButtonVisibilities(); - StateCasting.instance.onActiveDevicePlayChanged.subscribe(this) { - val activeDevice = StateCasting.instance.activeDevice; - if (activeDevice != null) { - handlePlayChanged(it); - - val v = video; - if (!it && v != null && v.duration - activeDevice.time.toLong() < 2L) { - nextVideo(); - } - } - }; - - StateCasting.instance.onActiveDeviceTimeChanged.subscribe(this) { - if (_isCasting) { - setLastPositionMilliseconds((it * 1000.0).toLong(), true); - _cast.setTime(lastPositionMilliseconds); - _timeBar.setPosition(it.toLong()); - _timeBar.setBufferedPosition(0); - _timeBar.setDuration(video?.duration ?: 0); - } - }; - _cast.onTimeJobTimeChanged_s.subscribe { if (_isCasting) { setLastPositionMilliseconds((it * 1000.0).toLong(), true); @@ -1188,8 +1235,13 @@ class VideoDetailView : ConstraintLayout { _onPauseCalled = true; _taskLoadVideo.cancel(); - if(StateCasting.instance.isCasting) - return; + if (Settings.instance.casting.experimentalCasting) { + if(ExpStateCasting.instance.isCasting) + return; + } else { + if(StateCasting.instance.isCasting) + return; + } if(isAudioOnlyUserAction) StatePlayer.instance.startOrUpdateMediaSession(context, video); @@ -1240,9 +1292,15 @@ class VideoDetailView : ConstraintLayout { _container_content_description.cleanup(); _container_content_support.cleanup(); StatePlayer.instance.autoplayChanged.remove(this) - StateCasting.instance.onActiveDevicePlayChanged.remove(this); - StateCasting.instance.onActiveDeviceTimeChanged.remove(this); - StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this); + if (Settings.instance.casting.experimentalCasting) { + ExpStateCasting.instance.onActiveDevicePlayChanged.remove(this); + ExpStateCasting.instance.onActiveDeviceTimeChanged.remove(this); + ExpStateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this); + } else { + StateCasting.instance.onActiveDevicePlayChanged.remove(this); + StateCasting.instance.onActiveDeviceTimeChanged.remove(this); + StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this); + } StateApp.instance.preventPictureInPicture.remove(this); StatePlayer.instance.onQueueChanged.remove(this); StatePlayer.instance.onVideoChanging.remove(this); @@ -1974,7 +2032,11 @@ class VideoDetailView : ConstraintLayout { return; } - val isCasting = StateCasting.instance.isCasting + val isCasting = if (Settings.instance.casting.experimentalCasting) { + ExpStateCasting.instance.isCasting + } else { + StateCasting.instance.isCasting + } if (!isCasting) { setCastEnabled(false); @@ -2051,11 +2113,19 @@ class VideoDetailView : ConstraintLayout { val startId = plugin?.getUnderlyingPlugin()?.runtimeId try { - val castingSucceeded = StateCasting.instance.castIfAvailable(contentResolver, video, videoSource, audioSource, subtitleSource, resumePositionMs, speed, onLoading = { - _cast.setLoading(it) - }, onLoadingEstimate = { - _cast.setLoading(it) - }) + val castingSucceeded = if (Settings.instance.casting.experimentalCasting) { + ExpStateCasting.instance.castIfAvailable(contentResolver, video, videoSource, audioSource, subtitleSource, resumePositionMs, speed, onLoading = { + _cast.setLoading(it) + }, onLoadingEstimate = { + _cast.setLoading(it) + }) + } else { + StateCasting.instance.castIfAvailable(contentResolver, video, videoSource, audioSource, subtitleSource, resumePositionMs, speed, onLoading = { + _cast.setLoading(it) + }, onLoadingEstimate = { + _cast.setLoading(it) + }) + } if (castingSucceeded) { withContext(Dispatchers.Main) { @@ -2250,7 +2320,13 @@ class VideoDetailView : ConstraintLayout { } } - val currentPlaybackRate = (if (_isCasting) StateCasting.instance.activeDevice?.speed else _player.getPlaybackRate()) ?: 1.0 + val currentPlaybackRate = (if (_isCasting) { + if (Settings.instance.casting.experimentalCasting) { + ExpStateCasting.instance.activeDevice?.speed + } else { + StateCasting.instance.activeDevice?.speed + } + } else _player.getPlaybackRate()) ?: 1.0 _overlay_quality_selector?.groupItems?.firstOrNull { it is SlideUpMenuButtonList && it.id == "playback_rate" }?.let { (it as SlideUpMenuButtonList).setSelected(currentPlaybackRate.toString()) }; @@ -2368,8 +2444,18 @@ class VideoDetailView : ConstraintLayout { ?.distinct() ?.toList() ?: listOf() else audioSources?.toList() ?: listOf(); - val canSetSpeed = !_isCasting || StateCasting.instance.activeDevice?.canSetSpeed == true - val currentPlaybackRate = if (_isCasting) StateCasting.instance.activeDevice?.speed else _player.getPlaybackRate() + val canSetSpeed = !_isCasting || if (Settings.instance.casting.experimentalCasting) { + ExpStateCasting.instance.activeDevice?.device?.supportsFeature(DeviceFeature.SET_SPEED) == true + } else { + StateCasting.instance.activeDevice?.canSetSpeed == true + } + val currentPlaybackRate = if (_isCasting) { + if (Settings.instance.casting.experimentalCasting) { + ExpStateCasting.instance.activeDevice?.speed + } else { + StateCasting.instance.activeDevice?.speed + } + } else _player.getPlaybackRate() val qualityPlaybackSpeedTitle = if (canSetSpeed) SlideUpMenuTitle(this.context).apply { setTitle(context.getString(R.string.playback_rate) + " (${String.format("%.2f", currentPlaybackRate)})"); } else null; _overlay_quality_selector = SlideUpMenuOverlay(this.context, _overlay_quality_container, context.getString( R.string.quality), null, true, @@ -2383,7 +2469,13 @@ class VideoDetailView : ConstraintLayout { setButtons(playbackLabels, String.format(Locale.US, format, currentPlaybackRate)); onClick.subscribe { v -> - val currentPlaybackSpeed = if (_isCasting) StateCasting.instance.activeDevice?.speed else _player.getPlaybackRate(); + val currentPlaybackSpeed = if (_isCasting) { + if (Settings.instance.casting.experimentalCasting) { + ExpStateCasting.instance.activeDevice?.speed + } else { + StateCasting.instance.activeDevice?.speed + } + } else _player.getPlaybackRate(); var playbackSpeedString = v; val stepSpeed = Settings.instance.playback.getPlaybackSpeedStep(); if(v == "+") @@ -2392,14 +2484,25 @@ class VideoDetailView : ConstraintLayout { playbackSpeedString = String.format(Locale.US, "%.2f", Math.max(0.1, (currentPlaybackSpeed?.toDouble() ?: 1.0) - stepSpeed)).toString(); val newPlaybackSpeed = playbackSpeedString.toDouble(); if (_isCasting) { - val ad = StateCasting.instance.activeDevice ?: return@subscribe - if (!ad.canSetSpeed) { - return@subscribe - } + if (Settings.instance.casting.experimentalCasting) { + val ad = ExpStateCasting.instance.activeDevice ?: return@subscribe + if (!ad.device.supportsFeature(DeviceFeature.SET_SPEED)) { + return@subscribe + } - qualityPlaybackSpeedTitle?.setTitle(context.getString(R.string.playback_rate) + " (${String.format(Locale.US, "%.2f", newPlaybackSpeed)})"); - ad.changeSpeed(newPlaybackSpeed) - setSelected(playbackSpeedString); + qualityPlaybackSpeedTitle?.setTitle(context.getString(R.string.playback_rate) + " (${String.format(Locale.US, "%.2f", newPlaybackSpeed)})"); + ad.device.changeSpeed(newPlaybackSpeed) + setSelected(playbackSpeedString); + } else { + val ad = StateCasting.instance.activeDevice ?: return@subscribe + if (!ad.canSetSpeed) { + return@subscribe + } + + qualityPlaybackSpeedTitle?.setTitle(context.getString(R.string.playback_rate) + " (${String.format(Locale.US, "%.2f", newPlaybackSpeed)})"); + ad.changeSpeed(newPlaybackSpeed) + setSelected(playbackSpeedString); + } } else { qualityPlaybackSpeedTitle?.setTitle(context.getString(R.string.playback_rate) + " (${String.format(Locale.US, "%.2f", newPlaybackSpeed)})"); _player.setPlaybackRate(playbackSpeedString.toFloat()); @@ -2514,8 +2617,14 @@ class VideoDetailView : ConstraintLayout { //Handlers private fun handlePlay() { Logger.i(TAG, "handlePlay") - if (!StateCasting.instance.resumeVideo()) { - _player.play(); + if (Settings.instance.casting.experimentalCasting) { + if (!ExpStateCasting.instance.resumeVideo()) { + _player.play() + } + } else { + if (!StateCasting.instance.resumeVideo()) { + _player.play(); + } } onShouldEnterPictureInPictureChanged.emit() @@ -2531,33 +2640,61 @@ class VideoDetailView : ConstraintLayout { private fun handlePause() { Logger.i(TAG, "handlePause") - if (!StateCasting.instance.pauseVideo()) { - _player.pause(); + if (Settings.instance.casting.experimentalCasting) { + if (!ExpStateCasting.instance.pauseVideo()) { + _player.pause() + } + } else { + if (!StateCasting.instance.pauseVideo()) { + _player.pause() + } } onShouldEnterPictureInPictureChanged.emit() } private fun handleSeek(ms: Long) { Logger.i(TAG, "handleSeek(ms=$ms)") - if (!StateCasting.instance.videoSeekTo(ms.toDouble() / 1000.0)) { - _player.seekTo(ms); + if (Settings.instance.casting.experimentalCasting) { + if (!ExpStateCasting.instance.videoSeekTo(ms.toDouble() / 1000.0)) { + _player.seekTo(ms) + } + } else { + if (!StateCasting.instance.videoSeekTo(ms.toDouble() / 1000.0)) { + _player.seekTo(ms) + } } } private fun handleStop() { Logger.i(TAG, "handleStop") - if (!StateCasting.instance.stopVideo()) { - _player.stop(); + if (Settings.instance.casting.experimentalCasting) { + if (!ExpStateCasting.instance.stopVideo()) { + _player.stop() + } + } else { + if (!StateCasting.instance.stopVideo()) { + _player.stop() + } } } private fun handlePlayChanged(playing: Boolean) { Logger.i(TAG, "handlePlayChanged(playing=$playing)") - val ad = StateCasting.instance.activeDevice; - if (ad != null) { - _cast.setIsPlaying(playing); + if (Settings.instance.casting.experimentalCasting) { + val ad = ExpStateCasting.instance.activeDevice; + if (ad != null) { + _cast.setIsPlaying(playing); + } else { + StatePlayer.instance.updateMediaSession( null); + StatePlayer.instance.updateMediaSessionPlaybackState(_player.exoPlayer?.getPlaybackStateCompat() ?: PlaybackStateCompat.STATE_NONE, _player.exoPlayer?.player?.currentPosition ?: 0); + } } else { - StatePlayer.instance.updateMediaSession( null); - StatePlayer.instance.updateMediaSessionPlaybackState(_player.exoPlayer?.getPlaybackStateCompat() ?: PlaybackStateCompat.STATE_NONE, _player.exoPlayer?.player?.currentPosition ?: 0); + val ad = StateCasting.instance.activeDevice; + if (ad != null) { + _cast.setIsPlaying(playing); + } else { + StatePlayer.instance.updateMediaSession( null); + StatePlayer.instance.updateMediaSessionPlaybackState(_player.exoPlayer?.getPlaybackStateCompat() ?: PlaybackStateCompat.STATE_NONE, _player.exoPlayer?.player?.currentPosition ?: 0); + } } if(playing) { @@ -2595,11 +2732,27 @@ class VideoDetailView : ConstraintLayout { fragment.lifecycleScope.launch(Dispatchers.Main) { try { - val d = StateCasting.instance.activeDevice; - if (d != null && d.connectionState == CastConnectionState.CONNECTED) - castIfAvailable(context.contentResolver, video, videoSource, _lastAudioSource, _lastSubtitleSource, (d.expectedCurrentTime * 1000.0).toLong(), d.speed); - else if(!_player.swapSources(videoSource, _lastAudioSource, true, true, true)) - _player.hideControls(false); //TODO: Disable player? + if (Settings.instance.casting.experimentalCasting) { + val d = ExpStateCasting.instance.activeDevice; + if (d != null && d.connectionState == com.futo.platformplayer.experimental_casting.CastConnectionState.CONNECTED) + castIfAvailable( + context.contentResolver, + video, + videoSource, + _lastAudioSource, + _lastSubtitleSource, + (d.expectedCurrentTime * 1000.0).toLong(), + d.speed + ); + else if(!_player.swapSources(videoSource, _lastAudioSource, true, true, true)) + _player.hideControls(false); //TODO: Disable player? + } else { + val d = StateCasting.instance.activeDevice; + if (d != null && d.connectionState == CastConnectionState.CONNECTED) + castIfAvailable(context.contentResolver, video, videoSource, _lastAudioSource, _lastSubtitleSource, (d.expectedCurrentTime * 1000.0).toLong(), d.speed); + else if(!_player.swapSources(videoSource, _lastAudioSource, true, true, true)) + _player.hideControls(false); //TODO: Disable player? + } } catch (e: Throwable) { Logger.e(TAG, "handleSelectVideoTrack failed", e) } @@ -2616,11 +2769,35 @@ class VideoDetailView : ConstraintLayout { fragment.lifecycleScope.launch(Dispatchers.Main) { try { - val d = StateCasting.instance.activeDevice; - if (d != null && d.connectionState == CastConnectionState.CONNECTED) - castIfAvailable(context.contentResolver, video, _lastVideoSource, audioSource, _lastSubtitleSource, (d.expectedCurrentTime * 1000.0).toLong(), d.speed) - else if (!_player.swapSources(_lastVideoSource, audioSource, true, true, true)) - _player.hideControls(false); //TODO: Disable player? + if (Settings.instance.casting.experimentalCasting) { + val d = ExpStateCasting.instance.activeDevice; + if (d != null && d.connectionState == com.futo.platformplayer.experimental_casting.CastConnectionState.CONNECTED) + castIfAvailable( + context.contentResolver, + video, + _lastVideoSource, + audioSource, + _lastSubtitleSource, + (d.expectedCurrentTime * 1000.0).toLong(), + d.speed + ) + else if (!_player.swapSources(_lastVideoSource, audioSource, true, true, true)) + _player.hideControls(false); //TODO: Disable player? + } else { + val d = StateCasting.instance.activeDevice; + if (d != null && d.connectionState == CastConnectionState.CONNECTED) + castIfAvailable( + context.contentResolver, + video, + _lastVideoSource, + audioSource, + _lastSubtitleSource, + (d.expectedCurrentTime * 1000.0).toLong(), + d.speed + ) + else if (!_player.swapSources(_lastVideoSource, audioSource, true, true, true)) + _player.hideControls(false); //TODO: Disable player? + } } catch (e: Throwable) { Logger.e(TAG, "handleSelectAudioTrack failed", e) } @@ -2638,11 +2815,36 @@ class VideoDetailView : ConstraintLayout { fragment.lifecycleScope.launch(Dispatchers.Main) { try { - val d = StateCasting.instance.activeDevice; - if (d != null && d.connectionState == CastConnectionState.CONNECTED) - castIfAvailable(context.contentResolver, video, _lastVideoSource, _lastAudioSource, toSet, (d.expectedCurrentTime * 1000.0).toLong(), d.speed); - else { - _player.swapSubtitles(toSet); + if (Settings.instance.casting.experimentalCasting) { + val d = ExpStateCasting.instance.activeDevice; + if (d != null && d.connectionState == com.futo.platformplayer.experimental_casting.CastConnectionState.CONNECTED) + castIfAvailable( + context.contentResolver, + video, + _lastVideoSource, + _lastAudioSource, + toSet, + (d.expectedCurrentTime * 1000.0).toLong(), + d.speed + ); + else { + _player.swapSubtitles(toSet); + } + } else { + val d = StateCasting.instance.activeDevice; + if (d != null && d.connectionState == CastConnectionState.CONNECTED) + castIfAvailable( + context.contentResolver, + video, + _lastVideoSource, + _lastAudioSource, + toSet, + (d.expectedCurrentTime * 1000.0).toLong(), + d.speed + ); + else { + _player.swapSubtitles(toSet); + } } } catch (e: Throwable) { Logger.e(TAG, "handleSelectSubtitleTrack failed", e) diff --git a/app/src/main/java/com/futo/platformplayer/models/CastingDeviceInfo.kt b/app/src/main/java/com/futo/platformplayer/models/CastingDeviceInfo.kt index a530e415..72afedcc 100644 --- a/app/src/main/java/com/futo/platformplayer/models/CastingDeviceInfo.kt +++ b/app/src/main/java/com/futo/platformplayer/models/CastingDeviceInfo.kt @@ -1,6 +1,7 @@ package com.futo.platformplayer.models import com.futo.platformplayer.casting.CastProtocolType +import com.futo.platformplayer.experimental_casting.ExpCastProtocolType @kotlinx.serialization.Serializable class CastingDeviceInfo { @@ -15,4 +16,19 @@ class CastingDeviceInfo { this.addresses = addresses; this.port = port; } -} \ No newline at end of file +} + +@kotlinx.serialization.Serializable +class ExpCastingDeviceInfo { + var name: String; + var type: ExpCastProtocolType; + var addresses: Array; + var port: Int; + + constructor(name: String, type: ExpCastProtocolType, addresses: Array, port: Int) { + this.name = name; + this.type = type; + this.addresses = addresses; + this.port = port; + } +} diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceAdapter.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceAdapter.kt index a2ff2435..06c4974d 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceAdapter.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceAdapter.kt @@ -6,14 +6,34 @@ import androidx.recyclerview.widget.RecyclerView import com.futo.platformplayer.R import com.futo.platformplayer.casting.CastingDevice import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.experimental_casting.CastingDeviceHandle -data class DeviceAdapterEntry(val castingDevice: CastingDevice, val isPinnedDevice: Boolean, val isOnlineDevice: Boolean) +sealed class GenericCastingDevice { + class Normal(val device: CastingDevice): GenericCastingDevice() + class Experimental(val handle: CastingDeviceHandle): GenericCastingDevice() + + fun name(): String? { + return when (this) { + is Experimental -> this.handle.device.name() + is Normal -> this.device.name + } + } + + fun isReady(): Boolean { + return when(this) { + is Experimental -> this.handle.device.isReady() + is Normal -> this.device.isReady + } + } +} + +data class DeviceAdapterEntry(val castingDevice: GenericCastingDevice, val isPinnedDevice: Boolean, val isOnlineDevice: Boolean) class DeviceAdapter : RecyclerView.Adapter { private val _devices: List; - var onPin = Event1(); - var onConnect = Event1(); + var onPin = Event1(); + var onConnect = Event1(); constructor(devices: List) : super() { _devices = devices; diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt index 133dd26b..b7bd5400 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt @@ -4,21 +4,20 @@ import android.graphics.drawable.Animatable import android.view.View import android.widget.FrameLayout import android.widget.ImageView -import android.widget.LinearLayout import android.widget.TextView import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView.ViewHolder import com.futo.platformplayer.R +import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.casting.AirPlayCastingDevice import com.futo.platformplayer.casting.CastConnectionState -import com.futo.platformplayer.casting.CastingDevice import com.futo.platformplayer.casting.ChromecastCastingDevice import com.futo.platformplayer.casting.FCastCastingDevice import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.constructs.Event1 -import com.futo.platformplayer.constructs.Event2 -import androidx.core.view.isVisible -import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.experimental_casting.ExpStateCasting +import org.fcast.sender_sdk.ProtocolType class DeviceViewHolder : ViewHolder { private val _layoutDevice: FrameLayout; @@ -32,11 +31,11 @@ class DeviceViewHolder : ViewHolder { private var _animatableLoader: Animatable? = null; private var _imagePin: ImageView; - var device: CastingDevice? = null + var device: GenericCastingDevice? = null private set - var onPin = Event1(); - val onConnect = Event1(); + var onPin = Event1(); + val onConnect = Event1(); constructor(view: View) : super(view) { _root = view.findViewById(R.id.layout_root); @@ -56,15 +55,34 @@ class DeviceViewHolder : ViewHolder { val connect = { device?.let { dev -> - if (dev.isReady) { - StateCasting.instance.activeDevice?.stopCasting() - StateCasting.instance.connectDevice(dev) - onConnect.emit(dev) - } else { - try { - view.context?.let { UIDialogs.toast(it, "Device not ready, may be offline") } - } catch (e: Throwable) { - //Ignored + when (dev) { + is GenericCastingDevice.Normal -> { + if (dev.device.isReady) { + // NOTE: we assume normal casting is used + StateCasting.instance.activeDevice?.stopCasting() + StateCasting.instance.connectDevice(dev.device) + onConnect.emit(dev) + } else { + try { + view.context?.let { UIDialogs.toast(it, "Device not ready, may be offline") } + } catch (e: Throwable) { + //Ignored + } + } + } + is GenericCastingDevice.Experimental -> { + if (dev.handle.device.isReady()) { + // NOTE: we assume experimental casting is used + ExpStateCasting.instance.activeDevice?.device?.stopCasting() + ExpStateCasting.instance.connectDevice(dev.handle) + onConnect.emit(dev) + } else { + try { + view.context?.let { UIDialogs.toast(it, "Device not ready, may be offline") } + } catch (e: Throwable) { + //Ignored + } + } } } } @@ -80,60 +98,122 @@ class DeviceViewHolder : ViewHolder { } } - fun bind(d: CastingDevice, isOnlineDevice: Boolean, isPinnedDevice: Boolean) { - if (d is ChromecastCastingDevice) { - _imageDevice.setImageResource(R.drawable.ic_chromecast); - _textType.text = "Chromecast"; - } else if (d is AirPlayCastingDevice) { - _imageDevice.setImageResource(R.drawable.ic_airplay); - _textType.text = "AirPlay"; - } else if (d is FCastCastingDevice) { - _imageDevice.setImageResource(R.drawable.ic_fc); - _textType.text = "FCast"; - } + // fun bind(d: CastingDevice, isOnlineDevice: Boolean, isPinnedDevice: Boolean) { - _textName.text = d.name; - _imageOnline.visibility = if (isOnlineDevice && d.isReady) View.VISIBLE else View.GONE - - if (!d.isReady) { - _imageLoader.visibility = View.GONE; - _textNotReady.visibility = View.VISIBLE; - _imagePin.visibility = View.GONE; - } else { - _textNotReady.visibility = View.GONE; - - val dev = StateCasting.instance.activeDevice; - if (dev == d) { - if (dev.connectionState == CastConnectionState.CONNECTED) { - _imageLoader.visibility = View.GONE; - _textNotReady.visibility = View.GONE; - _imagePin.visibility = View.VISIBLE; - } else { - _imageLoader.visibility = View.VISIBLE; - _textNotReady.visibility = View.GONE; - _imagePin.visibility = View.VISIBLE; + fun bind(d: GenericCastingDevice, isOnlineDevice: Boolean, isPinnedDevice: Boolean) { + when (d) { + is GenericCastingDevice.Normal -> { + if (d.device is ChromecastCastingDevice) { + _imageDevice.setImageResource(R.drawable.ic_chromecast); + _textType.text = "Chromecast"; + } else if (d.device is AirPlayCastingDevice) { + _imageDevice.setImageResource(R.drawable.ic_airplay); + _textType.text = "AirPlay"; + } else if (d.device is FCastCastingDevice) { + _imageDevice.setImageResource(R.drawable.ic_fc); + _textType.text = "FCast"; } - } else { - if (d.isReady) { - _imageLoader.visibility = View.GONE; - _textNotReady.visibility = View.GONE; - _imagePin.visibility = View.VISIBLE; - } else { + + _textName.text = d.device.name; + _imageOnline.visibility = if (isOnlineDevice && d.device.isReady) View.VISIBLE else View.GONE + + if (!d.device.isReady) { _imageLoader.visibility = View.GONE; _textNotReady.visibility = View.VISIBLE; - _imagePin.visibility = View.VISIBLE; + _imagePin.visibility = View.GONE; + } else { + _textNotReady.visibility = View.GONE; + + val dev = StateCasting.instance.activeDevice; + if (dev == d) { + if (dev.connectionState == CastConnectionState.CONNECTED) { + _imageLoader.visibility = View.GONE; + _textNotReady.visibility = View.GONE; + _imagePin.visibility = View.VISIBLE; + } else { + _imageLoader.visibility = View.VISIBLE; + _textNotReady.visibility = View.GONE; + _imagePin.visibility = View.VISIBLE; + } + } else { + if (d.device.isReady) { + _imageLoader.visibility = View.GONE; + _textNotReady.visibility = View.GONE; + _imagePin.visibility = View.VISIBLE; + } else { + _imageLoader.visibility = View.GONE; + _textNotReady.visibility = View.VISIBLE; + _imagePin.visibility = View.VISIBLE; + } + } + + _imagePin.setImageResource(if (isPinnedDevice) R.drawable.keep_24px else R.drawable.ic_pin) + + if (_imageLoader.isVisible) { + _animatableLoader?.start(); + } else { + _animatableLoader?.stop(); + } } + + device = d; } + is GenericCastingDevice.Experimental -> { + when (d.handle.device.castingProtocol()) { + ProtocolType.CHROMECAST -> { + _imageDevice.setImageResource(R.drawable.ic_chromecast); + _textType.text = "Chromecast"; + } + ProtocolType.F_CAST -> { + _imageDevice.setImageResource(R.drawable.ic_fc); + _textType.text = "FCast"; + } + } - _imagePin.setImageResource(if (isPinnedDevice) R.drawable.keep_24px else R.drawable.ic_pin) + _textName.text = d.handle.device.name(); + _imageOnline.visibility = if (isOnlineDevice && d.handle.device.isReady()) View.VISIBLE else View.GONE - if (_imageLoader.isVisible) { - _animatableLoader?.start(); - } else { - _animatableLoader?.stop(); + if (!d.handle.device.isReady()) { + _imageLoader.visibility = View.GONE; + _textNotReady.visibility = View.VISIBLE; + _imagePin.visibility = View.GONE; + } else { + _textNotReady.visibility = View.GONE; + + val dev = StateCasting.instance.activeDevice; + if (dev == d) { + if (dev.connectionState == CastConnectionState.CONNECTED) { + _imageLoader.visibility = View.GONE; + _textNotReady.visibility = View.GONE; + _imagePin.visibility = View.VISIBLE; + } else { + _imageLoader.visibility = View.VISIBLE; + _textNotReady.visibility = View.GONE; + _imagePin.visibility = View.VISIBLE; + } + } else { + if (d.handle.device.isReady()) { + _imageLoader.visibility = View.GONE; + _textNotReady.visibility = View.GONE; + _imagePin.visibility = View.VISIBLE; + } else { + _imageLoader.visibility = View.GONE; + _textNotReady.visibility = View.VISIBLE; + _imagePin.visibility = View.VISIBLE; + } + } + + _imagePin.setImageResource(if (isPinnedDevice) R.drawable.keep_24px else R.drawable.ic_pin) + + if (_imageLoader.isVisible) { + _animatableLoader?.start(); + } else { + _animatableLoader?.stop(); + } + } + + device = d; } } - - device = d; } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/casting/CastButton.kt b/app/src/main/java/com/futo/platformplayer/views/casting/CastButton.kt index f187230c..6f081a8c 100644 --- a/app/src/main/java/com/futo/platformplayer/views/casting/CastButton.kt +++ b/app/src/main/java/com/futo/platformplayer/views/casting/CastButton.kt @@ -15,6 +15,7 @@ import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.casting.CastConnectionState import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.experimental_casting.ExpStateCasting class CastButton : androidx.appcompat.widget.AppCompatImageButton { var onClick = Event1>(); @@ -27,9 +28,15 @@ class CastButton : androidx.appcompat.widget.AppCompatImageButton { visibility = View.GONE; } - StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, _ -> - updateCastState(); - }; + if (Settings.instance.casting.experimentalCasting) { + ExpStateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, _ -> + updateCastState(); + }; + } else { + StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, _ -> + updateCastState(); + }; + } updateCastState(); } @@ -37,25 +44,44 @@ class CastButton : androidx.appcompat.widget.AppCompatImageButton { private fun updateCastState() { val c = context ?: return; - val d = StateCasting.instance.activeDevice; + if (Settings.instance.casting.experimentalCasting) { + val d = ExpStateCasting.instance.activeDevice; - val activeColor = ContextCompat.getColor(c, R.color.colorPrimary); - val connectingColor = ContextCompat.getColor(c, R.color.gray_c3); - val inactiveColor = ContextCompat.getColor(c, R.color.white); + val activeColor = ContextCompat.getColor(c, R.color.colorPrimary); + val connectingColor = ContextCompat.getColor(c, R.color.gray_c3); + val inactiveColor = ContextCompat.getColor(c, R.color.white); - if (d != null) { - when (d.connectionState) { - CastConnectionState.CONNECTED -> setColorFilter(activeColor) - CastConnectionState.CONNECTING -> setColorFilter(connectingColor) - CastConnectionState.DISCONNECTED -> setColorFilter(activeColor) + if (d != null) { + when (d.connectionState) { + com.futo.platformplayer.experimental_casting.CastConnectionState.CONNECTED -> setColorFilter(activeColor) + com.futo.platformplayer.experimental_casting.CastConnectionState.CONNECTING -> setColorFilter(connectingColor) + com.futo.platformplayer.experimental_casting.CastConnectionState.DISCONNECTED -> setColorFilter(activeColor) + } + } else { + setColorFilter(inactiveColor); } } else { - setColorFilter(inactiveColor); + val d = StateCasting.instance.activeDevice; + + val activeColor = ContextCompat.getColor(c, R.color.colorPrimary); + val connectingColor = ContextCompat.getColor(c, R.color.gray_c3); + val inactiveColor = ContextCompat.getColor(c, R.color.white); + + if (d != null) { + when (d.connectionState) { + CastConnectionState.CONNECTED -> setColorFilter(activeColor) + CastConnectionState.CONNECTING -> setColorFilter(connectingColor) + CastConnectionState.DISCONNECTED -> setColorFilter(activeColor) + } + } else { + setColorFilter(inactiveColor); + } } } fun cleanup() { setOnClickListener(null); StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this); + ExpStateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this); } } \ No newline at end of file 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 161f3dc3..fdcc3c6c 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 @@ -27,6 +27,7 @@ import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.constructs.Event0 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.states.StateHistory import com.futo.platformplayer.states.StatePlayer @@ -38,6 +39,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import org.fcast.sender_sdk.DeviceFeature class CastView : ConstraintLayout { private val _thumbnail: ImageView; @@ -96,22 +98,48 @@ class CastView : ConstraintLayout { _gestureControlView.fullScreenGestureEnabled = false _gestureControlView.setupTouchArea(); _gestureControlView.onSpeedHoldStart.subscribe { - val d = StateCasting.instance.activeDevice ?: return@subscribe; - _speedHoldWasPlaying = d.isPlaying - _speedHoldPrevRate = d.speed - if (d.canSetSpeed) - d.changeSpeed(Settings.instance.playback.getHoldPlaybackSpeed()) - d.resumeVideo() + if (Settings.instance.casting.experimentalCasting) { + val d = ExpStateCasting.instance.activeDevice ?: return@subscribe; + _speedHoldWasPlaying = d.isPlaying + _speedHoldPrevRate = d.speed + if (d.device.supportsFeature(DeviceFeature.SET_SPEED)) { + d.device.changeSpeed(Settings.instance.playback.getHoldPlaybackSpeed()) + } + d.device.resumePlayback() + } else { + val d = StateCasting.instance.activeDevice ?: return@subscribe; + _speedHoldWasPlaying = d.isPlaying + _speedHoldPrevRate = d.speed + if (d.canSetSpeed) { + d.changeSpeed(Settings.instance.playback.getHoldPlaybackSpeed()) + } + d.resumeVideo() + } } _gestureControlView.onSpeedHoldEnd.subscribe { - val d = StateCasting.instance.activeDevice ?: return@subscribe; - if (!_speedHoldWasPlaying) d.pauseVideo() - d.changeSpeed(_speedHoldPrevRate) + if (Settings.instance.casting.experimentalCasting) { + val d = ExpStateCasting.instance.activeDevice ?: return@subscribe; + if (!_speedHoldWasPlaying) { + d.device.resumePlayback() + } + d.device.changeSpeed(_speedHoldPrevRate) + } else { + val d = StateCasting.instance.activeDevice ?: return@subscribe; + if (!_speedHoldWasPlaying) { + d.pauseVideo() + } + d.changeSpeed(_speedHoldPrevRate) + } } _gestureControlView.onSeek.subscribe { - val d = StateCasting.instance.activeDevice ?: return@subscribe; - StateCasting.instance.videoSeekTo(d.expectedCurrentTime + it / 1000); + if (Settings.instance.casting.experimentalCasting) { + val d = ExpStateCasting.instance.activeDevice ?: return@subscribe; + ExpStateCasting.instance.videoSeekTo(d.expectedCurrentTime + it / 1000); + } else { + val d = StateCasting.instance.activeDevice ?: return@subscribe; + StateCasting.instance.videoSeekTo(d.expectedCurrentTime + it / 1000); + } }; _buttonLoop.setOnClickListener { @@ -122,22 +150,46 @@ class CastView : ConstraintLayout { _timeBar.addListener(object : TimeBar.OnScrubListener { override fun onScrubStart(timeBar: TimeBar, position: Long) { - StateCasting.instance.videoSeekTo(position.toDouble()); + if (Settings.instance.casting.experimentalCasting) { + ExpStateCasting.instance.videoSeekTo(position.toDouble()); + } else { + StateCasting.instance.videoSeekTo(position.toDouble()); + } } override fun onScrubMove(timeBar: TimeBar, position: Long) { - StateCasting.instance.videoSeekTo(position.toDouble()); + if (Settings.instance.casting.experimentalCasting) { + ExpStateCasting.instance.videoSeekTo(position.toDouble()); + } else { + StateCasting.instance.videoSeekTo(position.toDouble()); + } } override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) { - StateCasting.instance.videoSeekTo(position.toDouble()); + if (Settings.instance.casting.experimentalCasting) { + ExpStateCasting.instance.videoSeekTo(position.toDouble()); + } else { + StateCasting.instance.videoSeekTo(position.toDouble()); + } } }); _buttonMinimize.setOnClickListener { onMinimizeClick.emit(); }; _buttonSettings.setOnClickListener { onSettingsClick.emit(); }; - _buttonPlay.setOnClickListener { StateCasting.instance.resumeVideo(); }; - _buttonPause.setOnClickListener { StateCasting.instance.pauseVideo(); }; + _buttonPlay.setOnClickListener { + if (Settings.instance.casting.experimentalCasting) { + ExpStateCasting.instance.resumeVideo() + } else { + StateCasting.instance.resumeVideo() + } + } + _buttonPause.setOnClickListener { + if (Settings.instance.casting.experimentalCasting) { + ExpStateCasting.instance.pauseVideo() + } else { + StateCasting.instance.pauseVideo() + } + } if (!isInEditMode) { setIsPlaying(false); @@ -220,19 +272,22 @@ class CastView : ConstraintLayout { stopTimeJob() if(isPlaying) { - val d = StateCasting.instance.activeDevice; - if (d is AirPlayCastingDevice || d is ChromecastCastingDevice) { - _updateTimeJob = _scope.launch { - while (true) { - val device = StateCasting.instance.activeDevice; - if (device == null || !device.isPlaying) { - break; - } + // NOTE: the experimental implementation polls automatically + if (!Settings.instance.casting.experimentalCasting) { + val d = StateCasting.instance.activeDevice; + if (d is AirPlayCastingDevice || d is ChromecastCastingDevice) { + _updateTimeJob = _scope.launch { + while (true) { + val device = StateCasting.instance.activeDevice; + if (device == null || !device.isPlaying) { + break; + } - delay(1000); - val time_ms = (device.expectedCurrentTime * 1000.0).toLong() - setTime(time_ms); - onTimeJobTimeChanged_s.emit(device.expectedCurrentTime.toLong()) + delay(1000); + val time_ms = (device.expectedCurrentTime * 1000.0).toLong() + setTime(time_ms); + onTimeJobTimeChanged_s.emit(device.expectedCurrentTime.toLong()) + } } } } @@ -247,7 +302,11 @@ class CastView : ConstraintLayout { _buttonPlay.visibility = View.VISIBLE; } - val position = StateCasting.instance.activeDevice?.expectedCurrentTime?.times(1000.0)?.toLong(); + val position = if (Settings.instance.casting.experimentalCasting) { + ExpStateCasting.instance.activeDevice?.expectedCurrentTime?.times(1000.0)?.toLong() + } else { + StateCasting.instance.activeDevice?.expectedCurrentTime?.times(1000.0)?.toLong(); + } if(StatePlayer.instance.hasMediaSession()) { StatePlayer.instance.updateMediaSession(null); StatePlayer.instance.updateMediaSessionPlaybackState(getPlaybackStateCompat(), (position ?: 0)); @@ -311,11 +370,20 @@ class CastView : ConstraintLayout { } private fun getPlaybackStateCompat(): Int { - val d = StateCasting.instance.activeDevice ?: return PlaybackState.STATE_NONE; + if (Settings.instance.casting.experimentalCasting) { + val d = ExpStateCasting.instance.activeDevice ?: return PlaybackState.STATE_NONE; - return when(d.isPlaying) { - true -> PlaybackStateCompat.STATE_PLAYING; - else -> PlaybackStateCompat.STATE_PAUSED; + return when(d.isPlaying) { + true -> PlaybackStateCompat.STATE_PLAYING; + else -> PlaybackStateCompat.STATE_PAUSED; + } + } else { + val d = StateCasting.instance.activeDevice ?: return PlaybackState.STATE_NONE; + + return when(d.isPlaying) { + true -> PlaybackStateCompat.STATE_PLAYING; + else -> PlaybackStateCompat.STATE_PAUSED; + } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6bc87252..f4a7050c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -82,6 +82,8 @@ If casting over IPV6 is allowed, can cause issues on some networks Allow Link Local IPV4 If casting over IPV4 link local is allowed, can cause issues on some networks + Experimental + Use experimental casting backend (requires restart) Discover Find new video sources to add These sources have been disabled From 7169cd1b8202ea380d928efff24e8fc8c3ff54df Mon Sep 17 00:00:00 2001 From: Marcus Hanestad Date: Wed, 13 Aug 2025 09:27:40 +0200 Subject: [PATCH 02/30] casting: add discovered devices directly to internal list when added --- .../experimental_casting/StateCasting.kt | 22 ++++++------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/experimental_casting/StateCasting.kt b/app/src/main/java/com/futo/platformplayer/experimental_casting/StateCasting.kt index 5edbcfe4..90270089 100644 --- a/app/src/main/java/com/futo/platformplayer/experimental_casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/experimental_casting/StateCasting.kt @@ -111,21 +111,6 @@ class ExpStateCasting { init { org.fcast.sender_sdk.initLogger(org.fcast.sender_sdk.LogLevelFilter.DEBUG) - onDeviceAdded.subscribe { device -> - invokeInMainScopeIfRequired { - val deviceHandle = CastingDeviceHandle(device) - devices[deviceHandle.device.name()] = deviceHandle - Log.i(TAG, "Device added: ${deviceHandle.device.name()}") - } - } - - onDeviceRemoved.subscribe { deviceName -> - invokeInMainScopeIfRequired { - if (devices.containsKey(deviceName)) { - devices.remove(deviceName) - } - } - } } fun handleUrl(context: Context, url: String) { @@ -191,17 +176,24 @@ class ExpStateCasting { context, DiscoveryEventHandler( { deviceInfo -> // Added + Logger.i(TAG, "Device added: ${deviceInfo.name}") val device = _context.createDeviceFromInfo(deviceInfo) + val deviceHandle = CastingDeviceHandle(device) + devices[deviceHandle.device.name()] = deviceHandle invokeInMainScopeIfRequired { onDeviceAdded.emit(device) } }, { deviceName -> // Removed invokeInMainScopeIfRequired { + if (devices.containsKey(deviceName)) { + devices.remove(deviceName) + } onDeviceRemoved.emit(deviceName) } }, { deviceInfo -> // Updated + Logger.i(TAG, "Device updated: $deviceInfo") val handle = devices[deviceInfo.name] if (handle != null) { handle.device.setPort(deviceInfo.port) From 0fd83cbd74a2dcd1054125405df16d3b1c2c85e4 Mon Sep 17 00:00:00 2001 From: Marcus Hanestad Date: Thu, 14 Aug 2025 11:24:13 +0200 Subject: [PATCH 03/30] 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 --- .../dialogs/ConnectCastingDialog.kt | 17 ++++-- .../dialogs/ConnectedCastingDialog.kt | 31 ++++++---- .../experimental_casting/CastingDevice.kt | 8 ++- .../experimental_casting/StateCasting.kt | 18 +++++- .../mainactivity/main/VideoDetailFragment.kt | 7 ++- .../mainactivity/main/VideoDetailView.kt | 60 ++++++------------- .../futo/platformplayer/states/StateApp.kt | 7 ++- .../views/adapters/DeviceViewHolder.kt | 14 +++-- .../views/casting/CastButton.kt | 8 ++- .../platformplayer/views/casting/CastView.kt | 13 +++- 10 files changed, 104 insertions(+), 79 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt index 204b7df6..54dc7bc4 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt @@ -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() { diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt index 042885ed..1dc9084b 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt @@ -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() { diff --git a/app/src/main/java/com/futo/platformplayer/experimental_casting/CastingDevice.kt b/app/src/main/java/com/futo/platformplayer/experimental_casting/CastingDevice.kt index e24a9ec8..8be10138 100644 --- a/app/src/main/java/com/futo/platformplayer/experimental_casting/CastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/experimental_casting/CastingDevice.kt @@ -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") + } } } diff --git a/app/src/main/java/com/futo/platformplayer/experimental_casting/StateCasting.kt b/app/src/main/java/com/futo/platformplayer/experimental_casting/StateCasting.kt index 90270089..c34c6204 100644 --- a/app/src/main/java/com/futo/platformplayer/experimental_casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/experimental_casting/StateCasting.kt @@ -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(); diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt index af228bf7..9293be3b 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt @@ -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; 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 594a7c46..6a2c26eb 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 @@ -325,7 +325,7 @@ class VideoDetailView : ConstraintLayout { val onEnterPictureInPicture = Event0(); val onVideoChanged = Event2() - 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() { override fun onResourceReady(resource: Bitmap, transition: Transition?) { @@ -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"; diff --git a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt index 7883b4fd..ed9f8922 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt @@ -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(); diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt index b7bd5400..e23a1622 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt @@ -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; diff --git a/app/src/main/java/com/futo/platformplayer/views/casting/CastButton.kt b/app/src/main/java/com/futo/platformplayer/views/casting/CastButton.kt index 6f081a8c..7184eea8 100644 --- a/app/src/main/java/com/futo/platformplayer/views/casting/CastButton.kt +++ b/app/src/main/java/com/futo/platformplayer/views/casting/CastButton.kt @@ -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); + } } } \ No newline at end of file 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 fdcc3c6c..c64ccc0e 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 @@ -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 From 5a74b714b8b21518f27a9947c8a467bd178ad525 Mon Sep 17 00:00:00 2001 From: Marcus Hanestad Date: Thu, 21 Aug 2025 17:20:47 +0200 Subject: [PATCH 04/30] casting: update SDK to 0.2.1 --- app/build.gradle | 5 +++- .../dialogs/ConnectedCastingDialog.kt | 7 ++++- .../experimental_casting/CastingDevice.kt | 22 +++++++++----- .../experimental_casting/StateCasting.kt | 30 +++++++++---------- .../views/adapters/DeviceViewHolder.kt | 3 +- 5 files changed, 41 insertions(+), 26 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index e708fa1e..8d729051 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -233,5 +233,8 @@ dependencies { androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' //Rust casting SDK - implementation "net.java.dev.jna:jna:5.12.0@aar" + implementation('org.futo.gitlab.videostreaming.fcast-sdk-jitpack:sender-sdk-minimal:0.2.1') { + // Polycentricandroid includes this + exclude group: 'net.java.dev.jna' + } } diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt index 1dc9084b..a52fb38d 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt @@ -108,7 +108,12 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) { _buttonClose.setOnClickListener { dismiss(); }; _buttonDisconnect.setOnClickListener { if (Settings.instance.casting.experimentalCasting) { - ExpStateCasting.instance.activeDevice?.device?.stopCasting() + try { + ExpStateCasting.instance.activeDevice?.device?.stopPlayback() + ExpStateCasting.instance.activeDevice?.device?.disconnect() + } catch (e: Throwable) { + // Ignored + } } else { StateCasting.instance.activeDevice?.stopCasting(); } diff --git a/app/src/main/java/com/futo/platformplayer/experimental_casting/CastingDevice.kt b/app/src/main/java/com/futo/platformplayer/experimental_casting/CastingDevice.kt index 8be10138..6793b551 100644 --- a/app/src/main/java/com/futo/platformplayer/experimental_casting/CastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/experimental_casting/CastingDevice.kt @@ -19,6 +19,7 @@ import java.net.InetAddress import org.fcast.sender_sdk.CastingDevice as RsCastingDevice; import org.fcast.sender_sdk.DeviceEventHandler as RsDeviceEventHandler; import org.fcast.sender_sdk.DeviceConnectionState +import org.fcast.sender_sdk.LoadRequest class CastingDeviceHandle { class EventHandler : RsDeviceEventHandler { @@ -64,6 +65,10 @@ class CastingDeviceHandle { override fun mediaEvent(event: GenericMediaEvent) { // Unreachable } + + override fun playbackError(message: String) { + Logger.e(TAG, "Playback error: $message") + } } val eventHandler = EventHandler() @@ -91,17 +96,16 @@ class CastingDeviceHandle { eventHandler.onConnectionStateChanged.subscribe { newState -> if (newState == DeviceConnectionState.Disconnected) { try { - Logger.i("CastingDeviceHandle", "Stopping device") + Logger.i(TAG, "Stopping device") device.disconnect() } catch (e: Throwable) { - Logger.e("CastingDeviceHandle", "Failed to stop device: $e") + Logger.e(TAG, "Failed to stop device: $e") } } } } fun loadVideo( - streamType: String, contentType: String, contentId: String, resumePosition: Double, @@ -109,9 +113,9 @@ class CastingDeviceHandle { speed: Double? ) { try { - device.loadVideo(contentType, contentId, resumePosition, speed) + device.load(LoadRequest.Video(contentType, contentId, resumePosition, speed, duration)) } catch (e: Throwable) { - Logger.e("CastingDevice", "Failed to load video: $e") + Logger.e(TAG, "Failed to load video: $e") } } @@ -123,11 +127,15 @@ class CastingDeviceHandle { speed: Double? ) { try { - device.loadContent(contentType, content, resumePosition, duration, speed) + device.load(LoadRequest.Content(contentType, content, resumePosition, duration, speed)) } catch (e: Throwable) { - Logger.e("CastingDevice", "Failed to load content: $e") + Logger.e(TAG, "Failed to load content: $e") } } + + companion object { + private val TAG = "ExperimentalCastingDevice" + } } enum class CastConnectionState { diff --git a/app/src/main/java/com/futo/platformplayer/experimental_casting/StateCasting.kt b/app/src/main/java/com/futo/platformplayer/experimental_casting/StateCasting.kt index c34c6204..4589d537 100644 --- a/app/src/main/java/com/futo/platformplayer/experimental_casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/experimental_casting/StateCasting.kt @@ -7,6 +7,7 @@ import android.os.Looper import android.util.Log import androidx.annotation.OptIn import androidx.media3.common.util.UnstableApi +import com.futo.platformplayer.BuildConfig import com.futo.platformplayer.R import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs @@ -50,6 +51,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.fcast.sender_sdk.ApplicationInfo import java.net.Inet6Address import java.net.InetAddress import java.net.URLDecoder @@ -110,7 +112,9 @@ class ExpStateCasting { } init { - org.fcast.sender_sdk.initLogger(org.fcast.sender_sdk.LogLevelFilter.DEBUG) + if (BuildConfig.DEBUG) { + org.fcast.sender_sdk.initLogger(org.fcast.sender_sdk.LogLevelFilter.DEBUG) + } } fun handleUrl(context: Context, url: String) { @@ -382,7 +386,13 @@ class ExpStateCasting { } try { - device.device.connect(device.eventHandler) + device.device.connect( + ApplicationInfo( + "grayjay android", + "${BuildConfig.VERSION_NAME}-${BuildConfig.FLAVOR}", + "Grayjay" + ), device.eventHandler + ) Logger.i(TAG, "Requested manager to start device") } catch (e: Throwable) { Logger.w(TAG, "Failed to connect to device."); @@ -415,7 +425,7 @@ class ExpStateCasting { val rsDeviceInfo = device.device.getDeviceInfo() val deviceInfo = CastingDeviceInfo( name = device.device.name(), - type = when (rsDeviceInfo.type) { + type = when (rsDeviceInfo.protocol) { ProtocolType.CHROMECAST -> com.futo.platformplayer.casting.CastProtocolType.CHROMECAST ProtocolType.F_CAST -> com.futo.platformplayer.casting.CastProtocolType.FCAST }, @@ -547,7 +557,6 @@ class ExpStateCasting { val videoUrl = if (proxyStreams) url + videoPath else videoSource.getVideoUrl(); Logger.i(TAG, "Casting as singular video"); ad.loadVideo( - if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoUrl, resumePosition, @@ -559,7 +568,6 @@ class ExpStateCasting { val audioUrl = if (proxyStreams) url + audioPath else audioSource.getAudioUrl(); Logger.i(TAG, "Casting as singular audio"); ad.loadVideo( - if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioUrl, resumePosition, @@ -579,7 +587,6 @@ class ExpStateCasting { } else { Logger.i(TAG, "Casting as non-proxied HLS"); ad.loadVideo( - if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.url, resumePosition, @@ -600,7 +607,6 @@ class ExpStateCasting { } else { Logger.i(TAG, "Casting as non-proxied audio HLS"); ad.loadVideo( - if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.url, resumePosition, @@ -716,7 +722,6 @@ class ExpStateCasting { Logger.i(TAG, "Casting local video (videoUrl: $videoUrl)."); ad.loadVideo( - "BUFFERED", videoSource.container, videoUrl, resumePosition, @@ -747,7 +752,6 @@ class ExpStateCasting { Logger.i(TAG, "Casting local audio (audioUrl: $audioUrl)."); ad.loadVideo( - "BUFFERED", audioSource.container, audioUrl, resumePosition, @@ -941,7 +945,6 @@ class ExpStateCasting { "added new castLocalHls handlers (hlsPath: $hlsPath, videoPath: $videoPath, audioPath: $audioPath, subtitlePath: $subtitlePath)." ) ad.loadVideo( - "BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, @@ -1021,7 +1024,6 @@ class ExpStateCasting { "added new castLocalDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath, subtitlePath: $subtitlePath)." ); ad.loadVideo( - "BUFFERED", "application/dash+xml", dashUrl, resumePosition, @@ -1297,7 +1299,6 @@ class ExpStateCasting { val hackfixResumePosition = if (ad.device.castingProtocol() == ProtocolType.CHROMECAST && !video.isLive && resumePosition == 0.0) 0.1 else resumePosition; ad.loadVideo( - if (video.isLive) "LIVE" else "BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, hackfixResumePosition, @@ -1570,7 +1571,6 @@ class ExpStateCasting { Logger.i(TAG, "added new castHls handlers (hlsPath: $hlsPath)."); ad.loadVideo( - if (video.isLive) "LIVE" else "BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, @@ -1686,7 +1686,6 @@ class ExpStateCasting { "added new castDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath)." ); ad.loadVideo( - if (video.isLive) "LIVE" else "BUFFERED", "application/dash+xml", dashUrl, resumePosition, @@ -1955,7 +1954,6 @@ class ExpStateCasting { "added new castDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath)." ); ad.loadVideo( - if (video.isLive) "LIVE" else "BUFFERED", "application/dash+xml", dashUrl, resumePosition, @@ -1971,7 +1969,7 @@ class ExpStateCasting { deviceInfo.addresses.map { org.fcast.sender_sdk.tryIpAddrFromStr(it) } // Throws! val rsDeviceInfo = RsDeviceInfo( name = deviceInfo.name, - type = when (deviceInfo.type) { + protocol = when (deviceInfo.type) { com.futo.platformplayer.casting.CastProtocolType.CHROMECAST -> ProtocolType.CHROMECAST com.futo.platformplayer.casting.CastProtocolType.FCAST -> ProtocolType.F_CAST else -> throw IllegalArgumentException() diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt index e23a1622..d3f0f8c8 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt @@ -74,7 +74,8 @@ class DeviceViewHolder : ViewHolder { if (dev.handle.device.isReady()) { // NOTE: we assume experimental casting is used try { - ExpStateCasting.instance.activeDevice?.device?.stopCasting() + ExpStateCasting.instance.activeDevice?.device?.stopPlayback() + ExpStateCasting.instance.activeDevice?.device?.disconnect() } catch (e: Throwable) { //Ignored } From 6ff70d2df028f811875c8be6d95076f15577ac34 Mon Sep 17 00:00:00 2001 From: Marcus Hanestad Date: Fri, 22 Aug 2025 10:05:32 +0200 Subject: [PATCH 05/30] casting: set metadata for video load requests --- .../experimental_casting/CastingDevice.kt | 6 ++- .../experimental_casting/StateCasting.kt | 44 ++++++++++++++----- 2 files changed, 36 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/experimental_casting/CastingDevice.kt b/app/src/main/java/com/futo/platformplayer/experimental_casting/CastingDevice.kt index 6793b551..cc495e80 100644 --- a/app/src/main/java/com/futo/platformplayer/experimental_casting/CastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/experimental_casting/CastingDevice.kt @@ -20,6 +20,7 @@ import org.fcast.sender_sdk.CastingDevice as RsCastingDevice; import org.fcast.sender_sdk.DeviceEventHandler as RsDeviceEventHandler; import org.fcast.sender_sdk.DeviceConnectionState import org.fcast.sender_sdk.LoadRequest +import org.fcast.sender_sdk.Metadata class CastingDeviceHandle { class EventHandler : RsDeviceEventHandler { @@ -110,10 +111,11 @@ class CastingDeviceHandle { contentId: String, resumePosition: Double, duration: Double, - speed: Double? + speed: Double?, + metadata: Metadata? = null ) { try { - device.load(LoadRequest.Video(contentType, contentId, resumePosition, speed, duration)) + device.load(LoadRequest.Video(contentType, contentId, resumePosition, speed, duration, metadata)) } catch (e: Throwable) { Logger.e(TAG, "Failed to load video: $e") } diff --git a/app/src/main/java/com/futo/platformplayer/experimental_casting/StateCasting.kt b/app/src/main/java/com/futo/platformplayer/experimental_casting/StateCasting.kt index 4589d537..60f8c413 100644 --- a/app/src/main/java/com/futo/platformplayer/experimental_casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/experimental_casting/StateCasting.kt @@ -62,6 +62,7 @@ import org.fcast.sender_sdk.CastingDevice as RsCastingDevice import org.fcast.sender_sdk.ProtocolType import org.fcast.sender_sdk.CastContext import org.fcast.sender_sdk.DeviceConnectionState +import org.fcast.sender_sdk.Metadata import org.fcast.sender_sdk.NsdDeviceDiscoverer import org.fcast.sender_sdk.urlFormatIpAddr import java.util.concurrent.atomic.AtomicInteger @@ -463,6 +464,13 @@ class ExpStateCasting { _castId.incrementAndGet() } + private fun metadataFromVideo(video: IPlatformVideoDetails): Metadata { + return Metadata( + title = video.name, + thumbnailUrl = video.thumbnails.getHQThumbnail() + ) + } + suspend fun castIfAvailable( contentResolver: ContentResolver, video: IPlatformVideoDetails, @@ -561,7 +569,8 @@ class ExpStateCasting { videoUrl, resumePosition, video.duration.toDouble(), - speed + speed, + metadataFromVideo(video) ) } else if (audioSource is IAudioUrlSource) { val audioPath = "/audio-${id}" @@ -572,7 +581,8 @@ class ExpStateCasting { audioUrl, resumePosition, video.duration.toDouble(), - speed + speed, + metadataFromVideo(video) ); } else if (videoSource is IHLSManifestSource) { if (proxyStreams || ad.device.castingProtocol() == ProtocolType.CHROMECAST) { @@ -591,7 +601,8 @@ class ExpStateCasting { videoSource.url, resumePosition, video.duration.toDouble(), - speed + speed, + metadataFromVideo(video) ); } } else if (audioSource is IHLSManifestAudioSource) { @@ -611,7 +622,8 @@ class ExpStateCasting { audioSource.url, resumePosition, video.duration.toDouble(), - speed + speed, + metadataFromVideo(video) ); } } else if (videoSource is LocalVideoSource) { @@ -726,7 +738,8 @@ class ExpStateCasting { videoUrl, resumePosition, video.duration.toDouble(), - speed + speed, + metadataFromVideo(video) ); return listOf(videoUrl); @@ -756,7 +769,8 @@ class ExpStateCasting { audioUrl, resumePosition, video.duration.toDouble(), - speed + speed, + metadataFromVideo(video) ); return listOf(audioUrl); @@ -949,7 +963,8 @@ class ExpStateCasting { hlsUrl, resumePosition, video.duration.toDouble(), - speed + speed, + metadataFromVideo(video) ) return listOf(hlsUrl, videoUrl, audioUrl, subtitleUrl) @@ -1028,7 +1043,8 @@ class ExpStateCasting { dashUrl, resumePosition, video.duration.toDouble(), - speed + speed, + metadataFromVideo(video) ); return listOf(dashUrl, videoUrl, audioUrl, subtitleUrl); @@ -1303,7 +1319,8 @@ class ExpStateCasting { hlsUrl, hackfixResumePosition, video.duration.toDouble(), - speed + speed, + metadataFromVideo(video) ); return listOf(hlsUrl); @@ -1575,7 +1592,8 @@ class ExpStateCasting { hlsUrl, resumePosition, video.duration.toDouble(), - speed + speed, + metadataFromVideo(video) ); return listOf( @@ -1690,7 +1708,8 @@ class ExpStateCasting { dashUrl, resumePosition, video.duration.toDouble(), - speed + speed, + metadataFromVideo(video) ); return listOf( @@ -1958,7 +1977,8 @@ class ExpStateCasting { dashUrl, resumePosition, video.duration.toDouble(), - speed + speed, + metadataFromVideo(video) ); return listOf() From bf35da8ee373dc4db5879af2d6b4e6f65f32ea84 Mon Sep 17 00:00:00 2001 From: Marcus Hanestad Date: Fri, 22 Aug 2025 10:38:50 +0200 Subject: [PATCH 06/30] casting(experimental): removed unused hls cast function --- .../experimental_casting/StateCasting.kt | 221 ------------------ 1 file changed, 221 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/experimental_casting/StateCasting.kt b/app/src/main/java/com/futo/platformplayer/experimental_casting/StateCasting.kt index 60f8c413..1fc5ba74 100644 --- a/app/src/main/java/com/futo/platformplayer/experimental_casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/experimental_casting/StateCasting.kt @@ -1383,227 +1383,6 @@ class ExpStateCasting { } } - private suspend fun castHlsIndirect( - contentResolver: ContentResolver, - video: IPlatformVideoDetails, - videoSource: IVideoUrlSource?, - audioSource: IAudioUrlSource?, - subtitleSource: ISubtitleSource?, - resumePosition: Double, - speed: Double? - ): List { - val ad = activeDevice ?: return listOf(); - val url = getLocalUrl(ad); - val id = UUID.randomUUID(); - - val hlsPath = "/hls-${id}" - - val hlsUrl = url + hlsPath; - Logger.i(TAG, "HLS url: $hlsUrl"); - - val mediaRenditions = arrayListOf() - val variantPlaylistReferences = arrayListOf() - - if (audioSource != null) { - val audioPath = "/audio-${id}" - val audioUrl = url + audioPath - - val duration = - audioSource.duration ?: videoSource?.duration ?: throw Exception("Duration unknown") - val audioVariantPlaylistPath = "/audio-playlist-${id}" - val audioVariantPlaylistUrl = url + audioVariantPlaylistPath - val audioVariantPlaylistSegments = - listOf(HLS.MediaSegment(duration.toDouble(), audioUrl)) - val audioVariantPlaylist = HLS.VariantPlaylist( - 3, - duration.toInt(), - 0, - 0, - null, - null, - null, - audioVariantPlaylistSegments - ) - - _castServer.addHandlerWithAllowAllOptions( - HttpConstantHandler( - "GET", audioVariantPlaylistPath, audioVariantPlaylist.buildM3U8(), - "application/vnd.apple.mpegurl" - ) - .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("castHlsIndirectVariant"); - - mediaRenditions.add( - HLS.MediaRendition( - "AUDIO", - audioVariantPlaylistUrl, - "audio", - "df", - "default", - true, - true, - true - ) - ) - - _castServer.addHandlerWithAllowAllOptions( - HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true) - .withInjectedHost() - .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("castHlsIndirectVariant"); - } - - val subtitlesUri = if (subtitleSource != null) withContext(Dispatchers.IO) { - return@withContext subtitleSource.getSubtitlesURI(); - } else null; - - var subtitlesUrl: String? = null; - if (subtitlesUri != null) { - val subtitlePath = "/subtitles-${id}" - if (subtitlesUri.scheme == "file") { - var content: String? = null; - val inputStream = contentResolver.openInputStream(subtitlesUri); - inputStream?.use { stream -> - val reader = stream.bufferedReader(); - content = reader.use { it.readText() }; - } - - if (content != null) { - _castServer.addHandlerWithAllowAllOptions( - HttpConstantHandler( - "GET", - subtitlePath, - content!!, - subtitleSource?.format ?: "text/vtt" - ) - .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("castHlsIndirectVariant"); - } - - subtitlesUrl = url + subtitlePath; - } else { - subtitlesUrl = subtitlesUri.toString(); - } - } - - if (subtitlesUrl != null) { - val duration = videoSource?.duration ?: audioSource?.duration - ?: throw Exception("Duration unknown") - val subtitleVariantPlaylistPath = "/subtitle-playlist-${id}" - val subtitleVariantPlaylistUrl = url + subtitleVariantPlaylistPath - val subtitleVariantPlaylistSegments = - listOf(HLS.MediaSegment(duration.toDouble(), subtitlesUrl)) - val subtitleVariantPlaylist = HLS.VariantPlaylist( - 3, - duration.toInt(), - 0, - 0, - null, - null, - null, - subtitleVariantPlaylistSegments - ) - - _castServer.addHandlerWithAllowAllOptions( - HttpConstantHandler( - "GET", subtitleVariantPlaylistPath, subtitleVariantPlaylist.buildM3U8(), - "application/vnd.apple.mpegurl" - ) - .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("castHlsIndirectVariant"); - - mediaRenditions.add( - HLS.MediaRendition( - "SUBTITLES", - subtitleVariantPlaylistUrl, - "subtitles", - "df", - "default", - true, - true, - true - ) - ) - } - - if (videoSource != null) { - val videoPath = "/video-${id}" - val videoUrl = url + videoPath - - val duration = videoSource.duration - val videoVariantPlaylistPath = "/video-playlist-${id}" - val videoVariantPlaylistUrl = url + videoVariantPlaylistPath - val videoVariantPlaylistSegments = - listOf(HLS.MediaSegment(duration.toDouble(), videoUrl)) - val videoVariantPlaylist = HLS.VariantPlaylist( - 3, - duration.toInt(), - 0, - 0, - null, - null, - null, - videoVariantPlaylistSegments - ) - - _castServer.addHandlerWithAllowAllOptions( - HttpConstantHandler( - "GET", videoVariantPlaylistPath, videoVariantPlaylist.buildM3U8(), - "application/vnd.apple.mpegurl" - ) - .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("castHlsIndirectVariant"); - - variantPlaylistReferences.add( - HLS.VariantPlaylistReference( - videoVariantPlaylistUrl, HLS.StreamInfo( - videoSource.bitrate ?: 0, - "${videoSource.width}x${videoSource.height}", - videoSource.codec, - null, - null, - if (audioSource != null) "audio" else null, - if (subtitleSource != null) "subtitles" else null, - null, null - ) - ) - ) - - _castServer.addHandlerWithAllowAllOptions( - HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true) - .withInjectedHost() - .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("castHlsIndirectVariant"); - } - - val masterPlaylist = - HLS.MasterPlaylist(variantPlaylistReferences, mediaRenditions, listOf(), true) - _castServer.addHandlerWithAllowAllOptions( - HttpConstantHandler( - "GET", hlsPath, masterPlaylist.buildM3U8(), - "application/vnd.apple.mpegurl" - ) - .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("castHlsIndirectMaster") - - Logger.i(TAG, "added new castHls handlers (hlsPath: $hlsPath)."); - ad.loadVideo( - "application/vnd.apple.mpegurl", - hlsUrl, - resumePosition, - video.duration.toDouble(), - speed, - metadataFromVideo(video) - ); - - return listOf( - hlsUrl, - videoSource?.getVideoUrl() ?: "", - audioSource?.getAudioUrl() ?: "", - subtitlesUri.toString() - ); - } - private suspend fun castDashIndirect( contentResolver: ContentResolver, video: IPlatformVideoDetails, From cb733f1e096be28f9f4b10a0a8cbadc201b335fb Mon Sep 17 00:00:00 2001 From: Marcus Hanestad Date: Fri, 22 Aug 2025 16:13:57 +0200 Subject: [PATCH 07/30] casting: update ApplicationInfo content --- .../futo/platformplayer/experimental_casting/StateCasting.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/experimental_casting/StateCasting.kt b/app/src/main/java/com/futo/platformplayer/experimental_casting/StateCasting.kt index 1fc5ba74..04f09198 100644 --- a/app/src/main/java/com/futo/platformplayer/experimental_casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/experimental_casting/StateCasting.kt @@ -3,6 +3,7 @@ package com.futo.platformplayer.experimental_casting import android.app.AlertDialog import android.content.ContentResolver import android.content.Context +import android.os.Build import android.os.Looper import android.util.Log import androidx.annotation.OptIn @@ -389,9 +390,9 @@ class ExpStateCasting { try { device.device.connect( ApplicationInfo( - "grayjay android", + "Grayjay Android", "${BuildConfig.VERSION_NAME}-${BuildConfig.FLAVOR}", - "Grayjay" + "${Build.MANUFACTURER} ${Build.MODEL}" ), device.eventHandler ) Logger.i(TAG, "Requested manager to start device") From 2958f6c8e6ef73ec6c93ac17c3dfec2c61da4230 Mon Sep 17 00:00:00 2001 From: Marcus Hanestad Date: Wed, 27 Aug 2025 09:44:00 +0200 Subject: [PATCH 08/30] casting: use new FCast icon when experimental --- .../dialogs/ConnectedCastingDialog.kt | 2 +- .../views/adapters/DeviceViewHolder.kt | 2 +- app/src/main/res/drawable/ic_exp_fc.xml | 14 ++++++++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 app/src/main/res/drawable/ic_exp_fc.xml diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt index a52fb38d..08590914 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt @@ -263,7 +263,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) { _textType.text = "Chromecast"; } ProtocolType.F_CAST -> { - _imageDevice.setImageResource(R.drawable.ic_fc); + _imageDevice.setImageResource(R.drawable.ic_exp_fc); _textType.text = "FCast"; } } diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt index d3f0f8c8..4e0cc26b 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt @@ -170,7 +170,7 @@ class DeviceViewHolder : ViewHolder { _textType.text = "Chromecast"; } ProtocolType.F_CAST -> { - _imageDevice.setImageResource(R.drawable.ic_fc); + _imageDevice.setImageResource(R.drawable.ic_exp_fc); _textType.text = "FCast"; } } diff --git a/app/src/main/res/drawable/ic_exp_fc.xml b/app/src/main/res/drawable/ic_exp_fc.xml new file mode 100644 index 00000000..355f8836 --- /dev/null +++ b/app/src/main/res/drawable/ic_exp_fc.xml @@ -0,0 +1,14 @@ + + + + From 595904b48b0213812fb9a45a5fd223df92a77eb2 Mon Sep 17 00:00:00 2001 From: Marcus Hanestad Date: Wed, 27 Aug 2025 09:55:10 +0200 Subject: [PATCH 09/30] casting: show only valid device types when manually adding device in experimental mode --- .../platformplayer/dialogs/CastingAddDialog.kt | 15 +++++++++++++-- app/src/main/res/values/strings.xml | 4 ++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/CastingAddDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/CastingAddDialog.kt index 1c975547..c8cdc6c4 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/CastingAddDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/CastingAddDialog.kt @@ -15,6 +15,7 @@ import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.experimental_casting.ExpStateCasting import com.futo.platformplayer.models.CastingDeviceInfo import com.futo.platformplayer.toInetAddress +import com.futo.platformplayer.logging.Logger class CastingAddDialog(context: Context?) : AlertDialog(context) { @@ -40,7 +41,13 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) { _buttonConfirm = findViewById(R.id.button_confirm); _buttonTutorial = findViewById(R.id.button_tutorial) - ArrayAdapter.createFromResource(context, R.array.casting_device_type_array, R.layout.spinner_item_simple).also { adapter -> + val deviceTypeArray = if (Settings.instance.casting.experimentalCasting) { + R.array.exp_casting_device_type_array + } else { + R.array.casting_device_type_array + } + + ArrayAdapter.createFromResource(context, deviceTypeArray, R.layout.spinner_item_simple).also { adapter -> adapter.setDropDownViewResource(R.layout.spinner_dropdownitem_simple); _spinnerType.adapter = adapter; }; @@ -104,7 +111,11 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) { _textError.visibility = View.GONE; val castingDeviceInfo = CastingDeviceInfo(name, castProtocolType, arrayOf(ip), port.toInt()); if (Settings.instance.casting.experimentalCasting) { - ExpStateCasting.instance.addRememberedDevice(castingDeviceInfo) + try { + ExpStateCasting.instance.addRememberedDevice(castingDeviceInfo) + } catch (e: Throwable) { + Logger.e(TAG, "Failed to add remembered device: $e") + } } else { StateCasting.instance.addRememberedDevice(castingDeviceInfo) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f4a7050c..92c52cf4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1106,6 +1106,10 @@ ChromeCast AirPlay + + FCast + ChromeCast + None Error From 58e08f5ea33eb2c1d5893bd50ad5933f6441e032 Mon Sep 17 00:00:00 2001 From: Marcus Hanestad Date: Thu, 28 Aug 2025 10:37:14 +0200 Subject: [PATCH 10/30] casting: add helper functions for dispatching to active backend --- .../dialogs/ConnectedCastingDialog.kt | 55 +--- .../experimental_casting/CastingDevice.kt | 17 +- .../experimental_casting/StateCasting.kt | 12 - .../StateCastingDispatcher.kt | 134 ++++++++++ .../mainactivity/main/VideoDetailView.kt | 245 +++++------------- .../platformplayer/views/casting/CastView.kt | 66 ++--- 6 files changed, 236 insertions(+), 293 deletions(-) create mode 100644 app/src/main/java/com/futo/platformplayer/experimental_casting/StateCastingDispatcher.kt diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt index 08590914..f9466487 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt @@ -21,6 +21,7 @@ import com.futo.platformplayer.casting.ChromecastCastingDevice import com.futo.platformplayer.casting.FCastCastingDevice import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.experimental_casting.ExpStateCasting +import com.futo.platformplayer.experimental_casting.StateCastingDispatcher import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateApp @@ -74,30 +75,18 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) { _buttonPlay = findViewById(R.id.button_play); _buttonPlay.setOnClickListener { - if (Settings.instance.casting.experimentalCasting) { - ExpStateCasting.instance.activeDevice?.device?.resumePlayback() - } else { - StateCasting.instance.activeDevice?.resumeVideo() - } + StateCastingDispatcher.resumeVideo() } _buttonPause = findViewById(R.id.button_pause); _buttonPause.setOnClickListener { - if (Settings.instance.casting.experimentalCasting) { - ExpStateCasting.instance.activeDevice?.device?.pausePlayback() - } else { - StateCasting.instance.activeDevice?.pauseVideo() - } + StateCastingDispatcher.pauseVideo() } _buttonStop = findViewById(R.id.button_stop); _buttonStop.setOnClickListener { (ownerActivity as MainActivity?)?.getFragment()?.closeVideoDetails() - if (Settings.instance.casting.experimentalCasting) { - ExpStateCasting.instance.activeDevice?.device?.stopPlayback() - } else { - StateCasting.instance.activeDevice?.stopVideo() - } + StateCastingDispatcher.stopVideo() } _buttonNext = findViewById(R.id.button_next); @@ -125,21 +114,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) { return@OnChangeListener } - if (Settings.instance.casting.experimentalCasting) { - val activeDevice = ExpStateCasting.instance.activeDevice ?: return@OnChangeListener; - try { - activeDevice.device.seek(value.toDouble()); - } catch (e: Throwable) { - Logger.e(TAG, "Failed to seek.", e); - } - } else { - val activeDevice = StateCasting.instance.activeDevice ?: return@OnChangeListener; - try { - activeDevice.seekVideo(value.toDouble()); - } catch (e: Throwable) { - Logger.e(TAG, "Failed to seek.", e); - } - } + StateCastingDispatcher.videoSeekTo(value.toDouble()) }); //TODO: Check if volume slider is properly hidden in all cases @@ -148,25 +123,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) { return@OnChangeListener } - if (Settings.instance.casting.experimentalCasting) { - val activeDevice = ExpStateCasting.instance.activeDevice ?: return@OnChangeListener; - if (activeDevice.device.supportsFeature(DeviceFeature.SET_VOLUME)) { - try { - activeDevice.device.changeVolume(value.toDouble()); - } catch (e: Throwable) { - Logger.e(TAG, "Failed to change volume.", e); - } - } - } else { - val activeDevice = StateCasting.instance.activeDevice ?: return@OnChangeListener; - if (activeDevice.canSetVolume) { - try { - activeDevice.changeVolume(value.toDouble()); - } catch (e: Throwable) { - Logger.e(TAG, "Failed to change volume.", e); - } - } - } + StateCastingDispatcher.changeVolume(value.toDouble()) }); setLoading(false); diff --git a/app/src/main/java/com/futo/platformplayer/experimental_casting/CastingDevice.kt b/app/src/main/java/com/futo/platformplayer/experimental_casting/CastingDevice.kt index cc495e80..151a968f 100644 --- a/app/src/main/java/com/futo/platformplayer/experimental_casting/CastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/experimental_casting/CastingDevice.kt @@ -110,12 +110,17 @@ class CastingDeviceHandle { contentType: String, contentId: String, resumePosition: Double, - duration: Double, speed: Double?, metadata: Metadata? = null ) { try { - device.load(LoadRequest.Video(contentType, contentId, resumePosition, speed, duration, metadata)) + device.load(LoadRequest.Video( + contentType = contentType, + url = contentId, + resumePosition = resumePosition, + speed = speed, + metadata = metadata + )) } catch (e: Throwable) { Logger.e(TAG, "Failed to load video: $e") } @@ -125,11 +130,15 @@ class CastingDeviceHandle { contentType: String, content: String, resumePosition: Double, - duration: Double, speed: Double? ) { try { - device.load(LoadRequest.Content(contentType, content, resumePosition, duration, speed)) + device.load(LoadRequest.Content( + contentType =contentType, + content = content, + resumePosition = resumePosition, + speed = speed + )) } catch (e: Throwable) { Logger.e(TAG, "Failed to load content: $e") } diff --git a/app/src/main/java/com/futo/platformplayer/experimental_casting/StateCasting.kt b/app/src/main/java/com/futo/platformplayer/experimental_casting/StateCasting.kt index 04f09198..d2020370 100644 --- a/app/src/main/java/com/futo/platformplayer/experimental_casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/experimental_casting/StateCasting.kt @@ -569,7 +569,6 @@ class ExpStateCasting { videoSource.container, videoUrl, resumePosition, - video.duration.toDouble(), speed, metadataFromVideo(video) ) @@ -581,7 +580,6 @@ class ExpStateCasting { audioSource.container, audioUrl, resumePosition, - video.duration.toDouble(), speed, metadataFromVideo(video) ); @@ -601,7 +599,6 @@ class ExpStateCasting { videoSource.container, videoSource.url, resumePosition, - video.duration.toDouble(), speed, metadataFromVideo(video) ); @@ -622,7 +619,6 @@ class ExpStateCasting { audioSource.container, audioSource.url, resumePosition, - video.duration.toDouble(), speed, metadataFromVideo(video) ); @@ -738,7 +734,6 @@ class ExpStateCasting { videoSource.container, videoUrl, resumePosition, - video.duration.toDouble(), speed, metadataFromVideo(video) ); @@ -769,7 +764,6 @@ class ExpStateCasting { audioSource.container, audioUrl, resumePosition, - video.duration.toDouble(), speed, metadataFromVideo(video) ); @@ -963,7 +957,6 @@ class ExpStateCasting { "application/vnd.apple.mpegurl", hlsUrl, resumePosition, - video.duration.toDouble(), speed, metadataFromVideo(video) ) @@ -1043,7 +1036,6 @@ class ExpStateCasting { "application/dash+xml", dashUrl, resumePosition, - video.duration.toDouble(), speed, metadataFromVideo(video) ); @@ -1139,7 +1131,6 @@ class ExpStateCasting { "application/dash+xml", content, resumePosition, - video.duration.toDouble(), speed ); @@ -1319,7 +1310,6 @@ class ExpStateCasting { "application/vnd.apple.mpegurl", hlsUrl, hackfixResumePosition, - video.duration.toDouble(), speed, metadataFromVideo(video) ); @@ -1487,7 +1477,6 @@ class ExpStateCasting { "application/dash+xml", dashUrl, resumePosition, - video.duration.toDouble(), speed, metadataFromVideo(video) ); @@ -1756,7 +1745,6 @@ class ExpStateCasting { "application/dash+xml", dashUrl, resumePosition, - video.duration.toDouble(), speed, metadataFromVideo(video) ); diff --git a/app/src/main/java/com/futo/platformplayer/experimental_casting/StateCastingDispatcher.kt b/app/src/main/java/com/futo/platformplayer/experimental_casting/StateCastingDispatcher.kt new file mode 100644 index 00000000..dc73056e --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/experimental_casting/StateCastingDispatcher.kt @@ -0,0 +1,134 @@ +package com.futo.platformplayer.experimental_casting + +import com.futo.platformplayer.Settings +import com.futo.platformplayer.casting.CastConnectionState +import com.futo.platformplayer.casting.StateCasting +import com.futo.platformplayer.dialogs.ConnectedCastingDialog.Companion.TAG +import com.futo.platformplayer.logging.Logger +import org.fcast.sender_sdk.DeviceFeature + +class StateCastingDispatcher { + companion object { + fun canActiveDeviceSetSpeed(): Boolean { + return if (Settings.instance.casting.experimentalCasting) { + ExpStateCasting.instance.activeDevice?.device?.supportsFeature(DeviceFeature.SET_SPEED) == true + } else { + StateCasting.instance.activeDevice?.canSetSpeed == true + } + } + + fun getActiveDeviceSpeed(): Double? { + return if (Settings.instance.casting.experimentalCasting) { + ExpStateCasting.instance.activeDevice?.speed + } else { + StateCasting.instance.activeDevice?.speed + } + } + + fun activeDeviceSetSpeed(speed: Double) { + if (Settings.instance.casting.experimentalCasting) { + ExpStateCasting.instance.activeDevice?.device?.changeSpeed(speed) + } else { + StateCasting.instance.activeDevice?.changeSpeed(speed) + } + } + + fun resumeVideo(): Boolean { + return try { + if (Settings.instance.casting.experimentalCasting) { + ExpStateCasting.instance.resumeVideo() + } else { + StateCasting.instance.resumeVideo() + } + } catch (_: Throwable) { + false + } + } + + fun pauseVideo(): Boolean { + return try { + if (Settings.instance.casting.experimentalCasting) { + ExpStateCasting.instance.pauseVideo() + } else { + StateCasting.instance.pauseVideo() + } + } catch (_: Throwable) { + false + } + } + + fun videoSeekTo(timeSeconds: Double): Boolean { + return try { + return if (Settings.instance.casting.experimentalCasting) { + ExpStateCasting.instance.videoSeekTo(timeSeconds) + } else { + StateCasting.instance.videoSeekTo(timeSeconds) + } + } catch (_: Throwable) { + false + } + } + + fun stopVideo(): Boolean { + return try { + return if (Settings.instance.casting.experimentalCasting) { + ExpStateCasting.instance.stopVideo() + } else { + StateCasting.instance.stopVideo() + } + } catch (_: Throwable) { + false + } + } + + fun isCasting(): Boolean { + return if (Settings.instance.casting.experimentalCasting) { + ExpStateCasting.instance.isCasting + } else { + StateCasting.instance.isCasting + } + } + + fun isConnected(): Boolean { + return if (Settings.instance.casting.experimentalCasting) { + ExpStateCasting.instance.activeDevice?.connectionState == com.futo.platformplayer.experimental_casting.CastConnectionState.CONNECTED + } else { + StateCasting.instance.activeDevice?.connectionState == CastConnectionState.CONNECTED + } + } + + fun isPlaying(): Boolean { + return if (Settings.instance.casting.experimentalCasting) { + ExpStateCasting.instance.activeDevice?.isPlaying == true + } else { + StateCasting.instance.activeDevice?.isPlaying == true + } + } + + fun getExpectedCurrentTime(): Double? { + return if (Settings.instance.casting.experimentalCasting) { + ExpStateCasting.instance.activeDevice?.expectedCurrentTime + } else { + StateCasting.instance.activeDevice?.expectedCurrentTime + } + } + + fun changeVolume(volume: Double) { + try { + if (Settings.instance.casting.experimentalCasting) { + val activeDevice = + ExpStateCasting.instance.activeDevice ?: return; + if (activeDevice.device.supportsFeature(DeviceFeature.SET_VOLUME)) { + activeDevice.device.changeVolume(volume); + } + } else { + val activeDevice = + StateCasting.instance.activeDevice ?: return; + if (activeDevice.canSetVolume) { + activeDevice.changeVolume(volume); + } + } + } catch (_: Throwable) {} + } + } +} \ No newline at end of file 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 6a2c26eb..4848e748 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 @@ -99,6 +99,7 @@ import com.futo.platformplayer.engine.exceptions.ScriptReloadRequiredException import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException import com.futo.platformplayer.exceptions.UnsupportedCastException import com.futo.platformplayer.experimental_casting.ExpStateCasting +import com.futo.platformplayer.experimental_casting.StateCastingDispatcher import com.futo.platformplayer.fixHtmlLinks import com.futo.platformplayer.fixHtmlWhitespace import com.futo.platformplayer.getNowDiffSeconds @@ -1217,12 +1218,8 @@ class VideoDetailView : ConstraintLayout { _onPauseCalled = true; _taskLoadVideo.cancel(); - if (Settings.instance.casting.experimentalCasting) { - if(ExpStateCasting.instance.isCasting) - return; - } else { - if(StateCasting.instance.isCasting) - return; + if (StateCastingDispatcher.isCasting()) { + return } if(allowBackground) @@ -2014,12 +2011,7 @@ class VideoDetailView : ConstraintLayout { return; } - val isCasting = if (Settings.instance.casting.experimentalCasting) { - ExpStateCasting.instance.isCasting - } else { - StateCasting.instance.isCasting - } - if (!isCasting) { + if (!StateCastingDispatcher.isCasting()) { setCastEnabled(false); val isLimitedVersion = StatePlatform.instance.getContentClientOrNull(video.url)?.let { @@ -2303,11 +2295,7 @@ class VideoDetailView : ConstraintLayout { } val currentPlaybackRate = (if (_isCasting) { - if (Settings.instance.casting.experimentalCasting) { - ExpStateCasting.instance.activeDevice?.speed - } else { - StateCasting.instance.activeDevice?.speed - } + StateCastingDispatcher.getActiveDeviceSpeed() } else _player.getPlaybackRate()) ?: 1.0 _overlay_quality_selector?.groupItems?.firstOrNull { it is SlideUpMenuButtonList && it.id == "playback_rate" }?.let { (it as SlideUpMenuButtonList).setSelected(currentPlaybackRate.toString()) @@ -2426,18 +2414,12 @@ class VideoDetailView : ConstraintLayout { ?.distinct() ?.toList() ?: listOf() else audioSources?.toList() ?: listOf(); - val canSetSpeed = !_isCasting || if (Settings.instance.casting.experimentalCasting) { - ExpStateCasting.instance.activeDevice?.device?.supportsFeature(DeviceFeature.SET_SPEED) == true - } else { - StateCasting.instance.activeDevice?.canSetSpeed == true - } + val canSetSpeed = !_isCasting || StateCastingDispatcher.canActiveDeviceSetSpeed(); val currentPlaybackRate = if (_isCasting) { - if (Settings.instance.casting.experimentalCasting) { - ExpStateCasting.instance.activeDevice?.speed - } else { - StateCasting.instance.activeDevice?.speed - } - } else _player.getPlaybackRate() + StateCastingDispatcher.getActiveDeviceSpeed() + } else { + _player.getPlaybackRate() + } val qualityPlaybackSpeedTitle = if (canSetSpeed) SlideUpMenuTitle(this.context).apply { setTitle(context.getString(R.string.playback_rate) + " (${String.format("%.2f", currentPlaybackRate)})"); } else null; _overlay_quality_selector = SlideUpMenuOverlay(this.context, _overlay_quality_container, context.getString( R.string.quality), null, true, @@ -2452,11 +2434,7 @@ class VideoDetailView : ConstraintLayout { setButtons(playbackLabels, String.format(Locale.US, format, currentPlaybackRate)); onClick.subscribe { v -> val currentPlaybackSpeed = if (_isCasting) { - if (Settings.instance.casting.experimentalCasting) { - ExpStateCasting.instance.activeDevice?.speed - } else { - StateCasting.instance.activeDevice?.speed - } + StateCastingDispatcher.getActiveDeviceSpeed() } else _player.getPlaybackRate(); var playbackSpeedString = v; val stepSpeed = Settings.instance.playback.getPlaybackSpeedStep(); @@ -2465,26 +2443,10 @@ class VideoDetailView : ConstraintLayout { else if(v == "-") playbackSpeedString = String.format(Locale.US, "%.2f", Math.max(0.1, (currentPlaybackSpeed?.toDouble() ?: 1.0) - stepSpeed)).toString(); val newPlaybackSpeed = playbackSpeedString.toDouble(); - if (_isCasting) { - if (Settings.instance.casting.experimentalCasting) { - val ad = ExpStateCasting.instance.activeDevice ?: return@subscribe - if (!ad.device.supportsFeature(DeviceFeature.SET_SPEED)) { - return@subscribe - } - - qualityPlaybackSpeedTitle?.setTitle(context.getString(R.string.playback_rate) + " (${String.format(Locale.US, "%.2f", newPlaybackSpeed)})"); - ad.device.changeSpeed(newPlaybackSpeed) - setSelected(playbackSpeedString); - } else { - val ad = StateCasting.instance.activeDevice ?: return@subscribe - if (!ad.canSetSpeed) { - return@subscribe - } - - qualityPlaybackSpeedTitle?.setTitle(context.getString(R.string.playback_rate) + " (${String.format(Locale.US, "%.2f", newPlaybackSpeed)})"); - ad.changeSpeed(newPlaybackSpeed) - setSelected(playbackSpeedString); - } + if (_isCasting && StateCastingDispatcher.canActiveDeviceSetSpeed()) { + qualityPlaybackSpeedTitle?.setTitle(context.getString(R.string.playback_rate) + " (${String.format(Locale.US, "%.2f", newPlaybackSpeed)})"); + StateCastingDispatcher.activeDeviceSetSpeed(newPlaybackSpeed) + setSelected(playbackSpeedString); } else { qualityPlaybackSpeedTitle?.setTitle(context.getString(R.string.playback_rate) + " (${String.format(Locale.US, "%.2f", newPlaybackSpeed)})"); _player.setPlaybackRate(playbackSpeedString.toFloat()); @@ -2599,14 +2561,8 @@ class VideoDetailView : ConstraintLayout { //Handlers private fun handlePlay() { Logger.i(TAG, "handlePlay") - if (Settings.instance.casting.experimentalCasting) { - if (!ExpStateCasting.instance.resumeVideo()) { - _player.play() - } - } else { - if (!StateCasting.instance.resumeVideo()) { - _player.play(); - } + if (!StateCastingDispatcher.resumeVideo()) { + _player.play() } //TODO: This was needed because handleLowerVolume was done. @@ -2621,60 +2577,31 @@ class VideoDetailView : ConstraintLayout { private fun handlePause() { Logger.i(TAG, "handlePause") - if (Settings.instance.casting.experimentalCasting) { - if (!ExpStateCasting.instance.pauseVideo()) { - _player.pause() - } - } else { - if (!StateCasting.instance.pauseVideo()) { - _player.pause() - } + if (!StateCastingDispatcher.pauseVideo()) { + _player.pause() } } private fun handleSeek(ms: Long) { Logger.i(TAG, "handleSeek(ms=$ms)") - if (Settings.instance.casting.experimentalCasting) { - if (!ExpStateCasting.instance.videoSeekTo(ms.toDouble() / 1000.0)) { - _player.seekTo(ms) - } - } else { - if (!StateCasting.instance.videoSeekTo(ms.toDouble() / 1000.0)) { - _player.seekTo(ms) - } + if (!StateCastingDispatcher.videoSeekTo(ms.toDouble() / 1000.0)) { + _player.seekTo(ms) } } private fun handleStop() { Logger.i(TAG, "handleStop") - if (Settings.instance.casting.experimentalCasting) { - if (!ExpStateCasting.instance.stopVideo()) { - _player.stop() - } - } else { - if (!StateCasting.instance.stopVideo()) { - _player.stop() - } + if (!StateCastingDispatcher.stopVideo()) { + _player.stop() } } private fun handlePlayChanged(playing: Boolean) { Logger.i(TAG, "handlePlayChanged(playing=$playing)") - if (Settings.instance.casting.experimentalCasting) { - val ad = ExpStateCasting.instance.activeDevice; - if (ad != null) { - _cast.setIsPlaying(playing); - } else { - StatePlayer.instance.updateMediaSession( null); - StatePlayer.instance.updateMediaSessionPlaybackState(_player.exoPlayer?.getPlaybackStateCompat() ?: PlaybackStateCompat.STATE_NONE, _player.exoPlayer?.player?.currentPosition ?: 0); - } + if (StateCastingDispatcher.isCasting()) { + _cast.setIsPlaying(playing); } else { - val ad = StateCasting.instance.activeDevice; - if (ad != null) { - _cast.setIsPlaying(playing); - } else { - StatePlayer.instance.updateMediaSession( null); - StatePlayer.instance.updateMediaSessionPlaybackState(_player.exoPlayer?.getPlaybackStateCompat() ?: PlaybackStateCompat.STATE_NONE, _player.exoPlayer?.player?.currentPosition ?: 0); - } + StatePlayer.instance.updateMediaSession( null); + StatePlayer.instance.updateMediaSessionPlaybackState(_player.exoPlayer?.getPlaybackStateCompat() ?: PlaybackStateCompat.STATE_NONE, _player.exoPlayer?.player?.currentPosition ?: 0); } if(playing) { @@ -2712,26 +2639,20 @@ class VideoDetailView : ConstraintLayout { fragment.lifecycleScope.launch(Dispatchers.Main) { try { - if (Settings.instance.casting.experimentalCasting) { - val d = ExpStateCasting.instance.activeDevice; - if (d != null && d.connectionState == com.futo.platformplayer.experimental_casting.CastConnectionState.CONNECTED) - castIfAvailable( - context.contentResolver, - video, - videoSource, - _lastAudioSource, - _lastSubtitleSource, - (d.expectedCurrentTime * 1000.0).toLong(), - d.speed - ); - else if(!_player.swapSources(videoSource, _lastAudioSource, true, true, true)) - _player.hideControls(false); //TODO: Disable player? - } else { - val d = StateCasting.instance.activeDevice; - if (d != null && d.connectionState == CastConnectionState.CONNECTED) - castIfAvailable(context.contentResolver, video, videoSource, _lastAudioSource, _lastSubtitleSource, (d.expectedCurrentTime * 1000.0).toLong(), d.speed); - else if(!_player.swapSources(videoSource, _lastAudioSource, true, true, true)) - _player.hideControls(false); //TODO: Disable player? + if (StateCastingDispatcher.isConnected()) { + val expectedCurrentTime = StateCastingDispatcher.getExpectedCurrentTime() ?: 0.0 + val speed = StateCastingDispatcher.getActiveDeviceSpeed() ?: 1.0 + castIfAvailable( + context.contentResolver, + video, + videoSource, + _lastAudioSource, + _lastSubtitleSource, + (expectedCurrentTime * 1000.0).toLong(), + speed + ) + } else if(!_player.swapSources(videoSource, _lastAudioSource, true, true, true)) { + _player.hideControls(false); //TODO: Disable player? } } catch (e: Throwable) { Logger.e(TAG, "handleSelectVideoTrack failed", e) @@ -2749,34 +2670,20 @@ class VideoDetailView : ConstraintLayout { fragment.lifecycleScope.launch(Dispatchers.Main) { try { - if (Settings.instance.casting.experimentalCasting) { - val d = ExpStateCasting.instance.activeDevice; - if (d != null && d.connectionState == com.futo.platformplayer.experimental_casting.CastConnectionState.CONNECTED) - castIfAvailable( - context.contentResolver, - video, - _lastVideoSource, - audioSource, - _lastSubtitleSource, - (d.expectedCurrentTime * 1000.0).toLong(), - d.speed - ) - else if (!_player.swapSources(_lastVideoSource, audioSource, true, true, true)) - _player.hideControls(false); //TODO: Disable player? - } else { - val d = StateCasting.instance.activeDevice; - if (d != null && d.connectionState == CastConnectionState.CONNECTED) - castIfAvailable( - context.contentResolver, - video, - _lastVideoSource, - audioSource, - _lastSubtitleSource, - (d.expectedCurrentTime * 1000.0).toLong(), - d.speed - ) - else if (!_player.swapSources(_lastVideoSource, audioSource, true, true, true)) - _player.hideControls(false); //TODO: Disable player? + if (StateCastingDispatcher.isConnected()) { + val expectedCurrentTime = StateCastingDispatcher.getExpectedCurrentTime() ?: 0.0 + val speed = StateCastingDispatcher.getActiveDeviceSpeed() ?: 1.0 + castIfAvailable( + context.contentResolver, + video, + _lastVideoSource, + audioSource, + _lastSubtitleSource, + (expectedCurrentTime * 1000.0).toLong(), + speed + ) + } else if (!_player.swapSources(_lastVideoSource, audioSource, true, true, true)) { + _player.hideControls(false); //TODO: Disable player? } } catch (e: Throwable) { Logger.e(TAG, "handleSelectAudioTrack failed", e) @@ -2795,36 +2702,20 @@ class VideoDetailView : ConstraintLayout { fragment.lifecycleScope.launch(Dispatchers.Main) { try { - if (Settings.instance.casting.experimentalCasting) { - val d = ExpStateCasting.instance.activeDevice; - if (d != null && d.connectionState == com.futo.platformplayer.experimental_casting.CastConnectionState.CONNECTED) - castIfAvailable( - context.contentResolver, - video, - _lastVideoSource, - _lastAudioSource, - toSet, - (d.expectedCurrentTime * 1000.0).toLong(), - d.speed - ); - else { - _player.swapSubtitles(toSet); - } + if (StateCastingDispatcher.isConnected()) { + val expectedCurrentTime = StateCastingDispatcher.getExpectedCurrentTime() ?: 0.0 + val speed = StateCastingDispatcher.getActiveDeviceSpeed() ?: 1.0 + castIfAvailable( + context.contentResolver, + video, + _lastVideoSource, + _lastAudioSource, + toSet, + (expectedCurrentTime * 1000.0).toLong(), + speed + ) } else { - val d = StateCasting.instance.activeDevice; - if (d != null && d.connectionState == CastConnectionState.CONNECTED) - castIfAvailable( - context.contentResolver, - video, - _lastVideoSource, - _lastAudioSource, - toSet, - (d.expectedCurrentTime * 1000.0).toLong(), - d.speed - ); - else { - _player.swapSubtitles(toSet); - } + _player.swapSubtitles(toSet); } } catch (e: Throwable) { Logger.e(TAG, "handleSelectSubtitleTrack failed", e) 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 c64ccc0e..f66995d7 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 @@ -28,6 +28,7 @@ import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.experimental_casting.ExpStateCasting +import com.futo.platformplayer.experimental_casting.StateCastingDispatcher import com.futo.platformplayer.formatDuration import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateHistory @@ -142,13 +143,8 @@ class CastView : ConstraintLayout { } _gestureControlView.onSeek.subscribe { - if (Settings.instance.casting.experimentalCasting) { - val d = ExpStateCasting.instance.activeDevice ?: return@subscribe; - ExpStateCasting.instance.videoSeekTo(d.expectedCurrentTime + it / 1000); - } else { - val d = StateCasting.instance.activeDevice ?: return@subscribe; - StateCasting.instance.videoSeekTo(d.expectedCurrentTime + it / 1000); - } + val expectedCurrentTime = StateCastingDispatcher.getExpectedCurrentTime() ?: return@subscribe + StateCastingDispatcher.videoSeekTo(expectedCurrentTime + it / 1000) }; _buttonLoop.setOnClickListener { @@ -159,45 +155,25 @@ class CastView : ConstraintLayout { _timeBar.addListener(object : TimeBar.OnScrubListener { override fun onScrubStart(timeBar: TimeBar, position: Long) { - if (Settings.instance.casting.experimentalCasting) { - ExpStateCasting.instance.videoSeekTo(position.toDouble()); - } else { - StateCasting.instance.videoSeekTo(position.toDouble()); - } + StateCastingDispatcher.videoSeekTo(position.toDouble()) } override fun onScrubMove(timeBar: TimeBar, position: Long) { - if (Settings.instance.casting.experimentalCasting) { - ExpStateCasting.instance.videoSeekTo(position.toDouble()); - } else { - StateCasting.instance.videoSeekTo(position.toDouble()); - } + StateCastingDispatcher.videoSeekTo(position.toDouble()) } override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) { - if (Settings.instance.casting.experimentalCasting) { - ExpStateCasting.instance.videoSeekTo(position.toDouble()); - } else { - StateCasting.instance.videoSeekTo(position.toDouble()); - } + StateCastingDispatcher.videoSeekTo(position.toDouble()) } }); _buttonMinimize.setOnClickListener { onMinimizeClick.emit(); }; _buttonSettings.setOnClickListener { onSettingsClick.emit(); }; _buttonPlay.setOnClickListener { - if (Settings.instance.casting.experimentalCasting) { - ExpStateCasting.instance.resumeVideo() - } else { - StateCasting.instance.resumeVideo() - } + StateCastingDispatcher.resumeVideo() } _buttonPause.setOnClickListener { - if (Settings.instance.casting.experimentalCasting) { - ExpStateCasting.instance.pauseVideo() - } else { - StateCasting.instance.pauseVideo() - } + StateCastingDispatcher.pauseVideo() } if (!isInEditMode) { @@ -311,11 +287,7 @@ class CastView : ConstraintLayout { _buttonPlay.visibility = View.VISIBLE; } - val position = if (Settings.instance.casting.experimentalCasting) { - ExpStateCasting.instance.activeDevice?.expectedCurrentTime?.times(1000.0)?.toLong() - } else { - StateCasting.instance.activeDevice?.expectedCurrentTime?.times(1000.0)?.toLong(); - } + val position = StateCastingDispatcher.getExpectedCurrentTime()?.times(1000.0)?.toLong() if(StatePlayer.instance.hasMediaSession()) { StatePlayer.instance.updateMediaSession(null); StatePlayer.instance.updateMediaSessionPlaybackState(getPlaybackStateCompat(), (position ?: 0)); @@ -379,20 +351,12 @@ class CastView : ConstraintLayout { } private fun getPlaybackStateCompat(): Int { - if (Settings.instance.casting.experimentalCasting) { - val d = ExpStateCasting.instance.activeDevice ?: return PlaybackState.STATE_NONE; - - return when(d.isPlaying) { - true -> PlaybackStateCompat.STATE_PLAYING; - else -> PlaybackStateCompat.STATE_PAUSED; - } - } else { - val d = StateCasting.instance.activeDevice ?: return PlaybackState.STATE_NONE; - - return when(d.isPlaying) { - true -> PlaybackStateCompat.STATE_PLAYING; - else -> PlaybackStateCompat.STATE_PAUSED; - } + if (!StateCastingDispatcher.isConnected()) { + return PlaybackState.STATE_NONE + } + return when(StateCastingDispatcher.isPlaying()) { + true -> PlaybackStateCompat.STATE_PLAYING; + else -> PlaybackStateCompat.STATE_PAUSED; } } From e78e3dbbedc3994af604de7df05fb99a5eb9dc45 Mon Sep 17 00:00:00 2001 From: Marcus Hanestad Date: Fri, 29 Aug 2025 08:49:57 +0200 Subject: [PATCH 11/30] casting: remove invalid imports --- .../experimental_casting/StateCastingDispatcher.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/experimental_casting/StateCastingDispatcher.kt b/app/src/main/java/com/futo/platformplayer/experimental_casting/StateCastingDispatcher.kt index dc73056e..1adde666 100644 --- a/app/src/main/java/com/futo/platformplayer/experimental_casting/StateCastingDispatcher.kt +++ b/app/src/main/java/com/futo/platformplayer/experimental_casting/StateCastingDispatcher.kt @@ -3,8 +3,6 @@ package com.futo.platformplayer.experimental_casting import com.futo.platformplayer.Settings import com.futo.platformplayer.casting.CastConnectionState import com.futo.platformplayer.casting.StateCasting -import com.futo.platformplayer.dialogs.ConnectedCastingDialog.Companion.TAG -import com.futo.platformplayer.logging.Logger import org.fcast.sender_sdk.DeviceFeature class StateCastingDispatcher { From 99813da435ddd8f6b385a916d7b794667a7fe0a8 Mon Sep 17 00:00:00 2001 From: Marcus Hanestad Date: Mon, 1 Sep 2025 10:38:34 +0200 Subject: [PATCH 12/30] casting: set volume when loading media --- .../platformplayer/experimental_casting/CastingDevice.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/experimental_casting/CastingDevice.kt b/app/src/main/java/com/futo/platformplayer/experimental_casting/CastingDevice.kt index 151a968f..fd0be55f 100644 --- a/app/src/main/java/com/futo/platformplayer/experimental_casting/CastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/experimental_casting/CastingDevice.kt @@ -78,7 +78,7 @@ class CastingDeviceHandle { var usedRemoteAddress: InetAddress? = null var localAddress: InetAddress? = null var connectionState = CastConnectionState.DISCONNECTED - var volume: Double = 0.0 + var volume: Double = 1.0 var duration: Double = 0.0 var lastTimeChangeTime_ms: Long = 0 var time: Double = 0.0 @@ -119,6 +119,7 @@ class CastingDeviceHandle { url = contentId, resumePosition = resumePosition, speed = speed, + volume = volume, metadata = metadata )) } catch (e: Throwable) { @@ -137,7 +138,8 @@ class CastingDeviceHandle { contentType =contentType, content = content, resumePosition = resumePosition, - speed = speed + speed = speed, + volume = volume )) } catch (e: Throwable) { Logger.e(TAG, "Failed to load content: $e") From 1470d5ac7440e0ce78a4523c7d1ecc52d58f342c Mon Sep 17 00:00:00 2001 From: Marcus Hanestad Date: Tue, 2 Sep 2025 10:46:25 +0200 Subject: [PATCH 13/30] casting: update to SDK v0.3.0 --- app/build.gradle | 2 +- .../experimental_casting/StateCasting.kt | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 8d729051..2a861322 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -233,7 +233,7 @@ dependencies { androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' //Rust casting SDK - implementation('org.futo.gitlab.videostreaming.fcast-sdk-jitpack:sender-sdk-minimal:0.2.1') { + implementation('org.futo.gitlab.videostreaming.fcast-sdk-jitpack:sender-sdk-minimal:0.3.0') { // Polycentricandroid includes this exclude group: 'net.java.dev.jna' } diff --git a/app/src/main/java/com/futo/platformplayer/experimental_casting/StateCasting.kt b/app/src/main/java/com/futo/platformplayer/experimental_casting/StateCasting.kt index d2020370..cc4821d1 100644 --- a/app/src/main/java/com/futo/platformplayer/experimental_casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/experimental_casting/StateCasting.kt @@ -306,12 +306,12 @@ class ExpStateCasting { ); } - DeviceConnectionState.Connecting -> { - device.connectionState = CastConnectionState.CONNECTING - Logger.i(TAG, "Casting connecting to [${device.device.name()}]"); - UIDialogs.toast(it, "Connecting to device...") + DeviceConnectionState.Connecting, DeviceConnectionState.Reconnecting -> { synchronized(_castingDialogLock) { if (_currentDialog == null) { + device.connectionState = CastConnectionState.CONNECTING + Logger.i(TAG, "Casting connecting to [${device.device.name()}]"); + UIDialogs.toast(it, "Connecting to device...") _currentDialog = UIDialogs.showDialog( context, R.drawable.ic_loader_animated, @@ -393,7 +393,9 @@ class ExpStateCasting { "Grayjay Android", "${BuildConfig.VERSION_NAME}-${BuildConfig.FLAVOR}", "${Build.MANUFACTURER} ${Build.MODEL}" - ), device.eventHandler + ), + device.eventHandler, + 1000.toULong() ) Logger.i(TAG, "Requested manager to start device") } catch (e: Throwable) { From e2a5665516792fbda07747e00370d84bffa671d0 Mon Sep 17 00:00:00 2001 From: Marcus Hanestad Date: Wed, 3 Sep 2025 18:06:17 +0200 Subject: [PATCH 14/30] casting: refactor SDK integration --- .../java/com/futo/platformplayer/UIDialogs.kt | 70 +- .../platformplayer/activities/MainActivity.kt | 14 +- .../casting/AirPlayCastingDevice.kt | 2 +- .../platformplayer/casting/CastingDevice.kt | 189 +- .../casting/ChomecastCastingDevice.kt | 2 +- .../casting/ExpCastingDevice.kt | 284 +++ .../platformplayer/casting/ExpStateCasting.kt | 174 ++ .../casting/FCastCastingDevice.kt | 5 +- .../casting/OldCastingDevice.kt | 243 +++ .../platformplayer/casting/OldStateCasting.kt | 397 ++++ .../platformplayer/casting/StateCasting.kt | 1676 ++++++++------- .../dialogs/CastingAddDialog.kt | 12 +- .../dialogs/ConnectCastingDialog.kt | 228 +-- .../dialogs/ConnectedCastingDialog.kt | 256 +-- .../experimental_casting/CastingDevice.kt | 181 -- .../experimental_casting/StateCasting.kt | 1813 ----------------- .../StateCastingDispatcher.kt | 132 -- .../mainactivity/main/VideoDetailFragment.kt | 2 +- .../mainactivity/main/VideoDetailView.kt | 199 +- .../models/CastingDeviceInfo.kt | 35 +- .../futo/platformplayer/states/StateApp.kt | 8 +- .../views/adapters/DeviceAdapter.kt | 26 +- .../views/adapters/DeviceViewHolder.kt | 227 +-- .../views/casting/CastButton.kt | 64 +- .../platformplayer/views/casting/CastView.kt | 106 +- 25 files changed, 2508 insertions(+), 3837 deletions(-) create mode 100644 app/src/main/java/com/futo/platformplayer/casting/ExpCastingDevice.kt create mode 100644 app/src/main/java/com/futo/platformplayer/casting/ExpStateCasting.kt create mode 100644 app/src/main/java/com/futo/platformplayer/casting/OldCastingDevice.kt create mode 100644 app/src/main/java/com/futo/platformplayer/casting/OldStateCasting.kt delete mode 100644 app/src/main/java/com/futo/platformplayer/experimental_casting/CastingDevice.kt delete mode 100644 app/src/main/java/com/futo/platformplayer/experimental_casting/StateCasting.kt delete mode 100644 app/src/main/java/com/futo/platformplayer/experimental_casting/StateCastingDispatcher.kt diff --git a/app/src/main/java/com/futo/platformplayer/UIDialogs.kt b/app/src/main/java/com/futo/platformplayer/UIDialogs.kt index babac1ef..e88ff6a5 100644 --- a/app/src/main/java/com/futo/platformplayer/UIDialogs.kt +++ b/app/src/main/java/com/futo/platformplayer/UIDialogs.kt @@ -7,7 +7,6 @@ import android.content.Intent import android.graphics.Color import android.graphics.drawable.Animatable import android.net.Uri -import android.text.Layout import android.text.method.ScrollingMovementMethod import android.util.TypedValue import android.view.Gravity @@ -22,6 +21,7 @@ import androidx.core.content.ContextCompat import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.api.media.models.comments.IPlatformComment import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.casting.OldStateCasting import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.dialogs.AutoUpdateDialog import com.futo.platformplayer.dialogs.AutomaticBackupDialog @@ -38,7 +38,6 @@ import com.futo.platformplayer.dialogs.MigrateDialog import com.futo.platformplayer.dialogs.PluginUpdateDialog import com.futo.platformplayer.dialogs.ProgressDialog import com.futo.platformplayer.engine.exceptions.PluginException -import com.futo.platformplayer.experimental_casting.ExpStateCasting import com.futo.platformplayer.fragment.mainactivity.main.MainFragment import com.futo.platformplayer.fragment.mainactivity.main.SourceDetailFragment import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment @@ -438,56 +437,29 @@ class UIDialogs { fun showCastingDialog(context: Context, ownerActivity: Activity? = null) { - if (Settings.instance.casting.experimentalCasting) { - val d = ExpStateCasting.instance.activeDevice; - if (d != null) { - val dialog = ConnectedCastingDialog(context); - if (context is Activity) { - dialog.setOwnerActivity(context) - } - registerDialogOpened(dialog); - ownerActivity?.let { dialog.setOwnerActivity(it) } - dialog.setOnDismissListener { registerDialogClosed(dialog) }; - dialog.show(); - } else { - val dialog = ConnectCastingDialog(context); - if (context is Activity) { - dialog.setOwnerActivity(context) - } - registerDialogOpened(dialog); - val c = context - if (c is Activity) { - dialog.setOwnerActivity(c); - } - ownerActivity?.let { dialog.setOwnerActivity(it) } - dialog.setOnDismissListener { registerDialogClosed(dialog) }; - dialog.show(); + val d = StateCasting.instance.activeDevice + if (d != null) { + val dialog = ConnectedCastingDialog(context); + if (context is Activity) { + dialog.setOwnerActivity(context) } + registerDialogOpened(dialog); + ownerActivity?.let { dialog.setOwnerActivity(it) } + dialog.setOnDismissListener { registerDialogClosed(dialog) }; + dialog.show(); } else { - val d = StateCasting.instance.activeDevice; - if (d != null) { - val dialog = ConnectedCastingDialog(context); - if (context is Activity) { - dialog.setOwnerActivity(context) - } - registerDialogOpened(dialog); - ownerActivity?.let { dialog.setOwnerActivity(it) } - dialog.setOnDismissListener { registerDialogClosed(dialog) }; - dialog.show(); - } else { - val dialog = ConnectCastingDialog(context); - if (context is Activity) { - dialog.setOwnerActivity(context) - } - registerDialogOpened(dialog); - val c = context - if (c is Activity) { - dialog.setOwnerActivity(c); - } - ownerActivity?.let { dialog.setOwnerActivity(it) } - dialog.setOnDismissListener { registerDialogClosed(dialog) }; - dialog.show(); + val dialog = ConnectCastingDialog(context); + if (context is Activity) { + dialog.setOwnerActivity(context) } + registerDialogOpened(dialog); + val c = context + if (c is Activity) { + dialog.setOwnerActivity(c); + } + ownerActivity?.let { dialog.setOwnerActivity(it) } + dialog.setOnDismissListener { registerDialogClosed(dialog) }; + dialog.show(); } } diff --git a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt index d664ae9f..9993d4c3 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt @@ -42,7 +42,6 @@ import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.dp -import com.futo.platformplayer.experimental_casting.ExpStateCasting import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment import com.futo.platformplayer.fragment.mainactivity.main.ArticleDetailFragment import com.futo.platformplayer.fragment.mainactivity.main.BrowserFragment @@ -118,7 +117,6 @@ import java.util.LinkedList import java.util.UUID import java.util.concurrent.ConcurrentLinkedQueue - class MainActivity : AppCompatActivity, IWithResultLauncher { //TODO: Move to dimensions @@ -508,11 +506,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { handleIntent(intent); if (Settings.instance.casting.enabled) { - if (Settings.instance.casting.experimentalCasting) { - ExpStateCasting.instance.start(this) - } else { - StateCasting.instance.start(this) - } + StateCasting.instance.start(this) } StatePlatform.instance.onDevSourceChanged.subscribe { @@ -1051,11 +1045,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { Logger.i(TAG, "handleFCast"); try { - if (Settings.instance.casting.experimentalCasting) { - ExpStateCasting.instance.handleUrl(this, url) - } else { - StateCasting.instance.handleUrl(this, url) - } + StateCasting.instance.handleUrl(url) return true; } catch (e: Throwable) { Log.e(TAG, "Failed to parse FCast URL '${url}'.", e) diff --git a/app/src/main/java/com/futo/platformplayer/casting/AirPlayCastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/AirPlayCastingDevice.kt index 0cc1bebc..b89071f8 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/AirPlayCastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/AirPlayCastingDevice.kt @@ -15,7 +15,7 @@ import kotlinx.coroutines.launch import java.net.InetAddress import java.util.UUID -class AirPlayCastingDevice : CastingDevice { +class AirPlayCastingDevice : OldCastingDevice { //See for more info: https://nto.github.io/AirPlay override val protocol: CastProtocolType get() = CastProtocolType.AIRPLAY; diff --git a/app/src/main/java/com/futo/platformplayer/casting/CastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/CastingDevice.kt index 69f74747..cb6c9ab0 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/CastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/CastingDevice.kt @@ -2,147 +2,78 @@ package com.futo.platformplayer.casting import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.models.CastingDeviceInfo -import kotlinx.serialization.KSerializer -import kotlinx.serialization.Serializable -import kotlinx.serialization.descriptors.PrimitiveKind -import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder +import org.fcast.sender_sdk.Metadata import java.net.InetAddress -enum class CastConnectionState { - DISCONNECTED, - CONNECTING, - CONNECTED -} - -@Serializable(with = CastProtocolType.CastProtocolTypeSerializer::class) -enum class CastProtocolType { - CHROMECAST, - AIRPLAY, - FCAST; - - object CastProtocolTypeSerializer : KSerializer { - override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("CastProtocolType", PrimitiveKind.STRING) - - override fun serialize(encoder: Encoder, value: CastProtocolType) { - encoder.encodeString(value.name) - } - - override fun deserialize(decoder: Decoder): CastProtocolType { - val name = decoder.decodeString() - return when (name) { - "FASTCAST" -> FCAST // Handle the renamed case - else -> CastProtocolType.valueOf(name) - } - } - } -} - abstract class CastingDevice { - abstract val protocol: CastProtocolType; - abstract val isReady: Boolean; - abstract var usedRemoteAddress: InetAddress?; - abstract var localAddress: InetAddress?; - abstract val canSetVolume: Boolean; - abstract val canSetSpeed: Boolean; + abstract val isReady: Boolean + abstract val usedRemoteAddress: InetAddress? + abstract val localAddress: InetAddress? + abstract val name: String? + abstract val onConnectionStateChanged: Event1 + abstract val onPlayChanged: Event1 + abstract val onTimeChanged: Event1 + abstract val onDurationChanged: Event1 + abstract val onVolumeChanged: Event1 + abstract val onSpeedChanged: Event1 + abstract var connectionState: CastConnectionState + abstract val protocolType: CastProtocolType + abstract var isPlaying: Boolean + abstract val expectedCurrentTime: Double + abstract var speed: Double + abstract var time: Double + abstract var duration: Double + abstract var volume: Double + abstract fun canSetVolume(): Boolean + abstract fun canSetSpeed(): Boolean - var name: String? = null; - var isPlaying: Boolean = false - set(value) { - val changed = value != field; - field = value; - if (changed) { - onPlayChanged.emit(value); - } - }; + @Throws + abstract fun resumePlayback() - private var lastTimeChangeTime_ms: Long = 0 - var time: Double = 0.0 - private set + @Throws + abstract fun pausePlayback() - protected fun setTime(value: Double, changeTime_ms: Long = System.currentTimeMillis()) { - if (changeTime_ms > lastTimeChangeTime_ms && value != time) { - time = value - lastTimeChangeTime_ms = changeTime_ms - onTimeChanged.emit(value) - } - } + @Throws + abstract fun stopPlayback() - private var lastDurationChangeTime_ms: Long = 0 - var duration: Double = 0.0 - private set + @Throws + abstract fun seekTo(timeSeconds: Double) - protected fun setDuration(value: Double, changeTime_ms: Long = System.currentTimeMillis()) { - if (changeTime_ms > lastDurationChangeTime_ms && value != duration) { - duration = value - lastDurationChangeTime_ms = changeTime_ms - onDurationChanged.emit(value) - } - } + @Throws + abstract fun changeVolume(timeSeconds: Double) - private var lastVolumeChangeTime_ms: Long = 0 - var volume: Double = 1.0 - private set + @Throws + abstract fun changeSpeed(speed: Double) - protected fun setVolume(value: Double, changeTime_ms: Long = System.currentTimeMillis()) { - if (changeTime_ms > lastVolumeChangeTime_ms && value != volume) { - volume = value - lastVolumeChangeTime_ms = changeTime_ms - onVolumeChanged.emit(value) - } - } + @Throws + abstract fun connect() - private var lastSpeedChangeTime_ms: Long = 0 - var speed: Double = 1.0 - private set + @Throws + abstract fun disconnect() + abstract fun getDeviceInfo(): CastingDeviceInfo + abstract fun getAddresses(): List - protected fun setSpeed(value: Double, changeTime_ms: Long = System.currentTimeMillis()) { - if (changeTime_ms > lastSpeedChangeTime_ms && value != speed) { - speed = value - lastSpeedChangeTime_ms = changeTime_ms - onSpeedChanged.emit(value) - } - } + @Throws + abstract fun loadVideo( + streamType: String, + contentType: String, + contentId: String, + resumePosition: Double, + duration: Double, + speed: Double?, + metadata: Metadata? + ) - val expectedCurrentTime: Double - get() { - val diff = if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0; - return time + diff; - }; - var connectionState: CastConnectionState = CastConnectionState.DISCONNECTED - set(value) { - val changed = value != field; - field = value; + @Throws + abstract fun loadContent( + contentType: String, + content: String, + resumePosition: Double, + duration: Double, + speed: Double?, + metadata: Metadata? + ) - if (changed) { - onConnectionStateChanged.emit(value); - } - }; + abstract fun ensureThreadStarted() +} - var onConnectionStateChanged = Event1(); - var onPlayChanged = Event1(); - var onTimeChanged = Event1(); - var onDurationChanged = Event1(); - var onVolumeChanged = Event1(); - var onSpeedChanged = Event1(); - - abstract fun stopCasting(); - - abstract fun seekVideo(timeSeconds: Double); - abstract fun stopVideo(); - abstract fun pauseVideo(); - abstract fun resumeVideo(); - abstract fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double, speed: Double?); - abstract fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?); - open fun changeVolume(volume: Double) { throw NotImplementedError() } - open fun changeSpeed(speed: Double) { throw NotImplementedError() } - - abstract fun start(); - abstract fun stop(); - - abstract fun getDeviceInfo(): CastingDeviceInfo; - - abstract fun getAddresses(): List; -} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt index f6582055..d85a7607 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt @@ -27,7 +27,7 @@ import javax.net.ssl.SSLSocket import javax.net.ssl.TrustManager import javax.net.ssl.X509TrustManager -class ChromecastCastingDevice : CastingDevice { +class ChromecastCastingDevice : OldCastingDevice { //See for more info: https://developers.google.com/cast/docs/media/messages override val protocol: CastProtocolType get() = CastProtocolType.CHROMECAST; diff --git a/app/src/main/java/com/futo/platformplayer/casting/ExpCastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/ExpCastingDevice.kt new file mode 100644 index 00000000..dd094a4d --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/casting/ExpCastingDevice.kt @@ -0,0 +1,284 @@ +package com.futo.platformplayer.casting + +import android.os.Build +import com.futo.platformplayer.BuildConfig +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.models.CastingDeviceInfo +import org.fcast.sender_sdk.ApplicationInfo +import org.fcast.sender_sdk.GenericKeyEvent +import org.fcast.sender_sdk.GenericMediaEvent +import org.fcast.sender_sdk.PlaybackState +import org.fcast.sender_sdk.Source +import java.net.InetAddress +import org.fcast.sender_sdk.CastingDevice as RsCastingDevice; +import org.fcast.sender_sdk.DeviceEventHandler as RsDeviceEventHandler; +import org.fcast.sender_sdk.DeviceConnectionState +import org.fcast.sender_sdk.DeviceFeature +import org.fcast.sender_sdk.IpAddr +import org.fcast.sender_sdk.LoadRequest +import org.fcast.sender_sdk.Metadata +import org.fcast.sender_sdk.ProtocolType +import org.fcast.sender_sdk.urlFormatIpAddr +import java.net.Inet4Address +import java.net.Inet6Address + +private fun ipAddrToInetAddress(addr: IpAddr): InetAddress = when (addr) { + is IpAddr.V4 -> Inet4Address.getByAddress( + byteArrayOf( + addr.o1.toByte(), + addr.o2.toByte(), + addr.o3.toByte(), + addr.o4.toByte() + ) + ) + + is IpAddr.V6 -> Inet6Address.getByAddress( + byteArrayOf( + addr.o1.toByte(), + addr.o2.toByte(), + addr.o3.toByte(), + addr.o4.toByte(), + addr.o5.toByte(), + addr.o6.toByte(), + addr.o7.toByte(), + addr.o8.toByte(), + addr.o9.toByte(), + addr.o10.toByte(), + addr.o11.toByte(), + addr.o12.toByte(), + addr.o13.toByte(), + addr.o14.toByte(), + addr.o15.toByte(), + addr.o16.toByte() + ) + ) +} + +class ExpCastingDevice(val device: RsCastingDevice) : CastingDevice() { + class EventHandler : RsDeviceEventHandler { + var onConnectionStateChanged = Event1(); + var onPlayChanged = Event1() + var onTimeChanged = Event1() + var onDurationChanged = Event1() + var onVolumeChanged = Event1() + var onSpeedChanged = Event1() + + override fun connectionStateChanged(state: DeviceConnectionState) { + onConnectionStateChanged.emit(state) + } + + override fun volumeChanged(volume: Double) { + onVolumeChanged.emit(volume) + } + + override fun timeChanged(time: Double) { + onTimeChanged.emit(time) + } + + override fun playbackStateChanged(state: PlaybackState) { + onPlayChanged.emit(state == PlaybackState.PLAYING) + } + + override fun durationChanged(duration: Double) { + onDurationChanged.emit(duration) + } + + override fun speedChanged(speed: Double) { + onSpeedChanged.emit(speed) + } + + override fun sourceChanged(source: Source) { + // TODO + } + + override fun keyEvent(event: GenericKeyEvent) { + // Unreachable + } + + override fun mediaEvent(event: GenericMediaEvent) { + // Unreachable + } + + override fun playbackError(message: String) { + Logger.e(TAG, "Playback error: $message") + } + } + + val eventHandler = EventHandler() + override val isReady: Boolean + get() = device.isReady() + override val name: String + get() = device.name() + override var usedRemoteAddress: InetAddress? = null + override var localAddress: InetAddress? = null + override fun canSetVolume(): Boolean = device.supportsFeature(DeviceFeature.SET_VOLUME) + override fun canSetSpeed(): Boolean = device.supportsFeature(DeviceFeature.SET_SPEED) + + override val onConnectionStateChanged = + Event1() + override val onPlayChanged: Event1 + get() = eventHandler.onPlayChanged + override val onTimeChanged: Event1 + get() = eventHandler.onTimeChanged + override val onDurationChanged: Event1 + get() = eventHandler.onDurationChanged + override val onVolumeChanged: Event1 + get() = eventHandler.onVolumeChanged + override val onSpeedChanged: Event1 + get() = eventHandler.onSpeedChanged + + override fun resumePlayback() = try { + device.resumePlayback() + } catch (_: Throwable) { + } + + override fun pausePlayback() = try { + device.pausePlayback() + } catch (_: Throwable) { + } + + override fun stopPlayback() = try { + device.stopPlayback() + } catch (_: Throwable) { + } + + override fun seekTo(timeSeconds: Double) = try { + device.seek(timeSeconds) + } catch (_: Throwable) { + } + + override fun changeVolume(timeSeconds: Double) = device.changeVolume(timeSeconds) + + override fun changeSpeed(speed: Double) = device.changeSpeed(speed) + + override fun connect() = device.connect( + ApplicationInfo( + "Grayjay Android", + "${BuildConfig.VERSION_NAME}-${BuildConfig.FLAVOR}", + "${Build.MANUFACTURER} ${Build.MODEL}" + ), + eventHandler, + 1000.toULong() + ) + + override fun disconnect() = device.disconnect() + + override fun getDeviceInfo(): CastingDeviceInfo { + val info = device.getDeviceInfo() + return CastingDeviceInfo( + info.name, + when (info.protocol) { + ProtocolType.CHROMECAST -> CastProtocolType.CHROMECAST + ProtocolType.F_CAST -> CastProtocolType.FCAST + }, + addresses = info.addresses.map { urlFormatIpAddr(it) }.toTypedArray(), + port = info.port.toInt(), + ) + } + + override fun getAddresses(): List = device.getAddresses().map { + ipAddrToInetAddress(it) + } + + override fun loadVideo( + streamType: String, + contentType: String, + contentId: String, + resumePosition: Double, + duration: Double, + speed: Double?, + metadata: Metadata? + ) = try { + device.load( + LoadRequest.Video( + contentType = contentType, + url = contentId, + resumePosition = resumePosition, + speed = speed, + volume = volume, + metadata = metadata + ) + ) + } catch (_: Throwable) { + } + + override fun loadContent( + contentType: String, + content: String, + resumePosition: Double, + duration: Double, + speed: Double?, + metadata: Metadata? + ) = try { + device.load( + LoadRequest.Content( + contentType = contentType, + content = content, + resumePosition = resumePosition, + speed = speed, + volume = volume, + metadata = metadata, + ) + ) + } catch (_: Throwable) { + } + + override var connectionState = CastConnectionState.DISCONNECTED + override val protocolType: CastProtocolType + get() = when (device.castingProtocol()) { + ProtocolType.CHROMECAST -> CastProtocolType.CHROMECAST + ProtocolType.F_CAST -> CastProtocolType.FCAST + } + override var volume: Double = 1.0 + override var duration: Double = 0.0 + private var lastTimeChangeTime_ms: Long = 0 + override var time: Double = 0.0 + override var speed: Double = 0.0 + override var isPlaying: Boolean = false + + override val expectedCurrentTime: Double + get() { + val diff = + if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0; + return time + diff + } + + init { + eventHandler.onConnectionStateChanged.subscribe { newState -> + when (newState) { + is DeviceConnectionState.Connected -> { + usedRemoteAddress = ipAddrToInetAddress(newState.usedRemoteAddr) + localAddress = ipAddrToInetAddress(newState.localAddr) + connectionState = CastConnectionState.CONNECTED + onConnectionStateChanged.emit(CastConnectionState.CONNECTED) + } + + DeviceConnectionState.Connecting, DeviceConnectionState.Reconnecting -> { + connectionState = CastConnectionState.CONNECTING + onConnectionStateChanged.emit(CastConnectionState.CONNECTING) + } + + DeviceConnectionState.Disconnected -> { + connectionState = CastConnectionState.CONNECTING + onConnectionStateChanged.emit(CastConnectionState.DISCONNECTED) + } + } + + if (newState == DeviceConnectionState.Disconnected) { + try { + Logger.i(TAG, "Stopping device") + device.disconnect() + } catch (e: Throwable) { + Logger.e(TAG, "Failed to stop device: $e") + } + } + } + } + + override fun ensureThreadStarted() {} + + companion object { + private val TAG = "ExperimentalCastingDevice" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/casting/ExpStateCasting.kt b/app/src/main/java/com/futo/platformplayer/casting/ExpStateCasting.kt new file mode 100644 index 00000000..fd4c75ae --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/casting/ExpStateCasting.kt @@ -0,0 +1,174 @@ +package com.futo.platformplayer.casting + +import android.content.Context +import android.util.Log +import com.futo.platformplayer.BuildConfig +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.models.CastingDeviceInfo +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import org.fcast.sender_sdk.DeviceInfo as RsDeviceInfo +import org.fcast.sender_sdk.ProtocolType +import org.fcast.sender_sdk.CastContext +import org.fcast.sender_sdk.NsdDeviceDiscoverer + +class ExpStateCasting : StateCasting() { + private val _context = CastContext() + var _deviceDiscoverer: NsdDeviceDiscoverer? = null + + class DiscoveryEventHandler( + private val onDeviceAdded: (RsDeviceInfo) -> Unit, + private val onDeviceRemoved: (String) -> Unit, + private val onDeviceUpdated: (RsDeviceInfo) -> Unit, + ) : org.fcast.sender_sdk.DeviceDiscovererEventHandler { + override fun deviceAvailable(deviceInfo: RsDeviceInfo) { + onDeviceAdded(deviceInfo) + } + + override fun deviceChanged(deviceInfo: RsDeviceInfo) { + onDeviceUpdated(deviceInfo) + } + + override fun deviceRemoved(deviceName: String) { + onDeviceRemoved(deviceName) + } + } + + init { + if (BuildConfig.DEBUG) { + org.fcast.sender_sdk.initLogger(org.fcast.sender_sdk.LogLevelFilter.DEBUG) + } + } + + override fun handleUrl(url: String) { + try { + val foundDeviceInfo = org.fcast.sender_sdk.deviceInfoFromUrl(url)!! + val foundDevice = _context.createDeviceFromInfo(foundDeviceInfo) + connectDevice(ExpCastingDevice(foundDevice)) + } catch (e: Throwable) { + Logger.e(TAG, "Failed to handle URL: $e") + } + } + + override fun onStop() { + val ad = activeDevice ?: return + _resumeCastingDevice = ad.getDeviceInfo() + Log.i(TAG, "_resumeCastingDevice set to '${ad.name}'") + Logger.i(TAG, "Stopping active device because of onStop.") + try { + ad.disconnect() + } catch (e: Throwable) { + Logger.e(TAG, "Failed to disconnect from device: $e") + } + } + + @Synchronized + override fun start(context: Context) { + if (_started) + return + _started = true + + Log.i(TAG, "_resumeCastingDevice set null start") + _resumeCastingDevice = null + + Logger.i(TAG, "CastingService starting...") + + _castServer.start() + enableDeveloper(true) + + Logger.i(TAG, "CastingService started.") + + _deviceDiscoverer = NsdDeviceDiscoverer( + context, + DiscoveryEventHandler( + { deviceInfo -> // Added + Logger.i(TAG, "Device added: ${deviceInfo.name}") + val device = _context.createDeviceFromInfo(deviceInfo) + val deviceHandle = ExpCastingDevice(device) + devices[deviceHandle.device.name()] = deviceHandle + invokeInMainScopeIfRequired { + onDeviceAdded.emit(deviceHandle) + } + }, + { deviceName -> // Removed + invokeInMainScopeIfRequired { + if (devices.containsKey(deviceName)) { + val device = devices.remove(deviceName) + if (device != null) { + onDeviceRemoved.emit(device) + } + } + } + }, + { deviceInfo -> // Updated + Logger.i(TAG, "Device updated: $deviceInfo") + val handle = devices[deviceInfo.name] + if (handle != null && handle is ExpCastingDevice) { + handle.device.setPort(deviceInfo.port) + handle.device.setAddresses(deviceInfo.addresses) + invokeInMainScopeIfRequired { + onDeviceChanged.emit(handle) + } + } + }, + ) + ) + } + + @Synchronized + override fun stop() { + if (!_started) { + return + } + + _started = false + + Logger.i(TAG, "CastingService stopping.") + + _scopeIO.cancel() + _scopeMain.cancel() + + Logger.i(TAG, "Stopping active device because StateCasting is being stopped.") + val d = activeDevice + activeDevice = null + try { + d?.disconnect() + } catch (e: Throwable) { + Logger.e(TAG, "Failed to disconnect device: $e") + } + + _castServer.stop() + _castServer.removeAllHandlers() + + Logger.i(TAG, "CastingService stopped.") + + _deviceDiscoverer = null + } + + override fun startUpdateTimeJob( + onTimeJobTimeChanged_s: Event1, + setTime: (Long) -> Unit + ): Job? = null + + override fun deviceFromInfo(deviceInfo: CastingDeviceInfo): ExpCastingDevice { + val rsAddrs = + deviceInfo.addresses.map { org.fcast.sender_sdk.tryIpAddrFromStr(it) } // Throws! + val rsDeviceInfo = RsDeviceInfo( + name = deviceInfo.name, + protocol = when (deviceInfo.type) { + com.futo.platformplayer.casting.CastProtocolType.CHROMECAST -> ProtocolType.CHROMECAST + com.futo.platformplayer.casting.CastProtocolType.FCAST -> ProtocolType.F_CAST + else -> throw IllegalArgumentException() + }, + addresses = rsAddrs, + port = deviceInfo.port.toUShort(), + ) + + return ExpCastingDevice(_context.createDeviceFromInfo(rsDeviceInfo)) + } + + companion object { + private val TAG = "ExperimentalStateCasting" + } +} diff --git a/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt index be62f726..27edb1db 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt @@ -3,7 +3,6 @@ package com.futo.platformplayer.casting import android.os.Looper import android.util.Base64 import android.util.Log -import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.casting.models.FCastDecryptedMessage import com.futo.platformplayer.casting.models.FCastEncryptedMessage @@ -25,7 +24,6 @@ import com.futo.platformplayer.toInetAddress import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.serialization.encodeToString @@ -34,7 +32,6 @@ import java.io.IOException import java.io.InputStream import java.io.OutputStream import java.math.BigInteger -import java.net.Inet4Address import java.net.InetAddress import java.net.InetSocketAddress import java.net.Socket @@ -72,7 +69,7 @@ enum class Opcode(val value: Byte) { } } -class FCastCastingDevice : CastingDevice { +class FCastCastingDevice : OldCastingDevice { //See for more info: TODO override val protocol: CastProtocolType get() = CastProtocolType.FCAST; diff --git a/app/src/main/java/com/futo/platformplayer/casting/OldCastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/OldCastingDevice.kt new file mode 100644 index 00000000..be3ac724 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/casting/OldCastingDevice.kt @@ -0,0 +1,243 @@ +package com.futo.platformplayer.casting + +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.models.CastingDeviceInfo +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import org.fcast.sender_sdk.DeviceConnectionState +import org.fcast.sender_sdk.Metadata +import java.net.InetAddress + +enum class CastConnectionState { + DISCONNECTED, + CONNECTING, + CONNECTED +} + +@Serializable(with = CastProtocolType.CastProtocolTypeSerializer::class) +enum class CastProtocolType { + CHROMECAST, + AIRPLAY, + FCAST; + + object CastProtocolTypeSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("CastProtocolType", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: CastProtocolType) { + encoder.encodeString(value.name) + } + + override fun deserialize(decoder: Decoder): CastProtocolType { + val name = decoder.decodeString() + return when (name) { + "FASTCAST" -> FCAST // Handle the renamed case + else -> CastProtocolType.valueOf(name) + } + } + } +} + +abstract class OldCastingDevice { + abstract val protocol: CastProtocolType; + abstract val isReady: Boolean; + abstract var usedRemoteAddress: InetAddress?; + abstract var localAddress: InetAddress?; + abstract val canSetVolume: Boolean; + abstract val canSetSpeed: Boolean; + + var name: String? = null; + var isPlaying: Boolean = false + set(value) { + val changed = value != field; + field = value; + if (changed) { + onPlayChanged.emit(value); + } + }; + + private var lastTimeChangeTime_ms: Long = 0 + var time: Double = 0.0 + private set + + protected fun setTime(value: Double, changeTime_ms: Long = System.currentTimeMillis()) { + if (changeTime_ms > lastTimeChangeTime_ms && value != time) { + time = value + lastTimeChangeTime_ms = changeTime_ms + onTimeChanged.emit(value) + } + } + + private var lastDurationChangeTime_ms: Long = 0 + var duration: Double = 0.0 + private set + + protected fun setDuration(value: Double, changeTime_ms: Long = System.currentTimeMillis()) { + if (changeTime_ms > lastDurationChangeTime_ms && value != duration) { + duration = value + lastDurationChangeTime_ms = changeTime_ms + onDurationChanged.emit(value) + } + } + + private var lastVolumeChangeTime_ms: Long = 0 + var volume: Double = 1.0 + private set + + protected fun setVolume(value: Double, changeTime_ms: Long = System.currentTimeMillis()) { + if (changeTime_ms > lastVolumeChangeTime_ms && value != volume) { + volume = value + lastVolumeChangeTime_ms = changeTime_ms + onVolumeChanged.emit(value) + } + } + + private var lastSpeedChangeTime_ms: Long = 0 + var speed: Double = 1.0 + private set + + protected fun setSpeed(value: Double, changeTime_ms: Long = System.currentTimeMillis()) { + if (changeTime_ms > lastSpeedChangeTime_ms && value != speed) { + speed = value + lastSpeedChangeTime_ms = changeTime_ms + onSpeedChanged.emit(value) + } + } + + val expectedCurrentTime: Double + get() { + val diff = + if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0; + return time + diff; + }; + var connectionState: CastConnectionState = CastConnectionState.DISCONNECTED + set(value) { + val changed = value != field; + field = value; + + if (changed) { + onConnectionStateChanged.emit(value); + } + }; + + var onConnectionStateChanged = Event1(); + var onPlayChanged = Event1(); + var onTimeChanged = Event1(); + var onDurationChanged = Event1(); + var onVolumeChanged = Event1(); + var onSpeedChanged = Event1(); + + abstract fun stopCasting(); + + abstract fun seekVideo(timeSeconds: Double); + abstract fun stopVideo(); + abstract fun pauseVideo(); + abstract fun resumeVideo(); + abstract fun loadVideo( + streamType: String, + contentType: String, + contentId: String, + resumePosition: Double, + duration: Double, + speed: Double? + ); + + abstract fun loadContent( + contentType: String, + content: String, + resumePosition: Double, + duration: Double, + speed: Double? + ); + + open fun changeVolume(volume: Double) { + throw NotImplementedError() + } + + open fun changeSpeed(speed: Double) { + throw NotImplementedError() + } + + abstract fun start(); + abstract fun stop(); + + abstract fun getDeviceInfo(): CastingDeviceInfo; + + abstract fun getAddresses(): List; +} + +class OldCastingDeviceWrapper(val inner: OldCastingDevice) : CastingDevice() { + override val isReady: Boolean get() = inner.isReady + override val usedRemoteAddress: InetAddress? get() = inner.usedRemoteAddress + override val localAddress: InetAddress? get() = inner.localAddress + override val name: String? get() = inner.name + override val onConnectionStateChanged: Event1 get() = inner.onConnectionStateChanged + override val onPlayChanged: Event1 get() = inner.onPlayChanged + override val onTimeChanged: Event1 get() = inner.onTimeChanged + override val onDurationChanged: Event1 get() = inner.onDurationChanged + override val onVolumeChanged: Event1 get() = inner.onVolumeChanged + override val onSpeedChanged: Event1 get() = inner.onSpeedChanged + override var connectionState: CastConnectionState + get() = inner.connectionState + set(_) = Unit + override val protocolType: CastProtocolType get() = inner.protocol + override var isPlaying: Boolean + get() = inner.isPlaying + set(_) = Unit + override val expectedCurrentTime: Double + get() = inner.expectedCurrentTime + override var speed: Double + get() = inner.speed + set(_) = Unit + override var time: Double + get() = inner.time + set(_) = Unit + override var duration: Double + get() = inner.duration + set(_) = Unit + override var volume: Double + get() = inner.volume + set(_) = Unit + + override fun canSetVolume(): Boolean = inner.canSetVolume + override fun canSetSpeed(): Boolean = inner.canSetSpeed + override fun resumePlayback() = inner.resumeVideo() + override fun pausePlayback() = inner.pauseVideo() + override fun stopPlayback() = inner.stopVideo() + override fun seekTo(timeSeconds: Double) = inner.seekVideo(timeSeconds) + override fun changeVolume(timeSeconds: Double) = inner.changeVolume(timeSeconds) + override fun changeSpeed(speed: Double) = inner.changeSpeed(speed) + override fun connect() = inner.start() + override fun disconnect() = inner.stop() + override fun getDeviceInfo(): CastingDeviceInfo = inner.getDeviceInfo() + override fun getAddresses(): List = inner.getAddresses() + override fun loadVideo( + streamType: String, + contentType: String, + contentId: String, + resumePosition: Double, + duration: Double, + speed: Double?, + metadata: Metadata? + ) = inner.loadVideo(streamType, contentType, contentId, resumePosition, duration, speed) + + override fun loadContent( + contentType: String, + content: String, + resumePosition: Double, + duration: Double, + speed: Double?, + metadata: Metadata? + ) = inner.loadContent(contentType, content, resumePosition, duration, speed) + + override fun ensureThreadStarted() = when (inner) { + is FCastCastingDevice -> inner.ensureThreadStarted() + is ChromecastCastingDevice -> inner.ensureThreadsStarted() + else -> {} + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/casting/OldStateCasting.kt b/app/src/main/java/com/futo/platformplayer/casting/OldStateCasting.kt new file mode 100644 index 00000000..ec0339ee --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/casting/OldStateCasting.kt @@ -0,0 +1,397 @@ +package com.futo.platformplayer.casting + +import android.content.Context +import android.net.Uri +import android.net.nsd.NsdManager +import android.net.nsd.NsdServiceInfo +import android.os.Build +import android.util.Base64 +import android.util.Log +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.models.CastingDeviceInfo +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.net.InetAddress +import kotlinx.coroutines.delay + +class OldStateCasting : StateCasting() { + private var _nsdManager: NsdManager? = null + + private val _discoveryListeners = mapOf( + "_googlecast._tcp" to createDiscoveryListener(::addOrUpdateChromeCastDevice), + "_airplay._tcp" to createDiscoveryListener(::addOrUpdateAirPlayDevice), + "_fastcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice), + "_fcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice) + ) + + override fun handleUrl(url: String) { + val uri = Uri.parse(url) + if (uri.scheme != "fcast") { + throw Exception("Expected scheme to be FCast") + } + + val type = uri.host + if (type != "r") { + throw Exception("Expected type r") + } + + val connectionInfo = uri.pathSegments[0] + val json = + Base64.decode(connectionInfo, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) + .toString(Charsets.UTF_8) + val networkConfig = Json.decodeFromString(json) + val tcpService = networkConfig.services.first { v -> v.type == 0 } + + val foundInfo = addRememberedDevice( + CastingDeviceInfo( + name = networkConfig.name, + type = CastProtocolType.FCAST, + addresses = networkConfig.addresses.toTypedArray(), + port = tcpService.port + ) + ) + + connectDevice(deviceFromInfo(foundInfo)) + } + + override fun onStop() { + val ad = activeDevice ?: return; + _resumeCastingDevice = ad.getDeviceInfo() + Log.i(TAG, "_resumeCastingDevice set to '${ad.name}'") + Logger.i(TAG, "Stopping active device because of onStop."); + ad.disconnect(); + } + + @Synchronized + override fun start(context: Context) { + if (_started) + return; + _started = true; + + Log.i(TAG, "_resumeCastingDevice set null start") + _resumeCastingDevice = null; + + Logger.i(TAG, "CastingService starting..."); + + _castServer.start(); + enableDeveloper(true); + + Logger.i(TAG, "CastingService started."); + + _nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager + startDiscovering() + } + + @Synchronized + private fun startDiscovering() { + _nsdManager?.apply { + _discoveryListeners.forEach { + discoverServices(it.key, NsdManager.PROTOCOL_DNS_SD, it.value) + } + } + } + + @Synchronized + private fun stopDiscovering() { + _nsdManager?.apply { + _discoveryListeners.forEach { + try { + stopServiceDiscovery(it.value) + } catch (e: Throwable) { + Logger.w(TAG, "Failed to stop service discovery", e) + } + } + } + } + + @Synchronized + override fun stop() { + if (!_started) + return; + + _started = false; + + Logger.i(TAG, "CastingService stopping.") + + stopDiscovering() + _scopeIO.cancel(); + _scopeMain.cancel(); + + Logger.i(TAG, "Stopping active device because StateCasting is being stopped.") + val d = activeDevice; + activeDevice = null; + d?.disconnect(); + + _castServer.stop(); + _castServer.removeAllHandlers(); + + Logger.i(TAG, "CastingService stopped.") + + _nsdManager = null + } + + private fun createDiscoveryListener(addOrUpdate: (String, Array, Int) -> Unit): NsdManager.DiscoveryListener { + return object : NsdManager.DiscoveryListener { + override fun onDiscoveryStarted(regType: String) { + Log.d(TAG, "Service discovery started for $regType") + } + + override fun onDiscoveryStopped(serviceType: String) { + Log.i(TAG, "Discovery stopped: $serviceType") + } + + override fun onServiceLost(service: NsdServiceInfo) { + Log.e(TAG, "service lost: $service") + // TODO: Handle service lost, e.g., remove device + } + + override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) { + Log.e(TAG, "Discovery failed for $serviceType: Error code:$errorCode") + try { + _nsdManager?.stopServiceDiscovery(this) + } catch (e: Throwable) { + Logger.w(TAG, "Failed to stop service discovery", e) + } + } + + override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) { + Log.e(TAG, "Stop discovery failed for $serviceType: Error code:$errorCode") + try { + _nsdManager?.stopServiceDiscovery(this) + } catch (e: Throwable) { + Logger.w(TAG, "Failed to stop service discovery", e) + } + } + + override fun onServiceFound(service: NsdServiceInfo) { + Log.v(TAG, "Service discovery success for ${service.serviceType}: $service") + val addresses = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + service.hostAddresses.toTypedArray() + } else { + arrayOf(service.host) + } + addOrUpdate(service.serviceName, addresses, service.port) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + _nsdManager?.registerServiceInfoCallback( + service, + { it.run() }, + object : NsdManager.ServiceInfoCallback { + override fun onServiceUpdated(serviceInfo: NsdServiceInfo) { + Log.v(TAG, "onServiceUpdated: $serviceInfo") + addOrUpdate( + serviceInfo.serviceName, + serviceInfo.hostAddresses.toTypedArray(), + serviceInfo.port + ) + } + + override fun onServiceLost() { + Log.v(TAG, "onServiceLost: $service") + // TODO: Handle service lost + } + + override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) { + Log.v(TAG, "onServiceInfoCallbackRegistrationFailed: $errorCode") + } + + override fun onServiceInfoCallbackUnregistered() { + Log.v(TAG, "onServiceInfoCallbackUnregistered") + } + }) + } else { + _nsdManager?.resolveService(service, object : NsdManager.ResolveListener { + override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { + Log.v(TAG, "Resolve failed: $errorCode") + } + + override fun onServiceResolved(serviceInfo: NsdServiceInfo) { + Log.v(TAG, "Resolve Succeeded: $serviceInfo") + addOrUpdate( + serviceInfo.serviceName, + arrayOf(serviceInfo.host), + serviceInfo.port + ) + } + }) + } + } + } + } + + override fun startUpdateTimeJob( + onTimeJobTimeChanged_s: Event1, + setTime: (Long) -> Unit + ): Job? { + val d = activeDevice; + if (d is OldCastingDeviceWrapper && (d.inner is AirPlayCastingDevice || d.inner is ChromecastCastingDevice)) { + return _scopeMain.launch { + while (true) { + val device = instance.activeDevice + if (device == null || !device.isPlaying) { + break + } + + delay(1000) + val time_ms = (device.expectedCurrentTime * 1000.0).toLong() + setTime(time_ms) + onTimeJobTimeChanged_s.emit(device.expectedCurrentTime.toLong()) + } + } + } + return null + } + + override fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDevice { + return OldCastingDeviceWrapper( + when (deviceInfo.type) { + CastProtocolType.CHROMECAST -> { + ChromecastCastingDevice(deviceInfo); + } + + CastProtocolType.AIRPLAY -> { + AirPlayCastingDevice(deviceInfo); + } + + CastProtocolType.FCAST -> { + FCastCastingDevice(deviceInfo); + } + } + ) + } + + private fun addOrUpdateChromeCastDevice( + name: String, + addresses: Array, + port: Int + ) { + return addOrUpdateCastDevice( + name, + deviceFactory = { + OldCastingDeviceWrapper( + ChromecastCastingDevice( + name, + addresses, + port + ) + ) + }, + deviceUpdater = { d -> + if (d.isReady || d !is OldCastingDeviceWrapper || d.inner !is ChromecastCastingDevice) { + return@addOrUpdateCastDevice false; + } + + val changed = + addresses.contentEquals(d.inner.addresses) || d.name != name || d.inner.port != port; + if (changed) { + d.inner.name = name; + d.inner.addresses = addresses; + d.inner.port = port; + } + + return@addOrUpdateCastDevice changed; + } + ); + } + + private fun addOrUpdateAirPlayDevice(name: String, addresses: Array, port: Int) { + return addOrUpdateCastDevice( + name, + deviceFactory = { + OldCastingDeviceWrapper( + AirPlayCastingDevice( + name, + addresses, + port + ) + ) + }, + deviceUpdater = { d -> + if (d.isReady || d !is OldCastingDeviceWrapper || d.inner !is AirPlayCastingDevice) { + return@addOrUpdateCastDevice false; + } + + val changed = + addresses.contentEquals(addresses) || d.name != name || d.inner.port != port; + if (changed) { + d.inner.name = name; + d.inner.port = port; + d.inner.addresses = addresses; + } + + return@addOrUpdateCastDevice changed; + } + ); + } + + private fun addOrUpdateFastCastDevice(name: String, addresses: Array, port: Int) { + return addOrUpdateCastDevice( + name, + deviceFactory = { OldCastingDeviceWrapper(FCastCastingDevice(name, addresses, port)) }, + deviceUpdater = { d -> + if (d.isReady || d !is OldCastingDeviceWrapper || d.inner !is FCastCastingDevice) { + return@addOrUpdateCastDevice false; + } + + val changed = + addresses.contentEquals(addresses) || d.name != name || d.inner.port != port; + if (changed) { + d.inner.name = name; + d.inner.port = port; + d.inner.addresses = addresses; + } + + return@addOrUpdateCastDevice changed; + } + ); + } + + private inline fun addOrUpdateCastDevice( + name: String, + deviceFactory: () -> CastingDevice, + deviceUpdater: (device: CastingDevice) -> Boolean + ) { + var invokeEvents: (() -> Unit)? = null; + + synchronized(devices) { + val device = devices[name]; + if (device != null) { + val changed = deviceUpdater(device); + if (changed) { + invokeEvents = { + onDeviceChanged.emit(device); + } + } + } else { + val newDevice = deviceFactory(); + this.devices[name] = newDevice + + invokeEvents = { + onDeviceAdded.emit(newDevice); + }; + } + } + + invokeEvents?.let { _scopeMain.launch { it(); }; }; + } + + @Serializable + private data class FCastNetworkConfig( + val name: String, + val addresses: List, + val services: List + ) + + @Serializable + private data class FCastService( + val port: Int, + val type: Int + ) + + companion object { + private val TAG = "OldStateCasting" + } +} 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 f7802e86..e57d0de2 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt @@ -3,15 +3,8 @@ package com.futo.platformplayer.casting import android.app.AlertDialog import android.content.ContentResolver import android.content.Context -import android.net.Uri -import android.net.nsd.NsdManager -import android.net.nsd.NsdServiceInfo -import android.os.Build import android.os.Looper -import android.util.Base64 import android.util.Log -import java.net.NetworkInterface -import java.net.Inet4Address import androidx.annotation.OptIn import androidx.media3.common.util.UnstableApi import com.futo.platformplayer.R @@ -41,12 +34,12 @@ import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManif import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource import com.futo.platformplayer.builders.DashBuilder +import com.futo.platformplayer.models.CastingDeviceInfo import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.exceptions.UnsupportedCastException import com.futo.platformplayer.findPreferredAddress import com.futo.platformplayer.logging.Logger -import com.futo.platformplayer.models.CastingDeviceInfo import com.futo.platformplayer.parsers.HLS import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.stores.CastingDeviceInfoStorage @@ -54,280 +47,109 @@ import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.toUrlAddress import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancel +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json +import org.fcast.sender_sdk.Metadata import java.net.Inet6Address -import java.net.InetAddress 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); - private val _scopeMain = CoroutineScope(Dispatchers.Main); - private val _storage: CastingDeviceInfoStorage = FragmentedStorage.get(); - - private val _castServer = ManagedHttpServer(); - private var _started = false; - - var devices: HashMap = hashMapOf(); - val onDeviceAdded = Event1(); - val onDeviceChanged = Event1(); - val onDeviceRemoved = Event1(); - val onActiveDeviceConnectionStateChanged = Event2(); - val onActiveDevicePlayChanged = Event1(); - val onActiveDeviceTimeChanged = Event1(); - val onActiveDeviceDurationChanged = Event1(); - val onActiveDeviceVolumeChanged = Event1(); - var activeDevice: CastingDevice? = null; +abstract class StateCasting { + val _castServer = ManagedHttpServer() private var _videoExecutor: JSRequestExecutor? = null private var _audioExecutor: JSRequestExecutor? = null - private val _client = ManagedHttpClient(); - var _resumeCastingDevice: CastingDeviceInfo? = null; - private var _nsdManager: NsdManager? = null - val isCasting: Boolean get() = activeDevice != null; + val _scopeIO = CoroutineScope(Dispatchers.IO); + var _started = false; + private val _storage: CastingDeviceInfoStorage = FragmentedStorage.get(); + val _client = ManagedHttpClient(); + var devices: HashMap = hashMapOf() + val onDeviceAdded = Event1() + val onDeviceChanged = Event1() + val onDeviceRemoved = Event1() + val onActiveDeviceConnectionStateChanged = Event2() + val onActiveDevicePlayChanged = Event1() + val onActiveDeviceTimeChanged = Event1() + val onActiveDeviceDurationChanged = Event1() + val onActiveDeviceVolumeChanged = Event1() + var activeDevice: CastingDevice? = null + val isCasting: Boolean get() = activeDevice != null + var _resumeCastingDevice: CastingDeviceInfo? = null + val _scopeMain = CoroutineScope(Dispatchers.Main) + private val _castingDialogLock = Any(); + private var _currentDialog: AlertDialog? = null; private val _castId = AtomicInteger(0) - private val _discoveryListeners = mapOf( - "_googlecast._tcp" to createDiscoveryListener(::addOrUpdateChromeCastDevice), - "_airplay._tcp" to createDiscoveryListener(::addOrUpdateAirPlayDevice), - "_fastcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice), - "_fcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice) - ) + abstract fun handleUrl(url: String) + abstract fun onStop() + abstract fun start(context: Context) + abstract fun stop() - fun handleUrl(context: Context, url: String) { - val uri = Uri.parse(url) - if (uri.scheme != "fcast") { - throw Exception("Expected scheme to be FCast") - } - - val type = uri.host - if (type != "r") { - throw Exception("Expected type r") - } - - val connectionInfo = uri.pathSegments[0] - val json = Base64.decode(connectionInfo, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP).toString(Charsets.UTF_8) - val networkConfig = Json.decodeFromString(json) - val tcpService = networkConfig.services.first { v -> v.type == 0 } - - val foundInfo = addRememberedDevice(CastingDeviceInfo( - name = networkConfig.name, - type = CastProtocolType.FCAST, - addresses = networkConfig.addresses.toTypedArray(), - port = tcpService.port - )) - - connectDevice(deviceFromCastingDeviceInfo(foundInfo)) - } - - fun onStop() { - val ad = activeDevice ?: return; - _resumeCastingDevice = ad.getDeviceInfo() - Log.i(TAG, "_resumeCastingDevice set to '${ad.name}'") - Logger.i(TAG, "Stopping active device because of onStop."); - ad.stop(); - } + @Throws + abstract fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDevice + abstract fun startUpdateTimeJob( + onTimeJobTimeChanged_s: Event1, setTime: (Long) -> Unit + ): Job? fun onResume() { val ad = activeDevice if (ad != null) { - if (ad is FCastCastingDevice) { - ad.ensureThreadStarted() - } else if (ad is ChromecastCastingDevice) { - ad.ensureThreadsStarted() - } + ad.ensureThreadStarted() } else { val resumeCastingDevice = _resumeCastingDevice if (resumeCastingDevice != null) { - connectDevice(deviceFromCastingDeviceInfo(resumeCastingDevice)) + val dev = deviceFromInfo(resumeCastingDevice) ?: return + connectDevice(dev) _resumeCastingDevice = null Log.i(TAG, "_resumeCastingDevice set to null onResume") } } } - @Synchronized - fun start(context: Context) { - if (_started) + fun cancel() { + _castId.incrementAndGet() + } + + fun invokeInMainScopeIfRequired(action: () -> Unit) { + if (Looper.getMainLooper().thread != Thread.currentThread()) { + _scopeMain.launch { action() } return; - _started = true; - - Log.i(TAG, "_resumeCastingDevice set null start") - _resumeCastingDevice = null; - - Logger.i(TAG, "CastingService starting..."); - - _castServer.start(); - enableDeveloper(true); - - Logger.i(TAG, "CastingService started."); - - _nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager - startDiscovering() - } - - @Synchronized - private fun startDiscovering() { - _nsdManager?.apply { - _discoveryListeners.forEach { - discoverServices(it.key, NsdManager.PROTOCOL_DNS_SD, it.value) - } } + + action(); } - @Synchronized - private fun stopDiscovering() { - _nsdManager?.apply { - _discoveryListeners.forEach { - try { - stopServiceDiscovery(it.value) - } catch (e: Throwable) { - Logger.w(TAG, "Failed to stop service discovery", e) - } - } - } - } - - @Synchronized - fun stop() { - if (!_started) - return; - - _started = false; - - Logger.i(TAG, "CastingService stopping.") - - stopDiscovering() - _scopeIO.cancel(); - _scopeMain.cancel(); - - Logger.i(TAG, "Stopping active device because StateCasting is being stopped.") - val d = activeDevice; - activeDevice = null; - d?.stop(); - - _castServer.stop(); - _castServer.removeAllHandlers(); - - Logger.i(TAG, "CastingService stopped.") - - _nsdManager = null - } - - private fun createDiscoveryListener(addOrUpdate: (String, Array, Int) -> Unit): NsdManager.DiscoveryListener { - return object : NsdManager.DiscoveryListener { - override fun onDiscoveryStarted(regType: String) { - Log.d(TAG, "Service discovery started for $regType") - } - - override fun onDiscoveryStopped(serviceType: String) { - Log.i(TAG, "Discovery stopped: $serviceType") - } - - override fun onServiceLost(service: NsdServiceInfo) { - Log.e(TAG, "service lost: $service") - // TODO: Handle service lost, e.g., remove device - } - - override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) { - Log.e(TAG, "Discovery failed for $serviceType: Error code:$errorCode") - try { - _nsdManager?.stopServiceDiscovery(this) - } catch (e: Throwable) { - Logger.w(TAG, "Failed to stop service discovery", e) - } - } - - override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) { - Log.e(TAG, "Stop discovery failed for $serviceType: Error code:$errorCode") - try { - _nsdManager?.stopServiceDiscovery(this) - } catch (e: Throwable) { - Logger.w(TAG, "Failed to stop service discovery", e) - } - } - - override fun onServiceFound(service: NsdServiceInfo) { - Log.v(TAG, "Service discovery success for ${service.serviceType}: $service") - val addresses = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - service.hostAddresses.toTypedArray() - } else { - arrayOf(service.host) - } - addOrUpdate(service.serviceName, addresses, service.port) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - _nsdManager?.registerServiceInfoCallback(service, { it.run() }, object : NsdManager.ServiceInfoCallback { - override fun onServiceUpdated(serviceInfo: NsdServiceInfo) { - Log.v(TAG, "onServiceUpdated: $serviceInfo") - addOrUpdate(serviceInfo.serviceName, serviceInfo.hostAddresses.toTypedArray(), serviceInfo.port) - } - - override fun onServiceLost() { - Log.v(TAG, "onServiceLost: $service") - // TODO: Handle service lost - } - - override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) { - Log.v(TAG, "onServiceInfoCallbackRegistrationFailed: $errorCode") - } - - override fun onServiceInfoCallbackUnregistered() { - Log.v(TAG, "onServiceInfoCallbackUnregistered") - } - }) - } else { - _nsdManager?.resolveService(service, object : NsdManager.ResolveListener { - override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { - Log.v(TAG, "Resolve failed: $errorCode") - } - - override fun onServiceResolved(serviceInfo: NsdServiceInfo) { - Log.v(TAG, "Resolve Succeeded: $serviceInfo") - addOrUpdate(serviceInfo.serviceName, arrayOf(serviceInfo.host), serviceInfo.port) - } - }) - } - } - } - } - - private val _castingDialogLock = Any(); - private var _currentDialog: AlertDialog? = null; - @Synchronized fun connectDevice(device: CastingDevice) { - if (activeDevice == device) - return; + if (activeDevice == device) { + return + } val ad = activeDevice; if (ad != null) { Logger.i(TAG, "Stopping previous device because a new one is being connected.") - device.onConnectionStateChanged.clear(); - device.onPlayChanged.clear(); - device.onTimeChanged.clear(); - device.onVolumeChanged.clear(); - device.onDurationChanged.clear(); - ad.stop(); + device.onConnectionStateChanged.clear() + device.onPlayChanged.clear() + device.onTimeChanged.clear() + device.onVolumeChanged.clear() + device.onDurationChanged.clear() + ad.disconnect() } device.onConnectionStateChanged.subscribe { castConnectionState -> - Logger.i(TAG, "Active device connection state changed: $castConnectionState"); + Logger.i(TAG, "Active device connection state changed: $castConnectionState") if (castConnectionState == CastConnectionState.DISCONNECTED) { - Logger.i(TAG, "Clearing events: $castConnectionState"); + Logger.i(TAG, "Clearing events: $castConnectionState") - device.onConnectionStateChanged.clear(); - device.onPlayChanged.clear(); - device.onTimeChanged.clear(); - device.onVolumeChanged.clear(); - device.onDurationChanged.clear(); - activeDevice = null; + device.onConnectionStateChanged.clear() + device.onPlayChanged.clear() + device.onTimeChanged.clear() + device.onVolumeChanged.clear() + device.onDurationChanged.clear() + activeDevice = null } invokeInMainScopeIfRequired { @@ -339,112 +161,115 @@ class StateCasting { Logger.i(TAG, "Casting connected to [${device.name}]"); UIDialogs.appToast("Connected to device") synchronized(_castingDialogLock) { - if(_currentDialog != null) { - _currentDialog?.hide(); - _currentDialog = null; + if (_currentDialog != null) { + _currentDialog?.hide() + _currentDialog = null } } } + CastConnectionState.CONNECTING -> { Logger.i(TAG, "Casting connecting to [${device.name}]"); UIDialogs.toast(it, "Connecting to device...") synchronized(_castingDialogLock) { - if(_currentDialog == null) { - _currentDialog = UIDialogs.showDialog(context, R.drawable.ic_loader_animated, true, - "Connecting to [${device.name}]", - "Make sure you are on the same network\n\nVPNs and guest networks can cause issues", null, -2, + if (_currentDialog == null) { + _currentDialog = UIDialogs.showDialog( + context, + R.drawable.ic_loader_animated, + true, + "Connecting to [${device.name}]", + "Make sure you are on the same network\n\nVPNs and guest networks can cause issues", + null, + -2, UIDialogs.Action("Disconnect", { - device.stop(); - })); + try { + device.disconnect() + } catch (e: Throwable) { + Logger.e( + TAG, "Failed to disconnect from device: $e" + ) + } + }) + ) } } } + CastConnectionState.DISCONNECTED -> { UIDialogs.toast(it, "Disconnected from device") synchronized(_castingDialogLock) { - if(_currentDialog != null) { - _currentDialog?.hide(); - _currentDialog = null; + if (_currentDialog != null) { + _currentDialog?.hide() + _currentDialog = null } } } } } - }; - onActiveDeviceConnectionStateChanged.emit(device, castConnectionState); - }; - }; + } + onActiveDeviceConnectionStateChanged.emit(device, castConnectionState) + } + } device.onPlayChanged.subscribe { - invokeInMainScopeIfRequired { onActiveDevicePlayChanged.emit(it) }; + invokeInMainScopeIfRequired { onActiveDevicePlayChanged.emit(it) } } device.onDurationChanged.subscribe { - invokeInMainScopeIfRequired { onActiveDeviceDurationChanged.emit(it) }; - }; + invokeInMainScopeIfRequired { onActiveDeviceDurationChanged.emit(it) } + } device.onVolumeChanged.subscribe { - invokeInMainScopeIfRequired { onActiveDeviceVolumeChanged.emit(it) }; - }; + invokeInMainScopeIfRequired { onActiveDeviceVolumeChanged.emit(it) } + } device.onTimeChanged.subscribe { - invokeInMainScopeIfRequired { onActiveDeviceTimeChanged.emit(it) }; - }; + invokeInMainScopeIfRequired { onActiveDeviceTimeChanged.emit(it) } + } try { - device.start(); + device.connect(); } catch (e: Throwable) { - Logger.w(TAG, "Failed to connect to device."); - device.onConnectionStateChanged.clear(); - device.onPlayChanged.clear(); - device.onTimeChanged.clear(); - device.onVolumeChanged.clear(); - device.onDurationChanged.clear(); - return; + Logger.w(TAG, "Failed to connect to device.") + device.onConnectionStateChanged.clear() + device.onPlayChanged.clear() + device.onTimeChanged.clear() + device.onVolumeChanged.clear() + device.onDurationChanged.clear() + return } - activeDevice = device; - Logger.i(TAG, "Connect to device ${device.name}"); + activeDevice = device + Logger.i(TAG, "Connect to device ${device.name}") } - fun addRememberedDevice(deviceInfo: CastingDeviceInfo): CastingDeviceInfo { - val device = deviceFromCastingDeviceInfo(deviceInfo); - return addRememberedDevice(device); + fun metadataFromVideo(video: IPlatformVideoDetails): Metadata { + return Metadata( + title = video.name, thumbnailUrl = video.thumbnails.getHQThumbnail() + ) } - fun getRememberedCastingDevices(): List { - return _storage.getDevices().map { deviceFromCastingDeviceInfo(it) } + private fun shouldProxyStreams( + castingDevice: CastingDevice, videoSource: IVideoSource?, audioSource: IAudioSource? + ): Boolean { + val hasRequestModifier = + (videoSource as? JSSource)?.hasRequestModifier == true || (audioSource as? JSSource)?.hasRequestModifier == true + return Settings.instance.casting.alwaysProxyRequests || castingDevice.protocolType != CastProtocolType.FCAST || hasRequestModifier } - fun getRememberedCastingDeviceNames(): List { - return _storage.getDeviceNames() - } - - fun addRememberedDevice(device: CastingDevice): CastingDeviceInfo { - val deviceInfo = device.getDeviceInfo() - return _storage.addDevice(deviceInfo) - } - - fun removeRememberedDevice(device: CastingDevice) { - val name = device.name ?: return - _storage.removeDevice(name) - } - - private fun invokeInMainScopeIfRequired(action: () -> Unit){ - if(Looper.getMainLooper().thread != Thread.currentThread()) { - _scopeMain.launch { action(); } - return; - } - - action(); - } - - fun cancel() { - _castId.incrementAndGet() - } - - suspend 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 { + suspend fun castIfAvailable( + contentResolver: ContentResolver, + video: IPlatformVideoDetails, + videoSource: IVideoSource?, + audioSource: IAudioSource?, + subtitleSource: ISubtitleSource?, + ms: Long, + speed: Double?, + onLoadingEstimate: ((Int) -> Unit)?, + onLoading: ((Boolean) -> Unit)? + ): Boolean { return withContext(Dispatchers.IO) { val ad = activeDevice ?: return@withContext false; if (ad.connectionState != CastConnectionState.CONNECTED) { return@withContext false; } + val deviceProto = ad.protocolType val resumePosition = if (ms > 0L) (ms.toDouble() / 1000.0) else 0.0; val castId = _castId.incrementAndGet() @@ -460,29 +285,79 @@ class StateCasting { if (sourceCount > 1) { if (videoSource is LocalVideoSource || audioSource is LocalAudioSource || subtitleSource is LocalSubtitleSource) { - if (ad is AirPlayCastingDevice) { + if (deviceProto == CastProtocolType.AIRPLAY) { Logger.i(TAG, "Casting as local HLS"); - castLocalHls(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition, speed); + castLocalHls( + video, + videoSource as LocalVideoSource?, + audioSource as LocalAudioSource?, + subtitleSource as LocalSubtitleSource?, + resumePosition, + speed + ); } else { Logger.i(TAG, "Casting as local DASH"); - castLocalDash(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition, speed); + castLocalDash( + video, + videoSource as LocalVideoSource?, + audioSource as LocalAudioSource?, + subtitleSource as LocalSubtitleSource?, + resumePosition, + speed + ); } } else { - val isRawDash = videoSource is JSDashManifestRawSource || audioSource is JSDashManifestRawAudioSource + val isRawDash = + videoSource is JSDashManifestRawSource || audioSource is JSDashManifestRawAudioSource if (isRawDash) { Logger.i(TAG, "Casting as raw DASH"); - castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, audioSource as JSDashManifestRawAudioSource?, subtitleSource, resumePosition, speed, castId, onLoadingEstimate, onLoading); + castDashRaw( + contentResolver, + video, + videoSource as JSDashManifestRawSource?, + audioSource as JSDashManifestRawAudioSource?, + subtitleSource, + resumePosition, + speed, + castId, + onLoadingEstimate, + onLoading + ); } else { - if (ad is FCastCastingDevice) { + if (deviceProto == CastProtocolType.FCAST) { Logger.i(TAG, "Casting as DASH direct"); - castDashDirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed); - } else if (ad is AirPlayCastingDevice) { + castDashDirect( + contentResolver, + video, + videoSource as IVideoUrlSource?, + audioSource as IAudioUrlSource?, + subtitleSource, + resumePosition, + speed + ); + } else if (deviceProto == CastProtocolType.AIRPLAY) { Logger.i(TAG, "Casting as HLS indirect"); - castHlsIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed); + castHlsIndirect( + contentResolver, + video, + videoSource as IVideoUrlSource?, + audioSource as IAudioUrlSource?, + subtitleSource, + resumePosition, + speed + ); } else { Logger.i(TAG, "Casting as DASH indirect"); - castDashIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed); + castDashIndirect( + contentResolver, + video, + videoSource as IVideoUrlSource?, + audioSource as IAudioUrlSource?, + subtitleSource, + resumePosition, + speed + ); } } } @@ -493,29 +368,65 @@ class StateCasting { if (videoSource is IVideoUrlSource) { val videoPath = "/video-${id}" - val videoUrl = if(proxyStreams) url + videoPath else videoSource.getVideoUrl(); + val videoUrl = if (proxyStreams) url + videoPath else videoSource.getVideoUrl(); Logger.i(TAG, "Casting as singular video"); - ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), speed); + ad.loadVideo( + if (video.isLive) "LIVE" else "BUFFERED", + videoSource.container, + videoUrl, + resumePosition, + video.duration.toDouble(), + speed, + metadataFromVideo(video) + ); } else if (audioSource is IAudioUrlSource) { val audioPath = "/audio-${id}" - val audioUrl = if(proxyStreams) url + audioPath else audioSource.getAudioUrl(); + val audioUrl = if (proxyStreams) url + audioPath else audioSource.getAudioUrl(); Logger.i(TAG, "Casting as singular audio"); - ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), speed); - } else if(videoSource is IHLSManifestSource) { - if (proxyStreams || ad is ChromecastCastingDevice) { + ad.loadVideo( + if (video.isLive) "LIVE" else "BUFFERED", + audioSource.container, + audioUrl, + resumePosition, + video.duration.toDouble(), + speed, + metadataFromVideo(video) + ); + } else if (videoSource is IHLSManifestSource) { + if (proxyStreams || deviceProto == CastProtocolType.CHROMECAST) { Logger.i(TAG, "Casting as proxied HLS"); - castProxiedHls(video, videoSource.url, videoSource.codec, resumePosition, speed); + castProxiedHls( + video, videoSource.url, videoSource.codec, resumePosition, speed + ); } else { Logger.i(TAG, "Casting as non-proxied HLS"); - ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble(), speed); + ad.loadVideo( + if (video.isLive) "LIVE" else "BUFFERED", + videoSource.container, + videoSource.url, + resumePosition, + video.duration.toDouble(), + speed, + metadataFromVideo(video) + ); } - } else if(audioSource is IHLSManifestAudioSource) { - if (proxyStreams || ad is ChromecastCastingDevice) { + } else if (audioSource is IHLSManifestAudioSource) { + if (proxyStreams || deviceProto == CastProtocolType.CHROMECAST) { Logger.i(TAG, "Casting as proxied audio HLS"); - castProxiedHls(video, audioSource.url, audioSource.codec, resumePosition, speed); + castProxiedHls( + video, audioSource.url, audioSource.codec, resumePosition, speed + ); } else { Logger.i(TAG, "Casting as non-proxied audio HLS"); - ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble(), speed); + ad.loadVideo( + if (video.isLive) "LIVE" else "BUFFERED", + audioSource.container, + audioSource.url, + resumePosition, + video.duration.toDouble(), + speed, + metadataFromVideo(video) + ); } } else if (videoSource is LocalVideoSource) { Logger.i(TAG, "Casting as local video"); @@ -525,15 +436,37 @@ class StateCasting { castLocalAudio(video, audioSource, resumePosition, speed); } else if (videoSource is JSDashManifestRawSource) { Logger.i(TAG, "Casting as JSDashManifestRawSource video"); - castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, null, null, resumePosition, speed, castId, onLoadingEstimate, onLoading); + castDashRaw( + contentResolver, + video, + videoSource as JSDashManifestRawSource?, + null, + null, + resumePosition, + speed, + castId, + onLoadingEstimate, + onLoading + ); } else if (audioSource is JSDashManifestRawAudioSource) { Logger.i(TAG, "Casting as JSDashManifestRawSource audio"); - castDashRaw(contentResolver, video, null, audioSource as JSDashManifestRawAudioSource?, null, resumePosition, speed, castId, onLoadingEstimate, onLoading); + castDashRaw( + contentResolver, + video, + null, + audioSource as JSDashManifestRawAudioSource?, + null, + resumePosition, + speed, + castId, + onLoadingEstimate, + onLoading + ); } else { var str = listOf( - if(videoSource != null) "Video: ${videoSource::class.java.simpleName}" else null, - if(audioSource != null) "Audio: ${audioSource::class.java.simpleName}" else null, - if(subtitleSource != null) "Subtitles: ${subtitleSource::class.java.simpleName}" else null + if (videoSource != null) "Video: ${videoSource::class.java.simpleName}" else null, + if (audioSource != null) "Audio: ${audioSource::class.java.simpleName}" else null, + if (subtitleSource != null) "Subtitles: ${subtitleSource::class.java.simpleName}" else null ).filterNotNull().joinToString(", "); throw UnsupportedCastException(str); } @@ -545,29 +478,46 @@ class StateCasting { fun resumeVideo(): Boolean { val ad = activeDevice ?: return false; - ad.resumeVideo(); + try { + ad.resumePlayback(); + } catch (_: Throwable) { + } return true; } fun pauseVideo(): Boolean { val ad = activeDevice ?: return false; - ad.pauseVideo(); + try { + ad.pausePlayback(); + } catch (_: Throwable) { + } return true; } fun stopVideo(): Boolean { val ad = activeDevice ?: return false; - ad.stopVideo(); + try { + ad.stopPlayback(); + } catch (_: Throwable) { + } return true; } fun videoSeekTo(timeSeconds: Double): Boolean { val ad = activeDevice ?: return false; - ad.seekVideo(timeSeconds); + try { + ad.seekTo(timeSeconds); + } catch (_: Throwable) { + } return true; } - private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double, speed: Double?) : List { + private fun castLocalVideo( + video: IPlatformVideoDetails, + videoSource: LocalVideoSource, + resumePosition: Double, + speed: Double? + ): List { val ad = activeDevice ?: return listOf(); val url = getLocalUrl(ad); @@ -576,17 +526,34 @@ class StateCasting { val videoUrl = url + videoPath; _castServer.addHandlerWithAllowAllOptions( - HttpFileHandler("GET", videoPath, videoSource.container, videoSource.filePath) - .withHeader("Access-Control-Allow-Origin", "*"), true + HttpFileHandler( + "GET", + videoPath, + videoSource.container, + videoSource.filePath + ).withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("cast"); Logger.i(TAG, "Casting local video (videoUrl: $videoUrl)."); - ad.loadVideo("BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), speed); + ad.loadVideo( + "BUFFERED", + videoSource.container, + videoUrl, + resumePosition, + video.duration.toDouble(), + speed, + metadataFromVideo(video) + ); return listOf(videoUrl); } - private fun castLocalAudio(video: IPlatformVideoDetails, audioSource: LocalAudioSource, resumePosition: Double, speed: Double?) : List { + private fun castLocalAudio( + video: IPlatformVideoDetails, + audioSource: LocalAudioSource, + resumePosition: Double, + speed: Double? + ): List { val ad = activeDevice ?: return listOf(); val url = getLocalUrl(ad); @@ -595,17 +562,36 @@ class StateCasting { val audioUrl = url + audioPath; _castServer.addHandlerWithAllowAllOptions( - HttpFileHandler("GET", audioPath, audioSource.container, audioSource.filePath) - .withHeader("Access-Control-Allow-Origin", "*"), true + HttpFileHandler( + "GET", + audioPath, + audioSource.container, + audioSource.filePath + ).withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("cast"); Logger.i(TAG, "Casting local audio (audioUrl: $audioUrl)."); - ad.loadVideo("BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), speed); + ad.loadVideo( + "BUFFERED", + audioSource.container, + audioUrl, + resumePosition, + video.duration.toDouble(), + speed, + metadataFromVideo(video) + ); return listOf(audioUrl); } - private fun castLocalHls(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double, speed: Double?): List { + private fun castLocalHls( + video: IPlatformVideoDetails, + videoSource: LocalVideoSource?, + audioSource: LocalAudioSource?, + subtitleSource: LocalSubtitleSource?, + resumePosition: Double, + speed: Double? + ): List { val ad = activeDevice ?: return listOf() val url = getLocalUrl(ad) @@ -626,82 +612,161 @@ class StateCasting { if (videoSource != null) { _castServer.addHandlerWithAllowAllOptions( - HttpFileHandler("GET", videoPath, videoSource.container, videoSource.filePath) - .withHeader("Access-Control-Allow-Origin", "*"), true + HttpFileHandler( + "GET", + videoPath, + videoSource.container, + videoSource.filePath + ).withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("castLocalHls") val duration = videoSource.duration val videoVariantPlaylistPath = "/video-playlist-${id}" val videoVariantPlaylistUrl = url + videoVariantPlaylistPath - val videoVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), videoUrl)) - val videoVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, null, videoVariantPlaylistSegments) + val videoVariantPlaylistSegments = + listOf(HLS.MediaSegment(duration.toDouble(), videoUrl)) + val videoVariantPlaylist = HLS.VariantPlaylist( + 3, duration.toInt(), 0, 0, null, null, null, videoVariantPlaylistSegments + ) _castServer.addHandlerWithAllowAllOptions( - HttpConstantHandler("GET", videoVariantPlaylistPath, videoVariantPlaylist.buildM3U8(), - "application/vnd.apple.mpegurl") - .withHeader("Access-Control-Allow-Origin", "*"), true + HttpConstantHandler( + "GET", + videoVariantPlaylistPath, + videoVariantPlaylist.buildM3U8(), + "application/vnd.apple.mpegurl" + ).withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("castLocalHls") - variantPlaylistReferences.add(HLS.VariantPlaylistReference(videoVariantPlaylistUrl, HLS.StreamInfo( - videoSource.bitrate, "${videoSource.width}x${videoSource.height}", videoSource.codec, null, null, if (audioSource != null) "audio" else null, if (subtitleSource != null) "subtitles" else null, null, null))) + variantPlaylistReferences.add( + HLS.VariantPlaylistReference( + videoVariantPlaylistUrl, HLS.StreamInfo( + videoSource.bitrate, + "${videoSource.width}x${videoSource.height}", + videoSource.codec, + null, + null, + if (audioSource != null) "audio" else null, + if (subtitleSource != null) "subtitles" else null, + null, + null + ) + ) + ) } if (audioSource != null) { _castServer.addHandlerWithAllowAllOptions( - HttpFileHandler("GET", audioPath, audioSource.container, audioSource.filePath) - .withHeader("Access-Control-Allow-Origin", "*"), true + HttpFileHandler( + "GET", + audioPath, + audioSource.container, + audioSource.filePath + ).withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("castLocalHls") - val duration = audioSource.duration ?: videoSource?.duration ?: throw Exception("Duration unknown") + val duration = + audioSource.duration ?: videoSource?.duration ?: throw Exception("Duration unknown") val audioVariantPlaylistPath = "/audio-playlist-${id}" val audioVariantPlaylistUrl = url + audioVariantPlaylistPath - val audioVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), audioUrl)) - val audioVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, null, audioVariantPlaylistSegments) + val audioVariantPlaylistSegments = + listOf(HLS.MediaSegment(duration.toDouble(), audioUrl)) + val audioVariantPlaylist = HLS.VariantPlaylist( + 3, duration.toInt(), 0, 0, null, null, null, audioVariantPlaylistSegments + ) _castServer.addHandlerWithAllowAllOptions( - HttpConstantHandler("GET", audioVariantPlaylistPath, audioVariantPlaylist.buildM3U8(), - "application/vnd.apple.mpegurl") - .withHeader("Access-Control-Allow-Origin", "*"), true + HttpConstantHandler( + "GET", + audioVariantPlaylistPath, + audioVariantPlaylist.buildM3U8(), + "application/vnd.apple.mpegurl" + ).withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("castLocalHls") - mediaRenditions.add(HLS.MediaRendition("AUDIO", audioVariantPlaylistUrl, "audio", "df", "default", true, true, true)) + mediaRenditions.add( + HLS.MediaRendition( + "AUDIO", audioVariantPlaylistUrl, "audio", "df", "default", true, true, true + ) + ) } if (subtitleSource != null) { _castServer.addHandlerWithAllowAllOptions( - HttpFileHandler("GET", subtitlePath, subtitleSource.format ?: "text/vtt", subtitleSource.filePath) - .withHeader("Access-Control-Allow-Origin", "*"), true + HttpFileHandler( + "GET", + subtitlePath, + subtitleSource.format ?: "text/vtt", + subtitleSource.filePath + ).withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("castLocalHls") - val duration = videoSource?.duration ?: audioSource?.duration ?: throw Exception("Duration unknown") + val duration = videoSource?.duration ?: audioSource?.duration + ?: throw Exception("Duration unknown") val subtitleVariantPlaylistPath = "/subtitle-playlist-${id}" val subtitleVariantPlaylistUrl = url + subtitleVariantPlaylistPath - val subtitleVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), subtitleUrl)) - val subtitleVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, null, subtitleVariantPlaylistSegments) + val subtitleVariantPlaylistSegments = + listOf(HLS.MediaSegment(duration.toDouble(), subtitleUrl)) + val subtitleVariantPlaylist = HLS.VariantPlaylist( + 3, duration.toInt(), 0, 0, null, null, null, subtitleVariantPlaylistSegments + ) _castServer.addHandlerWithAllowAllOptions( - HttpConstantHandler("GET", subtitleVariantPlaylistPath, subtitleVariantPlaylist.buildM3U8(), - "application/vnd.apple.mpegurl") - .withHeader("Access-Control-Allow-Origin", "*"), true + HttpConstantHandler( + "GET", + subtitleVariantPlaylistPath, + subtitleVariantPlaylist.buildM3U8(), + "application/vnd.apple.mpegurl" + ).withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("castLocalHls") - mediaRenditions.add(HLS.MediaRendition("SUBTITLES", subtitleVariantPlaylistUrl, "subtitles", "df", "default", true, true, true)) + mediaRenditions.add( + HLS.MediaRendition( + "SUBTITLES", + subtitleVariantPlaylistUrl, + "subtitles", + "df", + "default", + true, + true, + true + ) + ) } - val masterPlaylist = HLS.MasterPlaylist(variantPlaylistReferences, mediaRenditions, listOf(), true) + val masterPlaylist = + HLS.MasterPlaylist(variantPlaylistReferences, mediaRenditions, listOf(), true) _castServer.addHandlerWithAllowAllOptions( - HttpConstantHandler("GET", hlsPath, masterPlaylist.buildM3U8(), - "application/vnd.apple.mpegurl") - .withHeader("Access-Control-Allow-Origin", "*"), true + HttpConstantHandler( + "GET", hlsPath, masterPlaylist.buildM3U8(), "application/vnd.apple.mpegurl" + ).withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("castLocalHls") - Logger.i(TAG, "added new castLocalHls handlers (hlsPath: $hlsPath, videoPath: $videoPath, audioPath: $audioPath, subtitlePath: $subtitlePath).") - ad.loadVideo("BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble(), speed) + Logger.i( + TAG, + "added new castLocalHls handlers (hlsPath: $hlsPath, videoPath: $videoPath, audioPath: $audioPath, subtitlePath: $subtitlePath)." + ) + ad.loadVideo( + "BUFFERED", + "application/vnd.apple.mpegurl", + hlsUrl, + resumePosition, + video.duration.toDouble(), + speed, + metadataFromVideo(video) + ) return listOf(hlsUrl, videoUrl, audioUrl, subtitleUrl) } - private fun castLocalDash(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double, speed: Double?) : List { + private fun castLocalDash( + video: IPlatformVideoDetails, + videoSource: LocalVideoSource?, + audioSource: LocalAudioSource?, + subtitleSource: LocalSubtitleSource?, + resumePosition: Double, + speed: Double? + ): List { val ad = activeDevice ?: return listOf(); val url = getLocalUrl(ad); @@ -717,40 +782,73 @@ class StateCasting { val audioUrl = url + audioPath; val subtitleUrl = url + subtitlePath; - val dashContent = DashBuilder.generateOnDemandDash(videoSource, videoUrl, audioSource, audioUrl, subtitleSource, subtitleUrl); + val dashContent = DashBuilder.generateOnDemandDash( + videoSource, videoUrl, audioSource, audioUrl, subtitleSource, subtitleUrl + ); Logger.v(TAG) { "Dash manifest: $dashContent" }; _castServer.addHandlerWithAllowAllOptions( - HttpConstantHandler("GET", dashPath, dashContent, - "application/dash+xml") - .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("cast"); + HttpConstantHandler( + "GET", dashPath, dashContent, "application/dash+xml" + ).withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("cast"); if (videoSource != null) { _castServer.addHandlerWithAllowAllOptions( - HttpFileHandler("GET", videoPath, videoSource.container, videoSource.filePath) - .withHeader("Access-Control-Allow-Origin", "*"), true + HttpFileHandler( + "GET", + videoPath, + videoSource.container, + videoSource.filePath + ).withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("cast"); } if (audioSource != null) { _castServer.addHandlerWithAllowAllOptions( - HttpFileHandler("GET", audioPath, audioSource.container, audioSource.filePath) - .withHeader("Access-Control-Allow-Origin", "*"), true + HttpFileHandler( + "GET", + audioPath, + audioSource.container, + audioSource.filePath + ).withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("cast"); } if (subtitleSource != null) { _castServer.addHandlerWithAllowAllOptions( - HttpFileHandler("GET", subtitlePath, subtitleSource.format ?: "text/vtt", subtitleSource.filePath) - .withHeader("Access-Control-Allow-Origin", "*"), true + HttpFileHandler( + "GET", + subtitlePath, + subtitleSource.format ?: "text/vtt", + subtitleSource.filePath + ).withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("cast"); } - Logger.i(TAG, "added new castLocalDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath, subtitlePath: $subtitlePath)."); - ad.loadVideo("BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble(), speed); + Logger.i( + TAG, + "added new castLocalDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath, subtitlePath: $subtitlePath)." + ); + ad.loadVideo( + "BUFFERED", + "application/dash+xml", + dashUrl, + resumePosition, + video.duration.toDouble(), + speed, + metadataFromVideo(video) + ); return listOf(dashUrl, videoUrl, audioUrl, subtitleUrl); } - private suspend fun castDashDirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List { + private suspend fun castDashDirect( + contentResolver: ContentResolver, + video: IPlatformVideoDetails, + videoSource: IVideoUrlSource?, + audioSource: IAudioUrlSource?, + subtitleSource: ISubtitleSource?, + resumePosition: Double, + speed: Double? + ): List { val ad = activeDevice ?: return listOf(); val proxyStreams = shouldProxyStreams(ad, videoSource, audioSource) @@ -761,8 +859,8 @@ class StateCasting { val audioPath = "/audio-${id}" val subtitlePath = "/subtitle-${id}" - val videoUrl = if(proxyStreams) url + videoPath else videoSource?.getVideoUrl(); - val audioUrl = if(proxyStreams) url + audioPath else audioSource?.getAudioUrl(); + val videoUrl = if (proxyStreams) url + videoPath else videoSource?.getVideoUrl(); + val audioUrl = if (proxyStreams) url + audioPath else audioSource?.getAudioUrl(); val subtitlesUri = if (subtitleSource != null) withContext(Dispatchers.IO) { return@withContext subtitleSource.getSubtitlesURI(); @@ -770,7 +868,7 @@ class StateCasting { var subtitlesUrl: String? = null; if (subtitlesUri != null) { - if(subtitlesUri.scheme == "file") { + if (subtitlesUri.scheme == "file") { var content: String? = null; val inputStream = contentResolver.openInputStream(subtitlesUri); inputStream?.use { stream -> @@ -780,8 +878,9 @@ class StateCasting { if (content != null) { _castServer.addHandlerWithAllowAllOptions( - HttpConstantHandler("GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt") - .withHeader("Access-Control-Allow-Origin", "*"), true + HttpConstantHandler( + "GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt" + ).withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("cast"); } @@ -793,29 +892,59 @@ class StateCasting { if (videoSource != null) { _castServer.addHandlerWithAllowAllOptions( - HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true) - .withInjectedHost() - .withHeader("Access-Control-Allow-Origin", "*"), true + HttpProxyHandler( + "GET", + videoPath, + videoSource.getVideoUrl(), + true + ).withInjectedHost().withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("cast"); } if (audioSource != null) { _castServer.addHandlerWithAllowAllOptions( - HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true) - .withInjectedHost() - .withHeader("Access-Control-Allow-Origin", "*"), true + HttpProxyHandler( + "GET", + audioPath, + audioSource.getAudioUrl(), + true + ).withInjectedHost().withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("cast"); } - val content = DashBuilder.generateOnDemandDash(videoSource, videoUrl, audioSource, audioUrl, subtitleSource, subtitlesUrl); + val content = DashBuilder.generateOnDemandDash( + videoSource, videoUrl, audioSource, audioUrl, subtitleSource, subtitlesUrl + ); - Logger.i(TAG, "Direct dash cast to casting device (videoUrl: $videoUrl, audioUrl: $audioUrl)."); + Logger.i( + TAG, "Direct dash cast to casting device (videoUrl: $videoUrl, audioUrl: $audioUrl)." + ); Logger.v(TAG) { "Dash manifest: $content" }; - ad.loadContent("application/dash+xml", content, resumePosition, video.duration.toDouble(), speed); + ad.loadContent( + "application/dash+xml", + content, + resumePosition, + video.duration.toDouble(), + speed, + metadataFromVideo(video) + ); - return listOf(videoUrl ?: "", audioUrl ?: "", subtitlesUrl ?: "", videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString()); + return listOf( + videoUrl ?: "", + audioUrl ?: "", + subtitlesUrl ?: "", + videoSource?.getVideoUrl() ?: "", + audioSource?.getAudioUrl() ?: "", + subtitlesUri.toString() + ); } - private fun castProxiedHls(video: IPlatformVideoDetails, sourceUrl: String, codec: String?, resumePosition: Double, speed: Double?): List { + private fun castProxiedHls( + video: IPlatformVideoDetails, + sourceUrl: String, + codec: String?, + resumePosition: Double, + speed: Double? + ): List { _castServer.removeAllHandlers("castProxiedHlsMaster") val ad = activeDevice ?: return listOf(); @@ -826,122 +955,164 @@ class StateCasting { val hlsUrl = url + hlsPath Logger.i(TAG, "HLS url: $hlsUrl"); - _castServer.addHandlerWithAllowAllOptions(HttpFunctionHandler("GET", hlsPath) { masterContext -> - _castServer.removeAllHandlers("castProxiedHlsVariant") + _castServer.addHandlerWithAllowAllOptions( + HttpFunctionHandler( + "GET", hlsPath + ) { masterContext -> + _castServer.removeAllHandlers("castProxiedHlsVariant") - val headers = masterContext.headers.clone() - headers["Content-Type"] = "application/vnd.apple.mpegurl"; + val headers = masterContext.headers.clone() + headers["Content-Type"] = "application/vnd.apple.mpegurl"; - val masterPlaylistResponse = _client.get(sourceUrl) - check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" } + val masterPlaylistResponse = _client.get(sourceUrl) + check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" } - val masterPlaylistContent = masterPlaylistResponse.body?.string() - ?: throw Exception("Master playlist content is empty") + val masterPlaylistContent = masterPlaylistResponse.body?.string() + ?: throw Exception("Master playlist content is empty") - val masterPlaylist: HLS.MasterPlaylist - try { - masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, sourceUrl) - } catch (e: Throwable) { - if (masterPlaylistContent.lines().any { it.startsWith("#EXTINF:") }) { - //This is a variant playlist, not a master playlist - Logger.i(TAG, "HLS casting as variant playlist (codec: $codec): $hlsUrl"); + val masterPlaylist: HLS.MasterPlaylist + try { + masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, sourceUrl) + } catch (e: Throwable) { + if (masterPlaylistContent.lines().any { it.startsWith("#EXTINF:") }) { + //This is a variant playlist, not a master playlist + Logger.i(TAG, "HLS casting as variant playlist (codec: $codec): $hlsUrl"); - val vpHeaders = masterContext.headers.clone() - vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl"; - - val variantPlaylist = HLS.parseVariantPlaylist(masterPlaylistContent, sourceUrl) - val proxiedVariantPlaylist = proxyVariantPlaylist(url, id, variantPlaylist, video.isLive) - val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8() - masterContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8); - return@HttpFunctionHandler - } else { - throw e - } - } - - Logger.i(TAG, "HLS casting as master playlist: $hlsUrl"); - - val newVariantPlaylistRefs = arrayListOf() - val newMediaRenditions = arrayListOf() - val newMasterPlaylist = HLS.MasterPlaylist(newVariantPlaylistRefs, newMediaRenditions, masterPlaylist.sessionDataList, masterPlaylist.independentSegments) - - for (variantPlaylistRef in masterPlaylist.variantPlaylistsRefs) { - val playlistId = UUID.randomUUID(); - val newPlaylistPath = "/hls-playlist-${playlistId}" - val newPlaylistUrl = url + newPlaylistPath; - - _castServer.addHandlerWithAllowAllOptions(HttpFunctionHandler("GET", newPlaylistPath) { vpContext -> - val vpHeaders = vpContext.headers.clone() - vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl"; - - val response = _client.get(variantPlaylistRef.url) - check(response.isOk) { "Failed to get variant playlist: ${response.code}" } - - val vpContent = response.body?.string() - ?: throw Exception("Variant playlist content is empty") - - val variantPlaylist = HLS.parseVariantPlaylist(vpContent, variantPlaylistRef.url) - val proxiedVariantPlaylist = proxyVariantPlaylist(url, playlistId, variantPlaylist, video.isLive) - val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8() - vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8); - }.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castProxiedHlsVariant") - - newVariantPlaylistRefs.add(HLS.VariantPlaylistReference( - newPlaylistUrl, - variantPlaylistRef.streamInfo - )) - } - - for (mediaRendition in masterPlaylist.mediaRenditions) { - val playlistId = UUID.randomUUID() - - var newPlaylistUrl: String? = null - if (mediaRendition.uri != null) { - val newPlaylistPath = "/hls-playlist-${playlistId}" - newPlaylistUrl = url + newPlaylistPath - - _castServer.addHandlerWithAllowAllOptions(HttpFunctionHandler("GET", newPlaylistPath) { vpContext -> - val vpHeaders = vpContext.headers.clone() + val vpHeaders = masterContext.headers.clone() vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl"; - val response = _client.get(mediaRendition.uri) - check(response.isOk) { "Failed to get variant playlist: ${response.code}" } - - val vpContent = response.body?.string() - ?: throw Exception("Variant playlist content is empty") - - val variantPlaylist = HLS.parseVariantPlaylist(vpContent, mediaRendition.uri) - val proxiedVariantPlaylist = proxyVariantPlaylist(url, playlistId, variantPlaylist, video.isLive) + val variantPlaylist = + HLS.parseVariantPlaylist(masterPlaylistContent, sourceUrl) + val proxiedVariantPlaylist = + proxyVariantPlaylist(url, id, variantPlaylist, video.isLive) val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8() - vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8); - }.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castProxiedHlsVariant") + masterContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8); + return@HttpFunctionHandler + } else { + throw e + } } - newMediaRenditions.add(HLS.MediaRendition( - mediaRendition.type, - newPlaylistUrl, - mediaRendition.groupID, - mediaRendition.language, - mediaRendition.name, - mediaRendition.isDefault, - mediaRendition.isAutoSelect, - mediaRendition.isForced - )) - } + Logger.i(TAG, "HLS casting as master playlist: $hlsUrl"); - masterContext.respondCode(200, headers, newMasterPlaylist.buildM3U8()); - }.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castProxiedHlsMaster") + val newVariantPlaylistRefs = arrayListOf() + val newMediaRenditions = arrayListOf() + val newMasterPlaylist = HLS.MasterPlaylist( + newVariantPlaylistRefs, + newMediaRenditions, + masterPlaylist.sessionDataList, + masterPlaylist.independentSegments + ) + + for (variantPlaylistRef in masterPlaylist.variantPlaylistsRefs) { + val playlistId = UUID.randomUUID(); + val newPlaylistPath = "/hls-playlist-${playlistId}" + val newPlaylistUrl = url + newPlaylistPath; + + _castServer.addHandlerWithAllowAllOptions( + HttpFunctionHandler( + "GET", newPlaylistPath + ) { vpContext -> + val vpHeaders = vpContext.headers.clone() + vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl"; + + val response = _client.get(variantPlaylistRef.url) + check(response.isOk) { "Failed to get variant playlist: ${response.code}" } + + val vpContent = response.body?.string() + ?: throw Exception("Variant playlist content is empty") + + val variantPlaylist = + HLS.parseVariantPlaylist(vpContent, variantPlaylistRef.url) + val proxiedVariantPlaylist = + proxyVariantPlaylist(url, playlistId, variantPlaylist, video.isLive) + val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8() + vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8); + }.withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("castProxiedHlsVariant") + + newVariantPlaylistRefs.add( + HLS.VariantPlaylistReference( + newPlaylistUrl, variantPlaylistRef.streamInfo + ) + ) + } + + for (mediaRendition in masterPlaylist.mediaRenditions) { + val playlistId = UUID.randomUUID() + + var newPlaylistUrl: String? = null + if (mediaRendition.uri != null) { + val newPlaylistPath = "/hls-playlist-${playlistId}" + newPlaylistUrl = url + newPlaylistPath + + _castServer.addHandlerWithAllowAllOptions( + HttpFunctionHandler( + "GET", newPlaylistPath + ) { vpContext -> + val vpHeaders = vpContext.headers.clone() + vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl"; + + val response = _client.get(mediaRendition.uri) + check(response.isOk) { "Failed to get variant playlist: ${response.code}" } + + val vpContent = response.body?.string() + ?: throw Exception("Variant playlist content is empty") + + val variantPlaylist = + HLS.parseVariantPlaylist(vpContent, mediaRendition.uri) + val proxiedVariantPlaylist = proxyVariantPlaylist( + url, playlistId, variantPlaylist, video.isLive + ) + val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8() + vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8); + }.withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("castProxiedHlsVariant") + } + + newMediaRenditions.add( + HLS.MediaRendition( + mediaRendition.type, + newPlaylistUrl, + mediaRendition.groupID, + mediaRendition.language, + mediaRendition.name, + mediaRendition.isDefault, + mediaRendition.isAutoSelect, + mediaRendition.isForced + ) + ) + } + + masterContext.respondCode(200, headers, newMasterPlaylist.buildM3U8()); + }.withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("castProxiedHlsMaster") Logger.i(TAG, "added new castHlsIndirect handlers (hlsPath: $hlsPath)."); //ChromeCast is sometimes funky with resume position 0 - val hackfixResumePosition = if (ad is ChromecastCastingDevice && !video.isLive && resumePosition == 0.0) 0.1 else resumePosition; - ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, hackfixResumePosition, video.duration.toDouble(), speed); + val hackfixResumePosition = + if (ad.protocolType == CastProtocolType.CHROMECAST && !video.isLive && resumePosition == 0.0) 0.1 else resumePosition; + ad.loadVideo( + if (video.isLive) "LIVE" else "BUFFERED", + "application/vnd.apple.mpegurl", + hlsUrl, + hackfixResumePosition, + video.duration.toDouble(), + speed, + metadataFromVideo(video) + ); return listOf(hlsUrl); } - private fun proxyVariantPlaylist(url: String, playlistId: UUID, variantPlaylist: HLS.VariantPlaylist, isLive: Boolean, proxySegments: Boolean = true): HLS.VariantPlaylist { + private fun proxyVariantPlaylist( + url: String, + playlistId: UUID, + variantPlaylist: HLS.VariantPlaylist, + isLive: Boolean, + proxySegments: Boolean = true + ): HLS.VariantPlaylist { val newSegments = arrayListOf() if (proxySegments) { @@ -965,29 +1136,37 @@ class StateCasting { ) } - private fun proxySegment(url: String, playlistId: UUID, segment: HLS.Segment, index: Long): HLS.Segment { + private fun proxySegment( + url: String, playlistId: UUID, segment: HLS.Segment, index: Long + ): HLS.Segment { if (segment is HLS.MediaSegment) { val newSegmentPath = "/hls-playlist-${playlistId}-segment-${index}" val newSegmentUrl = url + newSegmentPath; if (_castServer.getHandler("GET", newSegmentPath) == null) { _castServer.addHandlerWithAllowAllOptions( - HttpProxyHandler("GET", newSegmentPath, segment.uri, true) - .withInjectedHost() + HttpProxyHandler("GET", newSegmentPath, segment.uri, true).withInjectedHost() .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("castProxiedHlsVariant") } return HLS.MediaSegment( - segment.duration, - newSegmentUrl + segment.duration, newSegmentUrl ) } else { return segment } } - private suspend fun castHlsIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List { + private suspend fun castHlsIndirect( + contentResolver: ContentResolver, + video: IPlatformVideoDetails, + videoSource: IVideoUrlSource?, + audioSource: IAudioUrlSource?, + subtitleSource: ISubtitleSource?, + resumePosition: Double, + speed: Double? + ): List { val ad = activeDevice ?: return listOf(); val url = getLocalUrl(ad); val id = UUID.randomUUID(); @@ -1004,24 +1183,38 @@ class StateCasting { val audioPath = "/audio-${id}" val audioUrl = url + audioPath - val duration = audioSource.duration ?: videoSource?.duration ?: throw Exception("Duration unknown") + val duration = + audioSource.duration ?: videoSource?.duration ?: throw Exception("Duration unknown") val audioVariantPlaylistPath = "/audio-playlist-${id}" val audioVariantPlaylistUrl = url + audioVariantPlaylistPath - val audioVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), audioUrl)) - val audioVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, null, audioVariantPlaylistSegments) + val audioVariantPlaylistSegments = + listOf(HLS.MediaSegment(duration.toDouble(), audioUrl)) + val audioVariantPlaylist = HLS.VariantPlaylist( + 3, duration.toInt(), 0, 0, null, null, null, audioVariantPlaylistSegments + ) _castServer.addHandlerWithAllowAllOptions( - HttpConstantHandler("GET", audioVariantPlaylistPath, audioVariantPlaylist.buildM3U8(), - "application/vnd.apple.mpegurl") - .withHeader("Access-Control-Allow-Origin", "*"), true + HttpConstantHandler( + "GET", + audioVariantPlaylistPath, + audioVariantPlaylist.buildM3U8(), + "application/vnd.apple.mpegurl" + ).withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("castHlsIndirectVariant"); - mediaRenditions.add(HLS.MediaRendition("AUDIO", audioVariantPlaylistUrl, "audio", "df", "default", true, true, true)) + mediaRenditions.add( + HLS.MediaRendition( + "AUDIO", audioVariantPlaylistUrl, "audio", "df", "default", true, true, true + ) + ) _castServer.addHandlerWithAllowAllOptions( - HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true) - .withInjectedHost() - .withHeader("Access-Control-Allow-Origin", "*"), true + HttpProxyHandler( + "GET", + audioPath, + audioSource.getAudioUrl(), + true + ).withInjectedHost().withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("castHlsIndirectVariant"); } @@ -1032,7 +1225,7 @@ class StateCasting { var subtitlesUrl: String? = null; if (subtitlesUri != null) { val subtitlePath = "/subtitles-${id}" - if(subtitlesUri.scheme == "file") { + if (subtitlesUri.scheme == "file") { var content: String? = null; val inputStream = contentResolver.openInputStream(subtitlesUri); inputStream?.use { stream -> @@ -1042,8 +1235,9 @@ class StateCasting { if (content != null) { _castServer.addHandlerWithAllowAllOptions( - HttpConstantHandler("GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt") - .withHeader("Access-Control-Allow-Origin", "*"), true + HttpConstantHandler( + "GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt" + ).withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("castHlsIndirectVariant"); } @@ -1054,19 +1248,37 @@ class StateCasting { } if (subtitlesUrl != null) { - val duration = videoSource?.duration ?: audioSource?.duration ?: throw Exception("Duration unknown") + val duration = videoSource?.duration ?: audioSource?.duration + ?: throw Exception("Duration unknown") val subtitleVariantPlaylistPath = "/subtitle-playlist-${id}" val subtitleVariantPlaylistUrl = url + subtitleVariantPlaylistPath - val subtitleVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), subtitlesUrl)) - val subtitleVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, null, subtitleVariantPlaylistSegments) + val subtitleVariantPlaylistSegments = + listOf(HLS.MediaSegment(duration.toDouble(), subtitlesUrl)) + val subtitleVariantPlaylist = HLS.VariantPlaylist( + 3, duration.toInt(), 0, 0, null, null, null, subtitleVariantPlaylistSegments + ) _castServer.addHandlerWithAllowAllOptions( - HttpConstantHandler("GET", subtitleVariantPlaylistPath, subtitleVariantPlaylist.buildM3U8(), - "application/vnd.apple.mpegurl") - .withHeader("Access-Control-Allow-Origin", "*"), true + HttpConstantHandler( + "GET", + subtitleVariantPlaylistPath, + subtitleVariantPlaylist.buildM3U8(), + "application/vnd.apple.mpegurl" + ).withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("castHlsIndirectVariant"); - mediaRenditions.add(HLS.MediaRendition("SUBTITLES", subtitleVariantPlaylistUrl, "subtitles", "df", "default", true, true, true)) + mediaRenditions.add( + HLS.MediaRendition( + "SUBTITLES", + subtitleVariantPlaylistUrl, + "subtitles", + "df", + "default", + true, + true, + true + ) + ) } if (videoSource != null) { @@ -1076,51 +1288,83 @@ class StateCasting { val duration = videoSource.duration val videoVariantPlaylistPath = "/video-playlist-${id}" val videoVariantPlaylistUrl = url + videoVariantPlaylistPath - val videoVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), videoUrl)) - val videoVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, null, videoVariantPlaylistSegments) + val videoVariantPlaylistSegments = + listOf(HLS.MediaSegment(duration.toDouble(), videoUrl)) + val videoVariantPlaylist = HLS.VariantPlaylist( + 3, duration.toInt(), 0, 0, null, null, null, videoVariantPlaylistSegments + ) _castServer.addHandlerWithAllowAllOptions( - HttpConstantHandler("GET", videoVariantPlaylistPath, videoVariantPlaylist.buildM3U8(), - "application/vnd.apple.mpegurl") - .withHeader("Access-Control-Allow-Origin", "*"), true + HttpConstantHandler( + "GET", + videoVariantPlaylistPath, + videoVariantPlaylist.buildM3U8(), + "application/vnd.apple.mpegurl" + ).withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("castHlsIndirectVariant"); - variantPlaylistReferences.add(HLS.VariantPlaylistReference(videoVariantPlaylistUrl, HLS.StreamInfo( - videoSource.bitrate ?: 0, - "${videoSource.width}x${videoSource.height}", - videoSource.codec, - null, - null, - if (audioSource != null) "audio" else null, - if (subtitleSource != null) "subtitles" else null, - null, null))) + variantPlaylistReferences.add( + HLS.VariantPlaylistReference( + videoVariantPlaylistUrl, HLS.StreamInfo( + videoSource.bitrate ?: 0, + "${videoSource.width}x${videoSource.height}", + videoSource.codec, + null, + null, + if (audioSource != null) "audio" else null, + if (subtitleSource != null) "subtitles" else null, + null, + null + ) + ) + ) _castServer.addHandlerWithAllowAllOptions( - HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true) - .withInjectedHost() - .withHeader("Access-Control-Allow-Origin", "*"), true + HttpProxyHandler( + "GET", + videoPath, + videoSource.getVideoUrl(), + true + ).withInjectedHost().withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("castHlsIndirectVariant"); } - val masterPlaylist = HLS.MasterPlaylist(variantPlaylistReferences, mediaRenditions, listOf(), true) + val masterPlaylist = + HLS.MasterPlaylist(variantPlaylistReferences, mediaRenditions, listOf(), true) _castServer.addHandlerWithAllowAllOptions( - HttpConstantHandler("GET", hlsPath, masterPlaylist.buildM3U8(), - "application/vnd.apple.mpegurl") - .withHeader("Access-Control-Allow-Origin", "*"), true + HttpConstantHandler( + "GET", hlsPath, masterPlaylist.buildM3U8(), "application/vnd.apple.mpegurl" + ).withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("castHlsIndirectMaster") Logger.i(TAG, "added new castHls handlers (hlsPath: $hlsPath)."); - ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble(), speed); + ad.loadVideo( + if (video.isLive) "LIVE" else "BUFFERED", + "application/vnd.apple.mpegurl", + hlsUrl, + resumePosition, + video.duration.toDouble(), + speed, + metadataFromVideo(video) + ); - return listOf(hlsUrl, videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString()); + return listOf( + hlsUrl, + videoSource?.getVideoUrl() ?: "", + audioSource?.getAudioUrl() ?: "", + subtitlesUri.toString() + ); } - private fun shouldProxyStreams(castingDevice: CastingDevice, videoSource: IVideoSource?, audioSource: IAudioSource?): Boolean { - val hasRequestModifier = (videoSource as? JSSource)?.hasRequestModifier == true || (audioSource as? JSSource)?.hasRequestModifier == true - return Settings.instance.casting.alwaysProxyRequests || castingDevice !is FCastCastingDevice || hasRequestModifier - } - - private suspend fun castDashIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List { + private suspend fun castDashIndirect( + contentResolver: ContentResolver, + video: IPlatformVideoDetails, + videoSource: IVideoUrlSource?, + audioSource: IAudioUrlSource?, + subtitleSource: ISubtitleSource?, + resumePosition: Double, + speed: Double? + ): List { val ad = activeDevice ?: return listOf(); val proxyStreams = shouldProxyStreams(ad, videoSource, audioSource) @@ -1135,8 +1379,8 @@ class StateCasting { val dashUrl = url + dashPath; Logger.i(TAG, "DASH url: $dashUrl"); - val videoUrl = if(proxyStreams) url + videoPath else videoSource?.getVideoUrl(); - val audioUrl = if(proxyStreams) url + audioPath else audioSource?.getAudioUrl(); + val videoUrl = if (proxyStreams) url + videoPath else videoSource?.getVideoUrl(); + val audioUrl = if (proxyStreams) url + audioPath else audioSource?.getAudioUrl(); val subtitlesUri = if (subtitleSource != null) withContext(Dispatchers.IO) { return@withContext subtitleSource.getSubtitlesURI(); @@ -1147,7 +1391,7 @@ class StateCasting { var subtitlesUrl: String? = null; if (subtitlesUri != null) { - if(subtitlesUri.scheme == "file") { + if (subtitlesUri.scheme == "file") { var content: String? = null; val inputStream = contentResolver.openInputStream(subtitlesUri); inputStream?.use { stream -> @@ -1157,8 +1401,9 @@ class StateCasting { if (content != null) { _castServer.addHandlerWithAllowAllOptions( - HttpConstantHandler("GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt") - .withHeader("Access-Control-Allow-Origin", "*"), true + HttpConstantHandler( + "GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt" + ).withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("cast"); } @@ -1168,37 +1413,64 @@ class StateCasting { } } - val dashContent = DashBuilder.generateOnDemandDash(videoSource, videoUrl, audioSource, audioUrl, subtitleSource, subtitlesUrl); + val dashContent = DashBuilder.generateOnDemandDash( + videoSource, videoUrl, audioSource, audioUrl, subtitleSource, subtitlesUrl + ); Logger.v(TAG) { "Dash manifest: $dashContent" }; _castServer.addHandlerWithAllowAllOptions( - HttpConstantHandler("GET", dashPath, dashContent, - "application/dash+xml") - .withHeader("Access-Control-Allow-Origin", "*"), true + HttpConstantHandler( + "GET", dashPath, dashContent, "application/dash+xml" + ).withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("cast"); if (videoSource != null) { _castServer.addHandlerWithAllowAllOptions( - HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true) - .withInjectedHost() - .withHeader("Access-Control-Allow-Origin", "*"), true + HttpProxyHandler( + "GET", + videoPath, + videoSource.getVideoUrl(), + true + ).withInjectedHost().withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("cast"); } if (audioSource != null) { _castServer.addHandlerWithAllowAllOptions( - HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true) - .withInjectedHost() - .withHeader("Access-Control-Allow-Origin", "*"), true + HttpProxyHandler( + "GET", + audioPath, + audioSource.getAudioUrl(), + true + ).withInjectedHost().withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("cast"); } - Logger.i(TAG, "added new castDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath)."); - ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble(), speed); + Logger.i( + TAG, + "added new castDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath)." + ); + ad.loadVideo( + if (video.isLive) "LIVE" else "BUFFERED", + "application/dash+xml", + dashUrl, + resumePosition, + video.duration.toDouble(), + speed, + metadataFromVideo(video) + ); - return listOf(dashUrl, videoUrl ?: "", audioUrl ?: "", subtitlesUrl ?: "", videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString()); + return listOf( + dashUrl, + videoUrl ?: "", + audioUrl ?: "", + subtitlesUrl ?: "", + videoSource?.getVideoUrl() ?: "", + audioSource?.getAudioUrl() ?: "", + subtitlesUri.toString() + ); } - private fun cleanExecutors() { + fun cleanExecutors() { if (_videoExecutor != null) { _videoExecutor?.cleanup() _videoExecutor = null @@ -1210,7 +1482,7 @@ class StateCasting { } } - private fun getLocalUrl(ad: CastingDevice): String { + fun getLocalUrl(ad: CastingDevice): String { var address = ad.localAddress!! if (Settings.instance.casting.allowLinkLocalIpv4) { if (address.isLinkLocalAddress && address is Inet6Address) { @@ -1227,7 +1499,18 @@ class StateCasting { } @OptIn(UnstableApi::class) - 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 { + 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() @@ -1253,7 +1536,7 @@ class StateCasting { var subtitlesUrl: String? = null; if (subtitlesUri != null) { - if(subtitlesUri.scheme == "file") { + if (subtitlesUri.scheme == "file") { var content: String? = null; val inputStream = contentResolver.openInputStream(subtitlesUri); inputStream?.use { stream -> @@ -1263,8 +1546,9 @@ class StateCasting { if (content != null) { _castServer.addHandlerWithAllowAllOptions( - HttpConstantHandler("GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt") - .withHeader("Access-Control-Allow-Origin", "*"), true + HttpConstantHandler( + "GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt" + ).withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("cast"); } @@ -1317,16 +1601,25 @@ class StateCasting { } for (representation in representationRegex.findAll(dashContent)) { - val mediaType = representation.groups[1]?.value ?: throw Exception("Media type should be found") + val mediaType = + representation.groups[1]?.value ?: throw Exception("Media type should be found") dashContent = mediaInitializationRegex.replace(dashContent) { if (it.range.first < representation.range.first || it.range.last > representation.range.last) { return@replace it.value } if (mediaType.startsWith("video/")) { - return@replace "${it.groups[1]!!.value}=\"${videoUrl}?url=${URLEncoder.encode(it.groups[2]!!.value, "UTF-8").replace("%24Number%24", "\$Number\$")}&mediaType=${URLEncoder.encode(mediaType, "UTF-8")}\"" + return@replace "${it.groups[1]!!.value}=\"${videoUrl}?url=${ + URLEncoder.encode( + it.groups[2]!!.value, "UTF-8" + ).replace("%24Number%24", "\$Number\$") + }&mediaType=${URLEncoder.encode(mediaType, "UTF-8")}\"" } else if (mediaType.startsWith("audio/")) { - return@replace "${it.groups[1]!!.value}=\"${audioUrl}?url=${URLEncoder.encode(it.groups[2]!!.value, "UTF-8").replace("%24Number%24", "\$Number\$")}&mediaType=${URLEncoder.encode(mediaType, "UTF-8")}\"" + return@replace "${it.groups[1]!!.value}=\"${audioUrl}?url=${ + URLEncoder.encode( + it.groups[2]!!.value, "UTF-8" + ).replace("%24Number%24", "\$Number\$") + }&mediaType=${URLEncoder.encode(mediaType, "UTF-8")}\"" } else { throw Exception("Expected audio or video") } @@ -1354,20 +1647,26 @@ class StateCasting { Logger.v(TAG) { "Dash manifest: $dashContent" }; _castServer.addHandlerWithAllowAllOptions( - HttpConstantHandler("GET", dashPath, dashContent, - "application/dash+xml") - .withHeader("Access-Control-Allow-Origin", "*"), true + HttpConstantHandler( + "GET", dashPath, dashContent, "application/dash+xml" + ).withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("castDashRaw"); if (videoSource != null) { _castServer.addHandlerWithAllowAllOptions( HttpFunctionHandler("GET", videoPath) { httpContext -> - val originalUrl = httpContext.query["url"]?.let { URLDecoder.decode(it, "UTF-8") } ?: return@HttpFunctionHandler - val mediaType = httpContext.query["mediaType"]?.let { URLDecoder.decode(it, "UTF-8") } ?: return@HttpFunctionHandler + val originalUrl = + httpContext.query["url"]?.let { URLDecoder.decode(it, "UTF-8") } + ?: return@HttpFunctionHandler + val mediaType = + httpContext.query["mediaType"]?.let { URLDecoder.decode(it, "UTF-8") } + ?: return@HttpFunctionHandler val videoExecutor = _videoExecutor; if (videoExecutor != null) { - val data = videoExecutor.executeRequest("GET", originalUrl, null, httpContext.headers) + val data = videoExecutor.executeRequest( + "GET", originalUrl, null, httpContext.headers + ) httpContext.respondBytes(200, HttpHeaders().apply { put("Content-Type", mediaType) }, data); @@ -1380,12 +1679,18 @@ class StateCasting { if (audioSource != null) { _castServer.addHandlerWithAllowAllOptions( HttpFunctionHandler("GET", audioPath) { httpContext -> - val originalUrl = httpContext.query["url"]?.let { URLDecoder.decode(it, "UTF-8") } ?: return@HttpFunctionHandler - val mediaType = httpContext.query["mediaType"]?.let { URLDecoder.decode(it, "UTF-8") } ?: return@HttpFunctionHandler + val originalUrl = + httpContext.query["url"]?.let { URLDecoder.decode(it, "UTF-8") } + ?: return@HttpFunctionHandler + val mediaType = + httpContext.query["mediaType"]?.let { URLDecoder.decode(it, "UTF-8") } + ?: return@HttpFunctionHandler val audioExecutor = _audioExecutor; if (audioExecutor != null) { - val data = audioExecutor.executeRequest("GET", originalUrl, null, httpContext.headers) + val data = audioExecutor.executeRequest( + "GET", originalUrl, null, httpContext.headers + ) httpContext.respondBytes(200, HttpHeaders().apply { put("Content-Type", mediaType) }, data); @@ -1396,164 +1701,73 @@ class StateCasting { ).withTag("castDashRaw"); } - Logger.i(TAG, "added new castDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath)."); - ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble(), speed); + Logger.i( + TAG, + "added new castDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath)." + ); + ad.loadVideo( + if (video.isLive) "LIVE" else "BUFFERED", + "application/dash+xml", + dashUrl, + resumePosition, + video.duration.toDouble(), + speed, + metadataFromVideo(video) + ); return listOf() } - private fun deviceFromCastingDeviceInfo(deviceInfo: CastingDeviceInfo): CastingDevice { - return when (deviceInfo.type) { - CastProtocolType.CHROMECAST -> { - ChromecastCastingDevice(deviceInfo); - } - CastProtocolType.AIRPLAY -> { - AirPlayCastingDevice(deviceInfo); - } - CastProtocolType.FCAST -> { - FCastCastingDevice(deviceInfo); - } - } + fun addRememberedDevice(deviceInfo: CastingDeviceInfo): CastingDeviceInfo { + val device = deviceFromInfo(deviceInfo); + return addRememberedDevice(device); } - private fun addOrUpdateChromeCastDevice(name: String, addresses: Array, port: Int) { - return addOrUpdateCastDevice(name, - deviceFactory = { ChromecastCastingDevice(name, addresses, port) }, - deviceUpdater = { d -> - if (d.isReady) { - return@addOrUpdateCastDevice false; - } - - val changed = addresses.contentEquals(d.addresses) || d.name != name || d.port != port; - if (changed) { - d.name = name; - d.addresses = addresses; - d.port = port; - } - - return@addOrUpdateCastDevice changed; - } - ); + fun addRememberedDevice(device: CastingDevice): CastingDeviceInfo { + val deviceInfo = device.getDeviceInfo() + return _storage.addDevice(deviceInfo) } - private fun addOrUpdateAirPlayDevice(name: String, addresses: Array, port: Int) { - return addOrUpdateCastDevice(name, - deviceFactory = { AirPlayCastingDevice(name, addresses, port) }, - deviceUpdater = { d -> - if (d.isReady) { - return@addOrUpdateCastDevice false; - } - - val changed = addresses.contentEquals(addresses) || d.name != name || d.port != port; - if (changed) { - d.name = name; - d.port = port; - d.addresses = addresses; - } - - return@addOrUpdateCastDevice changed; - } - ); + fun getRememberedCastingDevices(): List { + return _storage.getDevices().map { deviceFromInfo(it) } } - private fun addOrUpdateFastCastDevice(name: String, addresses: Array, port: Int) { - return addOrUpdateCastDevice(name, - deviceFactory = { FCastCastingDevice(name, addresses, port) }, - deviceUpdater = { d -> - if (d.isReady) { - return@addOrUpdateCastDevice false; - } - - val changed = addresses.contentEquals(addresses) || d.name != name || d.port != port; - if (changed) { - d.name = name; - d.port = port; - d.addresses = addresses; - } - - return@addOrUpdateCastDevice changed; - } - ); + fun getRememberedCastingDeviceNames(): List { + return _storage.getDeviceNames() } - private inline fun addOrUpdateCastDevice(name: String, deviceFactory: () -> TCastDevice, deviceUpdater: (device: TCastDevice) -> Boolean) where TCastDevice : CastingDevice { - var invokeEvents: (() -> Unit)? = null; - - synchronized(devices) { - val device = devices[name]; - if (device != null) { - if (device !is TCastDevice) { - Logger.w(TAG, "Device name conflict between device types. Ignoring device."); - } else { - val changed = deviceUpdater(device as TCastDevice); - if (changed) { - invokeEvents = { - onDeviceChanged.emit(device); - } - } else { - - } - } - } else { - val newDevice = deviceFactory(); - this.devices[name] = newDevice; - - invokeEvents = { - onDeviceAdded.emit(newDevice); - }; - } - } - - invokeEvents?.let { _scopeMain.launch { it(); }; }; + fun removeRememberedDevice(device: CastingDevice) { + val name = device.name ?: return + _storage.removeDevice(name) } - fun enableDeveloper(enableDev: Boolean){ + fun enableDeveloper(enableDev: Boolean) { _castServer.removeAllHandlers("dev"); - if(enableDev) { + if (enableDev) { _castServer.addHandler(HttpFunctionHandler("GET", "/dashPlayer") { context -> if (context.query.containsKey("dashUrl")) { val dashUrl = context.query["dashUrl"]; - val html = "
\n" + - " \n" + - " \n" + - " \n" + - " \n" + - " \n" + - "
"; + val html = + "
\n" + " \n" + " \n" + " \n" + " \n" + " \n" + "
"; context.respondCode(200, html, "text/html"); } }).withTag("dev"); } } - @Serializable - private data class FCastNetworkConfig( - val name: String, - val addresses: List, - val services: List - ) - - @Serializable - private data class FCastService( - val port: Int, - val type: Int - ) - companion object { - val instance: StateCasting = StateCasting(); - - private val representationRegex = Regex("(.*?)<\\/Representation>", RegexOption.DOT_MATCHES_ALL) - private val mediaInitializationRegex = Regex("(media|initiali[sz]ation)=\"([^\"]+)\"", RegexOption.DOT_MATCHES_ALL); + var instance: StateCasting = if (Settings.instance.casting.experimentalCasting) { + ExpStateCasting() + } else { + OldStateCasting() + } + private val representationRegex = Regex( + "(.*?)<\\/Representation>", + RegexOption.DOT_MATCHES_ALL + ) + private val mediaInitializationRegex = + Regex("(media|initiali[sz]ation)=\"([^\"]+)\"", RegexOption.DOT_MATCHES_ALL); private val TAG = "StateCasting"; } -} - +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/CastingAddDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/CastingAddDialog.kt index c8cdc6c4..4cc6ce80 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/CastingAddDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/CastingAddDialog.kt @@ -11,8 +11,8 @@ import com.futo.platformplayer.R import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.casting.CastProtocolType +import com.futo.platformplayer.casting.OldStateCasting import com.futo.platformplayer.casting.StateCasting -import com.futo.platformplayer.experimental_casting.ExpStateCasting import com.futo.platformplayer.models.CastingDeviceInfo import com.futo.platformplayer.toInetAddress import com.futo.platformplayer.logging.Logger @@ -110,14 +110,10 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) { _textError.visibility = View.GONE; val castingDeviceInfo = CastingDeviceInfo(name, castProtocolType, arrayOf(ip), port.toInt()); - if (Settings.instance.casting.experimentalCasting) { - try { - ExpStateCasting.instance.addRememberedDevice(castingDeviceInfo) - } catch (e: Throwable) { - Logger.e(TAG, "Failed to add remembered device: $e") - } - } else { + try { StateCasting.instance.addRememberedDevice(castingDeviceInfo) + } catch (e: Throwable) { + Logger.e(TAG, "Failed to add remembered device: $e") } performDismiss(); }; diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt index 54dc7bc4..c6cebbe8 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt @@ -7,7 +7,6 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.widget.Button -import android.widget.ImageButton import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView @@ -15,18 +14,14 @@ import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.futo.platformplayer.R -import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.casting.CastConnectionState -import com.futo.platformplayer.casting.CastingDevice import com.futo.platformplayer.casting.StateCasting -import com.futo.platformplayer.experimental_casting.ExpStateCasting import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.views.adapters.DeviceAdapter import com.futo.platformplayer.views.adapters.DeviceAdapterEntry -import com.futo.platformplayer.views.adapters.GenericCastingDevice import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -58,33 +53,17 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) { _recyclerDevices.layoutManager = LinearLayoutManager(context); _adapter.onPin.subscribe { d -> - when (d) { - is GenericCastingDevice.Experimental -> { - val isRemembered = _rememberedDevices.contains(d.handle.device.name()) - val newIsRemembered = !isRemembered - if (newIsRemembered) { - ExpStateCasting.instance.addRememberedDevice(d.handle) - val name = d.handle.device.name() - _rememberedDevices.add(name) - } else { - ExpStateCasting.instance.removeRememberedDevice(d.handle) - _rememberedDevices.remove(d.handle.device.name()) - } - } - is GenericCastingDevice.Normal -> { - val isRemembered = _rememberedDevices.contains(d.device.name) - val newIsRemembered = !isRemembered - if (newIsRemembered) { - StateCasting.instance.addRememberedDevice(d.device) - val name = d.device.name - if (name != null) { - _rememberedDevices.add(name) - } - } else { - StateCasting.instance.removeRememberedDevice(d.device) - _rememberedDevices.remove(d.device.name) - } + val isRemembered = _rememberedDevices.contains(d.name) + val newIsRemembered = !isRemembered + if (newIsRemembered) { + StateCasting.instance.addRememberedDevice(d) + val name = d.name + if (name != null) { + _rememberedDevices.add(name) } + } else { + StateCasting.instance.removeRememberedDevice(d) + _rememberedDevices.remove(d.name) } updateUnifiedList() } @@ -124,77 +103,42 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) { (_imageLoader.drawable as Animatable?)?.start(); - if (Settings.instance.casting.experimentalCasting) { - synchronized(ExpStateCasting.instance.devices) { - _devices.addAll(ExpStateCasting.instance.devices.values.map { it.device.name() }) - } - _rememberedDevices.addAll(ExpStateCasting.instance.getRememberedCastingDeviceNames()) - } else { - synchronized(StateCasting.instance.devices) { - _devices.addAll(StateCasting.instance.devices.values.mapNotNull { it.name }) - } - _rememberedDevices.addAll(StateCasting.instance.getRememberedCastingDeviceNames()) + synchronized(StateCasting.instance.devices) { + _devices.addAll(StateCasting.instance.devices.values.map { it.name.orEmpty() }) } + _rememberedDevices.addAll(StateCasting.instance.getRememberedCastingDeviceNames()) updateUnifiedList() - if (Settings.instance.casting.experimentalCasting) { - ExpStateCasting.instance.onDeviceAdded.subscribe(this) { d -> - _devices.add(d.name()) - updateUnifiedList() - } - - ExpStateCasting.instance.onDeviceChanged.subscribe(this) { d -> - val index = _unifiedDevices.indexOfFirst { it.castingDevice.name() == d.device.name() } - if (index != -1) { - val dev = GenericCastingDevice.Experimental(d) - _unifiedDevices[index] = DeviceAdapterEntry(dev, _unifiedDevices[index].isPinnedDevice, _unifiedDevices[index].isOnlineDevice) - _adapter.notifyItemChanged(index) - } - } - - ExpStateCasting.instance.onDeviceRemoved.subscribe(this) { deviceName -> - _devices.remove(deviceName) + StateCasting.instance.onDeviceAdded.subscribe(this) { d -> + val name = d.name + if (name != null) { + _devices.add(name) updateUnifiedList() } + } - ExpStateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, connectionState -> - if (connectionState == com.futo.platformplayer.experimental_casting.CastConnectionState.CONNECTED) { - StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { - dismiss() - } - } - } - } else { - StateCasting.instance.onDeviceAdded.subscribe(this) { d -> - val name = d.name - if (name != null) - _devices.add(name) - updateUnifiedList() + StateCasting.instance.onDeviceChanged.subscribe(this) { d -> + val index = _unifiedDevices.indexOfFirst { it.castingDevice.name == d.name } + if (index != -1) { + _unifiedDevices[index] = DeviceAdapterEntry( + d, + _unifiedDevices[index].isPinnedDevice, + _unifiedDevices[index].isOnlineDevice + ) + _adapter.notifyItemChanged(index) } + } - StateCasting.instance.onDeviceChanged.subscribe(this) { d -> - val index = _unifiedDevices.indexOfFirst { it.castingDevice.name() == d.name } - if (index != -1) { - _unifiedDevices[index] = DeviceAdapterEntry( - GenericCastingDevice.Normal(d), - _unifiedDevices[index].isPinnedDevice, - _unifiedDevices[index].isOnlineDevice - ) - _adapter.notifyItemChanged(index) - } - } + StateCasting.instance.onDeviceRemoved.subscribe(this) { deviceName -> + _devices.remove(deviceName.name) + updateUnifiedList() + } - StateCasting.instance.onDeviceRemoved.subscribe(this) { d -> - _devices.remove(d.name) - updateUnifiedList() - } - - StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, connectionState -> - if (connectionState == CastConnectionState.CONNECTED) { - StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { - dismiss() - } + StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, connectionState -> + if (connectionState == CastConnectionState.CONNECTED) { + StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { + dismiss() } } } @@ -203,17 +147,10 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) { override fun dismiss() { super.dismiss() (_imageLoader.drawable as Animatable?)?.stop() - 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) - } + StateCasting.instance.onDeviceAdded.remove(this) + StateCasting.instance.onDeviceChanged.remove(this) + StateCasting.instance.onDeviceRemoved.remove(this) + StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this) } private fun updateUnifiedList() { @@ -226,16 +163,17 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) { override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { val oldItem = oldList[oldItemPosition] val newItem = newList[newItemPosition] - return oldItem.castingDevice.name() == newItem.castingDevice.name() - && oldItem.castingDevice.isReady() == newItem.castingDevice.isReady() + return oldItem.castingDevice.name == newItem.castingDevice.name + && oldItem.castingDevice.isReady == newItem.castingDevice.isReady && oldItem.isOnlineDevice == newItem.isOnlineDevice && oldItem.isPinnedDevice == newItem.isPinnedDevice } + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { val oldItem = oldList[oldItemPosition] val newItem = newList[newItemPosition] - return oldItem.castingDevice.name() == newItem.castingDevice.name() - && oldItem.castingDevice.isReady() == newItem.castingDevice.isReady() + return oldItem.castingDevice.name == newItem.castingDevice.name + && oldItem.castingDevice.isReady == newItem.castingDevice.isReady && oldItem.isOnlineDevice == newItem.isOnlineDevice && oldItem.isPinnedDevice == newItem.isPinnedDevice } @@ -252,64 +190,40 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) { private fun buildUnifiedList(): List { val unifiedList = mutableListOf() - if (Settings.instance.casting.experimentalCasting) { - val onlineDevices = ExpStateCasting.instance.devices.values.associateBy { it.device.name() } - val rememberedDevices = ExpStateCasting.instance.getRememberedCastingDevices().associateBy { it.device.name() } + val onlineDevices = StateCasting.instance.devices.values.associateBy { it.name } + val rememberedDevices = + StateCasting.instance.getRememberedCastingDevices().associateBy { it.name } - val intersectionNames = _devices.intersect(_rememberedDevices) - for (name in intersectionNames) { - onlineDevices[name]?.let { - unifiedList.add(DeviceAdapterEntry( - GenericCastingDevice.Experimental(it), true, true) + val intersectionNames = _devices.intersect(_rememberedDevices) + for (name in intersectionNames) { + onlineDevices[name]?.let { + unifiedList.add( + DeviceAdapterEntry( + it, true, true ) - } + ) } + } - val onlineOnlyNames = _devices - _rememberedDevices - for (name in onlineOnlyNames) { - onlineDevices[name]?.let { - unifiedList.add(DeviceAdapterEntry( - GenericCastingDevice.Experimental(it), false, true) + val onlineOnlyNames = _devices - _rememberedDevices + for (name in onlineOnlyNames) { + onlineDevices[name]?.let { + unifiedList.add( + DeviceAdapterEntry( + it, false, true ) - } + ) } + } - val rememberedOnlyNames = _rememberedDevices - _devices - for (name in rememberedOnlyNames) { - rememberedDevices[name]?.let { - unifiedList.add(DeviceAdapterEntry( - GenericCastingDevice.Experimental(it), true, false) + val rememberedOnlyNames = _rememberedDevices - _devices + for (name in rememberedOnlyNames) { + rememberedDevices[name]?.let { + unifiedList.add( + DeviceAdapterEntry( + it, true, false ) - } - } - } else { - val onlineDevices = StateCasting.instance.devices.values.associateBy { it.name } - val rememberedDevices = StateCasting.instance.getRememberedCastingDevices().associateBy { it.name } - - val intersectionNames = _devices.intersect(_rememberedDevices) - for (name in intersectionNames) { - onlineDevices[name]?.let { - unifiedList.add(DeviceAdapterEntry( - GenericCastingDevice.Normal(it), true, true) - ) - } - } - - val onlineOnlyNames = _devices - _rememberedDevices - for (name in onlineOnlyNames) { - onlineDevices[name]?.let { - unifiedList.add(DeviceAdapterEntry( - GenericCastingDevice.Normal( it), false, true)) - } - } - - val rememberedOnlyNames = _rememberedDevices - _devices - for (name in rememberedOnlyNames) { - rememberedDevices[name]?.let { - unifiedList.add(DeviceAdapterEntry( - GenericCastingDevice.Normal(it), true, false) - ) - } + ) } } diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt index f9466487..bd76f3ea 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt @@ -16,16 +16,15 @@ import com.futo.platformplayer.Settings import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.casting.AirPlayCastingDevice import com.futo.platformplayer.casting.CastConnectionState +import com.futo.platformplayer.casting.CastProtocolType import com.futo.platformplayer.casting.CastingDevice import com.futo.platformplayer.casting.ChromecastCastingDevice import com.futo.platformplayer.casting.FCastCastingDevice +import com.futo.platformplayer.casting.OldStateCasting import com.futo.platformplayer.casting.StateCasting -import com.futo.platformplayer.experimental_casting.ExpStateCasting -import com.futo.platformplayer.experimental_casting.StateCastingDispatcher 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 @@ -51,7 +50,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) { private lateinit var _buttonStop: ImageButton; private lateinit var _buttonNext: ImageButton; - private var _device: GenericCastingDevice? = null; + private var _device: CastingDevice? = null; override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState); @@ -75,18 +74,24 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) { _buttonPlay = findViewById(R.id.button_play); _buttonPlay.setOnClickListener { - StateCastingDispatcher.resumeVideo() + try { + StateCasting.instance.activeDevice?.resumePlayback() + } catch (_: Throwable) {} } _buttonPause = findViewById(R.id.button_pause); _buttonPause.setOnClickListener { - StateCastingDispatcher.pauseVideo() + try { + StateCasting.instance.activeDevice?.pausePlayback() + } catch (_: Throwable) {} } _buttonStop = findViewById(R.id.button_stop); _buttonStop.setOnClickListener { (ownerActivity as MainActivity?)?.getFragment()?.closeVideoDetails() - StateCastingDispatcher.stopVideo() + try { + StateCasting.instance.activeDevice?.stopPlayback() + } catch (_: Throwable) {} } _buttonNext = findViewById(R.id.button_next); @@ -96,16 +101,9 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) { _buttonClose.setOnClickListener { dismiss(); }; _buttonDisconnect.setOnClickListener { - if (Settings.instance.casting.experimentalCasting) { - try { - ExpStateCasting.instance.activeDevice?.device?.stopPlayback() - ExpStateCasting.instance.activeDevice?.device?.disconnect() - } catch (e: Throwable) { - // Ignored - } - } else { - StateCasting.instance.activeDevice?.stopCasting(); - } + try { + StateCasting.instance.activeDevice?.disconnect() + } catch (_: Throwable) {} dismiss(); }; @@ -114,7 +112,9 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) { return@OnChangeListener } - StateCastingDispatcher.videoSeekTo(value.toDouble()) + try { + StateCasting.instance.activeDevice?.seekTo(value.toDouble()) + } catch (_: Throwable) {} }); //TODO: Check if volume slider is properly hidden in all cases @@ -123,7 +123,9 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) { return@OnChangeListener } - StateCastingDispatcher.changeVolume(value.toDouble()) + try { + StateCasting.instance.activeDevice?.changeVolume(value.toDouble()) + } catch (_: Throwable) {} }); setLoading(false); @@ -134,64 +136,34 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) { super.show(); Logger.i(TAG, "Dialog shown."); - if (Settings.instance.casting.experimentalCasting) { - ExpStateCasting.instance.onActiveDeviceVolumeChanged.remove(this) - ExpStateCasting.instance.onActiveDeviceVolumeChanged.subscribe { - _sliderVolume.value = it.toFloat().coerceAtLeast(0.0f).coerceAtMost(_sliderVolume.valueTo) - } + StateCasting.instance.onActiveDeviceVolumeChanged.remove(this) + StateCasting.instance.onActiveDeviceVolumeChanged.subscribe { + _sliderVolume.value = it.toFloat().coerceAtLeast(0.0f).coerceAtMost(_sliderVolume.valueTo) + } - ExpStateCasting.instance.onActiveDeviceTimeChanged.remove(this) - ExpStateCasting.instance.onActiveDeviceTimeChanged.subscribe { - _sliderPosition.value = it.toFloat().coerceAtLeast(0.0f).coerceAtMost(_sliderPosition.valueTo) - } + StateCasting.instance.onActiveDeviceTimeChanged.remove(this) + StateCasting.instance.onActiveDeviceTimeChanged.subscribe { + _sliderPosition.value = it.toFloat().coerceAtLeast(0.0f).coerceAtMost(_sliderPosition.valueTo) + } - ExpStateCasting.instance.onActiveDeviceDurationChanged.remove(this) - ExpStateCasting.instance.onActiveDeviceDurationChanged.subscribe { - val dur = it.toFloat().coerceAtLeast(1.0f) - _sliderPosition.value = _sliderPosition.value.coerceAtLeast(0.0f).coerceAtMost(dur) - _sliderPosition.valueTo = dur - } + StateCasting.instance.onActiveDeviceDurationChanged.remove(this) + StateCasting.instance.onActiveDeviceDurationChanged.subscribe { + val dur = it.toFloat().coerceAtLeast(1.0f) + _sliderPosition.value = _sliderPosition.value.coerceAtLeast(0.0f).coerceAtMost(dur) + _sliderPosition.valueTo = dur + } - 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() - } - } else { - StateCasting.instance.onActiveDeviceVolumeChanged.remove(this); - StateCasting.instance.onActiveDeviceVolumeChanged.subscribe { - _sliderVolume.value = it.toFloat().coerceAtLeast(0.0f).coerceAtMost(_sliderVolume.valueTo); - }; - - StateCasting.instance.onActiveDeviceTimeChanged.remove(this); - StateCasting.instance.onActiveDeviceTimeChanged.subscribe { - _sliderPosition.value = it.toFloat().coerceAtLeast(0.0f).coerceAtMost(_sliderPosition.valueTo); - }; - - StateCasting.instance.onActiveDeviceDurationChanged.remove(this); - StateCasting.instance.onActiveDeviceDurationChanged.subscribe { - val dur = it.toFloat().coerceAtLeast(1.0f) - _sliderPosition.value = _sliderPosition.value.coerceAtLeast(0.0f).coerceAtMost(dur); - _sliderPosition.valueTo = dur - }; - - 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() + val ad = StateCasting.instance.activeDevice + if (ad != null) { + _device = 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(); @@ -199,117 +171,71 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) { override fun dismiss() { super.dismiss(); + StateCasting.instance.onActiveDeviceVolumeChanged.remove(this); StateCasting.instance.onActiveDeviceDurationChanged.remove(this); StateCasting.instance.onActiveDeviceTimeChanged.remove(this); 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() { - if (Settings.instance.casting.experimentalCasting) { - val d = ExpStateCasting.instance.activeDevice ?: return; + val d = StateCasting.instance.activeDevice ?: return; - when (d.device.castingProtocol()) { - ProtocolType.CHROMECAST -> { - _imageDevice.setImageResource(R.drawable.ic_chromecast); - _textType.text = "Chromecast"; - } - ProtocolType.F_CAST -> { - _imageDevice.setImageResource(R.drawable.ic_exp_fc); - _textType.text = "FCast"; - } - } - - _textName.text = d.device.name(); - _sliderPosition.valueFrom = 0.0f; - _sliderVolume.valueFrom = 0.0f; - _sliderVolume.value = d.volume.toFloat().coerceAtLeast(0.0f).coerceAtMost(_sliderVolume.valueTo); - - val dur = d.duration.toFloat().coerceAtLeast(1.0f) - _sliderPosition.value = d.time.toFloat().coerceAtLeast(0.0f).coerceAtMost(dur) - _sliderPosition.valueTo = dur - - if (d.device.supportsFeature(DeviceFeature.SET_VOLUME)) { - _layoutVolumeAdjustable.visibility = View.VISIBLE; - _layoutVolumeFixed.visibility = View.GONE; - } else { - _layoutVolumeAdjustable.visibility = View.GONE; - _layoutVolumeFixed.visibility = View.VISIBLE; - } - - val interactiveControls = listOf( - _sliderPosition, - _sliderVolume, - _buttonPrevious, - _buttonPlay, - _buttonPause, - _buttonStop, - _buttonNext - ) - - when (d.connectionState) { - com.futo.platformplayer.experimental_casting.CastConnectionState.CONNECTED -> { - enableControls(interactiveControls) - } - com.futo.platformplayer.experimental_casting.CastConnectionState.CONNECTING, - com.futo.platformplayer.experimental_casting.CastConnectionState.DISCONNECTED -> { - disableControls(interactiveControls) - } - } - } else { - val d = StateCasting.instance.activeDevice ?: return; - - if (d is ChromecastCastingDevice) { + when (d.protocolType) { + CastProtocolType.CHROMECAST -> { _imageDevice.setImageResource(R.drawable.ic_chromecast); _textType.text = "Chromecast"; - } else if (d is AirPlayCastingDevice) { + } + CastProtocolType.AIRPLAY -> { _imageDevice.setImageResource(R.drawable.ic_airplay); _textType.text = "AirPlay"; - } else if (d is FCastCastingDevice) { - _imageDevice.setImageResource(R.drawable.ic_fc); + } + CastProtocolType.FCAST -> { + _imageDevice.setImageResource( + if (Settings.instance.casting.experimentalCasting) { + R.drawable.ic_exp_fc + } else { + R.drawable.ic_fc + } + ) _textType.text = "FCast"; } + } - _textName.text = d.name; - _sliderPosition.valueFrom = 0.0f; - _sliderVolume.valueFrom = 0.0f; - _sliderVolume.value = d.volume.toFloat().coerceAtLeast(0.0f).coerceAtMost(_sliderVolume.valueTo); + _textName.text = d.name; + _sliderPosition.valueFrom = 0.0f; + _sliderVolume.valueFrom = 0.0f; + _sliderVolume.value = d.volume.toFloat().coerceAtLeast(0.0f).coerceAtMost(_sliderVolume.valueTo); - val dur = d.duration.toFloat().coerceAtLeast(1.0f) - _sliderPosition.value = d.time.toFloat().coerceAtLeast(0.0f).coerceAtMost(dur) - _sliderPosition.valueTo = dur + val dur = d.duration.toFloat().coerceAtLeast(1.0f) + _sliderPosition.value = d.time.toFloat().coerceAtLeast(0.0f).coerceAtMost(dur) + _sliderPosition.valueTo = dur - if (d.canSetVolume) { - _layoutVolumeAdjustable.visibility = View.VISIBLE; - _layoutVolumeFixed.visibility = View.GONE; - } else { - _layoutVolumeAdjustable.visibility = View.GONE; - _layoutVolumeFixed.visibility = View.VISIBLE; + if (d.canSetVolume()) { + _layoutVolumeAdjustable.visibility = View.VISIBLE; + _layoutVolumeFixed.visibility = View.GONE; + } else { + _layoutVolumeAdjustable.visibility = View.GONE; + _layoutVolumeFixed.visibility = View.VISIBLE; + } + + val interactiveControls = listOf( + _sliderPosition, + _sliderVolume, + _buttonPrevious, + _buttonPlay, + _buttonPause, + _buttonStop, + _buttonNext + ) + + when (d.connectionState) { + CastConnectionState.CONNECTED -> { + enableControls(interactiveControls) } - - val interactiveControls = listOf( - _sliderPosition, - _sliderVolume, - _buttonPrevious, - _buttonPlay, - _buttonPause, - _buttonStop, - _buttonNext - ) - - when (d.connectionState) { - CastConnectionState.CONNECTED -> { - enableControls(interactiveControls) - } - CastConnectionState.CONNECTING, - CastConnectionState.DISCONNECTED -> { - disableControls(interactiveControls) - } + CastConnectionState.CONNECTING, CastConnectionState.DISCONNECTED -> { + disableControls(interactiveControls) } } } diff --git a/app/src/main/java/com/futo/platformplayer/experimental_casting/CastingDevice.kt b/app/src/main/java/com/futo/platformplayer/experimental_casting/CastingDevice.kt deleted file mode 100644 index fd0be55f..00000000 --- a/app/src/main/java/com/futo/platformplayer/experimental_casting/CastingDevice.kt +++ /dev/null @@ -1,181 +0,0 @@ -package com.futo.platformplayer.experimental_casting - -import com.futo.platformplayer.constructs.Event1 -import com.futo.platformplayer.logging.Logger -import kotlinx.coroutines.MainScope -import kotlinx.coroutines.launch -import kotlinx.serialization.KSerializer -import kotlinx.serialization.Serializable -import kotlinx.serialization.descriptors.PrimitiveKind -import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder -import org.fcast.sender_sdk.GenericKeyEvent -import org.fcast.sender_sdk.GenericMediaEvent -import org.fcast.sender_sdk.PlaybackState -import org.fcast.sender_sdk.Source -import java.net.InetAddress -import org.fcast.sender_sdk.CastingDevice as RsCastingDevice; -import org.fcast.sender_sdk.DeviceEventHandler as RsDeviceEventHandler; -import org.fcast.sender_sdk.DeviceConnectionState -import org.fcast.sender_sdk.LoadRequest -import org.fcast.sender_sdk.Metadata - -class CastingDeviceHandle { - class EventHandler : RsDeviceEventHandler { - var onConnectionStateChanged = Event1(); - var onPlayChanged = Event1() - var onTimeChanged = Event1() - var onDurationChanged = Event1() - var onVolumeChanged = Event1() - var onSpeedChanged = Event1() - - override fun connectionStateChanged(state: DeviceConnectionState) { - onConnectionStateChanged.emit(state) - } - - override fun volumeChanged(volume: Double) { - onVolumeChanged.emit(volume) - } - - override fun timeChanged(time: Double) { - onTimeChanged.emit(time) - } - - override fun playbackStateChanged(state: PlaybackState) { - onPlayChanged.emit(state == PlaybackState.PLAYING) - } - - override fun durationChanged(duration: Double) { - onDurationChanged.emit(duration) - } - - override fun speedChanged(speed: Double) { - onSpeedChanged.emit(speed) - } - - override fun sourceChanged(source: Source) { - // TODO - } - - override fun keyEvent(event: GenericKeyEvent) { - // Unreachable - } - - override fun mediaEvent(event: GenericMediaEvent) { - // Unreachable - } - - override fun playbackError(message: String) { - Logger.e(TAG, "Playback error: $message") - } - } - - val eventHandler = EventHandler() - val device: RsCastingDevice - - var usedRemoteAddress: InetAddress? = null - var localAddress: InetAddress? = null - var connectionState = CastConnectionState.DISCONNECTED - var volume: Double = 1.0 - var duration: Double = 0.0 - var lastTimeChangeTime_ms: Long = 0 - var time: Double = 0.0 - var speed: Double = 0.0 - var isPlaying: Boolean = false - - val expectedCurrentTime: Double - get() { - val diff = - if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0; - return time + diff; - }; - - constructor(newDevice: RsCastingDevice) { - device = newDevice - eventHandler.onConnectionStateChanged.subscribe { newState -> - if (newState == DeviceConnectionState.Disconnected) { - try { - Logger.i(TAG, "Stopping device") - device.disconnect() - } catch (e: Throwable) { - Logger.e(TAG, "Failed to stop device: $e") - } - } - } - } - - fun loadVideo( - contentType: String, - contentId: String, - resumePosition: Double, - speed: Double?, - metadata: Metadata? = null - ) { - try { - device.load(LoadRequest.Video( - contentType = contentType, - url = contentId, - resumePosition = resumePosition, - speed = speed, - volume = volume, - metadata = metadata - )) - } catch (e: Throwable) { - Logger.e(TAG, "Failed to load video: $e") - } - } - - fun loadContent( - contentType: String, - content: String, - resumePosition: Double, - speed: Double? - ) { - try { - device.load(LoadRequest.Content( - contentType =contentType, - content = content, - resumePosition = resumePosition, - speed = speed, - volume = volume - )) - } catch (e: Throwable) { - Logger.e(TAG, "Failed to load content: $e") - } - } - - companion object { - private val TAG = "ExperimentalCastingDevice" - } -} - -enum class CastConnectionState { - DISCONNECTED, - CONNECTING, - CONNECTED -} - -@Serializable(with = ExpCastProtocolType.CastProtocolTypeSerializer::class) -enum class ExpCastProtocolType { - CHROMECAST, - FCAST; - - object CastProtocolTypeSerializer : KSerializer { - override val descriptor: SerialDescriptor = - PrimitiveSerialDescriptor("CastProtocolType", PrimitiveKind.STRING) - - override fun serialize(encoder: Encoder, value: ExpCastProtocolType) { - encoder.encodeString(value.name) - } - - override fun deserialize(decoder: Decoder): ExpCastProtocolType { - val name = decoder.decodeString() - return when (name) { - "FASTCAST" -> FCAST // Handle the renamed case - else -> ExpCastProtocolType.valueOf(name) - } - } - } -} diff --git a/app/src/main/java/com/futo/platformplayer/experimental_casting/StateCasting.kt b/app/src/main/java/com/futo/platformplayer/experimental_casting/StateCasting.kt deleted file mode 100644 index cc4821d1..00000000 --- a/app/src/main/java/com/futo/platformplayer/experimental_casting/StateCasting.kt +++ /dev/null @@ -1,1813 +0,0 @@ -package com.futo.platformplayer.experimental_casting - -import android.app.AlertDialog -import android.content.ContentResolver -import android.content.Context -import android.os.Build -import android.os.Looper -import android.util.Log -import androidx.annotation.OptIn -import androidx.media3.common.util.UnstableApi -import com.futo.platformplayer.BuildConfig -import com.futo.platformplayer.R -import com.futo.platformplayer.Settings -import com.futo.platformplayer.UIDialogs -import com.futo.platformplayer.api.http.ManagedHttpClient -import com.futo.platformplayer.api.http.server.HttpHeaders -import com.futo.platformplayer.api.http.server.ManagedHttpServer -import com.futo.platformplayer.api.http.server.handlers.HttpConstantHandler -import com.futo.platformplayer.api.http.server.handlers.HttpFileHandler -import com.futo.platformplayer.api.http.server.handlers.HttpFunctionHandler -import com.futo.platformplayer.api.http.server.handlers.HttpProxyHandler -import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource -import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource -import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudioSource -import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource -import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource -import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource -import com.futo.platformplayer.api.media.models.streams.sources.LocalAudioSource -import com.futo.platformplayer.api.media.models.streams.sources.LocalSubtitleSource -import com.futo.platformplayer.api.media.models.streams.sources.LocalVideoSource -import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource -import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails -import com.futo.platformplayer.api.media.platforms.js.models.JSRequestExecutor -import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestMergingRawSource -import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource -import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource -import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource -import com.futo.platformplayer.builders.DashBuilder -import com.futo.platformplayer.constructs.Event1 -import com.futo.platformplayer.constructs.Event2 -import com.futo.platformplayer.exceptions.UnsupportedCastException -import com.futo.platformplayer.findPreferredAddress -import com.futo.platformplayer.logging.Logger -import com.futo.platformplayer.models.CastingDeviceInfo -import com.futo.platformplayer.parsers.HLS -import com.futo.platformplayer.states.StateApp -import com.futo.platformplayer.stores.CastingDeviceInfoStorage -import com.futo.platformplayer.stores.FragmentedStorage -import com.futo.platformplayer.toUrlAddress -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancel -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.fcast.sender_sdk.ApplicationInfo -import java.net.Inet6Address -import java.net.InetAddress -import java.net.URLDecoder -import java.net.URLEncoder -import java.util.UUID -import org.fcast.sender_sdk.DeviceInfo as RsDeviceInfo -import org.fcast.sender_sdk.CastingDevice as RsCastingDevice -import org.fcast.sender_sdk.ProtocolType -import org.fcast.sender_sdk.CastContext -import org.fcast.sender_sdk.DeviceConnectionState -import org.fcast.sender_sdk.Metadata -import org.fcast.sender_sdk.NsdDeviceDiscoverer -import org.fcast.sender_sdk.urlFormatIpAddr -import java.util.concurrent.atomic.AtomicInteger - -class ExpStateCasting { - private val _scopeIO = CoroutineScope(Dispatchers.IO) - private val _scopeMain = CoroutineScope(Dispatchers.Main) - private val _storage: CastingDeviceInfoStorage = FragmentedStorage.get() - - private val _castServer = ManagedHttpServer() - private var _started = false - - val onDeviceAdded = Event1() - val onDeviceChanged = Event1() - val onDeviceRemoved = Event1() - val onActiveDeviceTimeChanged = Event1() - val onActiveDeviceDurationChanged = Event1() - val onActiveDeviceVolumeChanged = Event1() - val onActiveDevicePlayChanged = Event1() - val onActiveDeviceConnectionStateChanged = Event2() - private var _videoExecutor: JSRequestExecutor? = null - private var _audioExecutor: JSRequestExecutor? = null - private val _client = ManagedHttpClient() - val isCasting: Boolean get() = activeDevice != null - private val _context = CastContext() - var activeDevice: CastingDeviceHandle? = null - var devices: HashMap = hashMapOf() - var _resumeCastingDevice: RsDeviceInfo? = null - var _deviceDiscoverer: NsdDeviceDiscoverer? = null - private val _castId = AtomicInteger(0) - - class DiscoveryEventHandler( - private val onDeviceAdded: (RsDeviceInfo) -> Unit, - private val onDeviceRemoved: (String) -> Unit, - private val onDeviceUpdated: (RsDeviceInfo) -> Unit, - ) : org.fcast.sender_sdk.DeviceDiscovererEventHandler { - override fun deviceAvailable(deviceInfo: RsDeviceInfo) { - onDeviceAdded(deviceInfo) - } - - override fun deviceChanged(deviceInfo: RsDeviceInfo) { - onDeviceUpdated(deviceInfo) - } - - override fun deviceRemoved(deviceName: String) { - onDeviceRemoved(deviceName) - } - } - - init { - if (BuildConfig.DEBUG) { - org.fcast.sender_sdk.initLogger(org.fcast.sender_sdk.LogLevelFilter.DEBUG) - } - } - - fun handleUrl(context: Context, url: String) { - try { - val foundDeviceInfo = org.fcast.sender_sdk.deviceInfoFromUrl(url)!! - val foundDevice = _context.createDeviceFromInfo(foundDeviceInfo) - connectDevice(CastingDeviceHandle(foundDevice)) - } catch (e: Throwable) { - Logger.e(TAG, "Failed to handle URL: $e") - } - } - - fun onStop() { - val ad = activeDevice ?: return - _resumeCastingDevice = ad.device.getDeviceInfo() - Log.i(TAG, "_resumeCastingDevice set to '${ad.device.name()}'") - Logger.i(TAG, "Stopping active device because of onStop.") - try { - ad.device.disconnect() - } catch (e: Throwable) { - Logger.e(TAG, "Failed to disconnect from device: $e") - } - } - - fun onResume() { - val ad = activeDevice - if (ad != null) { - // TODO: needed? - // if (ad is FCastCastingDevice) { - // ad.ensureThreadStarted() - // } else if (ad is ChromecastCastingDevice) { - // ad.ensureThreadsStarted() - // } - } else { - val resumeCastingDevice = _resumeCastingDevice - if (resumeCastingDevice != null) { - try { - connectDevice( - CastingDeviceHandle(_context.createDeviceFromInfo(resumeCastingDevice)) - ) - _resumeCastingDevice = null - Log.i(TAG, "_resumeCastingDevice set to null onResume") - } catch (e: Throwable) { - Logger.e(TAG, "Failed to resume: $e") - } - } - } - } - - @Synchronized - fun start(context: Context) { - if (_started) - return; - _started = true; - - Log.i(TAG, "_resumeCastingDevice set null start") - _resumeCastingDevice = null; - - Logger.i(TAG, "CastingService starting..."); - - _castServer.start(); - enableDeveloper(true); - - Logger.i(TAG, "CastingService started."); - - _deviceDiscoverer = NsdDeviceDiscoverer( - context, - DiscoveryEventHandler( - { deviceInfo -> // Added - Logger.i(TAG, "Device added: ${deviceInfo.name}") - val device = _context.createDeviceFromInfo(deviceInfo) - val deviceHandle = CastingDeviceHandle(device) - devices[deviceHandle.device.name()] = deviceHandle - invokeInMainScopeIfRequired { - onDeviceAdded.emit(device) - } - }, - { deviceName -> // Removed - invokeInMainScopeIfRequired { - if (devices.containsKey(deviceName)) { - devices.remove(deviceName) - } - onDeviceRemoved.emit(deviceName) - } - }, - { deviceInfo -> // Updated - Logger.i(TAG, "Device updated: $deviceInfo") - val handle = devices[deviceInfo.name] - if (handle != null) { - handle.device.setPort(deviceInfo.port) - handle.device.setAddresses(deviceInfo.addresses) - invokeInMainScopeIfRequired { - onDeviceChanged.emit(handle) - } - } - }, - ) - ) - } - - @Synchronized - fun stop() { - if (!_started) - return; - - _started = false; - - Logger.i(TAG, "CastingService stopping.") - - _scopeIO.cancel(); - _scopeMain.cancel(); - - Logger.i(TAG, "Stopping active device because StateCasting is being stopped.") - val d = activeDevice; - activeDevice = null; - d?.device?.disconnect(); - - _castServer.stop(); - _castServer.removeAllHandlers(); - - Logger.i(TAG, "CastingService stopped.") - - _deviceDiscoverer = null - } - - private val _castingDialogLock = Any(); - private var _currentDialog: AlertDialog? = null; - - @Synchronized - fun connectDevice(device: CastingDeviceHandle) { - if (activeDevice == device) - return; - - val ad = activeDevice; - if (ad != null) { - Logger.i(TAG, "Stopping previous device because a new one is being connected.") - device.eventHandler.onConnectionStateChanged.clear(); - device.eventHandler.onPlayChanged.clear(); - device.eventHandler.onTimeChanged.clear(); - device.eventHandler.onVolumeChanged.clear(); - device.eventHandler.onDurationChanged.clear(); - try { - ad.device.disconnect(); - } catch (e: Throwable) { - Logger.e(TAG, "Failed to disconnect from device: $e") - } - } - - device.eventHandler.onConnectionStateChanged.subscribe { castConnectionState -> - Logger.i(TAG, "Active device connection state changed: $castConnectionState"); - - if (castConnectionState == DeviceConnectionState.Disconnected) { - Logger.i(TAG, "Clearing events: $castConnectionState"); - device.eventHandler.onConnectionStateChanged.clear(); - device.eventHandler.onPlayChanged.clear(); - device.eventHandler.onTimeChanged.clear(); - device.eventHandler.onVolumeChanged.clear(); - device.eventHandler.onDurationChanged.clear(); - activeDevice = null; - } - - invokeInMainScopeIfRequired { - StateApp.withContext(false) { context -> - context.let { - Logger.i(TAG, "Casting state changed to ${castConnectionState}"); - when (castConnectionState) { - is DeviceConnectionState.Connected -> { - device.connectionState = CastConnectionState.CONNECTED - val localAddrOctets = - org.fcast.sender_sdk.octetsFromIpAddr(castConnectionState.localAddr) - val remoteAddrOctets = - org.fcast.sender_sdk.octetsFromIpAddr(castConnectionState.usedRemoteAddr) - device.localAddress = InetAddress.getByAddress(localAddrOctets) - device.usedRemoteAddress = - InetAddress.getByAddress(remoteAddrOctets) - Logger.i(TAG, "Casting connected to [${device.device.name()}]"); - UIDialogs.appToast("Connected to device") - synchronized(_castingDialogLock) { - if (_currentDialog != null) { - _currentDialog?.hide(); - _currentDialog = null; - } - } - onActiveDeviceConnectionStateChanged.emit( - device, - CastConnectionState.CONNECTED - ); - } - - DeviceConnectionState.Connecting, DeviceConnectionState.Reconnecting -> { - synchronized(_castingDialogLock) { - if (_currentDialog == null) { - device.connectionState = CastConnectionState.CONNECTING - Logger.i(TAG, "Casting connecting to [${device.device.name()}]"); - UIDialogs.toast(it, "Connecting to device...") - _currentDialog = UIDialogs.showDialog( - context, - R.drawable.ic_loader_animated, - true, - "Connecting to [${device.device.name()}]", - "Make sure you are on the same network\n\nVPNs and guest networks can cause issues", - null, - -2, - UIDialogs.Action("Disconnect", { - try { - device.device.disconnect(); - } catch (e: Throwable) { - Logger.e(TAG, "Failed to stop device: $e") - } - }) - ); - } - } - onActiveDeviceConnectionStateChanged.emit( - device, - CastConnectionState.CONNECTING - ); - } - - DeviceConnectionState.Disconnected -> { - device.connectionState = CastConnectionState.DISCONNECTED - UIDialogs.toast(it, "Disconnected from device") - synchronized(_castingDialogLock) { - if (_currentDialog != null) { - _currentDialog?.hide(); - _currentDialog = null; - } - } - onActiveDeviceConnectionStateChanged.emit( - device, - CastConnectionState.DISCONNECTED - ); - } - } - } - }; - }; - }; - - device.eventHandler.onPlayChanged.subscribe { - invokeInMainScopeIfRequired { - device.isPlaying = it - onActiveDevicePlayChanged.emit(it) - } - } - device.eventHandler.onDurationChanged.subscribe { - invokeInMainScopeIfRequired { - device.duration = it - onActiveDeviceDurationChanged.emit(it) - } - } - device.eventHandler.onVolumeChanged.subscribe { - invokeInMainScopeIfRequired { - device.volume = it - onActiveDeviceVolumeChanged.emit(it) - } - } - device.eventHandler.onTimeChanged.subscribe { - invokeInMainScopeIfRequired { - device.time = it - device.lastTimeChangeTime_ms = System.currentTimeMillis() - onActiveDeviceTimeChanged.emit(it) - } - } - device.eventHandler.onSpeedChanged.subscribe { - invokeInMainScopeIfRequired { - device.speed = it - } - } - - try { - device.device.connect( - ApplicationInfo( - "Grayjay Android", - "${BuildConfig.VERSION_NAME}-${BuildConfig.FLAVOR}", - "${Build.MANUFACTURER} ${Build.MODEL}" - ), - device.eventHandler, - 1000.toULong() - ) - Logger.i(TAG, "Requested manager to start device") - } catch (e: Throwable) { - Logger.w(TAG, "Failed to connect to device."); - device.eventHandler.onConnectionStateChanged.clear(); - device.eventHandler.onPlayChanged.clear(); - device.eventHandler.onTimeChanged.clear(); - device.eventHandler.onVolumeChanged.clear(); - device.eventHandler.onDurationChanged.clear(); - return; - } - - activeDevice = device; - Logger.i(TAG, "Started device `${device.device.name()}`"); - } - - fun addRememberedDevice(deviceInfo: CastingDeviceInfo): CastingDeviceInfo { - val device = deviceFromCastingDeviceInfo(deviceInfo); - return addRememberedDevice(device); - } - - fun getRememberedCastingDevices(): List { - return _storage.getDevices().map { deviceFromCastingDeviceInfo(it) } - } - - fun getRememberedCastingDeviceNames(): List { - return _storage.getDeviceNames() - } - - fun addRememberedDevice(device: CastingDeviceHandle): CastingDeviceInfo { - val rsDeviceInfo = device.device.getDeviceInfo() - val deviceInfo = CastingDeviceInfo( - name = device.device.name(), - type = when (rsDeviceInfo.protocol) { - ProtocolType.CHROMECAST -> com.futo.platformplayer.casting.CastProtocolType.CHROMECAST - ProtocolType.F_CAST -> com.futo.platformplayer.casting.CastProtocolType.FCAST - }, - addresses = rsDeviceInfo.addresses.map { urlFormatIpAddr(it) }.toTypedArray(), - port = rsDeviceInfo.port.toInt(), - ) - return _storage.addDevice(deviceInfo) - } - - fun removeRememberedDevice(device: CastingDeviceHandle) { - val name = device.device.name() - _storage.removeDevice(name) - } - - private fun invokeInMainScopeIfRequired(action: () -> Unit) { - if (Looper.getMainLooper().thread != Thread.currentThread()) { - _scopeMain.launch { action(); } - return; - } - - action(); - } - - private fun shouldProxyStreams( - deviceHandle: CastingDeviceHandle, - videoSource: IVideoSource?, - audioSource: IAudioSource? - ): Boolean { - val hasRequestModifier = - (videoSource as? JSSource)?.hasRequestModifier == true || (audioSource as? JSSource)?.hasRequestModifier == true - return Settings.instance.casting.alwaysProxyRequests || deviceHandle.device.castingProtocol() != ProtocolType.F_CAST || hasRequestModifier - } - - fun cancel() { - _castId.incrementAndGet() - } - - private fun metadataFromVideo(video: IPlatformVideoDetails): Metadata { - return Metadata( - title = video.name, - thumbnailUrl = video.thumbnails.getHQThumbnail() - ) - } - - suspend 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 { - return withContext(Dispatchers.IO) { - val ad = activeDevice ?: return@withContext false; - if (ad.connectionState != CastConnectionState.CONNECTED) { - return@withContext false; - } - - val resumePosition = if (ms > 0L) (ms.toDouble() / 1000.0) else 0.0; - val castId = _castId.incrementAndGet() - - var sourceCount = 0; - if (videoSource != null) sourceCount++; - if (audioSource != null) sourceCount++; - if (subtitleSource != null) sourceCount++; - - if (sourceCount < 1) { - throw Exception("At least one source should be specified."); - } - - if (sourceCount > 1) { - if (videoSource is LocalVideoSource || audioSource is LocalAudioSource || subtitleSource is LocalSubtitleSource) { - Logger.i(TAG, "Casting as local DASH"); - castLocalDash( - video, - videoSource as LocalVideoSource?, - audioSource as LocalAudioSource?, - subtitleSource as LocalSubtitleSource?, - resumePosition, - speed - ); - } else { - val isRawDash = - videoSource is JSDashManifestRawSource || audioSource is JSDashManifestRawAudioSource - if (isRawDash) { - Logger.i(TAG, "Casting as raw DASH"); - - castDashRaw( - contentResolver, - video, - videoSource as JSDashManifestRawSource?, - audioSource as JSDashManifestRawAudioSource?, - subtitleSource, - resumePosition, - speed, - castId, - onLoadingEstimate, - onLoading - ); - } else { - if (ad.device.castingProtocol() == ProtocolType.F_CAST) { - Logger.i(TAG, "Casting as DASH direct"); - castDashDirect( - contentResolver, - video, - videoSource as IVideoUrlSource?, - audioSource as IAudioUrlSource?, - subtitleSource, - resumePosition, - speed - ); - } else { - Logger.i(TAG, "Casting as DASH indirect"); - castDashIndirect( - contentResolver, - video, - videoSource as IVideoUrlSource?, - audioSource as IAudioUrlSource?, - subtitleSource, - resumePosition, - speed - ); - } - } - } - } else { - val proxyStreams = shouldProxyStreams(ad, videoSource, audioSource) - val url = getLocalUrl(ad); - val id = UUID.randomUUID(); - - if (videoSource is IVideoUrlSource) { - val videoPath = "/video-${id}" - val videoUrl = if (proxyStreams) url + videoPath else videoSource.getVideoUrl(); - Logger.i(TAG, "Casting as singular video"); - ad.loadVideo( - videoSource.container, - videoUrl, - resumePosition, - speed, - metadataFromVideo(video) - ) - } else if (audioSource is IAudioUrlSource) { - val audioPath = "/audio-${id}" - val audioUrl = if (proxyStreams) url + audioPath else audioSource.getAudioUrl(); - Logger.i(TAG, "Casting as singular audio"); - ad.loadVideo( - audioSource.container, - audioUrl, - resumePosition, - speed, - metadataFromVideo(video) - ); - } else if (videoSource is IHLSManifestSource) { - if (proxyStreams || ad.device.castingProtocol() == ProtocolType.CHROMECAST) { - Logger.i(TAG, "Casting as proxied HLS"); - castProxiedHls( - video, - videoSource.url, - videoSource.codec, - resumePosition, - speed - ); - } else { - Logger.i(TAG, "Casting as non-proxied HLS"); - ad.loadVideo( - videoSource.container, - videoSource.url, - resumePosition, - speed, - metadataFromVideo(video) - ); - } - } else if (audioSource is IHLSManifestAudioSource) { - if (proxyStreams || ad.device.castingProtocol() == ProtocolType.CHROMECAST) { - Logger.i(TAG, "Casting as proxied audio HLS"); - castProxiedHls( - video, - audioSource.url, - audioSource.codec, - resumePosition, - speed - ); - } else { - Logger.i(TAG, "Casting as non-proxied audio HLS"); - ad.loadVideo( - audioSource.container, - audioSource.url, - resumePosition, - speed, - metadataFromVideo(video) - ); - } - } else if (videoSource is LocalVideoSource) { - Logger.i(TAG, "Casting as local video"); - castLocalVideo(video, videoSource, resumePosition, speed); - } else if (audioSource is LocalAudioSource) { - Logger.i(TAG, "Casting as local audio"); - castLocalAudio(video, audioSource, resumePosition, speed); - } else if (videoSource is JSDashManifestRawSource) { - Logger.i(TAG, "Casting as JSDashManifestRawSource video"); - castDashRaw( - contentResolver, - video, - videoSource as JSDashManifestRawSource?, - null, - null, - resumePosition, - speed, - castId, - onLoadingEstimate, - onLoading - ); - } else if (audioSource is JSDashManifestRawAudioSource) { - Logger.i(TAG, "Casting as JSDashManifestRawSource audio"); - castDashRaw( - contentResolver, - video, - null, - audioSource as JSDashManifestRawAudioSource?, - null, - resumePosition, - speed, - castId, - onLoadingEstimate, - onLoading - ); - } else { - var str = listOf( - if (videoSource != null) "Video: ${videoSource::class.java.simpleName}" else null, - if (audioSource != null) "Audio: ${audioSource::class.java.simpleName}" else null, - if (subtitleSource != null) "Subtitles: ${subtitleSource::class.java.simpleName}" else null - ).filterNotNull().joinToString(", "); - throw UnsupportedCastException(str); - } - } - - return@withContext true; - } - } - - fun resumeVideo(): Boolean { - val ad = activeDevice ?: return false; - try { - ad.device.resumePlayback() - } catch (e: Throwable) { - Logger.e(TAG, "Failed to resume playback: $e") - } - return true; - } - - fun pauseVideo(): Boolean { - val ad = activeDevice ?: return false; - try { - ad.device.pausePlayback() - } catch (e: Throwable) { - Logger.e(TAG, "Failed to pause playback: $e") - } - return true; - } - - fun stopVideo(): Boolean { - val ad = activeDevice ?: return false; - try { - ad.device.stopPlayback() - } catch (e: Throwable) { - Logger.e(TAG, "Failed to stop playback: $e") - } - return true; - } - - fun videoSeekTo(timeSeconds: Double): Boolean { - val ad = activeDevice ?: return false; - try { - ad.device.seek(timeSeconds) - } catch (e: Throwable) { - Logger.e(TAG, "Failed to seek: $e") - } - return true; - } - - private fun castLocalVideo( - video: IPlatformVideoDetails, - videoSource: LocalVideoSource, - resumePosition: Double, - speed: Double? - ): List { - val ad = activeDevice ?: return listOf(); - - val url = getLocalUrl(ad); - val id = UUID.randomUUID(); - val videoPath = "/video-${id}" - val videoUrl = url + videoPath; - - _castServer.addHandlerWithAllowAllOptions( - HttpFileHandler("GET", videoPath, videoSource.container, videoSource.filePath) - .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("cast"); - - Logger.i(TAG, "Casting local video (videoUrl: $videoUrl)."); - ad.loadVideo( - videoSource.container, - videoUrl, - resumePosition, - speed, - metadataFromVideo(video) - ); - - return listOf(videoUrl); - } - - private fun castLocalAudio( - video: IPlatformVideoDetails, - audioSource: LocalAudioSource, - resumePosition: Double, - speed: Double? - ): List { - val ad = activeDevice ?: return listOf(); - - val url = getLocalUrl(ad); - val id = UUID.randomUUID(); - val audioPath = "/audio-${id}" - val audioUrl = url + audioPath; - - _castServer.addHandlerWithAllowAllOptions( - HttpFileHandler("GET", audioPath, audioSource.container, audioSource.filePath) - .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("cast"); - - Logger.i(TAG, "Casting local audio (audioUrl: $audioUrl)."); - ad.loadVideo( - audioSource.container, - audioUrl, - resumePosition, - speed, - metadataFromVideo(video) - ); - - return listOf(audioUrl); - } - - private fun castLocalHls( - video: IPlatformVideoDetails, - videoSource: LocalVideoSource?, - audioSource: LocalAudioSource?, - subtitleSource: LocalSubtitleSource?, - resumePosition: Double, - speed: Double? - ): List { - val ad = activeDevice ?: return listOf() - - val url = getLocalUrl(ad) - val id = UUID.randomUUID() - - val hlsPath = "/hls-${id}" - val videoPath = "/video-${id}" - val audioPath = "/audio-${id}" - val subtitlePath = "/subtitle-${id}" - - val hlsUrl = url + hlsPath - val videoUrl = url + videoPath - val audioUrl = url + audioPath - val subtitleUrl = url + subtitlePath - - val mediaRenditions = arrayListOf() - val variantPlaylistReferences = arrayListOf() - - if (videoSource != null) { - _castServer.addHandlerWithAllowAllOptions( - HttpFileHandler("GET", videoPath, videoSource.container, videoSource.filePath) - .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("castLocalHls") - - val duration = videoSource.duration - val videoVariantPlaylistPath = "/video-playlist-${id}" - val videoVariantPlaylistUrl = url + videoVariantPlaylistPath - val videoVariantPlaylistSegments = - listOf(HLS.MediaSegment(duration.toDouble(), videoUrl)) - val videoVariantPlaylist = HLS.VariantPlaylist( - 3, - duration.toInt(), - 0, - 0, - null, - null, - null, - videoVariantPlaylistSegments - ) - - _castServer.addHandlerWithAllowAllOptions( - HttpConstantHandler( - "GET", videoVariantPlaylistPath, videoVariantPlaylist.buildM3U8(), - "application/vnd.apple.mpegurl" - ) - .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("castLocalHls") - - variantPlaylistReferences.add( - HLS.VariantPlaylistReference( - videoVariantPlaylistUrl, HLS.StreamInfo( - videoSource.bitrate, - "${videoSource.width}x${videoSource.height}", - videoSource.codec, - null, - null, - if (audioSource != null) "audio" else null, - if (subtitleSource != null) "subtitles" else null, - null, - null - ) - ) - ) - } - - if (audioSource != null) { - _castServer.addHandlerWithAllowAllOptions( - HttpFileHandler("GET", audioPath, audioSource.container, audioSource.filePath) - .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("castLocalHls") - - val duration = - audioSource.duration ?: videoSource?.duration ?: throw Exception("Duration unknown") - val audioVariantPlaylistPath = "/audio-playlist-${id}" - val audioVariantPlaylistUrl = url + audioVariantPlaylistPath - val audioVariantPlaylistSegments = - listOf(HLS.MediaSegment(duration.toDouble(), audioUrl)) - val audioVariantPlaylist = HLS.VariantPlaylist( - 3, - duration.toInt(), - 0, - 0, - null, - null, - null, - audioVariantPlaylistSegments - ) - - _castServer.addHandlerWithAllowAllOptions( - HttpConstantHandler( - "GET", audioVariantPlaylistPath, audioVariantPlaylist.buildM3U8(), - "application/vnd.apple.mpegurl" - ) - .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("castLocalHls") - - mediaRenditions.add( - HLS.MediaRendition( - "AUDIO", - audioVariantPlaylistUrl, - "audio", - "df", - "default", - true, - true, - true - ) - ) - } - - if (subtitleSource != null) { - _castServer.addHandlerWithAllowAllOptions( - HttpFileHandler( - "GET", - subtitlePath, - subtitleSource.format ?: "text/vtt", - subtitleSource.filePath - ) - .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("castLocalHls") - - val duration = videoSource?.duration ?: audioSource?.duration - ?: throw Exception("Duration unknown") - val subtitleVariantPlaylistPath = "/subtitle-playlist-${id}" - val subtitleVariantPlaylistUrl = url + subtitleVariantPlaylistPath - val subtitleVariantPlaylistSegments = - listOf(HLS.MediaSegment(duration.toDouble(), subtitleUrl)) - val subtitleVariantPlaylist = HLS.VariantPlaylist( - 3, - duration.toInt(), - 0, - 0, - null, - null, - null, - subtitleVariantPlaylistSegments - ) - - _castServer.addHandlerWithAllowAllOptions( - HttpConstantHandler( - "GET", subtitleVariantPlaylistPath, subtitleVariantPlaylist.buildM3U8(), - "application/vnd.apple.mpegurl" - ) - .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("castLocalHls") - - mediaRenditions.add( - HLS.MediaRendition( - "SUBTITLES", - subtitleVariantPlaylistUrl, - "subtitles", - "df", - "default", - true, - true, - true - ) - ) - } - - val masterPlaylist = - HLS.MasterPlaylist(variantPlaylistReferences, mediaRenditions, listOf(), true) - _castServer.addHandlerWithAllowAllOptions( - HttpConstantHandler( - "GET", hlsPath, masterPlaylist.buildM3U8(), - "application/vnd.apple.mpegurl" - ) - .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("castLocalHls") - - Logger.i( - TAG, - "added new castLocalHls handlers (hlsPath: $hlsPath, videoPath: $videoPath, audioPath: $audioPath, subtitlePath: $subtitlePath)." - ) - ad.loadVideo( - "application/vnd.apple.mpegurl", - hlsUrl, - resumePosition, - speed, - metadataFromVideo(video) - ) - - return listOf(hlsUrl, videoUrl, audioUrl, subtitleUrl) - } - - private fun castLocalDash( - video: IPlatformVideoDetails, - videoSource: LocalVideoSource?, - audioSource: LocalAudioSource?, - subtitleSource: LocalSubtitleSource?, - resumePosition: Double, - speed: Double? - ): List { - val ad = activeDevice ?: return listOf(); - - val url = getLocalUrl(ad); - val id = UUID.randomUUID(); - - val dashPath = "/dash-${id}" - val videoPath = "/video-${id}" - val audioPath = "/audio-${id}" - val subtitlePath = "/subtitle-${id}" - - val dashUrl = url + dashPath; - val videoUrl = url + videoPath; - val audioUrl = url + audioPath; - val subtitleUrl = url + subtitlePath; - - val dashContent = DashBuilder.generateOnDemandDash( - videoSource, - videoUrl, - audioSource, - audioUrl, - subtitleSource, - subtitleUrl - ); - Logger.v(TAG) { "Dash manifest: $dashContent" }; - - _castServer.addHandlerWithAllowAllOptions( - HttpConstantHandler( - "GET", dashPath, dashContent, - "application/dash+xml" - ) - .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("cast"); - if (videoSource != null) { - _castServer.addHandlerWithAllowAllOptions( - HttpFileHandler("GET", videoPath, videoSource.container, videoSource.filePath) - .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("cast"); - } - if (audioSource != null) { - _castServer.addHandlerWithAllowAllOptions( - HttpFileHandler("GET", audioPath, audioSource.container, audioSource.filePath) - .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("cast"); - } - if (subtitleSource != null) { - _castServer.addHandlerWithAllowAllOptions( - HttpFileHandler( - "GET", - subtitlePath, - subtitleSource.format ?: "text/vtt", - subtitleSource.filePath - ) - .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("cast"); - } - - Logger.i( - TAG, - "added new castLocalDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath, subtitlePath: $subtitlePath)." - ); - ad.loadVideo( - "application/dash+xml", - dashUrl, - resumePosition, - speed, - metadataFromVideo(video) - ); - - return listOf(dashUrl, videoUrl, audioUrl, subtitleUrl); - } - - private suspend fun castDashDirect( - contentResolver: ContentResolver, - video: IPlatformVideoDetails, - videoSource: IVideoUrlSource?, - audioSource: IAudioUrlSource?, - subtitleSource: ISubtitleSource?, - resumePosition: Double, - speed: Double? - ): List { - val ad = activeDevice ?: return listOf(); - val proxyStreams = - Settings.instance.casting.alwaysProxyRequests || ad.device.castingProtocol() != ProtocolType.F_CAST - - val url = getLocalUrl(ad); - val id = UUID.randomUUID(); - - val videoPath = "/video-${id}" - val audioPath = "/audio-${id}" - val subtitlePath = "/subtitle-${id}" - - val videoUrl = if (proxyStreams) url + videoPath else videoSource?.getVideoUrl(); - val audioUrl = if (proxyStreams) url + audioPath else audioSource?.getAudioUrl(); - - val subtitlesUri = if (subtitleSource != null) withContext(Dispatchers.IO) { - return@withContext subtitleSource.getSubtitlesURI(); - } else null; - - var subtitlesUrl: String? = null; - if (subtitlesUri != null) { - if (subtitlesUri.scheme == "file") { - var content: String? = null; - val inputStream = contentResolver.openInputStream(subtitlesUri); - inputStream?.use { stream -> - val reader = stream.bufferedReader(); - content = reader.use { it.readText() }; - } - - if (content != null) { - _castServer.addHandlerWithAllowAllOptions( - HttpConstantHandler( - "GET", - subtitlePath, - content!!, - subtitleSource?.format ?: "text/vtt" - ) - .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("cast"); - } - - subtitlesUrl = url + subtitlePath; - } else { - subtitlesUrl = subtitlesUri.toString(); - } - } - - if (videoSource != null) { - _castServer.addHandlerWithAllowAllOptions( - HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true) - .withInjectedHost() - .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("cast"); - } - if (audioSource != null) { - _castServer.addHandlerWithAllowAllOptions( - HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true) - .withInjectedHost() - .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("cast"); - } - - val content = DashBuilder.generateOnDemandDash( - videoSource, - videoUrl, - audioSource, - audioUrl, - subtitleSource, - subtitlesUrl - ); - - Logger.i( - TAG, - "Direct dash cast to casting device (videoUrl: $videoUrl, audioUrl: $audioUrl)." - ); - Logger.v(TAG) { "Dash manifest: $content" }; - ad.loadContent( - "application/dash+xml", - content, - resumePosition, - speed - ); - - return listOf( - videoUrl ?: "", - audioUrl ?: "", - subtitlesUrl ?: "", - videoSource?.getVideoUrl() ?: "", - audioSource?.getAudioUrl() ?: "", - subtitlesUri.toString() - ); - } - - private fun castProxiedHls( - video: IPlatformVideoDetails, - sourceUrl: String, - codec: String?, - resumePosition: Double, - speed: Double? - ): List { - _castServer.removeAllHandlers("castProxiedHlsMaster") - - val ad = activeDevice ?: return listOf(); - val url = getLocalUrl(ad); - - val id = UUID.randomUUID(); - val hlsPath = "/hls-${id}" - val hlsUrl = url + hlsPath - Logger.i(TAG, "HLS url: $hlsUrl"); - - _castServer.addHandlerWithAllowAllOptions( - HttpFunctionHandler( - "GET", - hlsPath - ) { masterContext -> - _castServer.removeAllHandlers("castProxiedHlsVariant") - - val headers = masterContext.headers.clone() - headers["Content-Type"] = "application/vnd.apple.mpegurl"; - - val masterPlaylistResponse = _client.get(sourceUrl) - check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" } - - val masterPlaylistContent = masterPlaylistResponse.body?.string() - ?: throw Exception("Master playlist content is empty") - - val masterPlaylist: HLS.MasterPlaylist - try { - masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, sourceUrl) - } catch (e: Throwable) { - if (masterPlaylistContent.lines().any { it.startsWith("#EXTINF:") }) { - //This is a variant playlist, not a master playlist - Logger.i(TAG, "HLS casting as variant playlist (codec: $codec): $hlsUrl"); - - val vpHeaders = masterContext.headers.clone() - vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl"; - - val variantPlaylist = - HLS.parseVariantPlaylist(masterPlaylistContent, sourceUrl) - val proxiedVariantPlaylist = - proxyVariantPlaylist(url, id, variantPlaylist, video.isLive) - val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8() - masterContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8); - return@HttpFunctionHandler - } else { - throw e - } - } - - Logger.i(TAG, "HLS casting as master playlist: $hlsUrl"); - - val newVariantPlaylistRefs = arrayListOf() - val newMediaRenditions = arrayListOf() - val newMasterPlaylist = HLS.MasterPlaylist( - newVariantPlaylistRefs, - newMediaRenditions, - masterPlaylist.sessionDataList, - masterPlaylist.independentSegments - ) - - for (variantPlaylistRef in masterPlaylist.variantPlaylistsRefs) { - val playlistId = UUID.randomUUID(); - val newPlaylistPath = "/hls-playlist-${playlistId}" - val newPlaylistUrl = url + newPlaylistPath; - - _castServer.addHandlerWithAllowAllOptions( - HttpFunctionHandler( - "GET", - newPlaylistPath - ) { vpContext -> - val vpHeaders = vpContext.headers.clone() - vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl"; - - val response = _client.get(variantPlaylistRef.url) - check(response.isOk) { "Failed to get variant playlist: ${response.code}" } - - val vpContent = response.body?.string() - ?: throw Exception("Variant playlist content is empty") - - val variantPlaylist = - HLS.parseVariantPlaylist(vpContent, variantPlaylistRef.url) - val proxiedVariantPlaylist = - proxyVariantPlaylist(url, playlistId, variantPlaylist, video.isLive) - val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8() - vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8); - }.withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("castProxiedHlsVariant") - - newVariantPlaylistRefs.add( - HLS.VariantPlaylistReference( - newPlaylistUrl, - variantPlaylistRef.streamInfo - ) - ) - } - - for (mediaRendition in masterPlaylist.mediaRenditions) { - val playlistId = UUID.randomUUID() - - var newPlaylistUrl: String? = null - if (mediaRendition.uri != null) { - val newPlaylistPath = "/hls-playlist-${playlistId}" - newPlaylistUrl = url + newPlaylistPath - - _castServer.addHandlerWithAllowAllOptions( - HttpFunctionHandler( - "GET", - newPlaylistPath - ) { vpContext -> - val vpHeaders = vpContext.headers.clone() - vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl"; - - val response = _client.get(mediaRendition.uri) - check(response.isOk) { "Failed to get variant playlist: ${response.code}" } - - val vpContent = response.body?.string() - ?: throw Exception("Variant playlist content is empty") - - val variantPlaylist = - HLS.parseVariantPlaylist(vpContent, mediaRendition.uri) - val proxiedVariantPlaylist = proxyVariantPlaylist( - url, - playlistId, - variantPlaylist, - video.isLive - ) - val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8() - vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8); - }.withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("castProxiedHlsVariant") - } - - newMediaRenditions.add( - HLS.MediaRendition( - mediaRendition.type, - newPlaylistUrl, - mediaRendition.groupID, - mediaRendition.language, - mediaRendition.name, - mediaRendition.isDefault, - mediaRendition.isAutoSelect, - mediaRendition.isForced - ) - ) - } - - masterContext.respondCode(200, headers, newMasterPlaylist.buildM3U8()); - }.withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("castProxiedHlsMaster") - - Logger.i(TAG, "added new castHlsIndirect handlers (hlsPath: $hlsPath)."); - - //ChromeCast is sometimes funky with resume position 0 - val hackfixResumePosition = - if (ad.device.castingProtocol() == ProtocolType.CHROMECAST && !video.isLive && resumePosition == 0.0) 0.1 else resumePosition; - ad.loadVideo( - "application/vnd.apple.mpegurl", - hlsUrl, - hackfixResumePosition, - speed, - metadataFromVideo(video) - ); - - return listOf(hlsUrl); - } - - private fun proxyVariantPlaylist( - url: String, - playlistId: UUID, - variantPlaylist: HLS.VariantPlaylist, - isLive: Boolean, - proxySegments: Boolean = true - ): HLS.VariantPlaylist { - val newSegments = arrayListOf() - - if (proxySegments) { - variantPlaylist.segments.forEachIndexed { index, segment -> - val sequenceNumber = (variantPlaylist.mediaSequence ?: 0) + index.toLong() - newSegments.add(proxySegment(url, playlistId, segment, sequenceNumber)) - } - } else { - newSegments.addAll(variantPlaylist.segments) - } - - return HLS.VariantPlaylist( - variantPlaylist.version, - variantPlaylist.targetDuration, - variantPlaylist.mediaSequence, - variantPlaylist.discontinuitySequence, - variantPlaylist.programDateTime, - variantPlaylist.playlistType, - variantPlaylist.streamInfo, - newSegments - ) - } - - private fun proxySegment( - url: String, - playlistId: UUID, - segment: HLS.Segment, - index: Long - ): HLS.Segment { - if (segment is HLS.MediaSegment) { - val newSegmentPath = "/hls-playlist-${playlistId}-segment-${index}" - val newSegmentUrl = url + newSegmentPath; - - if (_castServer.getHandler("GET", newSegmentPath) == null) { - _castServer.addHandlerWithAllowAllOptions( - HttpProxyHandler("GET", newSegmentPath, segment.uri, true) - .withInjectedHost() - .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("castProxiedHlsVariant") - } - - return HLS.MediaSegment( - segment.duration, - newSegmentUrl - ) - } else { - return segment - } - } - - private suspend fun castDashIndirect( - contentResolver: ContentResolver, - video: IPlatformVideoDetails, - videoSource: IVideoUrlSource?, - audioSource: IAudioUrlSource?, - subtitleSource: ISubtitleSource?, - resumePosition: Double, - speed: Double? - ): List { - val ad = activeDevice ?: return listOf(); - val proxyStreams = - Settings.instance.casting.alwaysProxyRequests || ad.device.castingProtocol() != ProtocolType.F_CAST - - val url = getLocalUrl(ad); - val id = UUID.randomUUID(); - - val dashPath = "/dash-${id}" - val videoPath = "/video-${id}" - val audioPath = "/audio-${id}" - val subtitlePath = "/subtitle-${id}" - - val dashUrl = url + dashPath; - Logger.i(TAG, "DASH url: $dashUrl"); - - val videoUrl = if (proxyStreams) url + videoPath else videoSource?.getVideoUrl(); - val audioUrl = if (proxyStreams) url + audioPath else audioSource?.getAudioUrl(); - - val subtitlesUri = if (subtitleSource != null) withContext(Dispatchers.IO) { - return@withContext subtitleSource.getSubtitlesURI(); - } else null; - - //_castServer.removeAllHandlers("cast"); - //Logger.i(TAG, "removed all old castDash handlers."); - - var subtitlesUrl: String? = null; - if (subtitlesUri != null) { - if (subtitlesUri.scheme == "file") { - var content: String? = null; - val inputStream = contentResolver.openInputStream(subtitlesUri); - inputStream?.use { stream -> - val reader = stream.bufferedReader(); - content = reader.use { it.readText() }; - } - - if (content != null) { - _castServer.addHandlerWithAllowAllOptions( - HttpConstantHandler( - "GET", - subtitlePath, - content!!, - subtitleSource?.format ?: "text/vtt" - ) - .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("cast"); - } - - subtitlesUrl = url + subtitlePath; - } else { - subtitlesUrl = subtitlesUri.toString(); - } - } - - val dashContent = DashBuilder.generateOnDemandDash( - videoSource, - videoUrl, - audioSource, - audioUrl, - subtitleSource, - subtitlesUrl - ); - Logger.v(TAG) { "Dash manifest: $dashContent" }; - - _castServer.addHandlerWithAllowAllOptions( - HttpConstantHandler( - "GET", dashPath, dashContent, - "application/dash+xml" - ) - .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("cast"); - - if (videoSource != null) { - _castServer.addHandlerWithAllowAllOptions( - HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true) - .withInjectedHost() - .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("cast"); - } - if (audioSource != null) { - _castServer.addHandlerWithAllowAllOptions( - HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true) - .withInjectedHost() - .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("cast"); - } - - Logger.i( - TAG, - "added new castDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath)." - ); - ad.loadVideo( - "application/dash+xml", - dashUrl, - resumePosition, - speed, - metadataFromVideo(video) - ); - - return listOf( - dashUrl, - videoUrl ?: "", - audioUrl ?: "", - subtitlesUrl ?: "", - videoSource?.getVideoUrl() ?: "", - audioSource?.getAudioUrl() ?: "", - subtitlesUri.toString() - ); - } - - private fun cleanExecutors() { - if (_videoExecutor != null) { - _videoExecutor?.cleanup() - _videoExecutor = null - } - - if (_audioExecutor != null) { - _audioExecutor?.cleanup() - _audioExecutor = null - } - } - - private fun getLocalUrl(ad: CastingDeviceHandle): String { - var address = ad.localAddress!! - if (Settings.instance.casting.allowLinkLocalIpv4) { - if (address.isLinkLocalAddress && address is Inet6Address) { - address = findPreferredAddress() ?: address - Logger.i(TAG, "Selected casting address: $address") - } - } else { - if (address.isLinkLocalAddress) { - address = findPreferredAddress() ?: address - Logger.i(TAG, "Selected casting address: $address") - } - } - return "http://${address.toUrlAddress().trim('/')}:${_castServer.port}"; - } - - @OptIn(UnstableApi::class) - 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() - _castServer.removeAllHandlers("castDashRaw") - - val url = getLocalUrl(ad); - val id = UUID.randomUUID(); - - val dashPath = "/dash-${id}" - val videoPath = "/video-${id}" - val audioPath = "/audio-${id}" - val subtitlePath = "/subtitle-${id}" - - val dashUrl = url + dashPath; - Logger.i(TAG, "DASH url: $dashUrl"); - - val videoUrl = url + videoPath - val audioUrl = url + audioPath - - val subtitlesUri = if (subtitleSource != null) withContext(Dispatchers.IO) { - return@withContext subtitleSource.getSubtitlesURI(); - } else null; - - var subtitlesUrl: String? = null; - if (subtitlesUri != null) { - if (subtitlesUri.scheme == "file") { - var content: String? = null; - val inputStream = contentResolver.openInputStream(subtitlesUri); - inputStream?.use { stream -> - val reader = stream.bufferedReader(); - content = reader.use { it.readText() }; - } - - if (content != null) { - _castServer.addHandlerWithAllowAllOptions( - HttpConstantHandler( - "GET", - subtitlePath, - content!!, - subtitleSource?.format ?: "text/vtt" - ) - .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("cast"); - } - - subtitlesUrl = url + subtitlePath; - } else { - subtitlesUrl = subtitlesUri.toString(); - } - } - - var dashContent: String = withContext(Dispatchers.IO) { - stopVideo() - - //TODO: Include subtitlesURl in the future - val deferred = if (audioSource != null && videoSource != null) { - JSDashManifestMergingRawSource(videoSource, audioSource).generateAsync(_scopeIO) - } else if (audioSource != null) { - audioSource.generateAsync(_scopeIO) - } else if (videoSource != null) { - 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) { - if (it.range.first < representation.range.first || it.range.last > representation.range.last) { - return@replace it.value - } - - if (mediaType.startsWith("video/")) { - return@replace "${it.groups[1]!!.value}=\"${videoUrl}?url=${ - URLEncoder.encode( - it.groups[2]!!.value, - "UTF-8" - ).replace("%24Number%24", "\$Number\$") - }&mediaType=${URLEncoder.encode(mediaType, "UTF-8")}\"" - } else if (mediaType.startsWith("audio/")) { - return@replace "${it.groups[1]!!.value}=\"${audioUrl}?url=${ - URLEncoder.encode( - it.groups[2]!!.value, - "UTF-8" - ).replace("%24Number%24", "\$Number\$") - }&mediaType=${URLEncoder.encode(mediaType, "UTF-8")}\"" - } else { - throw Exception("Expected audio or video") - } - } - } - - if (videoSource != null && !videoSource.hasRequestExecutor) { - throw Exception("Video source without request executor not supported") - } - - if (audioSource != null && !audioSource.hasRequestExecutor) { - throw Exception("Audio source without request executor not supported") - } - - if (audioSource != null && audioSource.hasRequestExecutor) { - _audioExecutor = audioSource.getRequestExecutor() - } - - if (videoSource != null && videoSource.hasRequestExecutor) { - _videoExecutor = videoSource.getRequestExecutor() - } - - //TOOD: Else also handle the non request executor case, perhaps add ?url=$originalUrl to the query parameters, ... propagate this for all other flows also - - Logger.v(TAG) { "Dash manifest: $dashContent" }; - - _castServer.addHandlerWithAllowAllOptions( - HttpConstantHandler( - "GET", dashPath, dashContent, - "application/dash+xml" - ) - .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("castDashRaw"); - - if (videoSource != null) { - _castServer.addHandlerWithAllowAllOptions( - HttpFunctionHandler("GET", videoPath) { httpContext -> - val originalUrl = - httpContext.query["url"]?.let { URLDecoder.decode(it, "UTF-8") } - ?: return@HttpFunctionHandler - val mediaType = - httpContext.query["mediaType"]?.let { URLDecoder.decode(it, "UTF-8") } - ?: return@HttpFunctionHandler - - val videoExecutor = _videoExecutor; - if (videoExecutor != null) { - val data = videoExecutor.executeRequest( - "GET", - originalUrl, - null, - httpContext.headers - ) - httpContext.respondBytes(200, HttpHeaders().apply { - put("Content-Type", mediaType) - }, data); - } else { - throw NotImplementedError() - } - }.withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("castDashRaw"); - } - if (audioSource != null) { - _castServer.addHandlerWithAllowAllOptions( - HttpFunctionHandler("GET", audioPath) { httpContext -> - val originalUrl = - httpContext.query["url"]?.let { URLDecoder.decode(it, "UTF-8") } - ?: return@HttpFunctionHandler - val mediaType = - httpContext.query["mediaType"]?.let { URLDecoder.decode(it, "UTF-8") } - ?: return@HttpFunctionHandler - - val audioExecutor = _audioExecutor; - if (audioExecutor != null) { - val data = audioExecutor.executeRequest( - "GET", - originalUrl, - null, - httpContext.headers - ) - httpContext.respondBytes(200, HttpHeaders().apply { - put("Content-Type", mediaType) - }, data); - } else { - throw NotImplementedError() - } - }.withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("castDashRaw"); - } - - Logger.i( - TAG, - "added new castDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath)." - ); - ad.loadVideo( - "application/dash+xml", - dashUrl, - resumePosition, - speed, - metadataFromVideo(video) - ); - - return listOf() - } - - private fun deviceFromCastingDeviceInfo(deviceInfo: com.futo.platformplayer.models.CastingDeviceInfo): CastingDeviceHandle { - val rsAddrs = - deviceInfo.addresses.map { org.fcast.sender_sdk.tryIpAddrFromStr(it) } // Throws! - val rsDeviceInfo = RsDeviceInfo( - name = deviceInfo.name, - protocol = when (deviceInfo.type) { - com.futo.platformplayer.casting.CastProtocolType.CHROMECAST -> ProtocolType.CHROMECAST - com.futo.platformplayer.casting.CastProtocolType.FCAST -> ProtocolType.F_CAST - else -> throw IllegalArgumentException() - }, - addresses = rsAddrs, - port = deviceInfo.port.toUShort(), - ) - - return CastingDeviceHandle(_context.createDeviceFromInfo(rsDeviceInfo)) - } - - fun enableDeveloper(enableDev: Boolean) { - _castServer.removeAllHandlers("dev"); - if (enableDev) { - _castServer.addHandler(HttpFunctionHandler("GET", "/dashPlayer") { context -> - if (context.query.containsKey("dashUrl")) { - val dashUrl = context.query["dashUrl"]; - val html = "
\n" + - " \n" + - " \n" + - " \n" + - " \n" + - " \n" + - "
"; - context.respondCode(200, html, "text/html"); - } - }).withTag("dev"); - } - } - - companion object { - val instance: ExpStateCasting = ExpStateCasting(); - - private val representationRegex = Regex( - "(.*?)<\\/Representation>", - RegexOption.DOT_MATCHES_ALL - ) - private val mediaInitializationRegex = - Regex("(media|initiali[sz]ation)=\"([^\"]+)\"", RegexOption.DOT_MATCHES_ALL); - - private val TAG = "ExperimentalStateCasting"; - } -} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/experimental_casting/StateCastingDispatcher.kt b/app/src/main/java/com/futo/platformplayer/experimental_casting/StateCastingDispatcher.kt deleted file mode 100644 index 1adde666..00000000 --- a/app/src/main/java/com/futo/platformplayer/experimental_casting/StateCastingDispatcher.kt +++ /dev/null @@ -1,132 +0,0 @@ -package com.futo.platformplayer.experimental_casting - -import com.futo.platformplayer.Settings -import com.futo.platformplayer.casting.CastConnectionState -import com.futo.platformplayer.casting.StateCasting -import org.fcast.sender_sdk.DeviceFeature - -class StateCastingDispatcher { - companion object { - fun canActiveDeviceSetSpeed(): Boolean { - return if (Settings.instance.casting.experimentalCasting) { - ExpStateCasting.instance.activeDevice?.device?.supportsFeature(DeviceFeature.SET_SPEED) == true - } else { - StateCasting.instance.activeDevice?.canSetSpeed == true - } - } - - fun getActiveDeviceSpeed(): Double? { - return if (Settings.instance.casting.experimentalCasting) { - ExpStateCasting.instance.activeDevice?.speed - } else { - StateCasting.instance.activeDevice?.speed - } - } - - fun activeDeviceSetSpeed(speed: Double) { - if (Settings.instance.casting.experimentalCasting) { - ExpStateCasting.instance.activeDevice?.device?.changeSpeed(speed) - } else { - StateCasting.instance.activeDevice?.changeSpeed(speed) - } - } - - fun resumeVideo(): Boolean { - return try { - if (Settings.instance.casting.experimentalCasting) { - ExpStateCasting.instance.resumeVideo() - } else { - StateCasting.instance.resumeVideo() - } - } catch (_: Throwable) { - false - } - } - - fun pauseVideo(): Boolean { - return try { - if (Settings.instance.casting.experimentalCasting) { - ExpStateCasting.instance.pauseVideo() - } else { - StateCasting.instance.pauseVideo() - } - } catch (_: Throwable) { - false - } - } - - fun videoSeekTo(timeSeconds: Double): Boolean { - return try { - return if (Settings.instance.casting.experimentalCasting) { - ExpStateCasting.instance.videoSeekTo(timeSeconds) - } else { - StateCasting.instance.videoSeekTo(timeSeconds) - } - } catch (_: Throwable) { - false - } - } - - fun stopVideo(): Boolean { - return try { - return if (Settings.instance.casting.experimentalCasting) { - ExpStateCasting.instance.stopVideo() - } else { - StateCasting.instance.stopVideo() - } - } catch (_: Throwable) { - false - } - } - - fun isCasting(): Boolean { - return if (Settings.instance.casting.experimentalCasting) { - ExpStateCasting.instance.isCasting - } else { - StateCasting.instance.isCasting - } - } - - fun isConnected(): Boolean { - return if (Settings.instance.casting.experimentalCasting) { - ExpStateCasting.instance.activeDevice?.connectionState == com.futo.platformplayer.experimental_casting.CastConnectionState.CONNECTED - } else { - StateCasting.instance.activeDevice?.connectionState == CastConnectionState.CONNECTED - } - } - - fun isPlaying(): Boolean { - return if (Settings.instance.casting.experimentalCasting) { - ExpStateCasting.instance.activeDevice?.isPlaying == true - } else { - StateCasting.instance.activeDevice?.isPlaying == true - } - } - - fun getExpectedCurrentTime(): Double? { - return if (Settings.instance.casting.experimentalCasting) { - ExpStateCasting.instance.activeDevice?.expectedCurrentTime - } else { - StateCasting.instance.activeDevice?.expectedCurrentTime - } - } - - fun changeVolume(volume: Double) { - try { - if (Settings.instance.casting.experimentalCasting) { - val activeDevice = - ExpStateCasting.instance.activeDevice ?: return; - if (activeDevice.device.supportsFeature(DeviceFeature.SET_VOLUME)) { - activeDevice.device.changeVolume(volume); - } - } else { - val activeDevice = - StateCasting.instance.activeDevice ?: return; - if (activeDevice.canSetVolume) { - activeDevice.changeVolume(volume); - } - } - } catch (_: Throwable) {} - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt index 9293be3b..5d1f95f1 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt @@ -27,10 +27,10 @@ import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.activities.SettingsActivity import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails +import com.futo.platformplayer.casting.OldStateCasting 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 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 4848e748..84029c7a 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 @@ -84,6 +84,7 @@ import com.futo.platformplayer.api.media.platforms.js.models.JSVideoDetails import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.casting.CastConnectionState +import com.futo.platformplayer.casting.OldStateCasting import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event1 @@ -98,8 +99,6 @@ import com.futo.platformplayer.engine.exceptions.ScriptLoginRequiredException import com.futo.platformplayer.engine.exceptions.ScriptReloadRequiredException import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException import com.futo.platformplayer.exceptions.UnsupportedCastException -import com.futo.platformplayer.experimental_casting.ExpStateCasting -import com.futo.platformplayer.experimental_casting.StateCastingDispatcher import com.futo.platformplayer.fixHtmlLinks import com.futo.platformplayer.fixHtmlWhitespace import com.futo.platformplayer.getNowDiffSeconds @@ -177,7 +176,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import org.fcast.sender_sdk.DeviceFeature import userpackage.Protocol import java.time.OffsetDateTime import java.util.Locale @@ -581,7 +579,7 @@ class VideoDetailView : ConstraintLayout { } else if(chapter?.type == ChapterType.SKIP || chapter?.type == ChapterType.SKIPONCE) { val ad = StateCasting.instance.activeDevice if (ad != null) { - ad.seekVideo(chapter.timeEnd) + ad.seekTo(chapter.timeEnd) } else { _player.seekTo((chapter.timeEnd * 1000).toLong()); } @@ -667,94 +665,49 @@ class VideoDetailView : ConstraintLayout { } if (!isInEditMode) { - if (Settings.instance.casting.experimentalCasting) { - ExpStateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { device, connectionState -> - if (_onPauseCalled) { - return@subscribe; - } - - when (connectionState) { - com.futo.platformplayer.experimental_casting.CastConnectionState.CONNECTED -> { - loadCurrentVideo(lastPositionMilliseconds); - updatePillButtonVisibilities(); - setCastEnabled(true); - } - com.futo.platformplayer.experimental_casting.CastConnectionState.DISCONNECTED -> { - loadCurrentVideo(lastPositionMilliseconds, playWhenReady = device.isPlaying); - updatePillButtonVisibilities(); - setCastEnabled(false); - - } - else -> {} - } + StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { device, connectionState -> + if (_onPauseCalled) { + return@subscribe; } - ExpStateCasting.instance.onActiveDevicePlayChanged.subscribe(this) { - val activeDevice = StateCasting.instance.activeDevice; - if (activeDevice != null) { - handlePlayChanged(it); - - val v = video; - if (!it && v != null && v.duration - activeDevice.time.toLong() < 2L) { - nextVideo(); - } + when (connectionState) { + CastConnectionState.CONNECTED -> { + loadCurrentVideo(lastPositionMilliseconds); + updatePillButtonVisibilities(); + setCastEnabled(true); } - }; + CastConnectionState.DISCONNECTED -> { + loadCurrentVideo(lastPositionMilliseconds, playWhenReady = device.isPlaying); + updatePillButtonVisibilities(); + setCastEnabled(false); - ExpStateCasting.instance.onActiveDeviceTimeChanged.subscribe(this) { - if (_isCasting) { - setLastPositionMilliseconds((it * 1000.0).toLong(), true); - _cast.setTime(lastPositionMilliseconds); - _timeBar.setPosition(it.toLong()); - _timeBar.setBufferedPosition(0); - _timeBar.setDuration(video?.duration ?: 0); - } - }; - } else { - StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { device, connectionState -> - if (_onPauseCalled) { - return@subscribe; - } - - when (connectionState) { - CastConnectionState.CONNECTED -> { - loadCurrentVideo(lastPositionMilliseconds); - updatePillButtonVisibilities(); - setCastEnabled(true); - } - CastConnectionState.DISCONNECTED -> { - loadCurrentVideo(lastPositionMilliseconds, playWhenReady = device.isPlaying); - updatePillButtonVisibilities(); - setCastEnabled(false); - - } - else -> {} } + else -> {} } - - StateCasting.instance.onActiveDevicePlayChanged.subscribe(this) { - val activeDevice = StateCasting.instance.activeDevice; - if (activeDevice != null) { - handlePlayChanged(it); - - val v = video; - if (!it && v != null && v.duration - activeDevice.time.toLong() < 2L) { - nextVideo(); - } - } - }; - - StateCasting.instance.onActiveDeviceTimeChanged.subscribe(this) { - if (_isCasting) { - setLastPositionMilliseconds((it * 1000.0).toLong(), true); - _cast.setTime(lastPositionMilliseconds); - _timeBar.setPosition(it.toLong()); - _timeBar.setBufferedPosition(0); - _timeBar.setDuration(video?.duration ?: 0); - } - }; } + StateCasting.instance.onActiveDevicePlayChanged.subscribe(this) { + val activeDevice = StateCasting.instance.activeDevice; + if (activeDevice != null) { + handlePlayChanged(it); + + val v = video; + if (!it && v != null && v.duration - activeDevice.time.toLong() < 2L) { + nextVideo(); + } + } + }; + + StateCasting.instance.onActiveDeviceTimeChanged.subscribe(this) { + if (_isCasting) { + setLastPositionMilliseconds((it * 1000.0).toLong(), true); + _cast.setTime(lastPositionMilliseconds); + _timeBar.setPosition(it.toLong()); + _timeBar.setBufferedPosition(0); + _timeBar.setDuration(video?.duration ?: 0); + } + }; + updatePillButtonVisibilities(); _cast.onTimeJobTimeChanged_s.subscribe { @@ -934,7 +887,7 @@ class VideoDetailView : ConstraintLayout { if (ad != null) { val currentChapter = _cast.getCurrentChapter((ad.time * 1000).toLong()); if(currentChapter?.type == ChapterType.SKIPPABLE) { - ad.seekVideo(currentChapter.timeEnd); + ad.seekTo(currentChapter.timeEnd); } } else { val currentChapter = _player.getCurrentChapter(_player.position); @@ -1218,7 +1171,7 @@ class VideoDetailView : ConstraintLayout { _onPauseCalled = true; _taskLoadVideo.cancel(); - if (StateCastingDispatcher.isCasting()) { + if (StateCasting.instance.isCasting) { return } @@ -1271,15 +1224,9 @@ class VideoDetailView : ConstraintLayout { _container_content_description.cleanup(); _container_content_support.cleanup(); StatePlayer.instance.autoplayChanged.remove(this) - if (Settings.instance.casting.experimentalCasting) { - ExpStateCasting.instance.onActiveDevicePlayChanged.remove(this); - ExpStateCasting.instance.onActiveDeviceTimeChanged.remove(this); - ExpStateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this); - } else { - StateCasting.instance.onActiveDevicePlayChanged.remove(this); - StateCasting.instance.onActiveDeviceTimeChanged.remove(this); - StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this); - } + StateCasting.instance.onActiveDevicePlayChanged.remove(this); + StateCasting.instance.onActiveDeviceTimeChanged.remove(this); + StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this); StateApp.instance.preventPictureInPicture.remove(this); StatePlayer.instance.onQueueChanged.remove(this); StatePlayer.instance.onVideoChanging.remove(this); @@ -2011,7 +1958,7 @@ class VideoDetailView : ConstraintLayout { return; } - if (!StateCastingDispatcher.isCasting()) { + if (!StateCasting.instance.isCasting) { setCastEnabled(false); val isLimitedVersion = StatePlatform.instance.getContentClientOrNull(video.url)?.let { @@ -2087,19 +2034,11 @@ class VideoDetailView : ConstraintLayout { val startId = plugin?.getUnderlyingPlugin()?.runtimeId try { - val castingSucceeded = if (Settings.instance.casting.experimentalCasting) { - ExpStateCasting.instance.castIfAvailable(contentResolver, video, videoSource, audioSource, subtitleSource, resumePositionMs, speed, onLoading = { - _cast.setLoading(it) - }, onLoadingEstimate = { - _cast.setLoading(it) - }) - } else { - StateCasting.instance.castIfAvailable(contentResolver, video, videoSource, audioSource, subtitleSource, resumePositionMs, speed, onLoading = { - _cast.setLoading(it) - }, onLoadingEstimate = { - _cast.setLoading(it) - }) - } + val castingSucceeded = StateCasting.instance.castIfAvailable(contentResolver, video, videoSource, audioSource, subtitleSource, resumePositionMs, speed, onLoading = { + _cast.setLoading(it) + }, onLoadingEstimate = { + _cast.setLoading(it) + }) if (castingSucceeded) { withContext(Dispatchers.Main) { @@ -2295,7 +2234,7 @@ class VideoDetailView : ConstraintLayout { } val currentPlaybackRate = (if (_isCasting) { - StateCastingDispatcher.getActiveDeviceSpeed() + StateCasting.instance.activeDevice?.speed } else _player.getPlaybackRate()) ?: 1.0 _overlay_quality_selector?.groupItems?.firstOrNull { it is SlideUpMenuButtonList && it.id == "playback_rate" }?.let { (it as SlideUpMenuButtonList).setSelected(currentPlaybackRate.toString()) @@ -2414,9 +2353,9 @@ class VideoDetailView : ConstraintLayout { ?.distinct() ?.toList() ?: listOf() else audioSources?.toList() ?: listOf(); - val canSetSpeed = !_isCasting || StateCastingDispatcher.canActiveDeviceSetSpeed(); + val canSetSpeed = !_isCasting || StateCasting.instance.activeDevice?.canSetSpeed() ?: false val currentPlaybackRate = if (_isCasting) { - StateCastingDispatcher.getActiveDeviceSpeed() + StateCasting.instance.activeDevice?.speed } else { _player.getPlaybackRate() } @@ -2434,7 +2373,7 @@ class VideoDetailView : ConstraintLayout { setButtons(playbackLabels, String.format(Locale.US, format, currentPlaybackRate)); onClick.subscribe { v -> val currentPlaybackSpeed = if (_isCasting) { - StateCastingDispatcher.getActiveDeviceSpeed() + StateCasting.instance.activeDevice?.speed } else _player.getPlaybackRate(); var playbackSpeedString = v; val stepSpeed = Settings.instance.playback.getPlaybackSpeedStep(); @@ -2443,9 +2382,11 @@ class VideoDetailView : ConstraintLayout { else if(v == "-") playbackSpeedString = String.format(Locale.US, "%.2f", Math.max(0.1, (currentPlaybackSpeed?.toDouble() ?: 1.0) - stepSpeed)).toString(); val newPlaybackSpeed = playbackSpeedString.toDouble(); - if (_isCasting && StateCastingDispatcher.canActiveDeviceSetSpeed()) { + if (_isCasting && StateCasting.instance.activeDevice?.canSetSpeed() ?: false) { qualityPlaybackSpeedTitle?.setTitle(context.getString(R.string.playback_rate) + " (${String.format(Locale.US, "%.2f", newPlaybackSpeed)})"); - StateCastingDispatcher.activeDeviceSetSpeed(newPlaybackSpeed) + try { + StateCasting.instance.activeDevice?.changeSpeed(newPlaybackSpeed) + } catch (_: Throwable) {} setSelected(playbackSpeedString); } else { qualityPlaybackSpeedTitle?.setTitle(context.getString(R.string.playback_rate) + " (${String.format(Locale.US, "%.2f", newPlaybackSpeed)})"); @@ -2561,7 +2502,7 @@ class VideoDetailView : ConstraintLayout { //Handlers private fun handlePlay() { Logger.i(TAG, "handlePlay") - if (!StateCastingDispatcher.resumeVideo()) { + if (!StateCasting.instance.resumeVideo()) { _player.play() } @@ -2577,19 +2518,19 @@ class VideoDetailView : ConstraintLayout { private fun handlePause() { Logger.i(TAG, "handlePause") - if (!StateCastingDispatcher.pauseVideo()) { + if (!StateCasting.instance.pauseVideo()) { _player.pause() } } private fun handleSeek(ms: Long) { Logger.i(TAG, "handleSeek(ms=$ms)") - if (!StateCastingDispatcher.videoSeekTo(ms.toDouble() / 1000.0)) { + if (!StateCasting.instance.videoSeekTo(ms.toDouble() / 1000.0)) { _player.seekTo(ms) } } private fun handleStop() { Logger.i(TAG, "handleStop") - if (!StateCastingDispatcher.stopVideo()) { + if (!StateCasting.instance.stopVideo()) { _player.stop() } } @@ -2597,7 +2538,7 @@ class VideoDetailView : ConstraintLayout { private fun handlePlayChanged(playing: Boolean) { Logger.i(TAG, "handlePlayChanged(playing=$playing)") - if (StateCastingDispatcher.isCasting()) { + if (StateCasting.instance.isCasting) { _cast.setIsPlaying(playing); } else { StatePlayer.instance.updateMediaSession( null); @@ -2639,9 +2580,9 @@ class VideoDetailView : ConstraintLayout { fragment.lifecycleScope.launch(Dispatchers.Main) { try { - if (StateCastingDispatcher.isConnected()) { - val expectedCurrentTime = StateCastingDispatcher.getExpectedCurrentTime() ?: 0.0 - val speed = StateCastingDispatcher.getActiveDeviceSpeed() ?: 1.0 + if (StateCasting.instance.activeDevice != null) { + val expectedCurrentTime = StateCasting.instance.activeDevice?.expectedCurrentTime ?: 0.0 + val speed = StateCasting.instance.activeDevice?.speed ?: 1.0 castIfAvailable( context.contentResolver, video, @@ -2670,9 +2611,9 @@ class VideoDetailView : ConstraintLayout { fragment.lifecycleScope.launch(Dispatchers.Main) { try { - if (StateCastingDispatcher.isConnected()) { - val expectedCurrentTime = StateCastingDispatcher.getExpectedCurrentTime() ?: 0.0 - val speed = StateCastingDispatcher.getActiveDeviceSpeed() ?: 1.0 + if (StateCasting.instance.activeDevice != null) { + val expectedCurrentTime = StateCasting.instance.activeDevice?.expectedCurrentTime ?: 0.0 + val speed = StateCasting.instance.activeDevice?.speed ?: 1.0 castIfAvailable( context.contentResolver, video, @@ -2702,9 +2643,9 @@ class VideoDetailView : ConstraintLayout { fragment.lifecycleScope.launch(Dispatchers.Main) { try { - if (StateCastingDispatcher.isConnected()) { - val expectedCurrentTime = StateCastingDispatcher.getExpectedCurrentTime() ?: 0.0 - val speed = StateCastingDispatcher.getActiveDeviceSpeed() ?: 1.0 + if (StateCasting.instance.activeDevice != null) { + val expectedCurrentTime = StateCasting.instance.activeDevice?.expectedCurrentTime ?: 0.0 + val speed = StateCasting.instance.activeDevice?.speed ?: 1.0 castIfAvailable( context.contentResolver, video, diff --git a/app/src/main/java/com/futo/platformplayer/models/CastingDeviceInfo.kt b/app/src/main/java/com/futo/platformplayer/models/CastingDeviceInfo.kt index 72afedcc..4bb5fa5d 100644 --- a/app/src/main/java/com/futo/platformplayer/models/CastingDeviceInfo.kt +++ b/app/src/main/java/com/futo/platformplayer/models/CastingDeviceInfo.kt @@ -1,34 +1,11 @@ package com.futo.platformplayer.models import com.futo.platformplayer.casting.CastProtocolType -import com.futo.platformplayer.experimental_casting.ExpCastProtocolType @kotlinx.serialization.Serializable -class CastingDeviceInfo { - var name: String; - var type: CastProtocolType; - var addresses: Array; - var port: Int; - - constructor(name: String, type: CastProtocolType, addresses: Array, port: Int) { - this.name = name; - this.type = type; - this.addresses = addresses; - this.port = port; - } -} - -@kotlinx.serialization.Serializable -class ExpCastingDeviceInfo { - var name: String; - var type: ExpCastProtocolType; - var addresses: Array; - var port: Int; - - constructor(name: String, type: ExpCastProtocolType, addresses: Array, port: Int) { - this.name = name; - this.type = type; - this.addresses = addresses; - this.port = port; - } -} +class CastingDeviceInfo( + var name: String, + var type: CastProtocolType, + var addresses: Array, + var port: Int +) \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt index ed9f8922..b1b87d4c 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt @@ -33,11 +33,11 @@ import com.futo.platformplayer.activities.SettingsActivity.Companion.settingsAct import com.futo.platformplayer.api.media.platforms.js.DevJSClient import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.background.BackgroundWorker +import com.futo.platformplayer.casting.OldStateCasting 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 @@ -760,11 +760,7 @@ class StateApp { _connectivityManager?.unregisterNetworkCallback(_connectivityEvents); StatePlayer.instance.closeMediaSession(); - if (Settings.instance.casting.experimentalCasting) { - ExpStateCasting.instance.stop() - } else { - StateCasting.instance.stop() - } + StateCasting.instance.stop() StateSync.instance.stop(); StatePlayer.dispose(); Companion.dispose(); diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceAdapter.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceAdapter.kt index 06c4974d..a2ff2435 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceAdapter.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceAdapter.kt @@ -6,34 +6,14 @@ import androidx.recyclerview.widget.RecyclerView import com.futo.platformplayer.R import com.futo.platformplayer.casting.CastingDevice import com.futo.platformplayer.constructs.Event1 -import com.futo.platformplayer.experimental_casting.CastingDeviceHandle -sealed class GenericCastingDevice { - class Normal(val device: CastingDevice): GenericCastingDevice() - class Experimental(val handle: CastingDeviceHandle): GenericCastingDevice() - - fun name(): String? { - return when (this) { - is Experimental -> this.handle.device.name() - is Normal -> this.device.name - } - } - - fun isReady(): Boolean { - return when(this) { - is Experimental -> this.handle.device.isReady() - is Normal -> this.device.isReady - } - } -} - -data class DeviceAdapterEntry(val castingDevice: GenericCastingDevice, val isPinnedDevice: Boolean, val isOnlineDevice: Boolean) +data class DeviceAdapterEntry(val castingDevice: CastingDevice, val isPinnedDevice: Boolean, val isOnlineDevice: Boolean) class DeviceAdapter : RecyclerView.Adapter { private val _devices: List; - var onPin = Event1(); - var onConnect = Event1(); + var onPin = Event1(); + var onConnect = Event1(); constructor(devices: List) : super() { _devices = devices; diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt index 4e0cc26b..4a260fe1 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt @@ -9,15 +9,13 @@ import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView.ViewHolder import com.futo.platformplayer.R +import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs -import com.futo.platformplayer.casting.AirPlayCastingDevice import com.futo.platformplayer.casting.CastConnectionState -import com.futo.platformplayer.casting.ChromecastCastingDevice -import com.futo.platformplayer.casting.FCastCastingDevice +import com.futo.platformplayer.casting.CastProtocolType +import com.futo.platformplayer.casting.CastingDevice import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.constructs.Event1 -import com.futo.platformplayer.experimental_casting.ExpStateCasting -import org.fcast.sender_sdk.ProtocolType class DeviceViewHolder : ViewHolder { private val _layoutDevice: FrameLayout; @@ -31,11 +29,11 @@ class DeviceViewHolder : ViewHolder { private var _animatableLoader: Animatable? = null; private var _imagePin: ImageView; - var device: GenericCastingDevice? = null + var device: CastingDevice? = null private set - var onPin = Event1(); - val onConnect = Event1(); + var onPin = Event1(); + val onConnect = Event1(); constructor(view: View) : super(view) { _root = view.findViewById(R.id.layout_root); @@ -55,41 +53,17 @@ class DeviceViewHolder : ViewHolder { val connect = { device?.let { dev -> - when (dev) { - is GenericCastingDevice.Normal -> { - if (dev.device.isReady) { - // NOTE: we assume normal casting is used - StateCasting.instance.activeDevice?.stopCasting() - StateCasting.instance.connectDevice(dev.device) - onConnect.emit(dev) - } else { - try { - view.context?.let { UIDialogs.toast(it, "Device not ready, may be offline") } - } catch (e: Throwable) { - //Ignored - } + try { + if (dev.isReady) { + StateCasting.instance.activeDevice?.stopPlayback() + StateCasting.instance.connectDevice(dev) + onConnect.emit(dev) + } else { + view.context?.let { + UIDialogs.toast(it, "Device not ready, may be offline") } } - is GenericCastingDevice.Experimental -> { - if (dev.handle.device.isReady()) { - // NOTE: we assume experimental casting is used - try { - ExpStateCasting.instance.activeDevice?.device?.stopPlayback() - ExpStateCasting.instance.activeDevice?.device?.disconnect() - } catch (e: Throwable) { - //Ignored - } - ExpStateCasting.instance.connectDevice(dev.handle) - onConnect.emit(dev) - } else { - try { - view.context?.let { UIDialogs.toast(it, "Device not ready, may be offline") } - } catch (e: Throwable) { - //Ignored - } - } - } - } + } catch (_: Throwable) { } } } @@ -103,122 +77,69 @@ class DeviceViewHolder : ViewHolder { } } - // fun bind(d: CastingDevice, isOnlineDevice: Boolean, isPinnedDevice: Boolean) { - - fun bind(d: GenericCastingDevice, isOnlineDevice: Boolean, isPinnedDevice: Boolean) { - when (d) { - is GenericCastingDevice.Normal -> { - if (d.device is ChromecastCastingDevice) { - _imageDevice.setImageResource(R.drawable.ic_chromecast); - _textType.text = "Chromecast"; - } else if (d.device is AirPlayCastingDevice) { - _imageDevice.setImageResource(R.drawable.ic_airplay); - _textType.text = "AirPlay"; - } else if (d.device is FCastCastingDevice) { - _imageDevice.setImageResource(R.drawable.ic_fc); - _textType.text = "FCast"; - } - - _textName.text = d.device.name; - _imageOnline.visibility = if (isOnlineDevice && d.device.isReady) View.VISIBLE else View.GONE - - if (!d.device.isReady) { - _imageLoader.visibility = View.GONE; - _textNotReady.visibility = View.VISIBLE; - _imagePin.visibility = View.GONE; - } else { - _textNotReady.visibility = View.GONE; - - val dev = StateCasting.instance.activeDevice; - if (dev == d.device) { - if (dev.connectionState == CastConnectionState.CONNECTED) { - _imageLoader.visibility = View.GONE; - _textNotReady.visibility = View.GONE; - _imagePin.visibility = View.VISIBLE; - } else { - _imageLoader.visibility = View.VISIBLE; - _textNotReady.visibility = View.GONE; - _imagePin.visibility = View.VISIBLE; - } - } else { - if (d.device.isReady) { - _imageLoader.visibility = View.GONE; - _textNotReady.visibility = View.GONE; - _imagePin.visibility = View.VISIBLE; - } else { - _imageLoader.visibility = View.GONE; - _textNotReady.visibility = View.VISIBLE; - _imagePin.visibility = View.VISIBLE; - } - } - - _imagePin.setImageResource(if (isPinnedDevice) R.drawable.keep_24px else R.drawable.ic_pin) - - if (_imageLoader.isVisible) { - _animatableLoader?.start(); - } else { - _animatableLoader?.stop(); - } - } - - device = d; + fun bind(d: CastingDevice, isOnlineDevice: Boolean, isPinnedDevice: Boolean) { + when (d.protocolType) { + CastProtocolType.CHROMECAST -> { + _imageDevice.setImageResource(R.drawable.ic_chromecast); + _textType.text = "Chromecast"; } - is GenericCastingDevice.Experimental -> { - when (d.handle.device.castingProtocol()) { - ProtocolType.CHROMECAST -> { - _imageDevice.setImageResource(R.drawable.ic_chromecast); - _textType.text = "Chromecast"; - } - ProtocolType.F_CAST -> { - _imageDevice.setImageResource(R.drawable.ic_exp_fc); - _textType.text = "FCast"; - } - } - - _textName.text = d.handle.device.name(); - _imageOnline.visibility = if (isOnlineDevice && d.handle.device.isReady()) View.VISIBLE else View.GONE - - if (!d.handle.device.isReady()) { - _imageLoader.visibility = View.GONE; - _textNotReady.visibility = View.VISIBLE; - _imagePin.visibility = View.GONE; + CastProtocolType.AIRPLAY -> { + _imageDevice.setImageResource(R.drawable.ic_airplay); + _textType.text = "AirPlay"; + } + CastProtocolType.FCAST -> { + if (Settings.instance.casting.experimentalCasting) { + _imageDevice.setImageResource(R.drawable.ic_exp_fc) } else { - _textNotReady.visibility = View.GONE; - - 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; - } else { - _imageLoader.visibility = View.VISIBLE; - _textNotReady.visibility = View.GONE; - _imagePin.visibility = View.VISIBLE; - } - } else { - if (d.handle.device.isReady()) { - _imageLoader.visibility = View.GONE; - _textNotReady.visibility = View.GONE; - _imagePin.visibility = View.VISIBLE; - } else { - _imageLoader.visibility = View.GONE; - _textNotReady.visibility = View.VISIBLE; - _imagePin.visibility = View.VISIBLE; - } - } - - _imagePin.setImageResource(if (isPinnedDevice) R.drawable.keep_24px else R.drawable.ic_pin) - - if (_imageLoader.isVisible) { - _animatableLoader?.start(); - } else { - _animatableLoader?.stop(); - } + _imageDevice.setImageResource(R.drawable.ic_fc); } - - device = d; + _textType.text = "FCast"; } } + + _textName.text = d.name; + _imageOnline.visibility = if (isOnlineDevice && d.isReady) View.VISIBLE else View.GONE + + + if (!d.isReady) { + _imageLoader.visibility = View.GONE; + _textNotReady.visibility = View.VISIBLE; + _imagePin.visibility = View.GONE; + } else { + _textNotReady.visibility = View.GONE; + + val dev = StateCasting.instance.activeDevice; + if (dev == d) { + if (dev.connectionState == CastConnectionState.CONNECTED) { + _imageLoader.visibility = View.GONE; + _textNotReady.visibility = View.GONE; + _imagePin.visibility = View.VISIBLE; + } else { + _imageLoader.visibility = View.VISIBLE; + _textNotReady.visibility = View.GONE; + _imagePin.visibility = View.VISIBLE; + } + } else { + if (d.isReady) { + _imageLoader.visibility = View.GONE; + _textNotReady.visibility = View.GONE; + _imagePin.visibility = View.VISIBLE; + } else { + _imageLoader.visibility = View.GONE; + _textNotReady.visibility = View.VISIBLE; + _imagePin.visibility = View.VISIBLE; + } + } + + _imagePin.setImageResource(if (isPinnedDevice) R.drawable.keep_24px else R.drawable.ic_pin) + + if (_imageLoader.isVisible) { + _animatableLoader?.start(); + } else { + _animatableLoader?.stop(); + } + } + + device = d; } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/casting/CastButton.kt b/app/src/main/java/com/futo/platformplayer/views/casting/CastButton.kt index 7184eea8..68e249ee 100644 --- a/app/src/main/java/com/futo/platformplayer/views/casting/CastButton.kt +++ b/app/src/main/java/com/futo/platformplayer/views/casting/CastButton.kt @@ -2,21 +2,16 @@ package com.futo.platformplayer.views.casting import android.content.Context import android.util.AttributeSet -import android.view.LayoutInflater import android.view.View -import android.widget.FrameLayout -import android.widget.ImageButton -import android.widget.LinearLayout -import android.widget.TextView 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.CastConnectionState.* +import com.futo.platformplayer.casting.OldStateCasting import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.constructs.Event1 -import com.futo.platformplayer.experimental_casting.ExpStateCasting class CastButton : androidx.appcompat.widget.AppCompatImageButton { var onClick = Event1>(); @@ -29,14 +24,8 @@ class CastButton : androidx.appcompat.widget.AppCompatImageButton { visibility = View.GONE; } - if (Settings.instance.casting.experimentalCasting) { - ExpStateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, _ -> - updateCastState(); - }; - } else { - StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, _ -> - updateCastState(); - }; + StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, _ -> + updateCastState() } updateCastState(); @@ -45,47 +34,26 @@ class CastButton : androidx.appcompat.widget.AppCompatImageButton { private fun updateCastState() { val c = context ?: return; - if (Settings.instance.casting.experimentalCasting) { - val d = ExpStateCasting.instance.activeDevice; - val activeColor = ContextCompat.getColor(c, R.color.colorPrimary); - val connectingColor = ContextCompat.getColor(c, R.color.gray_c3); - val inactiveColor = ContextCompat.getColor(c, R.color.white); + val d = StateCasting.instance.activeDevice; - if (d != null) { - when (d.connectionState) { - com.futo.platformplayer.experimental_casting.CastConnectionState.CONNECTED -> setColorFilter(activeColor) - com.futo.platformplayer.experimental_casting.CastConnectionState.CONNECTING -> setColorFilter(connectingColor) - com.futo.platformplayer.experimental_casting.CastConnectionState.DISCONNECTED -> setColorFilter(activeColor) - } - } else { - setColorFilter(inactiveColor); + val activeColor = ContextCompat.getColor(c, R.color.colorPrimary); + val connectingColor = ContextCompat.getColor(c, R.color.gray_c3); + val inactiveColor = ContextCompat.getColor(c, R.color.white); + + if (d != null) { + when (d.connectionState) { + DISCONNECTED -> setColorFilter(activeColor) + CONNECTING -> setColorFilter(connectingColor) + CONNECTED -> setColorFilter(activeColor) } } else { - val d = StateCasting.instance.activeDevice; - - val activeColor = ContextCompat.getColor(c, R.color.colorPrimary); - val connectingColor = ContextCompat.getColor(c, R.color.gray_c3); - val inactiveColor = ContextCompat.getColor(c, R.color.white); - - if (d != null) { - when (d.connectionState) { - CastConnectionState.CONNECTED -> setColorFilter(activeColor) - CastConnectionState.CONNECTING -> setColorFilter(connectingColor) - CastConnectionState.DISCONNECTED -> setColorFilter(activeColor) - } - } else { - setColorFilter(inactiveColor); - } + setColorFilter(inactiveColor); } } fun cleanup() { setOnClickListener(null); - if (Settings.instance.casting.experimentalCasting) { - ExpStateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this); - } else { - StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this); - } + StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this); } } \ No newline at end of file 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 f66995d7..f09b4689 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 @@ -21,17 +21,12 @@ import com.futo.platformplayer.R import com.futo.platformplayer.Settings import com.futo.platformplayer.api.media.models.chapters.IChapter import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails -import com.futo.platformplayer.casting.AirPlayCastingDevice -import com.futo.platformplayer.casting.ChromecastCastingDevice +import com.futo.platformplayer.casting.CastConnectionState import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event2 -import com.futo.platformplayer.experimental_casting.ExpStateCasting -import com.futo.platformplayer.experimental_casting.StateCastingDispatcher 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 import com.futo.platformplayer.views.behavior.GestureControlView @@ -39,9 +34,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import org.fcast.sender_sdk.DeviceFeature class CastView : ConstraintLayout { private val _thumbnail: ImageView; @@ -100,51 +93,40 @@ class CastView : ConstraintLayout { _gestureControlView.fullScreenGestureEnabled = false _gestureControlView.setupTouchArea(); _gestureControlView.onSpeedHoldStart.subscribe { - if (Settings.instance.casting.experimentalCasting) { - val d = ExpStateCasting.instance.activeDevice ?: return@subscribe; - _speedHoldWasPlaying = d.isPlaying - _speedHoldPrevRate = d.speed - if (d.device.supportsFeature(DeviceFeature.SET_SPEED)) { - try { - d.device.changeSpeed(Settings.instance.playback.getHoldPlaybackSpeed()) - } catch (e: Throwable) { - // Ignored - } - } + val d = StateCasting.instance.activeDevice ?: return@subscribe + _speedHoldWasPlaying = d.isPlaying + _speedHoldPrevRate = d.speed + if (d.canSetSpeed()) { try { - d.device.resumePlayback() + d.changeSpeed(Settings.instance.playback.getHoldPlaybackSpeed()) } catch (e: Throwable) { // Ignored } - } else { - val d = StateCasting.instance.activeDevice ?: return@subscribe; - _speedHoldWasPlaying = d.isPlaying - _speedHoldPrevRate = d.speed - if (d.canSetSpeed) { - d.changeSpeed(Settings.instance.playback.getHoldPlaybackSpeed()) - } - d.resumeVideo() + } + try { + d.resumePlayback() + } catch (e: Throwable) { + // Ignored } } _gestureControlView.onSpeedHoldEnd.subscribe { - if (Settings.instance.casting.experimentalCasting) { - val d = ExpStateCasting.instance.activeDevice ?: return@subscribe; - if (!_speedHoldWasPlaying) { - d.device.resumePlayback() - } - d.device.changeSpeed(_speedHoldPrevRate) - } else { + try { val d = StateCasting.instance.activeDevice ?: return@subscribe; if (!_speedHoldWasPlaying) { - d.pauseVideo() + d.resumePlayback() } d.changeSpeed(_speedHoldPrevRate) + } catch (e: Throwable) { + // Ignored } } _gestureControlView.onSeek.subscribe { - val expectedCurrentTime = StateCastingDispatcher.getExpectedCurrentTime() ?: return@subscribe - StateCastingDispatcher.videoSeekTo(expectedCurrentTime + it / 1000) + try { + val d = StateCasting.instance.activeDevice ?: return@subscribe + val expectedCurrentTime = d.expectedCurrentTime + d.seekTo(expectedCurrentTime + it / 1000) + } catch (_: Throwable) { } }; _buttonLoop.setOnClickListener { @@ -155,25 +137,35 @@ class CastView : ConstraintLayout { _timeBar.addListener(object : TimeBar.OnScrubListener { override fun onScrubStart(timeBar: TimeBar, position: Long) { - StateCastingDispatcher.videoSeekTo(position.toDouble()) + try { + StateCasting.instance.activeDevice?.seekTo(position.toDouble()) + } catch (_: Throwable) { } } override fun onScrubMove(timeBar: TimeBar, position: Long) { - StateCastingDispatcher.videoSeekTo(position.toDouble()) + try { + StateCasting.instance.activeDevice?.seekTo(position.toDouble()) + } catch (_: Throwable) { } } override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) { - StateCastingDispatcher.videoSeekTo(position.toDouble()) + try { + StateCasting.instance.activeDevice?.seekTo(position.toDouble()) + } catch (_: Throwable) { } } }); _buttonMinimize.setOnClickListener { onMinimizeClick.emit(); }; _buttonSettings.setOnClickListener { onSettingsClick.emit(); }; _buttonPlay.setOnClickListener { - StateCastingDispatcher.resumeVideo() + try { + StateCasting.instance.activeDevice?.resumePlayback() + } catch (_: Throwable) { } } _buttonPause.setOnClickListener { - StateCastingDispatcher.pauseVideo() + try { + StateCasting.instance.activeDevice?.pausePlayback() + } catch (_: Throwable) { } } if (!isInEditMode) { @@ -257,25 +249,9 @@ class CastView : ConstraintLayout { stopTimeJob() if(isPlaying) { - // NOTE: the experimental implementation polls automatically - if (!Settings.instance.casting.experimentalCasting) { - val d = StateCasting.instance.activeDevice; - if (d is AirPlayCastingDevice || d is ChromecastCastingDevice) { - _updateTimeJob = _scope.launch { - while (true) { - val device = StateCasting.instance.activeDevice; - if (device == null || !device.isPlaying) { - break; - } - - delay(1000); - val time_ms = (device.expectedCurrentTime * 1000.0).toLong() - setTime(time_ms); - onTimeJobTimeChanged_s.emit(device.expectedCurrentTime.toLong()) - } - } - } - } + StateCasting.instance.startUpdateTimeJob( + onTimeJobTimeChanged_s + ) { setTime(it) } if (!_inPictureInPicture) { _buttonPause.visibility = View.VISIBLE; @@ -287,7 +263,7 @@ class CastView : ConstraintLayout { _buttonPlay.visibility = View.VISIBLE; } - val position = StateCastingDispatcher.getExpectedCurrentTime()?.times(1000.0)?.toLong() + val position = StateCasting.instance.activeDevice?.expectedCurrentTime?.times(1000.0)?.toLong() if(StatePlayer.instance.hasMediaSession()) { StatePlayer.instance.updateMediaSession(null); StatePlayer.instance.updateMediaSessionPlaybackState(getPlaybackStateCompat(), (position ?: 0)); @@ -351,10 +327,10 @@ class CastView : ConstraintLayout { } private fun getPlaybackStateCompat(): Int { - if (!StateCastingDispatcher.isConnected()) { + if (StateCasting.instance.activeDevice?.connectionState != CastConnectionState.CONNECTED) { return PlaybackState.STATE_NONE } - return when(StateCastingDispatcher.isPlaying()) { + return when(StateCasting.instance.activeDevice?.isPlaying) { true -> PlaybackStateCompat.STATE_PLAYING; else -> PlaybackStateCompat.STATE_PAUSED; } From 42d886ba44047cf7d84a54d27eeca32dd4b9f140 Mon Sep 17 00:00:00 2001 From: Marcus Hanestad Date: Thu, 4 Sep 2025 09:05:33 +0200 Subject: [PATCH 15/30] casting: update propertiees when updates are received --- .../futo/platformplayer/casting/ExpCastingDevice.kt | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/futo/platformplayer/casting/ExpCastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/ExpCastingDevice.kt index dd094a4d..b017fe88 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/ExpCastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/ExpCastingDevice.kt @@ -148,7 +148,10 @@ class ExpCastingDevice(val device: RsCastingDevice) : CastingDevice() { } catch (_: Throwable) { } - override fun changeVolume(timeSeconds: Double) = device.changeVolume(timeSeconds) + override fun changeVolume(newVolume: Double) { + device.changeVolume(newVolume) + volume = newVolume + } override fun changeSpeed(speed: Double) = device.changeSpeed(speed) @@ -274,6 +277,11 @@ class ExpCastingDevice(val device: RsCastingDevice) : CastingDevice() { } } } + eventHandler.onPlayChanged.subscribe { isPlaying = it } + eventHandler.onTimeChanged.subscribe { time = it } + eventHandler.onDurationChanged.subscribe { duration = it } + eventHandler.onVolumeChanged.subscribe { volume = it } + eventHandler.onSpeedChanged.subscribe { speed = it } } override fun ensureThreadStarted() {} From b1c079aaee16e4782d84c8f2c1c24d8c3a9d819f Mon Sep 17 00:00:00 2001 From: Marcus Hanestad Date: Thu, 4 Sep 2025 10:55:10 +0200 Subject: [PATCH 16/30] casting: undo formatting --- .../platformplayer/activities/MainActivity.kt | 3 +- .../platformplayer/casting/StateCasting.kt | 921 +++++------------- .../dialogs/ConnectCastingDialog.kt | 36 +- .../dialogs/ConnectedCastingDialog.kt | 27 +- .../mainactivity/main/VideoDetailView.kt | 5 +- .../futo/platformplayer/states/StateApp.kt | 2 +- 6 files changed, 272 insertions(+), 722 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt index 9993d4c3..5e4e1e42 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt @@ -117,6 +117,7 @@ import java.util.LinkedList import java.util.UUID import java.util.concurrent.ConcurrentLinkedQueue + class MainActivity : AppCompatActivity, IWithResultLauncher { //TODO: Move to dimensions @@ -506,7 +507,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { handleIntent(intent); if (Settings.instance.casting.enabled) { - StateCasting.instance.start(this) + StateCasting.instance.start(this); } StatePlatform.instance.onDevSourceChanged.subscribe { 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 e57d0de2..44b5167f 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt @@ -58,28 +58,28 @@ import java.util.UUID import java.util.concurrent.atomic.AtomicInteger abstract class StateCasting { - val _castServer = ManagedHttpServer() + val _scopeIO = CoroutineScope(Dispatchers.IO); + val _scopeMain = CoroutineScope(Dispatchers.Main); + private val _storage: CastingDeviceInfoStorage = FragmentedStorage.get(); + + val _castServer = ManagedHttpServer(); + var _started = false; + + var devices: HashMap = hashMapOf(); + val onDeviceAdded = Event1(); + val onDeviceChanged = Event1(); + val onDeviceRemoved = Event1(); + val onActiveDeviceConnectionStateChanged = Event2(); + val onActiveDevicePlayChanged = Event1(); + val onActiveDeviceTimeChanged = Event1(); + val onActiveDeviceDurationChanged = Event1(); + val onActiveDeviceVolumeChanged = Event1(); + var activeDevice: CastingDevice? = null; private var _videoExecutor: JSRequestExecutor? = null private var _audioExecutor: JSRequestExecutor? = null - val _scopeIO = CoroutineScope(Dispatchers.IO); - var _started = false; - private val _storage: CastingDeviceInfoStorage = FragmentedStorage.get(); - val _client = ManagedHttpClient(); - var devices: HashMap = hashMapOf() - val onDeviceAdded = Event1() - val onDeviceChanged = Event1() - val onDeviceRemoved = Event1() - val onActiveDeviceConnectionStateChanged = Event2() - val onActiveDevicePlayChanged = Event1() - val onActiveDeviceTimeChanged = Event1() - val onActiveDeviceDurationChanged = Event1() - val onActiveDeviceVolumeChanged = Event1() - var activeDevice: CastingDevice? = null - val isCasting: Boolean get() = activeDevice != null - var _resumeCastingDevice: CastingDeviceInfo? = null - val _scopeMain = CoroutineScope(Dispatchers.Main) - private val _castingDialogLock = Any(); - private var _currentDialog: AlertDialog? = null; + private val _client = ManagedHttpClient(); + var _resumeCastingDevice: CastingDeviceInfo? = null; + val isCasting: Boolean get() = activeDevice != null; private val _castId = AtomicInteger(0) abstract fun handleUrl(url: String) @@ -121,6 +121,9 @@ abstract class StateCasting { action(); } + private val _castingDialogLock = Any(); + private var _currentDialog: AlertDialog? = null; + @Synchronized fun connectDevice(device: CastingDevice) { if (activeDevice == device) { @@ -130,11 +133,11 @@ abstract class StateCasting { val ad = activeDevice; if (ad != null) { Logger.i(TAG, "Stopping previous device because a new one is being connected.") - device.onConnectionStateChanged.clear() - device.onPlayChanged.clear() - device.onTimeChanged.clear() - device.onVolumeChanged.clear() - device.onDurationChanged.clear() + device.onConnectionStateChanged.clear(); + device.onPlayChanged.clear(); + device.onTimeChanged.clear(); + device.onVolumeChanged.clear(); + device.onDurationChanged.clear(); ad.disconnect() } @@ -142,14 +145,14 @@ abstract class StateCasting { Logger.i(TAG, "Active device connection state changed: $castConnectionState") if (castConnectionState == CastConnectionState.DISCONNECTED) { - Logger.i(TAG, "Clearing events: $castConnectionState") + Logger.i(TAG, "Clearing events: $castConnectionState"); - device.onConnectionStateChanged.clear() - device.onPlayChanged.clear() - device.onTimeChanged.clear() - device.onVolumeChanged.clear() - device.onDurationChanged.clear() - activeDevice = null + device.onConnectionStateChanged.clear(); + device.onPlayChanged.clear(); + device.onTimeChanged.clear(); + device.onVolumeChanged.clear(); + device.onDurationChanged.clear(); + activeDevice = null; } invokeInMainScopeIfRequired { @@ -161,26 +164,20 @@ abstract class StateCasting { Logger.i(TAG, "Casting connected to [${device.name}]"); UIDialogs.appToast("Connected to device") synchronized(_castingDialogLock) { - if (_currentDialog != null) { - _currentDialog?.hide() - _currentDialog = null + if(_currentDialog != null) { + _currentDialog?.hide(); + _currentDialog = null; } } } - CastConnectionState.CONNECTING -> { Logger.i(TAG, "Casting connecting to [${device.name}]"); UIDialogs.toast(it, "Connecting to device...") synchronized(_castingDialogLock) { if (_currentDialog == null) { - _currentDialog = UIDialogs.showDialog( - context, - R.drawable.ic_loader_animated, - true, + _currentDialog = UIDialogs.showDialog(context, R.drawable.ic_loader_animated, true, "Connecting to [${device.name}]", - "Make sure you are on the same network\n\nVPNs and guest networks can cause issues", - null, - -2, + "Make sure you are on the same network\n\nVPNs and guest networks can cause issues", null, -2, UIDialogs.Action("Disconnect", { try { device.disconnect() @@ -198,40 +195,40 @@ abstract class StateCasting { CastConnectionState.DISCONNECTED -> { UIDialogs.toast(it, "Disconnected from device") synchronized(_castingDialogLock) { - if (_currentDialog != null) { - _currentDialog?.hide() - _currentDialog = null + if(_currentDialog != null) { + _currentDialog?.hide(); + _currentDialog = null; } } } } } - } - onActiveDeviceConnectionStateChanged.emit(device, castConnectionState) - } - } + }; + onActiveDeviceConnectionStateChanged.emit(device, castConnectionState); + }; + }; device.onPlayChanged.subscribe { - invokeInMainScopeIfRequired { onActiveDevicePlayChanged.emit(it) } - } + invokeInMainScopeIfRequired { onActiveDevicePlayChanged.emit(it) }; + }; device.onDurationChanged.subscribe { - invokeInMainScopeIfRequired { onActiveDeviceDurationChanged.emit(it) } - } + invokeInMainScopeIfRequired { onActiveDeviceDurationChanged.emit(it) }; + }; device.onVolumeChanged.subscribe { - invokeInMainScopeIfRequired { onActiveDeviceVolumeChanged.emit(it) } - } + invokeInMainScopeIfRequired { onActiveDeviceVolumeChanged.emit(it) }; + }; device.onTimeChanged.subscribe { - invokeInMainScopeIfRequired { onActiveDeviceTimeChanged.emit(it) } - } + invokeInMainScopeIfRequired { onActiveDeviceTimeChanged.emit(it) }; + }; try { device.connect(); } catch (e: Throwable) { - Logger.w(TAG, "Failed to connect to device.") - device.onConnectionStateChanged.clear() - device.onPlayChanged.clear() - device.onTimeChanged.clear() - device.onVolumeChanged.clear() - device.onDurationChanged.clear() + Logger.w(TAG, "Failed to connect to device."); + device.onConnectionStateChanged.clear(); + device.onPlayChanged.clear(); + device.onTimeChanged.clear(); + device.onVolumeChanged.clear(); + device.onDurationChanged.clear(); return } @@ -245,25 +242,7 @@ abstract class StateCasting { ) } - private fun shouldProxyStreams( - castingDevice: CastingDevice, videoSource: IVideoSource?, audioSource: IAudioSource? - ): Boolean { - val hasRequestModifier = - (videoSource as? JSSource)?.hasRequestModifier == true || (audioSource as? JSSource)?.hasRequestModifier == true - return Settings.instance.casting.alwaysProxyRequests || castingDevice.protocolType != CastProtocolType.FCAST || hasRequestModifier - } - - suspend fun castIfAvailable( - contentResolver: ContentResolver, - video: IPlatformVideoDetails, - videoSource: IVideoSource?, - audioSource: IAudioSource?, - subtitleSource: ISubtitleSource?, - ms: Long, - speed: Double?, - onLoadingEstimate: ((Int) -> Unit)?, - onLoading: ((Boolean) -> Unit)? - ): Boolean { + suspend fun castIfAvailable(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, ms: Long, speed: Double?, onLoadingEstimate: ((Int) -> Unit)?, onLoading: ((Boolean) -> Unit)?): Boolean { return withContext(Dispatchers.IO) { val ad = activeDevice ?: return@withContext false; if (ad.connectionState != CastConnectionState.CONNECTED) { @@ -287,24 +266,10 @@ abstract class StateCasting { if (videoSource is LocalVideoSource || audioSource is LocalAudioSource || subtitleSource is LocalSubtitleSource) { if (deviceProto == CastProtocolType.AIRPLAY) { Logger.i(TAG, "Casting as local HLS"); - castLocalHls( - video, - videoSource as LocalVideoSource?, - audioSource as LocalAudioSource?, - subtitleSource as LocalSubtitleSource?, - resumePosition, - speed - ); + castLocalHls(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition, speed); } else { Logger.i(TAG, "Casting as local DASH"); - castLocalDash( - video, - videoSource as LocalVideoSource?, - audioSource as LocalAudioSource?, - subtitleSource as LocalSubtitleSource?, - resumePosition, - speed - ); + castLocalDash(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition, speed); } } else { val isRawDash = @@ -312,52 +277,17 @@ abstract class StateCasting { if (isRawDash) { Logger.i(TAG, "Casting as raw DASH"); - castDashRaw( - contentResolver, - video, - videoSource as JSDashManifestRawSource?, - audioSource as JSDashManifestRawAudioSource?, - subtitleSource, - resumePosition, - speed, - castId, - onLoadingEstimate, - onLoading - ); + castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, audioSource as JSDashManifestRawAudioSource?, subtitleSource, resumePosition, speed, castId, onLoadingEstimate, onLoading); } else { if (deviceProto == CastProtocolType.FCAST) { Logger.i(TAG, "Casting as DASH direct"); - castDashDirect( - contentResolver, - video, - videoSource as IVideoUrlSource?, - audioSource as IAudioUrlSource?, - subtitleSource, - resumePosition, - speed - ); + castDashDirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed); } else if (deviceProto == CastProtocolType.AIRPLAY) { Logger.i(TAG, "Casting as HLS indirect"); - castHlsIndirect( - contentResolver, - video, - videoSource as IVideoUrlSource?, - audioSource as IAudioUrlSource?, - subtitleSource, - resumePosition, - speed - ); + castHlsIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed); } else { Logger.i(TAG, "Casting as DASH indirect"); - castDashIndirect( - contentResolver, - video, - videoSource as IVideoUrlSource?, - audioSource as IAudioUrlSource?, - subtitleSource, - resumePosition, - speed - ); + castDashIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed); } } } @@ -413,9 +343,7 @@ abstract class StateCasting { } else if (audioSource is IHLSManifestAudioSource) { if (proxyStreams || deviceProto == CastProtocolType.CHROMECAST) { Logger.i(TAG, "Casting as proxied audio HLS"); - castProxiedHls( - video, audioSource.url, audioSource.codec, resumePosition, speed - ); + castProxiedHls(video, audioSource.url, audioSource.codec, resumePosition, speed); } else { Logger.i(TAG, "Casting as non-proxied audio HLS"); ad.loadVideo( @@ -436,37 +364,15 @@ abstract class StateCasting { castLocalAudio(video, audioSource, resumePosition, speed); } else if (videoSource is JSDashManifestRawSource) { Logger.i(TAG, "Casting as JSDashManifestRawSource video"); - castDashRaw( - contentResolver, - video, - videoSource as JSDashManifestRawSource?, - null, - null, - resumePosition, - speed, - castId, - onLoadingEstimate, - onLoading - ); + castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, null, null, resumePosition, speed, castId, onLoadingEstimate, onLoading); } else if (audioSource is JSDashManifestRawAudioSource) { Logger.i(TAG, "Casting as JSDashManifestRawSource audio"); - castDashRaw( - contentResolver, - video, - null, - audioSource as JSDashManifestRawAudioSource?, - null, - resumePosition, - speed, - castId, - onLoadingEstimate, - onLoading - ); + castDashRaw(contentResolver, video, null, audioSource as JSDashManifestRawAudioSource?, null, resumePosition, speed, castId, onLoadingEstimate, onLoading); } else { var str = listOf( - if (videoSource != null) "Video: ${videoSource::class.java.simpleName}" else null, - if (audioSource != null) "Audio: ${audioSource::class.java.simpleName}" else null, - if (subtitleSource != null) "Subtitles: ${subtitleSource::class.java.simpleName}" else null + if(videoSource != null) "Video: ${videoSource::class.java.simpleName}" else null, + if(audioSource != null) "Audio: ${audioSource::class.java.simpleName}" else null, + if(subtitleSource != null) "Subtitles: ${subtitleSource::class.java.simpleName}" else null ).filterNotNull().joinToString(", "); throw UnsupportedCastException(str); } @@ -512,12 +418,7 @@ abstract class StateCasting { return true; } - private fun castLocalVideo( - video: IPlatformVideoDetails, - videoSource: LocalVideoSource, - resumePosition: Double, - speed: Double? - ): List { + private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double, speed: Double?) : List { val ad = activeDevice ?: return listOf(); val url = getLocalUrl(ad); @@ -526,34 +427,17 @@ abstract class StateCasting { val videoUrl = url + videoPath; _castServer.addHandlerWithAllowAllOptions( - HttpFileHandler( - "GET", - videoPath, - videoSource.container, - videoSource.filePath - ).withHeader("Access-Control-Allow-Origin", "*"), true + HttpFileHandler("GET", videoPath, videoSource.container, videoSource.filePath) + .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("cast"); Logger.i(TAG, "Casting local video (videoUrl: $videoUrl)."); - ad.loadVideo( - "BUFFERED", - videoSource.container, - videoUrl, - resumePosition, - video.duration.toDouble(), - speed, - metadataFromVideo(video) - ); + ad.loadVideo("BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video)); return listOf(videoUrl); } - private fun castLocalAudio( - video: IPlatformVideoDetails, - audioSource: LocalAudioSource, - resumePosition: Double, - speed: Double? - ): List { + private fun castLocalAudio(video: IPlatformVideoDetails, audioSource: LocalAudioSource, resumePosition: Double, speed: Double?): List { val ad = activeDevice ?: return listOf(); val url = getLocalUrl(ad); @@ -562,36 +446,17 @@ abstract class StateCasting { val audioUrl = url + audioPath; _castServer.addHandlerWithAllowAllOptions( - HttpFileHandler( - "GET", - audioPath, - audioSource.container, - audioSource.filePath - ).withHeader("Access-Control-Allow-Origin", "*"), true + HttpFileHandler("GET", audioPath, audioSource.container, audioSource.filePath) + .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("cast"); Logger.i(TAG, "Casting local audio (audioUrl: $audioUrl)."); - ad.loadVideo( - "BUFFERED", - audioSource.container, - audioUrl, - resumePosition, - video.duration.toDouble(), - speed, - metadataFromVideo(video) - ); + ad.loadVideo("BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video)); return listOf(audioUrl); } - private fun castLocalHls( - video: IPlatformVideoDetails, - videoSource: LocalVideoSource?, - audioSource: LocalAudioSource?, - subtitleSource: LocalSubtitleSource?, - resumePosition: Double, - speed: Double? - ): List { + private fun castLocalHls(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double, speed: Double?): List { val ad = activeDevice ?: return listOf() val url = getLocalUrl(ad) @@ -612,161 +477,82 @@ abstract class StateCasting { if (videoSource != null) { _castServer.addHandlerWithAllowAllOptions( - HttpFileHandler( - "GET", - videoPath, - videoSource.container, - videoSource.filePath - ).withHeader("Access-Control-Allow-Origin", "*"), true + HttpFileHandler("GET", videoPath, videoSource.container, videoSource.filePath) + .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("castLocalHls") val duration = videoSource.duration val videoVariantPlaylistPath = "/video-playlist-${id}" val videoVariantPlaylistUrl = url + videoVariantPlaylistPath - val videoVariantPlaylistSegments = - listOf(HLS.MediaSegment(duration.toDouble(), videoUrl)) - val videoVariantPlaylist = HLS.VariantPlaylist( - 3, duration.toInt(), 0, 0, null, null, null, videoVariantPlaylistSegments - ) + val videoVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), videoUrl)) + val videoVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, null, videoVariantPlaylistSegments) _castServer.addHandlerWithAllowAllOptions( - HttpConstantHandler( - "GET", - videoVariantPlaylistPath, - videoVariantPlaylist.buildM3U8(), - "application/vnd.apple.mpegurl" - ).withHeader("Access-Control-Allow-Origin", "*"), true + HttpConstantHandler("GET", videoVariantPlaylistPath, videoVariantPlaylist.buildM3U8(), + "application/vnd.apple.mpegurl") + .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("castLocalHls") - variantPlaylistReferences.add( - HLS.VariantPlaylistReference( - videoVariantPlaylistUrl, HLS.StreamInfo( - videoSource.bitrate, - "${videoSource.width}x${videoSource.height}", - videoSource.codec, - null, - null, - if (audioSource != null) "audio" else null, - if (subtitleSource != null) "subtitles" else null, - null, - null - ) - ) - ) + variantPlaylistReferences.add(HLS.VariantPlaylistReference(videoVariantPlaylistUrl, HLS.StreamInfo( + videoSource.bitrate, "${videoSource.width}x${videoSource.height}", videoSource.codec, null, null, if (audioSource != null) "audio" else null, if (subtitleSource != null) "subtitles" else null, null, null))) } if (audioSource != null) { _castServer.addHandlerWithAllowAllOptions( - HttpFileHandler( - "GET", - audioPath, - audioSource.container, - audioSource.filePath - ).withHeader("Access-Control-Allow-Origin", "*"), true + HttpFileHandler("GET", audioPath, audioSource.container, audioSource.filePath) + .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("castLocalHls") - val duration = - audioSource.duration ?: videoSource?.duration ?: throw Exception("Duration unknown") + val duration = audioSource.duration ?: videoSource?.duration ?: throw Exception("Duration unknown") val audioVariantPlaylistPath = "/audio-playlist-${id}" val audioVariantPlaylistUrl = url + audioVariantPlaylistPath - val audioVariantPlaylistSegments = - listOf(HLS.MediaSegment(duration.toDouble(), audioUrl)) - val audioVariantPlaylist = HLS.VariantPlaylist( - 3, duration.toInt(), 0, 0, null, null, null, audioVariantPlaylistSegments - ) + val audioVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), audioUrl)) + val audioVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, null, audioVariantPlaylistSegments) _castServer.addHandlerWithAllowAllOptions( - HttpConstantHandler( - "GET", - audioVariantPlaylistPath, - audioVariantPlaylist.buildM3U8(), - "application/vnd.apple.mpegurl" - ).withHeader("Access-Control-Allow-Origin", "*"), true + HttpConstantHandler("GET", audioVariantPlaylistPath, audioVariantPlaylist.buildM3U8(), + "application/vnd.apple.mpegurl") + .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("castLocalHls") - mediaRenditions.add( - HLS.MediaRendition( - "AUDIO", audioVariantPlaylistUrl, "audio", "df", "default", true, true, true - ) - ) + mediaRenditions.add(HLS.MediaRendition("AUDIO", audioVariantPlaylistUrl, "audio", "df", "default", true, true, true)) } if (subtitleSource != null) { _castServer.addHandlerWithAllowAllOptions( - HttpFileHandler( - "GET", - subtitlePath, - subtitleSource.format ?: "text/vtt", - subtitleSource.filePath - ).withHeader("Access-Control-Allow-Origin", "*"), true + HttpFileHandler("GET", subtitlePath, subtitleSource.format ?: "text/vtt", subtitleSource.filePath) + .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("castLocalHls") - val duration = videoSource?.duration ?: audioSource?.duration - ?: throw Exception("Duration unknown") + val duration = videoSource?.duration ?: audioSource?.duration ?: throw Exception("Duration unknown") val subtitleVariantPlaylistPath = "/subtitle-playlist-${id}" val subtitleVariantPlaylistUrl = url + subtitleVariantPlaylistPath - val subtitleVariantPlaylistSegments = - listOf(HLS.MediaSegment(duration.toDouble(), subtitleUrl)) - val subtitleVariantPlaylist = HLS.VariantPlaylist( - 3, duration.toInt(), 0, 0, null, null, null, subtitleVariantPlaylistSegments - ) + val subtitleVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), subtitleUrl)) + val subtitleVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, null, subtitleVariantPlaylistSegments) _castServer.addHandlerWithAllowAllOptions( - HttpConstantHandler( - "GET", - subtitleVariantPlaylistPath, - subtitleVariantPlaylist.buildM3U8(), - "application/vnd.apple.mpegurl" - ).withHeader("Access-Control-Allow-Origin", "*"), true + HttpConstantHandler("GET", subtitleVariantPlaylistPath, subtitleVariantPlaylist.buildM3U8(), + "application/vnd.apple.mpegurl") + .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("castLocalHls") - mediaRenditions.add( - HLS.MediaRendition( - "SUBTITLES", - subtitleVariantPlaylistUrl, - "subtitles", - "df", - "default", - true, - true, - true - ) - ) + mediaRenditions.add(HLS.MediaRendition("SUBTITLES", subtitleVariantPlaylistUrl, "subtitles", "df", "default", true, true, true)) } - val masterPlaylist = - HLS.MasterPlaylist(variantPlaylistReferences, mediaRenditions, listOf(), true) + val masterPlaylist = HLS.MasterPlaylist(variantPlaylistReferences, mediaRenditions, listOf(), true) _castServer.addHandlerWithAllowAllOptions( - HttpConstantHandler( - "GET", hlsPath, masterPlaylist.buildM3U8(), "application/vnd.apple.mpegurl" - ).withHeader("Access-Control-Allow-Origin", "*"), true + HttpConstantHandler("GET", hlsPath, masterPlaylist.buildM3U8(), + "application/vnd.apple.mpegurl") + .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("castLocalHls") - Logger.i( - TAG, - "added new castLocalHls handlers (hlsPath: $hlsPath, videoPath: $videoPath, audioPath: $audioPath, subtitlePath: $subtitlePath)." - ) - ad.loadVideo( - "BUFFERED", - "application/vnd.apple.mpegurl", - hlsUrl, - resumePosition, - video.duration.toDouble(), - speed, - metadataFromVideo(video) - ) + Logger.i(TAG, "added new castLocalHls handlers (hlsPath: $hlsPath, videoPath: $videoPath, audioPath: $audioPath, subtitlePath: $subtitlePath).") + ad.loadVideo("BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video)) return listOf(hlsUrl, videoUrl, audioUrl, subtitleUrl) } - private fun castLocalDash( - video: IPlatformVideoDetails, - videoSource: LocalVideoSource?, - audioSource: LocalAudioSource?, - subtitleSource: LocalSubtitleSource?, - resumePosition: Double, - speed: Double? - ): List { + private fun castLocalDash(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double, speed: Double?) : List { val ad = activeDevice ?: return listOf(); val url = getLocalUrl(ad); @@ -782,73 +568,40 @@ abstract class StateCasting { val audioUrl = url + audioPath; val subtitleUrl = url + subtitlePath; - val dashContent = DashBuilder.generateOnDemandDash( - videoSource, videoUrl, audioSource, audioUrl, subtitleSource, subtitleUrl - ); + val dashContent = DashBuilder.generateOnDemandDash(videoSource, videoUrl, audioSource, audioUrl, subtitleSource, subtitleUrl); Logger.v(TAG) { "Dash manifest: $dashContent" }; _castServer.addHandlerWithAllowAllOptions( - HttpConstantHandler( - "GET", dashPath, dashContent, "application/dash+xml" - ).withHeader("Access-Control-Allow-Origin", "*"), true + HttpConstantHandler("GET", dashPath, dashContent, + "application/dash+xml") + .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("cast"); if (videoSource != null) { _castServer.addHandlerWithAllowAllOptions( - HttpFileHandler( - "GET", - videoPath, - videoSource.container, - videoSource.filePath - ).withHeader("Access-Control-Allow-Origin", "*"), true + HttpFileHandler("GET", videoPath, videoSource.container, videoSource.filePath) + .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("cast"); } if (audioSource != null) { _castServer.addHandlerWithAllowAllOptions( - HttpFileHandler( - "GET", - audioPath, - audioSource.container, - audioSource.filePath - ).withHeader("Access-Control-Allow-Origin", "*"), true + HttpFileHandler("GET", audioPath, audioSource.container, audioSource.filePath) + .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("cast"); } if (subtitleSource != null) { _castServer.addHandlerWithAllowAllOptions( - HttpFileHandler( - "GET", - subtitlePath, - subtitleSource.format ?: "text/vtt", - subtitleSource.filePath - ).withHeader("Access-Control-Allow-Origin", "*"), true + HttpFileHandler("GET", subtitlePath, subtitleSource.format ?: "text/vtt", subtitleSource.filePath) + .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("cast"); } - Logger.i( - TAG, - "added new castLocalDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath, subtitlePath: $subtitlePath)." - ); - ad.loadVideo( - "BUFFERED", - "application/dash+xml", - dashUrl, - resumePosition, - video.duration.toDouble(), - speed, - metadataFromVideo(video) - ); + Logger.i(TAG, "added new castLocalDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath, subtitlePath: $subtitlePath)."); + ad.loadVideo("BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video)); return listOf(dashUrl, videoUrl, audioUrl, subtitleUrl); } - private suspend fun castDashDirect( - contentResolver: ContentResolver, - video: IPlatformVideoDetails, - videoSource: IVideoUrlSource?, - audioSource: IAudioUrlSource?, - subtitleSource: ISubtitleSource?, - resumePosition: Double, - speed: Double? - ): List { + private suspend fun castDashDirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List { val ad = activeDevice ?: return listOf(); val proxyStreams = shouldProxyStreams(ad, videoSource, audioSource) @@ -859,8 +612,8 @@ abstract class StateCasting { val audioPath = "/audio-${id}" val subtitlePath = "/subtitle-${id}" - val videoUrl = if (proxyStreams) url + videoPath else videoSource?.getVideoUrl(); - val audioUrl = if (proxyStreams) url + audioPath else audioSource?.getAudioUrl(); + val videoUrl = if(proxyStreams) url + videoPath else videoSource?.getVideoUrl(); + val audioUrl = if(proxyStreams) url + audioPath else audioSource?.getAudioUrl(); val subtitlesUri = if (subtitleSource != null) withContext(Dispatchers.IO) { return@withContext subtitleSource.getSubtitlesURI(); @@ -868,7 +621,7 @@ abstract class StateCasting { var subtitlesUrl: String? = null; if (subtitlesUri != null) { - if (subtitlesUri.scheme == "file") { + if(subtitlesUri.scheme == "file") { var content: String? = null; val inputStream = contentResolver.openInputStream(subtitlesUri); inputStream?.use { stream -> @@ -878,9 +631,8 @@ abstract class StateCasting { if (content != null) { _castServer.addHandlerWithAllowAllOptions( - HttpConstantHandler( - "GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt" - ).withHeader("Access-Control-Allow-Origin", "*"), true + HttpConstantHandler("GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt") + .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("cast"); } @@ -892,50 +644,26 @@ abstract class StateCasting { if (videoSource != null) { _castServer.addHandlerWithAllowAllOptions( - HttpProxyHandler( - "GET", - videoPath, - videoSource.getVideoUrl(), - true - ).withInjectedHost().withHeader("Access-Control-Allow-Origin", "*"), true + HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true) + .withInjectedHost() + .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("cast"); } if (audioSource != null) { _castServer.addHandlerWithAllowAllOptions( - HttpProxyHandler( - "GET", - audioPath, - audioSource.getAudioUrl(), - true - ).withInjectedHost().withHeader("Access-Control-Allow-Origin", "*"), true + HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true) + .withInjectedHost() + .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("cast"); } - val content = DashBuilder.generateOnDemandDash( - videoSource, videoUrl, audioSource, audioUrl, subtitleSource, subtitlesUrl - ); + val content = DashBuilder.generateOnDemandDash(videoSource, videoUrl, audioSource, audioUrl, subtitleSource, subtitlesUrl); - Logger.i( - TAG, "Direct dash cast to casting device (videoUrl: $videoUrl, audioUrl: $audioUrl)." - ); + Logger.i(TAG, "Direct dash cast to casting device (videoUrl: $videoUrl, audioUrl: $audioUrl)."); Logger.v(TAG) { "Dash manifest: $content" }; - ad.loadContent( - "application/dash+xml", - content, - resumePosition, - video.duration.toDouble(), - speed, - metadataFromVideo(video) - ); + ad.loadContent("application/dash+xml", content, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video)); - return listOf( - videoUrl ?: "", - audioUrl ?: "", - subtitlesUrl ?: "", - videoSource?.getVideoUrl() ?: "", - audioSource?.getAudioUrl() ?: "", - subtitlesUri.toString() - ); + return listOf(videoUrl ?: "", audioUrl ?: "", subtitlesUrl ?: "", videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString()); } private fun castProxiedHls( @@ -1070,18 +798,16 @@ abstract class StateCasting { ).withTag("castProxiedHlsVariant") } - newMediaRenditions.add( - HLS.MediaRendition( - mediaRendition.type, - newPlaylistUrl, - mediaRendition.groupID, - mediaRendition.language, - mediaRendition.name, - mediaRendition.isDefault, - mediaRendition.isAutoSelect, - mediaRendition.isForced - ) - ) + newMediaRenditions.add(HLS.MediaRendition( + mediaRendition.type, + newPlaylistUrl, + mediaRendition.groupID, + mediaRendition.language, + mediaRendition.name, + mediaRendition.isDefault, + mediaRendition.isAutoSelect, + mediaRendition.isForced + )) } masterContext.respondCode(200, headers, newMasterPlaylist.buildM3U8()); @@ -1106,13 +832,7 @@ abstract class StateCasting { return listOf(hlsUrl); } - private fun proxyVariantPlaylist( - url: String, - playlistId: UUID, - variantPlaylist: HLS.VariantPlaylist, - isLive: Boolean, - proxySegments: Boolean = true - ): HLS.VariantPlaylist { + private fun proxyVariantPlaylist(url: String, playlistId: UUID, variantPlaylist: HLS.VariantPlaylist, isLive: Boolean, proxySegments: Boolean = true): HLS.VariantPlaylist { val newSegments = arrayListOf() if (proxySegments) { @@ -1136,37 +856,29 @@ abstract class StateCasting { ) } - private fun proxySegment( - url: String, playlistId: UUID, segment: HLS.Segment, index: Long - ): HLS.Segment { + private fun proxySegment(url: String, playlistId: UUID, segment: HLS.Segment, index: Long): HLS.Segment { if (segment is HLS.MediaSegment) { val newSegmentPath = "/hls-playlist-${playlistId}-segment-${index}" val newSegmentUrl = url + newSegmentPath; if (_castServer.getHandler("GET", newSegmentPath) == null) { _castServer.addHandlerWithAllowAllOptions( - HttpProxyHandler("GET", newSegmentPath, segment.uri, true).withInjectedHost() + HttpProxyHandler("GET", newSegmentPath, segment.uri, true) + .withInjectedHost() .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("castProxiedHlsVariant") } return HLS.MediaSegment( - segment.duration, newSegmentUrl + segment.duration, + newSegmentUrl ) } else { return segment } } - private suspend fun castHlsIndirect( - contentResolver: ContentResolver, - video: IPlatformVideoDetails, - videoSource: IVideoUrlSource?, - audioSource: IAudioUrlSource?, - subtitleSource: ISubtitleSource?, - resumePosition: Double, - speed: Double? - ): List { + private suspend fun castHlsIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List { val ad = activeDevice ?: return listOf(); val url = getLocalUrl(ad); val id = UUID.randomUUID(); @@ -1183,38 +895,24 @@ abstract class StateCasting { val audioPath = "/audio-${id}" val audioUrl = url + audioPath - val duration = - audioSource.duration ?: videoSource?.duration ?: throw Exception("Duration unknown") + val duration = audioSource.duration ?: videoSource?.duration ?: throw Exception("Duration unknown") val audioVariantPlaylistPath = "/audio-playlist-${id}" val audioVariantPlaylistUrl = url + audioVariantPlaylistPath - val audioVariantPlaylistSegments = - listOf(HLS.MediaSegment(duration.toDouble(), audioUrl)) - val audioVariantPlaylist = HLS.VariantPlaylist( - 3, duration.toInt(), 0, 0, null, null, null, audioVariantPlaylistSegments - ) + val audioVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), audioUrl)) + val audioVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, null, audioVariantPlaylistSegments) _castServer.addHandlerWithAllowAllOptions( - HttpConstantHandler( - "GET", - audioVariantPlaylistPath, - audioVariantPlaylist.buildM3U8(), - "application/vnd.apple.mpegurl" - ).withHeader("Access-Control-Allow-Origin", "*"), true + HttpConstantHandler("GET", audioVariantPlaylistPath, audioVariantPlaylist.buildM3U8(), + "application/vnd.apple.mpegurl") + .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("castHlsIndirectVariant"); - mediaRenditions.add( - HLS.MediaRendition( - "AUDIO", audioVariantPlaylistUrl, "audio", "df", "default", true, true, true - ) - ) + mediaRenditions.add(HLS.MediaRendition("AUDIO", audioVariantPlaylistUrl, "audio", "df", "default", true, true, true)) _castServer.addHandlerWithAllowAllOptions( - HttpProxyHandler( - "GET", - audioPath, - audioSource.getAudioUrl(), - true - ).withInjectedHost().withHeader("Access-Control-Allow-Origin", "*"), true + HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true) + .withInjectedHost() + .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("castHlsIndirectVariant"); } @@ -1225,7 +923,7 @@ abstract class StateCasting { var subtitlesUrl: String? = null; if (subtitlesUri != null) { val subtitlePath = "/subtitles-${id}" - if (subtitlesUri.scheme == "file") { + if(subtitlesUri.scheme == "file") { var content: String? = null; val inputStream = contentResolver.openInputStream(subtitlesUri); inputStream?.use { stream -> @@ -1235,9 +933,8 @@ abstract class StateCasting { if (content != null) { _castServer.addHandlerWithAllowAllOptions( - HttpConstantHandler( - "GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt" - ).withHeader("Access-Control-Allow-Origin", "*"), true + HttpConstantHandler("GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt") + .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("castHlsIndirectVariant"); } @@ -1248,37 +945,19 @@ abstract class StateCasting { } if (subtitlesUrl != null) { - val duration = videoSource?.duration ?: audioSource?.duration - ?: throw Exception("Duration unknown") + val duration = videoSource?.duration ?: audioSource?.duration ?: throw Exception("Duration unknown") val subtitleVariantPlaylistPath = "/subtitle-playlist-${id}" val subtitleVariantPlaylistUrl = url + subtitleVariantPlaylistPath - val subtitleVariantPlaylistSegments = - listOf(HLS.MediaSegment(duration.toDouble(), subtitlesUrl)) - val subtitleVariantPlaylist = HLS.VariantPlaylist( - 3, duration.toInt(), 0, 0, null, null, null, subtitleVariantPlaylistSegments - ) + val subtitleVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), subtitlesUrl)) + val subtitleVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, null, subtitleVariantPlaylistSegments) _castServer.addHandlerWithAllowAllOptions( - HttpConstantHandler( - "GET", - subtitleVariantPlaylistPath, - subtitleVariantPlaylist.buildM3U8(), - "application/vnd.apple.mpegurl" - ).withHeader("Access-Control-Allow-Origin", "*"), true + HttpConstantHandler("GET", subtitleVariantPlaylistPath, subtitleVariantPlaylist.buildM3U8(), + "application/vnd.apple.mpegurl") + .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("castHlsIndirectVariant"); - mediaRenditions.add( - HLS.MediaRendition( - "SUBTITLES", - subtitleVariantPlaylistUrl, - "subtitles", - "df", - "default", - true, - true, - true - ) - ) + mediaRenditions.add(HLS.MediaRendition("SUBTITLES", subtitleVariantPlaylistUrl, "subtitles", "df", "default", true, true, true)) } if (videoSource != null) { @@ -1288,83 +967,51 @@ abstract class StateCasting { val duration = videoSource.duration val videoVariantPlaylistPath = "/video-playlist-${id}" val videoVariantPlaylistUrl = url + videoVariantPlaylistPath - val videoVariantPlaylistSegments = - listOf(HLS.MediaSegment(duration.toDouble(), videoUrl)) - val videoVariantPlaylist = HLS.VariantPlaylist( - 3, duration.toInt(), 0, 0, null, null, null, videoVariantPlaylistSegments - ) + val videoVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), videoUrl)) + val videoVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, null, videoVariantPlaylistSegments) _castServer.addHandlerWithAllowAllOptions( - HttpConstantHandler( - "GET", - videoVariantPlaylistPath, - videoVariantPlaylist.buildM3U8(), - "application/vnd.apple.mpegurl" - ).withHeader("Access-Control-Allow-Origin", "*"), true + HttpConstantHandler("GET", videoVariantPlaylistPath, videoVariantPlaylist.buildM3U8(), + "application/vnd.apple.mpegurl") + .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("castHlsIndirectVariant"); - variantPlaylistReferences.add( - HLS.VariantPlaylistReference( - videoVariantPlaylistUrl, HLS.StreamInfo( - videoSource.bitrate ?: 0, - "${videoSource.width}x${videoSource.height}", - videoSource.codec, - null, - null, - if (audioSource != null) "audio" else null, - if (subtitleSource != null) "subtitles" else null, - null, - null - ) - ) - ) + variantPlaylistReferences.add(HLS.VariantPlaylistReference(videoVariantPlaylistUrl, HLS.StreamInfo( + videoSource.bitrate ?: 0, + "${videoSource.width}x${videoSource.height}", + videoSource.codec, + null, + null, + if (audioSource != null) "audio" else null, + if (subtitleSource != null) "subtitles" else null, + null, null))) _castServer.addHandlerWithAllowAllOptions( - HttpProxyHandler( - "GET", - videoPath, - videoSource.getVideoUrl(), - true - ).withInjectedHost().withHeader("Access-Control-Allow-Origin", "*"), true + HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true) + .withInjectedHost() + .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("castHlsIndirectVariant"); } - val masterPlaylist = - HLS.MasterPlaylist(variantPlaylistReferences, mediaRenditions, listOf(), true) + val masterPlaylist = HLS.MasterPlaylist(variantPlaylistReferences, mediaRenditions, listOf(), true) _castServer.addHandlerWithAllowAllOptions( - HttpConstantHandler( - "GET", hlsPath, masterPlaylist.buildM3U8(), "application/vnd.apple.mpegurl" - ).withHeader("Access-Control-Allow-Origin", "*"), true + HttpConstantHandler("GET", hlsPath, masterPlaylist.buildM3U8(), + "application/vnd.apple.mpegurl") + .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("castHlsIndirectMaster") Logger.i(TAG, "added new castHls handlers (hlsPath: $hlsPath)."); - ad.loadVideo( - if (video.isLive) "LIVE" else "BUFFERED", - "application/vnd.apple.mpegurl", - hlsUrl, - resumePosition, - video.duration.toDouble(), - speed, - metadataFromVideo(video) - ); + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video)); - return listOf( - hlsUrl, - videoSource?.getVideoUrl() ?: "", - audioSource?.getAudioUrl() ?: "", - subtitlesUri.toString() - ); + return listOf(hlsUrl, videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString()); } - private suspend fun castDashIndirect( - contentResolver: ContentResolver, - video: IPlatformVideoDetails, - videoSource: IVideoUrlSource?, - audioSource: IAudioUrlSource?, - subtitleSource: ISubtitleSource?, - resumePosition: Double, - speed: Double? - ): List { + private fun shouldProxyStreams(castingDevice: CastingDevice, videoSource: IVideoSource?, audioSource: IAudioSource?): Boolean { + val hasRequestModifier = (videoSource as? JSSource)?.hasRequestModifier == true || (audioSource as? JSSource)?.hasRequestModifier == true + return Settings.instance.casting.alwaysProxyRequests || castingDevice.protocolType != CastProtocolType.FCAST || hasRequestModifier + } + + private suspend fun castDashIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List { val ad = activeDevice ?: return listOf(); val proxyStreams = shouldProxyStreams(ad, videoSource, audioSource) @@ -1379,8 +1026,8 @@ abstract class StateCasting { val dashUrl = url + dashPath; Logger.i(TAG, "DASH url: $dashUrl"); - val videoUrl = if (proxyStreams) url + videoPath else videoSource?.getVideoUrl(); - val audioUrl = if (proxyStreams) url + audioPath else audioSource?.getAudioUrl(); + val videoUrl = if(proxyStreams) url + videoPath else videoSource?.getVideoUrl(); + val audioUrl = if(proxyStreams) url + audioPath else audioSource?.getAudioUrl(); val subtitlesUri = if (subtitleSource != null) withContext(Dispatchers.IO) { return@withContext subtitleSource.getSubtitlesURI(); @@ -1391,7 +1038,7 @@ abstract class StateCasting { var subtitlesUrl: String? = null; if (subtitlesUri != null) { - if (subtitlesUri.scheme == "file") { + if(subtitlesUri.scheme == "file") { var content: String? = null; val inputStream = contentResolver.openInputStream(subtitlesUri); inputStream?.use { stream -> @@ -1401,9 +1048,8 @@ abstract class StateCasting { if (content != null) { _castServer.addHandlerWithAllowAllOptions( - HttpConstantHandler( - "GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt" - ).withHeader("Access-Control-Allow-Origin", "*"), true + HttpConstantHandler("GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt") + .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("cast"); } @@ -1413,61 +1059,34 @@ abstract class StateCasting { } } - val dashContent = DashBuilder.generateOnDemandDash( - videoSource, videoUrl, audioSource, audioUrl, subtitleSource, subtitlesUrl - ); + val dashContent = DashBuilder.generateOnDemandDash(videoSource, videoUrl, audioSource, audioUrl, subtitleSource, subtitlesUrl); Logger.v(TAG) { "Dash manifest: $dashContent" }; _castServer.addHandlerWithAllowAllOptions( - HttpConstantHandler( - "GET", dashPath, dashContent, "application/dash+xml" - ).withHeader("Access-Control-Allow-Origin", "*"), true + HttpConstantHandler("GET", dashPath, dashContent, + "application/dash+xml") + .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("cast"); if (videoSource != null) { _castServer.addHandlerWithAllowAllOptions( - HttpProxyHandler( - "GET", - videoPath, - videoSource.getVideoUrl(), - true - ).withInjectedHost().withHeader("Access-Control-Allow-Origin", "*"), true + HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true) + .withInjectedHost() + .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("cast"); } if (audioSource != null) { _castServer.addHandlerWithAllowAllOptions( - HttpProxyHandler( - "GET", - audioPath, - audioSource.getAudioUrl(), - true - ).withInjectedHost().withHeader("Access-Control-Allow-Origin", "*"), true + HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true) + .withInjectedHost() + .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("cast"); } - Logger.i( - TAG, - "added new castDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath)." - ); - ad.loadVideo( - if (video.isLive) "LIVE" else "BUFFERED", - "application/dash+xml", - dashUrl, - resumePosition, - video.duration.toDouble(), - speed, - metadataFromVideo(video) - ); + Logger.i(TAG, "added new castDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath)."); + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video)); - return listOf( - dashUrl, - videoUrl ?: "", - audioUrl ?: "", - subtitlesUrl ?: "", - videoSource?.getVideoUrl() ?: "", - audioSource?.getAudioUrl() ?: "", - subtitlesUri.toString() - ); + return listOf(dashUrl, videoUrl ?: "", audioUrl ?: "", subtitlesUrl ?: "", videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString()); } fun cleanExecutors() { @@ -1482,7 +1101,7 @@ abstract class StateCasting { } } - fun getLocalUrl(ad: CastingDevice): String { + private fun getLocalUrl(ad: CastingDevice): String { var address = ad.localAddress!! if (Settings.instance.casting.allowLinkLocalIpv4) { if (address.isLinkLocalAddress && address is Inet6Address) { @@ -1499,18 +1118,7 @@ abstract class StateCasting { } @OptIn(UnstableApi::class) - 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 { + 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() @@ -1536,7 +1144,7 @@ abstract class StateCasting { var subtitlesUrl: String? = null; if (subtitlesUri != null) { - if (subtitlesUri.scheme == "file") { + if(subtitlesUri.scheme == "file") { var content: String? = null; val inputStream = contentResolver.openInputStream(subtitlesUri); inputStream?.use { stream -> @@ -1546,9 +1154,8 @@ abstract class StateCasting { if (content != null) { _castServer.addHandlerWithAllowAllOptions( - HttpConstantHandler( - "GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt" - ).withHeader("Access-Control-Allow-Origin", "*"), true + HttpConstantHandler("GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt") + .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("cast"); } @@ -1601,25 +1208,16 @@ abstract class StateCasting { } for (representation in representationRegex.findAll(dashContent)) { - val mediaType = - representation.groups[1]?.value ?: throw Exception("Media type should be found") + val mediaType = representation.groups[1]?.value ?: throw Exception("Media type should be found") dashContent = mediaInitializationRegex.replace(dashContent) { if (it.range.first < representation.range.first || it.range.last > representation.range.last) { return@replace it.value } if (mediaType.startsWith("video/")) { - return@replace "${it.groups[1]!!.value}=\"${videoUrl}?url=${ - URLEncoder.encode( - it.groups[2]!!.value, "UTF-8" - ).replace("%24Number%24", "\$Number\$") - }&mediaType=${URLEncoder.encode(mediaType, "UTF-8")}\"" + return@replace "${it.groups[1]!!.value}=\"${videoUrl}?url=${URLEncoder.encode(it.groups[2]!!.value, "UTF-8").replace("%24Number%24", "\$Number\$")}&mediaType=${URLEncoder.encode(mediaType, "UTF-8")}\"" } else if (mediaType.startsWith("audio/")) { - return@replace "${it.groups[1]!!.value}=\"${audioUrl}?url=${ - URLEncoder.encode( - it.groups[2]!!.value, "UTF-8" - ).replace("%24Number%24", "\$Number\$") - }&mediaType=${URLEncoder.encode(mediaType, "UTF-8")}\"" + return@replace "${it.groups[1]!!.value}=\"${audioUrl}?url=${URLEncoder.encode(it.groups[2]!!.value, "UTF-8").replace("%24Number%24", "\$Number\$")}&mediaType=${URLEncoder.encode(mediaType, "UTF-8")}\"" } else { throw Exception("Expected audio or video") } @@ -1647,26 +1245,20 @@ abstract class StateCasting { Logger.v(TAG) { "Dash manifest: $dashContent" }; _castServer.addHandlerWithAllowAllOptions( - HttpConstantHandler( - "GET", dashPath, dashContent, "application/dash+xml" - ).withHeader("Access-Control-Allow-Origin", "*"), true + HttpConstantHandler("GET", dashPath, dashContent, + "application/dash+xml") + .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("castDashRaw"); if (videoSource != null) { _castServer.addHandlerWithAllowAllOptions( HttpFunctionHandler("GET", videoPath) { httpContext -> - val originalUrl = - httpContext.query["url"]?.let { URLDecoder.decode(it, "UTF-8") } - ?: return@HttpFunctionHandler - val mediaType = - httpContext.query["mediaType"]?.let { URLDecoder.decode(it, "UTF-8") } - ?: return@HttpFunctionHandler + val originalUrl = httpContext.query["url"]?.let { URLDecoder.decode(it, "UTF-8") } ?: return@HttpFunctionHandler + val mediaType = httpContext.query["mediaType"]?.let { URLDecoder.decode(it, "UTF-8") } ?: return@HttpFunctionHandler val videoExecutor = _videoExecutor; if (videoExecutor != null) { - val data = videoExecutor.executeRequest( - "GET", originalUrl, null, httpContext.headers - ) + val data = videoExecutor.executeRequest("GET", originalUrl, null, httpContext.headers) httpContext.respondBytes(200, HttpHeaders().apply { put("Content-Type", mediaType) }, data); @@ -1679,18 +1271,12 @@ abstract class StateCasting { if (audioSource != null) { _castServer.addHandlerWithAllowAllOptions( HttpFunctionHandler("GET", audioPath) { httpContext -> - val originalUrl = - httpContext.query["url"]?.let { URLDecoder.decode(it, "UTF-8") } - ?: return@HttpFunctionHandler - val mediaType = - httpContext.query["mediaType"]?.let { URLDecoder.decode(it, "UTF-8") } - ?: return@HttpFunctionHandler + val originalUrl = httpContext.query["url"]?.let { URLDecoder.decode(it, "UTF-8") } ?: return@HttpFunctionHandler + val mediaType = httpContext.query["mediaType"]?.let { URLDecoder.decode(it, "UTF-8") } ?: return@HttpFunctionHandler val audioExecutor = _audioExecutor; if (audioExecutor != null) { - val data = audioExecutor.executeRequest( - "GET", originalUrl, null, httpContext.headers - ) + val data = audioExecutor.executeRequest("GET", originalUrl, null, httpContext.headers) httpContext.respondBytes(200, HttpHeaders().apply { put("Content-Type", mediaType) }, data); @@ -1701,19 +1287,8 @@ abstract class StateCasting { ).withTag("castDashRaw"); } - Logger.i( - TAG, - "added new castDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath)." - ); - ad.loadVideo( - if (video.isLive) "LIVE" else "BUFFERED", - "application/dash+xml", - dashUrl, - resumePosition, - video.duration.toDouble(), - speed, - metadataFromVideo(video) - ); + Logger.i(TAG, "added new castDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath)."); + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video)); return listOf() } diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt index c6cebbe8..b1b63080 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt @@ -121,17 +121,13 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) { StateCasting.instance.onDeviceChanged.subscribe(this) { d -> val index = _unifiedDevices.indexOfFirst { it.castingDevice.name == d.name } if (index != -1) { - _unifiedDevices[index] = DeviceAdapterEntry( - d, - _unifiedDevices[index].isPinnedDevice, - _unifiedDevices[index].isOnlineDevice - ) + _unifiedDevices[index] = DeviceAdapterEntry(d, _unifiedDevices[index].isPinnedDevice, _unifiedDevices[index].isOnlineDevice) _adapter.notifyItemChanged(index) } } - StateCasting.instance.onDeviceRemoved.subscribe(this) { deviceName -> - _devices.remove(deviceName.name) + StateCasting.instance.onDeviceRemoved.subscribe(this) { d -> + _devices.remove(d.name) updateUnifiedList() } @@ -168,7 +164,6 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) { && oldItem.isOnlineDevice == newItem.isOnlineDevice && oldItem.isPinnedDevice == newItem.isPinnedDevice } - override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { val oldItem = oldList[oldItemPosition] val newItem = newList[newItemPosition] @@ -191,40 +186,23 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) { val unifiedList = mutableListOf() val onlineDevices = StateCasting.instance.devices.values.associateBy { it.name } - val rememberedDevices = - StateCasting.instance.getRememberedCastingDevices().associateBy { it.name } + val rememberedDevices = StateCasting.instance.getRememberedCastingDevices().associateBy { it.name } val intersectionNames = _devices.intersect(_rememberedDevices) for (name in intersectionNames) { - onlineDevices[name]?.let { - unifiedList.add( - DeviceAdapterEntry( - it, true, true - ) + onlineDevices[name]?.let { unifiedList.add(DeviceAdapterEntry(it, true, true) ) } } val onlineOnlyNames = _devices - _rememberedDevices for (name in onlineOnlyNames) { - onlineDevices[name]?.let { - unifiedList.add( - DeviceAdapterEntry( - it, false, true - ) - ) - } + onlineDevices[name]?.let { unifiedList.add(DeviceAdapterEntry(it, false, true )) } } val rememberedOnlyNames = _rememberedDevices - _devices for (name in rememberedOnlyNames) { - rememberedDevices[name]?.let { - unifiedList.add( - DeviceAdapterEntry( - it, true, false - ) - ) - } + rememberedDevices[name]?.let { unifiedList.add(DeviceAdapterEntry(it, true, false)) } } return unifiedList diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt index bd76f3ea..1597cc2c 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt @@ -138,20 +138,20 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) { StateCasting.instance.onActiveDeviceVolumeChanged.remove(this) StateCasting.instance.onActiveDeviceVolumeChanged.subscribe { - _sliderVolume.value = it.toFloat().coerceAtLeast(0.0f).coerceAtMost(_sliderVolume.valueTo) - } + _sliderVolume.value = it.toFloat().coerceAtLeast(0.0f).coerceAtMost(_sliderVolume.valueTo); + }; - StateCasting.instance.onActiveDeviceTimeChanged.remove(this) + StateCasting.instance.onActiveDeviceTimeChanged.remove(this); StateCasting.instance.onActiveDeviceTimeChanged.subscribe { - _sliderPosition.value = it.toFloat().coerceAtLeast(0.0f).coerceAtMost(_sliderPosition.valueTo) - } + _sliderPosition.value = it.toFloat().coerceAtLeast(0.0f).coerceAtMost(_sliderPosition.valueTo); + }; - StateCasting.instance.onActiveDeviceDurationChanged.remove(this) + StateCasting.instance.onActiveDeviceDurationChanged.remove(this); StateCasting.instance.onActiveDeviceDurationChanged.subscribe { val dur = it.toFloat().coerceAtLeast(1.0f) - _sliderPosition.value = _sliderPosition.value.coerceAtLeast(0.0f).coerceAtMost(dur) + _sliderPosition.value = _sliderPosition.value.coerceAtLeast(0.0f).coerceAtMost(dur); _sliderPosition.valueTo = dur - } + }; val ad = StateCasting.instance.activeDevice if (ad != null) { @@ -160,23 +160,20 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) { 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() - } + StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { setLoading(connectionState != CastConnectionState.CONNECTED); }; + updateDevice(); + }; updateDevice(); } override fun dismiss() { super.dismiss(); - StateCasting.instance.onActiveDeviceVolumeChanged.remove(this); StateCasting.instance.onActiveDeviceDurationChanged.remove(this); StateCasting.instance.onActiveDeviceTimeChanged.remove(this); - StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this); _device = null; + StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this); } private fun updateDevice() { 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 84029c7a..07f83b17 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 @@ -1171,9 +1171,8 @@ class VideoDetailView : ConstraintLayout { _onPauseCalled = true; _taskLoadVideo.cancel(); - if (StateCasting.instance.isCasting) { - return - } + if (StateCasting.instance.isCasting) + return; if(allowBackground) StatePlayer.instance.startOrUpdateMediaSession(context, video); diff --git a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt index b1b87d4c..14247544 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt @@ -760,7 +760,7 @@ class StateApp { _connectivityManager?.unregisterNetworkCallback(_connectivityEvents); StatePlayer.instance.closeMediaSession(); - StateCasting.instance.stop() + StateCasting.instance.stop(); StateSync.instance.stop(); StatePlayer.dispose(); Companion.dispose(); From 86cafffda7f4fdbce26e9a64341026f54c14cb65 Mon Sep 17 00:00:00 2001 From: Marcus Hanestad Date: Thu, 4 Sep 2025 12:07:14 +0200 Subject: [PATCH 17/30] casting: undo more formatting --- .../platformplayer/casting/StateCasting.kt | 64 ++++--------------- 1 file changed, 13 insertions(+), 51 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 44b5167f..69477dcb 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt @@ -174,7 +174,7 @@ abstract class StateCasting { Logger.i(TAG, "Casting connecting to [${device.name}]"); UIDialogs.toast(it, "Connecting to device...") synchronized(_castingDialogLock) { - if (_currentDialog == null) { + if(_currentDialog == null) { _currentDialog = UIDialogs.showDialog(context, R.drawable.ic_loader_animated, true, "Connecting to [${device.name}]", "Make sure you are on the same network\n\nVPNs and guest networks can cause issues", null, -2, @@ -182,16 +182,12 @@ abstract class StateCasting { try { device.disconnect() } catch (e: Throwable) { - Logger.e( - TAG, "Failed to disconnect from device: $e" - ) + Logger.e(TAG, "Failed to disconnect from device: $e") } - }) - ) + })); } } } - CastConnectionState.DISCONNECTED -> { UIDialogs.toast(it, "Disconnected from device") synchronized(_castingDialogLock) { @@ -229,7 +225,7 @@ abstract class StateCasting { device.onTimeChanged.clear(); device.onVolumeChanged.clear(); device.onDurationChanged.clear(); - return + return; } activeDevice = device @@ -242,7 +238,7 @@ abstract class StateCasting { ) } - suspend fun castIfAvailable(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, ms: Long, speed: Double?, onLoadingEstimate: ((Int) -> Unit)?, onLoading: ((Boolean) -> Unit)?): Boolean { + suspend 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 { return withContext(Dispatchers.IO) { val ad = activeDevice ?: return@withContext false; if (ad.connectionState != CastConnectionState.CONNECTED) { @@ -298,47 +294,21 @@ abstract class StateCasting { if (videoSource is IVideoUrlSource) { val videoPath = "/video-${id}" - val videoUrl = if (proxyStreams) url + videoPath else videoSource.getVideoUrl(); + val videoUrl = if(proxyStreams) url + videoPath else videoSource.getVideoUrl(); Logger.i(TAG, "Casting as singular video"); - ad.loadVideo( - if (video.isLive) "LIVE" else "BUFFERED", - videoSource.container, - videoUrl, - resumePosition, - video.duration.toDouble(), - speed, - metadataFromVideo(video) - ); + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video)); } else if (audioSource is IAudioUrlSource) { val audioPath = "/audio-${id}" - val audioUrl = if (proxyStreams) url + audioPath else audioSource.getAudioUrl(); + val audioUrl = if(proxyStreams) url + audioPath else audioSource.getAudioUrl(); Logger.i(TAG, "Casting as singular audio"); - ad.loadVideo( - if (video.isLive) "LIVE" else "BUFFERED", - audioSource.container, - audioUrl, - resumePosition, - video.duration.toDouble(), - speed, - metadataFromVideo(video) - ); + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video)); } else if (videoSource is IHLSManifestSource) { if (proxyStreams || deviceProto == CastProtocolType.CHROMECAST) { Logger.i(TAG, "Casting as proxied HLS"); - castProxiedHls( - video, videoSource.url, videoSource.codec, resumePosition, speed - ); + castProxiedHls(video, videoSource.url, videoSource.codec, resumePosition, speed); } else { Logger.i(TAG, "Casting as non-proxied HLS"); - ad.loadVideo( - if (video.isLive) "LIVE" else "BUFFERED", - videoSource.container, - videoSource.url, - resumePosition, - video.duration.toDouble(), - speed, - metadataFromVideo(video) - ); + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video)); } } else if (audioSource is IHLSManifestAudioSource) { if (proxyStreams || deviceProto == CastProtocolType.CHROMECAST) { @@ -346,15 +316,7 @@ abstract class StateCasting { castProxiedHls(video, audioSource.url, audioSource.codec, resumePosition, speed); } else { Logger.i(TAG, "Casting as non-proxied audio HLS"); - ad.loadVideo( - if (video.isLive) "LIVE" else "BUFFERED", - audioSource.container, - audioSource.url, - resumePosition, - video.duration.toDouble(), - speed, - metadataFromVideo(video) - ); + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video)); } } else if (videoSource is LocalVideoSource) { Logger.i(TAG, "Casting as local video"); @@ -437,7 +399,7 @@ abstract class StateCasting { return listOf(videoUrl); } - private fun castLocalAudio(video: IPlatformVideoDetails, audioSource: LocalAudioSource, resumePosition: Double, speed: Double?): List { + private fun castLocalAudio(video: IPlatformVideoDetails, audioSource: LocalAudioSource, resumePosition: Double, speed: Double?) : List { val ad = activeDevice ?: return listOf(); val url = getLocalUrl(ad); From 455b42696940d48b194df031b29bf7dd5037cc8f Mon Sep 17 00:00:00 2001 From: Marcus Hanestad Date: Thu, 4 Sep 2025 12:09:07 +0200 Subject: [PATCH 18/30] casting: only add devices with name in connect casting dialog --- .../com/futo/platformplayer/dialogs/ConnectCastingDialog.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt index b1b63080..52136d0e 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt @@ -104,7 +104,7 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) { (_imageLoader.drawable as Animatable?)?.start(); synchronized(StateCasting.instance.devices) { - _devices.addAll(StateCasting.instance.devices.values.map { it.name.orEmpty() }) + _devices.addAll(StateCasting.instance.devices.values.mapNotNull { it.name }) } _rememberedDevices.addAll(StateCasting.instance.getRememberedCastingDeviceNames()) From 3de5cd92aef5a702562c3554476113592e560f1a Mon Sep 17 00:00:00 2001 From: Marcus Hanestad Date: Thu, 4 Sep 2025 13:08:40 +0200 Subject: [PATCH 19/30] casting: log exceptions from playback controls --- .../casting/ExpCastingDevice.kt | 68 ++++++------------- .../platformplayer/casting/StateCasting.kt | 40 +++++++++-- .../dialogs/ConnectedCastingDialog.kt | 24 +++---- .../mainactivity/main/VideoDetailView.kt | 9 +-- .../views/adapters/DeviceViewHolder.kt | 9 ++- .../platformplayer/views/casting/CastView.kt | 47 +++++-------- 6 files changed, 94 insertions(+), 103 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/casting/ExpCastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/ExpCastingDevice.kt index b017fe88..cda02ebf 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/ExpCastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/ExpCastingDevice.kt @@ -128,33 +128,15 @@ class ExpCastingDevice(val device: RsCastingDevice) : CastingDevice() { override val onSpeedChanged: Event1 get() = eventHandler.onSpeedChanged - override fun resumePlayback() = try { - device.resumePlayback() - } catch (_: Throwable) { - } - - override fun pausePlayback() = try { - device.pausePlayback() - } catch (_: Throwable) { - } - - override fun stopPlayback() = try { - device.stopPlayback() - } catch (_: Throwable) { - } - - override fun seekTo(timeSeconds: Double) = try { - device.seek(timeSeconds) - } catch (_: Throwable) { - } - + override fun resumePlayback() = device.resumePlayback() + override fun pausePlayback() = device.pausePlayback() + override fun stopPlayback() = device.stopPlayback() + override fun seekTo(timeSeconds: Double) = device.seek(timeSeconds) override fun changeVolume(newVolume: Double) { device.changeVolume(newVolume) volume = newVolume } - override fun changeSpeed(speed: Double) = device.changeSpeed(speed) - override fun connect() = device.connect( ApplicationInfo( "Grayjay Android", @@ -192,19 +174,16 @@ class ExpCastingDevice(val device: RsCastingDevice) : CastingDevice() { duration: Double, speed: Double?, metadata: Metadata? - ) = try { - device.load( - LoadRequest.Video( - contentType = contentType, - url = contentId, - resumePosition = resumePosition, - speed = speed, - volume = volume, - metadata = metadata - ) + ) = device.load( + LoadRequest.Video( + contentType = contentType, + url = contentId, + resumePosition = resumePosition, + speed = speed, + volume = volume, + metadata = metadata ) - } catch (_: Throwable) { - } + ) override fun loadContent( contentType: String, @@ -213,19 +192,16 @@ class ExpCastingDevice(val device: RsCastingDevice) : CastingDevice() { duration: Double, speed: Double?, metadata: Metadata? - ) = try { - device.load( - LoadRequest.Content( - contentType = contentType, - content = content, - resumePosition = resumePosition, - speed = speed, - volume = volume, - metadata = metadata, - ) + ) = device.load( + LoadRequest.Content( + contentType = contentType, + content = content, + resumePosition = resumePosition, + speed = speed, + volume = volume, + metadata = metadata, ) - } catch (_: Throwable) { - } + ) override var connectionState = CastConnectionState.DISCONNECTED override val protocolType: CastProtocolType 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 69477dcb..e3b9ef5e 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt @@ -45,6 +45,8 @@ import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.stores.CastingDeviceInfoStorage import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.toUrlAddress +import com.futo.platformplayer.views.casting.CastView +import com.futo.platformplayer.views.casting.CastView.Companion import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -238,6 +240,7 @@ abstract class StateCasting { ) } + @Throws suspend 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 { return withContext(Dispatchers.IO) { val ad = activeDevice ?: return@withContext false; @@ -348,7 +351,9 @@ abstract class StateCasting { val ad = activeDevice ?: return false; try { ad.resumePlayback(); - } catch (_: Throwable) { + } catch (e: Throwable) { + Logger.e(TAG, "Failed to resume playback: $e") + return false } return true; } @@ -357,7 +362,9 @@ abstract class StateCasting { val ad = activeDevice ?: return false; try { ad.pausePlayback(); - } catch (_: Throwable) { + } catch (e: Throwable) { + Logger.e(TAG, "Failed to pause playback: $e") + return false } return true; } @@ -366,7 +373,9 @@ abstract class StateCasting { val ad = activeDevice ?: return false; try { ad.stopPlayback(); - } catch (_: Throwable) { + } catch (e: Throwable) { + Logger.e(TAG, "Failed to stop playback: $e") + return false } return true; } @@ -375,11 +384,34 @@ abstract class StateCasting { val ad = activeDevice ?: return false; try { ad.seekTo(timeSeconds); - } catch (_: Throwable) { + } catch (e: Throwable) { + Logger.e(TAG, "Failed to seek: $e") + return false } return true; } + fun changeVolume(volume: Double): Boolean { + val ad = activeDevice ?: return false; + try { + ad.changeVolume(volume); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to change volume: $e") + return false + } + return true; + } + + fun changeSpeed(speed: Double): Boolean { + val ad = activeDevice ?: return false; + try { + ad.changeSpeed(speed); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to change speed: $e") + return false + } + return true; + } private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double, speed: Double?) : List { val ad = activeDevice ?: return listOf(); diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt index 1597cc2c..cea969a9 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt @@ -74,24 +74,18 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) { _buttonPlay = findViewById(R.id.button_play); _buttonPlay.setOnClickListener { - try { - StateCasting.instance.activeDevice?.resumePlayback() - } catch (_: Throwable) {} + StateCasting.instance.resumeVideo() } _buttonPause = findViewById(R.id.button_pause); _buttonPause.setOnClickListener { - try { - StateCasting.instance.activeDevice?.pausePlayback() - } catch (_: Throwable) {} + StateCasting.instance.pauseVideo() } _buttonStop = findViewById(R.id.button_stop); _buttonStop.setOnClickListener { (ownerActivity as MainActivity?)?.getFragment()?.closeVideoDetails() - try { - StateCasting.instance.activeDevice?.stopPlayback() - } catch (_: Throwable) {} + StateCasting.instance.stopVideo() } _buttonNext = findViewById(R.id.button_next); @@ -103,7 +97,9 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) { _buttonDisconnect.setOnClickListener { try { StateCasting.instance.activeDevice?.disconnect() - } catch (_: Throwable) {} + } catch (e: Throwable) { + Logger.e(TAG, "Active device failed to disconnect: $e") + } dismiss(); }; @@ -112,9 +108,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) { return@OnChangeListener } - try { - StateCasting.instance.activeDevice?.seekTo(value.toDouble()) - } catch (_: Throwable) {} + StateCasting.instance.videoSeekTo(value.toDouble()) }); //TODO: Check if volume slider is properly hidden in all cases @@ -123,9 +117,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) { return@OnChangeListener } - try { - StateCasting.instance.activeDevice?.changeVolume(value.toDouble()) - } catch (_: Throwable) {} + StateCasting.instance.changeVolume(value.toDouble()) }); setLoading(false); 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 07f83b17..1481916e 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 @@ -577,10 +577,7 @@ class VideoDetailView : ConstraintLayout { if(chapter?.type == ChapterType.SKIPPABLE) { _layoutSkip.visibility = VISIBLE; } else if(chapter?.type == ChapterType.SKIP || chapter?.type == ChapterType.SKIPONCE) { - val ad = StateCasting.instance.activeDevice - if (ad != null) { - ad.seekTo(chapter.timeEnd) - } else { + if (!StateCasting.instance.videoSeekTo(chapter.timeEnd)) { _player.seekTo((chapter.timeEnd * 1000).toLong()); } @@ -2383,9 +2380,7 @@ class VideoDetailView : ConstraintLayout { val newPlaybackSpeed = playbackSpeedString.toDouble(); if (_isCasting && StateCasting.instance.activeDevice?.canSetSpeed() ?: false) { qualityPlaybackSpeedTitle?.setTitle(context.getString(R.string.playback_rate) + " (${String.format(Locale.US, "%.2f", newPlaybackSpeed)})"); - try { - StateCasting.instance.activeDevice?.changeSpeed(newPlaybackSpeed) - } catch (_: Throwable) {} + StateCasting.instance.changeSpeed(newPlaybackSpeed) setSelected(playbackSpeedString); } else { qualityPlaybackSpeedTitle?.setTitle(context.getString(R.string.playback_rate) + " (${String.format(Locale.US, "%.2f", newPlaybackSpeed)})"); diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt index 4a260fe1..c99af145 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt @@ -16,6 +16,7 @@ import com.futo.platformplayer.casting.CastProtocolType import com.futo.platformplayer.casting.CastingDevice import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.logging.Logger class DeviceViewHolder : ViewHolder { private val _layoutDevice: FrameLayout; @@ -63,7 +64,9 @@ class DeviceViewHolder : ViewHolder { UIDialogs.toast(it, "Device not ready, may be offline") } } - } catch (_: Throwable) { } + } catch (e: Throwable) { + Logger.e(TAG, "Failed to connect: $e") + } } } @@ -142,4 +145,8 @@ class DeviceViewHolder : ViewHolder { device = d; } + + companion object { + private val TAG = "DeviceViewHolder" + } } \ No newline at end of file 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 f09b4689..7e31d326 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 @@ -27,6 +27,7 @@ import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.formatDuration +import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.views.TargetTapLoaderView import com.futo.platformplayer.views.behavior.GestureControlView @@ -96,17 +97,13 @@ class CastView : ConstraintLayout { val d = StateCasting.instance.activeDevice ?: return@subscribe _speedHoldWasPlaying = d.isPlaying _speedHoldPrevRate = d.speed - if (d.canSetSpeed()) { - try { - d.changeSpeed(Settings.instance.playback.getHoldPlaybackSpeed()) - } catch (e: Throwable) { - // Ignored - } - } try { + if (d.canSetSpeed()) { + d.changeSpeed(Settings.instance.playback.getHoldPlaybackSpeed()) + } d.resumePlayback() } catch (e: Throwable) { - // Ignored + Logger.e(TAG, "$e") } } _gestureControlView.onSpeedHoldEnd.subscribe { @@ -117,16 +114,14 @@ class CastView : ConstraintLayout { } d.changeSpeed(_speedHoldPrevRate) } catch (e: Throwable) { - // Ignored + Logger.e(TAG, "$e") } } _gestureControlView.onSeek.subscribe { - try { - val d = StateCasting.instance.activeDevice ?: return@subscribe - val expectedCurrentTime = d.expectedCurrentTime - d.seekTo(expectedCurrentTime + it / 1000) - } catch (_: Throwable) { } + val d = StateCasting.instance.activeDevice ?: return@subscribe + val expectedCurrentTime = d.expectedCurrentTime + StateCasting.instance.videoSeekTo(expectedCurrentTime + it / 1000) }; _buttonLoop.setOnClickListener { @@ -137,35 +132,25 @@ class CastView : ConstraintLayout { _timeBar.addListener(object : TimeBar.OnScrubListener { override fun onScrubStart(timeBar: TimeBar, position: Long) { - try { - StateCasting.instance.activeDevice?.seekTo(position.toDouble()) - } catch (_: Throwable) { } + StateCasting.instance.videoSeekTo(position.toDouble()) } override fun onScrubMove(timeBar: TimeBar, position: Long) { - try { - StateCasting.instance.activeDevice?.seekTo(position.toDouble()) - } catch (_: Throwable) { } + StateCasting.instance.videoSeekTo(position.toDouble()) } override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) { - try { - StateCasting.instance.activeDevice?.seekTo(position.toDouble()) - } catch (_: Throwable) { } + StateCasting.instance.videoSeekTo(position.toDouble()) } }); _buttonMinimize.setOnClickListener { onMinimizeClick.emit(); }; _buttonSettings.setOnClickListener { onSettingsClick.emit(); }; _buttonPlay.setOnClickListener { - try { - StateCasting.instance.activeDevice?.resumePlayback() - } catch (_: Throwable) { } + StateCasting.instance.resumeVideo() } _buttonPause.setOnClickListener { - try { - StateCasting.instance.activeDevice?.pausePlayback() - } catch (_: Throwable) { } + StateCasting.instance.pauseVideo() } if (!isInEditMode) { @@ -350,4 +335,8 @@ class CastView : ConstraintLayout { _loaderGame.visibility = View.VISIBLE _loaderGame.startLoader(expectedDurationMs.toLong()) } + + companion object { + private val TAG = "CastView"; + } } \ No newline at end of file From b562e66b8b9d9589813a5806758305bff9afdd0d Mon Sep 17 00:00:00 2001 From: Marcus Hanestad Date: Thu, 4 Sep 2025 13:17:59 +0200 Subject: [PATCH 20/30] casting: apply a0f4cc76 --- .../mainactivity/main/VideoDetailFragment.kt | 6 ++--- .../mainactivity/main/VideoDetailView.kt | 25 +++++++++---------- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt index 5d1f95f1..1cc96142 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt @@ -438,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?.allowBackground}"); + Logger.i(TAG, "onUserLeaveHint preventPictureInPicture=${viewDetail?.preventPictureInPicture} isCasting=${StateCasting.instance.isCasting} isBackgroundPictureInPicture=${Settings.instance.playback.isBackgroundPictureInPicture()} allowBackground=${viewDetail?.isAudioOnlyUserAction}"); if (viewDetail === null) { return @@ -447,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.allowBackground) { + if(Build.VERSION.SDK_INT < Build.VERSION_CODES.S && viewDetail.preventPictureInPicture == false && !StateCasting.instance.isCasting && Settings.instance.playback.isBackgroundPictureInPicture() && !viewDetail.isAudioOnlyUserAction) { val params = _viewDetail?.getPictureInPictureParams(); if(params != null) { Logger.i(TAG, "enterPictureInPictureMode") @@ -527,7 +527,7 @@ class VideoDetailFragment() : MainFragment() { private fun stopIfRequired() { var shouldStop = true; - if (_viewDetail?.allowBackground == true) { + if (_viewDetail?.isAudioOnlyUserAction == true) { shouldStop = false; } else if (Settings.instance.playback.isBackgroundPictureInPicture() && !_leavingPiP) { shouldStop = false; 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 1481916e..3569a22f 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 @@ -324,7 +324,7 @@ class VideoDetailView : ConstraintLayout { val onEnterPictureInPicture = Event0(); val onVideoChanged = Event2() - var allowBackground: Boolean = false + var isAudioOnlyUserAction: Boolean = false private set(value) { if (field != value) { field = value @@ -336,7 +336,7 @@ class VideoDetailView : ConstraintLayout { get() = !preventPictureInPicture && !StateCasting.instance.isCasting && Settings.instance.playback.isBackgroundPictureInPicture() && - !allowBackground && + !isAudioOnlyUserAction && isPlaying val onShouldEnterPictureInPictureChanged = Event0(); @@ -759,7 +759,7 @@ class VideoDetailView : ConstraintLayout { MediaControlReceiver.onBackgroundReceived.subscribe(this) { Logger.i(TAG, "MediaControlReceiver.onBackgroundReceived") _player.switchToAudioMode(video); - allowBackground = true; + isAudioOnlyUserAction = true; StateApp.instance.contextOrNull?.let { try { if (it is MainActivity) { @@ -1004,14 +1004,14 @@ class VideoDetailView : ConstraintLayout { } _slideUpOverlay?.hide(); } else null, - 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) { + 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) { _player.switchToAudioMode(video); - allowBackground = true; + isAudioOnlyUserAction = true; it.text.text = resources.getString(R.string.background_revert); } else { _player.switchToVideoMode(); - allowBackground = false; + isAudioOnlyUserAction = false; it.text.text = resources.getString(R.string.background); } _slideUpOverlay?.hide(); @@ -1151,9 +1151,9 @@ class VideoDetailView : ConstraintLayout { if(_player.isAudioMode) { //Requested behavior to leave it in audio mode. leaving it commented if it causes issues, revert? - if(!allowBackground) { + if(!isAudioOnlyUserAction) { _player.switchToVideoMode(); - allowBackground = false; + isAudioOnlyUserAction = false; _buttonPins.getButtonByTag(TAG_BACKGROUND)?.text?.text = resources.getString(R.string.background); } } @@ -1171,7 +1171,7 @@ class VideoDetailView : ConstraintLayout { if (StateCasting.instance.isCasting) return; - if(allowBackground) + if(isAudioOnlyUserAction) StatePlayer.instance.startOrUpdateMediaSession(context, video); else { when (Settings.instance.playback.backgroundPlay) { @@ -1179,7 +1179,6 @@ class VideoDetailView : ConstraintLayout { 1 -> { if(!(video?.isLive ?: false)) { _player.switchToAudioMode(video); - allowBackground = true; } StatePlayer.instance.startOrUpdateMediaSession(context, video); } @@ -1965,10 +1964,10 @@ class VideoDetailView : ConstraintLayout { if (isLimitedVersion && _player.isAudioMode) { _player.switchToVideoMode() - allowBackground = false; + isAudioOnlyUserAction = false; } else { val thumbnail = video.thumbnails.getHQThumbnail(); - if ((videoSource == null || _player.isAudioMode) && !thumbnail.isNullOrBlank()) + if ((videoSource == null) && !thumbnail.isNullOrBlank()) // || _player.isAudioMode Glide.with(context).asBitmap().load(thumbnail) .into(object: CustomTarget() { override fun onResourceReady(resource: Bitmap, transition: Transition?) { From e79e1fd1084e2675ef43d776395bd45c44256d2f Mon Sep 17 00:00:00 2001 From: Marcus Hanestad Date: Thu, 4 Sep 2025 13:24:52 +0200 Subject: [PATCH 21/30] casting: remove unused imports --- app/src/main/java/com/futo/platformplayer/UIDialogs.kt | 1 - .../futo/platformplayer/dialogs/ConnectedCastingDialog.kt | 6 ------ .../fragment/mainactivity/main/VideoDetailFragment.kt | 1 - .../fragment/mainactivity/main/VideoDetailView.kt | 1 - .../main/java/com/futo/platformplayer/states/StateApp.kt | 1 - .../com/futo/platformplayer/views/casting/CastButton.kt | 2 -- 6 files changed, 12 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/UIDialogs.kt b/app/src/main/java/com/futo/platformplayer/UIDialogs.kt index e88ff6a5..bc281b95 100644 --- a/app/src/main/java/com/futo/platformplayer/UIDialogs.kt +++ b/app/src/main/java/com/futo/platformplayer/UIDialogs.kt @@ -21,7 +21,6 @@ import androidx.core.content.ContextCompat import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.api.media.models.comments.IPlatformComment import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig -import com.futo.platformplayer.casting.OldStateCasting import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.dialogs.AutoUpdateDialog import com.futo.platformplayer.dialogs.AutomaticBackupDialog diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt index cea969a9..65667f5b 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt @@ -14,13 +14,9 @@ import android.widget.TextView import com.futo.platformplayer.R import com.futo.platformplayer.Settings import com.futo.platformplayer.activities.MainActivity -import com.futo.platformplayer.casting.AirPlayCastingDevice import com.futo.platformplayer.casting.CastConnectionState import com.futo.platformplayer.casting.CastProtocolType import com.futo.platformplayer.casting.CastingDevice -import com.futo.platformplayer.casting.ChromecastCastingDevice -import com.futo.platformplayer.casting.FCastCastingDevice -import com.futo.platformplayer.casting.OldStateCasting import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment import com.futo.platformplayer.logging.Logger @@ -29,8 +25,6 @@ import com.google.android.material.slider.Slider import com.google.android.material.slider.Slider.OnChangeListener import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import org.fcast.sender_sdk.DeviceFeature -import org.fcast.sender_sdk.ProtocolType class ConnectedCastingDialog(context: Context?) : AlertDialog(context) { private lateinit var _buttonClose: Button; diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt index 1cc96142..af228bf7 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt @@ -27,7 +27,6 @@ import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.activities.SettingsActivity import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails -import com.futo.platformplayer.casting.OldStateCasting import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event1 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 3569a22f..04bf2f42 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 @@ -84,7 +84,6 @@ import com.futo.platformplayer.api.media.platforms.js.models.JSVideoDetails import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.casting.CastConnectionState -import com.futo.platformplayer.casting.OldStateCasting import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event1 diff --git a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt index 14247544..7883b4fd 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt @@ -33,7 +33,6 @@ import com.futo.platformplayer.activities.SettingsActivity.Companion.settingsAct import com.futo.platformplayer.api.media.platforms.js.DevJSClient import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.background.BackgroundWorker -import com.futo.platformplayer.casting.OldStateCasting import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event1 diff --git a/app/src/main/java/com/futo/platformplayer/views/casting/CastButton.kt b/app/src/main/java/com/futo/platformplayer/views/casting/CastButton.kt index 68e249ee..d2b993ec 100644 --- a/app/src/main/java/com/futo/platformplayer/views/casting/CastButton.kt +++ b/app/src/main/java/com/futo/platformplayer/views/casting/CastButton.kt @@ -7,9 +7,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.casting.CastConnectionState import com.futo.platformplayer.casting.CastConnectionState.* -import com.futo.platformplayer.casting.OldStateCasting import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.constructs.Event1 From feeba10429eb85d11f25fe41660e32cbded6e23b Mon Sep 17 00:00:00 2001 From: Marcus Hanestad Date: Thu, 4 Sep 2025 13:41:11 +0200 Subject: [PATCH 22/30] casting: undo formatting to VideoDetailView --- .../mainactivity/main/VideoDetailView.kt | 175 +++++++++--------- 1 file changed, 87 insertions(+), 88 deletions(-) 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 04bf2f42..329c4e12 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 @@ -576,7 +576,9 @@ class VideoDetailView : ConstraintLayout { if(chapter?.type == ChapterType.SKIPPABLE) { _layoutSkip.visibility = VISIBLE; } else if(chapter?.type == ChapterType.SKIP || chapter?.type == ChapterType.SKIPONCE) { - if (!StateCasting.instance.videoSeekTo(chapter.timeEnd)) { + if (StateCasting.instance.activeDevice != null) { + StateCasting.instance.videoSeekTo(chapter.timeEnd) + } else { _player.seekTo((chapter.timeEnd * 1000).toLong()); } @@ -680,7 +682,9 @@ class VideoDetailView : ConstraintLayout { } else -> {} } - } + }; + + updatePillButtonVisibilities(); StateCasting.instance.onActiveDevicePlayChanged.subscribe(this) { val activeDevice = StateCasting.instance.activeDevice; @@ -704,8 +708,6 @@ class VideoDetailView : ConstraintLayout { } }; - updatePillButtonVisibilities(); - _cast.onTimeJobTimeChanged_s.subscribe { if (_isCasting) { setLastPositionMilliseconds((it * 1000.0).toLong(), true); @@ -883,7 +885,7 @@ class VideoDetailView : ConstraintLayout { if (ad != null) { val currentChapter = _cast.getCurrentChapter((ad.time * 1000).toLong()); if(currentChapter?.type == ChapterType.SKIPPABLE) { - ad.seekTo(currentChapter.timeEnd); + StateCasting.instance.videoSeekTo(currentChapter.timeEnd); } } else { val currentChapter = _player.getCurrentChapter(_player.position); @@ -985,11 +987,11 @@ class VideoDetailView : ConstraintLayout { } }, _chapters?.let { - if(it != null && it.size > 0) - RoundButton(context, R.drawable.ic_list, "Chapters", TAG_CHAPTERS) { - showChaptersUI(); - } - else null + if(it != null && it.size > 0) + RoundButton(context, R.drawable.ic_list, "Chapters", TAG_CHAPTERS) { + showChaptersUI(); + } + else null }, if(video?.isLive ?: false) RoundButton(context, R.drawable.ic_chat, context.getString(R.string.live_chat), TAG_LIVECHAT) { @@ -1002,6 +1004,17 @@ 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) { @@ -1023,14 +1036,14 @@ class VideoDetailView : ConstraintLayout { }; } else null, - RoundButton(context, R.drawable.ic_share, context.getString(R.string.share), TAG_SHARE) { - video?.let { - Logger.i(TAG, "Share preventPictureInPicture = true"); - preventPictureInPicture = true; - shareVideo(); - }; - _slideUpOverlay?.hide(); - }, + RoundButton(context, R.drawable.ic_share, context.getString(R.string.share), TAG_SHARE) { + video?.let { + Logger.i(TAG, "Share preventPictureInPicture = true"); + preventPictureInPicture = true; + shareVideo(); + }; + _slideUpOverlay?.hide(); + }, if(!isLimitedVersion) RoundButton(context, R.drawable.ic_screen_share, context.getString(R.string.overlay), TAG_OVERLAY) { this.startPictureInPicture(); @@ -1126,19 +1139,23 @@ 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) + if(_searchVideo != null && !wasLoginCall) setVideoOverview(_searchVideo!!, true, t); - else if(_url != null) + else if(_url != null && !wasLoginCall) setVideo(_url!!, t, _playWhenReady); } else if(_didStop) { @@ -1155,6 +1172,9 @@ class VideoDetailView : ConstraintLayout { isAudioOnlyUserAction = 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(); @@ -1167,7 +1187,7 @@ class VideoDetailView : ConstraintLayout { _onPauseCalled = true; _taskLoadVideo.cancel(); - if (StateCasting.instance.isCasting) + if(StateCasting.instance.isCasting) return; if(isAudioOnlyUserAction) @@ -1200,6 +1220,7 @@ class VideoDetailView : ConstraintLayout { _taskLoadVideo.cancel(); handleStop(); _didStop = true; + onShouldEnterPictureInPictureChanged.emit() Logger.i(TAG, "_didStop set to true"); StatePlayer.instance.rotationLock = false; @@ -1952,7 +1973,8 @@ class VideoDetailView : ConstraintLayout { return; } - if (!StateCasting.instance.isCasting) { + val isCasting = StateCasting.instance.isCasting + if (!isCasting) { setCastEnabled(false); val isLimitedVersion = StatePlatform.instance.getContentClientOrNull(video.url)?.let { @@ -2023,8 +2045,8 @@ class VideoDetailView : ConstraintLayout { private suspend fun castIfAvailable(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, resumePositionMs: Long, speed: Double?) { try { val plugin = if (videoSource is JSSource) videoSource.getUnderlyingPlugin() - else if (audioSource is JSSource) audioSource.getUnderlyingPlugin() - else null + else if (audioSource is JSSource) audioSource.getUnderlyingPlugin() + else null val startId = plugin?.getUnderlyingPlugin()?.runtimeId try { @@ -2227,9 +2249,7 @@ class VideoDetailView : ConstraintLayout { } } - val currentPlaybackRate = (if (_isCasting) { - StateCasting.instance.activeDevice?.speed - } else _player.getPlaybackRate()) ?: 1.0 + val currentPlaybackRate = (if (_isCasting) StateCasting.instance.activeDevice?.speed else _player.getPlaybackRate()) ?: 1.0 _overlay_quality_selector?.groupItems?.firstOrNull { it is SlideUpMenuButtonList && it.id == "playback_rate" }?.let { (it as SlideUpMenuButtonList).setSelected(currentPlaybackRate.toString()) }; @@ -2347,15 +2367,11 @@ class VideoDetailView : ConstraintLayout { ?.distinct() ?.toList() ?: listOf() else audioSources?.toList() ?: listOf(); - val canSetSpeed = !_isCasting || StateCasting.instance.activeDevice?.canSetSpeed() ?: false - val currentPlaybackRate = if (_isCasting) { - StateCasting.instance.activeDevice?.speed - } else { - _player.getPlaybackRate() - } + val canSetSpeed = !_isCasting || StateCasting.instance.activeDevice?.canSetSpeed() == true + val currentPlaybackRate = if (_isCasting) StateCasting.instance.activeDevice?.speed else _player.getPlaybackRate() val qualityPlaybackSpeedTitle = if (canSetSpeed) SlideUpMenuTitle(this.context).apply { setTitle(context.getString(R.string.playback_rate) + " (${String.format("%.2f", currentPlaybackRate)})"); } else null; _overlay_quality_selector = SlideUpMenuOverlay(this.context, _overlay_quality_container, context.getString( - R.string.quality), null, true, + R.string.quality), null, true, qualityPlaybackSpeedTitle, if (canSetSpeed) SlideUpMenuButtonList(this.context, null, "playback_rate").apply { val playbackSpeeds = Settings.instance.playback.getPlaybackSpeeds(); @@ -2366,9 +2382,7 @@ class VideoDetailView : ConstraintLayout { setButtons(playbackLabels, String.format(Locale.US, format, currentPlaybackRate)); onClick.subscribe { v -> - val currentPlaybackSpeed = if (_isCasting) { - StateCasting.instance.activeDevice?.speed - } else _player.getPlaybackRate(); + val currentPlaybackSpeed = if (_isCasting) StateCasting.instance.activeDevice?.speed else _player.getPlaybackRate(); var playbackSpeedString = v; val stepSpeed = Settings.instance.playback.getPlaybackSpeedStep(); if(v == "+") @@ -2376,9 +2390,14 @@ class VideoDetailView : ConstraintLayout { else if(v == "-") playbackSpeedString = String.format(Locale.US, "%.2f", Math.max(0.1, (currentPlaybackSpeed?.toDouble() ?: 1.0) - stepSpeed)).toString(); val newPlaybackSpeed = playbackSpeedString.toDouble(); - if (_isCasting && StateCasting.instance.activeDevice?.canSetSpeed() ?: false) { + if (_isCasting) { + val ad = StateCasting.instance.activeDevice ?: return@subscribe + if (!ad.canSetSpeed()) { + return@subscribe + } + qualityPlaybackSpeedTitle?.setTitle(context.getString(R.string.playback_rate) + " (${String.format(Locale.US, "%.2f", newPlaybackSpeed)})"); - StateCasting.instance.changeSpeed(newPlaybackSpeed) + ad.changeSpeed(newPlaybackSpeed) setSelected(playbackSpeedString); } else { qualityPlaybackSpeedTitle?.setTitle(context.getString(R.string.playback_rate) + " (${String.format(Locale.US, "%.2f", newPlaybackSpeed)})"); @@ -2495,8 +2514,9 @@ class VideoDetailView : ConstraintLayout { private fun handlePlay() { Logger.i(TAG, "handlePlay") if (!StateCasting.instance.resumeVideo()) { - _player.play() + _player.play(); } + onShouldEnterPictureInPictureChanged.emit() //TODO: This was needed because handleLowerVolume was done. //_player.setVolume(1.0f); @@ -2511,26 +2531,28 @@ class VideoDetailView : ConstraintLayout { private fun handlePause() { Logger.i(TAG, "handlePause") if (!StateCasting.instance.pauseVideo()) { - _player.pause() + _player.pause(); } + onShouldEnterPictureInPictureChanged.emit() } private fun handleSeek(ms: Long) { Logger.i(TAG, "handleSeek(ms=$ms)") if (!StateCasting.instance.videoSeekTo(ms.toDouble() / 1000.0)) { - _player.seekTo(ms) + _player.seekTo(ms); } } private fun handleStop() { Logger.i(TAG, "handleStop") if (!StateCasting.instance.stopVideo()) { - _player.stop() + _player.stop(); } } private fun handlePlayChanged(playing: Boolean) { Logger.i(TAG, "handlePlayChanged(playing=$playing)") - if (StateCasting.instance.isCasting) { + val ad = StateCasting.instance.activeDevice; + if (ad != null) { _cast.setIsPlaying(playing); } else { StatePlayer.instance.updateMediaSession( null); @@ -2572,21 +2594,11 @@ class VideoDetailView : ConstraintLayout { fragment.lifecycleScope.launch(Dispatchers.Main) { try { - if (StateCasting.instance.activeDevice != null) { - val expectedCurrentTime = StateCasting.instance.activeDevice?.expectedCurrentTime ?: 0.0 - val speed = StateCasting.instance.activeDevice?.speed ?: 1.0 - castIfAvailable( - context.contentResolver, - video, - videoSource, - _lastAudioSource, - _lastSubtitleSource, - (expectedCurrentTime * 1000.0).toLong(), - speed - ) - } else if(!_player.swapSources(videoSource, _lastAudioSource, true, true, true)) { + val d = StateCasting.instance.activeDevice; + if (d != null && d.connectionState == CastConnectionState.CONNECTED) + castIfAvailable(context.contentResolver, video, videoSource, _lastAudioSource, _lastSubtitleSource, (d.expectedCurrentTime * 1000.0).toLong(), d.speed); + else if(!_player.swapSources(videoSource, _lastAudioSource, true, true, true)) _player.hideControls(false); //TODO: Disable player? - } } catch (e: Throwable) { Logger.e(TAG, "handleSelectVideoTrack failed", e) } @@ -2603,21 +2615,11 @@ class VideoDetailView : ConstraintLayout { fragment.lifecycleScope.launch(Dispatchers.Main) { try { - if (StateCasting.instance.activeDevice != null) { - val expectedCurrentTime = StateCasting.instance.activeDevice?.expectedCurrentTime ?: 0.0 - val speed = StateCasting.instance.activeDevice?.speed ?: 1.0 - castIfAvailable( - context.contentResolver, - video, - _lastVideoSource, - audioSource, - _lastSubtitleSource, - (expectedCurrentTime * 1000.0).toLong(), - speed - ) - } else if (!_player.swapSources(_lastVideoSource, audioSource, true, true, true)) { + val d = StateCasting.instance.activeDevice; + if (d != null && d.connectionState == CastConnectionState.CONNECTED) + castIfAvailable(context.contentResolver, video, _lastVideoSource, audioSource, _lastSubtitleSource, (d.expectedCurrentTime * 1000.0).toLong(), d.speed) + else if (!_player.swapSources(_lastVideoSource, audioSource, true, true, true)) _player.hideControls(false); //TODO: Disable player? - } } catch (e: Throwable) { Logger.e(TAG, "handleSelectAudioTrack failed", e) } @@ -2635,19 +2637,10 @@ class VideoDetailView : ConstraintLayout { fragment.lifecycleScope.launch(Dispatchers.Main) { try { - if (StateCasting.instance.activeDevice != null) { - val expectedCurrentTime = StateCasting.instance.activeDevice?.expectedCurrentTime ?: 0.0 - val speed = StateCasting.instance.activeDevice?.speed ?: 1.0 - castIfAvailable( - context.contentResolver, - video, - _lastVideoSource, - _lastAudioSource, - toSet, - (expectedCurrentTime * 1000.0).toLong(), - speed - ) - } else { + val d = StateCasting.instance.activeDevice; + if (d != null && d.connectionState == CastConnectionState.CONNECTED) + castIfAvailable(context.contentResolver, video, _lastVideoSource, _lastAudioSource, toSet, (d.expectedCurrentTime * 1000.0).toLong(), d.speed); + else { _player.swapSubtitles(toSet); } } catch (e: Throwable) { @@ -3287,8 +3280,13 @@ class VideoDetailView : ConstraintLayout { val id = e.config.let { if(it is SourcePluginConfig) it.id else null }; val didLogin = if(id == null) false - else StatePlugins.instance.loginPlugin(context, id) { - fetchVideo(); + else { + isLoginStop = true; + StatePlugins.instance.loginPlugin(context, id) { + fragment.lifecycleScope.launch(Dispatchers.Main) { + fetchVideo(); + } + } } if(!didLogin) UIDialogs.showDialogOk(context, R.drawable.ic_error_pred, "Failed to login"); @@ -3313,7 +3311,7 @@ class VideoDetailView : ConstraintLayout { UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video_scriptimplementationexception), it, ::fetchVideo, null, fragment); } else { StateAnnouncement.instance.registerAnnouncement(video?.id?.value + "_Q_INVALIDVIDEO", context.getString(R.string.invalid_video), context.getString( - R.string.there_was_an_invalid_video_in_your_queue_videoname_by_authorname_playback_was_skipped).replace("{videoName}", video?.name ?: "").replace("{authorName}", video?.author?.name ?: ""), AnnouncementType.SESSION) + R.string.there_was_an_invalid_video_in_your_queue_videoname_by_authorname_playback_was_skipped).replace("{videoName}", video?.name ?: "").replace("{authorName}", video?.author?.name ?: ""), AnnouncementType.SESSION) } } .exception { @@ -3466,6 +3464,7 @@ 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"; From f21ed9aa2ad4422ce01b9b555a57c63bcf65b39f Mon Sep 17 00:00:00 2001 From: Marcus Hanestad Date: Thu, 4 Sep 2025 13:52:38 +0200 Subject: [PATCH 23/30] casting: undo even more formatting --- .../platformplayer/dialogs/ConnectCastingDialog.kt | 10 ++++------ .../platformplayer/dialogs/ConnectedCastingDialog.kt | 12 +++++------- app/src/stable/assets/sources/apple-podcasts | 2 +- 3 files changed, 10 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt index 52136d0e..28f2f989 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt @@ -183,21 +183,19 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) { } private fun buildUnifiedList(): List { - val unifiedList = mutableListOf() - val onlineDevices = StateCasting.instance.devices.values.associateBy { it.name } val rememberedDevices = StateCasting.instance.getRememberedCastingDevices().associateBy { it.name } + val unifiedList = mutableListOf() + val intersectionNames = _devices.intersect(_rememberedDevices) for (name in intersectionNames) { - onlineDevices[name]?.let { unifiedList.add(DeviceAdapterEntry(it, true, true) - ) - } + onlineDevices[name]?.let { unifiedList.add(DeviceAdapterEntry(it, true, true)) } } val onlineOnlyNames = _devices - _rememberedDevices for (name in onlineOnlyNames) { - onlineDevices[name]?.let { unifiedList.add(DeviceAdapterEntry(it, false, true )) } + onlineDevices[name]?.let { unifiedList.add(DeviceAdapterEntry(it, false, true)) } } val rememberedOnlyNames = _rememberedDevices - _devices diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt index 65667f5b..90f09a05 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt @@ -122,7 +122,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) { super.show(); Logger.i(TAG, "Dialog shown."); - StateCasting.instance.onActiveDeviceVolumeChanged.remove(this) + StateCasting.instance.onActiveDeviceVolumeChanged.remove(this); StateCasting.instance.onActiveDeviceVolumeChanged.subscribe { _sliderVolume.value = it.toFloat().coerceAtLeast(0.0f).coerceAtMost(_sliderVolume.valueTo); }; @@ -139,12 +139,10 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) { _sliderPosition.valueTo = dur }; - val ad = StateCasting.instance.activeDevice - if (ad != null) { - _device = ad - } - val isConnected = ad != null && ad.connectionState == CastConnectionState.CONNECTED - setLoading(!isConnected) + _device = StateCasting.instance.activeDevice; + val d = _device; + val isConnected = d != null && d.connectionState == CastConnectionState.CONNECTED; + setLoading(!isConnected); StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, connectionState -> StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { setLoading(connectionState != CastConnectionState.CONNECTED); }; updateDevice(); diff --git a/app/src/stable/assets/sources/apple-podcasts b/app/src/stable/assets/sources/apple-podcasts index 8cff240c..089987f0 160000 --- a/app/src/stable/assets/sources/apple-podcasts +++ b/app/src/stable/assets/sources/apple-podcasts @@ -1 +1 @@ -Subproject commit 8cff240ca7e9089ab26c03f78b6104d9cc2162fe +Subproject commit 089987f007319cf22972090a0cb09afd8c008adb From f27022a3dc7a482dd05495a4c0c82bee87e787f2 Mon Sep 17 00:00:00 2001 From: Marcus Hanestad Date: Thu, 4 Sep 2025 14:00:43 +0200 Subject: [PATCH 24/30] casting: minimize diff --- .../java/com/futo/platformplayer/UIDialogs.kt | 3 +- .../views/adapters/DeviceViewHolder.kt | 13 ++++---- .../views/casting/CastButton.kt | 13 ++++---- .../platformplayer/views/casting/CastView.kt | 30 ++++++++----------- 4 files changed, 27 insertions(+), 32 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/UIDialogs.kt b/app/src/main/java/com/futo/platformplayer/UIDialogs.kt index bc281b95..59917e75 100644 --- a/app/src/main/java/com/futo/platformplayer/UIDialogs.kt +++ b/app/src/main/java/com/futo/platformplayer/UIDialogs.kt @@ -7,6 +7,7 @@ import android.content.Intent import android.graphics.Color import android.graphics.drawable.Animatable import android.net.Uri +import android.text.Layout import android.text.method.ScrollingMovementMethod import android.util.TypedValue import android.view.Gravity @@ -436,7 +437,7 @@ class UIDialogs { fun showCastingDialog(context: Context, ownerActivity: Activity? = null) { - val d = StateCasting.instance.activeDevice + val d = StateCasting.instance.activeDevice; if (d != null) { val dialog = ConnectedCastingDialog(context); if (context is Activity) { diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt index c99af145..32fb5367 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt @@ -91,11 +91,13 @@ class DeviceViewHolder : ViewHolder { _textType.text = "AirPlay"; } CastProtocolType.FCAST -> { - if (Settings.instance.casting.experimentalCasting) { - _imageDevice.setImageResource(R.drawable.ic_exp_fc) - } else { - _imageDevice.setImageResource(R.drawable.ic_fc); - } + _imageDevice.setImageResource( + if (Settings.instance.casting.experimentalCasting) { + R.drawable.ic_exp_fc + } else { + R.drawable.ic_fc + } + ) _textType.text = "FCast"; } } @@ -103,7 +105,6 @@ class DeviceViewHolder : ViewHolder { _textName.text = d.name; _imageOnline.visibility = if (isOnlineDevice && d.isReady) View.VISIBLE else View.GONE - if (!d.isReady) { _imageLoader.visibility = View.GONE; _textNotReady.visibility = View.VISIBLE; diff --git a/app/src/main/java/com/futo/platformplayer/views/casting/CastButton.kt b/app/src/main/java/com/futo/platformplayer/views/casting/CastButton.kt index d2b993ec..a42fa94f 100644 --- a/app/src/main/java/com/futo/platformplayer/views/casting/CastButton.kt +++ b/app/src/main/java/com/futo/platformplayer/views/casting/CastButton.kt @@ -7,7 +7,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.casting.CastConnectionState.* +import com.futo.platformplayer.casting.CastConnectionState import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.constructs.Event1 @@ -23,8 +23,8 @@ class CastButton : androidx.appcompat.widget.AppCompatImageButton { } StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, _ -> - updateCastState() - } + updateCastState(); + }; updateCastState(); } @@ -32,7 +32,6 @@ class CastButton : androidx.appcompat.widget.AppCompatImageButton { private fun updateCastState() { val c = context ?: return; - val d = StateCasting.instance.activeDevice; val activeColor = ContextCompat.getColor(c, R.color.colorPrimary); @@ -41,9 +40,9 @@ class CastButton : androidx.appcompat.widget.AppCompatImageButton { if (d != null) { when (d.connectionState) { - DISCONNECTED -> setColorFilter(activeColor) - CONNECTING -> setColorFilter(connectingColor) - CONNECTED -> setColorFilter(activeColor) + CastConnectionState.DISCONNECTED -> setColorFilter(activeColor) + CastConnectionState.CONNECTING -> setColorFilter(connectingColor) + CastConnectionState.CONNECTED -> setColorFilter(activeColor) } } else { setColorFilter(inactiveColor); 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 7e31d326..565f3cee 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 @@ -94,7 +94,7 @@ class CastView : ConstraintLayout { _gestureControlView.fullScreenGestureEnabled = false _gestureControlView.setupTouchArea(); _gestureControlView.onSpeedHoldStart.subscribe { - val d = StateCasting.instance.activeDevice ?: return@subscribe + val d = StateCasting.instance.activeDevice ?: return@subscribe; _speedHoldWasPlaying = d.isPlaying _speedHoldPrevRate = d.speed try { @@ -119,9 +119,8 @@ class CastView : ConstraintLayout { } _gestureControlView.onSeek.subscribe { - val d = StateCasting.instance.activeDevice ?: return@subscribe - val expectedCurrentTime = d.expectedCurrentTime - StateCasting.instance.videoSeekTo(expectedCurrentTime + it / 1000) + val d = StateCasting.instance.activeDevice ?: return@subscribe; + StateCasting.instance.videoSeekTo( d.expectedCurrentTime + it / 1000); }; _buttonLoop.setOnClickListener { @@ -132,26 +131,22 @@ class CastView : ConstraintLayout { _timeBar.addListener(object : TimeBar.OnScrubListener { override fun onScrubStart(timeBar: TimeBar, position: Long) { - StateCasting.instance.videoSeekTo(position.toDouble()) + StateCasting.instance.videoSeekTo(position.toDouble()); } override fun onScrubMove(timeBar: TimeBar, position: Long) { - StateCasting.instance.videoSeekTo(position.toDouble()) + StateCasting.instance.videoSeekTo(position.toDouble()); } override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) { - StateCasting.instance.videoSeekTo(position.toDouble()) + StateCasting.instance.videoSeekTo(position.toDouble()); } }); _buttonMinimize.setOnClickListener { onMinimizeClick.emit(); }; _buttonSettings.setOnClickListener { onSettingsClick.emit(); }; - _buttonPlay.setOnClickListener { - StateCasting.instance.resumeVideo() - } - _buttonPause.setOnClickListener { - StateCasting.instance.pauseVideo() - } + _buttonPlay.setOnClickListener { StateCasting.instance.resumeVideo(); }; + _buttonPause.setOnClickListener { StateCasting.instance.pauseVideo(); }; if (!isInEditMode) { setIsPlaying(false); @@ -248,7 +243,7 @@ class CastView : ConstraintLayout { _buttonPlay.visibility = View.VISIBLE; } - val position = StateCasting.instance.activeDevice?.expectedCurrentTime?.times(1000.0)?.toLong() + val position = StateCasting.instance.activeDevice?.expectedCurrentTime?.times(1000.0)?.toLong(); if(StatePlayer.instance.hasMediaSession()) { StatePlayer.instance.updateMediaSession(null); StatePlayer.instance.updateMediaSessionPlaybackState(getPlaybackStateCompat(), (position ?: 0)); @@ -312,10 +307,9 @@ class CastView : ConstraintLayout { } private fun getPlaybackStateCompat(): Int { - if (StateCasting.instance.activeDevice?.connectionState != CastConnectionState.CONNECTED) { - return PlaybackState.STATE_NONE - } - return when(StateCasting.instance.activeDevice?.isPlaying) { + val d = StateCasting.instance.activeDevice ?: return PlaybackState.STATE_NONE; + + return when(d.isPlaying) { true -> PlaybackStateCompat.STATE_PLAYING; else -> PlaybackStateCompat.STATE_PAUSED; } From 58448390ddbf9689a15ed0f530a882ff03b17bf3 Mon Sep 17 00:00:00 2001 From: Marcus Hanestad Date: Thu, 4 Sep 2025 14:03:46 +0200 Subject: [PATCH 25/30] casting: revert apple-podcasts version --- app/src/stable/assets/sources/apple-podcasts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/stable/assets/sources/apple-podcasts b/app/src/stable/assets/sources/apple-podcasts index 089987f0..8cff240c 160000 --- a/app/src/stable/assets/sources/apple-podcasts +++ b/app/src/stable/assets/sources/apple-podcasts @@ -1 +1 @@ -Subproject commit 089987f007319cf22972090a0cb09afd8c008adb +Subproject commit 8cff240ca7e9089ab26c03f78b6104d9cc2162fe From 9504c31b36bc46947ae03783534c9b38bd2c5c95 Mon Sep 17 00:00:00 2001 From: Marcus Hanestad Date: Thu, 4 Sep 2025 14:10:56 +0200 Subject: [PATCH 26/30] casting: undo android studio auto formatting --- .../mainactivity/main/VideoDetailView.kt | 34 +++++++++---------- .../views/casting/CastButton.kt | 4 +-- 2 files changed, 19 insertions(+), 19 deletions(-) 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 329c4e12..317373e7 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 @@ -987,11 +987,11 @@ class VideoDetailView : ConstraintLayout { } }, _chapters?.let { - if(it != null && it.size > 0) - RoundButton(context, R.drawable.ic_list, "Chapters", TAG_CHAPTERS) { - showChaptersUI(); - } - else null + if(it != null && it.size > 0) + RoundButton(context, R.drawable.ic_list, "Chapters", TAG_CHAPTERS) { + showChaptersUI(); + } + else null }, if(video?.isLive ?: false) RoundButton(context, R.drawable.ic_chat, context.getString(R.string.live_chat), TAG_LIVECHAT) { @@ -1036,14 +1036,14 @@ class VideoDetailView : ConstraintLayout { }; } else null, - RoundButton(context, R.drawable.ic_share, context.getString(R.string.share), TAG_SHARE) { - video?.let { - Logger.i(TAG, "Share preventPictureInPicture = true"); - preventPictureInPicture = true; - shareVideo(); - }; - _slideUpOverlay?.hide(); - }, + RoundButton(context, R.drawable.ic_share, context.getString(R.string.share), TAG_SHARE) { + video?.let { + Logger.i(TAG, "Share preventPictureInPicture = true"); + preventPictureInPicture = true; + shareVideo(); + }; + _slideUpOverlay?.hide(); + }, if(!isLimitedVersion) RoundButton(context, R.drawable.ic_screen_share, context.getString(R.string.overlay), TAG_OVERLAY) { this.startPictureInPicture(); @@ -2045,8 +2045,8 @@ class VideoDetailView : ConstraintLayout { private suspend fun castIfAvailable(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, resumePositionMs: Long, speed: Double?) { try { val plugin = if (videoSource is JSSource) videoSource.getUnderlyingPlugin() - else if (audioSource is JSSource) audioSource.getUnderlyingPlugin() - else null + else if (audioSource is JSSource) audioSource.getUnderlyingPlugin() + else null val startId = plugin?.getUnderlyingPlugin()?.runtimeId try { @@ -2371,7 +2371,7 @@ class VideoDetailView : ConstraintLayout { val currentPlaybackRate = if (_isCasting) StateCasting.instance.activeDevice?.speed else _player.getPlaybackRate() val qualityPlaybackSpeedTitle = if (canSetSpeed) SlideUpMenuTitle(this.context).apply { setTitle(context.getString(R.string.playback_rate) + " (${String.format("%.2f", currentPlaybackRate)})"); } else null; _overlay_quality_selector = SlideUpMenuOverlay(this.context, _overlay_quality_container, context.getString( - R.string.quality), null, true, + R.string.quality), null, true, qualityPlaybackSpeedTitle, if (canSetSpeed) SlideUpMenuButtonList(this.context, null, "playback_rate").apply { val playbackSpeeds = Settings.instance.playback.getPlaybackSpeeds(); @@ -3311,7 +3311,7 @@ class VideoDetailView : ConstraintLayout { UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video_scriptimplementationexception), it, ::fetchVideo, null, fragment); } else { StateAnnouncement.instance.registerAnnouncement(video?.id?.value + "_Q_INVALIDVIDEO", context.getString(R.string.invalid_video), context.getString( - R.string.there_was_an_invalid_video_in_your_queue_videoname_by_authorname_playback_was_skipped).replace("{videoName}", video?.name ?: "").replace("{authorName}", video?.author?.name ?: ""), AnnouncementType.SESSION) + R.string.there_was_an_invalid_video_in_your_queue_videoname_by_authorname_playback_was_skipped).replace("{videoName}", video?.name ?: "").replace("{authorName}", video?.author?.name ?: ""), AnnouncementType.SESSION) } } .exception { diff --git a/app/src/main/java/com/futo/platformplayer/views/casting/CastButton.kt b/app/src/main/java/com/futo/platformplayer/views/casting/CastButton.kt index a42fa94f..acffc619 100644 --- a/app/src/main/java/com/futo/platformplayer/views/casting/CastButton.kt +++ b/app/src/main/java/com/futo/platformplayer/views/casting/CastButton.kt @@ -40,9 +40,9 @@ class CastButton : androidx.appcompat.widget.AppCompatImageButton { if (d != null) { when (d.connectionState) { - CastConnectionState.DISCONNECTED -> setColorFilter(activeColor) - CastConnectionState.CONNECTING -> setColorFilter(connectingColor) CastConnectionState.CONNECTED -> setColorFilter(activeColor) + CastConnectionState.CONNECTING -> setColorFilter(connectingColor) + CastConnectionState.DISCONNECTED -> setColorFilter(activeColor) } } else { setColorFilter(inactiveColor); From f872b02e36320295cb0c9b51e9d27fcd91d073d6 Mon Sep 17 00:00:00 2001 From: Marcus Hanestad Date: Thu, 4 Sep 2025 16:02:42 +0200 Subject: [PATCH 27/30] casting: update SDK to v0.3.1 --- app/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle b/app/build.gradle index 2a861322..5b375434 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -233,7 +233,7 @@ dependencies { androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' //Rust casting SDK - implementation('org.futo.gitlab.videostreaming.fcast-sdk-jitpack:sender-sdk-minimal:0.3.0') { + implementation('org.futo.gitlab.videostreaming.fcast-sdk-jitpack:sender-sdk-minimal:0.3.1') { // Polycentricandroid includes this exclude group: 'net.java.dev.jna' } From a763a128f869e9a0afa372d05ea0d001f6fb4342 Mon Sep 17 00:00:00 2001 From: Marcus Hanestad Date: Wed, 10 Sep 2025 08:56:50 +0200 Subject: [PATCH 28/30] casting: add more descriptive error log --- .../java/com/futo/platformplayer/views/casting/CastView.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 565f3cee..6a47893d 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 @@ -103,7 +103,7 @@ class CastView : ConstraintLayout { } d.resumePlayback() } catch (e: Throwable) { - Logger.e(TAG, "$e") + Logger.e(TAG, "Failed to change playback speed to hold playback speed: $e") } } _gestureControlView.onSpeedHoldEnd.subscribe { @@ -114,7 +114,7 @@ class CastView : ConstraintLayout { } d.changeSpeed(_speedHoldPrevRate) } catch (e: Throwable) { - Logger.e(TAG, "$e") + Logger.e(TAG, "Failed to change playback speed to previous hold playback speed: $e") } } From ab4924408bf39b6902c37f9d9dc3dfcf2da1e55a Mon Sep 17 00:00:00 2001 From: Marcus Hanestad Date: Wed, 10 Sep 2025 09:14:01 +0200 Subject: [PATCH 29/30] casting: pause playback if speed hold was paused when started --- .../main/java/com/futo/platformplayer/views/casting/CastView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 6a47893d..4dc307b5 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 @@ -110,7 +110,7 @@ class CastView : ConstraintLayout { try { val d = StateCasting.instance.activeDevice ?: return@subscribe; if (!_speedHoldWasPlaying) { - d.resumePlayback() + d.pausePlayback() } d.changeSpeed(_speedHoldPrevRate) } catch (e: Throwable) { From e8c786077431eacd684694cb72bad169c82579b9 Mon Sep 17 00:00:00 2001 From: Marcus Hanestad Date: Wed, 10 Sep 2025 09:31:42 +0200 Subject: [PATCH 30/30] casting: update lastTimeChanged when receiving time update --- .../java/com/futo/platformplayer/casting/ExpCastingDevice.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/futo/platformplayer/casting/ExpCastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/ExpCastingDevice.kt index cda02ebf..bbd56ca3 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/ExpCastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/ExpCastingDevice.kt @@ -254,7 +254,10 @@ class ExpCastingDevice(val device: RsCastingDevice) : CastingDevice() { } } eventHandler.onPlayChanged.subscribe { isPlaying = it } - eventHandler.onTimeChanged.subscribe { time = it } + eventHandler.onTimeChanged.subscribe { + lastTimeChangeTime_ms = System.currentTimeMillis() + time = it + } eventHandler.onDurationChanged.subscribe { duration = it } eventHandler.onVolumeChanged.subscribe { volume = it } eventHandler.onSpeedChanged.subscribe { speed = it }