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"
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"
}

View file

@ -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)

View file

@ -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,6 +438,32 @@ class UIDialogs {
fun showCastingDialog(context: Context, ownerActivity: Activity? = null) {
if (Settings.instance.casting.experimentalCasting) {
val d = ExpStateCasting.instance.activeDevice;
if (d != null) {
val dialog = ConnectedCastingDialog(context);
if (context is Activity) {
dialog.setOwnerActivity(context)
}
registerDialogOpened(dialog);
ownerActivity?.let { dialog.setOwnerActivity(it) }
dialog.setOnDismissListener { registerDialogClosed(dialog) };
dialog.show();
} else {
val dialog = ConnectCastingDialog(context);
if (context is Activity) {
dialog.setOwnerActivity(context)
}
registerDialogOpened(dialog);
val c = context
if (c is Activity) {
dialog.setOwnerActivity(c);
}
ownerActivity?.let { dialog.setOwnerActivity(it) }
dialog.setOnDismissListener { registerDialogClosed(dialog) };
dialog.show();
}
} else {
val d = StateCasting.instance.activeDevice;
if (d != null) {
val dialog = ConnectedCastingDialog(context);
@ -462,6 +489,7 @@ class UIDialogs {
dialog.show();
}
}
}
fun showCastingTutorialDialog(context: Context, ownerActivity: Activity? = null) {
val dialog = CastingHelpDialog(context);

View file

@ -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 {
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)

View file

@ -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();
};

View file

@ -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)
when (d) {
is GenericCastingDevice.Experimental -> {
val isRemembered = _rememberedDevices.contains(d.handle.device.name())
val newIsRemembered = !isRemembered
if (newIsRemembered) {
StateCasting.instance.addRememberedDevice(d)
val name = d.name
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)
_rememberedDevices.remove(d.name)
StateCasting.instance.removeRememberedDevice(d.device)
_rememberedDevices.remove(d.device.name)
}
}
}
updateUnifiedList()
}
@ -105,13 +124,48 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
(_imageLoader.drawable as Animatable?)?.start();
if (Settings.instance.casting.experimentalCasting) {
synchronized(ExpStateCasting.instance.devices) {
_devices.addAll(ExpStateCasting.instance.devices.values.map { it.device.name() })
}
_rememberedDevices.addAll(StateCasting.instance.getRememberedCastingDeviceNames())
} else {
synchronized(StateCasting.instance.devices) {
_devices.addAll(StateCasting.instance.devices.values.mapNotNull { it.name })
}
_rememberedDevices.addAll(StateCasting.instance.getRememberedCastingDeviceNames())
}
updateUnifiedList()
if (Settings.instance.casting.experimentalCasting) {
ExpStateCasting.instance.onDeviceAdded.subscribe(this) { d ->
_devices.add(d.name())
updateUnifiedList()
}
ExpStateCasting.instance.onDeviceChanged.subscribe(this) { d ->
val index = _unifiedDevices.indexOfFirst { it.castingDevice.name() == d.device.name() }
if (index != -1) {
val dev = GenericCastingDevice.Experimental(d)
_unifiedDevices[index] = DeviceAdapterEntry(dev, _unifiedDevices[index].isPinnedDevice, _unifiedDevices[index].isOnlineDevice)
_adapter.notifyItemChanged(index)
}
}
ExpStateCasting.instance.onDeviceRemoved.subscribe(this) { deviceName ->
_devices.remove(deviceName)
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)
@ -120,9 +174,13 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
}
StateCasting.instance.onDeviceChanged.subscribe(this) { d ->
val index = _unifiedDevices.indexOfFirst { it.castingDevice.name == d.name }
val index = _unifiedDevices.indexOfFirst { it.castingDevice.name() == d.name }
if (index != -1) {
_unifiedDevices[index] = DeviceAdapterEntry(d, _unifiedDevices[index].isPinnedDevice, _unifiedDevices[index].isOnlineDevice)
_unifiedDevices[index] = DeviceAdapterEntry(
GenericCastingDevice.Normal(d),
_unifiedDevices[index].isPinnedDevice,
_unifiedDevices[index].isOnlineDevice
)
_adapter.notifyItemChanged(index)
}
}
@ -140,6 +198,7 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
}
}
}
}
override fun dismiss() {
super.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<DeviceAdapterEntry> {
val onlineDevices = StateCasting.instance.devices.values.associateBy { it.name }
val rememberedDevices = StateCasting.instance.getRememberedCastingDevices().associateBy { it.name }
val unifiedList = mutableListOf<DeviceAdapterEntry>()
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 intersectionNames = _devices.intersect(_rememberedDevices)
for (name in intersectionNames) {
onlineDevices[name]?.let { unifiedList.add(DeviceAdapterEntry(it, true, true)) }
onlineDevices[name]?.let {
unifiedList.add(DeviceAdapterEntry(
GenericCastingDevice.Experimental(it), true, true)
)
}
}
val onlineOnlyNames = _devices - _rememberedDevices
for (name in onlineOnlyNames) {
onlineDevices[name]?.let { unifiedList.add(DeviceAdapterEntry(it, false, true)) }
onlineDevices[name]?.let {
unifiedList.add(DeviceAdapterEntry(
GenericCastingDevice.Experimental(it), false, true)
)
}
}
val rememberedOnlyNames = _rememberedDevices - _devices
for (name in rememberedOnlyNames) {
rememberedDevices[name]?.let { unifiedList.add(DeviceAdapterEntry(it, true, false)) }
rememberedDevices[name]?.let {
unifiedList.add(DeviceAdapterEntry(
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

View file

@ -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,19 +73,31 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
_buttonPlay = findViewById(R.id.button_play);
_buttonPlay.setOnClickListener {
if (Settings.instance.casting.experimentalCasting) {
ExpStateCasting.instance.activeDevice?.device?.resumePlayback()
} else {
StateCasting.instance.activeDevice?.resumeVideo()
}
}
_buttonPause = findViewById(R.id.button_pause);
_buttonPause.setOnClickListener {
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<VideoDetailFragment>()?.closeVideoDetails()
if (Settings.instance.casting.experimentalCasting) {
ExpStateCasting.instance.activeDevice?.device?.stopPlayback()
} else {
StateCasting.instance.activeDevice?.stopVideo()
}
}
_buttonNext = findViewById(R.id.button_next);
_buttonNext.setOnClickListener {
@ -90,7 +106,11 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
_buttonClose.setOnClickListener { dismiss(); };
_buttonDisconnect.setOnClickListener {
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
}
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 change volume.", e);
Logger.e(TAG, "Failed to seek.", e);
}
}
});
@ -113,6 +142,16 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
return@OnChangeListener
}
if (Settings.instance.casting.experimentalCasting) {
val activeDevice = ExpStateCasting.instance.activeDevice ?: return@OnChangeListener;
if (activeDevice.device.supportsFeature(DeviceFeature.SET_VOLUME)) {
try {
activeDevice.device.changeVolume(value.toDouble());
} catch (e: Throwable) {
Logger.e(TAG, "Failed to change volume.", e);
}
}
} else {
val activeDevice = StateCasting.instance.activeDevice ?: return@OnChangeListener;
if (activeDevice.canSetVolume) {
try {
@ -121,6 +160,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
Logger.e(TAG, "Failed to change volume.", e);
}
}
}
});
setLoading(false);
@ -131,6 +171,35 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
super.show();
Logger.i(TAG, "Dialog shown.");
if (Settings.instance.casting.experimentalCasting) {
ExpStateCasting.instance.onActiveDeviceVolumeChanged.remove(this)
ExpStateCasting.instance.onActiveDeviceVolumeChanged.subscribe {
_sliderVolume.value = it.toFloat().coerceAtLeast(0.0f).coerceAtMost(_sliderVolume.valueTo)
}
ExpStateCasting.instance.onActiveDeviceTimeChanged.remove(this)
StateCasting.instance.onActiveDeviceTimeChanged.subscribe {
_sliderPosition.value = it.toFloat().coerceAtLeast(0.0f).coerceAtMost(_sliderPosition.valueTo)
}
ExpStateCasting.instance.onActiveDeviceDurationChanged.remove(this)
ExpStateCasting.instance.onActiveDeviceDurationChanged.subscribe {
val dur = it.toFloat().coerceAtLeast(1.0f)
_sliderPosition.value = _sliderPosition.value.coerceAtLeast(0.0f).coerceAtMost(dur)
_sliderPosition.valueTo = dur
}
_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);
@ -156,6 +225,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { setLoading(connectionState != CastConnectionState.CONNECTED); };
updateDevice();
};
}
updateDevice();
}
@ -167,9 +237,64 @@ 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() {
if (Settings.instance.casting.experimentalCasting) {
val d = ExpStateCasting.instance.activeDevice ?: return;
when (d.device.castingProtocol()) {
ProtocolType.CHROMECAST -> {
_imageDevice.setImageResource(R.drawable.ic_chromecast);
_textType.text = "Chromecast";
}
ProtocolType.F_CAST -> {
_imageDevice.setImageResource(R.drawable.ic_fc);
_textType.text = "FCast";
}
}
_textName.text = d.device.name();
_sliderPosition.valueFrom = 0.0f;
_sliderVolume.valueFrom = 0.0f;
_sliderVolume.value = d.volume.toFloat().coerceAtLeast(0.0f).coerceAtMost(_sliderVolume.valueTo);
val dur = d.duration.toFloat().coerceAtLeast(1.0f)
_sliderPosition.value = d.time.toFloat().coerceAtLeast(0.0f).coerceAtMost(dur)
_sliderPosition.valueTo = dur
if (d.device.supportsFeature(DeviceFeature.SET_VOLUME)) {
_layoutVolumeAdjustable.visibility = View.VISIBLE;
_layoutVolumeFixed.visibility = View.GONE;
} else {
_layoutVolumeAdjustable.visibility = View.GONE;
_layoutVolumeFixed.visibility = View.VISIBLE;
}
val interactiveControls = listOf(
_sliderPosition,
_sliderVolume,
_buttonPrevious,
_buttonPlay,
_buttonPause,
_buttonStop,
_buttonNext
)
when (d.connectionState) {
com.futo.platformplayer.experimental_casting.CastConnectionState.CONNECTED -> {
enableControls(interactiveControls)
}
com.futo.platformplayer.experimental_casting.CastConnectionState.CONNECTING,
com.futo.platformplayer.experimental_casting.CastConnectionState.DISCONNECTED -> {
disableControls(interactiveControls)
}
}
} else {
val d = StateCasting.instance.activeDevice ?: return;
if (d is ChromecastCastingDevice) {
@ -180,7 +305,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
_textType.text = "AirPlay";
} else if (d is FCastCastingDevice) {
_imageDevice.setImageResource(R.drawable.ic_fc);
_textType.text = "FastCast";
_textType.text = "FCast";
}
_textName.text = d.name;
@ -220,6 +345,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
}
}
}
}
private fun enableControls(views: List<View>) {
views.forEach { enableControl(it) }

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.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,6 +666,50 @@ class VideoDetailView : ConstraintLayout {
}
if (!isInEditMode) {
if (Settings.instance.casting.experimentalCasting) {
ExpStateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { device, connectionState ->
if (_onPauseCalled) {
return@subscribe;
}
when (connectionState) {
com.futo.platformplayer.experimental_casting.CastConnectionState.CONNECTED -> {
loadCurrentVideo(lastPositionMilliseconds);
updatePillButtonVisibilities();
setCastEnabled(true);
}
com.futo.platformplayer.experimental_casting.CastConnectionState.DISCONNECTED -> {
loadCurrentVideo(lastPositionMilliseconds, playWhenReady = device.isPlaying);
updatePillButtonVisibilities();
setCastEnabled(false);
}
else -> {}
}
}
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;
@ -683,9 +729,7 @@ class VideoDetailView : ConstraintLayout {
}
else -> {}
}
};
updatePillButtonVisibilities();
}
StateCasting.instance.onActiveDevicePlayChanged.subscribe(this) {
val activeDevice = StateCasting.instance.activeDevice;
@ -708,6 +752,9 @@ class VideoDetailView : ConstraintLayout {
_timeBar.setDuration(video?.duration ?: 0);
}
};
}
updatePillButtonVisibilities();
_cast.onTimeJobTimeChanged_s.subscribe {
if (_isCasting) {
@ -1188,8 +1235,13 @@ class VideoDetailView : ConstraintLayout {
_onPauseCalled = true;
_taskLoadVideo.cancel();
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)
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 = {
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,6 +2484,16 @@ 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) {
if (Settings.instance.casting.experimentalCasting) {
val ad = ExpStateCasting.instance.activeDevice ?: return@subscribe
if (!ad.device.supportsFeature(DeviceFeature.SET_SPEED)) {
return@subscribe
}
qualityPlaybackSpeedTitle?.setTitle(context.getString(R.string.playback_rate) + " (${String.format(Locale.US, "%.2f", newPlaybackSpeed)})");
ad.device.changeSpeed(newPlaybackSpeed)
setSelected(playbackSpeedString);
} else {
val ad = StateCasting.instance.activeDevice ?: return@subscribe
if (!ad.canSetSpeed) {
return@subscribe
@ -2400,6 +2502,7 @@ class VideoDetailView : ConstraintLayout {
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,9 +2617,15 @@ class VideoDetailView : ConstraintLayout {
//Handlers
private fun handlePlay() {
Logger.i(TAG, "handlePlay")
if (Settings.instance.casting.experimentalCasting) {
if (!ExpStateCasting.instance.resumeVideo()) {
_player.play()
}
} else {
if (!StateCasting.instance.resumeVideo()) {
_player.play();
}
}
onShouldEnterPictureInPictureChanged.emit()
//TODO: This was needed because handleLowerVolume was done.
@ -2531,27 +2640,54 @@ class VideoDetailView : ConstraintLayout {
private fun handlePause() {
Logger.i(TAG, "handlePause")
if (Settings.instance.casting.experimentalCasting) {
if (!ExpStateCasting.instance.pauseVideo()) {
_player.pause()
}
} else {
if (!StateCasting.instance.pauseVideo()) {
_player.pause();
_player.pause()
}
}
onShouldEnterPictureInPictureChanged.emit()
}
private fun handleSeek(ms: Long) {
Logger.i(TAG, "handleSeek(ms=$ms)")
if (Settings.instance.casting.experimentalCasting) {
if (!ExpStateCasting.instance.videoSeekTo(ms.toDouble() / 1000.0)) {
_player.seekTo(ms)
}
} else {
if (!StateCasting.instance.videoSeekTo(ms.toDouble() / 1000.0)) {
_player.seekTo(ms);
_player.seekTo(ms)
}
}
}
private fun handleStop() {
Logger.i(TAG, "handleStop")
if (Settings.instance.casting.experimentalCasting) {
if (!ExpStateCasting.instance.stopVideo()) {
_player.stop()
}
} else {
if (!StateCasting.instance.stopVideo()) {
_player.stop();
_player.stop()
}
}
}
private fun handlePlayChanged(playing: Boolean) {
Logger.i(TAG, "handlePlayChanged(playing=$playing)")
if (Settings.instance.casting.experimentalCasting) {
val ad = ExpStateCasting.instance.activeDevice;
if (ad != null) {
_cast.setIsPlaying(playing);
} else {
StatePlayer.instance.updateMediaSession( null);
StatePlayer.instance.updateMediaSessionPlaybackState(_player.exoPlayer?.getPlaybackStateCompat() ?: PlaybackStateCompat.STATE_NONE, _player.exoPlayer?.player?.currentPosition ?: 0);
}
} else {
val ad = StateCasting.instance.activeDevice;
if (ad != null) {
_cast.setIsPlaying(playing);
@ -2559,6 +2695,7 @@ class VideoDetailView : ConstraintLayout {
StatePlayer.instance.updateMediaSession( null);
StatePlayer.instance.updateMediaSessionPlaybackState(_player.exoPlayer?.getPlaybackStateCompat() ?: PlaybackStateCompat.STATE_NONE, _player.exoPlayer?.player?.currentPosition ?: 0);
}
}
if(playing) {
_minimize_controls_pause.visibility = View.VISIBLE;
@ -2595,11 +2732,27 @@ class VideoDetailView : ConstraintLayout {
fragment.lifecycleScope.launch(Dispatchers.Main) {
try {
if (Settings.instance.casting.experimentalCasting) {
val d = ExpStateCasting.instance.activeDevice;
if (d != null && d.connectionState == com.futo.platformplayer.experimental_casting.CastConnectionState.CONNECTED)
castIfAvailable(
context.contentResolver,
video,
videoSource,
_lastAudioSource,
_lastSubtitleSource,
(d.expectedCurrentTime * 1000.0).toLong(),
d.speed
);
else if(!_player.swapSources(videoSource, _lastAudioSource, true, true, true))
_player.hideControls(false); //TODO: Disable player?
} else {
val d = StateCasting.instance.activeDevice;
if (d != null && d.connectionState == CastConnectionState.CONNECTED)
castIfAvailable(context.contentResolver, video, videoSource, _lastAudioSource, _lastSubtitleSource, (d.expectedCurrentTime * 1000.0).toLong(), d.speed);
else if(!_player.swapSources(videoSource, _lastAudioSource, true, true, true))
_player.hideControls(false); //TODO: Disable player?
}
} 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)
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,12 +2815,37 @@ 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);
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)
}

View file

@ -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 {
@ -16,3 +17,18 @@ class CastingDeviceInfo {
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.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<DeviceViewHolder> {
private val _devices: List<DeviceAdapterEntry>;
var onPin = Event1<CastingDevice>();
var onConnect = Event1<CastingDevice>();
var onPin = Event1<GenericCastingDevice>();
var onConnect = Event1<GenericCastingDevice>();
constructor(devices: List<DeviceAdapterEntry>) : super() {
_devices = devices;

View file

@ -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<CastingDevice>();
val onConnect = Event1<CastingDevice>();
var onPin = Event1<GenericCastingDevice>();
val onConnect = Event1<GenericCastingDevice>();
constructor(view: View) : super(view) {
_root = view.findViewById(R.id.layout_root);
@ -56,9 +55,12 @@ class DeviceViewHolder : ViewHolder {
val connect = {
device?.let { dev ->
if (dev.isReady) {
when (dev) {
is GenericCastingDevice.Normal -> {
if (dev.device.isReady) {
// NOTE: we assume normal casting is used
StateCasting.instance.activeDevice?.stopCasting()
StateCasting.instance.connectDevice(dev)
StateCasting.instance.connectDevice(dev.device)
onConnect.emit(dev)
} else {
try {
@ -68,6 +70,22 @@ class DeviceViewHolder : ViewHolder {
}
}
}
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
}
}
}
}
}
}
_textName.setOnClickListener { connect() };
@ -80,22 +98,26 @@ class DeviceViewHolder : ViewHolder {
}
}
fun bind(d: CastingDevice, isOnlineDevice: Boolean, isPinnedDevice: Boolean) {
if (d is ChromecastCastingDevice) {
// fun bind(d: CastingDevice, isOnlineDevice: Boolean, isPinnedDevice: Boolean) {
fun bind(d: GenericCastingDevice, isOnlineDevice: Boolean, isPinnedDevice: Boolean) {
when (d) {
is GenericCastingDevice.Normal -> {
if (d.device is ChromecastCastingDevice) {
_imageDevice.setImageResource(R.drawable.ic_chromecast);
_textType.text = "Chromecast";
} else if (d is AirPlayCastingDevice) {
} else if (d.device is AirPlayCastingDevice) {
_imageDevice.setImageResource(R.drawable.ic_airplay);
_textType.text = "AirPlay";
} else if (d is FCastCastingDevice) {
} else if (d.device is FCastCastingDevice) {
_imageDevice.setImageResource(R.drawable.ic_fc);
_textType.text = "FCast";
}
_textName.text = d.name;
_imageOnline.visibility = if (isOnlineDevice && d.isReady) View.VISIBLE else View.GONE
_textName.text = d.device.name;
_imageOnline.visibility = if (isOnlineDevice && d.device.isReady) View.VISIBLE else View.GONE
if (!d.isReady) {
if (!d.device.isReady) {
_imageLoader.visibility = View.GONE;
_textNotReady.visibility = View.VISIBLE;
_imagePin.visibility = View.GONE;
@ -114,7 +136,7 @@ class DeviceViewHolder : ViewHolder {
_imagePin.visibility = View.VISIBLE;
}
} else {
if (d.isReady) {
if (d.device.isReady) {
_imageLoader.visibility = View.GONE;
_textNotReady.visibility = View.GONE;
_imagePin.visibility = View.VISIBLE;
@ -136,4 +158,62 @@ class DeviceViewHolder : ViewHolder {
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";
}
}
_textName.text = d.handle.device.name();
_imageOnline.visibility = if (isOnlineDevice && d.handle.device.isReady()) View.VISIBLE else View.GONE
if (!d.handle.device.isReady()) {
_imageLoader.visibility = View.GONE;
_textNotReady.visibility = View.VISIBLE;
_imagePin.visibility = View.GONE;
} 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;
}
}
}
}

View file

@ -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<Pair<String, Any>>();
@ -27,9 +28,15 @@ class CastButton : androidx.appcompat.widget.AppCompatImageButton {
visibility = View.GONE;
}
if (Settings.instance.casting.experimentalCasting) {
ExpStateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, _ ->
updateCastState();
};
} else {
StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, _ ->
updateCastState();
};
}
updateCastState();
}
@ -37,6 +44,23 @@ class CastButton : androidx.appcompat.widget.AppCompatImageButton {
private fun updateCastState() {
val c = context ?: return;
if (Settings.instance.casting.experimentalCasting) {
val d = ExpStateCasting.instance.activeDevice;
val activeColor = ContextCompat.getColor(c, R.color.colorPrimary);
val connectingColor = ContextCompat.getColor(c, R.color.gray_c3);
val inactiveColor = ContextCompat.getColor(c, R.color.white);
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 {
val d = StateCasting.instance.activeDevice;
val activeColor = ContextCompat.getColor(c, R.color.colorPrimary);
@ -53,9 +77,11 @@ class CastButton : androidx.appcompat.widget.AppCompatImageButton {
setColorFilter(inactiveColor);
}
}
}
fun cleanup() {
setOnClickListener(null);
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.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 {
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)
if (d.canSetSpeed) {
d.changeSpeed(Settings.instance.playback.getHoldPlaybackSpeed())
}
d.resumeVideo()
}
}
_gestureControlView.onSpeedHoldEnd.subscribe {
if (Settings.instance.casting.experimentalCasting) {
val d = ExpStateCasting.instance.activeDevice ?: return@subscribe;
if (!_speedHoldWasPlaying) {
d.device.resumePlayback()
}
d.device.changeSpeed(_speedHoldPrevRate)
} else {
val d = StateCasting.instance.activeDevice ?: return@subscribe;
if (!_speedHoldWasPlaying) d.pauseVideo()
if (!_speedHoldWasPlaying) {
d.pauseVideo()
}
d.changeSpeed(_speedHoldPrevRate)
}
}
_gestureControlView.onSeek.subscribe {
if (Settings.instance.casting.experimentalCasting) {
val d = ExpStateCasting.instance.activeDevice ?: return@subscribe;
ExpStateCasting.instance.videoSeekTo(d.expectedCurrentTime + it / 1000);
} else {
val d = StateCasting.instance.activeDevice ?: return@subscribe;
StateCasting.instance.videoSeekTo(d.expectedCurrentTime + it / 1000);
}
};
_buttonLoop.setOnClickListener {
@ -122,22 +150,46 @@ class CastView : ConstraintLayout {
_timeBar.addListener(object : TimeBar.OnScrubListener {
override fun onScrubStart(timeBar: TimeBar, position: Long) {
if (Settings.instance.casting.experimentalCasting) {
ExpStateCasting.instance.videoSeekTo(position.toDouble());
} else {
StateCasting.instance.videoSeekTo(position.toDouble());
}
}
override fun onScrubMove(timeBar: TimeBar, position: Long) {
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) {
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,6 +272,8 @@ class CastView : ConstraintLayout {
stopTimeJob()
if(isPlaying) {
// NOTE: the experimental implementation polls automatically
if (!Settings.instance.casting.experimentalCasting) {
val d = StateCasting.instance.activeDevice;
if (d is AirPlayCastingDevice || d is ChromecastCastingDevice) {
_updateTimeJob = _scope.launch {
@ -236,6 +290,7 @@ class CastView : ConstraintLayout {
}
}
}
}
if (!_inPictureInPicture) {
_buttonPause.visibility = View.VISIBLE;
@ -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,6 +370,14 @@ class CastView : ConstraintLayout {
}
private fun getPlaybackStateCompat(): Int {
if (Settings.instance.casting.experimentalCasting) {
val d = ExpStateCasting.instance.activeDevice ?: return PlaybackState.STATE_NONE;
return when(d.isPlaying) {
true -> PlaybackStateCompat.STATE_PLAYING;
else -> PlaybackStateCompat.STATE_PAUSED;
}
} else {
val d = StateCasting.instance.activeDevice ?: return PlaybackState.STATE_NONE;
return when(d.isPlaying) {
@ -318,6 +385,7 @@ class CastView : ConstraintLayout {
else -> PlaybackStateCompat.STATE_PAUSED;
}
}
}
fun setLoading(isLoading: Boolean) {
if (isLoading) {

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_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="experimental_cast">Experimental</string>
<string name="experimental_cast_description">Use experimental casting backend (requires restart)</string>
<string name="discover">Discover</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>