casting: add experimental support for the FCast sender SDK

This commit is contained in:
Marcus Hanestad 2025-08-12 10:19:33 +02:00
commit 1176cee40b
16 changed files with 3226 additions and 356 deletions

View file

@ -231,4 +231,7 @@ dependencies {
testImplementation "org.mockito:mockito-core:5.4.0" testImplementation "org.mockito:mockito-core:5.4.0"
androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
//Rust casting SDK
implementation "net.java.dev.jna:jna:5.12.0@aar"
} }

View file

@ -719,6 +719,11 @@ class Settings : FragmentedStorageFileJson() {
@Serializable(with = FlexibleBooleanSerializer::class) @Serializable(with = FlexibleBooleanSerializer::class)
var allowLinkLocalIpv4: Boolean = false; 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? /*TODO: Should we have a different casting quality?
@FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3) @FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3)
@DropdownFieldOptionsId(R.array.preferred_quality_array) @DropdownFieldOptionsId(R.array.preferred_quality_array)

View file

@ -38,6 +38,7 @@ import com.futo.platformplayer.dialogs.MigrateDialog
import com.futo.platformplayer.dialogs.PluginUpdateDialog import com.futo.platformplayer.dialogs.PluginUpdateDialog
import com.futo.platformplayer.dialogs.ProgressDialog import com.futo.platformplayer.dialogs.ProgressDialog
import com.futo.platformplayer.engine.exceptions.PluginException 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.MainFragment
import com.futo.platformplayer.fragment.mainactivity.main.SourceDetailFragment import com.futo.platformplayer.fragment.mainactivity.main.SourceDetailFragment
import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment
@ -437,29 +438,56 @@ class UIDialogs {
fun showCastingDialog(context: Context, ownerActivity: Activity? = null) { fun showCastingDialog(context: Context, ownerActivity: Activity? = null) {
val d = StateCasting.instance.activeDevice; if (Settings.instance.casting.experimentalCasting) {
if (d != null) { val d = ExpStateCasting.instance.activeDevice;
val dialog = ConnectedCastingDialog(context); if (d != null) {
if (context is Activity) { val dialog = ConnectedCastingDialog(context);
dialog.setOwnerActivity(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 { } else {
val dialog = ConnectCastingDialog(context); val d = StateCasting.instance.activeDevice;
if (context is Activity) { if (d != null) {
dialog.setOwnerActivity(context) 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();
} }
} }

View file

@ -42,6 +42,7 @@ import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.dp 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.bottombar.MenuBottomBarFragment
import com.futo.platformplayer.fragment.mainactivity.main.ArticleDetailFragment import com.futo.platformplayer.fragment.mainactivity.main.ArticleDetailFragment
import com.futo.platformplayer.fragment.mainactivity.main.BrowserFragment import com.futo.platformplayer.fragment.mainactivity.main.BrowserFragment
@ -507,7 +508,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
handleIntent(intent); handleIntent(intent);
if (Settings.instance.casting.enabled) { 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 { StatePlatform.instance.onDevSourceChanged.subscribe {
@ -1046,7 +1051,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
Logger.i(TAG, "handleFCast"); Logger.i(TAG, "handleFCast");
try { try {
StateCasting.instance.handleUrl(this, url) if (Settings.instance.casting.experimentalCasting) {
ExpStateCasting.instance.handleUrl(this, url)
} else {
StateCasting.instance.handleUrl(this, url)
}
return true; return true;
} catch (e: Throwable) { } catch (e: Throwable) {
Log.e(TAG, "Failed to parse FCast URL '${url}'.", e) Log.e(TAG, "Failed to parse FCast URL '${url}'.", e)

View file

@ -8,9 +8,11 @@ import android.view.View
import android.view.WindowManager import android.view.WindowManager
import android.widget.* import android.widget.*
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.casting.CastProtocolType import com.futo.platformplayer.casting.CastProtocolType
import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.experimental_casting.ExpStateCasting
import com.futo.platformplayer.models.CastingDeviceInfo import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.platformplayer.toInetAddress import com.futo.platformplayer.toInetAddress
@ -101,7 +103,11 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) {
_textError.visibility = View.GONE; _textError.visibility = View.GONE;
val castingDeviceInfo = CastingDeviceInfo(name, castProtocolType, arrayOf(ip), port.toInt()); 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(); performDismiss();
}; };

View file

@ -15,15 +15,18 @@ import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.casting.CastConnectionState import com.futo.platformplayer.casting.CastConnectionState
import com.futo.platformplayer.casting.CastingDevice import com.futo.platformplayer.casting.CastingDevice
import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.experimental_casting.ExpStateCasting
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.views.adapters.DeviceAdapter import com.futo.platformplayer.views.adapters.DeviceAdapter
import com.futo.platformplayer.views.adapters.DeviceAdapterEntry import com.futo.platformplayer.views.adapters.DeviceAdapterEntry
import com.futo.platformplayer.views.adapters.GenericCastingDevice
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -55,17 +58,33 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
_recyclerDevices.layoutManager = LinearLayoutManager(context); _recyclerDevices.layoutManager = LinearLayoutManager(context);
_adapter.onPin.subscribe { d -> _adapter.onPin.subscribe { d ->
val isRemembered = _rememberedDevices.contains(d.name) when (d) {
val newIsRemembered = !isRemembered is GenericCastingDevice.Experimental -> {
if (newIsRemembered) { val isRemembered = _rememberedDevices.contains(d.handle.device.name())
StateCasting.instance.addRememberedDevice(d) val newIsRemembered = !isRemembered
val name = d.name if (newIsRemembered) {
if (name != null) { ExpStateCasting.instance.addRememberedDevice(d.handle)
_rememberedDevices.add(name) 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() updateUnifiedList()
} }
@ -105,37 +124,77 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
(_imageLoader.drawable as Animatable?)?.start(); (_imageLoader.drawable as Animatable?)?.start();
synchronized(StateCasting.instance.devices) { if (Settings.instance.casting.experimentalCasting) {
_devices.addAll(StateCasting.instance.devices.values.mapNotNull { it.name }) 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() updateUnifiedList()
StateCasting.instance.onDeviceAdded.subscribe(this) { d -> if (Settings.instance.casting.experimentalCasting) {
val name = d.name ExpStateCasting.instance.onDeviceAdded.subscribe(this) { d ->
if (name != null) _devices.add(d.name())
_devices.add(name) updateUnifiedList()
updateUnifiedList()
}
StateCasting.instance.onDeviceChanged.subscribe(this) { d ->
val index = _unifiedDevices.indexOfFirst { it.castingDevice.name == d.name }
if (index != -1) {
_unifiedDevices[index] = DeviceAdapterEntry(d, _unifiedDevices[index].isPinnedDevice, _unifiedDevices[index].isOnlineDevice)
_adapter.notifyItemChanged(index)
} }
}
StateCasting.instance.onDeviceRemoved.subscribe(this) { d -> ExpStateCasting.instance.onDeviceChanged.subscribe(this) { d ->
_devices.remove(d.name) val index = _unifiedDevices.indexOfFirst { it.castingDevice.name() == d.device.name() }
updateUnifiedList() 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 -> ExpStateCasting.instance.onDeviceRemoved.subscribe(this) { deviceName ->
if (connectionState == CastConnectionState.CONNECTED) { _devices.remove(deviceName)
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { updateUnifiedList()
dismiss() }
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 { override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldItem = oldList[oldItemPosition] val oldItem = oldList[oldItemPosition]
val newItem = newList[newItemPosition] val newItem = newList[newItemPosition]
return oldItem.castingDevice.name == newItem.castingDevice.name return oldItem.castingDevice.name() == newItem.castingDevice.name()
&& oldItem.castingDevice.isReady == newItem.castingDevice.isReady && oldItem.castingDevice.isReady() == newItem.castingDevice.isReady()
&& oldItem.isOnlineDevice == newItem.isOnlineDevice && oldItem.isOnlineDevice == newItem.isOnlineDevice
&& oldItem.isPinnedDevice == newItem.isPinnedDevice && oldItem.isPinnedDevice == newItem.isPinnedDevice
} }
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldItem = oldList[oldItemPosition] val oldItem = oldList[oldItemPosition]
val newItem = newList[newItemPosition] val newItem = newList[newItemPosition]
return oldItem.castingDevice.name == newItem.castingDevice.name return oldItem.castingDevice.name() == newItem.castingDevice.name()
&& oldItem.castingDevice.isReady == newItem.castingDevice.isReady && oldItem.castingDevice.isReady() == newItem.castingDevice.isReady()
&& oldItem.isOnlineDevice == newItem.isOnlineDevice && oldItem.isOnlineDevice == newItem.isOnlineDevice
&& oldItem.isPinnedDevice == newItem.isPinnedDevice && oldItem.isPinnedDevice == newItem.isPinnedDevice
} }
@ -184,24 +243,67 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
} }
private fun buildUnifiedList(): List<DeviceAdapterEntry> { private fun buildUnifiedList(): List<DeviceAdapterEntry> {
val onlineDevices = StateCasting.instance.devices.values.associateBy { it.name }
val rememberedDevices = StateCasting.instance.getRememberedCastingDevices().associateBy { it.name }
val unifiedList = mutableListOf<DeviceAdapterEntry>() val unifiedList = mutableListOf<DeviceAdapterEntry>()
val intersectionNames = _devices.intersect(_rememberedDevices) if (Settings.instance.casting.experimentalCasting) {
for (name in intersectionNames) { val onlineDevices = ExpStateCasting.instance.devices.values.associateBy { it.device.name() }
onlineDevices[name]?.let { unifiedList.add(DeviceAdapterEntry(it, true, true)) } val rememberedDevices = ExpStateCasting.instance.getRememberedCastingDevices().associateBy { it.device.name() }
}
val onlineOnlyNames = _devices - _rememberedDevices val intersectionNames = _devices.intersect(_rememberedDevices)
for (name in onlineOnlyNames) { for (name in intersectionNames) {
onlineDevices[name]?.let { unifiedList.add(DeviceAdapterEntry(it, false, true)) } onlineDevices[name]?.let {
} unifiedList.add(DeviceAdapterEntry(
GenericCastingDevice.Experimental(it), true, true)
)
}
}
val rememberedOnlyNames = _rememberedDevices - _devices val onlineOnlyNames = _devices - _rememberedDevices
for (name in rememberedOnlyNames) { for (name in onlineOnlyNames) {
rememberedDevices[name]?.let { unifiedList.add(DeviceAdapterEntry(it, true, false)) } 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 return unifiedList

View file

@ -12,6 +12,7 @@ import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.casting.AirPlayCastingDevice import com.futo.platformplayer.casting.AirPlayCastingDevice
import com.futo.platformplayer.casting.CastConnectionState 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.ChromecastCastingDevice
import com.futo.platformplayer.casting.FCastCastingDevice import com.futo.platformplayer.casting.FCastCastingDevice
import com.futo.platformplayer.casting.StateCasting 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.fragment.mainactivity.main.VideoDetailFragment
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp 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 com.google.android.material.slider.Slider.OnChangeListener
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.fcast.sender_sdk.DeviceFeature
import org.fcast.sender_sdk.ProtocolType
class ConnectedCastingDialog(context: Context?) : AlertDialog(context) { class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
private lateinit var _buttonClose: Button; private lateinit var _buttonClose: Button;
@ -69,18 +73,30 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
_buttonPlay = findViewById(R.id.button_play); _buttonPlay = findViewById(R.id.button_play);
_buttonPlay.setOnClickListener { _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 = findViewById(R.id.button_pause);
_buttonPause.setOnClickListener { _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 = findViewById(R.id.button_stop);
_buttonStop.setOnClickListener { _buttonStop.setOnClickListener {
(ownerActivity as MainActivity?)?.getFragment<VideoDetailFragment>()?.closeVideoDetails() (ownerActivity as MainActivity?)?.getFragment<VideoDetailFragment>()?.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); _buttonNext = findViewById(R.id.button_next);
@ -90,7 +106,11 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
_buttonClose.setOnClickListener { dismiss(); }; _buttonClose.setOnClickListener { dismiss(); };
_buttonDisconnect.setOnClickListener { _buttonDisconnect.setOnClickListener {
StateCasting.instance.activeDevice?.stopCasting(); if (Settings.instance.casting.experimentalCasting) {
ExpStateCasting.instance.activeDevice?.device?.stopCasting()
} else {
StateCasting.instance.activeDevice?.stopCasting();
}
dismiss(); dismiss();
}; };
@ -99,11 +119,20 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
return@OnChangeListener return@OnChangeListener
} }
val activeDevice = StateCasting.instance.activeDevice ?: return@OnChangeListener; if (Settings.instance.casting.experimentalCasting) {
try { val activeDevice = ExpStateCasting.instance.activeDevice ?: return@OnChangeListener;
activeDevice.seekVideo(value.toDouble()); try {
} catch (e: Throwable) { activeDevice.device.seek(value.toDouble());
Logger.e(TAG, "Failed to change volume.", e); } 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 return@OnChangeListener
} }
val activeDevice = StateCasting.instance.activeDevice ?: return@OnChangeListener; if (Settings.instance.casting.experimentalCasting) {
if (activeDevice.canSetVolume) { val activeDevice = ExpStateCasting.instance.activeDevice ?: return@OnChangeListener;
try { if (activeDevice.device.supportsFeature(DeviceFeature.SET_VOLUME)) {
activeDevice.changeVolume(value.toDouble()); try {
} catch (e: Throwable) { activeDevice.device.changeVolume(value.toDouble());
Logger.e(TAG, "Failed to change volume.", e); } 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(); super.show();
Logger.i(TAG, "Dialog shown."); Logger.i(TAG, "Dialog shown.");
StateCasting.instance.onActiveDeviceVolumeChanged.remove(this); if (Settings.instance.casting.experimentalCasting) {
StateCasting.instance.onActiveDeviceVolumeChanged.subscribe { ExpStateCasting.instance.onActiveDeviceVolumeChanged.remove(this)
_sliderVolume.value = it.toFloat().coerceAtLeast(0.0f).coerceAtMost(_sliderVolume.valueTo); ExpStateCasting.instance.onActiveDeviceVolumeChanged.subscribe {
}; _sliderVolume.value = it.toFloat().coerceAtLeast(0.0f).coerceAtMost(_sliderVolume.valueTo)
}
StateCasting.instance.onActiveDeviceTimeChanged.remove(this); ExpStateCasting.instance.onActiveDeviceTimeChanged.remove(this)
StateCasting.instance.onActiveDeviceTimeChanged.subscribe { StateCasting.instance.onActiveDeviceTimeChanged.subscribe {
_sliderPosition.value = it.toFloat().coerceAtLeast(0.0f).coerceAtMost(_sliderPosition.valueTo); _sliderPosition.value = it.toFloat().coerceAtLeast(0.0f).coerceAtMost(_sliderPosition.valueTo)
}; }
StateCasting.instance.onActiveDeviceDurationChanged.remove(this); ExpStateCasting.instance.onActiveDeviceDurationChanged.remove(this)
StateCasting.instance.onActiveDeviceDurationChanged.subscribe { ExpStateCasting.instance.onActiveDeviceDurationChanged.subscribe {
val dur = it.toFloat().coerceAtLeast(1.0f) val dur = it.toFloat().coerceAtLeast(1.0f)
_sliderPosition.value = _sliderPosition.value.coerceAtLeast(0.0f).coerceAtMost(dur); _sliderPosition.value = _sliderPosition.value.coerceAtLeast(0.0f).coerceAtMost(dur)
_sliderPosition.valueTo = dur _sliderPosition.valueTo = dur
}; }
_device = StateCasting.instance.activeDevice; _device = StateCasting.instance.activeDevice
val d = _device; val d = _device
val isConnected = d != null && d.connectionState == CastConnectionState.CONNECTED; val isConnected = d != null && d.connectionState == CastConnectionState.CONNECTED
setLoading(!isConnected); setLoading(!isConnected)
StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, connectionState -> ExpStateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, connectionState ->
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { setLoading(connectionState != CastConnectionState.CONNECTED); }; StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
updateDevice(); 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(); updateDevice();
} }
@ -167,56 +237,112 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
StateCasting.instance.onActiveDeviceTimeChanged.remove(this); StateCasting.instance.onActiveDeviceTimeChanged.remove(this);
_device = null; _device = null;
StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this); StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this);
ExpStateCasting.instance.onActiveDeviceVolumeChanged.remove(this);
ExpStateCasting.instance.onActiveDeviceDurationChanged.remove(this);
ExpStateCasting.instance.onActiveDeviceTimeChanged.remove(this);
ExpStateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this);
} }
private fun updateDevice() { private fun updateDevice() {
val d = StateCasting.instance.activeDevice ?: return; if (Settings.instance.casting.experimentalCasting) {
val d = ExpStateCasting.instance.activeDevice ?: return;
if (d is ChromecastCastingDevice) { when (d.device.castingProtocol()) {
_imageDevice.setImageResource(R.drawable.ic_chromecast); ProtocolType.CHROMECAST -> {
_textType.text = "Chromecast"; _imageDevice.setImageResource(R.drawable.ic_chromecast);
} else if (d is AirPlayCastingDevice) { _textType.text = "Chromecast";
_imageDevice.setImageResource(R.drawable.ic_airplay); }
_textType.text = "AirPlay"; ProtocolType.F_CAST -> {
} else if (d is FCastCastingDevice) { _imageDevice.setImageResource(R.drawable.ic_fc);
_imageDevice.setImageResource(R.drawable.ic_fc); _textType.text = "FCast";
_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)
} }
CastConnectionState.CONNECTING,
CastConnectionState.DISCONNECTED -> { _textName.text = d.device.name();
disableControls(interactiveControls) _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)
}
} }
} }
} }

View file

@ -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<DeviceConnectionState>();
var onPlayChanged = Event1<Boolean>()
var onTimeChanged = Event1<Double>()
var onDurationChanged = Event1<Double>()
var onVolumeChanged = Event1<Double>()
var onSpeedChanged = Event1<Double>()
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<ExpCastProtocolType> {
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)
}
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -98,6 +98,7 @@ import com.futo.platformplayer.engine.exceptions.ScriptLoginRequiredException
import com.futo.platformplayer.engine.exceptions.ScriptReloadRequiredException import com.futo.platformplayer.engine.exceptions.ScriptReloadRequiredException
import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException
import com.futo.platformplayer.exceptions.UnsupportedCastException import com.futo.platformplayer.exceptions.UnsupportedCastException
import com.futo.platformplayer.experimental_casting.ExpStateCasting
import com.futo.platformplayer.fixHtmlLinks import com.futo.platformplayer.fixHtmlLinks
import com.futo.platformplayer.fixHtmlWhitespace import com.futo.platformplayer.fixHtmlWhitespace
import com.futo.platformplayer.getNowDiffSeconds import com.futo.platformplayer.getNowDiffSeconds
@ -175,6 +176,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.fcast.sender_sdk.DeviceFeature
import userpackage.Protocol import userpackage.Protocol
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.util.Locale import java.util.Locale
@ -664,51 +666,96 @@ class VideoDetailView : ConstraintLayout {
} }
if (!isInEditMode) { if (!isInEditMode) {
StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { device, connectionState -> if (Settings.instance.casting.experimentalCasting) {
if (_onPauseCalled) { ExpStateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { device, connectionState ->
return@subscribe; 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) { ExpStateCasting.instance.onActiveDevicePlayChanged.subscribe(this) {
CastConnectionState.CONNECTED -> { val activeDevice = StateCasting.instance.activeDevice;
loadCurrentVideo(lastPositionMilliseconds); if (activeDevice != null) {
updatePillButtonVisibilities(); handlePlayChanged(it);
setCastEnabled(true);
}
CastConnectionState.DISCONNECTED -> {
loadCurrentVideo(lastPositionMilliseconds, playWhenReady = device.isPlaying);
updatePillButtonVisibilities();
setCastEnabled(false);
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(); 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 { _cast.onTimeJobTimeChanged_s.subscribe {
if (_isCasting) { if (_isCasting) {
setLastPositionMilliseconds((it * 1000.0).toLong(), true); setLastPositionMilliseconds((it * 1000.0).toLong(), true);
@ -1188,8 +1235,13 @@ class VideoDetailView : ConstraintLayout {
_onPauseCalled = true; _onPauseCalled = true;
_taskLoadVideo.cancel(); _taskLoadVideo.cancel();
if(StateCasting.instance.isCasting) if (Settings.instance.casting.experimentalCasting) {
return; if(ExpStateCasting.instance.isCasting)
return;
} else {
if(StateCasting.instance.isCasting)
return;
}
if(isAudioOnlyUserAction) if(isAudioOnlyUserAction)
StatePlayer.instance.startOrUpdateMediaSession(context, video); StatePlayer.instance.startOrUpdateMediaSession(context, video);
@ -1240,9 +1292,15 @@ class VideoDetailView : ConstraintLayout {
_container_content_description.cleanup(); _container_content_description.cleanup();
_container_content_support.cleanup(); _container_content_support.cleanup();
StatePlayer.instance.autoplayChanged.remove(this) StatePlayer.instance.autoplayChanged.remove(this)
StateCasting.instance.onActiveDevicePlayChanged.remove(this); if (Settings.instance.casting.experimentalCasting) {
StateCasting.instance.onActiveDeviceTimeChanged.remove(this); ExpStateCasting.instance.onActiveDevicePlayChanged.remove(this);
StateCasting.instance.onActiveDeviceConnectionStateChanged.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); StateApp.instance.preventPictureInPicture.remove(this);
StatePlayer.instance.onQueueChanged.remove(this); StatePlayer.instance.onQueueChanged.remove(this);
StatePlayer.instance.onVideoChanging.remove(this); StatePlayer.instance.onVideoChanging.remove(this);
@ -1974,7 +2032,11 @@ class VideoDetailView : ConstraintLayout {
return; return;
} }
val isCasting = StateCasting.instance.isCasting val isCasting = if (Settings.instance.casting.experimentalCasting) {
ExpStateCasting.instance.isCasting
} else {
StateCasting.instance.isCasting
}
if (!isCasting) { if (!isCasting) {
setCastEnabled(false); setCastEnabled(false);
@ -2051,11 +2113,19 @@ class VideoDetailView : ConstraintLayout {
val startId = plugin?.getUnderlyingPlugin()?.runtimeId val startId = plugin?.getUnderlyingPlugin()?.runtimeId
try { try {
val castingSucceeded = StateCasting.instance.castIfAvailable(contentResolver, video, videoSource, audioSource, subtitleSource, resumePositionMs, speed, onLoading = { val castingSucceeded = if (Settings.instance.casting.experimentalCasting) {
_cast.setLoading(it) ExpStateCasting.instance.castIfAvailable(contentResolver, video, videoSource, audioSource, subtitleSource, resumePositionMs, speed, onLoading = {
}, onLoadingEstimate = { _cast.setLoading(it)
_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) { if (castingSucceeded) {
withContext(Dispatchers.Main) { 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 { _overlay_quality_selector?.groupItems?.firstOrNull { it is SlideUpMenuButtonList && it.id == "playback_rate" }?.let {
(it as SlideUpMenuButtonList).setSelected(currentPlaybackRate.toString()) (it as SlideUpMenuButtonList).setSelected(currentPlaybackRate.toString())
}; };
@ -2368,8 +2444,18 @@ class VideoDetailView : ConstraintLayout {
?.distinct() ?.distinct()
?.toList() ?: listOf() else audioSources?.toList() ?: listOf(); ?.toList() ?: listOf() else audioSources?.toList() ?: listOf();
val canSetSpeed = !_isCasting || StateCasting.instance.activeDevice?.canSetSpeed == true val canSetSpeed = !_isCasting || if (Settings.instance.casting.experimentalCasting) {
val currentPlaybackRate = if (_isCasting) StateCasting.instance.activeDevice?.speed else _player.getPlaybackRate() 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; 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( _overlay_quality_selector = SlideUpMenuOverlay(this.context, _overlay_quality_container, context.getString(
R.string.quality), null, true, R.string.quality), null, true,
@ -2383,7 +2469,13 @@ class VideoDetailView : ConstraintLayout {
setButtons(playbackLabels, String.format(Locale.US, format, currentPlaybackRate)); setButtons(playbackLabels, String.format(Locale.US, format, currentPlaybackRate));
onClick.subscribe { v -> 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; var playbackSpeedString = v;
val stepSpeed = Settings.instance.playback.getPlaybackSpeedStep(); val stepSpeed = Settings.instance.playback.getPlaybackSpeedStep();
if(v == "+") 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(); playbackSpeedString = String.format(Locale.US, "%.2f", Math.max(0.1, (currentPlaybackSpeed?.toDouble() ?: 1.0) - stepSpeed)).toString();
val newPlaybackSpeed = playbackSpeedString.toDouble(); val newPlaybackSpeed = playbackSpeedString.toDouble();
if (_isCasting) { if (_isCasting) {
val ad = StateCasting.instance.activeDevice ?: return@subscribe if (Settings.instance.casting.experimentalCasting) {
if (!ad.canSetSpeed) { val ad = ExpStateCasting.instance.activeDevice ?: return@subscribe
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)})"); qualityPlaybackSpeedTitle?.setTitle(context.getString(R.string.playback_rate) + " (${String.format(Locale.US, "%.2f", newPlaybackSpeed)})");
ad.changeSpeed(newPlaybackSpeed) ad.device.changeSpeed(newPlaybackSpeed)
setSelected(playbackSpeedString); 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 { } else {
qualityPlaybackSpeedTitle?.setTitle(context.getString(R.string.playback_rate) + " (${String.format(Locale.US, "%.2f", newPlaybackSpeed)})"); qualityPlaybackSpeedTitle?.setTitle(context.getString(R.string.playback_rate) + " (${String.format(Locale.US, "%.2f", newPlaybackSpeed)})");
_player.setPlaybackRate(playbackSpeedString.toFloat()); _player.setPlaybackRate(playbackSpeedString.toFloat());
@ -2514,8 +2617,14 @@ class VideoDetailView : ConstraintLayout {
//Handlers //Handlers
private fun handlePlay() { private fun handlePlay() {
Logger.i(TAG, "handlePlay") Logger.i(TAG, "handlePlay")
if (!StateCasting.instance.resumeVideo()) { if (Settings.instance.casting.experimentalCasting) {
_player.play(); if (!ExpStateCasting.instance.resumeVideo()) {
_player.play()
}
} else {
if (!StateCasting.instance.resumeVideo()) {
_player.play();
}
} }
onShouldEnterPictureInPictureChanged.emit() onShouldEnterPictureInPictureChanged.emit()
@ -2531,33 +2640,61 @@ class VideoDetailView : ConstraintLayout {
private fun handlePause() { private fun handlePause() {
Logger.i(TAG, "handlePause") Logger.i(TAG, "handlePause")
if (!StateCasting.instance.pauseVideo()) { if (Settings.instance.casting.experimentalCasting) {
_player.pause(); if (!ExpStateCasting.instance.pauseVideo()) {
_player.pause()
}
} else {
if (!StateCasting.instance.pauseVideo()) {
_player.pause()
}
} }
onShouldEnterPictureInPictureChanged.emit() onShouldEnterPictureInPictureChanged.emit()
} }
private fun handleSeek(ms: Long) { private fun handleSeek(ms: Long) {
Logger.i(TAG, "handleSeek(ms=$ms)") Logger.i(TAG, "handleSeek(ms=$ms)")
if (!StateCasting.instance.videoSeekTo(ms.toDouble() / 1000.0)) { if (Settings.instance.casting.experimentalCasting) {
_player.seekTo(ms); 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() { private fun handleStop() {
Logger.i(TAG, "handleStop") Logger.i(TAG, "handleStop")
if (!StateCasting.instance.stopVideo()) { if (Settings.instance.casting.experimentalCasting) {
_player.stop(); if (!ExpStateCasting.instance.stopVideo()) {
_player.stop()
}
} else {
if (!StateCasting.instance.stopVideo()) {
_player.stop()
}
} }
} }
private fun handlePlayChanged(playing: Boolean) { private fun handlePlayChanged(playing: Boolean) {
Logger.i(TAG, "handlePlayChanged(playing=$playing)") Logger.i(TAG, "handlePlayChanged(playing=$playing)")
val ad = StateCasting.instance.activeDevice; if (Settings.instance.casting.experimentalCasting) {
if (ad != null) { val ad = ExpStateCasting.instance.activeDevice;
_cast.setIsPlaying(playing); 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 { } else {
StatePlayer.instance.updateMediaSession( null); val ad = StateCasting.instance.activeDevice;
StatePlayer.instance.updateMediaSessionPlaybackState(_player.exoPlayer?.getPlaybackStateCompat() ?: PlaybackStateCompat.STATE_NONE, _player.exoPlayer?.player?.currentPosition ?: 0); 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) { if(playing) {
@ -2595,11 +2732,27 @@ class VideoDetailView : ConstraintLayout {
fragment.lifecycleScope.launch(Dispatchers.Main) { fragment.lifecycleScope.launch(Dispatchers.Main) {
try { try {
val d = StateCasting.instance.activeDevice; if (Settings.instance.casting.experimentalCasting) {
if (d != null && d.connectionState == CastConnectionState.CONNECTED) val d = ExpStateCasting.instance.activeDevice;
castIfAvailable(context.contentResolver, video, videoSource, _lastAudioSource, _lastSubtitleSource, (d.expectedCurrentTime * 1000.0).toLong(), d.speed); if (d != null && d.connectionState == com.futo.platformplayer.experimental_casting.CastConnectionState.CONNECTED)
else if(!_player.swapSources(videoSource, _lastAudioSource, true, true, true)) castIfAvailable(
_player.hideControls(false); //TODO: Disable player? 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) { } catch (e: Throwable) {
Logger.e(TAG, "handleSelectVideoTrack failed", e) Logger.e(TAG, "handleSelectVideoTrack failed", e)
} }
@ -2616,11 +2769,35 @@ class VideoDetailView : ConstraintLayout {
fragment.lifecycleScope.launch(Dispatchers.Main) { fragment.lifecycleScope.launch(Dispatchers.Main) {
try { try {
val d = StateCasting.instance.activeDevice; if (Settings.instance.casting.experimentalCasting) {
if (d != null && d.connectionState == CastConnectionState.CONNECTED) val d = ExpStateCasting.instance.activeDevice;
castIfAvailable(context.contentResolver, video, _lastVideoSource, audioSource, _lastSubtitleSource, (d.expectedCurrentTime * 1000.0).toLong(), d.speed) if (d != null && d.connectionState == com.futo.platformplayer.experimental_casting.CastConnectionState.CONNECTED)
else if (!_player.swapSources(_lastVideoSource, audioSource, true, true, true)) castIfAvailable(
_player.hideControls(false); //TODO: Disable player? 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) { } catch (e: Throwable) {
Logger.e(TAG, "handleSelectAudioTrack failed", e) Logger.e(TAG, "handleSelectAudioTrack failed", e)
} }
@ -2638,11 +2815,36 @@ class VideoDetailView : ConstraintLayout {
fragment.lifecycleScope.launch(Dispatchers.Main) { fragment.lifecycleScope.launch(Dispatchers.Main) {
try { try {
val d = StateCasting.instance.activeDevice; if (Settings.instance.casting.experimentalCasting) {
if (d != null && d.connectionState == CastConnectionState.CONNECTED) val d = ExpStateCasting.instance.activeDevice;
castIfAvailable(context.contentResolver, video, _lastVideoSource, _lastAudioSource, toSet, (d.expectedCurrentTime * 1000.0).toLong(), d.speed); if (d != null && d.connectionState == com.futo.platformplayer.experimental_casting.CastConnectionState.CONNECTED)
else { castIfAvailable(
_player.swapSubtitles(toSet); 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) { } catch (e: Throwable) {
Logger.e(TAG, "handleSelectSubtitleTrack failed", e) Logger.e(TAG, "handleSelectSubtitleTrack failed", e)

View file

@ -1,6 +1,7 @@
package com.futo.platformplayer.models package com.futo.platformplayer.models
import com.futo.platformplayer.casting.CastProtocolType import com.futo.platformplayer.casting.CastProtocolType
import com.futo.platformplayer.experimental_casting.ExpCastProtocolType
@kotlinx.serialization.Serializable @kotlinx.serialization.Serializable
class CastingDeviceInfo { class CastingDeviceInfo {
@ -15,4 +16,19 @@ class CastingDeviceInfo {
this.addresses = addresses; this.addresses = addresses;
this.port = port; this.port = port;
} }
} }
@kotlinx.serialization.Serializable
class ExpCastingDeviceInfo {
var name: String;
var type: ExpCastProtocolType;
var addresses: Array<String>;
var port: Int;
constructor(name: String, type: ExpCastProtocolType, addresses: Array<String>, port: Int) {
this.name = name;
this.type = type;
this.addresses = addresses;
this.port = port;
}
}

View file

@ -6,14 +6,34 @@ import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.casting.CastingDevice import com.futo.platformplayer.casting.CastingDevice
import com.futo.platformplayer.constructs.Event1 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<DeviceViewHolder> { class DeviceAdapter : RecyclerView.Adapter<DeviceViewHolder> {
private val _devices: List<DeviceAdapterEntry>; private val _devices: List<DeviceAdapterEntry>;
var onPin = Event1<CastingDevice>(); var onPin = Event1<GenericCastingDevice>();
var onConnect = Event1<CastingDevice>(); var onConnect = Event1<GenericCastingDevice>();
constructor(devices: List<DeviceAdapterEntry>) : super() { constructor(devices: List<DeviceAdapterEntry>) : super() {
_devices = devices; _devices = devices;

View file

@ -4,21 +4,20 @@ import android.graphics.drawable.Animatable
import android.view.View import android.view.View
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.ImageView import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView.ViewHolder import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.casting.AirPlayCastingDevice import com.futo.platformplayer.casting.AirPlayCastingDevice
import com.futo.platformplayer.casting.CastConnectionState import com.futo.platformplayer.casting.CastConnectionState
import com.futo.platformplayer.casting.CastingDevice
import com.futo.platformplayer.casting.ChromecastCastingDevice import com.futo.platformplayer.casting.ChromecastCastingDevice
import com.futo.platformplayer.casting.FCastCastingDevice import com.futo.platformplayer.casting.FCastCastingDevice
import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.experimental_casting.ExpStateCasting
import androidx.core.view.isVisible import org.fcast.sender_sdk.ProtocolType
import com.futo.platformplayer.UIDialogs
class DeviceViewHolder : ViewHolder { class DeviceViewHolder : ViewHolder {
private val _layoutDevice: FrameLayout; private val _layoutDevice: FrameLayout;
@ -32,11 +31,11 @@ class DeviceViewHolder : ViewHolder {
private var _animatableLoader: Animatable? = null; private var _animatableLoader: Animatable? = null;
private var _imagePin: ImageView; private var _imagePin: ImageView;
var device: CastingDevice? = null var device: GenericCastingDevice? = null
private set private set
var onPin = Event1<CastingDevice>(); var onPin = Event1<GenericCastingDevice>();
val onConnect = Event1<CastingDevice>(); val onConnect = Event1<GenericCastingDevice>();
constructor(view: View) : super(view) { constructor(view: View) : super(view) {
_root = view.findViewById(R.id.layout_root); _root = view.findViewById(R.id.layout_root);
@ -56,15 +55,34 @@ class DeviceViewHolder : ViewHolder {
val connect = { val connect = {
device?.let { dev -> device?.let { dev ->
if (dev.isReady) { when (dev) {
StateCasting.instance.activeDevice?.stopCasting() is GenericCastingDevice.Normal -> {
StateCasting.instance.connectDevice(dev) if (dev.device.isReady) {
onConnect.emit(dev) // NOTE: we assume normal casting is used
} else { StateCasting.instance.activeDevice?.stopCasting()
try { StateCasting.instance.connectDevice(dev.device)
view.context?.let { UIDialogs.toast(it, "Device not ready, may be offline") } onConnect.emit(dev)
} catch (e: Throwable) { } else {
//Ignored 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) { // 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";
}
_textName.text = d.name; fun bind(d: GenericCastingDevice, isOnlineDevice: Boolean, isPinnedDevice: Boolean) {
_imageOnline.visibility = if (isOnlineDevice && d.isReady) View.VISIBLE else View.GONE when (d) {
is GenericCastingDevice.Normal -> {
if (!d.isReady) { if (d.device is ChromecastCastingDevice) {
_imageLoader.visibility = View.GONE; _imageDevice.setImageResource(R.drawable.ic_chromecast);
_textNotReady.visibility = View.VISIBLE; _textType.text = "Chromecast";
_imagePin.visibility = View.GONE; } else if (d.device is AirPlayCastingDevice) {
} else { _imageDevice.setImageResource(R.drawable.ic_airplay);
_textNotReady.visibility = View.GONE; _textType.text = "AirPlay";
} else if (d.device is FCastCastingDevice) {
val dev = StateCasting.instance.activeDevice; _imageDevice.setImageResource(R.drawable.ic_fc);
if (dev == d) { _textType.text = "FCast";
if (dev.connectionState == CastConnectionState.CONNECTED) {
_imageLoader.visibility = View.GONE;
_textNotReady.visibility = View.GONE;
_imagePin.visibility = View.VISIBLE;
} else {
_imageLoader.visibility = View.VISIBLE;
_textNotReady.visibility = View.GONE;
_imagePin.visibility = View.VISIBLE;
} }
} else {
if (d.isReady) { _textName.text = d.device.name;
_imageLoader.visibility = View.GONE; _imageOnline.visibility = if (isOnlineDevice && d.device.isReady) View.VISIBLE else View.GONE
_textNotReady.visibility = View.GONE;
_imagePin.visibility = View.VISIBLE; if (!d.device.isReady) {
} else {
_imageLoader.visibility = View.GONE; _imageLoader.visibility = View.GONE;
_textNotReady.visibility = View.VISIBLE; _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) { if (!d.handle.device.isReady()) {
_animatableLoader?.start(); _imageLoader.visibility = View.GONE;
} else { _textNotReady.visibility = View.VISIBLE;
_animatableLoader?.stop(); _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;
} }
} }

View file

@ -15,6 +15,7 @@ import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.casting.CastConnectionState import com.futo.platformplayer.casting.CastConnectionState
import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.experimental_casting.ExpStateCasting
class CastButton : androidx.appcompat.widget.AppCompatImageButton { class CastButton : androidx.appcompat.widget.AppCompatImageButton {
var onClick = Event1<Pair<String, Any>>(); var onClick = Event1<Pair<String, Any>>();
@ -27,9 +28,15 @@ class CastButton : androidx.appcompat.widget.AppCompatImageButton {
visibility = View.GONE; visibility = View.GONE;
} }
StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, _ -> if (Settings.instance.casting.experimentalCasting) {
updateCastState(); ExpStateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, _ ->
}; updateCastState();
};
} else {
StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, _ ->
updateCastState();
};
}
updateCastState(); updateCastState();
} }
@ -37,25 +44,44 @@ class CastButton : androidx.appcompat.widget.AppCompatImageButton {
private fun updateCastState() { private fun updateCastState() {
val c = context ?: return; 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 activeColor = ContextCompat.getColor(c, R.color.colorPrimary);
val connectingColor = ContextCompat.getColor(c, R.color.gray_c3); val connectingColor = ContextCompat.getColor(c, R.color.gray_c3);
val inactiveColor = ContextCompat.getColor(c, R.color.white); val inactiveColor = ContextCompat.getColor(c, R.color.white);
if (d != null) { if (d != null) {
when (d.connectionState) { when (d.connectionState) {
CastConnectionState.CONNECTED -> setColorFilter(activeColor) com.futo.platformplayer.experimental_casting.CastConnectionState.CONNECTED -> setColorFilter(activeColor)
CastConnectionState.CONNECTING -> setColorFilter(connectingColor) com.futo.platformplayer.experimental_casting.CastConnectionState.CONNECTING -> setColorFilter(connectingColor)
CastConnectionState.DISCONNECTED -> setColorFilter(activeColor) com.futo.platformplayer.experimental_casting.CastConnectionState.DISCONNECTED -> setColorFilter(activeColor)
}
} else {
setColorFilter(inactiveColor);
} }
} else { } 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() { fun cleanup() {
setOnClickListener(null); setOnClickListener(null);
StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this); StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this);
ExpStateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this);
} }
} }

View file

@ -27,6 +27,7 @@ import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.experimental_casting.ExpStateCasting
import com.futo.platformplayer.formatDuration import com.futo.platformplayer.formatDuration
import com.futo.platformplayer.states.StateHistory import com.futo.platformplayer.states.StateHistory
import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlayer
@ -38,6 +39,7 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.fcast.sender_sdk.DeviceFeature
class CastView : ConstraintLayout { class CastView : ConstraintLayout {
private val _thumbnail: ImageView; private val _thumbnail: ImageView;
@ -96,22 +98,48 @@ class CastView : ConstraintLayout {
_gestureControlView.fullScreenGestureEnabled = false _gestureControlView.fullScreenGestureEnabled = false
_gestureControlView.setupTouchArea(); _gestureControlView.setupTouchArea();
_gestureControlView.onSpeedHoldStart.subscribe { _gestureControlView.onSpeedHoldStart.subscribe {
val d = StateCasting.instance.activeDevice ?: return@subscribe; if (Settings.instance.casting.experimentalCasting) {
_speedHoldWasPlaying = d.isPlaying val d = ExpStateCasting.instance.activeDevice ?: return@subscribe;
_speedHoldPrevRate = d.speed _speedHoldWasPlaying = d.isPlaying
if (d.canSetSpeed) _speedHoldPrevRate = d.speed
d.changeSpeed(Settings.instance.playback.getHoldPlaybackSpeed()) if (d.device.supportsFeature(DeviceFeature.SET_SPEED)) {
d.resumeVideo() 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 { _gestureControlView.onSpeedHoldEnd.subscribe {
val d = StateCasting.instance.activeDevice ?: return@subscribe; if (Settings.instance.casting.experimentalCasting) {
if (!_speedHoldWasPlaying) d.pauseVideo() val d = ExpStateCasting.instance.activeDevice ?: return@subscribe;
d.changeSpeed(_speedHoldPrevRate) 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 { _gestureControlView.onSeek.subscribe {
val d = StateCasting.instance.activeDevice ?: return@subscribe; if (Settings.instance.casting.experimentalCasting) {
StateCasting.instance.videoSeekTo(d.expectedCurrentTime + it / 1000); 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 { _buttonLoop.setOnClickListener {
@ -122,22 +150,46 @@ class CastView : ConstraintLayout {
_timeBar.addListener(object : TimeBar.OnScrubListener { _timeBar.addListener(object : TimeBar.OnScrubListener {
override fun onScrubStart(timeBar: TimeBar, position: Long) { 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) { 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) { 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(); }; _buttonMinimize.setOnClickListener { onMinimizeClick.emit(); };
_buttonSettings.setOnClickListener { onSettingsClick.emit(); }; _buttonSettings.setOnClickListener { onSettingsClick.emit(); };
_buttonPlay.setOnClickListener { StateCasting.instance.resumeVideo(); }; _buttonPlay.setOnClickListener {
_buttonPause.setOnClickListener { StateCasting.instance.pauseVideo(); }; 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) { if (!isInEditMode) {
setIsPlaying(false); setIsPlaying(false);
@ -220,19 +272,22 @@ class CastView : ConstraintLayout {
stopTimeJob() stopTimeJob()
if(isPlaying) { if(isPlaying) {
val d = StateCasting.instance.activeDevice; // NOTE: the experimental implementation polls automatically
if (d is AirPlayCastingDevice || d is ChromecastCastingDevice) { if (!Settings.instance.casting.experimentalCasting) {
_updateTimeJob = _scope.launch { val d = StateCasting.instance.activeDevice;
while (true) { if (d is AirPlayCastingDevice || d is ChromecastCastingDevice) {
val device = StateCasting.instance.activeDevice; _updateTimeJob = _scope.launch {
if (device == null || !device.isPlaying) { while (true) {
break; val device = StateCasting.instance.activeDevice;
} if (device == null || !device.isPlaying) {
break;
}
delay(1000); delay(1000);
val time_ms = (device.expectedCurrentTime * 1000.0).toLong() val time_ms = (device.expectedCurrentTime * 1000.0).toLong()
setTime(time_ms); setTime(time_ms);
onTimeJobTimeChanged_s.emit(device.expectedCurrentTime.toLong()) onTimeJobTimeChanged_s.emit(device.expectedCurrentTime.toLong())
}
} }
} }
} }
@ -247,7 +302,11 @@ class CastView : ConstraintLayout {
_buttonPlay.visibility = View.VISIBLE; _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()) { if(StatePlayer.instance.hasMediaSession()) {
StatePlayer.instance.updateMediaSession(null); StatePlayer.instance.updateMediaSession(null);
StatePlayer.instance.updateMediaSessionPlaybackState(getPlaybackStateCompat(), (position ?: 0)); StatePlayer.instance.updateMediaSessionPlaybackState(getPlaybackStateCompat(), (position ?: 0));
@ -311,11 +370,20 @@ class CastView : ConstraintLayout {
} }
private fun getPlaybackStateCompat(): Int { 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) { return when(d.isPlaying) {
true -> PlaybackStateCompat.STATE_PLAYING; true -> PlaybackStateCompat.STATE_PLAYING;
else -> PlaybackStateCompat.STATE_PAUSED; 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;
}
} }
} }

View file

@ -82,6 +82,8 @@
<string name="allow_ipv6_description">If casting over IPV6 is allowed, can cause issues on some networks</string> <string name="allow_ipv6_description">If casting over IPV6 is allowed, can cause issues on some networks</string>
<string name="allow_ipv4">Allow Link Local IPV4</string> <string name="allow_ipv4">Allow Link Local IPV4</string>
<string name="allow_ipv4_description">If casting over IPV4 link local is allowed, can cause issues on some networks</string> <string name="allow_ipv4_description">If casting over IPV4 link local is allowed, can cause issues on some networks</string>
<string name="experimental_cast">Experimental</string>
<string name="experimental_cast_description">Use experimental casting backend (requires restart)</string>
<string name="discover">Discover</string> <string name="discover">Discover</string>
<string name="find_new_video_sources_to_add">Find new video sources to add</string> <string name="find_new_video_sources_to_add">Find new video sources to add</string>
<string name="these_sources_have_been_disabled">These sources have been disabled</string> <string name="these_sources_have_been_disabled">These sources have been disabled</string>