Added support for playback rate changing when casting.

This commit is contained in:
Koen 2023-12-13 13:28:51 +01:00
parent d3ab8ecf3a
commit 14d579eb1b
7 changed files with 106 additions and 70 deletions

View File

@ -1,12 +1,17 @@
package com.futo.platformplayer.casting package com.futo.platformplayer.casting
import android.os.Looper import android.os.Looper
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.getConnectedSocket import com.futo.platformplayer.getConnectedSocket
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.platformplayer.toInetAddress import com.futo.platformplayer.toInetAddress
import kotlinx.coroutines.* 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.net.InetAddress
import java.util.UUID import java.util.UUID
@ -18,7 +23,7 @@ class AirPlayCastingDevice : CastingDevice {
override var usedRemoteAddress: InetAddress? = null; override var usedRemoteAddress: InetAddress? = null;
override var localAddress: InetAddress? = null; override var localAddress: InetAddress? = null;
override val canSetVolume: Boolean get() = false; override val canSetVolume: Boolean get() = false;
override val canSetSpeed: Boolean get() = false; //TODO: Implement playback speed for AirPlay override val canSetSpeed: Boolean get() = true;
var addresses: Array<InetAddress>? = null; var addresses: Array<InetAddress>? = null;
var port: Int = 0; var port: Int = 0;
@ -59,6 +64,10 @@ class AirPlayCastingDevice : CastingDevice {
} else { } else {
post("play", "text/parameters", "Content-Location: $contentId\r\nStart-Position: 0"); 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?) { override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?) {
@ -186,6 +195,11 @@ class AirPlayCastingDevice : CastingDevice {
_scopeIO = null; _scopeIO = null;
} }
override fun changeSpeed(speed: Double) {
this.speed = speed
post("rate?value=$speed")
}
override fun getDeviceInfo(): CastingDeviceInfo { override fun getDeviceInfo(): CastingDeviceInfo {
return CastingDeviceInfo(name!!, CastProtocolType.AIRPLAY, addresses!!.filter { a -> a.hostAddress != null }.map { a -> a.hostAddress!! }.toTypedArray(), port); return CastingDeviceInfo(name!!, CastProtocolType.AIRPLAY, addresses!!.filter { a -> a.hostAddress != null }.map { a -> a.hostAddress!! }.toTypedArray(), port);
} }

View File

@ -81,7 +81,7 @@ abstract class CastingDevice {
var speed: Double = 1.0 var speed: Double = 1.0
set(value) { set(value) {
val changed = value != field; val changed = value != field;
speed = value; field = value;
if (changed) { if (changed) {
onSpeedChanged.emit(value); onSpeedChanged.emit(value);
} }

View File

@ -3,14 +3,24 @@ package com.futo.platformplayer.casting
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.casting.models.* import com.futo.platformplayer.casting.models.FCastPlayMessage
import com.futo.platformplayer.logging.Logger 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.getConnectedSocket import com.futo.platformplayer.getConnectedSocket
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.platformplayer.toHexString import com.futo.platformplayer.toHexString
import com.futo.platformplayer.toInetAddress import com.futo.platformplayer.toInetAddress
import kotlinx.coroutines.* import kotlinx.coroutines.CoroutineScope
import kotlinx.serialization.decodeFromString import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.io.DataInputStream import java.io.DataInputStream
@ -89,6 +99,8 @@ class FCastCastingDevice : CastingDevice {
time = resumePosition, time = resumePosition,
speed = speed speed = speed
)); ));
this.speed = speed ?: 1.0
} }
override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?) { override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?) {
@ -110,6 +122,8 @@ class FCastCastingDevice : CastingDevice {
time = resumePosition, time = resumePosition,
speed = speed speed = speed
)); ));
this.speed = speed ?: 1.0
} }
override fun changeVolume(volume: Double) { override fun changeVolume(volume: Double) {
@ -122,12 +136,12 @@ class FCastCastingDevice : CastingDevice {
} }
override fun changeSpeed(speed: Double) { override fun changeSpeed(speed: Double) {
if (invokeInIOScopeIfRequired({ changeSpeed(volume) })) { if (invokeInIOScopeIfRequired({ changeSpeed(speed) })) {
return; return;
} }
this.speed = speed this.speed = speed
sendMessage(Opcode.SET_SPEED, FCastSetSpeedMessage(volume)) sendMessage(Opcode.SET_SPEED, FCastSetSpeedMessage(speed))
} }
override fun seekVideo(timeSeconds: Double) { override fun seekVideo(timeSeconds: Double) {

View File

@ -262,6 +262,7 @@ class StateCasting {
try { try {
jmDNS.removeServiceListener("_googlecast._tcp.local.", _chromecastServiceListener); jmDNS.removeServiceListener("_googlecast._tcp.local.", _chromecastServiceListener);
jmDNS.removeServiceListener("_airplay._tcp", _airPlayServiceListener); jmDNS.removeServiceListener("_airplay._tcp", _airPlayServiceListener);
jmDNS.removeServiceListener("_fastcast._tcp.local.", _fastCastServiceListener);
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
jmDNS.removeServiceTypeListener(_serviceTypeListener); jmDNS.removeServiceTypeListener(_serviceTypeListener);
@ -382,7 +383,7 @@ class StateCasting {
action(); action();
} }
fun castIfAvailable(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, ms: Long = -1): Boolean { fun castIfAvailable(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, ms: Long = -1, speed: Double?): Boolean {
val ad = activeDevice ?: return false; val ad = activeDevice ?: return false;
if (ad.connectionState != CastConnectionState.CONNECTED) { if (ad.connectionState != CastConnectionState.CONNECTED) {
return false; return false;
@ -403,23 +404,23 @@ class StateCasting {
if (videoSource is LocalVideoSource || audioSource is LocalAudioSource || subtitleSource is LocalSubtitleSource) { if (videoSource is LocalVideoSource || audioSource is LocalAudioSource || subtitleSource is LocalSubtitleSource) {
if (ad is AirPlayCastingDevice) { if (ad is AirPlayCastingDevice) {
Logger.i(TAG, "Casting as local HLS"); Logger.i(TAG, "Casting as local HLS");
castLocalHls(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition); castLocalHls(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition, speed);
} else { } else {
Logger.i(TAG, "Casting as local DASH"); Logger.i(TAG, "Casting as local DASH");
castLocalDash(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition); castLocalDash(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition, speed);
} }
} else { } else {
StateApp.instance.scope.launch(Dispatchers.IO) { StateApp.instance.scope.launch(Dispatchers.IO) {
try { try {
if (ad is FCastCastingDevice) { if (ad is FCastCastingDevice) {
Logger.i(TAG, "Casting as DASH direct"); Logger.i(TAG, "Casting as DASH direct");
castDashDirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition); castDashDirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed);
} else if (ad is AirPlayCastingDevice) { } else if (ad is AirPlayCastingDevice) {
Logger.i(TAG, "Casting as HLS indirect"); Logger.i(TAG, "Casting as HLS indirect");
castHlsIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition); castHlsIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed);
} else { } else {
Logger.i(TAG, "Casting as DASH indirect"); Logger.i(TAG, "Casting as DASH indirect");
castDashIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition); castDashIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed);
} }
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.e(TAG, "Failed to start casting DASH videoSource=${videoSource} audioSource=${audioSource}.", e); Logger.e(TAG, "Failed to start casting DASH videoSource=${videoSource} audioSource=${audioSource}.", e);
@ -429,32 +430,32 @@ class StateCasting {
} else { } else {
if (videoSource is IVideoUrlSource) { if (videoSource is IVideoUrlSource) {
Logger.i(TAG, "Casting as singular video"); Logger.i(TAG, "Casting as singular video");
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.getVideoUrl(), resumePosition, video.duration.toDouble(), null); ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.getVideoUrl(), resumePosition, video.duration.toDouble(), speed);
} else if (audioSource is IAudioUrlSource) { } else if (audioSource is IAudioUrlSource) {
Logger.i(TAG, "Casting as singular audio"); Logger.i(TAG, "Casting as singular audio");
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.getAudioUrl(), resumePosition, video.duration.toDouble(), null); ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.getAudioUrl(), resumePosition, video.duration.toDouble(), speed);
} else if(videoSource is IHLSManifestSource) { } else if(videoSource is IHLSManifestSource) {
if (ad is ChromecastCastingDevice) { if (ad is ChromecastCastingDevice) {
Logger.i(TAG, "Casting as proxied HLS"); Logger.i(TAG, "Casting as proxied HLS");
castProxiedHls(video, videoSource.url, videoSource.codec, resumePosition); castProxiedHls(video, videoSource.url, videoSource.codec, resumePosition, speed);
} else { } else {
Logger.i(TAG, "Casting as non-proxied HLS"); Logger.i(TAG, "Casting as non-proxied HLS");
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble(), null); ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble(), speed);
} }
} else if(audioSource is IHLSManifestAudioSource) { } else if(audioSource is IHLSManifestAudioSource) {
if (ad is ChromecastCastingDevice) { if (ad is ChromecastCastingDevice) {
Logger.i(TAG, "Casting as proxied audio HLS"); Logger.i(TAG, "Casting as proxied audio HLS");
castProxiedHls(video, audioSource.url, audioSource.codec, resumePosition); castProxiedHls(video, audioSource.url, audioSource.codec, resumePosition, speed);
} else { } else {
Logger.i(TAG, "Casting as non-proxied audio HLS"); 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(), null); ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble(), speed);
} }
} else if (videoSource is LocalVideoSource) { } else if (videoSource is LocalVideoSource) {
Logger.i(TAG, "Casting as local video"); Logger.i(TAG, "Casting as local video");
castLocalVideo(video, videoSource, resumePosition); castLocalVideo(video, videoSource, resumePosition, speed);
} else if (audioSource is LocalAudioSource) { } else if (audioSource is LocalAudioSource) {
Logger.i(TAG, "Casting as local audio"); Logger.i(TAG, "Casting as local audio");
castLocalAudio(video, audioSource, resumePosition); castLocalAudio(video, audioSource, resumePosition, speed);
} else { } else {
var str = listOf( var str = listOf(
if(videoSource != null) "Video: ${videoSource::class.java.simpleName}" else null, if(videoSource != null) "Video: ${videoSource::class.java.simpleName}" else null,
@ -492,15 +493,7 @@ class StateCasting {
return true; return true;
} }
private fun castVideoIndirect() { private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double, speed: Double?) : List<String> {
}
private fun castAudioIndirect() {
}
private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double) : List<String> {
val ad = activeDevice ?: return listOf(); val ad = activeDevice ?: return listOf();
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}"; val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
@ -514,12 +507,12 @@ class StateCasting {
).withTag("cast"); ).withTag("cast");
Logger.i(TAG, "Casting local video (videoUrl: $videoUrl)."); Logger.i(TAG, "Casting local video (videoUrl: $videoUrl).");
ad.loadVideo("BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), null); ad.loadVideo("BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), speed);
return listOf(videoUrl); return listOf(videoUrl);
} }
private fun castLocalAudio(video: IPlatformVideoDetails, audioSource: LocalAudioSource, resumePosition: Double) : List<String> { private fun castLocalAudio(video: IPlatformVideoDetails, audioSource: LocalAudioSource, resumePosition: Double, speed: Double?) : List<String> {
val ad = activeDevice ?: return listOf(); val ad = activeDevice ?: return listOf();
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}"; val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
@ -533,12 +526,12 @@ class StateCasting {
).withTag("cast"); ).withTag("cast");
Logger.i(TAG, "Casting local audio (audioUrl: $audioUrl)."); Logger.i(TAG, "Casting local audio (audioUrl: $audioUrl).");
ad.loadVideo("BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), null); ad.loadVideo("BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), speed);
return listOf(audioUrl); return listOf(audioUrl);
} }
private fun castLocalHls(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double): List<String> { private fun castLocalHls(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double, speed: Double?): List<String> {
val ad = activeDevice ?: return listOf() val ad = activeDevice ?: return listOf()
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}" val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}"
@ -629,12 +622,12 @@ class StateCasting {
).withTag("castLocalHls") ).withTag("castLocalHls")
Logger.i(TAG, "added new castLocalHls handlers (hlsPath: $hlsPath, videoPath: $videoPath, audioPath: $audioPath, subtitlePath: $subtitlePath).") 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(), null) ad.loadVideo("BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble(), speed)
return listOf(hlsUrl, videoUrl, audioUrl, subtitleUrl) return listOf(hlsUrl, videoUrl, audioUrl, subtitleUrl)
} }
private fun castLocalDash(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double) : List<String> { private fun castLocalDash(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double, speed: Double?) : List<String> {
val ad = activeDevice ?: return listOf(); val ad = activeDevice ?: return listOf();
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}"; val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
@ -675,12 +668,12 @@ class StateCasting {
} }
Logger.i(TAG, "added new castLocalDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath, subtitlePath: $subtitlePath)."); 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(), null); ad.loadVideo("BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble(), speed);
return listOf(dashUrl, videoUrl, audioUrl, subtitleUrl); return listOf(dashUrl, videoUrl, audioUrl, subtitleUrl);
} }
private suspend fun castDashDirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double) : List<String> { private suspend fun castDashDirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List<String> {
val ad = activeDevice ?: return listOf(); val ad = activeDevice ?: return listOf();
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}"; val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
@ -720,12 +713,12 @@ class StateCasting {
val content = DashBuilder.generateOnDemandDash(videoSource, videoUrl, audioSource, audioUrl, subtitleSource, subtitlesUrl); val content = DashBuilder.generateOnDemandDash(videoSource, videoUrl, audioSource, audioUrl, subtitleSource, subtitlesUrl);
Logger.i(TAG, "Direct dash cast to casting device (videoUrl: $videoUrl, audioUrl: $audioUrl)."); Logger.i(TAG, "Direct dash cast to casting device (videoUrl: $videoUrl, audioUrl: $audioUrl).");
ad.loadContent("application/dash+xml", content, resumePosition, video.duration.toDouble(), null); ad.loadContent("application/dash+xml", content, resumePosition, video.duration.toDouble(), speed);
return listOf(videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: ""); return listOf(videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "");
} }
private fun castProxiedHls(video: IPlatformVideoDetails, sourceUrl: String, codec: String?, resumePosition: Double): List<String> { private fun castProxiedHls(video: IPlatformVideoDetails, sourceUrl: String, codec: String?, resumePosition: Double, speed: Double?): List<String> {
_castServer.removeAllHandlers("castProxiedHlsMaster") _castServer.removeAllHandlers("castProxiedHlsMaster")
val ad = activeDevice ?: return listOf(); val ad = activeDevice ?: return listOf();
@ -846,7 +839,7 @@ class StateCasting {
//ChromeCast is sometimes funky with resume position 0 //ChromeCast is sometimes funky with resume position 0
val hackfixResumePosition = if (ad is ChromecastCastingDevice && !video.isLive && resumePosition == 0.0) 0.1 else resumePosition; 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(), null); ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, hackfixResumePosition, video.duration.toDouble(), speed);
return listOf(hlsUrl); return listOf(hlsUrl);
} }
@ -897,7 +890,7 @@ class StateCasting {
} }
} }
private suspend fun castHlsIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double) : List<String> { private suspend fun castHlsIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List<String> {
val ad = activeDevice ?: return listOf(); val ad = activeDevice ?: return listOf();
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}"; val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
val id = UUID.randomUUID(); val id = UUID.randomUUID();
@ -1020,12 +1013,12 @@ class StateCasting {
).withTag("castHlsIndirectMaster") ).withTag("castHlsIndirectMaster")
Logger.i(TAG, "added new castHls handlers (hlsPath: $hlsPath)."); 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(), null); ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble(), speed);
return listOf(hlsUrl, videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString()); return listOf(hlsUrl, videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString());
} }
private suspend fun castDashIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double) : List<String> { private suspend fun castDashIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List<String> {
val ad = activeDevice ?: return listOf(); val ad = activeDevice ?: return listOf();
val proxyStreams = ad !is FCastCastingDevice; val proxyStreams = ad !is FCastCastingDevice;
@ -1095,7 +1088,7 @@ class StateCasting {
} }
Logger.i(TAG, "added new castDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath)."); 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(), null); ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble(), speed);
return listOf(dashUrl, videoUrl ?: "", audioUrl ?: "", subtitlesUrl ?: "", videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString()); return listOf(dashUrl, videoUrl ?: "", audioUrl ?: "", subtitlesUrl ?: "", videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString());
} }

View File

@ -1468,7 +1468,7 @@ class VideoDetailView : ConstraintLayout {
_player.seekTo(resumePositionMs); _player.seekTo(resumePositionMs);
} }
else else
loadCurrentVideoCast(video, videoSource, audioSource, subtitleSource, resumePositionMs); loadCurrentVideoCast(video, videoSource, audioSource, subtitleSource, resumePositionMs, Settings.instance.playback.getDefaultPlaybackSpeed().toDouble());
_lastVideoSource = videoSource; _lastVideoSource = videoSource;
_lastAudioSource = audioSource; _lastAudioSource = audioSource;
@ -1482,14 +1482,13 @@ class VideoDetailView : ConstraintLayout {
UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.failed_to_load_media), ex); UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.failed_to_load_media), ex);
} }
} }
private fun loadCurrentVideoCast(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, resumePositionMs: Long) { private fun loadCurrentVideoCast(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, resumePositionMs: Long, speed: Double?) {
Logger.i(TAG, "loadCurrentVideoCast(video=$video, videoSource=$videoSource, audioSource=$audioSource, resumePositionMs=$resumePositionMs)") Logger.i(TAG, "loadCurrentVideoCast(video=$video, videoSource=$videoSource, audioSource=$audioSource, resumePositionMs=$resumePositionMs)")
if(StateCasting.instance.castIfAvailable(context.contentResolver, video, videoSource, audioSource, subtitleSource, resumePositionMs)) { if(StateCasting.instance.castIfAvailable(context.contentResolver, video, videoSource, audioSource, subtitleSource, resumePositionMs, speed)) {
_cast.setVideoDetails(video, resumePositionMs / 1000); _cast.setVideoDetails(video, resumePositionMs / 1000);
setCastEnabled(true); setCastEnabled(true);
} } else throw IllegalStateException("Disconnected cast during loading");
else throw IllegalStateException("Disconnected cast during loading");
} }
//Events //Events
@ -1576,6 +1575,11 @@ class VideoDetailView : ConstraintLayout {
_overlay_quality_selector?.selectOption("video", _lastVideoSource); _overlay_quality_selector?.selectOption("video", _lastVideoSource);
_overlay_quality_selector?.selectOption("audio", _lastAudioSource); _overlay_quality_selector?.selectOption("audio", _lastAudioSource);
_overlay_quality_selector?.selectOption("subtitles", _lastSubtitleSource); _overlay_quality_selector?.selectOption("subtitles", _lastSubtitleSource);
val currentPlaybackRate = (if (_isCasting) StateCasting.instance.activeDevice?.speed else _player.getPlaybackRate()) ?: 1.0
_overlay_quality_selector?.groupItems?.firstOrNull { it is SlideUpMenuButtonList && it.id == "playback_rate" }?.let {
(it as SlideUpMenuButtonList).setSelected(currentPlaybackRate.toString())
};
_overlay_quality_selector?.show(); _overlay_quality_selector?.show();
_slideUpOverlay = _overlay_quality_selector; _slideUpOverlay = _overlay_quality_selector;
} }
@ -1656,18 +1660,26 @@ class VideoDetailView : ConstraintLayout {
?.filter { it.container == bestAudioContainer } ?.filter { it.container == bestAudioContainer }
?.toList() ?: listOf(); ?.toList() ?: listOf();
val canSetSpeed = !_isCasting || StateCasting.instance.activeDevice?.canSetSpeed == true
val currentPlaybackRate = if (_isCasting) StateCasting.instance.activeDevice?.speed else _player.getPlaybackRate()
_overlay_quality_selector = SlideUpMenuOverlay(this.context, _overlay_quality_container, context.getString( _overlay_quality_selector = SlideUpMenuOverlay(this.context, _overlay_quality_container, context.getString(
R.string.quality), null, true, R.string.quality), null, true,
if (!_isCasting) SlideUpMenuTitle(this.context).apply { setTitle(context.getString(R.string.playback_rate)) } else null, if (canSetSpeed) SlideUpMenuTitle(this.context).apply { setTitle(context.getString(R.string.playback_rate)) } else null,
if (!_isCasting) SlideUpMenuButtonList(this.context).apply { if (canSetSpeed) SlideUpMenuButtonList(this.context, null, "playback_rate").apply {
setButtons(listOf("0.25", "0.5", "0.75", "1.0", "1.25", "1.5", "1.75", "2.0", "2.25"), _player.getPlaybackRate().toString()); setButtons(listOf("0.25", "0.5", "0.75", "1.0", "1.25", "1.5", "1.75", "2.0", "2.25"), currentPlaybackRate!!.toString());
onClick.subscribe { v -> onClick.subscribe { v ->
if (_isCasting) { if (_isCasting) {
return@subscribe; val ad = StateCasting.instance.activeDevice ?: return@subscribe
if (!ad.canSetSpeed) {
return@subscribe
} }
ad.changeSpeed(v.toDouble())
setSelected(v);
} else {
_player.setPlaybackRate(v.toFloat()); _player.setPlaybackRate(v.toFloat());
setSelected(v); setSelected(v);
}
}; };
} else null, } else null,
@ -1829,7 +1841,7 @@ class VideoDetailView : ConstraintLayout {
val d = StateCasting.instance.activeDevice; val d = StateCasting.instance.activeDevice;
if (d != null && d.connectionState == CastConnectionState.CONNECTED) if (d != null && d.connectionState == CastConnectionState.CONNECTED)
StateCasting.instance.castIfAvailable(context.contentResolver, video, videoSource, _lastAudioSource, _lastSubtitleSource, (d.expectedCurrentTime * 1000.0).toLong()); StateCasting.instance.castIfAvailable(context.contentResolver, video, videoSource, _lastAudioSource, _lastSubtitleSource, (d.expectedCurrentTime * 1000.0).toLong(), d.speed);
else if(!_player.swapSources(videoSource, _lastAudioSource, true, true, true)) else if(!_player.swapSources(videoSource, _lastAudioSource, true, true, true))
_player.hideControls(false); //TODO: Disable player? _player.hideControls(false); //TODO: Disable player?
@ -1844,7 +1856,7 @@ class VideoDetailView : ConstraintLayout {
val d = StateCasting.instance.activeDevice; val d = StateCasting.instance.activeDevice;
if (d != null && d.connectionState == CastConnectionState.CONNECTED) if (d != null && d.connectionState == CastConnectionState.CONNECTED)
StateCasting.instance.castIfAvailable(context.contentResolver, video, _lastVideoSource, audioSource, _lastSubtitleSource, (d.expectedCurrentTime * 1000.0).toLong()); StateCasting.instance.castIfAvailable(context.contentResolver, video, _lastVideoSource, audioSource, _lastSubtitleSource, (d.expectedCurrentTime * 1000.0).toLong(), d.speed);
else(!_player.swapSources(_lastVideoSource, audioSource, true, true, true)) else(!_player.swapSources(_lastVideoSource, audioSource, true, true, true))
_player.hideControls(false); //TODO: Disable player? _player.hideControls(false); //TODO: Disable player?
@ -1860,7 +1872,7 @@ class VideoDetailView : ConstraintLayout {
val d = StateCasting.instance.activeDevice; val d = StateCasting.instance.activeDevice;
if (d != null && d.connectionState == CastConnectionState.CONNECTED) if (d != null && d.connectionState == CastConnectionState.CONNECTED)
StateCasting.instance.castIfAvailable(context.contentResolver, video, _lastVideoSource, _lastAudioSource, toSet, (d.expectedCurrentTime * 1000.0).toLong()); StateCasting.instance.castIfAvailable(context.contentResolver, video, _lastVideoSource, _lastAudioSource, toSet, (d.expectedCurrentTime * 1000.0).toLong(), d.speed);
else else
_player.swapSubtitles(fragment.lifecycleScope, toSet); _player.swapSubtitles(fragment.lifecycleScope, toSet);

View File

@ -18,8 +18,11 @@ class SlideUpMenuButtonList : LinearLayout {
val onClick = Event1<String>(); val onClick = Event1<String>();
val buttons: HashMap<String, LinearLayout> = hashMapOf(); val buttons: HashMap<String, LinearLayout> = hashMapOf();
var _activeText: String? = null; var _activeText: String? = null;
val id: String?
constructor(context: Context, attrs: AttributeSet? = null, id: String? = null): super(context, attrs) {
this.id = id
constructor(context: Context, attrs: AttributeSet? = null): super(context, attrs) {
LayoutInflater.from(context).inflate(R.layout.overlay_slide_up_menu_button_list, this, true); LayoutInflater.from(context).inflate(R.layout.overlay_slide_up_menu_button_list, this, true);
_root = findViewById(R.id.root); _root = findViewById(R.id.root);

View File

@ -26,7 +26,7 @@ class SlideUpMenuOverlay : RelativeLayout {
private lateinit var _viewContainer: LinearLayout; private lateinit var _viewContainer: LinearLayout;
private var _animated: Boolean = true; private var _animated: Boolean = true;
private var _groupItems: List<View>; var groupItems: List<View>;
var isVisible = false var isVisible = false
private set; private set;
@ -36,7 +36,7 @@ class SlideUpMenuOverlay : RelativeLayout {
constructor(context: Context, attrs: AttributeSet? = null): super(context, attrs) { constructor(context: Context, attrs: AttributeSet? = null): super(context, attrs) {
init(false, null); init(false, null);
_groupItems = listOf(); groupItems = listOf();
} }
constructor(context: Context, parent: ViewGroup, titleText: String, okText: String?, animated: Boolean, items: List<View>, hideButtons: Boolean = false): super(context){ constructor(context: Context, parent: ViewGroup, titleText: String, okText: String?, animated: Boolean, items: List<View>, hideButtons: Boolean = false): super(context){
@ -47,7 +47,7 @@ class SlideUpMenuOverlay : RelativeLayout {
_container!!.addView(this); _container!!.addView(this);
} }
_textTitle.text = titleText; _textTitle.text = titleText;
_groupItems = items; groupItems = items;
if(hideButtons) { if(hideButtons) {
_textCancel.visibility = GONE; _textCancel.visibility = GONE;
@ -74,7 +74,7 @@ class SlideUpMenuOverlay : RelativeLayout {
item.setParentClickListener { hide() }; item.setParentClickListener { hide() };
} }
_groupItems = items; groupItems = items;
} }
private fun init(animated: Boolean, okText: String?){ private fun init(animated: Boolean, okText: String?){
@ -116,12 +116,12 @@ class SlideUpMenuOverlay : RelativeLayout {
fun selectOption(groupTag: Any?, itemTag: Any?, multiSelect: Boolean = false, toggle: Boolean = false): Boolean { fun selectOption(groupTag: Any?, itemTag: Any?, multiSelect: Boolean = false, toggle: Boolean = false): Boolean {
var didSelect = false; var didSelect = false;
for(view in _groupItems) { for(view in groupItems) {
if(view is SlideUpMenuGroup && view.groupTag == groupTag) if(view is SlideUpMenuGroup && view.groupTag == groupTag)
didSelect = didSelect || view.selectItem(itemTag); didSelect = didSelect || view.selectItem(itemTag);
} }
if(groupTag == null) if(groupTag == null)
for(item in _groupItems) for(item in groupItems)
if(item is SlideUpMenuItem) { if(item is SlideUpMenuItem) {
if(multiSelect) { if(multiSelect) {
if(item.itemTag == itemTag) if(item.itemTag == itemTag)