mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-16 04:52:39 +02:00
casting: remove legacy backend
This commit is contained in:
@@ -725,11 +725,6 @@ 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 = true
|
||||
|
||||
/*TODO: Should we have a different casting quality?
|
||||
@FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3)
|
||||
@DropdownFieldOptionsId(R.array.preferred_quality_array)
|
||||
|
||||
@@ -1,330 +0,0 @@
|
||||
package com.futo.platformplayer.casting
|
||||
|
||||
import android.os.Looper
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.getConnectedSocket
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
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 java.net.InetAddress
|
||||
import java.util.UUID
|
||||
|
||||
class AirPlayCastingDevice : CastingDeviceLegacy {
|
||||
//See for more info: https://nto.github.io/AirPlay
|
||||
|
||||
override val protocol: CastProtocolType get() = CastProtocolType.AIRPLAY;
|
||||
override val isReady: Boolean get() = name != null && addresses != null && addresses?.isNotEmpty() == true && port != 0;
|
||||
override var usedRemoteAddress: InetAddress? = null;
|
||||
override var localAddress: InetAddress? = null;
|
||||
override val canSetVolume: Boolean get() = false;
|
||||
override val canSetSpeed: Boolean get() = true;
|
||||
|
||||
var addresses: Array<InetAddress>? = null;
|
||||
var port: Int = 0;
|
||||
|
||||
private var _scopeIO: CoroutineScope? = null;
|
||||
private var _started: Boolean = false;
|
||||
private var _sessionId: String? = null;
|
||||
private val _client = ManagedHttpClient();
|
||||
|
||||
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
|
||||
this.name = name;
|
||||
this.addresses = addresses;
|
||||
this.port = port;
|
||||
}
|
||||
|
||||
constructor(deviceInfo: CastingDeviceInfo) : super() {
|
||||
this.name = deviceInfo.name;
|
||||
this.addresses = deviceInfo.addresses.map { a -> a.toInetAddress() }.filterNotNull().toTypedArray();
|
||||
this.port = deviceInfo.port;
|
||||
}
|
||||
|
||||
override fun getAddresses(): List<InetAddress> {
|
||||
return addresses?.toList() ?: listOf();
|
||||
}
|
||||
|
||||
override fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double, speed: Double?) {
|
||||
if (invokeInIOScopeIfRequired({ loadVideo(streamType, contentType, contentId, resumePosition, duration, speed) })) {
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.i(FCastCastingDevice.TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration, speed: $speed)");
|
||||
|
||||
setTime(resumePosition);
|
||||
setDuration(duration);
|
||||
if (resumePosition > 0.0) {
|
||||
val pos = resumePosition / duration;
|
||||
Logger.i(TAG, "resumePosition: $resumePosition, duration: ${duration}, pos: $pos")
|
||||
post("play", "text/parameters", "Content-Location: $contentId\r\nStart-Position: $pos");
|
||||
} else {
|
||||
post("play", "text/parameters", "Content-Location: $contentId\r\nStart-Position: 0");
|
||||
}
|
||||
|
||||
if (speed != null) {
|
||||
changeSpeed(speed)
|
||||
}
|
||||
}
|
||||
|
||||
override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?) {
|
||||
throw NotImplementedError();
|
||||
}
|
||||
|
||||
override fun seekVideo(timeSeconds: Double) {
|
||||
if (invokeInIOScopeIfRequired({ seekVideo(timeSeconds) })) {
|
||||
return;
|
||||
}
|
||||
|
||||
post("scrub?position=${timeSeconds}");
|
||||
}
|
||||
|
||||
override fun resumeVideo() {
|
||||
if (invokeInIOScopeIfRequired(::resumeVideo)) {
|
||||
return;
|
||||
}
|
||||
|
||||
isPlaying = true;
|
||||
post("rate?value=1.000000");
|
||||
}
|
||||
|
||||
override fun pauseVideo() {
|
||||
if (invokeInIOScopeIfRequired(::pauseVideo)) {
|
||||
return;
|
||||
}
|
||||
|
||||
isPlaying = false;
|
||||
post("rate?value=0.000000");
|
||||
}
|
||||
|
||||
override fun stopVideo() {
|
||||
if (invokeInIOScopeIfRequired(::stopVideo)) {
|
||||
return;
|
||||
}
|
||||
|
||||
post("stop");
|
||||
}
|
||||
|
||||
override fun stopCasting() {
|
||||
if (invokeInIOScopeIfRequired(::stopCasting)) {
|
||||
return;
|
||||
}
|
||||
|
||||
post("stop");
|
||||
stop();
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
val adrs = addresses ?: return;
|
||||
if (_started) {
|
||||
return;
|
||||
}
|
||||
|
||||
_started = true;
|
||||
_scopeIO?.cancel();
|
||||
_scopeIO = CoroutineScope(Dispatchers.IO);
|
||||
|
||||
Logger.i(TAG, "Starting...");
|
||||
|
||||
_scopeIO?.launch {
|
||||
try {
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
|
||||
while (_scopeIO?.isActive == true) {
|
||||
try {
|
||||
val connectedSocket = getConnectedSocket(adrs.toList(), port);
|
||||
if (connectedSocket == null) {
|
||||
delay(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
usedRemoteAddress = connectedSocket.inetAddress;
|
||||
localAddress = connectedSocket.localAddress;
|
||||
connectedSocket.close();
|
||||
_sessionId = UUID.randomUUID().toString();
|
||||
break;
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to get setup initial connection to AirPlay device.", e)
|
||||
delay(1000);
|
||||
}
|
||||
}
|
||||
|
||||
while (_scopeIO?.isActive == true) {
|
||||
try {
|
||||
val progressInfo = getProgress();
|
||||
if (progressInfo == null) {
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
Logger.i(TAG, "Failed to retrieve progress from AirPlay device.");
|
||||
delay(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
connectionState = CastConnectionState.CONNECTED;
|
||||
|
||||
val progressIndex = progressInfo.lowercase().indexOf("position: ");
|
||||
if (progressIndex == -1) {
|
||||
delay(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
val progress = progressInfo.substring(progressIndex + "position: ".length).toDoubleOrNull() ?: continue;
|
||||
setTime(progress);
|
||||
|
||||
val durationIndex = progressInfo.lowercase().indexOf("duration: ");
|
||||
if (durationIndex == -1) {
|
||||
delay(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
val duration = progressInfo.substring(durationIndex + "duration: ".length).toDoubleOrNull() ?: continue;
|
||||
setDuration(duration);
|
||||
delay(1000);
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to get server info from AirPlay device.", e)
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to setup AirPlay device connection.", e)
|
||||
}
|
||||
};
|
||||
|
||||
Logger.i(TAG, "Started.");
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
Logger.i(TAG, "Stopping...");
|
||||
connectionState = CastConnectionState.DISCONNECTED;
|
||||
|
||||
usedRemoteAddress = null;
|
||||
localAddress = null;
|
||||
_started = false;
|
||||
_scopeIO?.cancel();
|
||||
_scopeIO = null;
|
||||
}
|
||||
|
||||
override fun changeSpeed(speed: Double) {
|
||||
setSpeed(speed)
|
||||
post("rate?value=$speed")
|
||||
}
|
||||
|
||||
override fun getDeviceInfo(): CastingDeviceInfo {
|
||||
return CastingDeviceInfo(name!!, CastProtocolType.AIRPLAY, addresses!!.filter { a -> a.hostAddress != null }.map { a -> a.hostAddress!! }.toTypedArray(), port);
|
||||
}
|
||||
|
||||
private fun getProgress(): String? {
|
||||
val info = get("scrub");
|
||||
Logger.i(TAG, "Progress: ${info ?: "null"}");
|
||||
return info;
|
||||
}
|
||||
|
||||
private fun getPlaybackInfo(): String? {
|
||||
val playbackInfo = get("playback-info");
|
||||
Logger.i(TAG, "Playback info: ${playbackInfo ?: "null"}");
|
||||
return playbackInfo;
|
||||
}
|
||||
|
||||
private fun getServerInfo(): String? {
|
||||
val serverInfo = get("server-info");
|
||||
Logger.i(TAG, "Server info: ${serverInfo ?: "null"}");
|
||||
return serverInfo;
|
||||
}
|
||||
|
||||
private fun post(path: String): Boolean {
|
||||
try {
|
||||
val sessionId = _sessionId ?: return false;
|
||||
|
||||
val headers = hashMapOf(
|
||||
"X-Apple-Device-ID" to "0xdc2b61a0ce79",
|
||||
"User-Agent" to "MediaControl/1.0",
|
||||
"Content-Length" to "0",
|
||||
"X-Apple-Session-ID" to sessionId
|
||||
);
|
||||
|
||||
val url = "http://${usedRemoteAddress}:${port}/${path}";
|
||||
|
||||
Logger.i(TAG, "POST $url");
|
||||
val response = _client.post(url, headers);
|
||||
if (!response.isOk) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to POST $path");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private fun post(path: String, contentType: String, body: String): Boolean {
|
||||
try {
|
||||
val sessionId = _sessionId ?: return false;
|
||||
|
||||
val headers = hashMapOf(
|
||||
"X-Apple-Device-ID" to "0xdc2b61a0ce79",
|
||||
"User-Agent" to "MediaControl/1.0",
|
||||
"X-Apple-Session-ID" to sessionId,
|
||||
"Content-Type" to contentType
|
||||
);
|
||||
|
||||
val url = "http://${usedRemoteAddress}:${port}/${path}";
|
||||
|
||||
Logger.i(TAG, "POST $url:\n$body");
|
||||
val response = _client.post(url, body, headers);
|
||||
if (!response.isOk) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to POST $path $body");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private fun get(path: String): String? {
|
||||
val sessionId = _sessionId ?: return null;
|
||||
|
||||
try {
|
||||
val headers = hashMapOf(
|
||||
"X-Apple-Device-ID" to "0xdc2b61a0ce79",
|
||||
"Content-Length" to "0",
|
||||
"User-Agent" to "MediaControl/1.0",
|
||||
"X-Apple-Session-ID" to sessionId
|
||||
);
|
||||
|
||||
val url = "http://${usedRemoteAddress}:${port}/${path}";
|
||||
|
||||
Logger.i(TAG, "GET $url");
|
||||
val response = _client.get(url, headers);
|
||||
if (!response.isOk) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (response.body == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.body.string();
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to GET $path");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private fun invokeInIOScopeIfRequired(action: () -> Unit): Boolean {
|
||||
if(Looper.getMainLooper().thread == Thread.currentThread()) {
|
||||
_scopeIO?.launch { action(); }
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
companion object {
|
||||
val TAG = "AirPlayCastingDevice";
|
||||
}
|
||||
}
|
||||
@@ -1,62 +1,289 @@
|
||||
package com.futo.platformplayer.casting
|
||||
|
||||
import android.os.Build
|
||||
import com.futo.platformplayer.BuildConfig
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
import org.fcast.sender_sdk.Metadata
|
||||
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.ApplicationInfo
|
||||
import org.fcast.sender_sdk.CastingDevice as RsCastingDevice
|
||||
import org.fcast.sender_sdk.KeyEvent
|
||||
import org.fcast.sender_sdk.MediaEvent
|
||||
import java.net.InetAddress
|
||||
import org.fcast.sender_sdk.PlaybackState
|
||||
import org.fcast.sender_sdk.Source
|
||||
import org.fcast.sender_sdk.urlFormatIpAddr
|
||||
import java.net.Inet4Address
|
||||
import java.net.Inet6Address
|
||||
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.EventSubscription
|
||||
import org.fcast.sender_sdk.IpAddr
|
||||
import org.fcast.sender_sdk.LoadRequest
|
||||
import org.fcast.sender_sdk.MediaItemEventType
|
||||
import org.fcast.sender_sdk.Metadata
|
||||
import org.fcast.sender_sdk.ProtocolType
|
||||
|
||||
abstract class CastingDevice {
|
||||
abstract val isReady: Boolean
|
||||
abstract val usedRemoteAddress: InetAddress?
|
||||
abstract val localAddress: InetAddress?
|
||||
abstract val name: String?
|
||||
abstract val onConnectionStateChanged: Event1<CastConnectionState>
|
||||
abstract val onPlayChanged: Event1<Boolean>
|
||||
abstract val onTimeChanged: Event1<Double>
|
||||
abstract val onDurationChanged: Event1<Double>
|
||||
abstract val onVolumeChanged: Event1<Double>
|
||||
abstract val onSpeedChanged: Event1<Double>
|
||||
abstract val onMediaItemEnd: Event0
|
||||
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
|
||||
enum class CastConnectionState {
|
||||
DISCONNECTED,
|
||||
CONNECTING,
|
||||
CONNECTED
|
||||
}
|
||||
|
||||
@Throws
|
||||
abstract fun resumePlayback()
|
||||
@Serializable(with = CastProtocolType.CastProtocolTypeSerializer::class)
|
||||
enum class CastProtocolType {
|
||||
CHROMECAST,
|
||||
AIRPLAY,
|
||||
FCAST;
|
||||
|
||||
@Throws
|
||||
abstract fun pausePlayback()
|
||||
object CastProtocolTypeSerializer : KSerializer<CastProtocolType> {
|
||||
override val descriptor: SerialDescriptor =
|
||||
PrimitiveSerialDescriptor("CastProtocolType", PrimitiveKind.STRING)
|
||||
|
||||
@Throws
|
||||
abstract fun stopPlayback()
|
||||
override fun serialize(encoder: Encoder, value: CastProtocolType) {
|
||||
encoder.encodeString(value.name)
|
||||
}
|
||||
|
||||
@Throws
|
||||
abstract fun seekTo(timeSeconds: Double)
|
||||
override fun deserialize(decoder: Decoder): CastProtocolType {
|
||||
val name = decoder.decodeString()
|
||||
return when (name) {
|
||||
"FASTCAST" -> FCAST // Handle the renamed case
|
||||
else -> CastProtocolType.valueOf(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Throws
|
||||
abstract fun changeVolume(timeSeconds: Double)
|
||||
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()
|
||||
)
|
||||
)
|
||||
|
||||
@Throws
|
||||
abstract fun changeSpeed(speed: Double)
|
||||
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()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Throws
|
||||
abstract fun connect()
|
||||
// abstract class CastingDevice {
|
||||
class CastingDevice(val device: RsCastingDevice) {
|
||||
// abstract val isReady: Boolean
|
||||
// abstract val usedRemoteAddress: InetAddress?
|
||||
// abstract val localAddress: InetAddress?
|
||||
// abstract val name: String?
|
||||
// abstract val onConnectionStateChanged: Event1<CastConnectionState>
|
||||
// abstract val onPlayChanged: Event1<Boolean>
|
||||
// abstract val onTimeChanged: Event1<Double>
|
||||
// abstract val onDurationChanged: Event1<Double>
|
||||
// abstract val onVolumeChanged: Event1<Double>
|
||||
// abstract val onSpeedChanged: Event1<Double>
|
||||
// abstract val onMediaItemEnd: Event0
|
||||
// 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
|
||||
|
||||
@Throws
|
||||
abstract fun disconnect()
|
||||
abstract fun getDeviceInfo(): CastingDeviceInfo
|
||||
abstract fun getAddresses(): List<InetAddress>
|
||||
// @Throws
|
||||
// abstract fun resumePlayback()
|
||||
|
||||
@Throws
|
||||
abstract fun loadVideo(
|
||||
// @Throws
|
||||
// abstract fun pausePlayback()
|
||||
|
||||
// @Throws
|
||||
// abstract fun stopPlayback()
|
||||
|
||||
// @Throws
|
||||
// abstract fun seekTo(timeSeconds: Double)
|
||||
|
||||
// @Throws
|
||||
// abstract fun changeVolume(timeSeconds: Double)
|
||||
|
||||
// @Throws
|
||||
// abstract fun changeSpeed(speed: Double)
|
||||
|
||||
// @Throws
|
||||
// abstract fun connect()
|
||||
|
||||
// @Throws
|
||||
// abstract fun disconnect()
|
||||
// abstract fun getDeviceInfo(): CastingDeviceInfo
|
||||
// abstract fun getAddresses(): List<InetAddress>
|
||||
|
||||
// @Throws
|
||||
// abstract fun loadVideo(
|
||||
// streamType: String,
|
||||
// contentType: String,
|
||||
// contentId: String,
|
||||
// resumePosition: Double,
|
||||
// duration: Double,
|
||||
// speed: Double?,
|
||||
// metadata: Metadata?
|
||||
// )
|
||||
|
||||
// @Throws
|
||||
// fun loadContent(
|
||||
// contentType: String,
|
||||
// content: String,
|
||||
// resumePosition: Double,
|
||||
// duration: Double,
|
||||
// speed: Double?,
|
||||
// metadata: Metadata?
|
||||
// )
|
||||
|
||||
// fun ensureThreadStarted()
|
||||
|
||||
class EventHandler : RsDeviceEventHandler {
|
||||
var onConnectionStateChanged = Event1<DeviceConnectionState>();
|
||||
var onPlayChanged = Event1<Boolean>()
|
||||
var onTimeChanged = Event1<Double>()
|
||||
var onDurationChanged = Event1<Double>()
|
||||
var onVolumeChanged = Event1<Double>()
|
||||
var onSpeedChanged = Event1<Double>()
|
||||
var onMediaItemEnd = Event0()
|
||||
|
||||
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: KeyEvent) {
|
||||
// Unreachable
|
||||
}
|
||||
|
||||
override fun mediaEvent(event: MediaEvent) {
|
||||
if (event.type == MediaItemEventType.END) {
|
||||
onMediaItemEnd.emit()
|
||||
}
|
||||
}
|
||||
|
||||
override fun playbackError(message: String) {
|
||||
Logger.e(TAG, "Playback error: $message")
|
||||
}
|
||||
}
|
||||
|
||||
val eventHandler = EventHandler()
|
||||
val isReady: Boolean
|
||||
get() = device.isReady()
|
||||
val name: String
|
||||
get() = device.name()
|
||||
var usedRemoteAddress: InetAddress? = null
|
||||
var localAddress: InetAddress? = null
|
||||
fun canSetVolume(): Boolean = device.supportsFeature(DeviceFeature.SET_VOLUME)
|
||||
fun canSetSpeed(): Boolean = device.supportsFeature(DeviceFeature.SET_SPEED)
|
||||
|
||||
val onConnectionStateChanged =
|
||||
Event1<CastConnectionState>()
|
||||
val onPlayChanged: Event1<Boolean>
|
||||
get() = eventHandler.onPlayChanged
|
||||
val onTimeChanged: Event1<Double>
|
||||
get() = eventHandler.onTimeChanged
|
||||
val onDurationChanged: Event1<Double>
|
||||
get() = eventHandler.onDurationChanged
|
||||
val onVolumeChanged: Event1<Double>
|
||||
get() = eventHandler.onVolumeChanged
|
||||
val onSpeedChanged: Event1<Double>
|
||||
get() = eventHandler.onSpeedChanged
|
||||
val onMediaItemEnd: Event0
|
||||
get() = eventHandler.onMediaItemEnd
|
||||
|
||||
fun resumePlayback() = device.resumePlayback()
|
||||
fun pausePlayback() = device.pausePlayback()
|
||||
fun stopPlayback() = device.stopPlayback()
|
||||
fun seekTo(timeSeconds: Double) = device.seek(timeSeconds)
|
||||
fun changeVolume(newVolume: Double) {
|
||||
device.changeVolume(newVolume)
|
||||
volume = newVolume
|
||||
}
|
||||
fun changeSpeed(speed: Double) = device.changeSpeed(speed)
|
||||
fun connect() = device.connect(
|
||||
ApplicationInfo(
|
||||
"Grayjay Android",
|
||||
"${BuildConfig.VERSION_NAME}-${BuildConfig.FLAVOR}",
|
||||
"${Build.MANUFACTURER} ${Build.MODEL}"
|
||||
),
|
||||
eventHandler,
|
||||
1000.toULong()
|
||||
)
|
||||
|
||||
fun disconnect() = device.disconnect()
|
||||
|
||||
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(),
|
||||
)
|
||||
}
|
||||
|
||||
fun getAddresses(): List<InetAddress> = device.getAddresses().map {
|
||||
ipAddrToInetAddress(it)
|
||||
}
|
||||
|
||||
fun loadVideo(
|
||||
streamType: String,
|
||||
contentType: String,
|
||||
contentId: String,
|
||||
@@ -64,18 +291,107 @@ abstract class CastingDevice {
|
||||
duration: Double,
|
||||
speed: Double?,
|
||||
metadata: Metadata?
|
||||
) = device.load(
|
||||
LoadRequest.Video(
|
||||
contentType = contentType,
|
||||
url = contentId,
|
||||
resumePosition = resumePosition,
|
||||
speed = speed,
|
||||
volume = volume,
|
||||
metadata = metadata,
|
||||
requestHeaders = null,
|
||||
)
|
||||
)
|
||||
|
||||
@Throws
|
||||
abstract fun loadContent(
|
||||
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,
|
||||
requestHeaders = null,
|
||||
)
|
||||
)
|
||||
|
||||
abstract fun ensureThreadStarted()
|
||||
}
|
||||
var connectionState = CastConnectionState.DISCONNECTED
|
||||
val protocolType: CastProtocolType
|
||||
get() = when (device.castingProtocol()) {
|
||||
ProtocolType.CHROMECAST -> CastProtocolType.CHROMECAST
|
||||
ProtocolType.F_CAST -> CastProtocolType.FCAST
|
||||
}
|
||||
var volume: Double = 1.0
|
||||
var duration: Double = 0.0
|
||||
private var lastTimeChangeTime_ms: Long = 0
|
||||
var time: Double = 0.0
|
||||
var speed: Double = 0.0
|
||||
var isPlaying: Boolean = false
|
||||
|
||||
val expectedCurrentTime: Double
|
||||
get() {
|
||||
val diff =
|
||||
if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0;
|
||||
return time + diff
|
||||
}
|
||||
|
||||
init {
|
||||
eventHandler.onConnectionStateChanged.subscribe { newState ->
|
||||
when (newState) {
|
||||
is DeviceConnectionState.Connected -> {
|
||||
if (device.supportsFeature(DeviceFeature.MEDIA_EVENT_SUBSCRIPTION)) {
|
||||
try {
|
||||
device.subscribeEvent(EventSubscription.MediaItemEnd)
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Failed to subscribe to MediaItemEnd events: $e")
|
||||
}
|
||||
}
|
||||
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.DISCONNECTED
|
||||
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 }
|
||||
}
|
||||
|
||||
fun ensureThreadStarted() {}
|
||||
|
||||
companion object {
|
||||
private val TAG = "CastingDeviceExp"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,289 +0,0 @@
|
||||
package com.futo.platformplayer.casting
|
||||
|
||||
import android.os.Build
|
||||
import com.futo.platformplayer.BuildConfig
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
import com.futo.polycentric.core.Event
|
||||
import org.fcast.sender_sdk.ApplicationInfo
|
||||
import org.fcast.sender_sdk.KeyEvent
|
||||
import org.fcast.sender_sdk.MediaEvent
|
||||
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.EventSubscription
|
||||
import org.fcast.sender_sdk.IpAddr
|
||||
import org.fcast.sender_sdk.LoadRequest
|
||||
import org.fcast.sender_sdk.MediaItemEventType
|
||||
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 CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
|
||||
class EventHandler : RsDeviceEventHandler {
|
||||
var onConnectionStateChanged = Event1<DeviceConnectionState>();
|
||||
var onPlayChanged = Event1<Boolean>()
|
||||
var onTimeChanged = Event1<Double>()
|
||||
var onDurationChanged = Event1<Double>()
|
||||
var onVolumeChanged = Event1<Double>()
|
||||
var onSpeedChanged = Event1<Double>()
|
||||
var onMediaItemEnd = Event0()
|
||||
|
||||
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: KeyEvent) {
|
||||
// Unreachable
|
||||
}
|
||||
|
||||
override fun mediaEvent(event: MediaEvent) {
|
||||
if (event.type == MediaItemEventType.END) {
|
||||
onMediaItemEnd.emit()
|
||||
}
|
||||
}
|
||||
|
||||
override fun playbackError(message: String) {
|
||||
Logger.e(TAG, "Playback error: $message")
|
||||
}
|
||||
}
|
||||
|
||||
val eventHandler = EventHandler()
|
||||
override val isReady: Boolean
|
||||
get() = device.isReady()
|
||||
override val name: String
|
||||
get() = device.name()
|
||||
override var usedRemoteAddress: InetAddress? = null
|
||||
override var localAddress: InetAddress? = null
|
||||
override fun canSetVolume(): Boolean = device.supportsFeature(DeviceFeature.SET_VOLUME)
|
||||
override fun canSetSpeed(): Boolean = device.supportsFeature(DeviceFeature.SET_SPEED)
|
||||
|
||||
override val onConnectionStateChanged =
|
||||
Event1<CastConnectionState>()
|
||||
override val onPlayChanged: Event1<Boolean>
|
||||
get() = eventHandler.onPlayChanged
|
||||
override val onTimeChanged: Event1<Double>
|
||||
get() = eventHandler.onTimeChanged
|
||||
override val onDurationChanged: Event1<Double>
|
||||
get() = eventHandler.onDurationChanged
|
||||
override val onVolumeChanged: Event1<Double>
|
||||
get() = eventHandler.onVolumeChanged
|
||||
override val onSpeedChanged: Event1<Double>
|
||||
get() = eventHandler.onSpeedChanged
|
||||
override val onMediaItemEnd: Event0
|
||||
get() = eventHandler.onMediaItemEnd
|
||||
|
||||
override fun resumePlayback() = device.resumePlayback()
|
||||
override fun pausePlayback() = device.pausePlayback()
|
||||
override fun stopPlayback() = device.stopPlayback()
|
||||
override fun seekTo(timeSeconds: Double) = device.seek(timeSeconds)
|
||||
override fun changeVolume(newVolume: Double) {
|
||||
device.changeVolume(newVolume)
|
||||
volume = newVolume
|
||||
}
|
||||
override fun changeSpeed(speed: Double) = device.changeSpeed(speed)
|
||||
override fun connect() = device.connect(
|
||||
ApplicationInfo(
|
||||
"Grayjay Android",
|
||||
"${BuildConfig.VERSION_NAME}-${BuildConfig.FLAVOR}",
|
||||
"${Build.MANUFACTURER} ${Build.MODEL}"
|
||||
),
|
||||
eventHandler,
|
||||
1000.toULong()
|
||||
)
|
||||
|
||||
override fun disconnect() = device.disconnect()
|
||||
|
||||
override fun getDeviceInfo(): CastingDeviceInfo {
|
||||
val info = device.getDeviceInfo()
|
||||
return CastingDeviceInfo(
|
||||
info.name,
|
||||
when (info.protocol) {
|
||||
ProtocolType.CHROMECAST -> CastProtocolType.CHROMECAST
|
||||
ProtocolType.F_CAST -> CastProtocolType.FCAST
|
||||
},
|
||||
addresses = info.addresses.map { urlFormatIpAddr(it) }.toTypedArray(),
|
||||
port = info.port.toInt(),
|
||||
)
|
||||
}
|
||||
|
||||
override fun getAddresses(): List<InetAddress> = device.getAddresses().map {
|
||||
ipAddrToInetAddress(it)
|
||||
}
|
||||
|
||||
override fun loadVideo(
|
||||
streamType: String,
|
||||
contentType: String,
|
||||
contentId: String,
|
||||
resumePosition: Double,
|
||||
duration: Double,
|
||||
speed: Double?,
|
||||
metadata: Metadata?
|
||||
) = device.load(
|
||||
LoadRequest.Video(
|
||||
contentType = contentType,
|
||||
url = contentId,
|
||||
resumePosition = resumePosition,
|
||||
speed = speed,
|
||||
volume = volume,
|
||||
metadata = metadata,
|
||||
requestHeaders = null,
|
||||
)
|
||||
)
|
||||
|
||||
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,
|
||||
requestHeaders = null,
|
||||
)
|
||||
)
|
||||
|
||||
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 -> {
|
||||
if (device.supportsFeature(DeviceFeature.MEDIA_EVENT_SUBSCRIPTION)) {
|
||||
try {
|
||||
device.subscribeEvent(EventSubscription.MediaItemEnd)
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Failed to subscribe to MediaItemEnd events: $e")
|
||||
}
|
||||
}
|
||||
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.DISCONNECTED
|
||||
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 = "CastingDeviceExp"
|
||||
}
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
package com.futo.platformplayer.casting
|
||||
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import org.fcast.sender_sdk.Metadata
|
||||
import java.net.InetAddress
|
||||
|
||||
enum class CastConnectionState {
|
||||
DISCONNECTED,
|
||||
CONNECTING,
|
||||
CONNECTED
|
||||
}
|
||||
|
||||
@Serializable(with = CastProtocolType.CastProtocolTypeSerializer::class)
|
||||
enum class CastProtocolType {
|
||||
CHROMECAST,
|
||||
AIRPLAY,
|
||||
FCAST;
|
||||
|
||||
object CastProtocolTypeSerializer : KSerializer<CastProtocolType> {
|
||||
override val descriptor: SerialDescriptor =
|
||||
PrimitiveSerialDescriptor("CastProtocolType", PrimitiveKind.STRING)
|
||||
|
||||
override fun serialize(encoder: Encoder, value: CastProtocolType) {
|
||||
encoder.encodeString(value.name)
|
||||
}
|
||||
|
||||
override fun deserialize(decoder: Decoder): CastProtocolType {
|
||||
val name = decoder.decodeString()
|
||||
return when (name) {
|
||||
"FASTCAST" -> FCAST // Handle the renamed case
|
||||
else -> CastProtocolType.valueOf(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abstract class CastingDeviceLegacy {
|
||||
abstract val protocol: CastProtocolType;
|
||||
abstract val isReady: Boolean;
|
||||
abstract var usedRemoteAddress: InetAddress?;
|
||||
abstract var localAddress: InetAddress?;
|
||||
abstract val canSetVolume: Boolean;
|
||||
abstract val canSetSpeed: Boolean;
|
||||
|
||||
var name: String? = null;
|
||||
var isPlaying: Boolean = false
|
||||
set(value) {
|
||||
val changed = value != field;
|
||||
field = value;
|
||||
if (changed) {
|
||||
onPlayChanged.emit(value);
|
||||
}
|
||||
};
|
||||
|
||||
private var lastTimeChangeTime_ms: Long = 0
|
||||
var time: Double = 0.0
|
||||
private set
|
||||
|
||||
protected fun setTime(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
|
||||
if (changeTime_ms > lastTimeChangeTime_ms && value != time) {
|
||||
time = value
|
||||
lastTimeChangeTime_ms = changeTime_ms
|
||||
onTimeChanged.emit(value)
|
||||
}
|
||||
}
|
||||
|
||||
private var lastDurationChangeTime_ms: Long = 0
|
||||
var duration: Double = 0.0
|
||||
private set
|
||||
|
||||
protected fun setDuration(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
|
||||
if (changeTime_ms > lastDurationChangeTime_ms && value != duration) {
|
||||
duration = value
|
||||
lastDurationChangeTime_ms = changeTime_ms
|
||||
onDurationChanged.emit(value)
|
||||
}
|
||||
}
|
||||
|
||||
private var lastVolumeChangeTime_ms: Long = 0
|
||||
var volume: Double = 1.0
|
||||
private set
|
||||
|
||||
protected fun setVolume(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
|
||||
if (changeTime_ms > lastVolumeChangeTime_ms && value != volume) {
|
||||
volume = value
|
||||
lastVolumeChangeTime_ms = changeTime_ms
|
||||
onVolumeChanged.emit(value)
|
||||
}
|
||||
}
|
||||
|
||||
private var lastSpeedChangeTime_ms: Long = 0
|
||||
var speed: Double = 1.0
|
||||
private set
|
||||
|
||||
protected fun setSpeed(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
|
||||
if (changeTime_ms > lastSpeedChangeTime_ms && value != speed) {
|
||||
speed = value
|
||||
lastSpeedChangeTime_ms = changeTime_ms
|
||||
onSpeedChanged.emit(value)
|
||||
}
|
||||
}
|
||||
|
||||
val expectedCurrentTime: Double
|
||||
get() {
|
||||
val diff =
|
||||
if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0;
|
||||
return time + diff;
|
||||
};
|
||||
var connectionState: CastConnectionState = CastConnectionState.DISCONNECTED
|
||||
set(value) {
|
||||
val changed = value != field;
|
||||
field = value;
|
||||
|
||||
if (changed) {
|
||||
onConnectionStateChanged.emit(value);
|
||||
}
|
||||
};
|
||||
|
||||
var onConnectionStateChanged = Event1<CastConnectionState>();
|
||||
var onPlayChanged = Event1<Boolean>();
|
||||
var onTimeChanged = Event1<Double>();
|
||||
var onDurationChanged = Event1<Double>();
|
||||
var onVolumeChanged = Event1<Double>();
|
||||
var onSpeedChanged = Event1<Double>();
|
||||
|
||||
abstract fun stopCasting();
|
||||
|
||||
abstract fun seekVideo(timeSeconds: Double);
|
||||
abstract fun stopVideo();
|
||||
abstract fun pauseVideo();
|
||||
abstract fun resumeVideo();
|
||||
abstract fun loadVideo(
|
||||
streamType: String,
|
||||
contentType: String,
|
||||
contentId: String,
|
||||
resumePosition: Double,
|
||||
duration: Double,
|
||||
speed: Double?
|
||||
);
|
||||
|
||||
abstract fun loadContent(
|
||||
contentType: String,
|
||||
content: String,
|
||||
resumePosition: Double,
|
||||
duration: Double,
|
||||
speed: Double?
|
||||
);
|
||||
|
||||
open fun changeVolume(volume: Double) {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
open fun changeSpeed(speed: Double) {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
abstract fun start();
|
||||
abstract fun stop();
|
||||
|
||||
abstract fun getDeviceInfo(): CastingDeviceInfo;
|
||||
|
||||
abstract fun getAddresses(): List<InetAddress>;
|
||||
}
|
||||
|
||||
class CastingDeviceLegacyWrapper(val inner: CastingDeviceLegacy) : CastingDevice() {
|
||||
override val isReady: Boolean get() = inner.isReady
|
||||
override val usedRemoteAddress: InetAddress? get() = inner.usedRemoteAddress
|
||||
override val localAddress: InetAddress? get() = inner.localAddress
|
||||
override val name: String? get() = inner.name
|
||||
override val onConnectionStateChanged: Event1<CastConnectionState> get() = inner.onConnectionStateChanged
|
||||
override val onPlayChanged: Event1<Boolean> get() = inner.onPlayChanged
|
||||
override val onTimeChanged: Event1<Double> get() = inner.onTimeChanged
|
||||
override val onDurationChanged: Event1<Double> get() = inner.onDurationChanged
|
||||
override val onVolumeChanged: Event1<Double> get() = inner.onVolumeChanged
|
||||
override val onSpeedChanged: Event1<Double> get() = inner.onSpeedChanged
|
||||
override val onMediaItemEnd: Event0 = Event0()
|
||||
override var connectionState: CastConnectionState
|
||||
get() = inner.connectionState
|
||||
set(_) = Unit
|
||||
override val protocolType: CastProtocolType get() = inner.protocol
|
||||
override var isPlaying: Boolean
|
||||
get() = inner.isPlaying
|
||||
set(_) = Unit
|
||||
override val expectedCurrentTime: Double
|
||||
get() = inner.expectedCurrentTime
|
||||
override var speed: Double
|
||||
get() = inner.speed
|
||||
set(_) = Unit
|
||||
override var time: Double
|
||||
get() = inner.time
|
||||
set(_) = Unit
|
||||
override var duration: Double
|
||||
get() = inner.duration
|
||||
set(_) = Unit
|
||||
override var volume: Double
|
||||
get() = inner.volume
|
||||
set(_) = Unit
|
||||
|
||||
override fun canSetVolume(): Boolean = inner.canSetVolume
|
||||
override fun canSetSpeed(): Boolean = inner.canSetSpeed
|
||||
override fun resumePlayback() = inner.resumeVideo()
|
||||
override fun pausePlayback() = inner.pauseVideo()
|
||||
override fun stopPlayback() = inner.stopVideo()
|
||||
override fun seekTo(timeSeconds: Double) = inner.seekVideo(timeSeconds)
|
||||
override fun changeVolume(timeSeconds: Double) = inner.changeVolume(timeSeconds)
|
||||
override fun changeSpeed(speed: Double) = inner.changeSpeed(speed)
|
||||
override fun connect() = inner.start()
|
||||
override fun disconnect() = inner.stop()
|
||||
override fun getDeviceInfo(): CastingDeviceInfo = inner.getDeviceInfo()
|
||||
override fun getAddresses(): List<InetAddress> = inner.getAddresses()
|
||||
override fun loadVideo(
|
||||
streamType: String,
|
||||
contentType: String,
|
||||
contentId: String,
|
||||
resumePosition: Double,
|
||||
duration: Double,
|
||||
speed: Double?,
|
||||
metadata: Metadata?
|
||||
) = inner.loadVideo(streamType, contentType, contentId, resumePosition, duration, speed)
|
||||
|
||||
override fun loadContent(
|
||||
contentType: String,
|
||||
content: String,
|
||||
resumePosition: Double,
|
||||
duration: Double,
|
||||
speed: Double?,
|
||||
metadata: Metadata?
|
||||
) = inner.loadContent(contentType, content, resumePosition, duration, speed)
|
||||
|
||||
override fun ensureThreadStarted() = when (inner) {
|
||||
is FCastCastingDevice -> inner.ensureThreadStarted()
|
||||
is ChromecastCastingDevice -> inner.ensureThreadsStarted()
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
@@ -1,736 +0,0 @@
|
||||
package com.futo.platformplayer.casting
|
||||
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import com.futo.platformplayer.getConnectedSocket
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
import com.futo.platformplayer.protos.ChromeCast
|
||||
import com.futo.platformplayer.toHexString
|
||||
import com.futo.platformplayer.toInetAddress
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import org.json.JSONObject
|
||||
import java.io.DataInputStream
|
||||
import java.io.DataOutputStream
|
||||
import java.net.InetAddress
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
import java.security.cert.X509Certificate
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.SSLSocket
|
||||
import javax.net.ssl.TrustManager
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
class ChromecastCastingDevice : CastingDeviceLegacy {
|
||||
//See for more info: https://developers.google.com/cast/docs/media/messages
|
||||
|
||||
override val protocol: CastProtocolType get() = CastProtocolType.CHROMECAST;
|
||||
override val isReady: Boolean get() = name != null && addresses != null && addresses?.isNotEmpty() == true && port != 0;
|
||||
override var usedRemoteAddress: InetAddress? = null;
|
||||
override var localAddress: InetAddress? = null;
|
||||
override val canSetVolume: Boolean get() = true;
|
||||
override val canSetSpeed: Boolean get() = true;
|
||||
|
||||
var addresses: Array<InetAddress>? = null;
|
||||
var port: Int = 0;
|
||||
|
||||
private var _streamType: String? = null;
|
||||
private var _contentType: String? = null;
|
||||
private var _contentId: String? = null;
|
||||
|
||||
private var _socket: SSLSocket? = null;
|
||||
private var _outputStream: DataOutputStream? = null;
|
||||
private var _outputStreamLock = Object();
|
||||
private var _inputStream: DataInputStream? = null;
|
||||
private var _inputStreamLock = Object();
|
||||
private var _scopeIO: CoroutineScope? = null;
|
||||
private var _requestId = 1;
|
||||
private var _started: Boolean = false;
|
||||
private var _sessionId: String? = null;
|
||||
private var _transportId: String? = null;
|
||||
private var _launching = false;
|
||||
private var _mediaSessionId: Int? = null;
|
||||
private var _thread: Thread? = null;
|
||||
private var _pingThread: Thread? = null;
|
||||
private var _launchRetries = 0
|
||||
private val MAX_LAUNCH_RETRIES = 3
|
||||
private var _lastLaunchTime_ms = 0L
|
||||
private var _retryJob: Job? = null
|
||||
private var _autoLaunchEnabled = true
|
||||
|
||||
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
|
||||
this.name = name;
|
||||
this.addresses = addresses;
|
||||
this.port = port;
|
||||
}
|
||||
|
||||
constructor(deviceInfo: CastingDeviceInfo) : super() {
|
||||
this.name = deviceInfo.name;
|
||||
this.addresses = deviceInfo.addresses.map { a -> a.toInetAddress() }.filterNotNull().toTypedArray();
|
||||
this.port = deviceInfo.port;
|
||||
}
|
||||
|
||||
override fun getAddresses(): List<InetAddress> {
|
||||
return addresses?.toList() ?: listOf();
|
||||
}
|
||||
|
||||
override fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double, speed: Double?) {
|
||||
if (invokeInIOScopeIfRequired({ loadVideo(streamType, contentType, contentId, resumePosition, duration, speed) })) {
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration, speed: $speed)");
|
||||
|
||||
setTime(resumePosition);
|
||||
setDuration(duration);
|
||||
_streamType = streamType;
|
||||
_contentType = contentType;
|
||||
_contentId = contentId;
|
||||
|
||||
playVideo();
|
||||
}
|
||||
|
||||
override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?) {
|
||||
//TODO: Can maybe be implemented by sending data:contentType,base64...
|
||||
throw NotImplementedError();
|
||||
}
|
||||
|
||||
private fun connectMediaChannel(transportId: String) {
|
||||
val connectObject = JSONObject();
|
||||
connectObject.put("type", "CONNECT");
|
||||
connectObject.put("connType", 0);
|
||||
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.tp.connection", connectObject.toString());
|
||||
}
|
||||
|
||||
private fun requestMediaStatus() {
|
||||
val transportId = _transportId ?: return;
|
||||
|
||||
val loadObject = JSONObject();
|
||||
loadObject.put("type", "GET_STATUS");
|
||||
loadObject.put("requestId", _requestId++);
|
||||
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString());
|
||||
}
|
||||
|
||||
private fun playVideo() {
|
||||
val transportId = _transportId ?: return;
|
||||
val contentId = _contentId ?: return;
|
||||
val streamType = _streamType ?: return;
|
||||
val contentType = _contentType ?: return;
|
||||
|
||||
val loadObject = JSONObject();
|
||||
loadObject.put("type", "LOAD");
|
||||
|
||||
val mediaObject = JSONObject();
|
||||
mediaObject.put("contentId", contentId);
|
||||
mediaObject.put("streamType", streamType);
|
||||
mediaObject.put("contentType", contentType);
|
||||
|
||||
if (time > 0.0) {
|
||||
val seekTime = time;
|
||||
loadObject.put("currentTime", seekTime);
|
||||
}
|
||||
|
||||
loadObject.put("media", mediaObject);
|
||||
loadObject.put("requestId", _requestId++);
|
||||
|
||||
|
||||
//TODO: This replace is necessary to get rid of backward slashes added by the JSON Object serializer
|
||||
val json = loadObject.toString().replace("\\/","/");
|
||||
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", json);
|
||||
}
|
||||
|
||||
override fun changeSpeed(speed: Double) {
|
||||
if (invokeInIOScopeIfRequired { changeSpeed(speed) }) return
|
||||
|
||||
val speedClamped = speed.coerceAtLeast(1.0).coerceAtLeast(1.0).coerceAtMost(2.0)
|
||||
setSpeed(speedClamped)
|
||||
val mediaSessionId = _mediaSessionId ?: return
|
||||
val transportId = _transportId ?: return
|
||||
val setSpeedObject = JSONObject().apply {
|
||||
put("type", "SET_PLAYBACK_RATE")
|
||||
put("mediaSessionId", mediaSessionId)
|
||||
put("playbackRate", speedClamped)
|
||||
put("requestId", _requestId++)
|
||||
}
|
||||
|
||||
sendChannelMessage(sourceId = "sender-0", destinationId = transportId, namespace = "urn:x-cast:com.google.cast.media", json = setSpeedObject.toString())
|
||||
}
|
||||
|
||||
override fun changeVolume(volume: Double) {
|
||||
if (invokeInIOScopeIfRequired({ changeVolume(volume) })) {
|
||||
return;
|
||||
}
|
||||
|
||||
setVolume(volume)
|
||||
val setVolumeObject = JSONObject();
|
||||
setVolumeObject.put("type", "SET_VOLUME");
|
||||
|
||||
val volumeObject = JSONObject();
|
||||
volumeObject.put("level", volume)
|
||||
setVolumeObject.put("volume", volumeObject);
|
||||
|
||||
setVolumeObject.put("requestId", _requestId++);
|
||||
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", setVolumeObject.toString());
|
||||
}
|
||||
|
||||
override fun seekVideo(timeSeconds: Double) {
|
||||
if (invokeInIOScopeIfRequired({ seekVideo(timeSeconds) })) {
|
||||
return;
|
||||
}
|
||||
|
||||
val transportId = _transportId ?: return;
|
||||
val mediaSessionId = _mediaSessionId ?: return;
|
||||
|
||||
val loadObject = JSONObject();
|
||||
loadObject.put("type", "SEEK");
|
||||
loadObject.put("mediaSessionId", mediaSessionId);
|
||||
loadObject.put("requestId", _requestId++);
|
||||
loadObject.put("currentTime", timeSeconds);
|
||||
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString());
|
||||
}
|
||||
|
||||
override fun resumeVideo() {
|
||||
if (invokeInIOScopeIfRequired(::resumeVideo)) {
|
||||
return;
|
||||
}
|
||||
|
||||
val transportId = _transportId ?: return;
|
||||
val mediaSessionId = _mediaSessionId ?: return;
|
||||
|
||||
val loadObject = JSONObject();
|
||||
loadObject.put("type", "PLAY");
|
||||
loadObject.put("mediaSessionId", mediaSessionId);
|
||||
loadObject.put("requestId", _requestId++);
|
||||
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString());
|
||||
}
|
||||
|
||||
override fun pauseVideo() {
|
||||
if (invokeInIOScopeIfRequired(::pauseVideo)) {
|
||||
return;
|
||||
}
|
||||
|
||||
val transportId = _transportId ?: return;
|
||||
val mediaSessionId = _mediaSessionId ?: return;
|
||||
|
||||
val loadObject = JSONObject();
|
||||
loadObject.put("type", "PAUSE");
|
||||
loadObject.put("mediaSessionId", mediaSessionId);
|
||||
loadObject.put("requestId", _requestId++);
|
||||
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString());
|
||||
}
|
||||
|
||||
override fun stopVideo() {
|
||||
if (invokeInIOScopeIfRequired(::stopVideo)) {
|
||||
return;
|
||||
}
|
||||
|
||||
val transportId = _transportId ?: return;
|
||||
val mediaSessionId = _mediaSessionId ?: return;
|
||||
_contentId = null;
|
||||
_contentType = null;
|
||||
_streamType = null;
|
||||
|
||||
val loadObject = JSONObject();
|
||||
loadObject.put("type", "STOP");
|
||||
loadObject.put("mediaSessionId", mediaSessionId);
|
||||
loadObject.put("requestId", _requestId++);
|
||||
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString());
|
||||
}
|
||||
|
||||
private fun launchPlayer() {
|
||||
if (invokeInIOScopeIfRequired(::launchPlayer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
val launchObject = JSONObject();
|
||||
launchObject.put("type", "LAUNCH");
|
||||
launchObject.put("appId", "CC1AD845");
|
||||
launchObject.put("requestId", _requestId++);
|
||||
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", launchObject.toString());
|
||||
_lastLaunchTime_ms = System.currentTimeMillis()
|
||||
}
|
||||
|
||||
private fun getStatus() {
|
||||
if (invokeInIOScopeIfRequired(::getStatus)) {
|
||||
return;
|
||||
}
|
||||
|
||||
val launchObject = JSONObject();
|
||||
launchObject.put("type", "GET_STATUS");
|
||||
launchObject.put("requestId", _requestId++);
|
||||
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", launchObject.toString());
|
||||
}
|
||||
|
||||
private fun invokeInIOScopeIfRequired(action: () -> Unit): Boolean {
|
||||
if(Looper.getMainLooper().thread == Thread.currentThread()) {
|
||||
_scopeIO?.launch { action(); }
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
override fun stopCasting() {
|
||||
if (invokeInIOScopeIfRequired(::stopCasting)) {
|
||||
return;
|
||||
}
|
||||
|
||||
val sessionId = _sessionId;
|
||||
if (sessionId != null) {
|
||||
val launchObject = JSONObject();
|
||||
launchObject.put("type", "STOP");
|
||||
launchObject.put("sessionId", sessionId);
|
||||
launchObject.put("requestId", _requestId++);
|
||||
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", launchObject.toString());
|
||||
|
||||
_contentId = null;
|
||||
_contentType = null;
|
||||
_streamType = null;
|
||||
_sessionId = null;
|
||||
_launchRetries = 0
|
||||
_transportId = null;
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Stopping active device because stopCasting was called.")
|
||||
stop();
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
if (_started) {
|
||||
return;
|
||||
}
|
||||
|
||||
_autoLaunchEnabled = true
|
||||
_started = true;
|
||||
_sessionId = null;
|
||||
_launchRetries = 0
|
||||
_mediaSessionId = null;
|
||||
|
||||
Logger.i(TAG, "Starting...");
|
||||
|
||||
_launching = true;
|
||||
|
||||
ensureThreadsStarted();
|
||||
Logger.i(TAG, "Started.");
|
||||
}
|
||||
|
||||
fun ensureThreadsStarted() {
|
||||
val adrs = addresses ?: return;
|
||||
|
||||
val thread = _thread
|
||||
val pingThread = _pingThread
|
||||
if (thread == null || !thread.isAlive || pingThread == null || !pingThread.isAlive) {
|
||||
Log.i(TAG, "Restarting threads because one of the threads has died")
|
||||
|
||||
_scopeIO?.cancel();
|
||||
Logger.i(TAG, "Cancelled previous scopeIO because a new one is starting.")
|
||||
_scopeIO = CoroutineScope(Dispatchers.IO);
|
||||
|
||||
_thread = Thread {
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
|
||||
var connectedSocket: Socket? = null
|
||||
while (_scopeIO?.isActive == true) {
|
||||
try {
|
||||
val resultSocket = getConnectedSocket(adrs.toList(), port);
|
||||
if (resultSocket == null) {
|
||||
Thread.sleep(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
connectedSocket = resultSocket
|
||||
usedRemoteAddress = connectedSocket.inetAddress;
|
||||
localAddress = connectedSocket.localAddress;
|
||||
break;
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to get setup initial connection to ChromeCast device.", e)
|
||||
Thread.sleep(1000);
|
||||
}
|
||||
}
|
||||
|
||||
val sslContext = SSLContext.getInstance("TLS");
|
||||
sslContext.init(null, trustAllCerts, null);
|
||||
|
||||
val factory = sslContext.socketFactory;
|
||||
|
||||
val address = InetSocketAddress(usedRemoteAddress, port)
|
||||
|
||||
//Connection loop
|
||||
while (_scopeIO?.isActive == true) {
|
||||
_sessionId = null;
|
||||
_launchRetries = 0
|
||||
_mediaSessionId = null;
|
||||
|
||||
Logger.i(TAG, "Connecting to Chromecast.");
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
|
||||
try {
|
||||
_socket?.close()
|
||||
if (connectedSocket != null) {
|
||||
Logger.i(TAG, "Using connected socket.")
|
||||
_socket = factory.createSocket(connectedSocket, connectedSocket.inetAddress.hostAddress, connectedSocket.port, true) as SSLSocket
|
||||
connectedSocket = null
|
||||
} else {
|
||||
Logger.i(TAG, "Using new socket.")
|
||||
val s = Socket().apply { this.connect(address, 2000) }
|
||||
_socket = factory.createSocket(s, s.inetAddress.hostAddress, s.port, true) as SSLSocket
|
||||
}
|
||||
|
||||
_socket?.startHandshake();
|
||||
Logger.i(TAG, "Successfully connected to Chromecast at $usedRemoteAddress:$port");
|
||||
|
||||
try {
|
||||
_outputStream = DataOutputStream(_socket?.outputStream);
|
||||
_inputStream = DataInputStream(_socket?.inputStream);
|
||||
} catch (e: Throwable) {
|
||||
Logger.i(TAG, "Failed to authenticate to Chromecast.", e);
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
_socket?.close();
|
||||
Logger.i(TAG, "Failed to connect to Chromecast.", e);
|
||||
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
Thread.sleep(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
localAddress = _socket?.localAddress;
|
||||
|
||||
try {
|
||||
val connectObject = JSONObject();
|
||||
connectObject.put("type", "CONNECT");
|
||||
connectObject.put("connType", 0);
|
||||
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.tp.connection", connectObject.toString());
|
||||
} catch (e: Throwable) {
|
||||
Logger.i(TAG, "Failed to send connect message to Chromecast.", e);
|
||||
_socket?.close();
|
||||
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
Thread.sleep(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
getStatus();
|
||||
|
||||
val buffer = ByteArray(409600);
|
||||
|
||||
Logger.i(TAG, "Started receiving.");
|
||||
while (_scopeIO?.isActive == true) {
|
||||
try {
|
||||
val inputStream = _inputStream ?: break;
|
||||
|
||||
val message = synchronized(_inputStreamLock)
|
||||
{
|
||||
Log.d(TAG, "Receiving next packet...");
|
||||
val b1 = inputStream.readUnsignedByte();
|
||||
val b2 = inputStream.readUnsignedByte();
|
||||
val b3 = inputStream.readUnsignedByte();
|
||||
val b4 = inputStream.readUnsignedByte();
|
||||
val size =
|
||||
((b1.toLong() shl 24) or (b2.toLong() shl 16) or (b3.toLong() shl 8) or b4.toLong()).toInt();
|
||||
if (size > buffer.size) {
|
||||
Logger.w(TAG, "Skipping packet that is too large $size bytes.")
|
||||
inputStream.skip(size.toLong());
|
||||
return@synchronized null
|
||||
}
|
||||
|
||||
Log.d(TAG, "Received header indicating $size bytes. Waiting for message.");
|
||||
inputStream.read(buffer, 0, size);
|
||||
|
||||
//TODO: In the future perhaps this size-1 will cause issues, why is there a 0 on the end?
|
||||
val messageBytes = buffer.sliceArray(IntRange(0, size - 1));
|
||||
Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}.");
|
||||
val msg = ChromeCast.CastMessage.parseFrom(messageBytes);
|
||||
if (msg.namespace != "urn:x-cast:com.google.cast.tp.heartbeat") {
|
||||
Logger.i(TAG, "Received message: $msg");
|
||||
}
|
||||
return@synchronized msg
|
||||
}
|
||||
|
||||
if (message != null) {
|
||||
try {
|
||||
handleMessage(message);
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to handle message.", e);
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (e: java.net.SocketException) {
|
||||
Logger.e(TAG, "Socket exception while receiving.", e);
|
||||
break;
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Exception while receiving.", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
_socket?.close();
|
||||
Logger.i(TAG, "Socket disconnected.");
|
||||
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
Thread.sleep(1000);
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Stopped connection loop.");
|
||||
connectionState = CastConnectionState.DISCONNECTED;
|
||||
}.apply { start() };
|
||||
|
||||
//Start ping loop
|
||||
_pingThread = Thread {
|
||||
Logger.i(TAG, "Started ping loop.")
|
||||
|
||||
val pingObject = JSONObject();
|
||||
pingObject.put("type", "PING");
|
||||
|
||||
while (_scopeIO?.isActive == true) {
|
||||
try {
|
||||
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.tp.heartbeat", pingObject.toString());
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG, "Failed to send ping.");
|
||||
}
|
||||
|
||||
Thread.sleep(5000);
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Stopped ping loop.");
|
||||
}.apply { start() };
|
||||
} else {
|
||||
Log.i(TAG, "Threads still alive, not restarted")
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendChannelMessage(sourceId: String, destinationId: String, namespace: String, json: String) {
|
||||
try {
|
||||
val castMessage = ChromeCast.CastMessage.newBuilder()
|
||||
.setProtocolVersion(ChromeCast.CastMessage.ProtocolVersion.CASTV2_1_0)
|
||||
.setSourceId(sourceId)
|
||||
.setDestinationId(destinationId)
|
||||
.setNamespace(namespace)
|
||||
.setPayloadType(ChromeCast.CastMessage.PayloadType.STRING)
|
||||
.setPayloadUtf8(json)
|
||||
.build();
|
||||
|
||||
sendMessage(castMessage.toByteArray());
|
||||
|
||||
if (namespace != "urn:x-cast:com.google.cast.tp.heartbeat") {
|
||||
//Log.d(TAG, "Sent channel message: $castMessage");
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to send channel message (sourceId: $sourceId, destinationId: $destinationId, namespace: $namespace, json: $json)", e);
|
||||
_socket?.close();
|
||||
Logger.i(TAG, "Socket disconnected.");
|
||||
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleMessage(message: ChromeCast.CastMessage) {
|
||||
if (message.payloadType == ChromeCast.CastMessage.PayloadType.STRING) {
|
||||
val jsonObject = JSONObject(message.payloadUtf8);
|
||||
val type = jsonObject.getString("type");
|
||||
if (type == "RECEIVER_STATUS") {
|
||||
val status = jsonObject.getJSONObject("status");
|
||||
|
||||
var sessionIsRunning = false;
|
||||
if (status.has("applications")) {
|
||||
val applications = status.getJSONArray("applications");
|
||||
|
||||
for (i in 0 until applications.length()) {
|
||||
val applicationUpdate = applications.getJSONObject(i);
|
||||
|
||||
val appId = applicationUpdate.getString("appId");
|
||||
Logger.i(TAG, "Status update received appId (appId: $appId)");
|
||||
|
||||
if (appId == "CC1AD845") {
|
||||
sessionIsRunning = true;
|
||||
_autoLaunchEnabled = false
|
||||
|
||||
if (_sessionId == null) {
|
||||
connectionState = CastConnectionState.CONNECTED;
|
||||
_sessionId = applicationUpdate.getString("sessionId");
|
||||
_launchRetries = 0
|
||||
|
||||
val transportId = applicationUpdate.getString("transportId");
|
||||
connectMediaChannel(transportId);
|
||||
Logger.i(TAG, "Connected to media channel $transportId");
|
||||
_transportId = transportId;
|
||||
|
||||
requestMediaStatus();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!sessionIsRunning) {
|
||||
if (System.currentTimeMillis() - _lastLaunchTime_ms > 5000) {
|
||||
_sessionId = null
|
||||
_mediaSessionId = null
|
||||
_transportId = null
|
||||
|
||||
if (_autoLaunchEnabled) {
|
||||
if (_launching && _launchRetries < MAX_LAUNCH_RETRIES) {
|
||||
Logger.i(TAG, "No player yet; attempting launch #${_launchRetries + 1}")
|
||||
_launchRetries++
|
||||
launchPlayer()
|
||||
} else {
|
||||
// Maybe the first GET_STATUS came back empty; still try launching
|
||||
Logger.i(TAG, "Player not found; triggering launch #${_launchRetries + 1}")
|
||||
_launching = true
|
||||
_launchRetries++
|
||||
launchPlayer()
|
||||
}
|
||||
} else {
|
||||
Logger.e(TAG, "Player not found ($_launchRetries, _autoLaunchEnabled = $_autoLaunchEnabled); giving up.")
|
||||
Logger.i(TAG, "Unable to start media receiver on device")
|
||||
stop()
|
||||
}
|
||||
} else {
|
||||
if (_retryJob == null) {
|
||||
Logger.i(TAG, "Scheduled retry job over 5 seconds")
|
||||
_retryJob = _scopeIO?.launch(Dispatchers.IO) {
|
||||
delay(5000)
|
||||
getStatus()
|
||||
_retryJob = null
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_launching = false
|
||||
_launchRetries = 0
|
||||
_autoLaunchEnabled = false
|
||||
}
|
||||
|
||||
val volume = status.getJSONObject("volume");
|
||||
//val volumeControlType = volume.getString("controlType");
|
||||
val volumeLevel = volume.getString("level").toDouble();
|
||||
val volumeMuted = volume.getBoolean("muted");
|
||||
//val volumeStepInterval = volume.getString("stepInterval").toFloat();
|
||||
setVolume(if (volumeMuted) 0.0 else volumeLevel);
|
||||
|
||||
Logger.i(TAG, "Status update received volume (level: $volumeLevel, muted: $volumeMuted)");
|
||||
} else if (type == "MEDIA_STATUS") {
|
||||
val statuses = jsonObject.getJSONArray("status");
|
||||
for (i in 0 until statuses.length()) {
|
||||
val status = statuses.getJSONObject(i);
|
||||
_mediaSessionId = status.getInt("mediaSessionId");
|
||||
|
||||
val playerState = status.getString("playerState");
|
||||
val currentTime = status.getDouble("currentTime");
|
||||
if (status.has("media")) {
|
||||
val media = status.getJSONObject("media")
|
||||
if (media.has("duration")) {
|
||||
setDuration(media.getDouble("duration"))
|
||||
}
|
||||
}
|
||||
|
||||
isPlaying = playerState == "PLAYING";
|
||||
if (isPlaying || playerState == "PAUSED") {
|
||||
setTime(currentTime);
|
||||
}
|
||||
|
||||
val playbackRate = status.getInt("playbackRate");
|
||||
Logger.i(TAG, "Media update received (mediaSessionId: $_mediaSessionId, playedState: $playerState, currentTime: $currentTime, playbackRate: $playbackRate)");
|
||||
|
||||
if (_contentType == null) {
|
||||
stopVideo();
|
||||
}
|
||||
}
|
||||
|
||||
val needsLoad = statuses.length() == 0 || (statuses.getJSONObject(0).getString("playerState") == "IDLE")
|
||||
if (needsLoad && _contentId != null && _mediaSessionId == null) {
|
||||
Logger.i(TAG, "Receiver idle, sending initial LOAD")
|
||||
playVideo()
|
||||
}
|
||||
} else if (type == "CLOSE") {
|
||||
if (message.sourceId == "receiver-0") {
|
||||
Logger.i(TAG, "Close received.");
|
||||
stopCasting();
|
||||
} else if (_transportId == message.sourceId) {
|
||||
throw Exception("Transport id closed.")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw Exception("Payload type ${message.payloadType} is not implemented.");
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendMessage(data: ByteArray) {
|
||||
val outputStream = _outputStream;
|
||||
if (outputStream == null) {
|
||||
Logger.w(TAG, "Failed to send ${data.size} bytes, output stream is null.");
|
||||
return;
|
||||
}
|
||||
|
||||
synchronized(_outputStreamLock)
|
||||
{
|
||||
val serializedSizeBE = ByteArray(4);
|
||||
serializedSizeBE[0] = (data.size shr 24 and 0xff).toByte();
|
||||
serializedSizeBE[1] = (data.size shr 16 and 0xff).toByte();
|
||||
serializedSizeBE[2] = (data.size shr 8 and 0xff).toByte();
|
||||
serializedSizeBE[3] = (data.size and 0xff).toByte();
|
||||
outputStream.write(serializedSizeBE);
|
||||
outputStream.write(data);
|
||||
}
|
||||
|
||||
//Log.d(TAG, "Sent ${data.size} bytes.");
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
Logger.i(TAG, "Stopping...");
|
||||
usedRemoteAddress = null;
|
||||
localAddress = null;
|
||||
_started = false;
|
||||
|
||||
_contentId = null
|
||||
_contentType = null
|
||||
_streamType = null
|
||||
|
||||
_retryJob?.cancel()
|
||||
_retryJob = null
|
||||
|
||||
val socket = _socket;
|
||||
val scopeIO = _scopeIO;
|
||||
|
||||
if (scopeIO != null && socket != null) {
|
||||
Logger.i(TAG, "Cancelling scopeIO with open socket.")
|
||||
|
||||
scopeIO.launch {
|
||||
socket.close();
|
||||
connectionState = CastConnectionState.DISCONNECTED;
|
||||
scopeIO.cancel();
|
||||
Logger.i(TAG, "Cancelled scopeIO with open socket.")
|
||||
}
|
||||
} else {
|
||||
scopeIO?.cancel();
|
||||
Logger.i(TAG, "Cancelled scopeIO without open socket.")
|
||||
}
|
||||
|
||||
_pingThread = null;
|
||||
_thread = null;
|
||||
_scopeIO = null;
|
||||
_socket = null;
|
||||
_outputStream = null;
|
||||
_inputStream = null;
|
||||
_mediaSessionId = null;
|
||||
connectionState = CastConnectionState.DISCONNECTED;
|
||||
}
|
||||
|
||||
override fun getDeviceInfo(): CastingDeviceInfo {
|
||||
return CastingDeviceInfo(name!!, CastProtocolType.CHROMECAST, addresses!!.filter { a -> a.hostAddress != null }.map { a -> a.hostAddress!! }.toTypedArray(), port);
|
||||
}
|
||||
|
||||
companion object {
|
||||
val TAG = "ChromecastCastingDevice";
|
||||
|
||||
val trustAllCerts: Array<TrustManager> = arrayOf<TrustManager>(object : X509TrustManager {
|
||||
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) { }
|
||||
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) { }
|
||||
override fun getAcceptedIssuers(): Array<X509Certificate> { return emptyArray(); }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,636 +0,0 @@
|
||||
package com.futo.platformplayer.casting
|
||||
|
||||
import android.os.Looper
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.casting.models.FCastDecryptedMessage
|
||||
import com.futo.platformplayer.casting.models.FCastEncryptedMessage
|
||||
import com.futo.platformplayer.casting.models.FCastKeyExchangeMessage
|
||||
import com.futo.platformplayer.casting.models.FCastPlayMessage
|
||||
import com.futo.platformplayer.casting.models.FCastPlaybackErrorMessage
|
||||
import com.futo.platformplayer.casting.models.FCastPlaybackUpdateMessage
|
||||
import com.futo.platformplayer.casting.models.FCastSeekMessage
|
||||
import com.futo.platformplayer.casting.models.FCastSetSpeedMessage
|
||||
import com.futo.platformplayer.casting.models.FCastSetVolumeMessage
|
||||
import com.futo.platformplayer.casting.models.FCastVersionMessage
|
||||
import com.futo.platformplayer.casting.models.FCastVolumeUpdateMessage
|
||||
import com.futo.platformplayer.ensureNotMainThread
|
||||
import com.futo.platformplayer.getConnectedSocket
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
import com.futo.platformplayer.toHexString
|
||||
import com.futo.platformplayer.toInetAddress
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.math.BigInteger
|
||||
import java.net.InetAddress
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
import java.security.KeyFactory
|
||||
import java.security.KeyPair
|
||||
import java.security.KeyPairGenerator
|
||||
import java.security.MessageDigest
|
||||
import java.security.PrivateKey
|
||||
import java.security.spec.X509EncodedKeySpec
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyAgreement
|
||||
import javax.crypto.spec.DHParameterSpec
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
enum class Opcode(val value: Byte) {
|
||||
None(0),
|
||||
Play(1),
|
||||
Pause(2),
|
||||
Resume(3),
|
||||
Stop(4),
|
||||
Seek(5),
|
||||
PlaybackUpdate(6),
|
||||
VolumeUpdate(7),
|
||||
SetVolume(8),
|
||||
PlaybackError(9),
|
||||
SetSpeed(10),
|
||||
Version(11),
|
||||
Ping(12),
|
||||
Pong(13);
|
||||
|
||||
companion object {
|
||||
private val _map = entries.associateBy { it.value }
|
||||
fun find(value: Byte): Opcode = _map[value] ?: Opcode.None
|
||||
}
|
||||
}
|
||||
|
||||
class FCastCastingDevice : CastingDeviceLegacy {
|
||||
//See for more info: TODO
|
||||
|
||||
override val protocol: CastProtocolType get() = CastProtocolType.FCAST;
|
||||
override val isReady: Boolean get() = name != null && addresses != null && addresses?.isNotEmpty() == true && port != 0;
|
||||
override var usedRemoteAddress: InetAddress? = null;
|
||||
override var localAddress: InetAddress? = null;
|
||||
override val canSetVolume: Boolean get() = true;
|
||||
override val canSetSpeed: Boolean get() = true;
|
||||
|
||||
var addresses: Array<InetAddress>? = null;
|
||||
var port: Int = 0;
|
||||
|
||||
private var _socket: Socket? = null;
|
||||
private var _outputStream: OutputStream? = null;
|
||||
private var _inputStream: InputStream? = null;
|
||||
private var _scopeIO: CoroutineScope? = null;
|
||||
private var _started: Boolean = false;
|
||||
private var _version: Long = 1;
|
||||
private var _thread: Thread? = null
|
||||
private var _pingThread: Thread? = null
|
||||
@Volatile private var _lastPongTime = System.currentTimeMillis()
|
||||
private var _outputStreamLock = Object()
|
||||
|
||||
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
|
||||
this.name = name;
|
||||
this.addresses = addresses;
|
||||
this.port = port;
|
||||
}
|
||||
|
||||
constructor(deviceInfo: CastingDeviceInfo) : super() {
|
||||
this.name = deviceInfo.name;
|
||||
this.addresses = deviceInfo.addresses.map { a -> a.toInetAddress() }.filterNotNull().toTypedArray();
|
||||
this.port = deviceInfo.port;
|
||||
}
|
||||
|
||||
override fun getAddresses(): List<InetAddress> {
|
||||
return addresses?.toList() ?: listOf();
|
||||
}
|
||||
|
||||
override fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double, speed: Double?) {
|
||||
if (invokeInIOScopeIfRequired({ loadVideo(streamType, contentType, contentId, resumePosition, duration, speed) })) {
|
||||
return;
|
||||
}
|
||||
|
||||
//TODO: Remove this later, temporary for the transition
|
||||
if (_version <= 1L) {
|
||||
UIDialogs.toast("Version not received, if you are experiencing issues, try updating FCast")
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration, speed: $speed)");
|
||||
|
||||
setTime(resumePosition);
|
||||
setDuration(duration);
|
||||
send(Opcode.Play, FCastPlayMessage(
|
||||
container = contentType,
|
||||
url = contentId,
|
||||
time = resumePosition,
|
||||
speed = speed
|
||||
));
|
||||
|
||||
setSpeed(speed ?: 1.0);
|
||||
}
|
||||
|
||||
override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?) {
|
||||
if (invokeInIOScopeIfRequired({ loadContent(contentType, content, resumePosition, duration, speed) })) {
|
||||
return;
|
||||
}
|
||||
|
||||
//TODO: Remove this later, temporary for the transition
|
||||
if (_version <= 1L) {
|
||||
UIDialogs.toast("Version not received, if you are experiencing issues, try updating FCast")
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Start streaming content (contentType: $contentType, resumePosition: $resumePosition, duration: $duration, speed: $speed)");
|
||||
|
||||
setTime(resumePosition);
|
||||
setDuration(duration);
|
||||
send(Opcode.Play, FCastPlayMessage(
|
||||
container = contentType,
|
||||
content = content,
|
||||
time = resumePosition,
|
||||
speed = speed
|
||||
));
|
||||
|
||||
setSpeed(speed ?: 1.0);
|
||||
}
|
||||
|
||||
override fun changeVolume(volume: Double) {
|
||||
if (invokeInIOScopeIfRequired({ changeVolume(volume) })) {
|
||||
return;
|
||||
}
|
||||
|
||||
setVolume(volume);
|
||||
send(Opcode.SetVolume, FCastSetVolumeMessage(volume))
|
||||
}
|
||||
|
||||
override fun changeSpeed(speed: Double) {
|
||||
if (invokeInIOScopeIfRequired({ changeSpeed(speed) })) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSpeed(speed);
|
||||
send(Opcode.SetSpeed, FCastSetSpeedMessage(speed))
|
||||
}
|
||||
|
||||
override fun seekVideo(timeSeconds: Double) {
|
||||
if (invokeInIOScopeIfRequired({ seekVideo(timeSeconds) })) {
|
||||
return;
|
||||
}
|
||||
|
||||
send(Opcode.Seek, FCastSeekMessage(
|
||||
time = timeSeconds
|
||||
));
|
||||
}
|
||||
|
||||
override fun resumeVideo() {
|
||||
if (invokeInIOScopeIfRequired(::resumeVideo)) {
|
||||
return;
|
||||
}
|
||||
|
||||
send(Opcode.Resume);
|
||||
}
|
||||
|
||||
override fun pauseVideo() {
|
||||
if (invokeInIOScopeIfRequired(::pauseVideo)) {
|
||||
return;
|
||||
}
|
||||
|
||||
send(Opcode.Pause);
|
||||
}
|
||||
|
||||
override fun stopVideo() {
|
||||
if (invokeInIOScopeIfRequired(::stopVideo)) {
|
||||
return;
|
||||
}
|
||||
|
||||
send(Opcode.Stop);
|
||||
}
|
||||
|
||||
private fun invokeInIOScopeIfRequired(action: () -> Unit): Boolean {
|
||||
if(Looper.getMainLooper().thread == Thread.currentThread()) {
|
||||
_scopeIO?.launch {
|
||||
try {
|
||||
action();
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to invoke in IO scope.", e)
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
override fun stopCasting() {
|
||||
if (invokeInIOScopeIfRequired(::stopCasting)) {
|
||||
return;
|
||||
}
|
||||
|
||||
stopVideo();
|
||||
|
||||
Logger.i(TAG, "Stopping active device because stopCasting was called.")
|
||||
stop();
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
if (_started) {
|
||||
return;
|
||||
}
|
||||
|
||||
_started = true;
|
||||
Logger.i(TAG, "Starting...");
|
||||
|
||||
ensureThreadStarted();
|
||||
Logger.i(TAG, "Started.");
|
||||
}
|
||||
|
||||
fun ensureThreadStarted() {
|
||||
val adrs = addresses ?: return;
|
||||
|
||||
val thread = _thread
|
||||
val pingThread = _pingThread
|
||||
if (_started && (thread == null || !thread.isAlive || pingThread == null || !pingThread.isAlive)) {
|
||||
Log.i(TAG, "(Re)starting thread because the thread has died")
|
||||
|
||||
_scopeIO?.let {
|
||||
it.cancel()
|
||||
Logger.i(TAG, "Cancelled previous scopeIO because a new one is starting.")
|
||||
}
|
||||
|
||||
_scopeIO = CoroutineScope(Dispatchers.IO);
|
||||
|
||||
_thread = Thread {
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
Log.i(TAG, "Connection thread started.")
|
||||
|
||||
var connectedSocket: Socket? = null
|
||||
while (_scopeIO?.isActive == true) {
|
||||
try {
|
||||
Log.i(TAG, "getConnectedSocket (adrs = [ ${adrs.joinToString(", ")} ], port = ${port}).")
|
||||
|
||||
val resultSocket = getConnectedSocket(adrs.toList(), port);
|
||||
|
||||
if (resultSocket == null) {
|
||||
Log.i(TAG, "Connection failed, waiting 1 seconds.")
|
||||
Thread.sleep(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
Log.i(TAG, "Connection succeeded.")
|
||||
|
||||
connectedSocket = resultSocket
|
||||
usedRemoteAddress = connectedSocket.inetAddress
|
||||
localAddress = connectedSocket.localAddress
|
||||
break;
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to get setup initial connection to FastCast device.", e)
|
||||
Thread.sleep(1000);
|
||||
}
|
||||
}
|
||||
|
||||
val address = InetSocketAddress(usedRemoteAddress, port)
|
||||
|
||||
//Connection loop
|
||||
while (_scopeIO?.isActive == true) {
|
||||
Logger.i(TAG, "Connecting to FastCast.");
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
|
||||
try {
|
||||
_socket?.close()
|
||||
_inputStream?.close()
|
||||
_outputStream?.close()
|
||||
if (connectedSocket != null) {
|
||||
Logger.i(TAG, "Using connected socket.");
|
||||
_socket = connectedSocket
|
||||
connectedSocket = null
|
||||
} else {
|
||||
Logger.i(TAG, "Using new socket.");
|
||||
_socket = Socket().apply { this.connect(address, 2000) };
|
||||
}
|
||||
Logger.i(TAG, "Successfully connected to FastCast at $usedRemoteAddress:$port");
|
||||
|
||||
_outputStream = _socket?.outputStream;
|
||||
_inputStream = _socket?.inputStream;
|
||||
} catch (e: IOException) {
|
||||
_socket?.close()
|
||||
_inputStream?.close()
|
||||
_outputStream?.close()
|
||||
Logger.i(TAG, "Failed to connect to FastCast.", e);
|
||||
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
Thread.sleep(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
localAddress = _socket?.localAddress
|
||||
_lastPongTime = System.currentTimeMillis()
|
||||
connectionState = CastConnectionState.CONNECTED
|
||||
|
||||
val buffer = ByteArray(4096);
|
||||
|
||||
Logger.i(TAG, "Started receiving.");
|
||||
while (_scopeIO?.isActive == true) {
|
||||
try {
|
||||
val inputStream = _inputStream ?: break;
|
||||
Log.d(TAG, "Receiving next packet...");
|
||||
|
||||
var headerBytesRead = 0
|
||||
while (headerBytesRead < 4) {
|
||||
val read = inputStream.read(buffer, headerBytesRead, 4 - headerBytesRead)
|
||||
if (read == -1)
|
||||
throw Exception("Stream closed")
|
||||
headerBytesRead += read
|
||||
}
|
||||
|
||||
val size = ((buffer[3].toUByte().toLong() shl 24) or (buffer[2].toUByte().toLong() shl 16) or (buffer[1].toUByte().toLong() shl 8) or buffer[0].toUByte().toLong()).toInt();
|
||||
if (size > buffer.size) {
|
||||
Logger.w(TAG, "Packets larger than $size bytes are not supported.")
|
||||
break
|
||||
}
|
||||
|
||||
Log.d(TAG, "Received header indicating $size bytes. Waiting for message.");
|
||||
var bytesRead = 0
|
||||
while (bytesRead < size) {
|
||||
val read = inputStream.read(buffer, bytesRead, size - bytesRead)
|
||||
if (read == -1)
|
||||
throw Exception("Stream closed")
|
||||
bytesRead += read
|
||||
}
|
||||
|
||||
val messageBytes = buffer.sliceArray(IntRange(0, size));
|
||||
Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}.");
|
||||
|
||||
val opcode = messageBytes[0];
|
||||
var json: String? = null;
|
||||
if (size > 1) {
|
||||
json = messageBytes.sliceArray(IntRange(1, size - 1)).decodeToString();
|
||||
}
|
||||
|
||||
try {
|
||||
handleMessage(Opcode.find(opcode), json);
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to handle message.", e)
|
||||
break
|
||||
}
|
||||
} catch (e: java.net.SocketException) {
|
||||
Logger.e(TAG, "Socket exception while receiving.", e);
|
||||
break
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Exception while receiving.", e);
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
_socket?.close()
|
||||
_inputStream?.close()
|
||||
_outputStream?.close()
|
||||
Logger.i(TAG, "Socket disconnected.");
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to close socket.", e)
|
||||
}
|
||||
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
Thread.sleep(1000);
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Stopped connection loop.");
|
||||
connectionState = CastConnectionState.DISCONNECTED;
|
||||
}.apply { start() }
|
||||
|
||||
_pingThread = Thread {
|
||||
Logger.i(TAG, "Started ping loop.")
|
||||
while (_scopeIO?.isActive == true) {
|
||||
if (connectionState == CastConnectionState.CONNECTED) {
|
||||
try {
|
||||
send(Opcode.Ping)
|
||||
if (System.currentTimeMillis() - _lastPongTime > 15000) {
|
||||
Logger.w(TAG, "Closing socket due to last pong time being larger than 15 seconds.")
|
||||
try {
|
||||
_socket?.close()
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG, "Failed to close socket.", e)
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG, "Failed to send ping.")
|
||||
try {
|
||||
_socket?.close()
|
||||
_inputStream?.close()
|
||||
_outputStream?.close()
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG, "Failed to close socket.", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
Thread.sleep(5000)
|
||||
}
|
||||
Logger.i(TAG, "Stopped ping loop.")
|
||||
}.apply { start() }
|
||||
} else {
|
||||
Log.i(TAG, "Thread was still alive, not restarted")
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleMessage(opcode: Opcode, json: String? = null) {
|
||||
Log.i(TAG, "Processing packet (opcode: $opcode, size: ${json?.length ?: 0})")
|
||||
|
||||
when (opcode) {
|
||||
Opcode.PlaybackUpdate -> {
|
||||
if (json == null) {
|
||||
Logger.w(TAG, "Got playback update without JSON, ignoring.");
|
||||
return;
|
||||
}
|
||||
|
||||
val playbackUpdate = FCastCastingDevice.json.decodeFromString<FCastPlaybackUpdateMessage>(json);
|
||||
setTime(playbackUpdate.time, playbackUpdate.generationTime);
|
||||
setDuration(playbackUpdate.duration, playbackUpdate.generationTime);
|
||||
isPlaying = when (playbackUpdate.state) {
|
||||
1 -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
Opcode.VolumeUpdate -> {
|
||||
if (json == null) {
|
||||
Logger.w(TAG, "Got volume update without JSON, ignoring.");
|
||||
return;
|
||||
}
|
||||
|
||||
val volumeUpdate = FCastCastingDevice.json.decodeFromString<FCastVolumeUpdateMessage>(json);
|
||||
setVolume(volumeUpdate.volume, volumeUpdate.generationTime);
|
||||
}
|
||||
Opcode.PlaybackError -> {
|
||||
if (json == null) {
|
||||
Logger.w(TAG, "Got playback error without JSON, ignoring.");
|
||||
return;
|
||||
}
|
||||
|
||||
val playbackError = FCastCastingDevice.json.decodeFromString<FCastPlaybackErrorMessage>(json);
|
||||
Logger.e(TAG, "Remote casting playback error received: $playbackError")
|
||||
}
|
||||
Opcode.Version -> {
|
||||
if (json == null) {
|
||||
Logger.w(TAG, "Got version without JSON, ignoring.");
|
||||
return;
|
||||
}
|
||||
|
||||
val version = FCastCastingDevice.json.decodeFromString<FCastVersionMessage>(json);
|
||||
_version = version.version;
|
||||
Logger.i(TAG, "Remote version received: $version")
|
||||
}
|
||||
Opcode.Ping -> send(Opcode.Pong)
|
||||
Opcode.Pong -> _lastPongTime = System.currentTimeMillis()
|
||||
else -> { }
|
||||
}
|
||||
}
|
||||
|
||||
private fun send(opcode: Opcode, message: String? = null) {
|
||||
ensureNotMainThread()
|
||||
|
||||
synchronized (_outputStreamLock) {
|
||||
try {
|
||||
val data: ByteArray = message?.encodeToByteArray() ?: ByteArray(0)
|
||||
val size = 1 + data.size
|
||||
val outputStream = _outputStream
|
||||
if (outputStream == null) {
|
||||
Log.w(TAG, "Failed to send $size bytes, output stream is null.")
|
||||
return
|
||||
}
|
||||
|
||||
val serializedSizeLE = ByteArray(4)
|
||||
serializedSizeLE[0] = (size and 0xff).toByte()
|
||||
serializedSizeLE[1] = (size shr 8 and 0xff).toByte()
|
||||
serializedSizeLE[2] = (size shr 16 and 0xff).toByte()
|
||||
serializedSizeLE[3] = (size shr 24 and 0xff).toByte()
|
||||
outputStream.write(serializedSizeLE)
|
||||
|
||||
val opcodeBytes = ByteArray(1)
|
||||
opcodeBytes[0] = opcode.value
|
||||
outputStream.write(opcodeBytes)
|
||||
|
||||
if (data.isNotEmpty()) {
|
||||
outputStream.write(data)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Sent $size bytes: (opcode: $opcode, body: $message).")
|
||||
} catch (e: Throwable) {
|
||||
Log.i(TAG, "Failed to send message.", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun <reified T> send(opcode: Opcode, message: T) {
|
||||
try {
|
||||
send(opcode, message?.let { Json.encodeToString(it) })
|
||||
} catch (e: Throwable) {
|
||||
Log.i(TAG, "Failed to encode message to string.", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
Logger.i(TAG, "Stopping...");
|
||||
usedRemoteAddress = null;
|
||||
localAddress = null;
|
||||
_started = false;
|
||||
//TODO: Kill and/or join thread?
|
||||
_thread = null;
|
||||
_pingThread = null;
|
||||
|
||||
val socket = _socket;
|
||||
val scopeIO = _scopeIO;
|
||||
|
||||
if (scopeIO != null && socket != null) {
|
||||
Logger.i(TAG, "Cancelling scopeIO with open socket.")
|
||||
|
||||
scopeIO.launch {
|
||||
socket.close();
|
||||
_inputStream?.close()
|
||||
_outputStream?.close()
|
||||
connectionState = CastConnectionState.DISCONNECTED;
|
||||
scopeIO.cancel();
|
||||
Logger.i(TAG, "Cancelled scopeIO with open socket.")
|
||||
}
|
||||
} else {
|
||||
scopeIO?.cancel();
|
||||
Logger.i(TAG, "Cancelled scopeIO without open socket.")
|
||||
}
|
||||
|
||||
_scopeIO = null;
|
||||
_socket = null;
|
||||
_outputStream = null;
|
||||
_inputStream = null;
|
||||
connectionState = CastConnectionState.DISCONNECTED;
|
||||
}
|
||||
|
||||
override fun getDeviceInfo(): CastingDeviceInfo {
|
||||
return CastingDeviceInfo(name!!, CastProtocolType.FCAST, addresses!!.filter { a -> a.hostAddress != null }.map { a -> a.hostAddress!! }.toTypedArray(), port);
|
||||
}
|
||||
|
||||
companion object {
|
||||
val TAG = "FCastCastingDevice";
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
fun getKeyExchangeMessage(keyPair: KeyPair): FCastKeyExchangeMessage {
|
||||
return FCastKeyExchangeMessage(1, Base64.encodeToString(keyPair.public.encoded, Base64.NO_WRAP))
|
||||
}
|
||||
|
||||
fun generateKeyPair(): KeyPair {
|
||||
//modp14
|
||||
val p = BigInteger("ffffffffffffffffc90fdaa22168c234c4c6628b80dc1cd129024e088a67cc74020bbea63b139b22514a08798e3404ddef9519b3cd3a431b302b0a6df25f14374fe1356d6d51c245e485b576625e7ec6f44c42e9a637ed6b0bff5cb6f406b7edee386bfb5a899fa5ae9f24117c4b1fe649286651ece45b3dc2007cb8a163bf0598da48361c55d39a69163fa8fd24cf5f83655d23dca3ad961c62f356208552bb9ed529077096966d670c354e4abc9804f1746c08ca18217c32905e462e36ce3be39e772c180e86039b2783a2ec07a28fb5c55df06f4c52c9de2bcbf6955817183995497cea956ae515d2261898fa051015728e5a8aacaa68ffffffffffffffff", 16)
|
||||
val g = BigInteger("2", 16)
|
||||
val dhSpec = DHParameterSpec(p, g)
|
||||
|
||||
val keyGen = KeyPairGenerator.getInstance("DH")
|
||||
keyGen.initialize(dhSpec)
|
||||
|
||||
return keyGen.generateKeyPair()
|
||||
}
|
||||
|
||||
fun computeSharedSecret(privateKey: PrivateKey, keyExchangeMessage: FCastKeyExchangeMessage): SecretKeySpec {
|
||||
val keyFactory = KeyFactory.getInstance("DH")
|
||||
val receivedPublicKeyBytes = Base64.decode(keyExchangeMessage.publicKey, Base64.NO_WRAP)
|
||||
val receivedPublicKeySpec = X509EncodedKeySpec(receivedPublicKeyBytes)
|
||||
val receivedPublicKey = keyFactory.generatePublic(receivedPublicKeySpec)
|
||||
|
||||
val keyAgreement = KeyAgreement.getInstance("DH")
|
||||
keyAgreement.init(privateKey)
|
||||
keyAgreement.doPhase(receivedPublicKey, true)
|
||||
|
||||
val sharedSecret = keyAgreement.generateSecret()
|
||||
Log.i(TAG, "sharedSecret ${Base64.encodeToString(sharedSecret, Base64.NO_WRAP)}")
|
||||
val sha256 = MessageDigest.getInstance("SHA-256")
|
||||
val hashedSecret = sha256.digest(sharedSecret)
|
||||
Log.i(TAG, "hashedSecret ${Base64.encodeToString(hashedSecret, Base64.NO_WRAP)}")
|
||||
|
||||
return SecretKeySpec(hashedSecret, "AES")
|
||||
}
|
||||
|
||||
fun encryptMessage(aesKey: SecretKeySpec, decryptedMessage: FCastDecryptedMessage): FCastEncryptedMessage {
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
cipher.init(Cipher.ENCRYPT_MODE, aesKey)
|
||||
val iv = cipher.iv
|
||||
val json = Json.encodeToString(decryptedMessage)
|
||||
val encrypted = cipher.doFinal(json.toByteArray(Charsets.UTF_8))
|
||||
return FCastEncryptedMessage(
|
||||
version = 1,
|
||||
iv = Base64.encodeToString(iv, Base64.NO_WRAP),
|
||||
blob = Base64.encodeToString(encrypted, Base64.NO_WRAP)
|
||||
)
|
||||
}
|
||||
|
||||
fun decryptMessage(aesKey: SecretKeySpec, encryptedMessage: FCastEncryptedMessage): FCastDecryptedMessage {
|
||||
val iv = Base64.decode(encryptedMessage.iv, Base64.NO_WRAP)
|
||||
val encrypted = Base64.decode(encryptedMessage.blob, Base64.NO_WRAP)
|
||||
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
cipher.init(Cipher.DECRYPT_MODE, aesKey, IvParameterSpec(iv))
|
||||
val decryptedJson = cipher.doFinal(encrypted)
|
||||
return Json.decodeFromString(String(decryptedJson, Charsets.UTF_8))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import android.util.Log
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.core.net.toUri
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.futo.platformplayer.BuildConfig
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
@@ -57,16 +58,22 @@ import com.futo.platformplayer.views.casting.CastView.Companion
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.fcast.sender_sdk.CastContext
|
||||
import org.fcast.sender_sdk.DeviceInfo
|
||||
import org.fcast.sender_sdk.Metadata
|
||||
import org.fcast.sender_sdk.NsdDeviceDiscoverer
|
||||
import org.fcast.sender_sdk.ProtocolType
|
||||
import java.net.Inet6Address
|
||||
import java.net.URLDecoder
|
||||
import java.net.URLEncoder
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import org.fcast.sender_sdk.DeviceInfo as RsDeviceInfo
|
||||
|
||||
abstract class StateCasting {
|
||||
class StateCasting {
|
||||
val _scopeIO = CoroutineScope(Dispatchers.IO);
|
||||
val _scopeMain = CoroutineScope(Dispatchers.Main);
|
||||
private val _storage: CastingDeviceInfoStorage = FragmentedStorage.get();
|
||||
@@ -92,15 +99,163 @@ abstract class StateCasting {
|
||||
val isCasting: Boolean get() = activeDevice != null;
|
||||
private val _castId = AtomicInteger(0)
|
||||
|
||||
abstract fun handleUrl(url: String)
|
||||
abstract fun onStop()
|
||||
abstract fun start(context: Context)
|
||||
abstract fun stop()
|
||||
private val _context = CastContext()
|
||||
var _deviceDiscoverer: NsdDeviceDiscoverer? = null
|
||||
|
||||
abstract fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDevice?
|
||||
abstract fun startUpdateTimeJob(
|
||||
onTimeJobTimeChanged_s: Event1<Long>, setTime: (Long) -> Unit
|
||||
): Job?
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
fun handleUrl(url: String) {
|
||||
try {
|
||||
val foundDeviceInfo = org.fcast.sender_sdk.deviceInfoFromUrl(url)!!
|
||||
val foundDevice = _context.createDeviceFromInfo(foundDeviceInfo)
|
||||
connectDevice(CastingDevice(foundDevice))
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to handle URL: $e")
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
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 = CastingDevice(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 CastingDevice) {
|
||||
handle.device.setPort(deviceInfo.port)
|
||||
handle.device.setAddresses(deviceInfo.addresses)
|
||||
invokeInMainScopeIfRequired {
|
||||
onDeviceChanged.emit(handle)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
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
|
||||
}
|
||||
|
||||
fun startUpdateTimeJob(
|
||||
onTimeJobTimeChanged_s: Event1<Long>,
|
||||
setTime: (Long) -> Unit
|
||||
): Job? = null
|
||||
|
||||
fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDevice? {
|
||||
try {
|
||||
val rsAddrs =
|
||||
deviceInfo.addresses.map { org.fcast.sender_sdk.tryIpAddrFromStr(it) }
|
||||
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 CastingDevice(_context.createDeviceFromInfo(rsDeviceInfo))
|
||||
} catch (_: Throwable) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
fun onResume() {
|
||||
val ad = activeDevice
|
||||
@@ -1532,11 +1687,7 @@ abstract class StateCasting {
|
||||
}
|
||||
|
||||
companion object {
|
||||
var instance: StateCasting = if (Settings.instance.casting.experimentalCasting) {
|
||||
StateCastingExp()
|
||||
} else {
|
||||
StateCastingLegacy()
|
||||
}
|
||||
var instance = StateCasting()
|
||||
private val representationRegex = Regex(
|
||||
"<Representation .*?mimeType=\"(.*?)\".*?>(.*?)<\\/Representation>",
|
||||
RegexOption.DOT_MATCHES_ALL
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
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 StateCastingExp : 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(CastingDeviceExp(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 = CastingDeviceExp(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 CastingDeviceExp) {
|
||||
handle.device.setPort(deviceInfo.port)
|
||||
handle.device.setAddresses(deviceInfo.addresses)
|
||||
invokeInMainScopeIfRequired {
|
||||
onDeviceChanged.emit(handle)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun stop() {
|
||||
if (!_started) {
|
||||
return
|
||||
}
|
||||
|
||||
_started = false
|
||||
|
||||
Logger.i(TAG, "CastingService stopping.")
|
||||
|
||||
_scopeIO.cancel()
|
||||
_scopeMain.cancel()
|
||||
|
||||
Logger.i(TAG, "Stopping active device because StateCasting is being stopped.")
|
||||
val d = activeDevice
|
||||
activeDevice = null
|
||||
try {
|
||||
d?.disconnect()
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to disconnect device: $e")
|
||||
}
|
||||
|
||||
_castServer.stop()
|
||||
_castServer.removeAllHandlers()
|
||||
|
||||
Logger.i(TAG, "CastingService stopped.")
|
||||
|
||||
_deviceDiscoverer = null
|
||||
}
|
||||
|
||||
override fun startUpdateTimeJob(
|
||||
onTimeJobTimeChanged_s: Event1<Long>,
|
||||
setTime: (Long) -> Unit
|
||||
): Job? = null
|
||||
|
||||
override fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDeviceExp? {
|
||||
try {
|
||||
val rsAddrs =
|
||||
deviceInfo.addresses.map { org.fcast.sender_sdk.tryIpAddrFromStr(it) }
|
||||
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 CastingDeviceExp(_context.createDeviceFromInfo(rsDeviceInfo))
|
||||
} catch (_: Throwable) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = "StateCastingExp"
|
||||
}
|
||||
}
|
||||
@@ -1,399 +0,0 @@
|
||||
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 StateCastingLegacy : StateCasting() {
|
||||
private var _nsdManager: NsdManager? = null
|
||||
|
||||
private val _discoveryListeners = mapOf(
|
||||
"_googlecast._tcp" to createDiscoveryListener(::addOrUpdateChromeCastDevice),
|
||||
"_airplay._tcp" to createDiscoveryListener(::addOrUpdateAirPlayDevice),
|
||||
"_fastcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice),
|
||||
"_fcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice)
|
||||
)
|
||||
|
||||
override fun handleUrl(url: String) {
|
||||
val uri = Uri.parse(url)
|
||||
if (uri.scheme != "fcast") {
|
||||
throw Exception("Expected scheme to be FCast")
|
||||
}
|
||||
|
||||
val type = uri.host
|
||||
if (type != "r") {
|
||||
throw Exception("Expected type r")
|
||||
}
|
||||
|
||||
val connectionInfo = uri.pathSegments[0]
|
||||
val json =
|
||||
Base64.decode(connectionInfo, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
|
||||
.toString(Charsets.UTF_8)
|
||||
val networkConfig = Json.decodeFromString<FCastNetworkConfig>(json)
|
||||
val tcpService = networkConfig.services.first { v -> v.type == 0 }
|
||||
|
||||
val foundInfo = addRememberedDevice(
|
||||
CastingDeviceInfo(
|
||||
name = networkConfig.name,
|
||||
type = CastProtocolType.FCAST,
|
||||
addresses = networkConfig.addresses.toTypedArray(),
|
||||
port = tcpService.port
|
||||
)
|
||||
)
|
||||
|
||||
if (foundInfo != null) {
|
||||
connectDevice(deviceFromInfo(foundInfo))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
val ad = activeDevice ?: return;
|
||||
_resumeCastingDevice = ad.getDeviceInfo()
|
||||
Log.i(TAG, "_resumeCastingDevice set to '${ad.name}'")
|
||||
Logger.i(TAG, "Stopping active device because of onStop.");
|
||||
ad.disconnect();
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun start(context: Context) {
|
||||
if (_started)
|
||||
return;
|
||||
_started = true;
|
||||
|
||||
Log.i(TAG, "_resumeCastingDevice set null start")
|
||||
_resumeCastingDevice = null;
|
||||
|
||||
Logger.i(TAG, "CastingService starting...");
|
||||
|
||||
_castServer.start();
|
||||
enableDeveloper(true);
|
||||
|
||||
Logger.i(TAG, "CastingService started.");
|
||||
|
||||
_nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager
|
||||
startDiscovering()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun startDiscovering() {
|
||||
_nsdManager?.apply {
|
||||
_discoveryListeners.forEach {
|
||||
discoverServices(it.key, NsdManager.PROTOCOL_DNS_SD, it.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun stopDiscovering() {
|
||||
_nsdManager?.apply {
|
||||
_discoveryListeners.forEach {
|
||||
try {
|
||||
stopServiceDiscovery(it.value)
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to stop service discovery", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun stop() {
|
||||
if (!_started)
|
||||
return;
|
||||
|
||||
_started = false;
|
||||
|
||||
Logger.i(TAG, "CastingService stopping.")
|
||||
|
||||
stopDiscovering()
|
||||
_scopeIO.cancel();
|
||||
_scopeMain.cancel();
|
||||
|
||||
Logger.i(TAG, "Stopping active device because StateCasting is being stopped.")
|
||||
val d = activeDevice;
|
||||
activeDevice = null;
|
||||
d?.disconnect();
|
||||
|
||||
_castServer.stop();
|
||||
_castServer.removeAllHandlers();
|
||||
|
||||
Logger.i(TAG, "CastingService stopped.")
|
||||
|
||||
_nsdManager = null
|
||||
}
|
||||
|
||||
private fun createDiscoveryListener(addOrUpdate: (String, Array<InetAddress>, Int) -> Unit): NsdManager.DiscoveryListener {
|
||||
return object : NsdManager.DiscoveryListener {
|
||||
override fun onDiscoveryStarted(regType: String) {
|
||||
Log.d(TAG, "Service discovery started for $regType")
|
||||
}
|
||||
|
||||
override fun onDiscoveryStopped(serviceType: String) {
|
||||
Log.i(TAG, "Discovery stopped: $serviceType")
|
||||
}
|
||||
|
||||
override fun onServiceLost(service: NsdServiceInfo) {
|
||||
Log.e(TAG, "service lost: $service")
|
||||
// TODO: Handle service lost, e.g., remove device
|
||||
}
|
||||
|
||||
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
|
||||
Log.e(TAG, "Discovery failed for $serviceType: Error code:$errorCode")
|
||||
try {
|
||||
_nsdManager?.stopServiceDiscovery(this)
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to stop service discovery", e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {
|
||||
Log.e(TAG, "Stop discovery failed for $serviceType: Error code:$errorCode")
|
||||
try {
|
||||
_nsdManager?.stopServiceDiscovery(this)
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to stop service discovery", e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceFound(service: NsdServiceInfo) {
|
||||
Log.v(TAG, "Service discovery success for ${service.serviceType}: $service")
|
||||
val addresses = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
service.hostAddresses.toTypedArray()
|
||||
} else {
|
||||
arrayOf(service.host)
|
||||
}
|
||||
addOrUpdate(service.serviceName, addresses, service.port)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
_nsdManager?.registerServiceInfoCallback(
|
||||
service,
|
||||
{ it.run() },
|
||||
object : NsdManager.ServiceInfoCallback {
|
||||
override fun onServiceUpdated(serviceInfo: NsdServiceInfo) {
|
||||
Log.v(TAG, "onServiceUpdated: $serviceInfo")
|
||||
addOrUpdate(
|
||||
serviceInfo.serviceName,
|
||||
serviceInfo.hostAddresses.toTypedArray(),
|
||||
serviceInfo.port
|
||||
)
|
||||
}
|
||||
|
||||
override fun onServiceLost() {
|
||||
Log.v(TAG, "onServiceLost: $service")
|
||||
// TODO: Handle service lost
|
||||
}
|
||||
|
||||
override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {
|
||||
Log.v(TAG, "onServiceInfoCallbackRegistrationFailed: $errorCode")
|
||||
}
|
||||
|
||||
override fun onServiceInfoCallbackUnregistered() {
|
||||
Log.v(TAG, "onServiceInfoCallbackUnregistered")
|
||||
}
|
||||
})
|
||||
} else {
|
||||
_nsdManager?.resolveService(service, object : NsdManager.ResolveListener {
|
||||
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
|
||||
Log.v(TAG, "Resolve failed: $errorCode")
|
||||
}
|
||||
|
||||
override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
|
||||
Log.v(TAG, "Resolve Succeeded: $serviceInfo")
|
||||
addOrUpdate(
|
||||
serviceInfo.serviceName,
|
||||
arrayOf(serviceInfo.host),
|
||||
serviceInfo.port
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun startUpdateTimeJob(
|
||||
onTimeJobTimeChanged_s: Event1<Long>,
|
||||
setTime: (Long) -> Unit
|
||||
): Job? {
|
||||
val d = activeDevice;
|
||||
if (d is CastingDeviceLegacyWrapper && (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 CastingDeviceLegacyWrapper(
|
||||
when (deviceInfo.type) {
|
||||
CastProtocolType.CHROMECAST -> {
|
||||
ChromecastCastingDevice(deviceInfo);
|
||||
}
|
||||
|
||||
CastProtocolType.AIRPLAY -> {
|
||||
AirPlayCastingDevice(deviceInfo);
|
||||
}
|
||||
|
||||
CastProtocolType.FCAST -> {
|
||||
FCastCastingDevice(deviceInfo);
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun addOrUpdateChromeCastDevice(
|
||||
name: String,
|
||||
addresses: Array<InetAddress>,
|
||||
port: Int
|
||||
) {
|
||||
return addOrUpdateCastDevice(
|
||||
name,
|
||||
deviceFactory = {
|
||||
CastingDeviceLegacyWrapper(
|
||||
ChromecastCastingDevice(
|
||||
name,
|
||||
addresses,
|
||||
port
|
||||
)
|
||||
)
|
||||
},
|
||||
deviceUpdater = { d ->
|
||||
if (d.isReady || d !is CastingDeviceLegacyWrapper || d.inner !is ChromecastCastingDevice) {
|
||||
return@addOrUpdateCastDevice false;
|
||||
}
|
||||
|
||||
val changed =
|
||||
addresses.contentEquals(d.inner.addresses) || d.name != name || d.inner.port != port;
|
||||
if (changed) {
|
||||
d.inner.name = name;
|
||||
d.inner.addresses = addresses;
|
||||
d.inner.port = port;
|
||||
}
|
||||
|
||||
return@addOrUpdateCastDevice changed;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private fun addOrUpdateAirPlayDevice(name: String, addresses: Array<InetAddress>, port: Int) {
|
||||
return addOrUpdateCastDevice(
|
||||
name,
|
||||
deviceFactory = {
|
||||
CastingDeviceLegacyWrapper(
|
||||
AirPlayCastingDevice(
|
||||
name,
|
||||
addresses,
|
||||
port
|
||||
)
|
||||
)
|
||||
},
|
||||
deviceUpdater = { d ->
|
||||
if (d.isReady || d !is CastingDeviceLegacyWrapper || d.inner !is AirPlayCastingDevice) {
|
||||
return@addOrUpdateCastDevice false;
|
||||
}
|
||||
|
||||
val changed =
|
||||
addresses.contentEquals(addresses) || d.name != name || d.inner.port != port;
|
||||
if (changed) {
|
||||
d.inner.name = name;
|
||||
d.inner.port = port;
|
||||
d.inner.addresses = addresses;
|
||||
}
|
||||
|
||||
return@addOrUpdateCastDevice changed;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private fun addOrUpdateFastCastDevice(name: String, addresses: Array<InetAddress>, port: Int) {
|
||||
return addOrUpdateCastDevice(
|
||||
name,
|
||||
deviceFactory = { CastingDeviceLegacyWrapper(FCastCastingDevice(name, addresses, port)) },
|
||||
deviceUpdater = { d ->
|
||||
if (d.isReady || d !is CastingDeviceLegacyWrapper || d.inner !is FCastCastingDevice) {
|
||||
return@addOrUpdateCastDevice false;
|
||||
}
|
||||
|
||||
val changed =
|
||||
addresses.contentEquals(addresses) || d.name != name || d.inner.port != port;
|
||||
if (changed) {
|
||||
d.inner.name = name;
|
||||
d.inner.port = port;
|
||||
d.inner.addresses = addresses;
|
||||
}
|
||||
|
||||
return@addOrUpdateCastDevice changed;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private inline fun addOrUpdateCastDevice(
|
||||
name: String,
|
||||
deviceFactory: () -> CastingDevice,
|
||||
deviceUpdater: (device: CastingDevice) -> Boolean
|
||||
) {
|
||||
var invokeEvents: (() -> Unit)? = null;
|
||||
|
||||
synchronized(devices) {
|
||||
val device = devices[name];
|
||||
if (device != null) {
|
||||
val changed = deviceUpdater(device);
|
||||
if (changed) {
|
||||
invokeEvents = {
|
||||
onDeviceChanged.emit(device);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val newDevice = deviceFactory();
|
||||
this.devices[name] = newDevice
|
||||
|
||||
invokeEvents = {
|
||||
onDeviceAdded.emit(newDevice);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
invokeEvents?.let { _scopeMain.launch { it(); }; };
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class FCastNetworkConfig(
|
||||
val name: String,
|
||||
val addresses: List<String>,
|
||||
val services: List<FCastService>
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class FCastService(
|
||||
val port: Int,
|
||||
val type: Int
|
||||
)
|
||||
|
||||
companion object {
|
||||
private val TAG = "StateCastingLegacy"
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
package com.futo.platformplayer.casting.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class FCastPlayMessage(
|
||||
val container: String,
|
||||
val url: String? = null,
|
||||
val content: String? = null,
|
||||
val time: Double? = null,
|
||||
val speed: Double? = null
|
||||
) { }
|
||||
|
||||
@Serializable
|
||||
data class FCastSeekMessage(
|
||||
val time: Double
|
||||
) { }
|
||||
|
||||
@Serializable
|
||||
data class FCastPlaybackUpdateMessage(
|
||||
val generationTime: Long,
|
||||
val time: Double,
|
||||
val duration: Double,
|
||||
val state: Int,
|
||||
val speed: Double
|
||||
) { }
|
||||
|
||||
|
||||
@Serializable
|
||||
data class FCastVolumeUpdateMessage(
|
||||
val generationTime: Long,
|
||||
val volume: Double
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class FCastSetVolumeMessage(
|
||||
val volume: Double
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class FCastSetSpeedMessage(
|
||||
val speed: Double
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class FCastPlaybackErrorMessage(
|
||||
val message: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class FCastVersionMessage(
|
||||
val version: Long
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class FCastKeyExchangeMessage(
|
||||
val version: Long,
|
||||
val publicKey: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class FCastDecryptedMessage(
|
||||
val opcode: Long,
|
||||
val message: String?
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class FCastEncryptedMessage(
|
||||
val version: Long,
|
||||
val iv: String?,
|
||||
val blob: String
|
||||
)
|
||||
@@ -40,13 +40,7 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) {
|
||||
_buttonConfirm = findViewById(R.id.button_confirm);
|
||||
_buttonTutorial = findViewById(R.id.button_tutorial)
|
||||
|
||||
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 ->
|
||||
ArrayAdapter.createFromResource(context, R.array.casting_device_type_array, R.layout.spinner_item_simple).also { adapter ->
|
||||
adapter.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
|
||||
_spinnerType.adapter = adapter;
|
||||
};
|
||||
|
||||
@@ -12,7 +12,6 @@ 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.CastConnectionState
|
||||
import com.futo.platformplayer.casting.CastProtocolType
|
||||
@@ -174,13 +173,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
|
||||
_textType.text = "AirPlay";
|
||||
}
|
||||
CastProtocolType.FCAST -> {
|
||||
_imageDevice.setImageResource(
|
||||
if (Settings.instance.casting.experimentalCasting) {
|
||||
R.drawable.ic_exp_fc
|
||||
} else {
|
||||
R.drawable.ic_fc
|
||||
}
|
||||
)
|
||||
_imageDevice.setImageResource(R.drawable.ic_fc)
|
||||
_textType.text = "FCast";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ 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.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.casting.CastConnectionState
|
||||
import com.futo.platformplayer.casting.CastProtocolType
|
||||
@@ -91,13 +90,7 @@ class DeviceViewHolder : ViewHolder {
|
||||
_textType.text = "AirPlay";
|
||||
}
|
||||
CastProtocolType.FCAST -> {
|
||||
_imageDevice.setImageResource(
|
||||
if (Settings.instance.casting.experimentalCasting) {
|
||||
R.drawable.ic_exp_fc
|
||||
} else {
|
||||
R.drawable.ic_fc
|
||||
}
|
||||
)
|
||||
_imageDevice.setImageResource(R.drawable.ic_fc)
|
||||
_textType.text = "FCast";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
syntax = "proto2";
|
||||
option optimize_for = LITE_RUNTIME;
|
||||
package com.futo.platformplayer.protos;
|
||||
|
||||
message CastMessage {
|
||||
enum ProtocolVersion { CASTV2_1_0 = 0; }
|
||||
required ProtocolVersion protocol_version = 1;
|
||||
required string source_id = 2;
|
||||
required string destination_id = 3;
|
||||
required string namespace = 4;
|
||||
enum PayloadType {
|
||||
STRING = 0;
|
||||
BINARY = 1;
|
||||
}
|
||||
required PayloadType payload_type = 5;
|
||||
optional string payload_utf8 = 6;
|
||||
optional bytes payload_binary = 7;
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="111.96dp"
|
||||
android:height="114.46dp"
|
||||
android:viewportWidth="111.96"
|
||||
android:viewportHeight="114.46">
|
||||
<path
|
||||
android:pathData="m84.76,5.58c2.06,-2.06 0.6,-5.58 -2.31,-5.58H3.27C1.46,-0 -0,1.46 -0,3.27V82.45c0,2.91 3.52,4.37 5.58,2.31L20.37,69.98c0.61,-0.61 0.96,-1.45 0.96,-2.31V24.6c0,-1.81 1.46,-3.27 3.27,-3.27h43.07c0.87,0 1.7,-0.34 2.31,-0.96z"
|
||||
android:fillColor="#ffffff"
|
||||
android:fillType="evenOdd"/>
|
||||
<path
|
||||
android:pathData="m45.68,73.5v17.18c0,0.68 -0.55,1.23 -1.23,1.23L27.56,91.9c-0.68,0 -1.23,-0.55 -1.23,-1.23v-17.18c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM45.68,51.16v17.18c0,0.68 -0.55,1.23 -1.23,1.23L27.56,69.57c-0.68,0 -1.23,-0.55 -1.23,-1.23L26.33,51.16c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM67.73,51.16v17.18c0,0.68 -0.55,1.23 -1.23,1.23L49.61,69.57c-0.68,0 -1.23,-0.55 -1.23,-1.23L48.38,51.16c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM67.73,28.83v17.18c0,0.68 -0.55,1.23 -1.23,1.23L49.61,47.24c-0.68,0 -1.23,-0.55 -1.23,-1.23L48.38,28.83c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM89.77,51.16v17.18c0,0.68 -0.55,1.23 -1.23,1.23h-16.89c-0.68,0 -1.23,-0.55 -1.23,-1.23L70.43,51.16c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM67.73,73.5v17.18c0,0.68 -0.55,1.23 -1.23,1.23L49.61,91.9c-0.68,0 -1.23,-0.55 -1.23,-1.23v-17.18c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM89.77,28.83v17.18c0,0.68 -0.55,1.23 -1.23,1.23h-16.89c-0.68,0 -1.23,-0.55 -1.23,-1.23L70.43,28.83c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM45.68,28.83v17.18c0,0.68 -0.55,1.23 -1.23,1.23L27.56,47.24c-0.68,0 -1.23,-0.55 -1.23,-1.23L26.33,28.83c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM45.68,95.83v17.18c0,0.68 -0.55,1.23 -1.23,1.23L27.56,114.24c-0.68,0 -1.23,-0.55 -1.23,-1.23L26.33,95.83c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM111.77,28.83v17.18c0,0.68 -0.55,1.23 -1.23,1.23L93.65,47.24c-0.68,0 -1.23,-0.55 -1.23,-1.23L92.43,28.83c0,-0.68 0.55,-1.23 1.23,-1.23L110.55,27.6c0.68,0 1.23,0.55 1.23,1.23z"
|
||||
android:strokeWidth="0"
|
||||
android:fillColor="#ffffff"/>
|
||||
</vector>
|
||||
@@ -1,9 +1,14 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="17dp"
|
||||
android:height="12dp"
|
||||
android:viewportWidth="17"
|
||||
android:viewportHeight="12">
|
||||
android:width="111.96dp"
|
||||
android:height="114.46dp"
|
||||
android:viewportWidth="111.96"
|
||||
android:viewportHeight="114.46">
|
||||
<path
|
||||
android:pathData="M0.672,11V0.818H6.563V1.653H1.601V5.487H6.101V6.322H1.601V11H0.672ZM16.849,4H15.915C15.845,3.652 15.719,3.33 15.537,3.036C15.358,2.737 15.132,2.477 14.861,2.255C14.589,2.033 14.281,1.861 13.936,1.738C13.591,1.615 13.218,1.554 12.817,1.554C12.174,1.554 11.588,1.721 11.057,2.056C10.53,2.391 10.108,2.883 9.79,3.533C9.475,4.179 9.317,4.971 9.317,5.909C9.317,6.854 9.475,7.649 9.79,8.295C10.108,8.942 10.53,9.432 11.057,9.767C11.588,10.099 12.174,10.264 12.817,10.264C13.218,10.264 13.591,10.203 13.936,10.08C14.281,9.958 14.589,9.787 14.861,9.568C15.132,9.346 15.358,9.086 15.537,8.788C15.719,8.489 15.845,8.166 15.915,7.818H16.849C16.766,8.286 16.611,8.721 16.382,9.126C16.156,9.527 15.868,9.878 15.517,10.18C15.169,10.481 14.768,10.717 14.314,10.886C13.86,11.055 13.361,11.139 12.817,11.139C11.962,11.139 11.203,10.925 10.54,10.498C9.877,10.067 9.357,9.46 8.979,8.678C8.605,7.896 8.417,6.973 8.417,5.909C8.417,4.845 8.605,3.922 8.979,3.14C9.357,2.358 9.877,1.753 10.54,1.325C11.203,0.894 11.962,0.679 12.817,0.679C13.361,0.679 13.86,0.763 14.314,0.933C14.768,1.098 15.169,1.334 15.517,1.638C15.868,1.94 16.156,2.291 16.382,2.692C16.611,3.094 16.766,3.529 16.849,4Z"
|
||||
android:pathData="m84.76,5.58c2.06,-2.06 0.6,-5.58 -2.31,-5.58H3.27C1.46,-0 -0,1.46 -0,3.27V82.45c0,2.91 3.52,4.37 5.58,2.31L20.37,69.98c0.61,-0.61 0.96,-1.45 0.96,-2.31V24.6c0,-1.81 1.46,-3.27 3.27,-3.27h43.07c0.87,0 1.7,-0.34 2.31,-0.96z"
|
||||
android:fillColor="#ffffff"
|
||||
android:fillType="evenOdd"/>
|
||||
<path
|
||||
android:pathData="m45.68,73.5v17.18c0,0.68 -0.55,1.23 -1.23,1.23L27.56,91.9c-0.68,0 -1.23,-0.55 -1.23,-1.23v-17.18c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM45.68,51.16v17.18c0,0.68 -0.55,1.23 -1.23,1.23L27.56,69.57c-0.68,0 -1.23,-0.55 -1.23,-1.23L26.33,51.16c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM67.73,51.16v17.18c0,0.68 -0.55,1.23 -1.23,1.23L49.61,69.57c-0.68,0 -1.23,-0.55 -1.23,-1.23L48.38,51.16c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM67.73,28.83v17.18c0,0.68 -0.55,1.23 -1.23,1.23L49.61,47.24c-0.68,0 -1.23,-0.55 -1.23,-1.23L48.38,28.83c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM89.77,51.16v17.18c0,0.68 -0.55,1.23 -1.23,1.23h-16.89c-0.68,0 -1.23,-0.55 -1.23,-1.23L70.43,51.16c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM67.73,73.5v17.18c0,0.68 -0.55,1.23 -1.23,1.23L49.61,91.9c-0.68,0 -1.23,-0.55 -1.23,-1.23v-17.18c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM89.77,28.83v17.18c0,0.68 -0.55,1.23 -1.23,1.23h-16.89c-0.68,0 -1.23,-0.55 -1.23,-1.23L70.43,28.83c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM45.68,28.83v17.18c0,0.68 -0.55,1.23 -1.23,1.23L27.56,47.24c-0.68,0 -1.23,-0.55 -1.23,-1.23L26.33,28.83c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM45.68,95.83v17.18c0,0.68 -0.55,1.23 -1.23,1.23L27.56,114.24c-0.68,0 -1.23,-0.55 -1.23,-1.23L26.33,95.83c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM111.77,28.83v17.18c0,0.68 -0.55,1.23 -1.23,1.23L93.65,47.24c-0.68,0 -1.23,-0.55 -1.23,-1.23L92.43,28.83c0,-0.68 0.55,-1.23 1.23,-1.23L110.55,27.6c0.68,0 1.23,0.55 1.23,1.23z"
|
||||
android:strokeWidth="0"
|
||||
android:fillColor="#ffffff"/>
|
||||
</vector>
|
||||
|
||||
@@ -1112,11 +1112,6 @@
|
||||
<string-array name="casting_device_type_array" translatable="false">
|
||||
<item>FCast</item>
|
||||
<item>ChromeCast</item>
|
||||
<item>AirPlay</item>
|
||||
</string-array>
|
||||
<string-array name="exp_casting_device_type_array" translatable="false">
|
||||
<item>FCast</item>
|
||||
<item>ChromeCast</item>
|
||||
</string-array>
|
||||
<string-array name="log_levels">
|
||||
<item>None</item>
|
||||
|
||||
Reference in New Issue
Block a user