From 2941546ae4489e059c9a0737151317a1badb4af2 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Thu, 22 Aug 2024 21:00:06 +0200 Subject: [PATCH] DashManifestRaw support, RequestExecutor support, http binary body and response support, spec version support, ignore unsupported sources, webm container preference in settings --- app/src/main/assets/scripts/source.js | 33 +++ .../java/com/futo/platformplayer/Settings.kt | 6 + .../media/platforms/js/JSClientConstants.kt | 7 + .../media/platforms/js/models/JSContent.kt | 4 + .../platforms/js/models/JSRequestExecutor.kt | 91 +++++++-- .../sources/JSDashManifestRawAudioSource.kt | 51 ++++- .../models/sources/JSDashManifestRawSource.kt | 98 ++++++++- .../platforms/js/models/sources/JSSource.kt | 33 ++- .../sources/JSUnMuxVideoSourceDescriptor.kt | 2 + .../models/sources/JSVideoSourceDescriptor.kt | 1 + .../developer/DeveloperEndpoints.kt | 4 +- .../engine/packages/PackageBridge.kt | 12 ++ .../engine/packages/PackageHttp.kt | 192 ++++++++++++++---- .../mainactivity/main/VideoDetailView.kt | 2 + .../views/video/FutoVideoPlayerBase.kt | 158 ++++++++++++-- .../video/datasources/JSHttpDataSource.java | 66 ++++-- app/src/main/res/values/strings.xml | 4 + 17 files changed, 657 insertions(+), 107 deletions(-) create mode 100644 app/src/main/java/com/futo/platformplayer/api/media/platforms/js/JSClientConstants.kt diff --git a/app/src/main/assets/scripts/source.js b/app/src/main/assets/scripts/source.js index 0324e0f5..134dd4a7 100644 --- a/app/src/main/assets/scripts/source.js +++ b/app/src/main/assets/scripts/source.js @@ -406,6 +406,39 @@ class DashSource { this.requestModifier = obj.requestModifier; } } +class DashManifestRawSource { + constructor(obj) { + obj = obj ?? {}; + this.plugin_type = "DashRawSource"; + this.name = obj.name ?? ""; + this.bitrate = obj.bitrate ?? 0; + this.container = obj.container ?? ""; + this.codec = obj.codec ?? ""; + this.duration = obj.duration ?? 0; + this.url = obj.url; + this.language = obj.language ?? Language.UNKNOWN; + if(obj.requestModifier) + this.requestModifier = obj.requestModifier; + } +} + +class DashManifestRawAudioSource { + constructor(obj) { + obj = obj ?? {}; + this.plugin_type = "DashRawAudioSource"; + this.name = obj.name ?? ""; + this.bitrate = obj.bitrate ?? 0; + this.container = obj.container ?? ""; + this.codec = obj.codec ?? ""; + this.duration = obj.duration ?? 0; + this.url = obj.url; + this.language = obj.language ?? Language.UNKNOWN; + this.manifest = obj.manifest ?? null; + if(obj.requestModifier) + this.requestModifier = obj.requestModifier; + } +} + class RequestModifier { constructor(obj) { diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt index 8c5a3e8b..aac5e8b3 100644 --- a/app/src/main/java/com/futo/platformplayer/Settings.kt +++ b/app/src/main/java/com/futo/platformplayer/Settings.kt @@ -454,6 +454,12 @@ class Settings : FragmentedStorageFileJson() { @FormField(R.string.full_screen_portrait, FieldForm.TOGGLE, R.string.allow_full_screen_portrait, 13) var fullscreenPortrait: Boolean = false; + + + @FormField(R.string.prefer_webm, FieldForm.TOGGLE, R.string.prefer_webm_description, 14) + var preferWebmVideo: Boolean = false; + @FormField(R.string.prefer_webm_audio, FieldForm.TOGGLE, R.string.prefer_webm_audio_description, 15) + var preferWebmAudio: Boolean = false; } @FormField(R.string.comments, "group", R.string.comments_description, 6) diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/JSClientConstants.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/JSClientConstants.kt new file mode 100644 index 00000000..e5c95047 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/JSClientConstants.kt @@ -0,0 +1,7 @@ +package com.futo.platformplayer.api.media.platforms.js + +class JSClientConstants { + companion object { + val PLUGIN_SPEC_VERSION = 2; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSContent.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSContent.kt index 6127601c..b3dd9dd5 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSContent.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSContent.kt @@ -54,4 +54,8 @@ open class JSContent : IPlatformContent, IPluginSourced { _hasGetDetails = _content.has("getDetails"); } + + fun getUnderlyingObject(): V8ValueObject? { + return _content; + } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSRequestExecutor.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSRequestExecutor.kt index 8a567d6f..a9ab2c1a 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSRequestExecutor.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSRequestExecutor.kt @@ -2,13 +2,20 @@ package com.futo.platformplayer.api.media.platforms.js.models import com.caoccao.javet.values.V8Value import com.caoccao.javet.values.primitive.V8ValueString +import com.caoccao.javet.values.primitive.V8ValueUndefined import com.caoccao.javet.values.reference.V8ValueObject import com.caoccao.javet.values.reference.V8ValueTypedArray +import com.futo.platformplayer.api.media.platforms.js.DevJSClient import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.engine.IV8PluginConfig import com.futo.platformplayer.engine.V8Plugin +import com.futo.platformplayer.engine.exceptions.PluginException +import com.futo.platformplayer.engine.exceptions.ScriptException import com.futo.platformplayer.engine.exceptions.ScriptImplementationException +import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.getOrThrow +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StateDeveloper import kotlinx.serialization.Serializable import java.util.Base64 @@ -16,6 +23,9 @@ class JSRequestExecutor { private val _plugin: JSClient; private val _config: IV8PluginConfig; private var _executor: V8ValueObject; + val urlPrefix: String?; + + private val hasCleanup: Boolean; constructor(plugin: JSClient, executor: V8ValueObject) { this._plugin = plugin; @@ -23,40 +33,95 @@ class JSRequestExecutor { this._config = plugin.config; val config = plugin.config; + urlPrefix = executor.getOrDefault(config, "urlPrefix", "RequestExecutor", null); + if(!executor.has("executeRequest")) throw ScriptImplementationException(config, "RequestExecutor is missing executeRequest", null); + hasCleanup = executor.has("cleanup"); } //TODO: Executor properties? - fun executeRequest(url: String, headers: Map): ByteArray { + @Throws(ScriptException::class) + open fun executeRequest(url: String, headers: Map): ByteArray { if (_executor.isClosed) throw IllegalStateException("Executor object is closed"); - val result = V8Plugin.catchScriptErrors( - _config, - "[${_config.name}] JSRequestExecutor", - "builder.modifyRequest()" - ) { - _executor.invoke("executeRequest", url, headers); - } as V8Value; + val result = if(_plugin is DevJSClient) + StateDeveloper.instance.handleDevCall(_plugin.devID, "requestExecutor.executeRequest()") { + V8Plugin.catchScriptErrors( + _config, + "[${_config.name}] JSRequestExecutor", + "builder.modifyRequest()" + ) { + _executor.invoke("executeRequest", url, headers); + } as V8Value; + } + else V8Plugin.catchScriptErrors( + _config, + "[${_config.name}] JSRequestExecutor", + "builder.modifyRequest()" + ) { + _executor.invoke("executeRequest", url, headers); + } as V8Value; try { - if(result is V8ValueString) - return Base64.getDecoder().decode(result.value); - if(result is V8ValueTypedArray) - return result.toBytes(); + if(result is V8ValueString) { + val base64Result = Base64.getDecoder().decode(result.value); + return base64Result; + } + if(result is V8ValueTypedArray) { + val buffer = result.buffer; + val byteBuffer = buffer.byteBuffer; + val bytesResult = ByteArray(result.byteLength); + byteBuffer.get(bytesResult, 0, result.byteLength); + buffer.close(); + return bytesResult; + } if(result is V8ValueObject && result.has("type")) { val type = result.getOrThrow(_config, "type", "JSRequestModifier"); when(type) { //TODO: Buffer type? } } - throw NotImplementedError("Executor result type not implemented?"); + if(result is V8ValueUndefined) { + if(_plugin is DevJSClient) + StateDeveloper.instance.logDevException(_plugin.devID, "JSRequestExecutor.executeRequest returned illegal undefined"); + throw ScriptImplementationException(_config, "JSRequestExecutor.executeRequest returned illegal undefined", null); + } + throw NotImplementedError("Executor result type not implemented? " + result.javaClass.name); } finally { result.close(); } } + + + open fun cleanup() { + if (!hasCleanup || _executor.isClosed) + return; + + if(_plugin is DevJSClient) + StateDeveloper.instance.handleDevCall(_plugin.devID, "requestExecutor.executeRequest()") { + V8Plugin.catchScriptErrors( + _config, + "[${_config.name}] JSRequestExecutor", + "builder.modifyRequest()" + ) { + _executor.invokeVoid("cleanup", null); + }; + } + else V8Plugin.catchScriptErrors( + _config, + "[${_config.name}] JSRequestExecutor", + "builder.modifyRequest()" + ) { + _executor.invokeVoid("cleanup", null); + }; + } + + protected fun finalize() { + cleanup(); + } } //TODO: are these available..? diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestRawAudioSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestRawAudioSource.kt index acb03a15..e95e7436 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestRawAudioSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestRawAudioSource.kt @@ -1,23 +1,64 @@ package com.futo.platformplayer.api.media.platforms.js.models.sources import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource +import com.futo.platformplayer.api.media.platforms.js.DevJSClient import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.engine.IV8PluginConfig import com.futo.platformplayer.engine.V8Plugin +import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.getOrNull import com.futo.platformplayer.getOrThrow +import com.futo.platformplayer.others.Language +import com.futo.platformplayer.states.StateDeveloper -class JSDashManifestRawAudioSource : JSSource { - val container : String = "application/dash+xml"; - val name : String; - val manifest: String; +class JSDashManifestRawAudioSource : JSSource, IAudioSource { + override val container : String = "application/dash+xml"; + override val name : String; + override val codec: String; + override val bitrate: Int; + override val duration: Long; + override val priority: Boolean; + + override val language: String; + + val url: String; + var manifest: String?; + + val hasGenerate: Boolean; constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH_RAW, plugin, obj) { - val contextName = "DashSource"; + val contextName = "DashRawSource"; val config = plugin.config; name = _obj.getOrThrow(config, "name", contextName); + url = _obj.getOrThrow(config, "url", contextName); manifest = _obj.getOrThrow(config, "manifest", contextName); + codec = _obj.getOrDefault(config, "codec", contextName, "") ?: ""; + bitrate = _obj.getOrDefault(config, "bitrate", contextName, 0) ?: 0; + duration = _obj.getOrDefault(config, "duration", contextName, 0) ?: 0; + priority = _obj.getOrDefault(config, "priority", contextName, false) ?: false; + language = _obj.getOrDefault(config, "language", contextName, Language.UNKNOWN) ?: Language.UNKNOWN; + hasGenerate = _obj.has("generate"); + } + + fun generate(): String? { + if(!hasGenerate) + return manifest; + if(_obj.isClosed) + throw IllegalStateException("Source object already closed"); + + val plugin = _plugin.getUnderlyingPlugin(); + if(_plugin is DevJSClient) + return StateDeveloper.instance.handleDevCall(_plugin.devID, "DashManifestRaw", false) { + _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw", "dashManifestRaw.generate()") { + _obj.invokeString("generate"); + } + } + else + return _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw", "dashManifestRaw.generate()") { + _obj.invokeString("generate"); + } } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestRawSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestRawSource.kt index 6204871e..006df8cd 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestRawSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestRawSource.kt @@ -1,23 +1,109 @@ package com.futo.platformplayer.api.media.platforms.js.models.sources +import com.caoccao.javet.values.V8Value +import com.caoccao.javet.values.primitive.V8ValueString import com.caoccao.javet.values.reference.V8ValueObject import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource +import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource +import com.futo.platformplayer.api.media.platforms.js.DevJSClient import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.engine.IV8PluginConfig import com.futo.platformplayer.engine.V8Plugin +import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.getOrNull import com.futo.platformplayer.getOrThrow +import com.futo.platformplayer.states.StateDeveloper -class JSDashManifestRawSource : JSSource { - val container : String = "application/dash+xml"; - val name : String; - val manifest: String; +open class JSDashManifestRawSource: JSSource, IVideoSource { + override val container : String = "application/dash+xml"; + override val name : String; + override val width: Int; + override val height: Int; + override val codec: String; + override val bitrate: Int?; + override val duration: Long; + override val priority: Boolean; + + var url: String?; + var manifest: String?; + + val hasGenerate: Boolean; + val canMerge: Boolean; constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH_RAW, plugin, obj) { - val contextName = "DashSource"; + val contextName = "DashRawSource"; val config = plugin.config; name = _obj.getOrThrow(config, "name", contextName); - manifest = _obj.getOrThrow(config, "manifest", contextName); + url = _obj.getOrThrow(config, "url", contextName); + manifest = _obj.getOrDefault(config, "manifest", contextName, null); + width = _obj.getOrDefault(config, "width", contextName, 0) ?: 0; + height = _obj.getOrDefault(config, "height", contextName, 0) ?: 0; + codec = _obj.getOrDefault(config, "codec", contextName, "") ?: ""; + bitrate = _obj.getOrDefault(config, "bitrate", contextName, 0) ?: 0; + duration = _obj.getOrDefault(config, "duration", contextName, 0) ?: 0; + priority = _obj.getOrDefault(config, "priority", contextName, false) ?: false; + canMerge = _obj.getOrDefault(config, "canMerge", contextName, false) ?: false; + hasGenerate = _obj.has("generate"); + } + + open fun generate(): String? { + if(!hasGenerate) + return manifest; + if(_obj.isClosed) + throw IllegalStateException("Source object already closed"); + if(_plugin is DevJSClient) { + return StateDeveloper.instance.handleDevCall(_plugin.devID, "DashManifestRawSource.generate()") { + _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw.generate", "generate()", { + _obj.invokeString("generate"); + }); + } + } + else + return _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw.generate", "generate()", { + _obj.invokeString("generate"); + }); + } +} + +class JSDashManifestMergingRawSource( + val video: JSDashManifestRawSource, + val audio: JSDashManifestRawAudioSource): JSDashManifestRawSource(video.getUnderlyingPlugin()!!, video.getUnderlyingObject()!!), IVideoSource { + + override val name: String + get() = video.name; + override val bitrate: Int + get() = (video.bitrate ?: 0) + audio.bitrate; + override val codec: String + get() = video.codec + override val container: String + get() = video.container + override val duration: Long + get() = video.duration; + override val height: Int + get() = video.height; + override val width: Int + get() = video.width; + override val priority: Boolean + get() = video.priority; + + override fun generate(): String? { + val videoDash = video.generate(); + val audioDash = audio.generate(); + if(videoDash != null && audioDash == null) return videoDash; + if(audioDash != null && videoDash == null) return audioDash; + if(videoDash == null) return null; + + //TODO: Temporary simple solution..make more reliable version + val audioAdaptationSet = adaptationSetRegex.find(audioDash!!); + if(audioAdaptationSet != null) { + return videoDash.replace("", "\n" + audioAdaptationSet.value) + } + else + return videoDash; + } + + companion object { + private val adaptationSetRegex = Regex(".*?<\\/AdaptationSet>", RegexOption.DOT_MATCHES_ALL); } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSSource.kt index 8f090e27..a658f5cb 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSSource.kt @@ -15,6 +15,7 @@ import com.futo.platformplayer.api.media.platforms.js.models.JSRequestModifier import com.futo.platformplayer.engine.IV8PluginConfig import com.futo.platformplayer.engine.V8Plugin import com.futo.platformplayer.getOrDefault +import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.orNull import com.futo.platformplayer.views.video.datasources.JSHttpDataSource @@ -70,12 +71,12 @@ abstract class JSSource { return JSRequestModifier(_plugin, result) } - fun getRequestExecutor(): JSRequestExecutor? { + open fun getRequestExecutor(): JSRequestExecutor? { if (!hasRequestExecutor || _obj.isClosed) return null; val result = V8Plugin.catchScriptErrors(_config, "[${_config.name}] JSSource", "obj.getRequestExecutor()") { - _obj.invoke("getRequestModifier", arrayOf()); + _obj.invoke("getRequestExecutor", arrayOf()); }; if (result !is V8ValueObject) @@ -84,40 +85,58 @@ abstract class JSSource { return JSRequestExecutor(_plugin, result) } + fun getUnderlyingPlugin(): JSClient? { + return _plugin; + } + fun getUnderlyingObject(): V8ValueObject? { + return _obj; + } + companion object { const val TYPE_AUDIOURL = "AudioUrlSource"; const val TYPE_VIDEOURL = "VideoUrlSource"; const val TYPE_AUDIO_WITH_METADATA = "AudioUrlRangeSource"; const val TYPE_VIDEO_WITH_METADATA = "VideoUrlRangeSource"; const val TYPE_DASH = "DashSource"; - const val TYPE_DASH_RAW = "DashSourceRaw"; + const val TYPE_DASH_RAW = "DashRawSource"; + const val TYPE_DASH_RAW_AUDIO = "DashRawAudioSource"; const val TYPE_HLS = "HLSSource"; const val TYPE_AUDIOURL_WIDEVINE = "AudioUrlWidevineSource" fun fromV8VideoNullable(plugin: JSClient, obj: V8Value?) : IVideoSource? = obj.orNull { fromV8Video(plugin, it as V8ValueObject) }; - fun fromV8Video(plugin: JSClient, obj: V8ValueObject) : IVideoSource { + fun fromV8Video(plugin: JSClient, obj: V8ValueObject) : IVideoSource? { val type = obj.getString("plugin_type"); return when(type) { TYPE_VIDEOURL -> JSVideoUrlSource(plugin, obj); TYPE_VIDEO_WITH_METADATA -> JSVideoUrlRangeSource(plugin, obj); TYPE_HLS -> fromV8HLS(plugin, obj); TYPE_DASH -> fromV8Dash(plugin, obj); - else -> throw NotImplementedError("Unknown type ${type}"); + TYPE_DASH_RAW -> fromV8DashRaw(plugin, obj); + else -> { + Logger.w("JSSource", "Unknown video type ${type}"); + null; + }; } } fun fromV8DashNullable(plugin: JSClient, obj: V8Value?) : JSDashManifestSource? = obj.orNull { fromV8Dash(plugin, it as V8ValueObject) }; fun fromV8Dash(plugin: JSClient, obj: V8ValueObject) : JSDashManifestSource = JSDashManifestSource(plugin, obj); + fun fromV8DashRaw(plugin: JSClient, obj: V8ValueObject) : JSDashManifestRawSource = JSDashManifestRawSource(plugin, obj); + fun fromV8DashRawAudio(plugin: JSClient, obj: V8ValueObject) : JSDashManifestRawAudioSource = JSDashManifestRawAudioSource(plugin, obj); fun fromV8HLSNullable(plugin: JSClient, obj: V8Value?) : JSHLSManifestSource? = obj.orNull { fromV8HLS(plugin, it as V8ValueObject) }; fun fromV8HLS(plugin: JSClient, obj: V8ValueObject) : JSHLSManifestSource = JSHLSManifestSource(plugin, obj); - fun fromV8Audio(plugin: JSClient, obj: V8ValueObject) : IAudioSource { + fun fromV8Audio(plugin: JSClient, obj: V8ValueObject) : IAudioSource? { val type = obj.getString("plugin_type"); return when(type) { TYPE_HLS -> JSHLSManifestAudioSource.fromV8HLS(plugin, obj); TYPE_AUDIOURL -> JSAudioUrlSource(plugin, obj); + TYPE_DASH_RAW_AUDIO -> fromV8DashRawAudio(plugin, obj); TYPE_AUDIOURL_WIDEVINE -> JSAudioUrlWidevineSource(plugin, obj); TYPE_AUDIO_WITH_METADATA -> JSAudioUrlRangeSource(plugin, obj); - else -> throw NotImplementedError("Unknown type ${type}"); + else -> { + Logger.w("JSSource", "Unknown audio type ${type}"); + null; + }; } } } diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSUnMuxVideoSourceDescriptor.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSUnMuxVideoSourceDescriptor.kt index 035f5fb6..548d2761 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSUnMuxVideoSourceDescriptor.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSUnMuxVideoSourceDescriptor.kt @@ -23,9 +23,11 @@ class JSUnMuxVideoSourceDescriptor: VideoUnMuxedSourceDescriptor { this.isUnMuxed = obj.getOrThrow(config, "isUnMuxed", contextName); this.videoSources = obj.getOrThrow(config, "videoSources", contextName).toArray() .map { JSSource.fromV8Video(plugin, it as V8ValueObject) } + .filterNotNull() .toTypedArray(); this.audioSources = obj.getOrThrow(config, "audioSources", contextName).toArray() .map { JSSource.fromV8Audio(plugin, it as V8ValueObject) } + .filterNotNull() .toTypedArray(); } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSVideoSourceDescriptor.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSVideoSourceDescriptor.kt index 4b983ef7..e68f0ae0 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSVideoSourceDescriptor.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSVideoSourceDescriptor.kt @@ -21,6 +21,7 @@ class JSVideoSourceDescriptor : VideoMuxedSourceDescriptor { this.isUnMuxed = obj.getOrThrow(config, "isUnMuxed", contextName); this.videoSources = obj.getOrThrow(config, "videoSources", contextName).toArray() .map { JSSource.fromV8Video(plugin, it as V8ValueObject) } + .filterNotNull() .toTypedArray(); } diff --git a/app/src/main/java/com/futo/platformplayer/developer/DeveloperEndpoints.kt b/app/src/main/java/com/futo/platformplayer/developer/DeveloperEndpoints.kt index e386681c..acb57b78 100644 --- a/app/src/main/java/com/futo/platformplayer/developer/DeveloperEndpoints.kt +++ b/app/src/main/java/com/futo/platformplayer/developer/DeveloperEndpoints.kt @@ -25,10 +25,8 @@ import com.futo.platformplayer.states.StateDeveloper import com.futo.platformplayer.states.StatePlatform import com.google.gson.ExclusionStrategy import com.google.gson.FieldAttributes -import com.google.gson.Gson import com.google.gson.GsonBuilder import com.google.gson.JsonArray -import com.google.gson.JsonElement import com.google.gson.JsonParser import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -573,7 +571,7 @@ class DeveloperEndpoints(private val context: Context) { val resp = _client.get(body.url!!, body.headers); context.respondCode(200, - Json.encodeToString(PackageHttp.BridgeHttpResponse(resp.url, resp.code, resp.body?.string())), + Json.encodeToString(PackageHttp.BridgeHttpStringResponse(resp.url, resp.code, resp.body?.string())), context.query.getOrDefault("CT", "text/plain")); } catch(ex: Exception) { diff --git a/app/src/main/java/com/futo/platformplayer/engine/packages/PackageBridge.kt b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageBridge.kt index c26885d9..027fd4b8 100644 --- a/app/src/main/java/com/futo/platformplayer/engine/packages/PackageBridge.kt +++ b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageBridge.kt @@ -2,12 +2,14 @@ package com.futo.platformplayer.engine.packages import com.caoccao.javet.annotations.V8Function import com.caoccao.javet.annotations.V8Property +import com.caoccao.javet.values.V8Value import com.futo.platformplayer.BuildConfig import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateDeveloper import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.media.platforms.js.JSClient +import com.futo.platformplayer.api.media.platforms.js.JSClientConstants import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient import com.futo.platformplayer.engine.IV8PluginConfig @@ -49,6 +51,16 @@ class PackageBridge : V8Package { fun buildFlavor(): String { return BuildConfig.FLAVOR; } + @V8Property + fun buildSpecVersion(): Int { + return JSClientConstants.PLUGIN_SPEC_VERSION; + } + + @V8Function + fun dispose(value: V8Value) { + Logger.e(TAG, "Manual dispose: " + value.javaClass.name); + value.close(); + } @V8Function fun toast(str: String) { diff --git a/app/src/main/java/com/futo/platformplayer/engine/packages/PackageHttp.kt b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageHttp.kt index 7822beb5..4eab03f0 100644 --- a/app/src/main/java/com/futo/platformplayer/engine/packages/PackageHttp.kt +++ b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageHttp.kt @@ -7,7 +7,11 @@ import com.caoccao.javet.enums.V8ConversionMode import com.caoccao.javet.enums.V8ProxyMode import com.caoccao.javet.interop.V8Runtime import com.caoccao.javet.values.V8Value +import com.caoccao.javet.values.primitive.V8ValueString +import com.caoccao.javet.values.reference.V8ValueArrayBuffer import com.caoccao.javet.values.reference.V8ValueObject +import com.caoccao.javet.values.reference.V8ValueSharedArrayBuffer +import com.caoccao.javet.values.reference.V8ValueTypedArray import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient @@ -16,6 +20,9 @@ import com.futo.platformplayer.engine.V8Plugin import com.futo.platformplayer.engine.internal.IV8Convertable import com.futo.platformplayer.engine.internal.V8BindObject import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StateApp +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import java.net.SocketTimeoutException import kotlin.streams.asSequence @@ -64,33 +71,42 @@ class PackageHttp: V8Package { } @V8Function - fun request(method: String, url: String, headers: MutableMap = HashMap(), useAuth: Boolean = false) : BridgeHttpResponse { + fun request(method: String, url: String, headers: MutableMap = HashMap(), useAuth: Boolean = false, bytesResult: Boolean = false) : IBridgeHttpResponse { return if(useAuth) - _packageClientAuth.request(method, url, headers) + _packageClientAuth.request(method, url, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING) else - _packageClient.request(method, url, headers); + _packageClient.request(method, url, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING); } @V8Function - fun requestWithBody(method: String, url: String, body:String, headers: MutableMap = HashMap(), useAuth: Boolean = false) : BridgeHttpResponse { + fun requestWithBody(method: String, url: String, body:String, headers: MutableMap = HashMap(), useAuth: Boolean = false, bytesResult: Boolean = false) : IBridgeHttpResponse { return if(useAuth) - _packageClientAuth.requestWithBody(method, url, body, headers) + _packageClientAuth.requestWithBody(method, url, body, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING) else - _packageClient.requestWithBody(method, url, body, headers); + _packageClient.requestWithBody(method, url, body, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING); } @V8Function - fun GET(url: String, headers: MutableMap = HashMap(), useAuth: Boolean = false) : BridgeHttpResponse { + fun GET(url: String, headers: MutableMap = HashMap(), useAuth: Boolean = false, useByteResponse: Boolean = false) : IBridgeHttpResponse { return if(useAuth) - _packageClientAuth.GET(url, headers) + _packageClientAuth.GET(url, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING) else - _packageClient.GET(url, headers); + _packageClient.GET(url, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING); } @V8Function - fun POST(url: String, body: String, headers: MutableMap = HashMap(), useAuth: Boolean = false) : BridgeHttpResponse { - return if(useAuth) - _packageClientAuth.POST(url, body, headers) + fun POST(url: String, body: Any, headers: MutableMap = HashMap(), useAuth: Boolean = false, useByteResponse: Boolean = false) : IBridgeHttpResponse { + + val client = if(useAuth) _packageClientAuth else _packageClient; + + if(body is V8ValueString) + return client.POST(url, body.value, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING); + else if(body is V8ValueTypedArray) + return client.POST(url, body.toBytes(), headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING); + else if(body is ByteArray) + return client.POST(url, body, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING); + else if(body is ArrayList<*>) //Avoid this case, used purely for testing + return client.POST(url, body.map { (it as Double).toInt().toByte() }.toByteArray(), headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING); else - _packageClient.POST(url, body, headers); + throw NotImplementedError("Body type " + body?.javaClass?.name?.toString() + " not implemented for POST"); } @V8Function @@ -111,8 +127,19 @@ class PackageHttp: V8Package { } } + interface IBridgeHttpResponse { + val url: String; + val code: Int; + val headers: Map>?; + } + @kotlinx.serialization.Serializable - class BridgeHttpResponse(val url: String, val code: Int, val body: String?, val headers: Map>? = null) : IV8Convertable { + class BridgeHttpStringResponse( + override val url: String, + override val code: Int, val + body: String?, + override val headers: Map>? = null) : IV8Convertable, IBridgeHttpResponse { + val isOk = code >= 200 && code < 300; override fun toV8(runtime: V8Runtime): V8Value? { @@ -125,6 +152,37 @@ class PackageHttp: V8Package { return obj; } } + @kotlinx.serialization.Serializable + class BridgeHttpBytesResponse: IV8Convertable, IBridgeHttpResponse { + override val url: String; + override val code: Int; + val body: ByteArray?; + override val headers: Map>?; + + val isOk: Boolean; + + constructor(url: String, code: Int, body: ByteArray? = null, headers: Map>? = null) { + this.url = url; + this.code = code; + this.body = body; + this.headers = headers; + this.isOk = code >= 200 && code < 300; + } + + override fun toV8(runtime: V8Runtime): V8Value? { + val obj = runtime.createV8ValueObject(); + obj.set("url", url); + obj.set("code", code); + if(body != null) { + val buffer = runtime.createV8ValueArrayBuffer(body.size); + buffer.fromBytes(body); + obj.set("body", body); + } + obj.set("headers", headers); + obj.set("isOk", isOk); + return obj; + } + } //TODO: This object is currently re-wrapped each modification, this is due to an issue passing the same object back and forth, should be fixed in future. @V8Convert(mode = V8ConversionMode.AllowOnly, proxyMode = V8ProxyMode.Class) @@ -147,6 +205,12 @@ class PackageHttp: V8Package { fun POST(url: String, body: String, headers: MutableMap = HashMap(), useAuth: Boolean = false) : BatchBuilder = clientPOST(_package.getDefaultClient(useAuth), url, body, headers); + @V8Function + fun DUMMY(): BatchBuilder { + _reqs.add(Pair(_package.getDefaultClient(false), RequestDescriptor("DUMMY", "", mutableMapOf()))); + return BatchBuilder(_package, _reqs); + } + //Client-specific @V8Function @@ -169,12 +233,14 @@ class PackageHttp: V8Package { //Finalizer @V8Function - fun execute(): List { + fun execute(): List { return _reqs.parallelStream().map { + if(it.second.method == "DUMMY") + return@map null; if(it.second.body != null) - return@map it.first.requestWithBody(it.second.method, it.second.url, it.second.body!!, it.second.headers); + return@map it.first.requestWithBody(it.second.method, it.second.url, it.second.body!!, it.second.headers, it.second.respType); else - return@map it.first.request(it.second.method, it.second.url, it.second.headers); + return@map it.first.request(it.second.method, it.second.url, it.second.headers, it.second.respType); } .asSequence() .toList(); @@ -232,63 +298,108 @@ class PackageHttp: V8Package { } @V8Function - fun request(method: String, url: String, headers: MutableMap = HashMap()) : BridgeHttpResponse { + fun request(method: String, url: String, headers: MutableMap = HashMap(), returnType: ReturnType) : IBridgeHttpResponse { applyDefaultHeaders(headers); return logExceptions { return@logExceptions catchHttp { val client = _client; //logRequest(method, url, headers, null); val resp = client.requestMethod(method, url, headers); - val responseBody = resp.body?.string(); //logResponse(method, url, resp.code, resp.headers, responseBody); - return@catchHttp BridgeHttpResponse(resp.url, resp.code, responseBody, sanitizeResponseHeaders(resp.headers, - _client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess)); + return@catchHttp when(returnType) { + ReturnType.STRING -> BridgeHttpStringResponse(resp.url, resp.code, resp.body?.string(), sanitizeResponseHeaders(resp.headers, + _client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess)); + ReturnType.BYTES -> BridgeHttpBytesResponse(resp.url, resp.code, resp.body?.bytes(), sanitizeResponseHeaders(resp.headers, + _client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess)); + else -> throw NotImplementedError("Return type " + returnType.toString() + " not implemented"); + } } }; } @V8Function - fun requestWithBody(method: String, url: String, body:String, headers: MutableMap = HashMap()) : BridgeHttpResponse { + fun requestWithBody(method: String, url: String, body:String, headers: MutableMap = HashMap(), returnType: ReturnType) : IBridgeHttpResponse { applyDefaultHeaders(headers); return logExceptions { catchHttp { val client = _client; //logRequest(method, url, headers, body); val resp = client.requestMethod(method, url, body, headers); - val responseBody = resp.body?.string(); //logResponse(method, url, resp.code, resp.headers, responseBody); - return@catchHttp BridgeHttpResponse(resp.url, resp.code, responseBody, sanitizeResponseHeaders(resp.headers, - _client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess)); + + return@catchHttp when(returnType) { + ReturnType.STRING -> BridgeHttpStringResponse(resp.url, resp.code, resp.body?.string(), sanitizeResponseHeaders(resp.headers, + _client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess)); + ReturnType.BYTES -> BridgeHttpBytesResponse(resp.url, resp.code, resp.body?.bytes(), sanitizeResponseHeaders(resp.headers, + _client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess)); + else -> throw NotImplementedError("Return type " + returnType.toString() + " not implemented"); + } } }; } @V8Function - fun GET(url: String, headers: MutableMap = HashMap()) : BridgeHttpResponse { + fun GET(url: String, headers: MutableMap = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse { applyDefaultHeaders(headers); return logExceptions { catchHttp { val client = _client; //logRequest("GET", url, headers, null); val resp = client.get(url, headers); - val responseBody = resp.body?.string(); + //val responseBody = resp.body?.string(); //logResponse("GET", url, resp.code, resp.headers, responseBody); - return@catchHttp BridgeHttpResponse(resp.url, resp.code, responseBody, sanitizeResponseHeaders(resp.headers, - _client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess)); + + + return@catchHttp when(returnType) { + ReturnType.STRING -> BridgeHttpStringResponse(resp.url, resp.code, resp.body?.string(), sanitizeResponseHeaders(resp.headers, + _client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess)); + ReturnType.BYTES -> BridgeHttpBytesResponse(resp.url, resp.code, resp.body?.bytes(), sanitizeResponseHeaders(resp.headers, + _client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess)); + else -> throw NotImplementedError("Return type " + returnType.toString() + " not implemented"); + } } }; } @V8Function - fun POST(url: String, body: String, headers: MutableMap = HashMap()) : BridgeHttpResponse { + fun POST(url: String, body: String, headers: MutableMap = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse { applyDefaultHeaders(headers); return logExceptions { catchHttp { val client = _client; //logRequest("POST", url, headers, body); val resp = client.post(url, body, headers); - val responseBody = resp.body?.string(); + //val responseBody = resp.body?.string(); //logResponse("POST", url, resp.code, resp.headers, responseBody); - return@catchHttp BridgeHttpResponse(resp.url, resp.code, responseBody, sanitizeResponseHeaders(resp.headers, - _client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess)); + + + return@catchHttp when(returnType) { + ReturnType.STRING -> BridgeHttpStringResponse(resp.url, resp.code, resp.body?.string(), sanitizeResponseHeaders(resp.headers, + _client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess)); + ReturnType.BYTES -> BridgeHttpBytesResponse(resp.url, resp.code, resp.body?.bytes(), sanitizeResponseHeaders(resp.headers, + _client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess)); + else -> throw NotImplementedError("Return type " + returnType.toString() + " not implemented"); + } + } + }; + } + @V8Function + fun POST(url: String, body: ByteArray, headers: MutableMap = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse { + applyDefaultHeaders(headers); + return logExceptions { + catchHttp { + val client = _client; + //logRequest("POST", url, headers, body); + val resp = client.post(url, body, headers); + //val responseBody = resp.body?.string(); + //logResponse("POST", url, resp.code, resp.headers, responseBody); + + + return@catchHttp when(returnType) { + ReturnType.STRING -> BridgeHttpStringResponse(resp.url, resp.code, resp.body?.string(), sanitizeResponseHeaders(resp.headers, + _client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess)); + ReturnType.BYTES -> BridgeHttpBytesResponse(resp.url, resp.code, resp.body?.bytes(), sanitizeResponseHeaders(resp.headers, + _client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess)); + else -> throw NotImplementedError("Return type " + returnType.toString() + " not implemented"); + } } }; } @@ -388,13 +499,13 @@ class PackageHttp: V8Package { } } - private fun catchHttp(handle: ()->BridgeHttpResponse): BridgeHttpResponse { + private fun catchHttp(handle: ()->IBridgeHttpResponse): IBridgeHttpResponse { try{ return handle(); } //Forward timeouts catch(ex: SocketTimeoutException) { - return BridgeHttpResponse("", 408, null); + return BridgeHttpStringResponse("", 408, null); } } } @@ -514,20 +625,25 @@ class PackageHttp: V8Package { val url: String, val headers: MutableMap, val body: String? = null, - val contentType: String? = null + val contentType: String? = null, + val respType: ReturnType = ReturnType.STRING ) - private fun catchHttp(handle: ()->BridgeHttpResponse): BridgeHttpResponse { + private fun catchHttp(handle: ()->BridgeHttpStringResponse): BridgeHttpStringResponse { try{ return handle(); } //Forward timeouts catch(ex: SocketTimeoutException) { - return BridgeHttpResponse("", 408, null); + return BridgeHttpStringResponse("", 408, null); } } + enum class ReturnType(val value: Int) { + STRING(0), + BYTES(1); + } companion object { private const val TAG = "PackageHttp"; diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt index 9b3162f3..c7dd930c 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt @@ -72,6 +72,7 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.platforms.js.models.JSVideoDetails +import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.casting.CastConnectionState import com.futo.platformplayer.casting.StateCasting @@ -693,6 +694,7 @@ class VideoDetailView : ConstraintLayout { _lastAudioSource = null; _lastSubtitleSource = null; video = null; + _player.clear(); cleanupPlaybackTracker(); Logger.i(TAG, "Keep screen on unset onClose") fragment.activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); diff --git a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt index ed00aedf..d638839b 100644 --- a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt +++ b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt @@ -3,9 +3,14 @@ package com.futo.platformplayer.views.video import android.content.Context import android.net.Uri import android.util.AttributeSet +import android.util.Xml import android.widget.RelativeLayout import androidx.annotation.OptIn +import androidx.fragment.app.findFragment +import androidx.lifecycle.coroutineScope +import androidx.lifecycle.findViewTreeLifecycleOwner import androidx.media3.common.C +import androidx.media3.common.C.Encoding import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackException import androidx.media3.common.Player @@ -17,6 +22,8 @@ import androidx.media3.datasource.DefaultHttpDataSource import androidx.media3.datasource.HttpDataSource import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.dash.DashMediaSource +import androidx.media3.exoplayer.dash.manifest.DashManifest +import androidx.media3.exoplayer.dash.manifest.DashManifestParser import androidx.media3.exoplayer.drm.DefaultDrmSessionManagerProvider import androidx.media3.exoplayer.hls.HlsMediaSource import androidx.media3.exoplayer.source.MediaSource @@ -42,6 +49,9 @@ import com.futo.platformplayer.api.media.models.streams.sources.VideoUrlSource import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails import com.futo.platformplayer.api.media.platforms.js.models.sources.JSAudioUrlRangeSource +import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestMergingRawSource +import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource +import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSHLSManifestAudioSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSVideoUrlRangeSource @@ -52,12 +62,14 @@ import com.futo.platformplayer.helpers.VideoHelper import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.video.PlayerManager +import com.futo.platformplayer.views.video.datasources.JSHttpDataSource import com.google.gson.Gson import getHttpDataSourceFactory import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.io.ByteArrayInputStream import java.io.File import kotlin.math.abs @@ -319,18 +331,31 @@ abstract class FutoVideoPlayerBase : RelativeLayout { swapSources(videoSource, audioSource,false, play, keepSubtitles); } fun swapSources(videoSource: IVideoSource?, audioSource: IAudioSource?, resume: Boolean = true, play: Boolean = true, keepSubtitles: Boolean = false): Boolean { - swapSourceInternal(videoSource); - swapSourceInternal(audioSource); + var videoSourceUsed = videoSource; + var audioSourceUsed = audioSource; + if(videoSource is JSDashManifestRawSource && audioSource is JSDashManifestRawAudioSource){ + videoSourceUsed = JSDashManifestMergingRawSource(videoSource, audioSource); + audioSourceUsed = null; + } + + swapSourceInternal(videoSourceUsed); + swapSourceInternal(audioSourceUsed); if(!keepSubtitles) _lastSubtitleMediaSource = null; return loadSelectedSources(play, resume); } fun swapSource(videoSource: IVideoSource?, resume: Boolean = true, play: Boolean = true): Boolean { - swapSourceInternal(videoSource); + var videoSourceUsed = videoSource; + if(videoSource is JSDashManifestRawSource && lastVideoSource is JSDashManifestMergingRawSource) + videoSourceUsed = JSDashManifestMergingRawSource(videoSource, (lastVideoSource as JSDashManifestMergingRawSource).audio); + swapSourceInternal(videoSourceUsed); return loadSelectedSources(play, resume); } fun swapSource(audioSource: IAudioSource?, resume: Boolean = true, play: Boolean = true): Boolean { - swapSourceInternal(audioSource); + if(audioSource is JSDashManifestRawAudioSource && lastVideoSource is JSDashManifestMergingRawSource) + swapSourceInternal(JSDashManifestMergingRawSource((lastVideoSource as JSDashManifestMergingRawSource).video, audioSource)); + else + swapSourceInternal(audioSource); return loadSelectedSources(play, resume); } @@ -387,6 +412,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout { is LocalVideoSource -> swapVideoSourceLocal(videoSource); is JSVideoUrlRangeSource -> swapVideoSourceUrlRange(videoSource); is IDashManifestSource -> swapVideoSourceDash(videoSource); + is JSDashManifestRawSource -> swapVideoSourceDashRaw(videoSource); is IHLSManifestSource -> swapVideoSourceHLS(videoSource); is IVideoUrlSource -> swapVideoSourceUrl(videoSource); null -> _lastVideoMediaSource = null; @@ -399,6 +425,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout { is LocalAudioSource -> swapAudioSourceLocal(audioSource); is JSAudioUrlRangeSource -> swapAudioSourceUrlRange(audioSource); is JSHLSManifestAudioSource -> swapAudioSourceHLS(audioSource); + is JSDashManifestRawAudioSource -> swapAudioSourceDashRaw(audioSource); is IAudioUrlWidevineSource -> swapAudioSourceUrlWidevine(audioSource) is IAudioUrlSource -> swapAudioSourceUrl(audioSource); null -> _lastAudioMediaSource = null; @@ -459,6 +486,54 @@ abstract class FutoVideoPlayerBase : RelativeLayout { .createMediaSource(MediaItem.fromUri(videoSource.url)) } @OptIn(UnstableApi::class) + private fun swapVideoSourceDashRaw(videoSource: JSDashManifestRawSource) { + Logger.i(TAG, "Loading VideoSource [Dash]"); + + if(videoSource.hasGenerate) { + findViewTreeLifecycleOwner()?.lifecycle?.coroutineScope?.launch(Dispatchers.IO) { + try { + val generated = videoSource.generate(); + if (generated != null) { + withContext(Dispatchers.Main) { + val dataSource = if(videoSource is JSSource && (videoSource.requiresCustomDatasource)) + videoSource.getHttpDataSourceFactory() + else + DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT); + + if(dataSource is JSHttpDataSource.Factory && videoSource is JSDashManifestMergingRawSource) + dataSource.setRequestExecutor2(videoSource.audio.getRequestExecutor()); + _lastVideoMediaSource = DashMediaSource.Factory(dataSource) + .createMediaSource( + DashManifestParser().parse( + Uri.parse(videoSource.url), + ByteArrayInputStream( + generated?.toByteArray() ?: ByteArray(0) + ) + ) + ); + loadSelectedSources(true, false); + } + } + } + catch(ex: Throwable) { + Logger.e(TAG, "DashRaw generator failed", ex); + } + } + } + else { + val dataSource = if(videoSource is JSSource && (videoSource.requiresCustomDatasource)) + videoSource.getHttpDataSourceFactory() + else + DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT); + + if(dataSource is JSHttpDataSource.Factory && videoSource is JSDashManifestMergingRawSource) + dataSource.setRequestExecutor2(videoSource.audio.getRequestExecutor()); + _lastVideoMediaSource = DashMediaSource.Factory(dataSource) + .createMediaSource(DashManifestParser().parse(Uri.parse(videoSource.url), + ByteArrayInputStream(videoSource.manifest?.toByteArray() ?: ByteArray(0)))); + } + } + @OptIn(UnstableApi::class) private fun swapVideoSourceHLS(videoSource: IHLSManifestSource) { Logger.i(TAG, "Loading VideoSource [HLS]"); val dataSource = if(videoSource is JSSource && videoSource.requiresCustomDatasource) @@ -521,6 +596,31 @@ abstract class FutoVideoPlayerBase : RelativeLayout { .createMediaSource(MediaItem.fromUri(audioSource.url)); } + @OptIn(UnstableApi::class) + private fun swapAudioSourceDashRaw(audioSource: JSDashManifestRawAudioSource) { + Logger.i(TAG, "Loading AudioSource [DashRaw]"); + val dataSource = if(audioSource is JSSource && (audioSource.requiresCustomDatasource)) + audioSource.getHttpDataSourceFactory() + else + DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT); + if(audioSource.hasGenerate) { + findViewTreeLifecycleOwner()?.lifecycle?.coroutineScope?.launch(Dispatchers.IO) { + val generated = audioSource.generate(); + if(generated != null) { + withContext(Dispatchers.Main) { + _lastVideoMediaSource = DashMediaSource.Factory(dataSource) + .createMediaSource(DashManifestParser().parse(Uri.parse(audioSource.url), + ByteArrayInputStream(generated?.toByteArray() ?: ByteArray(0)))); + loadSelectedSources(true, false); + } + } + } + } + else + _lastVideoMediaSource = DashMediaSource.Factory(dataSource) + .createMediaSource(DashManifestParser().parse(Uri.parse(audioSource.url), + ByteArrayInputStream(audioSource.manifest?.toByteArray() ?: ByteArray(0)))); + } @OptIn(UnstableApi::class) private fun swapAudioSourceUrlWidevine(audioSource: IAudioUrlWidevineSource) { Logger.i(TAG, "Loading AudioSource [UrlWidevine]") @@ -574,28 +674,37 @@ abstract class FutoVideoPlayerBase : RelativeLayout { val sourceAudio = _lastAudioMediaSource; val sourceSubs = _lastSubtitleMediaSource; - val sources = listOf(sourceVideo, sourceAudio, sourceSubs).filter { it != null }.map { it!! }.toTypedArray() beforeSourceChanged(); - _mediaSource = if(sources.size == 1) { - Logger.i(TAG, "Using single source mode") - (sourceVideo ?: sourceAudio); - } - else if(sources.size > 1) { - Logger.i(TAG, "Using multi source mode ${sources.size}") - MergingMediaSource(true, *sources); - } - else { - Logger.i(TAG, "Using no sources loaded"); - stop(); + val source = mergeMediaSources(sourceVideo, sourceAudio, sourceSubs); + if(source == null) return false; - } + _mediaSource = source; reloadMediaSource(play, resume); return true; } + @OptIn(UnstableApi::class) + fun mergeMediaSources(sourceVideo: MediaSource?, sourceAudio: MediaSource?, sourceSubs: MediaSource?): MediaSource? { + val sources = listOf(sourceVideo, sourceAudio, sourceSubs).filter { it != null }.map { it!! }.toTypedArray() + if(sources.size == 1) { + Logger.i(TAG, "Using single source mode") + return (sourceVideo ?: sourceAudio); + } + else if(sources.size > 1) { + Logger.i(TAG, "Using multi source mode ${sources.size}") + return MergingMediaSource(true, *sources); + } + else { + Logger.i(TAG, "Using no sources loaded"); + stop(); + return null; + } + } + + @OptIn(UnstableApi::class) private fun reloadMediaSource(play: Boolean = false, resume: Boolean = true) { val player = exoPlayer ?: return @@ -619,6 +728,10 @@ abstract class FutoVideoPlayerBase : RelativeLayout { fun clear() { exoPlayer?.player?.stop(); exoPlayer?.player?.clearMediaItems(); + _lastVideoMediaSource = null; + _lastAudioMediaSource = null; + _lastSubtitleMediaSource = null; + _mediaSource = null; } fun stop(){ @@ -697,8 +810,15 @@ abstract class FutoVideoPlayerBase : RelativeLayout { companion object { val DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0"; - val PREFERED_VIDEO_CONTAINERS = arrayOf("video/mp4", "video/webm", "video/3gpp"); - val PREFERED_AUDIO_CONTAINERS = arrayOf("audio/mp3", "audio/mp4", "audio/webm", "audio/opus"); + val PREFERED_VIDEO_CONTAINERS_MP4Pref = arrayOf("video/mp4", "video/webm", "video/3gpp"); + val PREFERED_VIDEO_CONTAINERS_WEBMPref = arrayOf("video/webm", "video/mp4", "video/3gpp"); + val PREFERED_VIDEO_CONTAINERS: Array get() { return if(Settings.instance.playback.preferWebmVideo) + PREFERED_VIDEO_CONTAINERS_WEBMPref else PREFERED_VIDEO_CONTAINERS_MP4Pref } + + val PREFERED_AUDIO_CONTAINERS_MP4Pref = arrayOf("audio/mp3", "audio/mp4", "audio/webm", "audio/opus"); + val PREFERED_AUDIO_CONTAINERS_WEBMPref = arrayOf("audio/webm", "audio/opus", "audio/mp3", "audio/mp4"); + val PREFERED_AUDIO_CONTAINERS: Array get() { return if(Settings.instance.playback.preferWebmAudio) + PREFERED_AUDIO_CONTAINERS_WEBMPref else PREFERED_AUDIO_CONTAINERS_MP4Pref } val SUPPORTED_SUBTITLES = hashSetOf("text/vtt", "application/x-subrip"); } diff --git a/app/src/main/java/com/futo/platformplayer/views/video/datasources/JSHttpDataSource.java b/app/src/main/java/com/futo/platformplayer/views/video/datasources/JSHttpDataSource.java index 9bdde431..870a248e 100644 --- a/app/src/main/java/com/futo/platformplayer/views/video/datasources/JSHttpDataSource.java +++ b/app/src/main/java/com/futo/platformplayer/views/video/datasources/JSHttpDataSource.java @@ -13,6 +13,7 @@ import androidx.annotation.VisibleForTesting; import com.futo.platformplayer.api.media.models.modifier.IRequest; import com.futo.platformplayer.api.media.models.modifier.IRequestModifier; +import com.futo.platformplayer.api.media.platforms.js.models.JSRequest; import com.futo.platformplayer.api.media.platforms.js.models.JSRequestExecutor; import com.futo.platformplayer.api.media.platforms.js.models.JSRequestModifier; import androidx.media3.common.C; @@ -27,6 +28,8 @@ import androidx.media3.datasource.HttpUtil; import androidx.media3.datasource.TransferListener; import com.futo.platformplayer.engine.dev.V8RemoteObject; +import com.futo.platformplayer.engine.exceptions.PluginException; +import com.futo.platformplayer.engine.exceptions.ScriptException; import com.futo.platformplayer.logging.Logger; import com.google.common.base.Predicate; import com.google.common.collect.ForwardingMap; @@ -70,7 +73,9 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource { private boolean allowCrossProtocolRedirects; private boolean keepPostFor302Redirects; @Nullable private IRequestModifier requestModifier = null; - @Nullable private JSRequestExecutor requestExecutor = null; + @Nullable public JSRequestExecutor requestExecutor = null; + @Nullable public JSRequestExecutor requestExecutor2 = null; + /** Creates an instance. */ public Factory() { @@ -109,6 +114,18 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource { this.requestExecutor = requestExecutor; return this; } + /** + * Sets the secondary request executor that will be used. + * + *

The default is {@code null}, which results in no request modification + * + * @param requestExecutor The request modifier that will be used, or {@code null} to use no request modifier + * @return This factory. + */ + public Factory setRequestExecutor2(@Nullable JSRequestExecutor requestExecutor) { + this.requestExecutor2 = requestExecutor; + return this; + } /** * Sets the user agent that will be used. @@ -216,7 +233,8 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource { contentTypePredicate, keepPostFor302Redirects, requestModifier, - requestExecutor); + requestExecutor, + requestExecutor2); if (transferListener != null) { dataSource.addTransferListener(transferListener); } @@ -252,7 +270,10 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource { private long bytesToRead; private long bytesRead; @Nullable private IRequestModifier requestModifier; - @Nullable private JSRequestExecutor requestExecutor; + @Nullable public JSRequestExecutor requestExecutor; + @Nullable public JSRequestExecutor requestExecutor2; //Not ideal, but required for now to have 2 executors under 1 datasource + + private Uri fallbackUri = null; private JSHttpDataSource( @Nullable String userAgent, @@ -263,7 +284,8 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource { @Nullable Predicate contentTypePredicate, boolean keepPostFor302Redirects, @Nullable IRequestModifier requestModifier, - @Nullable JSRequestExecutor requestExecutor) { + @Nullable JSRequestExecutor requestExecutor, + @Nullable JSRequestExecutor requestExecutor2) { super(/* isNetwork= */ true); this.userAgent = userAgent; this.connectTimeoutMillis = connectTimeoutMillis; @@ -275,12 +297,13 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource { this.keepPostFor302Redirects = keepPostFor302Redirects; this.requestModifier = requestModifier; this.requestExecutor = requestExecutor; + this.requestExecutor2 = requestExecutor2; } @Override @Nullable public Uri getUri() { - return connection == null ? null : Uri.parse(connection.getURL().toString()); + return connection == null ? fallbackUri : Uri.parse(connection.getURL().toString()); } @Override @@ -330,18 +353,29 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource { bytesToRead = 0; transferInitializing(dataSpec); - if(requestExecutor != null) { - byte[] data = requestExecutor.executeRequest(dataSpec.uri.toString(), dataSpec.httpRequestHeaders); - if(data == null) - throw new HttpDataSourceException( - "No response", - dataSpec, - PlaybackException.ERROR_CODE_IO_UNSPECIFIED, - HttpDataSourceException.TYPE_OPEN); - inputStream = new ByteArrayInputStream(data); + //Use executor 2 if it matches the urlPrefix + JSRequestExecutor executor = (requestExecutor2 != null && requestExecutor2.getUrlPrefix() != null && dataSpec.uri.toString().startsWith(requestExecutor2.getUrlPrefix())) ? + requestExecutor2 : requestExecutor; - transferStarted(dataSpec); - return data.length; + if(executor != null) { + try { + byte[] data = executor.executeRequest(dataSpec.uri.toString(), dataSpec.httpRequestHeaders); + if (data == null) + throw new HttpDataSourceException( + "No response", + dataSpec, + PlaybackException.ERROR_CODE_IO_UNSPECIFIED, + HttpDataSourceException.TYPE_OPEN); + inputStream = new ByteArrayInputStream(data); + fallbackUri = dataSpec.uri; + bytesToRead = data.length; + + transferStarted(dataSpec); + return data.length; + } + catch(PluginException ex) { + throw HttpDataSourceException.createForIOException(new IOException("Executor failed: " + ex.getMessage(), ex), dataSpec, HttpDataSourceException.TYPE_OPEN); + } } else { String responseMessage; diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8276a6d6..24c339a3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -373,6 +373,10 @@ Gesture controls adjust system volume Live Chat Webview Fullscreen portrait + Prefer Webm Video Codecs + If player should prefer Webm codecs (vp9/opus) over mp4 codecs (h264/AAC), may result in worse compatibility. + Prefer Webm Audio Codecs + If player should prefer Webm codecs (opus) over mp4 codecs (AAC), may result in worse compatibility. Allow fullscreen portrait Switch to Audio in Background Optimize bandwidth usage by switching to audio-only stream in background if available, may cause stutter