Merge branch 'marcus/fcast-casting-sdk' into 'master'

Experimental casting backend

See merge request videostreaming/grayjay!145
This commit is contained in:
Marcus Hanestad 2025-09-10 07:34:46 +00:00
commit 853e140708
22 changed files with 1600 additions and 760 deletions

View file

@ -231,4 +231,10 @@ 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('org.futo.gitlab.videostreaming.fcast-sdk-jitpack:sender-sdk-minimal:0.3.1') {
// Polycentricandroid includes this
exclude group: 'net.java.dev.jna'
}
} }

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

@ -1046,7 +1046,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
Logger.i(TAG, "handleFCast"); Logger.i(TAG, "handleFCast");
try { try {
StateCasting.instance.handleUrl(this, url) StateCasting.instance.handleUrl(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

@ -15,7 +15,7 @@ import kotlinx.coroutines.launch
import java.net.InetAddress import java.net.InetAddress
import java.util.UUID import java.util.UUID
class AirPlayCastingDevice : CastingDevice { class AirPlayCastingDevice : OldCastingDevice {
//See for more info: https://nto.github.io/AirPlay //See for more info: https://nto.github.io/AirPlay
override val protocol: CastProtocolType get() = CastProtocolType.AIRPLAY; override val protocol: CastProtocolType get() = CastProtocolType.AIRPLAY;

View file

@ -2,147 +2,78 @@ package com.futo.platformplayer.casting
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.models.CastingDeviceInfo import com.futo.platformplayer.models.CastingDeviceInfo
import kotlinx.serialization.KSerializer import org.fcast.sender_sdk.Metadata
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 java.net.InetAddress import java.net.InetAddress
enum class CastConnectionState {
DISCONNECTED,
CONNECTING,
CONNECTED
}
@Serializable(with = CastProtocolType.CastProtocolTypeSerializer::class)
enum class CastProtocolType {
CHROMECAST,
AIRPLAY,
FCAST;
object CastProtocolTypeSerializer : KSerializer<CastProtocolType> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("CastProtocolType", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: CastProtocolType) {
encoder.encodeString(value.name)
}
override fun deserialize(decoder: Decoder): CastProtocolType {
val name = decoder.decodeString()
return when (name) {
"FASTCAST" -> FCAST // Handle the renamed case
else -> CastProtocolType.valueOf(name)
}
}
}
}
abstract class CastingDevice { abstract class CastingDevice {
abstract val protocol: CastProtocolType; abstract val isReady: Boolean
abstract val isReady: Boolean; abstract val usedRemoteAddress: InetAddress?
abstract var usedRemoteAddress: InetAddress?; abstract val localAddress: InetAddress?
abstract var localAddress: InetAddress?; abstract val name: String?
abstract val canSetVolume: Boolean; abstract val onConnectionStateChanged: Event1<CastConnectionState>
abstract val canSetSpeed: Boolean; abstract val onPlayChanged: Event1<Boolean>
abstract val onTimeChanged: Event1<Double>
abstract val onDurationChanged: Event1<Double>
abstract val onVolumeChanged: Event1<Double>
abstract val onSpeedChanged: Event1<Double>
abstract var connectionState: CastConnectionState
abstract val protocolType: CastProtocolType
abstract var isPlaying: Boolean
abstract val expectedCurrentTime: Double
abstract var speed: Double
abstract var time: Double
abstract var duration: Double
abstract var volume: Double
abstract fun canSetVolume(): Boolean
abstract fun canSetSpeed(): Boolean
var name: String? = null; @Throws
var isPlaying: Boolean = false abstract fun resumePlayback()
set(value) {
val changed = value != field;
field = value;
if (changed) {
onPlayChanged.emit(value);
}
};
private var lastTimeChangeTime_ms: Long = 0 @Throws
var time: Double = 0.0 abstract fun pausePlayback()
private set
protected fun setTime(value: Double, changeTime_ms: Long = System.currentTimeMillis()) { @Throws
if (changeTime_ms > lastTimeChangeTime_ms && value != time) { abstract fun stopPlayback()
time = value
lastTimeChangeTime_ms = changeTime_ms
onTimeChanged.emit(value)
}
}
private var lastDurationChangeTime_ms: Long = 0 @Throws
var duration: Double = 0.0 abstract fun seekTo(timeSeconds: Double)
private set
protected fun setDuration(value: Double, changeTime_ms: Long = System.currentTimeMillis()) { @Throws
if (changeTime_ms > lastDurationChangeTime_ms && value != duration) { abstract fun changeVolume(timeSeconds: Double)
duration = value
lastDurationChangeTime_ms = changeTime_ms
onDurationChanged.emit(value)
}
}
private var lastVolumeChangeTime_ms: Long = 0 @Throws
var volume: Double = 1.0 abstract fun changeSpeed(speed: Double)
private set
protected fun setVolume(value: Double, changeTime_ms: Long = System.currentTimeMillis()) { @Throws
if (changeTime_ms > lastVolumeChangeTime_ms && value != volume) { abstract fun connect()
volume = value
lastVolumeChangeTime_ms = changeTime_ms
onVolumeChanged.emit(value)
}
}
private var lastSpeedChangeTime_ms: Long = 0 @Throws
var speed: Double = 1.0 abstract fun disconnect()
private set abstract fun getDeviceInfo(): CastingDeviceInfo
abstract fun getAddresses(): List<InetAddress>
protected fun setSpeed(value: Double, changeTime_ms: Long = System.currentTimeMillis()) { @Throws
if (changeTime_ms > lastSpeedChangeTime_ms && value != speed) { abstract fun loadVideo(
speed = value streamType: String,
lastSpeedChangeTime_ms = changeTime_ms contentType: String,
onSpeedChanged.emit(value) contentId: String,
} resumePosition: Double,
} duration: Double,
speed: Double?,
metadata: Metadata?
)
val expectedCurrentTime: Double @Throws
get() { abstract fun loadContent(
val diff = if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0; contentType: String,
return time + diff; content: String,
}; resumePosition: Double,
var connectionState: CastConnectionState = CastConnectionState.DISCONNECTED duration: Double,
set(value) { speed: Double?,
val changed = value != field; metadata: Metadata?
field = value; )
if (changed) { abstract fun ensureThreadStarted()
onConnectionStateChanged.emit(value);
}
};
var onConnectionStateChanged = Event1<CastConnectionState>();
var onPlayChanged = Event1<Boolean>();
var onTimeChanged = Event1<Double>();
var onDurationChanged = Event1<Double>();
var onVolumeChanged = Event1<Double>();
var onSpeedChanged = Event1<Double>();
abstract fun stopCasting();
abstract fun seekVideo(timeSeconds: Double);
abstract fun stopVideo();
abstract fun pauseVideo();
abstract fun resumeVideo();
abstract fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double, speed: Double?);
abstract fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?);
open fun changeVolume(volume: Double) { throw NotImplementedError() }
open fun changeSpeed(speed: Double) { throw NotImplementedError() }
abstract fun start();
abstract fun stop();
abstract fun getDeviceInfo(): CastingDeviceInfo;
abstract fun getAddresses(): List<InetAddress>;
} }

View file

@ -27,7 +27,7 @@ import javax.net.ssl.SSLSocket
import javax.net.ssl.TrustManager import javax.net.ssl.TrustManager
import javax.net.ssl.X509TrustManager import javax.net.ssl.X509TrustManager
class ChromecastCastingDevice : CastingDevice { class ChromecastCastingDevice : OldCastingDevice {
//See for more info: https://developers.google.com/cast/docs/media/messages //See for more info: https://developers.google.com/cast/docs/media/messages
override val protocol: CastProtocolType get() = CastProtocolType.CHROMECAST; override val protocol: CastProtocolType get() = CastProtocolType.CHROMECAST;

View file

@ -0,0 +1,271 @@
package com.futo.platformplayer.casting
import android.os.Build
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo
import org.fcast.sender_sdk.ApplicationInfo
import org.fcast.sender_sdk.GenericKeyEvent
import org.fcast.sender_sdk.GenericMediaEvent
import org.fcast.sender_sdk.PlaybackState
import org.fcast.sender_sdk.Source
import java.net.InetAddress
import org.fcast.sender_sdk.CastingDevice as RsCastingDevice;
import org.fcast.sender_sdk.DeviceEventHandler as RsDeviceEventHandler;
import org.fcast.sender_sdk.DeviceConnectionState
import org.fcast.sender_sdk.DeviceFeature
import org.fcast.sender_sdk.IpAddr
import org.fcast.sender_sdk.LoadRequest
import org.fcast.sender_sdk.Metadata
import org.fcast.sender_sdk.ProtocolType
import org.fcast.sender_sdk.urlFormatIpAddr
import java.net.Inet4Address
import java.net.Inet6Address
private fun ipAddrToInetAddress(addr: IpAddr): InetAddress = when (addr) {
is IpAddr.V4 -> Inet4Address.getByAddress(
byteArrayOf(
addr.o1.toByte(),
addr.o2.toByte(),
addr.o3.toByte(),
addr.o4.toByte()
)
)
is IpAddr.V6 -> Inet6Address.getByAddress(
byteArrayOf(
addr.o1.toByte(),
addr.o2.toByte(),
addr.o3.toByte(),
addr.o4.toByte(),
addr.o5.toByte(),
addr.o6.toByte(),
addr.o7.toByte(),
addr.o8.toByte(),
addr.o9.toByte(),
addr.o10.toByte(),
addr.o11.toByte(),
addr.o12.toByte(),
addr.o13.toByte(),
addr.o14.toByte(),
addr.o15.toByte(),
addr.o16.toByte()
)
)
}
class ExpCastingDevice(val device: RsCastingDevice) : CastingDevice() {
class EventHandler : RsDeviceEventHandler {
var onConnectionStateChanged = Event1<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
}
override fun playbackError(message: String) {
Logger.e(TAG, "Playback error: $message")
}
}
val eventHandler = EventHandler()
override val isReady: Boolean
get() = device.isReady()
override val name: String
get() = device.name()
override var usedRemoteAddress: InetAddress? = null
override var localAddress: InetAddress? = null
override fun canSetVolume(): Boolean = device.supportsFeature(DeviceFeature.SET_VOLUME)
override fun canSetSpeed(): Boolean = device.supportsFeature(DeviceFeature.SET_SPEED)
override val onConnectionStateChanged =
Event1<CastConnectionState>()
override val onPlayChanged: Event1<Boolean>
get() = eventHandler.onPlayChanged
override val onTimeChanged: Event1<Double>
get() = eventHandler.onTimeChanged
override val onDurationChanged: Event1<Double>
get() = eventHandler.onDurationChanged
override val onVolumeChanged: Event1<Double>
get() = eventHandler.onVolumeChanged
override val onSpeedChanged: Event1<Double>
get() = eventHandler.onSpeedChanged
override fun resumePlayback() = device.resumePlayback()
override fun pausePlayback() = device.pausePlayback()
override fun stopPlayback() = device.stopPlayback()
override fun seekTo(timeSeconds: Double) = device.seek(timeSeconds)
override fun changeVolume(newVolume: Double) {
device.changeVolume(newVolume)
volume = newVolume
}
override fun changeSpeed(speed: Double) = device.changeSpeed(speed)
override fun connect() = device.connect(
ApplicationInfo(
"Grayjay Android",
"${BuildConfig.VERSION_NAME}-${BuildConfig.FLAVOR}",
"${Build.MANUFACTURER} ${Build.MODEL}"
),
eventHandler,
1000.toULong()
)
override fun disconnect() = device.disconnect()
override fun getDeviceInfo(): CastingDeviceInfo {
val info = device.getDeviceInfo()
return CastingDeviceInfo(
info.name,
when (info.protocol) {
ProtocolType.CHROMECAST -> CastProtocolType.CHROMECAST
ProtocolType.F_CAST -> CastProtocolType.FCAST
},
addresses = info.addresses.map { urlFormatIpAddr(it) }.toTypedArray(),
port = info.port.toInt(),
)
}
override fun getAddresses(): List<InetAddress> = device.getAddresses().map {
ipAddrToInetAddress(it)
}
override fun loadVideo(
streamType: String,
contentType: String,
contentId: String,
resumePosition: Double,
duration: Double,
speed: Double?,
metadata: Metadata?
) = device.load(
LoadRequest.Video(
contentType = contentType,
url = contentId,
resumePosition = resumePosition,
speed = speed,
volume = volume,
metadata = metadata
)
)
override fun loadContent(
contentType: String,
content: String,
resumePosition: Double,
duration: Double,
speed: Double?,
metadata: Metadata?
) = device.load(
LoadRequest.Content(
contentType = contentType,
content = content,
resumePosition = resumePosition,
speed = speed,
volume = volume,
metadata = metadata,
)
)
override var connectionState = CastConnectionState.DISCONNECTED
override val protocolType: CastProtocolType
get() = when (device.castingProtocol()) {
ProtocolType.CHROMECAST -> CastProtocolType.CHROMECAST
ProtocolType.F_CAST -> CastProtocolType.FCAST
}
override var volume: Double = 1.0
override var duration: Double = 0.0
private var lastTimeChangeTime_ms: Long = 0
override var time: Double = 0.0
override var speed: Double = 0.0
override var isPlaying: Boolean = false
override val expectedCurrentTime: Double
get() {
val diff =
if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0;
return time + diff
}
init {
eventHandler.onConnectionStateChanged.subscribe { newState ->
when (newState) {
is DeviceConnectionState.Connected -> {
usedRemoteAddress = ipAddrToInetAddress(newState.usedRemoteAddr)
localAddress = ipAddrToInetAddress(newState.localAddr)
connectionState = CastConnectionState.CONNECTED
onConnectionStateChanged.emit(CastConnectionState.CONNECTED)
}
DeviceConnectionState.Connecting, DeviceConnectionState.Reconnecting -> {
connectionState = CastConnectionState.CONNECTING
onConnectionStateChanged.emit(CastConnectionState.CONNECTING)
}
DeviceConnectionState.Disconnected -> {
connectionState = CastConnectionState.CONNECTING
onConnectionStateChanged.emit(CastConnectionState.DISCONNECTED)
}
}
if (newState == DeviceConnectionState.Disconnected) {
try {
Logger.i(TAG, "Stopping device")
device.disconnect()
} catch (e: Throwable) {
Logger.e(TAG, "Failed to stop device: $e")
}
}
}
eventHandler.onPlayChanged.subscribe { isPlaying = it }
eventHandler.onTimeChanged.subscribe {
lastTimeChangeTime_ms = System.currentTimeMillis()
time = it
}
eventHandler.onDurationChanged.subscribe { duration = it }
eventHandler.onVolumeChanged.subscribe { volume = it }
eventHandler.onSpeedChanged.subscribe { speed = it }
}
override fun ensureThreadStarted() {}
companion object {
private val TAG = "ExperimentalCastingDevice"
}
}

View file

@ -0,0 +1,174 @@
package com.futo.platformplayer.casting
import android.content.Context
import android.util.Log
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import org.fcast.sender_sdk.DeviceInfo as RsDeviceInfo
import org.fcast.sender_sdk.ProtocolType
import org.fcast.sender_sdk.CastContext
import org.fcast.sender_sdk.NsdDeviceDiscoverer
class ExpStateCasting : StateCasting() {
private val _context = CastContext()
var _deviceDiscoverer: NsdDeviceDiscoverer? = null
class DiscoveryEventHandler(
private val onDeviceAdded: (RsDeviceInfo) -> Unit,
private val onDeviceRemoved: (String) -> Unit,
private val onDeviceUpdated: (RsDeviceInfo) -> Unit,
) : org.fcast.sender_sdk.DeviceDiscovererEventHandler {
override fun deviceAvailable(deviceInfo: RsDeviceInfo) {
onDeviceAdded(deviceInfo)
}
override fun deviceChanged(deviceInfo: RsDeviceInfo) {
onDeviceUpdated(deviceInfo)
}
override fun deviceRemoved(deviceName: String) {
onDeviceRemoved(deviceName)
}
}
init {
if (BuildConfig.DEBUG) {
org.fcast.sender_sdk.initLogger(org.fcast.sender_sdk.LogLevelFilter.DEBUG)
}
}
override fun handleUrl(url: String) {
try {
val foundDeviceInfo = org.fcast.sender_sdk.deviceInfoFromUrl(url)!!
val foundDevice = _context.createDeviceFromInfo(foundDeviceInfo)
connectDevice(ExpCastingDevice(foundDevice))
} catch (e: Throwable) {
Logger.e(TAG, "Failed to handle URL: $e")
}
}
override fun onStop() {
val ad = activeDevice ?: return
_resumeCastingDevice = ad.getDeviceInfo()
Log.i(TAG, "_resumeCastingDevice set to '${ad.name}'")
Logger.i(TAG, "Stopping active device because of onStop.")
try {
ad.disconnect()
} catch (e: Throwable) {
Logger.e(TAG, "Failed to disconnect from device: $e")
}
}
@Synchronized
override fun start(context: Context) {
if (_started)
return
_started = true
Log.i(TAG, "_resumeCastingDevice set null start")
_resumeCastingDevice = null
Logger.i(TAG, "CastingService starting...")
_castServer.start()
enableDeveloper(true)
Logger.i(TAG, "CastingService started.")
_deviceDiscoverer = NsdDeviceDiscoverer(
context,
DiscoveryEventHandler(
{ deviceInfo -> // Added
Logger.i(TAG, "Device added: ${deviceInfo.name}")
val device = _context.createDeviceFromInfo(deviceInfo)
val deviceHandle = ExpCastingDevice(device)
devices[deviceHandle.device.name()] = deviceHandle
invokeInMainScopeIfRequired {
onDeviceAdded.emit(deviceHandle)
}
},
{ deviceName -> // Removed
invokeInMainScopeIfRequired {
if (devices.containsKey(deviceName)) {
val device = devices.remove(deviceName)
if (device != null) {
onDeviceRemoved.emit(device)
}
}
}
},
{ deviceInfo -> // Updated
Logger.i(TAG, "Device updated: $deviceInfo")
val handle = devices[deviceInfo.name]
if (handle != null && handle is ExpCastingDevice) {
handle.device.setPort(deviceInfo.port)
handle.device.setAddresses(deviceInfo.addresses)
invokeInMainScopeIfRequired {
onDeviceChanged.emit(handle)
}
}
},
)
)
}
@Synchronized
override fun stop() {
if (!_started) {
return
}
_started = false
Logger.i(TAG, "CastingService stopping.")
_scopeIO.cancel()
_scopeMain.cancel()
Logger.i(TAG, "Stopping active device because StateCasting is being stopped.")
val d = activeDevice
activeDevice = null
try {
d?.disconnect()
} catch (e: Throwable) {
Logger.e(TAG, "Failed to disconnect device: $e")
}
_castServer.stop()
_castServer.removeAllHandlers()
Logger.i(TAG, "CastingService stopped.")
_deviceDiscoverer = null
}
override fun startUpdateTimeJob(
onTimeJobTimeChanged_s: Event1<Long>,
setTime: (Long) -> Unit
): Job? = null
override fun deviceFromInfo(deviceInfo: CastingDeviceInfo): ExpCastingDevice {
val rsAddrs =
deviceInfo.addresses.map { org.fcast.sender_sdk.tryIpAddrFromStr(it) } // Throws!
val rsDeviceInfo = RsDeviceInfo(
name = deviceInfo.name,
protocol = when (deviceInfo.type) {
com.futo.platformplayer.casting.CastProtocolType.CHROMECAST -> ProtocolType.CHROMECAST
com.futo.platformplayer.casting.CastProtocolType.FCAST -> ProtocolType.F_CAST
else -> throw IllegalArgumentException()
},
addresses = rsAddrs,
port = deviceInfo.port.toUShort(),
)
return ExpCastingDevice(_context.createDeviceFromInfo(rsDeviceInfo))
}
companion object {
private val TAG = "ExperimentalStateCasting"
}
}

View file

@ -3,7 +3,6 @@ package com.futo.platformplayer.casting
import android.os.Looper import android.os.Looper
import android.util.Base64 import android.util.Base64
import android.util.Log import android.util.Log
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.casting.models.FCastDecryptedMessage import com.futo.platformplayer.casting.models.FCastDecryptedMessage
import com.futo.platformplayer.casting.models.FCastEncryptedMessage import com.futo.platformplayer.casting.models.FCastEncryptedMessage
@ -25,7 +24,6 @@ import com.futo.platformplayer.toInetAddress
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
@ -34,7 +32,6 @@ import java.io.IOException
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import java.math.BigInteger import java.math.BigInteger
import java.net.Inet4Address
import java.net.InetAddress import java.net.InetAddress
import java.net.InetSocketAddress import java.net.InetSocketAddress
import java.net.Socket import java.net.Socket
@ -72,7 +69,7 @@ enum class Opcode(val value: Byte) {
} }
} }
class FCastCastingDevice : CastingDevice { class FCastCastingDevice : OldCastingDevice {
//See for more info: TODO //See for more info: TODO
override val protocol: CastProtocolType get() = CastProtocolType.FCAST; override val protocol: CastProtocolType get() = CastProtocolType.FCAST;

View file

@ -0,0 +1,243 @@
package com.futo.platformplayer.casting
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.models.CastingDeviceInfo
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import org.fcast.sender_sdk.DeviceConnectionState
import org.fcast.sender_sdk.Metadata
import java.net.InetAddress
enum class CastConnectionState {
DISCONNECTED,
CONNECTING,
CONNECTED
}
@Serializable(with = CastProtocolType.CastProtocolTypeSerializer::class)
enum class CastProtocolType {
CHROMECAST,
AIRPLAY,
FCAST;
object CastProtocolTypeSerializer : KSerializer<CastProtocolType> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("CastProtocolType", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: CastProtocolType) {
encoder.encodeString(value.name)
}
override fun deserialize(decoder: Decoder): CastProtocolType {
val name = decoder.decodeString()
return when (name) {
"FASTCAST" -> FCAST // Handle the renamed case
else -> CastProtocolType.valueOf(name)
}
}
}
}
abstract class OldCastingDevice {
abstract val protocol: CastProtocolType;
abstract val isReady: Boolean;
abstract var usedRemoteAddress: InetAddress?;
abstract var localAddress: InetAddress?;
abstract val canSetVolume: Boolean;
abstract val canSetSpeed: Boolean;
var name: String? = null;
var isPlaying: Boolean = false
set(value) {
val changed = value != field;
field = value;
if (changed) {
onPlayChanged.emit(value);
}
};
private var lastTimeChangeTime_ms: Long = 0
var time: Double = 0.0
private set
protected fun setTime(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
if (changeTime_ms > lastTimeChangeTime_ms && value != time) {
time = value
lastTimeChangeTime_ms = changeTime_ms
onTimeChanged.emit(value)
}
}
private var lastDurationChangeTime_ms: Long = 0
var duration: Double = 0.0
private set
protected fun setDuration(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
if (changeTime_ms > lastDurationChangeTime_ms && value != duration) {
duration = value
lastDurationChangeTime_ms = changeTime_ms
onDurationChanged.emit(value)
}
}
private var lastVolumeChangeTime_ms: Long = 0
var volume: Double = 1.0
private set
protected fun setVolume(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
if (changeTime_ms > lastVolumeChangeTime_ms && value != volume) {
volume = value
lastVolumeChangeTime_ms = changeTime_ms
onVolumeChanged.emit(value)
}
}
private var lastSpeedChangeTime_ms: Long = 0
var speed: Double = 1.0
private set
protected fun setSpeed(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
if (changeTime_ms > lastSpeedChangeTime_ms && value != speed) {
speed = value
lastSpeedChangeTime_ms = changeTime_ms
onSpeedChanged.emit(value)
}
}
val expectedCurrentTime: Double
get() {
val diff =
if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0;
return time + diff;
};
var connectionState: CastConnectionState = CastConnectionState.DISCONNECTED
set(value) {
val changed = value != field;
field = value;
if (changed) {
onConnectionStateChanged.emit(value);
}
};
var onConnectionStateChanged = Event1<CastConnectionState>();
var onPlayChanged = Event1<Boolean>();
var onTimeChanged = Event1<Double>();
var onDurationChanged = Event1<Double>();
var onVolumeChanged = Event1<Double>();
var onSpeedChanged = Event1<Double>();
abstract fun stopCasting();
abstract fun seekVideo(timeSeconds: Double);
abstract fun stopVideo();
abstract fun pauseVideo();
abstract fun resumeVideo();
abstract fun loadVideo(
streamType: String,
contentType: String,
contentId: String,
resumePosition: Double,
duration: Double,
speed: Double?
);
abstract fun loadContent(
contentType: String,
content: String,
resumePosition: Double,
duration: Double,
speed: Double?
);
open fun changeVolume(volume: Double) {
throw NotImplementedError()
}
open fun changeSpeed(speed: Double) {
throw NotImplementedError()
}
abstract fun start();
abstract fun stop();
abstract fun getDeviceInfo(): CastingDeviceInfo;
abstract fun getAddresses(): List<InetAddress>;
}
class OldCastingDeviceWrapper(val inner: OldCastingDevice) : CastingDevice() {
override val isReady: Boolean get() = inner.isReady
override val usedRemoteAddress: InetAddress? get() = inner.usedRemoteAddress
override val localAddress: InetAddress? get() = inner.localAddress
override val name: String? get() = inner.name
override val onConnectionStateChanged: Event1<CastConnectionState> get() = inner.onConnectionStateChanged
override val onPlayChanged: Event1<Boolean> get() = inner.onPlayChanged
override val onTimeChanged: Event1<Double> get() = inner.onTimeChanged
override val onDurationChanged: Event1<Double> get() = inner.onDurationChanged
override val onVolumeChanged: Event1<Double> get() = inner.onVolumeChanged
override val onSpeedChanged: Event1<Double> get() = inner.onSpeedChanged
override var connectionState: CastConnectionState
get() = inner.connectionState
set(_) = Unit
override val protocolType: CastProtocolType get() = inner.protocol
override var isPlaying: Boolean
get() = inner.isPlaying
set(_) = Unit
override val expectedCurrentTime: Double
get() = inner.expectedCurrentTime
override var speed: Double
get() = inner.speed
set(_) = Unit
override var time: Double
get() = inner.time
set(_) = Unit
override var duration: Double
get() = inner.duration
set(_) = Unit
override var volume: Double
get() = inner.volume
set(_) = Unit
override fun canSetVolume(): Boolean = inner.canSetVolume
override fun canSetSpeed(): Boolean = inner.canSetSpeed
override fun resumePlayback() = inner.resumeVideo()
override fun pausePlayback() = inner.pauseVideo()
override fun stopPlayback() = inner.stopVideo()
override fun seekTo(timeSeconds: Double) = inner.seekVideo(timeSeconds)
override fun changeVolume(timeSeconds: Double) = inner.changeVolume(timeSeconds)
override fun changeSpeed(speed: Double) = inner.changeSpeed(speed)
override fun connect() = inner.start()
override fun disconnect() = inner.stop()
override fun getDeviceInfo(): CastingDeviceInfo = inner.getDeviceInfo()
override fun getAddresses(): List<InetAddress> = inner.getAddresses()
override fun loadVideo(
streamType: String,
contentType: String,
contentId: String,
resumePosition: Double,
duration: Double,
speed: Double?,
metadata: Metadata?
) = inner.loadVideo(streamType, contentType, contentId, resumePosition, duration, speed)
override fun loadContent(
contentType: String,
content: String,
resumePosition: Double,
duration: Double,
speed: Double?,
metadata: Metadata?
) = inner.loadContent(contentType, content, resumePosition, duration, speed)
override fun ensureThreadStarted() = when (inner) {
is FCastCastingDevice -> inner.ensureThreadStarted()
is ChromecastCastingDevice -> inner.ensureThreadsStarted()
else -> {}
}
}

View file

@ -0,0 +1,397 @@
package com.futo.platformplayer.casting
import android.content.Context
import android.net.Uri
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.os.Build
import android.util.Base64
import android.util.Log
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.net.InetAddress
import kotlinx.coroutines.delay
class OldStateCasting : StateCasting() {
private var _nsdManager: NsdManager? = null
private val _discoveryListeners = mapOf(
"_googlecast._tcp" to createDiscoveryListener(::addOrUpdateChromeCastDevice),
"_airplay._tcp" to createDiscoveryListener(::addOrUpdateAirPlayDevice),
"_fastcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice),
"_fcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice)
)
override fun handleUrl(url: String) {
val uri = Uri.parse(url)
if (uri.scheme != "fcast") {
throw Exception("Expected scheme to be FCast")
}
val type = uri.host
if (type != "r") {
throw Exception("Expected type r")
}
val connectionInfo = uri.pathSegments[0]
val json =
Base64.decode(connectionInfo, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
.toString(Charsets.UTF_8)
val networkConfig = Json.decodeFromString<FCastNetworkConfig>(json)
val tcpService = networkConfig.services.first { v -> v.type == 0 }
val foundInfo = addRememberedDevice(
CastingDeviceInfo(
name = networkConfig.name,
type = CastProtocolType.FCAST,
addresses = networkConfig.addresses.toTypedArray(),
port = tcpService.port
)
)
connectDevice(deviceFromInfo(foundInfo))
}
override fun onStop() {
val ad = activeDevice ?: return;
_resumeCastingDevice = ad.getDeviceInfo()
Log.i(TAG, "_resumeCastingDevice set to '${ad.name}'")
Logger.i(TAG, "Stopping active device because of onStop.");
ad.disconnect();
}
@Synchronized
override fun start(context: Context) {
if (_started)
return;
_started = true;
Log.i(TAG, "_resumeCastingDevice set null start")
_resumeCastingDevice = null;
Logger.i(TAG, "CastingService starting...");
_castServer.start();
enableDeveloper(true);
Logger.i(TAG, "CastingService started.");
_nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager
startDiscovering()
}
@Synchronized
private fun startDiscovering() {
_nsdManager?.apply {
_discoveryListeners.forEach {
discoverServices(it.key, NsdManager.PROTOCOL_DNS_SD, it.value)
}
}
}
@Synchronized
private fun stopDiscovering() {
_nsdManager?.apply {
_discoveryListeners.forEach {
try {
stopServiceDiscovery(it.value)
} catch (e: Throwable) {
Logger.w(TAG, "Failed to stop service discovery", e)
}
}
}
}
@Synchronized
override fun stop() {
if (!_started)
return;
_started = false;
Logger.i(TAG, "CastingService stopping.")
stopDiscovering()
_scopeIO.cancel();
_scopeMain.cancel();
Logger.i(TAG, "Stopping active device because StateCasting is being stopped.")
val d = activeDevice;
activeDevice = null;
d?.disconnect();
_castServer.stop();
_castServer.removeAllHandlers();
Logger.i(TAG, "CastingService stopped.")
_nsdManager = null
}
private fun createDiscoveryListener(addOrUpdate: (String, Array<InetAddress>, Int) -> Unit): NsdManager.DiscoveryListener {
return object : NsdManager.DiscoveryListener {
override fun onDiscoveryStarted(regType: String) {
Log.d(TAG, "Service discovery started for $regType")
}
override fun onDiscoveryStopped(serviceType: String) {
Log.i(TAG, "Discovery stopped: $serviceType")
}
override fun onServiceLost(service: NsdServiceInfo) {
Log.e(TAG, "service lost: $service")
// TODO: Handle service lost, e.g., remove device
}
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
Log.e(TAG, "Discovery failed for $serviceType: Error code:$errorCode")
try {
_nsdManager?.stopServiceDiscovery(this)
} catch (e: Throwable) {
Logger.w(TAG, "Failed to stop service discovery", e)
}
}
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {
Log.e(TAG, "Stop discovery failed for $serviceType: Error code:$errorCode")
try {
_nsdManager?.stopServiceDiscovery(this)
} catch (e: Throwable) {
Logger.w(TAG, "Failed to stop service discovery", e)
}
}
override fun onServiceFound(service: NsdServiceInfo) {
Log.v(TAG, "Service discovery success for ${service.serviceType}: $service")
val addresses = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
service.hostAddresses.toTypedArray()
} else {
arrayOf(service.host)
}
addOrUpdate(service.serviceName, addresses, service.port)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
_nsdManager?.registerServiceInfoCallback(
service,
{ it.run() },
object : NsdManager.ServiceInfoCallback {
override fun onServiceUpdated(serviceInfo: NsdServiceInfo) {
Log.v(TAG, "onServiceUpdated: $serviceInfo")
addOrUpdate(
serviceInfo.serviceName,
serviceInfo.hostAddresses.toTypedArray(),
serviceInfo.port
)
}
override fun onServiceLost() {
Log.v(TAG, "onServiceLost: $service")
// TODO: Handle service lost
}
override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {
Log.v(TAG, "onServiceInfoCallbackRegistrationFailed: $errorCode")
}
override fun onServiceInfoCallbackUnregistered() {
Log.v(TAG, "onServiceInfoCallbackUnregistered")
}
})
} else {
_nsdManager?.resolveService(service, object : NsdManager.ResolveListener {
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
Log.v(TAG, "Resolve failed: $errorCode")
}
override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
Log.v(TAG, "Resolve Succeeded: $serviceInfo")
addOrUpdate(
serviceInfo.serviceName,
arrayOf(serviceInfo.host),
serviceInfo.port
)
}
})
}
}
}
}
override fun startUpdateTimeJob(
onTimeJobTimeChanged_s: Event1<Long>,
setTime: (Long) -> Unit
): Job? {
val d = activeDevice;
if (d is OldCastingDeviceWrapper && (d.inner is AirPlayCastingDevice || d.inner is ChromecastCastingDevice)) {
return _scopeMain.launch {
while (true) {
val device = instance.activeDevice
if (device == null || !device.isPlaying) {
break
}
delay(1000)
val time_ms = (device.expectedCurrentTime * 1000.0).toLong()
setTime(time_ms)
onTimeJobTimeChanged_s.emit(device.expectedCurrentTime.toLong())
}
}
}
return null
}
override fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDevice {
return OldCastingDeviceWrapper(
when (deviceInfo.type) {
CastProtocolType.CHROMECAST -> {
ChromecastCastingDevice(deviceInfo);
}
CastProtocolType.AIRPLAY -> {
AirPlayCastingDevice(deviceInfo);
}
CastProtocolType.FCAST -> {
FCastCastingDevice(deviceInfo);
}
}
)
}
private fun addOrUpdateChromeCastDevice(
name: String,
addresses: Array<InetAddress>,
port: Int
) {
return addOrUpdateCastDevice(
name,
deviceFactory = {
OldCastingDeviceWrapper(
ChromecastCastingDevice(
name,
addresses,
port
)
)
},
deviceUpdater = { d ->
if (d.isReady || d !is OldCastingDeviceWrapper || d.inner !is ChromecastCastingDevice) {
return@addOrUpdateCastDevice false;
}
val changed =
addresses.contentEquals(d.inner.addresses) || d.name != name || d.inner.port != port;
if (changed) {
d.inner.name = name;
d.inner.addresses = addresses;
d.inner.port = port;
}
return@addOrUpdateCastDevice changed;
}
);
}
private fun addOrUpdateAirPlayDevice(name: String, addresses: Array<InetAddress>, port: Int) {
return addOrUpdateCastDevice(
name,
deviceFactory = {
OldCastingDeviceWrapper(
AirPlayCastingDevice(
name,
addresses,
port
)
)
},
deviceUpdater = { d ->
if (d.isReady || d !is OldCastingDeviceWrapper || d.inner !is AirPlayCastingDevice) {
return@addOrUpdateCastDevice false;
}
val changed =
addresses.contentEquals(addresses) || d.name != name || d.inner.port != port;
if (changed) {
d.inner.name = name;
d.inner.port = port;
d.inner.addresses = addresses;
}
return@addOrUpdateCastDevice changed;
}
);
}
private fun addOrUpdateFastCastDevice(name: String, addresses: Array<InetAddress>, port: Int) {
return addOrUpdateCastDevice(
name,
deviceFactory = { OldCastingDeviceWrapper(FCastCastingDevice(name, addresses, port)) },
deviceUpdater = { d ->
if (d.isReady || d !is OldCastingDeviceWrapper || d.inner !is FCastCastingDevice) {
return@addOrUpdateCastDevice false;
}
val changed =
addresses.contentEquals(addresses) || d.name != name || d.inner.port != port;
if (changed) {
d.inner.name = name;
d.inner.port = port;
d.inner.addresses = addresses;
}
return@addOrUpdateCastDevice changed;
}
);
}
private inline fun addOrUpdateCastDevice(
name: String,
deviceFactory: () -> CastingDevice,
deviceUpdater: (device: CastingDevice) -> Boolean
) {
var invokeEvents: (() -> Unit)? = null;
synchronized(devices) {
val device = devices[name];
if (device != null) {
val changed = deviceUpdater(device);
if (changed) {
invokeEvents = {
onDeviceChanged.emit(device);
}
}
} else {
val newDevice = deviceFactory();
this.devices[name] = newDevice
invokeEvents = {
onDeviceAdded.emit(newDevice);
};
}
}
invokeEvents?.let { _scopeMain.launch { it(); }; };
}
@Serializable
private data class FCastNetworkConfig(
val name: String,
val addresses: List<String>,
val services: List<FCastService>
)
@Serializable
private data class FCastService(
val port: Int,
val type: Int
)
companion object {
private val TAG = "OldStateCasting"
}
}

View file

@ -8,11 +8,14 @@ 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.OldStateCasting
import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.models.CastingDeviceInfo import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.platformplayer.toInetAddress import com.futo.platformplayer.toInetAddress
import com.futo.platformplayer.logging.Logger
class CastingAddDialog(context: Context?) : AlertDialog(context) { class CastingAddDialog(context: Context?) : AlertDialog(context) {
@ -38,7 +41,13 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) {
_buttonConfirm = findViewById(R.id.button_confirm); _buttonConfirm = findViewById(R.id.button_confirm);
_buttonTutorial = findViewById(R.id.button_tutorial) _buttonTutorial = findViewById(R.id.button_tutorial)
ArrayAdapter.createFromResource(context, R.array.casting_device_type_array, R.layout.spinner_item_simple).also { adapter -> val deviceTypeArray = if (Settings.instance.casting.experimentalCasting) {
R.array.exp_casting_device_type_array
} else {
R.array.casting_device_type_array
}
ArrayAdapter.createFromResource(context, deviceTypeArray, R.layout.spinner_item_simple).also { adapter ->
adapter.setDropDownViewResource(R.layout.spinner_dropdownitem_simple); adapter.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
_spinnerType.adapter = adapter; _spinnerType.adapter = adapter;
}; };
@ -101,7 +110,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); try {
StateCasting.instance.addRememberedDevice(castingDeviceInfo)
} catch (e: Throwable) {
Logger.e(TAG, "Failed to add remembered device: $e")
}
performDismiss(); performDismiss();
}; };

View file

@ -7,7 +7,6 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.widget.Button import android.widget.Button
import android.widget.ImageButton
import android.widget.ImageView import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
@ -18,7 +17,6 @@ import com.futo.platformplayer.R
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.StateCasting import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
@ -108,15 +106,16 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
synchronized(StateCasting.instance.devices) { synchronized(StateCasting.instance.devices) {
_devices.addAll(StateCasting.instance.devices.values.mapNotNull { it.name }) _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 -> StateCasting.instance.onDeviceAdded.subscribe(this) { d ->
val name = d.name val name = d.name
if (name != null) if (name != null) {
_devices.add(name) _devices.add(name)
updateUnifiedList() updateUnifiedList()
}
} }
StateCasting.instance.onDeviceChanged.subscribe(this) { d -> StateCasting.instance.onDeviceChanged.subscribe(this) { d ->

View file

@ -12,12 +12,11 @@ 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.CastConnectionState import com.futo.platformplayer.casting.CastConnectionState
import com.futo.platformplayer.casting.CastProtocolType
import com.futo.platformplayer.casting.CastingDevice 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.casting.StateCasting
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
@ -69,18 +68,18 @@ 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() StateCasting.instance.resumeVideo()
} }
_buttonPause = findViewById(R.id.button_pause); _buttonPause = findViewById(R.id.button_pause);
_buttonPause.setOnClickListener { _buttonPause.setOnClickListener {
StateCasting.instance.activeDevice?.pauseVideo() StateCasting.instance.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() StateCasting.instance.stopVideo()
} }
_buttonNext = findViewById(R.id.button_next); _buttonNext = findViewById(R.id.button_next);
@ -90,7 +89,11 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
_buttonClose.setOnClickListener { dismiss(); }; _buttonClose.setOnClickListener { dismiss(); };
_buttonDisconnect.setOnClickListener { _buttonDisconnect.setOnClickListener {
StateCasting.instance.activeDevice?.stopCasting(); try {
StateCasting.instance.activeDevice?.disconnect()
} catch (e: Throwable) {
Logger.e(TAG, "Active device failed to disconnect: $e")
}
dismiss(); dismiss();
}; };
@ -99,12 +102,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
return@OnChangeListener return@OnChangeListener
} }
val activeDevice = StateCasting.instance.activeDevice ?: return@OnChangeListener; StateCasting.instance.videoSeekTo(value.toDouble())
try {
activeDevice.seekVideo(value.toDouble());
} catch (e: Throwable) {
Logger.e(TAG, "Failed to change volume.", e);
}
}); });
//TODO: Check if volume slider is properly hidden in all cases //TODO: Check if volume slider is properly hidden in all cases
@ -113,14 +111,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
return@OnChangeListener return@OnChangeListener
} }
val activeDevice = StateCasting.instance.activeDevice ?: return@OnChangeListener; StateCasting.instance.changeVolume(value.toDouble())
if (activeDevice.canSetVolume) {
try {
activeDevice.changeVolume(value.toDouble());
} catch (e: Throwable) {
Logger.e(TAG, "Failed to change volume.", e);
}
}
}); });
setLoading(false); setLoading(false);
@ -172,15 +163,25 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
private fun updateDevice() { private fun updateDevice() {
val d = StateCasting.instance.activeDevice ?: return; val d = StateCasting.instance.activeDevice ?: return;
if (d is ChromecastCastingDevice) { when (d.protocolType) {
_imageDevice.setImageResource(R.drawable.ic_chromecast); CastProtocolType.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"; CastProtocolType.AIRPLAY -> {
} else if (d is FCastCastingDevice) { _imageDevice.setImageResource(R.drawable.ic_airplay);
_imageDevice.setImageResource(R.drawable.ic_fc); _textType.text = "AirPlay";
_textType.text = "FastCast"; }
CastProtocolType.FCAST -> {
_imageDevice.setImageResource(
if (Settings.instance.casting.experimentalCasting) {
R.drawable.ic_exp_fc
} else {
R.drawable.ic_fc
}
)
_textType.text = "FCast";
}
} }
_textName.text = d.name; _textName.text = d.name;
@ -192,7 +193,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
_sliderPosition.value = d.time.toFloat().coerceAtLeast(0.0f).coerceAtMost(dur) _sliderPosition.value = d.time.toFloat().coerceAtLeast(0.0f).coerceAtMost(dur)
_sliderPosition.valueTo = dur _sliderPosition.valueTo = dur
if (d.canSetVolume) { if (d.canSetVolume()) {
_layoutVolumeAdjustable.visibility = View.VISIBLE; _layoutVolumeAdjustable.visibility = View.VISIBLE;
_layoutVolumeFixed.visibility = View.GONE; _layoutVolumeFixed.visibility = View.GONE;
} else { } else {
@ -214,8 +215,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
CastConnectionState.CONNECTED -> { CastConnectionState.CONNECTED -> {
enableControls(interactiveControls) enableControls(interactiveControls)
} }
CastConnectionState.CONNECTING, CastConnectionState.CONNECTING, CastConnectionState.DISCONNECTED -> {
CastConnectionState.DISCONNECTED -> {
disableControls(interactiveControls) disableControls(interactiveControls)
} }
} }

View file

@ -576,9 +576,8 @@ class VideoDetailView : ConstraintLayout {
if(chapter?.type == ChapterType.SKIPPABLE) { if(chapter?.type == ChapterType.SKIPPABLE) {
_layoutSkip.visibility = VISIBLE; _layoutSkip.visibility = VISIBLE;
} else if(chapter?.type == ChapterType.SKIP || chapter?.type == ChapterType.SKIPONCE) { } else if(chapter?.type == ChapterType.SKIP || chapter?.type == ChapterType.SKIPONCE) {
val ad = StateCasting.instance.activeDevice if (StateCasting.instance.activeDevice != null) {
if (ad != null) { StateCasting.instance.videoSeekTo(chapter.timeEnd)
ad.seekVideo(chapter.timeEnd)
} else { } else {
_player.seekTo((chapter.timeEnd * 1000).toLong()); _player.seekTo((chapter.timeEnd * 1000).toLong());
} }
@ -886,7 +885,7 @@ class VideoDetailView : ConstraintLayout {
if (ad != null) { if (ad != null) {
val currentChapter = _cast.getCurrentChapter((ad.time * 1000).toLong()); val currentChapter = _cast.getCurrentChapter((ad.time * 1000).toLong());
if(currentChapter?.type == ChapterType.SKIPPABLE) { if(currentChapter?.type == ChapterType.SKIPPABLE) {
ad.seekVideo(currentChapter.timeEnd); StateCasting.instance.videoSeekTo(currentChapter.timeEnd);
} }
} else { } else {
val currentChapter = _player.getCurrentChapter(_player.position); val currentChapter = _player.getCurrentChapter(_player.position);
@ -2368,11 +2367,11 @@ 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 || StateCasting.instance.activeDevice?.canSetSpeed() == true
val currentPlaybackRate = if (_isCasting) StateCasting.instance.activeDevice?.speed else _player.getPlaybackRate() val currentPlaybackRate = if (_isCasting) StateCasting.instance.activeDevice?.speed else _player.getPlaybackRate()
val qualityPlaybackSpeedTitle = if (canSetSpeed) SlideUpMenuTitle(this.context).apply { setTitle(context.getString(R.string.playback_rate) + " (${String.format("%.2f", currentPlaybackRate)})"); } else null; 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,
qualityPlaybackSpeedTitle, qualityPlaybackSpeedTitle,
if (canSetSpeed) SlideUpMenuButtonList(this.context, null, "playback_rate").apply { if (canSetSpeed) SlideUpMenuButtonList(this.context, null, "playback_rate").apply {
val playbackSpeeds = Settings.instance.playback.getPlaybackSpeeds(); val playbackSpeeds = Settings.instance.playback.getPlaybackSpeeds();
@ -2393,7 +2392,7 @@ class VideoDetailView : ConstraintLayout {
val newPlaybackSpeed = playbackSpeedString.toDouble(); val newPlaybackSpeed = playbackSpeedString.toDouble();
if (_isCasting) { if (_isCasting) {
val ad = StateCasting.instance.activeDevice ?: return@subscribe val ad = StateCasting.instance.activeDevice ?: return@subscribe
if (!ad.canSetSpeed) { if (!ad.canSetSpeed()) {
return@subscribe return@subscribe
} }

View file

@ -3,16 +3,9 @@ package com.futo.platformplayer.models
import com.futo.platformplayer.casting.CastProtocolType import com.futo.platformplayer.casting.CastProtocolType
@kotlinx.serialization.Serializable @kotlinx.serialization.Serializable
class CastingDeviceInfo { class CastingDeviceInfo(
var name: String; var name: String,
var type: CastProtocolType; var type: CastProtocolType,
var addresses: Array<String>; var addresses: Array<String>,
var port: Int; var port: Int
)
constructor(name: String, type: CastProtocolType, addresses: Array<String>, port: Int) {
this.name = name;
this.type = type;
this.addresses = addresses;
this.port = port;
}
}

View file

@ -4,21 +4,19 @@ 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.casting.AirPlayCastingDevice import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.casting.CastConnectionState import com.futo.platformplayer.casting.CastConnectionState
import com.futo.platformplayer.casting.CastProtocolType
import com.futo.platformplayer.casting.CastingDevice 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.casting.StateCasting
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.logging.Logger
import androidx.core.view.isVisible
import com.futo.platformplayer.UIDialogs
class DeviceViewHolder : ViewHolder { class DeviceViewHolder : ViewHolder {
private val _layoutDevice: FrameLayout; private val _layoutDevice: FrameLayout;
@ -56,16 +54,18 @@ class DeviceViewHolder : ViewHolder {
val connect = { val connect = {
device?.let { dev -> device?.let { dev ->
if (dev.isReady) { try {
StateCasting.instance.activeDevice?.stopCasting() if (dev.isReady) {
StateCasting.instance.connectDevice(dev) StateCasting.instance.activeDevice?.stopPlayback()
onConnect.emit(dev) StateCasting.instance.connectDevice(dev)
} else { onConnect.emit(dev)
try { } else {
view.context?.let { UIDialogs.toast(it, "Device not ready, may be offline") } view.context?.let {
} catch (e: Throwable) { UIDialogs.toast(it, "Device not ready, may be offline")
//Ignored }
} }
} catch (e: Throwable) {
Logger.e(TAG, "Failed to connect: $e")
} }
} }
} }
@ -81,15 +81,25 @@ class DeviceViewHolder : ViewHolder {
} }
fun bind(d: CastingDevice, isOnlineDevice: Boolean, isPinnedDevice: Boolean) { fun bind(d: CastingDevice, isOnlineDevice: Boolean, isPinnedDevice: Boolean) {
if (d is ChromecastCastingDevice) { when (d.protocolType) {
_imageDevice.setImageResource(R.drawable.ic_chromecast); CastProtocolType.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"; CastProtocolType.AIRPLAY -> {
} else if (d is FCastCastingDevice) { _imageDevice.setImageResource(R.drawable.ic_airplay);
_imageDevice.setImageResource(R.drawable.ic_fc); _textType.text = "AirPlay";
_textType.text = "FCast"; }
CastProtocolType.FCAST -> {
_imageDevice.setImageResource(
if (Settings.instance.casting.experimentalCasting) {
R.drawable.ic_exp_fc
} else {
R.drawable.ic_fc
}
)
_textType.text = "FCast";
}
} }
_textName.text = d.name; _textName.text = d.name;
@ -136,4 +146,8 @@ class DeviceViewHolder : ViewHolder {
device = d; device = d;
} }
companion object {
private val TAG = "DeviceViewHolder"
}
} }

View file

@ -2,12 +2,7 @@ package com.futo.platformplayer.views.casting
import android.content.Context import android.content.Context
import android.util.AttributeSet import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View import android.view.View
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings

View file

@ -21,14 +21,13 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.api.media.models.chapters.IChapter import com.futo.platformplayer.api.media.models.chapters.IChapter
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.casting.AirPlayCastingDevice import com.futo.platformplayer.casting.CastConnectionState
import com.futo.platformplayer.casting.ChromecastCastingDevice
import com.futo.platformplayer.casting.StateCasting 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.formatDuration import com.futo.platformplayer.formatDuration
import com.futo.platformplayer.states.StateHistory import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.views.TargetTapLoaderView import com.futo.platformplayer.views.TargetTapLoaderView
import com.futo.platformplayer.views.behavior.GestureControlView import com.futo.platformplayer.views.behavior.GestureControlView
@ -36,7 +35,6 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class CastView : ConstraintLayout { class CastView : ConstraintLayout {
@ -99,19 +97,30 @@ class CastView : ConstraintLayout {
val d = StateCasting.instance.activeDevice ?: return@subscribe; val d = StateCasting.instance.activeDevice ?: return@subscribe;
_speedHoldWasPlaying = d.isPlaying _speedHoldWasPlaying = d.isPlaying
_speedHoldPrevRate = d.speed _speedHoldPrevRate = d.speed
if (d.canSetSpeed) try {
d.changeSpeed(Settings.instance.playback.getHoldPlaybackSpeed()) if (d.canSetSpeed()) {
d.resumeVideo() d.changeSpeed(Settings.instance.playback.getHoldPlaybackSpeed())
}
d.resumePlayback()
} catch (e: Throwable) {
Logger.e(TAG, "Failed to change playback speed to hold playback speed: $e")
}
} }
_gestureControlView.onSpeedHoldEnd.subscribe { _gestureControlView.onSpeedHoldEnd.subscribe {
val d = StateCasting.instance.activeDevice ?: return@subscribe; try {
if (!_speedHoldWasPlaying) d.pauseVideo() val d = StateCasting.instance.activeDevice ?: return@subscribe;
d.changeSpeed(_speedHoldPrevRate) if (!_speedHoldWasPlaying) {
d.pausePlayback()
}
d.changeSpeed(_speedHoldPrevRate)
} catch (e: Throwable) {
Logger.e(TAG, "Failed to change playback speed to previous hold playback speed: $e")
}
} }
_gestureControlView.onSeek.subscribe { _gestureControlView.onSeek.subscribe {
val d = StateCasting.instance.activeDevice ?: return@subscribe; val d = StateCasting.instance.activeDevice ?: return@subscribe;
StateCasting.instance.videoSeekTo(d.expectedCurrentTime + it / 1000); StateCasting.instance.videoSeekTo( d.expectedCurrentTime + it / 1000);
}; };
_buttonLoop.setOnClickListener { _buttonLoop.setOnClickListener {
@ -220,22 +229,9 @@ class CastView : ConstraintLayout {
stopTimeJob() stopTimeJob()
if(isPlaying) { if(isPlaying) {
val d = StateCasting.instance.activeDevice; StateCasting.instance.startUpdateTimeJob(
if (d is AirPlayCastingDevice || d is ChromecastCastingDevice) { onTimeJobTimeChanged_s
_updateTimeJob = _scope.launch { ) { setTime(it) }
while (true) {
val device = StateCasting.instance.activeDevice;
if (device == null || !device.isPlaying) {
break;
}
delay(1000);
val time_ms = (device.expectedCurrentTime * 1000.0).toLong()
setTime(time_ms);
onTimeJobTimeChanged_s.emit(device.expectedCurrentTime.toLong())
}
}
}
if (!_inPictureInPicture) { if (!_inPictureInPicture) {
_buttonPause.visibility = View.VISIBLE; _buttonPause.visibility = View.VISIBLE;
@ -333,4 +329,8 @@ class CastView : ConstraintLayout {
_loaderGame.visibility = View.VISIBLE _loaderGame.visibility = View.VISIBLE
_loaderGame.startLoader(expectedDurationMs.toLong()) _loaderGame.startLoader(expectedDurationMs.toLong())
} }
companion object {
private val TAG = "CastView";
}
} }

View file

@ -0,0 +1,14 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="111.96dp"
android:height="114.46dp"
android:viewportWidth="111.96"
android:viewportHeight="114.46">
<path
android:pathData="m84.76,5.58c2.06,-2.06 0.6,-5.58 -2.31,-5.58H3.27C1.46,-0 -0,1.46 -0,3.27V82.45c0,2.91 3.52,4.37 5.58,2.31L20.37,69.98c0.61,-0.61 0.96,-1.45 0.96,-2.31V24.6c0,-1.81 1.46,-3.27 3.27,-3.27h43.07c0.87,0 1.7,-0.34 2.31,-0.96z"
android:fillColor="#ffffff"
android:fillType="evenOdd"/>
<path
android:pathData="m45.68,73.5v17.18c0,0.68 -0.55,1.23 -1.23,1.23L27.56,91.9c-0.68,0 -1.23,-0.55 -1.23,-1.23v-17.18c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM45.68,51.16v17.18c0,0.68 -0.55,1.23 -1.23,1.23L27.56,69.57c-0.68,0 -1.23,-0.55 -1.23,-1.23L26.33,51.16c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM67.73,51.16v17.18c0,0.68 -0.55,1.23 -1.23,1.23L49.61,69.57c-0.68,0 -1.23,-0.55 -1.23,-1.23L48.38,51.16c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM67.73,28.83v17.18c0,0.68 -0.55,1.23 -1.23,1.23L49.61,47.24c-0.68,0 -1.23,-0.55 -1.23,-1.23L48.38,28.83c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM89.77,51.16v17.18c0,0.68 -0.55,1.23 -1.23,1.23h-16.89c-0.68,0 -1.23,-0.55 -1.23,-1.23L70.43,51.16c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM67.73,73.5v17.18c0,0.68 -0.55,1.23 -1.23,1.23L49.61,91.9c-0.68,0 -1.23,-0.55 -1.23,-1.23v-17.18c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM89.77,28.83v17.18c0,0.68 -0.55,1.23 -1.23,1.23h-16.89c-0.68,0 -1.23,-0.55 -1.23,-1.23L70.43,28.83c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM45.68,28.83v17.18c0,0.68 -0.55,1.23 -1.23,1.23L27.56,47.24c-0.68,0 -1.23,-0.55 -1.23,-1.23L26.33,28.83c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM45.68,95.83v17.18c0,0.68 -0.55,1.23 -1.23,1.23L27.56,114.24c-0.68,0 -1.23,-0.55 -1.23,-1.23L26.33,95.83c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM111.77,28.83v17.18c0,0.68 -0.55,1.23 -1.23,1.23L93.65,47.24c-0.68,0 -1.23,-0.55 -1.23,-1.23L92.43,28.83c0,-0.68 0.55,-1.23 1.23,-1.23L110.55,27.6c0.68,0 1.23,0.55 1.23,1.23z"
android:strokeWidth="0"
android:fillColor="#ffffff"/>
</vector>

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>
@ -1104,6 +1106,10 @@
<item>ChromeCast</item> <item>ChromeCast</item>
<item>AirPlay</item> <item>AirPlay</item>
</string-array> </string-array>
<string-array name="exp_casting_device_type_array" translatable="false">
<item>FCast</item>
<item>ChromeCast</item>
</string-array>
<string-array name="log_levels"> <string-array name="log_levels">
<item>None</item> <item>None</item>
<item>Error</item> <item>Error</item>