From 1176cee40bacc25c38cc6818d489d41b4562375f Mon Sep 17 00:00:00 2001 From: Marcus Hanestad Date: Tue, 12 Aug 2025 10:19:33 +0200 Subject: [PATCH] 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