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"
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
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)
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

@ -1046,7 +1046,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
Logger.i(TAG, "handleFCast");
try {
StateCasting.instance.handleUrl(this, url)
StateCasting.instance.handleUrl(url)
return true;
} catch (e: Throwable) {
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.util.UUID
class AirPlayCastingDevice : CastingDevice {
class AirPlayCastingDevice : OldCastingDevice {
//See for more info: https://nto.github.io/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.models.CastingDeviceInfo
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import org.fcast.sender_sdk.Metadata
import java.net.InetAddress
enum class CastConnectionState {
DISCONNECTED,
CONNECTING,
CONNECTED
}
@Serializable(with = CastProtocolType.CastProtocolTypeSerializer::class)
enum class CastProtocolType {
CHROMECAST,
AIRPLAY,
FCAST;
object CastProtocolTypeSerializer : KSerializer<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 val protocol: CastProtocolType;
abstract val isReady: Boolean;
abstract var usedRemoteAddress: InetAddress?;
abstract var localAddress: InetAddress?;
abstract val canSetVolume: Boolean;
abstract val canSetSpeed: Boolean;
abstract val isReady: Boolean
abstract val usedRemoteAddress: InetAddress?
abstract val localAddress: InetAddress?
abstract val name: String?
abstract val onConnectionStateChanged: Event1<CastConnectionState>
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;
var isPlaying: Boolean = false
set(value) {
val changed = value != field;
field = value;
if (changed) {
onPlayChanged.emit(value);
}
};
@Throws
abstract fun resumePlayback()
private var lastTimeChangeTime_ms: Long = 0
var time: Double = 0.0
private set
@Throws
abstract fun pausePlayback()
protected fun setTime(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
if (changeTime_ms > lastTimeChangeTime_ms && value != time) {
time = value
lastTimeChangeTime_ms = changeTime_ms
onTimeChanged.emit(value)
}
@Throws
abstract fun stopPlayback()
@Throws
abstract fun seekTo(timeSeconds: Double)
@Throws
abstract fun changeVolume(timeSeconds: Double)
@Throws
abstract fun changeSpeed(speed: Double)
@Throws
abstract fun connect()
@Throws
abstract fun disconnect()
abstract fun getDeviceInfo(): CastingDeviceInfo
abstract fun getAddresses(): List<InetAddress>
@Throws
abstract fun loadVideo(
streamType: String,
contentType: String,
contentId: String,
resumePosition: Double,
duration: Double,
speed: Double?,
metadata: Metadata?
)
@Throws
abstract fun loadContent(
contentType: String,
content: String,
resumePosition: Double,
duration: Double,
speed: Double?,
metadata: Metadata?
)
abstract fun ensureThreadStarted()
}
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>;
}

View file

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

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

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

@ -3,15 +3,8 @@ package com.futo.platformplayer.casting
import android.app.AlertDialog
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.os.Build
import android.os.Looper
import android.util.Base64
import android.util.Log
import java.net.NetworkInterface
import java.net.Inet4Address
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import com.futo.platformplayer.R
@ -41,39 +34,38 @@ import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManif
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
import com.futo.platformplayer.builders.DashBuilder
import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.exceptions.UnsupportedCastException
import com.futo.platformplayer.findPreferredAddress
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.platformplayer.parsers.HLS
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.stores.CastingDeviceInfoStorage
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.toUrlAddress
import com.futo.platformplayer.views.casting.CastView
import com.futo.platformplayer.views.casting.CastView.Companion
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.fcast.sender_sdk.Metadata
import java.net.Inet6Address
import java.net.InetAddress
import java.net.URLDecoder
import java.net.URLEncoder
import java.util.Collections
import java.util.UUID
import java.util.concurrent.atomic.AtomicInteger
class StateCasting {
private val _scopeIO = CoroutineScope(Dispatchers.IO);
private val _scopeMain = CoroutineScope(Dispatchers.Main);
abstract class StateCasting {
val _scopeIO = CoroutineScope(Dispatchers.IO);
val _scopeMain = CoroutineScope(Dispatchers.Main);
private val _storage: CastingDeviceInfoStorage = FragmentedStorage.get();
private val _castServer = ManagedHttpServer();
private var _started = false;
val _castServer = ManagedHttpServer();
var _started = false;
var devices: HashMap<String, CastingDevice> = hashMapOf();
val onDeviceAdded = Event1<CastingDevice>();
@ -89,212 +81,46 @@ class StateCasting {
private var _audioExecutor: JSRequestExecutor? = null
private val _client = ManagedHttpClient();
var _resumeCastingDevice: CastingDeviceInfo? = null;
private var _nsdManager: NsdManager? = null
val isCasting: Boolean get() = activeDevice != null;
private val _castId = AtomicInteger(0)
private val _discoveryListeners = mapOf(
"_googlecast._tcp" to createDiscoveryListener(::addOrUpdateChromeCastDevice),
"_airplay._tcp" to createDiscoveryListener(::addOrUpdateAirPlayDevice),
"_fastcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice),
"_fcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice)
)
abstract fun handleUrl(url: String)
abstract fun onStop()
abstract fun start(context: Context)
abstract fun stop()
fun handleUrl(context: Context, url: String) {
val uri = Uri.parse(url)
if (uri.scheme != "fcast") {
throw Exception("Expected scheme to be FCast")
}
val type = uri.host
if (type != "r") {
throw Exception("Expected type r")
}
val connectionInfo = uri.pathSegments[0]
val json = Base64.decode(connectionInfo, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP).toString(Charsets.UTF_8)
val networkConfig = Json.decodeFromString<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(deviceFromCastingDeviceInfo(foundInfo))
}
fun onStop() {
val ad = activeDevice ?: return;
_resumeCastingDevice = ad.getDeviceInfo()
Log.i(TAG, "_resumeCastingDevice set to '${ad.name}'")
Logger.i(TAG, "Stopping active device because of onStop.");
ad.stop();
}
@Throws
abstract fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDevice
abstract fun startUpdateTimeJob(
onTimeJobTimeChanged_s: Event1<Long>, setTime: (Long) -> Unit
): Job?
fun onResume() {
val ad = activeDevice
if (ad != null) {
if (ad is FCastCastingDevice) {
ad.ensureThreadStarted()
} else if (ad is ChromecastCastingDevice) {
ad.ensureThreadsStarted()
}
} else {
val resumeCastingDevice = _resumeCastingDevice
if (resumeCastingDevice != null) {
connectDevice(deviceFromCastingDeviceInfo(resumeCastingDevice))
val dev = deviceFromInfo(resumeCastingDevice) ?: return
connectDevice(dev)
_resumeCastingDevice = null
Log.i(TAG, "_resumeCastingDevice set to null onResume")
}
}
}
@Synchronized
fun start(context: Context) {
if (_started)
fun cancel() {
_castId.incrementAndGet()
}
fun invokeInMainScopeIfRequired(action: () -> Unit) {
if (Looper.getMainLooper().thread != Thread.currentThread()) {
_scopeMain.launch { action() }
return;
_started = true;
Log.i(TAG, "_resumeCastingDevice set null start")
_resumeCastingDevice = null;
Logger.i(TAG, "CastingService starting...");
_castServer.start();
enableDeveloper(true);
Logger.i(TAG, "CastingService started.");
_nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager
startDiscovering()
}
@Synchronized
private fun startDiscovering() {
_nsdManager?.apply {
_discoveryListeners.forEach {
discoverServices(it.key, NsdManager.PROTOCOL_DNS_SD, it.value)
}
}
}
@Synchronized
private fun stopDiscovering() {
_nsdManager?.apply {
_discoveryListeners.forEach {
try {
stopServiceDiscovery(it.value)
} catch (e: Throwable) {
Logger.w(TAG, "Failed to stop service discovery", e)
}
}
}
}
@Synchronized
fun stop() {
if (!_started)
return;
_started = false;
Logger.i(TAG, "CastingService stopping.")
stopDiscovering()
_scopeIO.cancel();
_scopeMain.cancel();
Logger.i(TAG, "Stopping active device because StateCasting is being stopped.")
val d = activeDevice;
activeDevice = null;
d?.stop();
_castServer.stop();
_castServer.removeAllHandlers();
Logger.i(TAG, "CastingService stopped.")
_nsdManager = null
}
private fun createDiscoveryListener(addOrUpdate: (String, Array<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)
}
})
}
}
}
action();
}
private val _castingDialogLock = Any();
@ -302,8 +128,9 @@ class StateCasting {
@Synchronized
fun connectDevice(device: CastingDevice) {
if (activeDevice == device)
return;
if (activeDevice == device) {
return
}
val ad = activeDevice;
if (ad != null) {
@ -313,11 +140,11 @@ class StateCasting {
device.onTimeChanged.clear();
device.onVolumeChanged.clear();
device.onDurationChanged.clear();
ad.stop();
ad.disconnect()
}
device.onConnectionStateChanged.subscribe { castConnectionState ->
Logger.i(TAG, "Active device connection state changed: $castConnectionState");
Logger.i(TAG, "Active device connection state changed: $castConnectionState")
if (castConnectionState == CastConnectionState.DISCONNECTED) {
Logger.i(TAG, "Clearing events: $castConnectionState");
@ -354,7 +181,11 @@ class StateCasting {
"Connecting to [${device.name}]",
"Make sure you are on the same network\n\nVPNs and guest networks can cause issues", null, -2,
UIDialogs.Action("Disconnect", {
device.stop();
try {
device.disconnect()
} catch (e: Throwable) {
Logger.e(TAG, "Failed to disconnect from device: $e")
}
}));
}
}
@ -376,7 +207,7 @@ class StateCasting {
};
device.onPlayChanged.subscribe {
invokeInMainScopeIfRequired { onActiveDevicePlayChanged.emit(it) };
}
};
device.onDurationChanged.subscribe {
invokeInMainScopeIfRequired { onActiveDeviceDurationChanged.emit(it) };
};
@ -388,7 +219,7 @@ class StateCasting {
};
try {
device.start();
device.connect();
} catch (e: Throwable) {
Logger.w(TAG, "Failed to connect to device.");
device.onConnectionStateChanged.clear();
@ -399,52 +230,24 @@ class StateCasting {
return;
}
activeDevice = device;
Logger.i(TAG, "Connect to device ${device.name}");
activeDevice = device
Logger.i(TAG, "Connect to device ${device.name}")
}
fun addRememberedDevice(deviceInfo: CastingDeviceInfo): CastingDeviceInfo {
val device = deviceFromCastingDeviceInfo(deviceInfo);
return addRememberedDevice(device);
}
fun getRememberedCastingDevices(): List<CastingDevice> {
return _storage.getDevices().map { deviceFromCastingDeviceInfo(it) }
}
fun getRememberedCastingDeviceNames(): List<String> {
return _storage.getDeviceNames()
}
fun addRememberedDevice(device: CastingDevice): CastingDeviceInfo {
val deviceInfo = device.getDeviceInfo()
return _storage.addDevice(deviceInfo)
}
fun removeRememberedDevice(device: CastingDevice) {
val name = device.name ?: return
_storage.removeDevice(name)
}
private fun invokeInMainScopeIfRequired(action: () -> Unit){
if(Looper.getMainLooper().thread != Thread.currentThread()) {
_scopeMain.launch { action(); }
return;
}
action();
}
fun cancel() {
_castId.incrementAndGet()
fun metadataFromVideo(video: IPlatformVideoDetails): Metadata {
return Metadata(
title = video.name, thumbnailUrl = video.thumbnails.getHQThumbnail()
)
}
@Throws
suspend fun castIfAvailable(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, ms: Long = -1, speed: Double?, onLoadingEstimate: ((Int) -> Unit)? = null, onLoading: ((Boolean) -> Unit)? = null): Boolean {
return withContext(Dispatchers.IO) {
val ad = activeDevice ?: return@withContext false;
if (ad.connectionState != CastConnectionState.CONNECTED) {
return@withContext false;
}
val deviceProto = ad.protocolType
val resumePosition = if (ms > 0L) (ms.toDouble() / 1000.0) else 0.0;
val castId = _castId.incrementAndGet()
@ -460,7 +263,7 @@ class StateCasting {
if (sourceCount > 1) {
if (videoSource is LocalVideoSource || audioSource is LocalAudioSource || subtitleSource is LocalSubtitleSource) {
if (ad is AirPlayCastingDevice) {
if (deviceProto == CastProtocolType.AIRPLAY) {
Logger.i(TAG, "Casting as local HLS");
castLocalHls(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition, speed);
} else {
@ -468,16 +271,17 @@ class StateCasting {
castLocalDash(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition, speed);
}
} else {
val isRawDash = videoSource is JSDashManifestRawSource || audioSource is JSDashManifestRawAudioSource
val isRawDash =
videoSource is JSDashManifestRawSource || audioSource is JSDashManifestRawAudioSource
if (isRawDash) {
Logger.i(TAG, "Casting as raw DASH");
castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, audioSource as JSDashManifestRawAudioSource?, subtitleSource, resumePosition, speed, castId, onLoadingEstimate, onLoading);
} else {
if (ad is FCastCastingDevice) {
if (deviceProto == CastProtocolType.FCAST) {
Logger.i(TAG, "Casting as DASH direct");
castDashDirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed);
} else if (ad is AirPlayCastingDevice) {
} else if (deviceProto == CastProtocolType.AIRPLAY) {
Logger.i(TAG, "Casting as HLS indirect");
castHlsIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed);
} else {
@ -495,27 +299,27 @@ class StateCasting {
val videoPath = "/video-${id}"
val videoUrl = if(proxyStreams) url + videoPath else videoSource.getVideoUrl();
Logger.i(TAG, "Casting as singular video");
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), speed);
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video));
} else if (audioSource is IAudioUrlSource) {
val audioPath = "/audio-${id}"
val audioUrl = if(proxyStreams) url + audioPath else audioSource.getAudioUrl();
Logger.i(TAG, "Casting as singular audio");
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), speed);
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video));
} else if (videoSource is IHLSManifestSource) {
if (proxyStreams || ad is ChromecastCastingDevice) {
if (proxyStreams || deviceProto == CastProtocolType.CHROMECAST) {
Logger.i(TAG, "Casting as proxied HLS");
castProxiedHls(video, videoSource.url, videoSource.codec, resumePosition, speed);
} else {
Logger.i(TAG, "Casting as non-proxied HLS");
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble(), speed);
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video));
}
} else if (audioSource is IHLSManifestAudioSource) {
if (proxyStreams || ad is ChromecastCastingDevice) {
if (proxyStreams || deviceProto == CastProtocolType.CHROMECAST) {
Logger.i(TAG, "Casting as proxied audio HLS");
castProxiedHls(video, audioSource.url, audioSource.codec, resumePosition, speed);
} else {
Logger.i(TAG, "Casting as non-proxied audio HLS");
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble(), speed);
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video));
}
} else if (videoSource is LocalVideoSource) {
Logger.i(TAG, "Casting as local video");
@ -545,28 +349,69 @@ class StateCasting {
fun resumeVideo(): Boolean {
val ad = activeDevice ?: return false;
ad.resumeVideo();
try {
ad.resumePlayback();
} catch (e: Throwable) {
Logger.e(TAG, "Failed to resume playback: $e")
return false
}
return true;
}
fun pauseVideo(): Boolean {
val ad = activeDevice ?: return false;
ad.pauseVideo();
try {
ad.pausePlayback();
} catch (e: Throwable) {
Logger.e(TAG, "Failed to pause playback: $e")
return false
}
return true;
}
fun stopVideo(): Boolean {
val ad = activeDevice ?: return false;
ad.stopVideo();
try {
ad.stopPlayback();
} catch (e: Throwable) {
Logger.e(TAG, "Failed to stop playback: $e")
return false
}
return true;
}
fun videoSeekTo(timeSeconds: Double): Boolean {
val ad = activeDevice ?: return false;
ad.seekVideo(timeSeconds);
try {
ad.seekTo(timeSeconds);
} catch (e: Throwable) {
Logger.e(TAG, "Failed to seek: $e")
return false
}
return true;
}
fun changeVolume(volume: Double): Boolean {
val ad = activeDevice ?: return false;
try {
ad.changeVolume(volume);
} catch (e: Throwable) {
Logger.e(TAG, "Failed to change volume: $e")
return false
}
return true;
}
fun changeSpeed(speed: Double): Boolean {
val ad = activeDevice ?: return false;
try {
ad.changeSpeed(speed);
} catch (e: Throwable) {
Logger.e(TAG, "Failed to change speed: $e")
return false
}
return true;
}
private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double, speed: Double?) : List<String> {
val ad = activeDevice ?: return listOf();
@ -581,7 +426,7 @@ class StateCasting {
).withTag("cast");
Logger.i(TAG, "Casting local video (videoUrl: $videoUrl).");
ad.loadVideo("BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), speed);
ad.loadVideo("BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video));
return listOf(videoUrl);
}
@ -600,7 +445,7 @@ class StateCasting {
).withTag("cast");
Logger.i(TAG, "Casting local audio (audioUrl: $audioUrl).");
ad.loadVideo("BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), speed);
ad.loadVideo("BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video));
return listOf(audioUrl);
}
@ -696,7 +541,7 @@ class StateCasting {
).withTag("castLocalHls")
Logger.i(TAG, "added new castLocalHls handlers (hlsPath: $hlsPath, videoPath: $videoPath, audioPath: $audioPath, subtitlePath: $subtitlePath).")
ad.loadVideo("BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble(), speed)
ad.loadVideo("BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video))
return listOf(hlsUrl, videoUrl, audioUrl, subtitleUrl)
}
@ -745,7 +590,7 @@ class StateCasting {
}
Logger.i(TAG, "added new castLocalDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath, subtitlePath: $subtitlePath).");
ad.loadVideo("BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble(), speed);
ad.loadVideo("BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video));
return listOf(dashUrl, videoUrl, audioUrl, subtitleUrl);
}
@ -810,12 +655,18 @@ class StateCasting {
Logger.i(TAG, "Direct dash cast to casting device (videoUrl: $videoUrl, audioUrl: $audioUrl).");
Logger.v(TAG) { "Dash manifest: $content" };
ad.loadContent("application/dash+xml", content, resumePosition, video.duration.toDouble(), speed);
ad.loadContent("application/dash+xml", content, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video));
return listOf(videoUrl ?: "", audioUrl ?: "", subtitlesUrl ?: "", videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString());
}
private fun castProxiedHls(video: IPlatformVideoDetails, sourceUrl: String, codec: String?, resumePosition: Double, speed: Double?): List<String> {
private fun castProxiedHls(
video: IPlatformVideoDetails,
sourceUrl: String,
codec: String?,
resumePosition: Double,
speed: Double?
): List<String> {
_castServer.removeAllHandlers("castProxiedHlsMaster")
val ad = activeDevice ?: return listOf();
@ -826,7 +677,10 @@ class StateCasting {
val hlsUrl = url + hlsPath
Logger.i(TAG, "HLS url: $hlsUrl");
_castServer.addHandlerWithAllowAllOptions(HttpFunctionHandler("GET", hlsPath) { masterContext ->
_castServer.addHandlerWithAllowAllOptions(
HttpFunctionHandler(
"GET", hlsPath
) { masterContext ->
_castServer.removeAllHandlers("castProxiedHlsVariant")
val headers = masterContext.headers.clone()
@ -849,8 +703,10 @@ class StateCasting {
val vpHeaders = masterContext.headers.clone()
vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl";
val variantPlaylist = HLS.parseVariantPlaylist(masterPlaylistContent, sourceUrl)
val proxiedVariantPlaylist = proxyVariantPlaylist(url, id, variantPlaylist, video.isLive)
val variantPlaylist =
HLS.parseVariantPlaylist(masterPlaylistContent, sourceUrl)
val proxiedVariantPlaylist =
proxyVariantPlaylist(url, id, variantPlaylist, video.isLive)
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
masterContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
return@HttpFunctionHandler
@ -863,14 +719,22 @@ class StateCasting {
val newVariantPlaylistRefs = arrayListOf<HLS.VariantPlaylistReference>()
val newMediaRenditions = arrayListOf<HLS.MediaRendition>()
val newMasterPlaylist = HLS.MasterPlaylist(newVariantPlaylistRefs, newMediaRenditions, masterPlaylist.sessionDataList, masterPlaylist.independentSegments)
val newMasterPlaylist = HLS.MasterPlaylist(
newVariantPlaylistRefs,
newMediaRenditions,
masterPlaylist.sessionDataList,
masterPlaylist.independentSegments
)
for (variantPlaylistRef in masterPlaylist.variantPlaylistsRefs) {
val playlistId = UUID.randomUUID();
val newPlaylistPath = "/hls-playlist-${playlistId}"
val newPlaylistUrl = url + newPlaylistPath;
_castServer.addHandlerWithAllowAllOptions(HttpFunctionHandler("GET", newPlaylistPath) { vpContext ->
_castServer.addHandlerWithAllowAllOptions(
HttpFunctionHandler(
"GET", newPlaylistPath
) { vpContext ->
val vpHeaders = vpContext.headers.clone()
vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl";
@ -880,16 +744,20 @@ class StateCasting {
val vpContent = response.body?.string()
?: throw Exception("Variant playlist content is empty")
val variantPlaylist = HLS.parseVariantPlaylist(vpContent, variantPlaylistRef.url)
val proxiedVariantPlaylist = proxyVariantPlaylist(url, playlistId, variantPlaylist, video.isLive)
val variantPlaylist =
HLS.parseVariantPlaylist(vpContent, variantPlaylistRef.url)
val proxiedVariantPlaylist =
proxyVariantPlaylist(url, playlistId, variantPlaylist, video.isLive)
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
}.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castProxiedHlsVariant")
}.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castProxiedHlsVariant")
newVariantPlaylistRefs.add(HLS.VariantPlaylistReference(
newPlaylistUrl,
variantPlaylistRef.streamInfo
))
newVariantPlaylistRefs.add(
HLS.VariantPlaylistReference(
newPlaylistUrl, variantPlaylistRef.streamInfo
)
)
}
for (mediaRendition in masterPlaylist.mediaRenditions) {
@ -900,7 +768,10 @@ class StateCasting {
val newPlaylistPath = "/hls-playlist-${playlistId}"
newPlaylistUrl = url + newPlaylistPath
_castServer.addHandlerWithAllowAllOptions(HttpFunctionHandler("GET", newPlaylistPath) { vpContext ->
_castServer.addHandlerWithAllowAllOptions(
HttpFunctionHandler(
"GET", newPlaylistPath
) { vpContext ->
val vpHeaders = vpContext.headers.clone()
vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl";
@ -910,11 +781,15 @@ class StateCasting {
val vpContent = response.body?.string()
?: throw Exception("Variant playlist content is empty")
val variantPlaylist = HLS.parseVariantPlaylist(vpContent, mediaRendition.uri)
val proxiedVariantPlaylist = proxyVariantPlaylist(url, playlistId, variantPlaylist, video.isLive)
val variantPlaylist =
HLS.parseVariantPlaylist(vpContent, mediaRendition.uri)
val proxiedVariantPlaylist = proxyVariantPlaylist(
url, playlistId, variantPlaylist, video.isLive
)
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
}.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castProxiedHlsVariant")
}.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castProxiedHlsVariant")
}
newMediaRenditions.add(HLS.MediaRendition(
@ -930,13 +805,23 @@ class StateCasting {
}
masterContext.respondCode(200, headers, newMasterPlaylist.buildM3U8());
}.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castProxiedHlsMaster")
}.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castProxiedHlsMaster")
Logger.i(TAG, "added new castHlsIndirect handlers (hlsPath: $hlsPath).");
//ChromeCast is sometimes funky with resume position 0
val hackfixResumePosition = if (ad is ChromecastCastingDevice && !video.isLive && resumePosition == 0.0) 0.1 else resumePosition;
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, hackfixResumePosition, video.duration.toDouble(), speed);
val hackfixResumePosition =
if (ad.protocolType == CastProtocolType.CHROMECAST && !video.isLive && resumePosition == 0.0) 0.1 else resumePosition;
ad.loadVideo(
if (video.isLive) "LIVE" else "BUFFERED",
"application/vnd.apple.mpegurl",
hlsUrl,
hackfixResumePosition,
video.duration.toDouble(),
speed,
metadataFromVideo(video)
);
return listOf(hlsUrl);
}
@ -1110,14 +995,14 @@ class StateCasting {
).withTag("castHlsIndirectMaster")
Logger.i(TAG, "added new castHls handlers (hlsPath: $hlsPath).");
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble(), speed);
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video));
return listOf(hlsUrl, videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString());
}
private fun shouldProxyStreams(castingDevice: CastingDevice, videoSource: IVideoSource?, audioSource: IAudioSource?): Boolean {
val hasRequestModifier = (videoSource as? JSSource)?.hasRequestModifier == true || (audioSource as? JSSource)?.hasRequestModifier == true
return Settings.instance.casting.alwaysProxyRequests || castingDevice !is FCastCastingDevice || hasRequestModifier
return Settings.instance.casting.alwaysProxyRequests || castingDevice.protocolType != CastProtocolType.FCAST || hasRequestModifier
}
private suspend fun castDashIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List<String> {
@ -1193,12 +1078,12 @@ class StateCasting {
}
Logger.i(TAG, "added new castDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath).");
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble(), speed);
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video));
return listOf(dashUrl, videoUrl ?: "", audioUrl ?: "", subtitlesUrl ?: "", videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString());
}
private fun cleanExecutors() {
fun cleanExecutors() {
if (_videoExecutor != null) {
_videoExecutor?.cleanup()
_videoExecutor = null
@ -1397,114 +1282,32 @@ class StateCasting {
}
Logger.i(TAG, "added new castDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath).");
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble(), speed);
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video));
return listOf()
}
private fun deviceFromCastingDeviceInfo(deviceInfo: CastingDeviceInfo): CastingDevice {
return when (deviceInfo.type) {
CastProtocolType.CHROMECAST -> {
ChromecastCastingDevice(deviceInfo);
}
CastProtocolType.AIRPLAY -> {
AirPlayCastingDevice(deviceInfo);
}
CastProtocolType.FCAST -> {
FCastCastingDevice(deviceInfo);
}
}
fun addRememberedDevice(deviceInfo: CastingDeviceInfo): CastingDeviceInfo {
val device = deviceFromInfo(deviceInfo);
return addRememberedDevice(device);
}
private fun addOrUpdateChromeCastDevice(name: String, addresses: Array<InetAddress>, port: Int) {
return addOrUpdateCastDevice<ChromecastCastingDevice>(name,
deviceFactory = { ChromecastCastingDevice(name, addresses, port) },
deviceUpdater = { d ->
if (d.isReady) {
return@addOrUpdateCastDevice false;
fun addRememberedDevice(device: CastingDevice): CastingDeviceInfo {
val deviceInfo = device.getDeviceInfo()
return _storage.addDevice(deviceInfo)
}
val changed = addresses.contentEquals(d.addresses) || d.name != name || d.port != port;
if (changed) {
d.name = name;
d.addresses = addresses;
d.port = port;
fun getRememberedCastingDevices(): List<CastingDevice> {
return _storage.getDevices().map { deviceFromInfo(it) }
}
return@addOrUpdateCastDevice changed;
}
);
fun getRememberedCastingDeviceNames(): List<String> {
return _storage.getDeviceNames()
}
private fun addOrUpdateAirPlayDevice(name: String, addresses: Array<InetAddress>, port: Int) {
return addOrUpdateCastDevice<AirPlayCastingDevice>(name,
deviceFactory = { AirPlayCastingDevice(name, addresses, port) },
deviceUpdater = { d ->
if (d.isReady) {
return@addOrUpdateCastDevice false;
}
val changed = addresses.contentEquals(addresses) || d.name != name || d.port != port;
if (changed) {
d.name = name;
d.port = port;
d.addresses = addresses;
}
return@addOrUpdateCastDevice changed;
}
);
}
private fun addOrUpdateFastCastDevice(name: String, addresses: Array<InetAddress>, port: Int) {
return addOrUpdateCastDevice<FCastCastingDevice>(name,
deviceFactory = { FCastCastingDevice(name, addresses, port) },
deviceUpdater = { d ->
if (d.isReady) {
return@addOrUpdateCastDevice false;
}
val changed = addresses.contentEquals(addresses) || d.name != name || d.port != port;
if (changed) {
d.name = name;
d.port = port;
d.addresses = addresses;
}
return@addOrUpdateCastDevice changed;
}
);
}
private inline fun <reified TCastDevice> addOrUpdateCastDevice(name: String, deviceFactory: () -> TCastDevice, deviceUpdater: (device: TCastDevice) -> Boolean) where TCastDevice : CastingDevice {
var invokeEvents: (() -> Unit)? = null;
synchronized(devices) {
val device = devices[name];
if (device != null) {
if (device !is TCastDevice) {
Logger.w(TAG, "Device name conflict between device types. Ignoring device.");
} else {
val changed = deviceUpdater(device as TCastDevice);
if (changed) {
invokeEvents = {
onDeviceChanged.emit(device);
}
} else {
}
}
} else {
val newDevice = deviceFactory();
this.devices[name] = newDevice;
invokeEvents = {
onDeviceAdded.emit(newDevice);
};
}
}
invokeEvents?.let { _scopeMain.launch { it(); }; };
fun removeRememberedDevice(device: CastingDevice) {
val name = device.name ?: return
_storage.removeDevice(name)
}
fun enableDeveloper(enableDev: Boolean) {
@ -1513,47 +1316,27 @@ class StateCasting {
_castServer.addHandler(HttpFunctionHandler("GET", "/dashPlayer") { context ->
if (context.query.containsKey("dashUrl")) {
val dashUrl = context.query["dashUrl"];
val html = "<div>\n" +
" <video id=\"test\" width=\"1280\" height=\"720\" controls>\n" +
" </video>\n" +
" \n" +
" \n" +
" <script src=\"https://cdn.dashjs.org/latest/dash.all.min.js\"></script>\n" +
" <script>\n" +
" <!--setup the video element and attach it to the Dash player-->\n" +
" (function(){\n" +
" var url = \"${dashUrl}\";\n" +
" var player = dashjs.MediaPlayer().create();\n" +
" player.initialize(document.querySelector(\"#test\"), url, true);\n" +
" })();\n" +
" </script>\n" +
"</div>";
val html =
"<div>\n" + " <video id=\"test\" width=\"1280\" height=\"720\" controls>\n" + " </video>\n" + " \n" + " \n" + " <script src=\"https://cdn.dashjs.org/latest/dash.all.min.js\"></script>\n" + " <script>\n" + " <!--setup the video element and attach it to the Dash player-->\n" + " (function(){\n" + " var url = \"${dashUrl}\";\n" + " var player = dashjs.MediaPlayer().create();\n" + " player.initialize(document.querySelector(\"#test\"), url, true);\n" + " })();\n" + " </script>\n" + "</div>";
context.respondCode(200, html, "text/html");
}
}).withTag("dev");
}
}
@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 {
val instance: StateCasting = StateCasting();
private val representationRegex = Regex("<Representation .*?mimeType=\"(.*?)\".*?>(.*?)<\\/Representation>", RegexOption.DOT_MATCHES_ALL)
private val mediaInitializationRegex = Regex("(media|initiali[sz]ation)=\"([^\"]+)\"", RegexOption.DOT_MATCHES_ALL);
var instance: StateCasting = if (Settings.instance.casting.experimentalCasting) {
ExpStateCasting()
} else {
OldStateCasting()
}
private val representationRegex = Regex(
"<Representation .*?mimeType=\"(.*?)\".*?>(.*?)<\\/Representation>",
RegexOption.DOT_MATCHES_ALL
)
private val mediaInitializationRegex =
Regex("(media|initiali[sz]ation)=\"([^\"]+)\"", RegexOption.DOT_MATCHES_ALL);
private val TAG = "StateCasting";
}
}

View file

@ -8,11 +8,14 @@ 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.OldStateCasting
import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.platformplayer.toInetAddress
import com.futo.platformplayer.logging.Logger
class CastingAddDialog(context: Context?) : AlertDialog(context) {
@ -38,7 +41,13 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) {
_buttonConfirm = findViewById(R.id.button_confirm);
_buttonTutorial = findViewById(R.id.button_tutorial)
ArrayAdapter.createFromResource(context, R.array.casting_device_type_array, R.layout.spinner_item_simple).also { adapter ->
val deviceTypeArray = if (Settings.instance.casting.experimentalCasting) {
R.array.exp_casting_device_type_array
} else {
R.array.casting_device_type_array
}
ArrayAdapter.createFromResource(context, deviceTypeArray, R.layout.spinner_item_simple).also { adapter ->
adapter.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
_spinnerType.adapter = adapter;
};
@ -101,7 +110,11 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) {
_textError.visibility = View.GONE;
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();
};

View file

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

View file

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

View file

@ -576,9 +576,8 @@ class VideoDetailView : ConstraintLayout {
if(chapter?.type == ChapterType.SKIPPABLE) {
_layoutSkip.visibility = VISIBLE;
} else if(chapter?.type == ChapterType.SKIP || chapter?.type == ChapterType.SKIPONCE) {
val ad = StateCasting.instance.activeDevice
if (ad != null) {
ad.seekVideo(chapter.timeEnd)
if (StateCasting.instance.activeDevice != null) {
StateCasting.instance.videoSeekTo(chapter.timeEnd)
} else {
_player.seekTo((chapter.timeEnd * 1000).toLong());
}
@ -886,7 +885,7 @@ class VideoDetailView : ConstraintLayout {
if (ad != null) {
val currentChapter = _cast.getCurrentChapter((ad.time * 1000).toLong());
if(currentChapter?.type == ChapterType.SKIPPABLE) {
ad.seekVideo(currentChapter.timeEnd);
StateCasting.instance.videoSeekTo(currentChapter.timeEnd);
}
} else {
val currentChapter = _player.getCurrentChapter(_player.position);
@ -2368,7 +2367,7 @@ class VideoDetailView : ConstraintLayout {
?.distinct()
?.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 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(
@ -2393,7 +2392,7 @@ class VideoDetailView : ConstraintLayout {
val newPlaybackSpeed = playbackSpeedString.toDouble();
if (_isCasting) {
val ad = StateCasting.instance.activeDevice ?: return@subscribe
if (!ad.canSetSpeed) {
if (!ad.canSetSpeed()) {
return@subscribe
}

View file

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

View file

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

View file

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