diff --git a/app/src/main/java/com/futo/platformplayer/builders/DashBuilder.kt b/app/src/main/java/com/futo/platformplayer/builders/DashBuilder.kt index 3e1fcccd..98593848 100644 --- a/app/src/main/java/com/futo/platformplayer/builders/DashBuilder.kt +++ b/app/src/main/java/com/futo/platformplayer/builders/DashBuilder.kt @@ -146,7 +146,7 @@ class DashBuilder : XMLBuilder { dashBuilder.withAdaptationSet( mapOf( Pair("mimeType", subtitleSource.format ?: "text/vtt"), - Pair("lang", "en"), + Pair("lang", "df"), Pair("default", "true") ) ) { diff --git a/app/src/main/java/com/futo/platformplayer/casting/AirPlayCastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/AirPlayCastingDevice.kt index a6748cf2..93de4b05 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/AirPlayCastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/AirPlayCastingDevice.kt @@ -18,6 +18,7 @@ class AirPlayCastingDevice : CastingDevice { override var usedRemoteAddress: InetAddress? = null; override var localAddress: InetAddress? = null; override val canSetVolume: Boolean get() = false; + override val canSetSpeed: Boolean get() = false; //TODO: Implement playback speed for AirPlay var addresses: Array? = null; var port: Int = 0; @@ -43,12 +44,12 @@ class AirPlayCastingDevice : CastingDevice { return addresses?.toList() ?: listOf(); } - override fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double) { - if (invokeInIOScopeIfRequired({ loadVideo(streamType, contentType, contentId, resumePosition, duration) })) { + 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)"); + Logger.i(FCastCastingDevice.TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration, speed: $speed)"); time = resumePosition; if (resumePosition > 0.0) { @@ -60,7 +61,7 @@ class AirPlayCastingDevice : CastingDevice { } } - override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double) { + override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?) { throw NotImplementedError(); } diff --git a/app/src/main/java/com/futo/platformplayer/casting/CastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/CastingDevice.kt index 8beba2f2..f7e0438f 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/CastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/CastingDevice.kt @@ -48,6 +48,7 @@ abstract class CastingDevice { abstract var usedRemoteAddress: InetAddress?; abstract var localAddress: InetAddress?; abstract val canSetVolume: Boolean; + abstract val canSetSpeed: Boolean; var name: String? = null; var isPlaying: Boolean = false @@ -77,6 +78,14 @@ abstract class CastingDevice { onVolumeChanged.emit(value); } }; + var speed: Double = 1.0 + set(value) { + val changed = value != field; + speed = value; + if (changed) { + onSpeedChanged.emit(value); + } + }; val expectedCurrentTime: Double get() { val diff = timeReceivedAt.getNowDiffMiliseconds().toDouble() / 1000.0; @@ -96,6 +105,7 @@ abstract class CastingDevice { var onPlayChanged = Event1(); var onTimeChanged = Event1(); var onVolumeChanged = Event1(); + var onSpeedChanged = Event1(); abstract fun stopCasting(); @@ -103,9 +113,10 @@ abstract class CastingDevice { abstract fun stopVideo(); abstract fun pauseVideo(); abstract fun resumeVideo(); - abstract fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double); - abstract fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double); + 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(); diff --git a/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt index eb254b6d..b0335d6d 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt @@ -27,6 +27,7 @@ class ChromecastCastingDevice : CastingDevice { override var usedRemoteAddress: InetAddress? = null; override var localAddress: InetAddress? = null; override val canSetVolume: Boolean get() = true; + override val canSetSpeed: Boolean get() = false; //TODO: Implement var addresses: Array? = null; var port: Int = 0; @@ -62,12 +63,12 @@ class ChromecastCastingDevice : CastingDevice { return addresses?.toList() ?: listOf(); } - override fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double) { - if (invokeInIOScopeIfRequired({ loadVideo(streamType, contentType, contentId, resumePosition, duration) })) { + 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)"); + Logger.i(TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration, speed: $speed)"); time = resumePosition; _streamType = streamType; @@ -77,7 +78,7 @@ class ChromecastCastingDevice : CastingDevice { playVideo(); } - override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double) { + 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(); } diff --git a/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt index e3524846..2a6d51c3 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt @@ -2,6 +2,7 @@ package com.futo.platformplayer.casting import android.os.Looper import android.util.Log +import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.casting.models.* import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.getConnectedSocket @@ -27,7 +28,10 @@ enum class Opcode(val value: Byte) { SEEK(5), PLAYBACK_UPDATE(6), VOLUME_UPDATE(7), - SET_VOLUME(8) + SET_VOLUME(8), + PLAYBACK_ERROR(9), + SET_SPEED(10), + VERSION(11) } class FCastCastingDevice : CastingDevice { @@ -38,6 +42,7 @@ class FCastCastingDevice : CastingDevice { 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? = null; var port: Int = 0; @@ -47,6 +52,7 @@ class FCastCastingDevice : CastingDevice { private var _inputStream: DataInputStream? = null; private var _scopeIO: CoroutineScope? = null; private var _started: Boolean = false; + private var _version: Long = 1; constructor(name: String, addresses: Array, port: Int) : super() { this.name = name; @@ -64,33 +70,45 @@ class FCastCastingDevice : CastingDevice { return addresses?.toList() ?: listOf(); } - override fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double) { - if (invokeInIOScopeIfRequired({ loadVideo(streamType, contentType, contentId, resumePosition, duration) })) { + 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)"); + //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)"); time = resumePosition; sendMessage(Opcode.PLAY, FCastPlayMessage( container = contentType, url = contentId, - time = resumePosition.toInt() + time = resumePosition, + speed = speed )); } - override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double) { - if (invokeInIOScopeIfRequired({ loadContent(contentType, content, resumePosition, duration) })) { + override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?) { + if (invokeInIOScopeIfRequired({ loadContent(contentType, content, resumePosition, duration, speed) })) { return; } - Logger.i(TAG, "Start streaming content (contentType: $contentType, resumePosition: $resumePosition, duration: $duration)"); + //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)"); time = resumePosition; sendMessage(Opcode.PLAY, FCastPlayMessage( container = contentType, content = content, - time = resumePosition.toInt() + time = resumePosition, + speed = speed )); } @@ -103,13 +121,22 @@ class FCastCastingDevice : CastingDevice { sendMessage(Opcode.SET_VOLUME, FCastSetVolumeMessage(volume)) } + override fun changeSpeed(speed: Double) { + if (invokeInIOScopeIfRequired({ changeSpeed(volume) })) { + return; + } + + this.speed = speed + sendMessage(Opcode.SET_SPEED, FCastSetSpeedMessage(volume)) + } + override fun seekVideo(timeSeconds: Double) { if (invokeInIOScopeIfRequired({ seekVideo(timeSeconds) })) { return; } sendMessage(Opcode.SEEK, FCastSeekMessage( - time = timeSeconds.toInt() + time = timeSeconds )); } @@ -282,8 +309,8 @@ class FCastCastingDevice : CastingDevice { return; } - val playbackUpdate = Json.decodeFromString(json); - time = playbackUpdate.time.toDouble(); + val playbackUpdate = FCastCastingDevice.json.decodeFromString(json); + time = playbackUpdate.time; isPlaying = when (playbackUpdate.state) { 1 -> true else -> false @@ -295,9 +322,28 @@ class FCastCastingDevice : CastingDevice { return; } - val volumeUpdate = Json.decodeFromString(json); + val volumeUpdate = FCastCastingDevice.json.decodeFromString(json); volume = volumeUpdate.volume; } + Opcode.PLAYBACK_ERROR -> { + if (json == null) { + Logger.w(TAG, "Got playback error without JSON, ignoring."); + return; + } + + val playbackError = FCastCastingDevice.json.decodeFromString(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(json); + _version = version.version; + Logger.i(TAG, "Remote version received: $version") + } else -> { } } } @@ -333,7 +379,7 @@ class FCastCastingDevice : CastingDevice { val data: ByteArray; var jsonString: String? = null; if (message != null) { - jsonString = Json.encodeToString(message); + jsonString = json.encodeToString(message); data = jsonString.encodeToByteArray(); } else { data = ByteArray(0); @@ -403,5 +449,6 @@ class FCastCastingDevice : CastingDevice { companion object { val TAG = "FastCastCastingDevice"; + private val json = Json { ignoreUnknownKeys = true } } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt index f59b55ad..39c27308 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt @@ -395,17 +395,17 @@ class StateCasting { } else { if (videoSource is IVideoUrlSource) { Logger.i(TAG, "Casting as singular video"); - ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.getVideoUrl(), resumePosition, video.duration.toDouble()); + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.getVideoUrl(), resumePosition, video.duration.toDouble(), null); } else if (audioSource is IAudioUrlSource) { Logger.i(TAG, "Casting as singular audio"); - ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.getAudioUrl(), resumePosition, video.duration.toDouble()); + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.getAudioUrl(), resumePosition, video.duration.toDouble(), null); } else if(videoSource is IHLSManifestSource) { if (ad is ChromecastCastingDevice) { Logger.i(TAG, "Casting as proxied HLS"); castProxiedHls(video, videoSource.url, videoSource.codec, resumePosition); } else { Logger.i(TAG, "Casting as non-proxied HLS"); - ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble()); + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble(), null); } } else if(audioSource is IHLSManifestAudioSource) { if (ad is ChromecastCastingDevice) { @@ -413,7 +413,7 @@ class StateCasting { castProxiedHls(video, audioSource.url, audioSource.codec, resumePosition); } else { Logger.i(TAG, "Casting as non-proxied audio HLS"); - ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble()); + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble(), null); } } else if (videoSource is LocalVideoSource) { Logger.i(TAG, "Casting as local video"); @@ -480,7 +480,7 @@ class StateCasting { ).withTag("cast"); Logger.i(TAG, "Casting local video (videoUrl: $videoUrl)."); - ad.loadVideo("BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble()); + ad.loadVideo("BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), null); return listOf(videoUrl); } @@ -499,7 +499,7 @@ class StateCasting { ).withTag("cast"); Logger.i(TAG, "Casting local audio (audioUrl: $audioUrl)."); - ad.loadVideo("BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble()); + ad.loadVideo("BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), null); return listOf(audioUrl); } @@ -563,7 +563,7 @@ class StateCasting { .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("castLocalHls") - mediaRenditions.add(HLS.MediaRendition("AUDIO", audioVariantPlaylistUrl, "audio", "en", "english", true, true, true)) + mediaRenditions.add(HLS.MediaRendition("AUDIO", audioVariantPlaylistUrl, "audio", "df", "default", true, true, true)) } if (subtitleSource != null) { @@ -584,7 +584,7 @@ class StateCasting { .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("castLocalHls") - mediaRenditions.add(HLS.MediaRendition("SUBTITLES", subtitleVariantPlaylistUrl, "subtitles", "en", "english", true, true, true)) + mediaRenditions.add(HLS.MediaRendition("SUBTITLES", subtitleVariantPlaylistUrl, "subtitles", "df", "default", true, true, true)) } val masterPlaylist = HLS.MasterPlaylist(variantPlaylistReferences, mediaRenditions, listOf(), true) @@ -595,7 +595,7 @@ class StateCasting { ).withTag("castLocalHls") Logger.i(TAG, "added new castLocalHls handlers (hlsPath: $hlsPath, videoPath: $videoPath, audioPath: $audioPath, subtitlePath: $subtitlePath).") - ad.loadVideo("BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble()) + ad.loadVideo("BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble(), null) return listOf(hlsUrl, videoUrl, audioUrl, subtitleUrl) } @@ -641,7 +641,7 @@ class StateCasting { } Logger.i(TAG, "added new castLocalDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath, subtitlePath: $subtitlePath)."); - ad.loadVideo("BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble()); + ad.loadVideo("BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble(), null); return listOf(dashUrl, videoUrl, audioUrl, subtitleUrl); } @@ -686,7 +686,7 @@ class StateCasting { val content = DashBuilder.generateOnDemandDash(videoSource, videoUrl, audioSource, audioUrl, subtitleSource, subtitlesUrl); Logger.i(TAG, "Direct dash cast to casting device (videoUrl: $videoUrl, audioUrl: $audioUrl)."); - ad.loadContent("application/dash+xml", content, resumePosition, video.duration.toDouble()); + ad.loadContent("application/dash+xml", content, resumePosition, video.duration.toDouble(), null); return listOf(videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: ""); } @@ -812,7 +812,7 @@ class StateCasting { //ChromeCast is sometimes funky with resume position 0 val hackfixResumePosition = if (ad is ChromecastCastingDevice && !video.isLive && resumePosition == 0.0) 0.1 else resumePosition; - ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, hackfixResumePosition, video.duration.toDouble()); + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, hackfixResumePosition, video.duration.toDouble(), null); return listOf(hlsUrl); } @@ -892,7 +892,7 @@ class StateCasting { .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("castHlsIndirectVariant"); - mediaRenditions.add(HLS.MediaRendition("AUDIO", audioVariantPlaylistUrl, "audio", "en", "english", true, true, true)) + mediaRenditions.add(HLS.MediaRendition("AUDIO", audioVariantPlaylistUrl, "audio", "df", "default", true, true, true)) _castServer.addHandlerWithAllowAllOptions( HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true) @@ -942,7 +942,7 @@ class StateCasting { .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("castHlsIndirectVariant"); - mediaRenditions.add(HLS.MediaRendition("SUBTITLES", subtitleVariantPlaylistUrl, "subtitles", "en", "english", true, true, true)) + mediaRenditions.add(HLS.MediaRendition("SUBTITLES", subtitleVariantPlaylistUrl, "subtitles", "df", "default", true, true, true)) } if (videoSource != null) { @@ -986,7 +986,7 @@ class StateCasting { ).withTag("castHlsIndirectMaster") Logger.i(TAG, "added new castHls handlers (hlsPath: $hlsPath)."); - ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble()); + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble(), null); return listOf(hlsUrl, videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString()); } @@ -1061,7 +1061,7 @@ class StateCasting { } Logger.i(TAG, "added new castDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath)."); - ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble()); + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble(), null); return listOf(dashUrl, videoUrl ?: "", audioUrl ?: "", subtitlesUrl ?: "", videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString()); } diff --git a/app/src/main/java/com/futo/platformplayer/casting/models/FCast.kt b/app/src/main/java/com/futo/platformplayer/casting/models/FCast.kt index 64de18ba..f7078d18 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/models/FCast.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/models/FCast.kt @@ -2,32 +2,52 @@ package com.futo.platformplayer.casting.models import kotlinx.serialization.Serializable -@kotlinx.serialization.Serializable +@Serializable data class FCastPlayMessage( val container: String, val url: String? = null, val content: String? = null, - val time: Int? = null + val time: Double? = null, + val speed: Double? = null ) { } -@kotlinx.serialization.Serializable +@Serializable data class FCastSeekMessage( - val time: Int + val time: Double ) { } -@kotlinx.serialization.Serializable +@Serializable data class FCastPlaybackUpdateMessage( - val time: Int, - val state: Int + 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 ) \ No newline at end of file