diff --git a/app/build.gradle b/app/build.gradle index 65f600c6..5b375434 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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' + } } diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt index 4a536855..d67a531a 100644 --- a/app/src/main/java/com/futo/platformplayer/Settings.kt +++ b/app/src/main/java/com/futo/platformplayer/Settings.kt @@ -719,6 +719,11 @@ class Settings : FragmentedStorageFileJson() { @Serializable(with = FlexibleBooleanSerializer::class) var allowLinkLocalIpv4: Boolean = false; + @AdvancedField + @FormField(R.string.experimental_cast, FieldForm.TOGGLE, R.string.experimental_cast_description, 6) + @Serializable(with = FlexibleBooleanSerializer::class) + var experimentalCasting: Boolean = false + /*TODO: Should we have a different casting quality? @FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3) @DropdownFieldOptionsId(R.array.preferred_quality_array) diff --git a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt index 0d5bf8d9..5e4e1e42 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt @@ -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) diff --git a/app/src/main/java/com/futo/platformplayer/casting/AirPlayCastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/AirPlayCastingDevice.kt index 0cc1bebc..b89071f8 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/AirPlayCastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/AirPlayCastingDevice.kt @@ -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; diff --git a/app/src/main/java/com/futo/platformplayer/casting/CastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/CastingDevice.kt index 69f74747..cb6c9ab0 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/CastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/CastingDevice.kt @@ -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 { - 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 + abstract val onPlayChanged: Event1 + abstract val onTimeChanged: Event1 + abstract val onDurationChanged: Event1 + abstract val onVolumeChanged: Event1 + abstract val onSpeedChanged: Event1 + 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() - private var lastDurationChangeTime_ms: Long = 0 - var duration: Double = 0.0 - private set + @Throws + abstract fun seekTo(timeSeconds: Double) - 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) - } - } + @Throws + abstract fun changeVolume(timeSeconds: Double) - private var lastVolumeChangeTime_ms: Long = 0 - var volume: Double = 1.0 - private set + @Throws + abstract fun changeSpeed(speed: Double) - 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) - } - } + @Throws + abstract fun connect() - private var lastSpeedChangeTime_ms: Long = 0 - var speed: Double = 1.0 - private set + @Throws + abstract fun disconnect() + abstract fun getDeviceInfo(): CastingDeviceInfo + abstract fun getAddresses(): List - 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) - } - } + @Throws + abstract fun loadVideo( + streamType: String, + contentType: String, + contentId: String, + resumePosition: Double, + duration: Double, + speed: Double?, + metadata: Metadata? + ) - 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; + @Throws + abstract fun loadContent( + contentType: String, + content: String, + resumePosition: Double, + duration: Double, + speed: Double?, + metadata: Metadata? + ) - if (changed) { - onConnectionStateChanged.emit(value); - } - }; + abstract fun ensureThreadStarted() +} - var onConnectionStateChanged = Event1(); - var onPlayChanged = Event1(); - var onTimeChanged = Event1(); - var onDurationChanged = Event1(); - var onVolumeChanged = Event1(); - var onSpeedChanged = Event1(); - - 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; -} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt index f6582055..d85a7607 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt @@ -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; diff --git a/app/src/main/java/com/futo/platformplayer/casting/ExpCastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/ExpCastingDevice.kt new file mode 100644 index 00000000..bbd56ca3 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/casting/ExpCastingDevice.kt @@ -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(); + var onPlayChanged = Event1() + var onTimeChanged = Event1() + var onDurationChanged = Event1() + var onVolumeChanged = Event1() + var onSpeedChanged = Event1() + + override fun connectionStateChanged(state: DeviceConnectionState) { + onConnectionStateChanged.emit(state) + } + + override fun volumeChanged(volume: Double) { + onVolumeChanged.emit(volume) + } + + override fun timeChanged(time: Double) { + onTimeChanged.emit(time) + } + + override fun playbackStateChanged(state: PlaybackState) { + onPlayChanged.emit(state == PlaybackState.PLAYING) + } + + override fun durationChanged(duration: Double) { + onDurationChanged.emit(duration) + } + + override fun speedChanged(speed: Double) { + onSpeedChanged.emit(speed) + } + + override fun sourceChanged(source: Source) { + // TODO + } + + override fun keyEvent(event: GenericKeyEvent) { + // Unreachable + } + + override fun mediaEvent(event: GenericMediaEvent) { + // Unreachable + } + + 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() + override val onPlayChanged: Event1 + get() = eventHandler.onPlayChanged + override val onTimeChanged: Event1 + get() = eventHandler.onTimeChanged + override val onDurationChanged: Event1 + get() = eventHandler.onDurationChanged + override val onVolumeChanged: Event1 + get() = eventHandler.onVolumeChanged + override val onSpeedChanged: Event1 + 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 = 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" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/casting/ExpStateCasting.kt b/app/src/main/java/com/futo/platformplayer/casting/ExpStateCasting.kt new file mode 100644 index 00000000..fd4c75ae --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/casting/ExpStateCasting.kt @@ -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, + 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" + } +} diff --git a/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt index be62f726..27edb1db 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt @@ -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; diff --git a/app/src/main/java/com/futo/platformplayer/casting/OldCastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/OldCastingDevice.kt new file mode 100644 index 00000000..be3ac724 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/casting/OldCastingDevice.kt @@ -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 { + 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(); + var onPlayChanged = Event1(); + var onTimeChanged = Event1(); + var onDurationChanged = Event1(); + var onVolumeChanged = Event1(); + var onSpeedChanged = Event1(); + + 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; +} + +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 get() = inner.onConnectionStateChanged + override val onPlayChanged: Event1 get() = inner.onPlayChanged + override val onTimeChanged: Event1 get() = inner.onTimeChanged + override val onDurationChanged: Event1 get() = inner.onDurationChanged + override val onVolumeChanged: Event1 get() = inner.onVolumeChanged + override val onSpeedChanged: Event1 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 = 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 -> {} + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/casting/OldStateCasting.kt b/app/src/main/java/com/futo/platformplayer/casting/OldStateCasting.kt new file mode 100644 index 00000000..ec0339ee --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/casting/OldStateCasting.kt @@ -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(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, 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, + 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, + 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, 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, 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, + val services: List + ) + + @Serializable + private data class FCastService( + val port: Int, + val type: Int + ) + + companion object { + private val TAG = "OldStateCasting" + } +} diff --git a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt index f7802e86..e3b9ef5e 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt @@ -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 = hashMapOf(); val onDeviceAdded = Event1(); @@ -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(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, 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() - } + ad.ensureThreadStarted() } 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, 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"); @@ -351,10 +178,14 @@ class StateCasting { synchronized(_castingDialogLock) { if(_currentDialog == null) { _currentDialog = UIDialogs.showDialog(context, R.drawable.ic_loader_animated, true, - "Connecting to [${device.name}]", - "Make sure you are on the same network\n\nVPNs and guest networks can cause issues", null, -2, + "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 { - return _storage.getDevices().map { deviceFromCastingDeviceInfo(it) } - } - - fun getRememberedCastingDeviceNames(): List { - 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); - } else if(videoSource is IHLSManifestSource) { - if (proxyStreams || ad is ChromecastCastingDevice) { + 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 || 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) { + } else if (audioSource is IHLSManifestAudioSource) { + 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 { 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) } @@ -721,10 +566,10 @@ class StateCasting { Logger.v(TAG) { "Dash manifest: $dashContent" }; _castServer.addHandlerWithAllowAllOptions( - HttpConstantHandler("GET", dashPath, dashContent, - "application/dash+xml") - .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("cast"); + HttpConstantHandler("GET", dashPath, dashContent, + "application/dash+xml") + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("cast"); if (videoSource != null) { _castServer.addHandlerWithAllowAllOptions( HttpFileHandler("GET", videoPath, videoSource.container, videoSource.filePath) @@ -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 { + private fun castProxiedHls( + video: IPlatformVideoDetails, + sourceUrl: String, + codec: String?, + resumePosition: Double, + speed: Double? + ): List { _castServer.removeAllHandlers("castProxiedHlsMaster") val ad = activeDevice ?: return listOf(); @@ -826,117 +677,151 @@ class StateCasting { val hlsUrl = url + hlsPath Logger.i(TAG, "HLS url: $hlsUrl"); - _castServer.addHandlerWithAllowAllOptions(HttpFunctionHandler("GET", hlsPath) { masterContext -> - _castServer.removeAllHandlers("castProxiedHlsVariant") + _castServer.addHandlerWithAllowAllOptions( + HttpFunctionHandler( + "GET", hlsPath + ) { masterContext -> + _castServer.removeAllHandlers("castProxiedHlsVariant") - val headers = masterContext.headers.clone() - headers["Content-Type"] = "application/vnd.apple.mpegurl"; + val headers = masterContext.headers.clone() + headers["Content-Type"] = "application/vnd.apple.mpegurl"; - val masterPlaylistResponse = _client.get(sourceUrl) - check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" } + val masterPlaylistResponse = _client.get(sourceUrl) + check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" } - val masterPlaylistContent = masterPlaylistResponse.body?.string() - ?: throw Exception("Master playlist content is empty") + val masterPlaylistContent = masterPlaylistResponse.body?.string() + ?: throw Exception("Master playlist content is empty") - val masterPlaylist: HLS.MasterPlaylist - try { - masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, sourceUrl) - } catch (e: Throwable) { - if (masterPlaylistContent.lines().any { it.startsWith("#EXTINF:") }) { - //This is a variant playlist, not a master playlist - Logger.i(TAG, "HLS casting as variant playlist (codec: $codec): $hlsUrl"); + val masterPlaylist: HLS.MasterPlaylist + try { + masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, sourceUrl) + } catch (e: Throwable) { + if (masterPlaylistContent.lines().any { it.startsWith("#EXTINF:") }) { + //This is a variant playlist, not a master playlist + Logger.i(TAG, "HLS casting as variant playlist (codec: $codec): $hlsUrl"); - val vpHeaders = masterContext.headers.clone() - vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl"; - - val variantPlaylist = HLS.parseVariantPlaylist(masterPlaylistContent, sourceUrl) - val proxiedVariantPlaylist = proxyVariantPlaylist(url, id, variantPlaylist, video.isLive) - val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8() - masterContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8); - return@HttpFunctionHandler - } else { - throw e - } - } - - Logger.i(TAG, "HLS casting as master playlist: $hlsUrl"); - - val newVariantPlaylistRefs = arrayListOf() - val newMediaRenditions = arrayListOf() - val newMasterPlaylist = HLS.MasterPlaylist(newVariantPlaylistRefs, newMediaRenditions, masterPlaylist.sessionDataList, masterPlaylist.independentSegments) - - for (variantPlaylistRef in masterPlaylist.variantPlaylistsRefs) { - val playlistId = UUID.randomUUID(); - val newPlaylistPath = "/hls-playlist-${playlistId}" - val newPlaylistUrl = url + newPlaylistPath; - - _castServer.addHandlerWithAllowAllOptions(HttpFunctionHandler("GET", newPlaylistPath) { vpContext -> - val vpHeaders = vpContext.headers.clone() - vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl"; - - val response = _client.get(variantPlaylistRef.url) - check(response.isOk) { "Failed to get variant playlist: ${response.code}" } - - val vpContent = response.body?.string() - ?: throw Exception("Variant playlist content is empty") - - val variantPlaylist = HLS.parseVariantPlaylist(vpContent, variantPlaylistRef.url) - val proxiedVariantPlaylist = proxyVariantPlaylist(url, playlistId, variantPlaylist, video.isLive) - val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8() - vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8); - }.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castProxiedHlsVariant") - - newVariantPlaylistRefs.add(HLS.VariantPlaylistReference( - newPlaylistUrl, - variantPlaylistRef.streamInfo - )) - } - - for (mediaRendition in masterPlaylist.mediaRenditions) { - val playlistId = UUID.randomUUID() - - var newPlaylistUrl: String? = null - if (mediaRendition.uri != null) { - val newPlaylistPath = "/hls-playlist-${playlistId}" - newPlaylistUrl = url + newPlaylistPath - - _castServer.addHandlerWithAllowAllOptions(HttpFunctionHandler("GET", newPlaylistPath) { vpContext -> - val vpHeaders = vpContext.headers.clone() + val vpHeaders = masterContext.headers.clone() vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl"; - val response = _client.get(mediaRendition.uri) - check(response.isOk) { "Failed to get variant playlist: ${response.code}" } - - val vpContent = response.body?.string() - ?: throw Exception("Variant playlist content is empty") - - val variantPlaylist = HLS.parseVariantPlaylist(vpContent, mediaRendition.uri) - val proxiedVariantPlaylist = proxyVariantPlaylist(url, playlistId, variantPlaylist, video.isLive) + val variantPlaylist = + HLS.parseVariantPlaylist(masterPlaylistContent, sourceUrl) + val proxiedVariantPlaylist = + proxyVariantPlaylist(url, id, variantPlaylist, video.isLive) val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8() - vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8); - }.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castProxiedHlsVariant") + masterContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8); + return@HttpFunctionHandler + } else { + throw e + } } - newMediaRenditions.add(HLS.MediaRendition( - mediaRendition.type, - newPlaylistUrl, - mediaRendition.groupID, - mediaRendition.language, - mediaRendition.name, - mediaRendition.isDefault, - mediaRendition.isAutoSelect, - mediaRendition.isForced - )) - } + Logger.i(TAG, "HLS casting as master playlist: $hlsUrl"); - masterContext.respondCode(200, headers, newMasterPlaylist.buildM3U8()); - }.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castProxiedHlsMaster") + val newVariantPlaylistRefs = arrayListOf() + val newMediaRenditions = arrayListOf() + val newMasterPlaylist = HLS.MasterPlaylist( + newVariantPlaylistRefs, + newMediaRenditions, + masterPlaylist.sessionDataList, + masterPlaylist.independentSegments + ) + + for (variantPlaylistRef in masterPlaylist.variantPlaylistsRefs) { + val playlistId = UUID.randomUUID(); + val newPlaylistPath = "/hls-playlist-${playlistId}" + val newPlaylistUrl = url + newPlaylistPath; + + _castServer.addHandlerWithAllowAllOptions( + HttpFunctionHandler( + "GET", newPlaylistPath + ) { vpContext -> + val vpHeaders = vpContext.headers.clone() + vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl"; + + val response = _client.get(variantPlaylistRef.url) + check(response.isOk) { "Failed to get variant playlist: ${response.code}" } + + val vpContent = response.body?.string() + ?: throw Exception("Variant playlist content is empty") + + val variantPlaylist = + HLS.parseVariantPlaylist(vpContent, variantPlaylistRef.url) + val proxiedVariantPlaylist = + proxyVariantPlaylist(url, playlistId, variantPlaylist, video.isLive) + val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8() + vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8); + }.withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("castProxiedHlsVariant") + + newVariantPlaylistRefs.add( + HLS.VariantPlaylistReference( + newPlaylistUrl, variantPlaylistRef.streamInfo + ) + ) + } + + for (mediaRendition in masterPlaylist.mediaRenditions) { + val playlistId = UUID.randomUUID() + + var newPlaylistUrl: String? = null + if (mediaRendition.uri != null) { + val newPlaylistPath = "/hls-playlist-${playlistId}" + newPlaylistUrl = url + newPlaylistPath + + _castServer.addHandlerWithAllowAllOptions( + HttpFunctionHandler( + "GET", newPlaylistPath + ) { vpContext -> + val vpHeaders = vpContext.headers.clone() + vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl"; + + val response = _client.get(mediaRendition.uri) + check(response.isOk) { "Failed to get variant playlist: ${response.code}" } + + val vpContent = response.body?.string() + ?: throw Exception("Variant playlist content is empty") + + val variantPlaylist = + HLS.parseVariantPlaylist(vpContent, mediaRendition.uri) + val proxiedVariantPlaylist = proxyVariantPlaylist( + url, playlistId, variantPlaylist, video.isLive + ) + val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8() + vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8); + }.withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("castProxiedHlsVariant") + } + + newMediaRenditions.add(HLS.MediaRendition( + mediaRendition.type, + newPlaylistUrl, + mediaRendition.groupID, + mediaRendition.language, + mediaRendition.name, + mediaRendition.isDefault, + mediaRendition.isAutoSelect, + mediaRendition.isForced + )) + } + + masterContext.respondCode(200, headers, newMasterPlaylist.buildM3U8()); + }.withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("castProxiedHlsMaster") Logger.i(TAG, "added new castHlsIndirect handlers (hlsPath: $hlsPath)."); //ChromeCast is sometimes funky with resume position 0 - val hackfixResumePosition = if (ad 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 { @@ -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,163 +1282,61 @@ 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, port: Int) { - return addOrUpdateCastDevice(name, - deviceFactory = { ChromecastCastingDevice(name, addresses, port) }, - deviceUpdater = { d -> - if (d.isReady) { - return@addOrUpdateCastDevice false; - } - - val changed = addresses.contentEquals(d.addresses) || d.name != name || d.port != port; - if (changed) { - d.name = name; - d.addresses = addresses; - d.port = port; - } - - return@addOrUpdateCastDevice changed; - } - ); + fun addRememberedDevice(device: CastingDevice): CastingDeviceInfo { + val deviceInfo = device.getDeviceInfo() + return _storage.addDevice(deviceInfo) } - private fun addOrUpdateAirPlayDevice(name: String, addresses: Array, port: Int) { - return addOrUpdateCastDevice(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; - } - ); + fun getRememberedCastingDevices(): List { + return _storage.getDevices().map { deviceFromInfo(it) } } - private fun addOrUpdateFastCastDevice(name: String, addresses: Array, port: Int) { - return addOrUpdateCastDevice(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; - } - ); + fun getRememberedCastingDeviceNames(): List { + return _storage.getDeviceNames() } - private inline fun 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){ + fun enableDeveloper(enableDev: Boolean) { _castServer.removeAllHandlers("dev"); - if(enableDev) { + if (enableDev) { _castServer.addHandler(HttpFunctionHandler("GET", "/dashPlayer") { context -> if (context.query.containsKey("dashUrl")) { val dashUrl = context.query["dashUrl"]; - val html = "
\n" + - " \n" + - " \n" + - " \n" + - " \n" + - " \n" + - "
"; + val html = + "
\n" + " \n" + " \n" + " \n" + " \n" + " \n" + "
"; context.respondCode(200, html, "text/html"); } }).withTag("dev"); } } - @Serializable - private data class FCastNetworkConfig( - val name: String, - val addresses: List, - val services: List - ) - - @Serializable - private data class FCastService( - val port: Int, - val type: Int - ) - companion object { - val instance: StateCasting = StateCasting(); - - private val representationRegex = Regex("(.*?)<\\/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>", + RegexOption.DOT_MATCHES_ALL + ) + private val mediaInitializationRegex = + Regex("(media|initiali[sz]ation)=\"([^\"]+)\"", RegexOption.DOT_MATCHES_ALL); private val TAG = "StateCasting"; } -} - +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/CastingAddDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/CastingAddDialog.kt index 9eb71145..4cc6ce80 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/CastingAddDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/CastingAddDialog.kt @@ -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(); }; diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt index 2bb87111..28f2f989 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt @@ -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,15 +106,16 @@ 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() + updateUnifiedList() + } } StateCasting.instance.onDeviceChanged.subscribe(this) { d -> diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt index 862f8333..90f09a05 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt @@ -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()?.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) { - _imageDevice.setImageResource(R.drawable.ic_chromecast); - _textType.text = "Chromecast"; - } else if (d is AirPlayCastingDevice) { - _imageDevice.setImageResource(R.drawable.ic_airplay); - _textType.text = "AirPlay"; - } else if (d is FCastCastingDevice) { - _imageDevice.setImageResource(R.drawable.ic_fc); - _textType.text = "FastCast"; + when (d.protocolType) { + CastProtocolType.CHROMECAST -> { + _imageDevice.setImageResource(R.drawable.ic_chromecast); + _textType.text = "Chromecast"; + } + CastProtocolType.AIRPLAY -> { + _imageDevice.setImageResource(R.drawable.ic_airplay); + _textType.text = "AirPlay"; + } + 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) } } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt index 0818f9ed..317373e7 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt @@ -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,11 +2367,11 @@ 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( - R.string.quality), null, true, + R.string.quality), null, true, qualityPlaybackSpeedTitle, if (canSetSpeed) SlideUpMenuButtonList(this.context, null, "playback_rate").apply { val playbackSpeeds = Settings.instance.playback.getPlaybackSpeeds(); @@ -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 } diff --git a/app/src/main/java/com/futo/platformplayer/models/CastingDeviceInfo.kt b/app/src/main/java/com/futo/platformplayer/models/CastingDeviceInfo.kt index a530e415..4bb5fa5d 100644 --- a/app/src/main/java/com/futo/platformplayer/models/CastingDeviceInfo.kt +++ b/app/src/main/java/com/futo/platformplayer/models/CastingDeviceInfo.kt @@ -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; - var port: Int; - - constructor(name: String, type: CastProtocolType, addresses: Array, port: Int) { - this.name = name; - this.type = type; - this.addresses = addresses; - this.port = port; - } -} \ No newline at end of file +class CastingDeviceInfo( + var name: String, + var type: CastProtocolType, + var addresses: Array, + var port: Int +) \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt index 133dd26b..32fb5367 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt @@ -4,21 +4,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,16 +54,18 @@ class DeviceViewHolder : ViewHolder { val connect = { device?.let { dev -> - if (dev.isReady) { - StateCasting.instance.activeDevice?.stopCasting() - StateCasting.instance.connectDevice(dev) - onConnect.emit(dev) - } else { - try { - view.context?.let { UIDialogs.toast(it, "Device not ready, may be offline") } - } catch (e: Throwable) { - //Ignored + try { + if (dev.isReady) { + StateCasting.instance.activeDevice?.stopPlayback() + StateCasting.instance.connectDevice(dev) + onConnect.emit(dev) + } else { + view.context?.let { + UIDialogs.toast(it, "Device not ready, may be offline") + } } + } 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) { - if (d is ChromecastCastingDevice) { - _imageDevice.setImageResource(R.drawable.ic_chromecast); - _textType.text = "Chromecast"; - } else if (d is AirPlayCastingDevice) { - _imageDevice.setImageResource(R.drawable.ic_airplay); - _textType.text = "AirPlay"; - } else if (d is FCastCastingDevice) { - _imageDevice.setImageResource(R.drawable.ic_fc); - _textType.text = "FCast"; + when (d.protocolType) { + CastProtocolType.CHROMECAST -> { + _imageDevice.setImageResource(R.drawable.ic_chromecast); + _textType.text = "Chromecast"; + } + CastProtocolType.AIRPLAY -> { + _imageDevice.setImageResource(R.drawable.ic_airplay); + _textType.text = "AirPlay"; + } + 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; @@ -136,4 +146,8 @@ class DeviceViewHolder : ViewHolder { device = d; } + + companion object { + private val TAG = "DeviceViewHolder" + } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/casting/CastButton.kt b/app/src/main/java/com/futo/platformplayer/views/casting/CastButton.kt index f187230c..acffc619 100644 --- a/app/src/main/java/com/futo/platformplayer/views/casting/CastButton.kt +++ b/app/src/main/java/com/futo/platformplayer/views/casting/CastButton.kt @@ -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 diff --git a/app/src/main/java/com/futo/platformplayer/views/casting/CastView.kt b/app/src/main/java/com/futo/platformplayer/views/casting/CastView.kt index 161f3dc3..4dc307b5 100644 --- a/app/src/main/java/com/futo/platformplayer/views/casting/CastView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/casting/CastView.kt @@ -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,19 +97,30 @@ class CastView : ConstraintLayout { val d = StateCasting.instance.activeDevice ?: return@subscribe; _speedHoldWasPlaying = d.isPlaying _speedHoldPrevRate = d.speed - if (d.canSetSpeed) - d.changeSpeed(Settings.instance.playback.getHoldPlaybackSpeed()) - d.resumeVideo() + try { + if (d.canSetSpeed()) { + 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 { - val d = StateCasting.instance.activeDevice ?: return@subscribe; - if (!_speedHoldWasPlaying) d.pauseVideo() - d.changeSpeed(_speedHoldPrevRate) + try { + val d = StateCasting.instance.activeDevice ?: return@subscribe; + 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 { val d = StateCasting.instance.activeDevice ?: return@subscribe; - StateCasting.instance.videoSeekTo(d.expectedCurrentTime + it / 1000); + StateCasting.instance.videoSeekTo( d.expectedCurrentTime + it / 1000); }; _buttonLoop.setOnClickListener { @@ -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"; + } } \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_exp_fc.xml b/app/src/main/res/drawable/ic_exp_fc.xml new file mode 100644 index 00000000..355f8836 --- /dev/null +++ b/app/src/main/res/drawable/ic_exp_fc.xml @@ -0,0 +1,14 @@ + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6bc87252..92c52cf4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -82,6 +82,8 @@ If casting over IPV6 is allowed, can cause issues on some networks Allow Link Local IPV4 If casting over IPV4 link local is allowed, can cause issues on some networks + Experimental + Use experimental casting backend (requires restart) Discover Find new video sources to add These sources have been disabled @@ -1104,6 +1106,10 @@ ChromeCast AirPlay + + FCast + ChromeCast + None Error