mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-16 21:12:39 +02:00
Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3cf4a52a69 | |||
| eb8b02756b | |||
| 0510d34ed3 | |||
| 1c8d12e72a | |||
| 0a36a6b674 | |||
| b887c9d50f | |||
| ee4e108e4f | |||
| 5e14a0fed4 | |||
| 6045205ea9 | |||
| f2d763cdec | |||
| e5e348205a | |||
| af6d219936 | |||
| 82a07e2e09 | |||
| 12a9b99fff | |||
| 3adf761158 | |||
| 670a4c61ff | |||
| 220f50d3bb | |||
| e0bf9d2a7c | |||
| f61cf46a52 | |||
| d188128d27 | |||
| f698c4120d | |||
| 338a852d49 | |||
| a64ee2242c | |||
| e9ff5e6f0b | |||
| f3911d8b68 | |||
| 9ce0be6450 | |||
| 6ab3eff61c | |||
| 0281da1c5a | |||
| 0b4770188c | |||
| 9376bb05fa | |||
| ecca3b6793 | |||
| f41a971cd8 | |||
| 44ba66d619 | |||
| bf685a607f | |||
| 5713cf0508 | |||
| bdd50d70ca | |||
| 8188399ce6 | |||
| f72b7dbbbb | |||
| 2409afcc5c | |||
| 15c0d02c13 | |||
| a54a5081e6 | |||
| db9dfcf049 | |||
| 47f9948748 | |||
| 05e866df55 | |||
| fc431f0cb8 | |||
| 228ab359ed | |||
| 103a8587f7 | |||
| 7db0083928 | |||
| e6f6ab499a | |||
| 721b7dbba0 | |||
| a95ddab814 | |||
| 2941546ae4 | |||
| bd9b9179c1 | |||
| ce7d54c151 | |||
| 3c778c07c2 | |||
| 95207341db | |||
| 70cf24924d | |||
| a8ebba691e | |||
| ec19ea44ad | |||
| ca8dc0f0f5 | |||
| 1dc50a697c | |||
| 1167c314ee | |||
| 55781e2b34 | |||
| 7439e44e44 | |||
| cf2639df3d | |||
| 834de928c2 |
+12
@@ -70,3 +70,15 @@
|
||||
[submodule "app/src/unstable/assets/sources/spotify"]
|
||||
path = app/src/unstable/assets/sources/spotify
|
||||
url = ../plugins/spotify.git
|
||||
[submodule "app/src/stable/assets/sources/bitchute"]
|
||||
path = app/src/stable/assets/sources/bitchute
|
||||
url = ../plugins/bitchute.git
|
||||
[submodule "app/src/unstable/assets/sources/bitchute"]
|
||||
path = app/src/unstable/assets/sources/bitchute
|
||||
url = ../plugins/bitchute.git
|
||||
[submodule "app/src/unstable/assets/sources/dailymotion"]
|
||||
path = app/src/unstable/assets/sources/dailymotion
|
||||
url = ../plugins/dailymotion.git
|
||||
[submodule "app/src/stable/assets/sources/dailymotion"]
|
||||
path = app/src/stable/assets/sources/dailymotion
|
||||
url = ../plugins/dailymotion.git
|
||||
|
||||
+11
-2
@@ -2,7 +2,7 @@ plugins {
|
||||
id 'com.android.application'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
id 'org.jetbrains.kotlin.plugin.serialization' version '1.9.21'
|
||||
id 'org.ajoberstar.grgit' version '1.7.2'
|
||||
id 'org.ajoberstar.grgit' version '5.2.2'
|
||||
id 'com.google.protobuf'
|
||||
id 'kotlin-parcelize'
|
||||
id 'com.google.devtools.ksp'
|
||||
@@ -144,9 +144,19 @@ android {
|
||||
buildFeatures {
|
||||
buildConfig true
|
||||
}
|
||||
sourceSets {
|
||||
main {
|
||||
assets {
|
||||
srcDirs 'src/main/assets', 'src/tests/assets', 'src/test/assets'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'com.google.dagger:dagger:2.48'
|
||||
implementation 'androidx.test:monitor:1.7.2'
|
||||
annotationProcessor 'com.google.dagger:dagger-compiler:2.48'
|
||||
|
||||
//Core
|
||||
implementation 'androidx.core:core-ktx:1.12.0'
|
||||
@@ -184,7 +194,6 @@ dependencies {
|
||||
implementation 'androidx.media:media:1.7.0'
|
||||
|
||||
//Other
|
||||
implementation 'org.jmdns:jmdns:3.5.1'
|
||||
implementation 'org.jsoup:jsoup:1.15.3'
|
||||
implementation 'com.google.android.flexbox:flexbox:3.0.0'
|
||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="com.android.alarm.permission.SET_ALARM"/>
|
||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
|
||||
<!--<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>-->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
|
||||
<uses-permission android:name="android.permission.WRITE_SETTINGS" tools:ignore="ProtectedPermissions"/>
|
||||
@@ -50,7 +51,7 @@
|
||||
android:name=".activities.MainActivity"
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout"
|
||||
android:exported="true"
|
||||
android:screenOrientation="portrait"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar"
|
||||
android:launchMode="singleTask"
|
||||
android:resizeableActivity="true"
|
||||
@@ -152,27 +153,27 @@
|
||||
|
||||
<activity
|
||||
android:name=".activities.SettingsActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.DeveloperActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.ExceptionActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.CaptchaActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.LoginActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.AddSourceActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar">
|
||||
<intent-filter>
|
||||
@@ -186,44 +187,44 @@
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".activities.AddSourceOptionsActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.PolycentricHomeActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.PolycentricBackupActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.PolycentricCreateProfileActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.PolycentricProfileActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.PolycentricWhyActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.PolycentricImportProfileActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.ManageTabsActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
|
||||
<activity
|
||||
android:name=".activities.QRCaptureActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.FCastGuideActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -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) {
|
||||
@@ -762,3 +795,99 @@ class URLSearchParams {
|
||||
return searchString;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var __REGEX_SPACE_CHARACTERS = /<%= spaceCharacters %>/g;
|
||||
var __btoa_TABLE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||
function btoa(input) {
|
||||
input = String(input);
|
||||
if (/[^\0-\xFF]/.test(input)) {
|
||||
// Note: no need to special-case astral symbols here, as surrogates are
|
||||
// matched, and the input is supposed to only contain ASCII anyway.
|
||||
error(
|
||||
'The string to be encoded contains characters outside of the ' +
|
||||
'Latin1 range.'
|
||||
);
|
||||
}
|
||||
var padding = input.length % 3;
|
||||
var output = '';
|
||||
var position = -1;
|
||||
var a;
|
||||
var b;
|
||||
var c;
|
||||
var buffer;
|
||||
// Make sure any padding is handled outside of the loop.
|
||||
var length = input.length - padding;
|
||||
|
||||
while (++position < length) {
|
||||
// Read three bytes, i.e. 24 bits.
|
||||
a = input.charCodeAt(position) << 16;
|
||||
b = input.charCodeAt(++position) << 8;
|
||||
c = input.charCodeAt(++position);
|
||||
buffer = a + b + c;
|
||||
// Turn the 24 bits into four chunks of 6 bits each, and append the
|
||||
// matching character for each of them to the output.
|
||||
output += (
|
||||
__btoa_TABLE.charAt(buffer >> 18 & 0x3F) +
|
||||
__btoa_TABLE.charAt(buffer >> 12 & 0x3F) +
|
||||
__btoa_TABLE.charAt(buffer >> 6 & 0x3F) +
|
||||
__btoa_TABLE.charAt(buffer & 0x3F)
|
||||
);
|
||||
}
|
||||
|
||||
if (padding == 2) {
|
||||
a = input.charCodeAt(position) << 8;
|
||||
b = input.charCodeAt(++position);
|
||||
buffer = a + b;
|
||||
output += (
|
||||
__btoa_TABLE.charAt(buffer >> 10) +
|
||||
__btoa_TABLE.charAt((buffer >> 4) & 0x3F) +
|
||||
__btoa_TABLE.charAt((buffer << 2) & 0x3F) +
|
||||
'='
|
||||
);
|
||||
} else if (padding == 1) {
|
||||
buffer = input.charCodeAt(position);
|
||||
output += (
|
||||
__btoa_TABLE.charAt(buffer >> 2) +
|
||||
__btoa_TABLE.charAt((buffer << 4) & 0x3F) +
|
||||
'=='
|
||||
);
|
||||
}
|
||||
|
||||
return output;
|
||||
};
|
||||
function atob(input) {
|
||||
input = String(input)
|
||||
.replace(__REGEX_SPACE_CHARACTERS, '');
|
||||
var length = input.length;
|
||||
if (length % 4 == 0) {
|
||||
input = input.replace(/==?$/, '');
|
||||
length = input.length;
|
||||
}
|
||||
if (
|
||||
length % 4 == 1 ||
|
||||
// http://whatwg.org/C#alphanumeric-ascii-characters
|
||||
/[^+a-zA-Z0-9/]/.test(input)
|
||||
) {
|
||||
error(
|
||||
'Invalid character: the string to be decoded is not correctly encoded.'
|
||||
);
|
||||
}
|
||||
var bitCounter = 0;
|
||||
var bitStorage;
|
||||
var buffer;
|
||||
var output = '';
|
||||
var position = -1;
|
||||
while (++position < length) {
|
||||
buffer = __btoa_TABLE.indexOf(input.charAt(position));
|
||||
bitStorage = bitCounter % 4 ? bitStorage * 64 + buffer : buffer;
|
||||
// Unless this is the first of a group of 4 characters…
|
||||
if (bitCounter++ % 4) {
|
||||
// …convert the first 8 bits to a single ASCII character.
|
||||
output += String.fromCharCode(
|
||||
0xFF & bitStorage >> (-2 * bitCounter & 6)
|
||||
);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.hardware.Sensor
|
||||
import android.hardware.SensorEvent
|
||||
import android.hardware.SensorEventListener
|
||||
import android.hardware.SensorManager
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
||||
class AdvancedOrientationListener(private val activity: Activity, private val lifecycleScope: CoroutineScope) {
|
||||
private val sensorManager: SensorManager = activity.getSystemService(Context.SENSOR_SERVICE) as SensorManager
|
||||
private val accelerometer: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
|
||||
private val magnetometer: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD)
|
||||
|
||||
private var lastOrientation: Int = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||
private var lastStableOrientation: Int = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||
private var lastOrientationChangeTime = 0L
|
||||
private val debounceTime = 200L
|
||||
private val stabilityThresholdTime = 800L
|
||||
private var deviceAspectRatio: Float = 1.0f
|
||||
|
||||
private val gravity = FloatArray(3)
|
||||
private val geomagnetic = FloatArray(3)
|
||||
private val rotationMatrix = FloatArray(9)
|
||||
private val orientationAngles = FloatArray(3)
|
||||
|
||||
val onOrientationChanged = Event1<Int>()
|
||||
|
||||
private val sensorListener = object : SensorEventListener {
|
||||
override fun onSensorChanged(event: SensorEvent) {
|
||||
when (event.sensor.type) {
|
||||
Sensor.TYPE_ACCELEROMETER -> {
|
||||
System.arraycopy(event.values, 0, gravity, 0, gravity.size)
|
||||
}
|
||||
Sensor.TYPE_MAGNETIC_FIELD -> {
|
||||
System.arraycopy(event.values, 0, geomagnetic, 0, geomagnetic.size)
|
||||
}
|
||||
}
|
||||
|
||||
if (gravity.isNotEmpty() && geomagnetic.isNotEmpty()) {
|
||||
val success = SensorManager.getRotationMatrix(rotationMatrix, null, gravity, geomagnetic)
|
||||
if (success) {
|
||||
SensorManager.getOrientation(rotationMatrix, orientationAngles)
|
||||
|
||||
val azimuth = Math.toDegrees(orientationAngles[0].toDouble()).toFloat()
|
||||
val pitch = Math.toDegrees(orientationAngles[1].toDouble()).toFloat()
|
||||
val roll = Math.toDegrees(orientationAngles[2].toDouble()).toFloat()
|
||||
|
||||
val newOrientation = when {
|
||||
roll in -155f .. -15f && isWithinThreshold(pitch, 0f, 30.0) -> {
|
||||
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
|
||||
}
|
||||
roll in 15f .. 155f && isWithinThreshold(pitch, 0f, 30.0) -> {
|
||||
ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE
|
||||
}
|
||||
isWithinThreshold(pitch, -90f, 30.0 * deviceAspectRatio) && roll in -15f .. 15f -> {
|
||||
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
}
|
||||
isWithinThreshold(pitch, 90f, 30.0 * deviceAspectRatio) && roll in -15f .. 15f -> {
|
||||
ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT
|
||||
}
|
||||
else -> lastOrientation
|
||||
}
|
||||
|
||||
//Logger.i("AdvancedOrientationListener", "newOrientation = ${newOrientation}, roll = ${roll}, pitch = ${pitch}, azimuth = ${azimuth}")
|
||||
|
||||
if (newOrientation != lastStableOrientation) {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
if (currentTime - lastOrientationChangeTime > debounceTime) {
|
||||
lastOrientationChangeTime = currentTime
|
||||
lastStableOrientation = newOrientation
|
||||
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
delay(stabilityThresholdTime)
|
||||
if (newOrientation == lastStableOrientation) {
|
||||
lastOrientation = newOrientation
|
||||
onOrientationChanged.emit(newOrientation)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
|
||||
}
|
||||
|
||||
private fun isWithinThreshold(value: Float, target: Float, threshold: Double): Boolean {
|
||||
return Math.abs(value - target) <= threshold
|
||||
}
|
||||
|
||||
init {
|
||||
sensorManager.registerListener(sensorListener, accelerometer, SensorManager.SENSOR_DELAY_GAME)
|
||||
sensorManager.registerListener(sensorListener, magnetometer, SensorManager.SENSOR_DELAY_GAME)
|
||||
|
||||
val metrics = activity.resources.displayMetrics
|
||||
deviceAspectRatio = (metrics.heightPixels.toFloat() / metrics.widthPixels.toFloat())
|
||||
if (deviceAspectRatio == 0.0f)
|
||||
deviceAspectRatio = 1.0f
|
||||
|
||||
lastOrientation = activity.resources.configuration.orientation
|
||||
}
|
||||
|
||||
fun stopListening() {
|
||||
sensorManager.unregisterListener(sensorListener)
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,10 @@ fun IAudioSource.isDownloadable(): Boolean = VideoHelper.isDownloadable(this);
|
||||
@UnstableApi
|
||||
fun JSSource.getHttpDataSourceFactory(): HttpDataSource.Factory {
|
||||
val requestModifier = getRequestModifier();
|
||||
return if (requestModifier != null) {
|
||||
val requestExecutor = getRequestExecutor();
|
||||
return if (requestExecutor != null) {
|
||||
JSHttpDataSource.Factory().setRequestExecutor(requestExecutor);
|
||||
} else if (requestModifier != null) {
|
||||
JSHttpDataSource.Factory().setRequestModifier(requestModifier);
|
||||
} else {
|
||||
DefaultHttpDataSource.Factory();
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,9 @@
|
||||
package com.futo.platformplayer
|
||||
|
||||
import android.net.Uri
|
||||
import java.net.Inet4Address
|
||||
import java.net.Inet6Address
|
||||
import java.net.InetAddress
|
||||
import java.net.URI
|
||||
import java.net.URISyntaxException
|
||||
import java.net.URLEncoder
|
||||
@@ -25,4 +28,18 @@ fun String?.yesNoToBoolean(): Boolean {
|
||||
|
||||
fun Boolean?.toYesNo(): String {
|
||||
return if (this == true) "YES" else "NO"
|
||||
}
|
||||
|
||||
fun InetAddress?.toUrlAddress(): String {
|
||||
return when (this) {
|
||||
is Inet6Address -> {
|
||||
"[${toString()}]"
|
||||
}
|
||||
is Inet4Address -> {
|
||||
toString()
|
||||
}
|
||||
else -> {
|
||||
throw Exception("Invalid address type")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,11 @@ package com.futo.platformplayer
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Context.POWER_SERVICE
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.webkit.CookieManager
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
@@ -23,6 +26,7 @@ import com.futo.platformplayer.states.StateBackup
|
||||
import com.futo.platformplayer.states.StateCache
|
||||
import com.futo.platformplayer.states.StateMeta
|
||||
import com.futo.platformplayer.states.StatePayment
|
||||
import com.futo.platformplayer.states.StatePlayer
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
import com.futo.platformplayer.states.StateUpdate
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
@@ -34,6 +38,7 @@ import com.futo.platformplayer.views.fields.FormField
|
||||
import com.futo.platformplayer.views.fields.FormFieldButton
|
||||
import com.futo.platformplayer.views.fields.FormFieldWarning
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
|
||||
import com.stripe.android.customersheet.injection.CustomerSheetViewModelModule_Companion_ContextFactory.context
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -44,6 +49,7 @@ import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
|
||||
@Serializable
|
||||
data class MenuBottomBarSetting(val id: Int, var enabled: Boolean);
|
||||
|
||||
@@ -57,7 +63,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@Transient
|
||||
val onTabsChanged = Event0();
|
||||
|
||||
@FormField(R.string.manage_polycentric_identity, FieldForm.BUTTON, R.string.manage_your_polycentric_identity, -6)
|
||||
@FormField(R.string.manage_polycentric_identity, FieldForm.BUTTON, R.string.manage_your_polycentric_identity, -7)
|
||||
@FormFieldButton(R.drawable.ic_person)
|
||||
fun managePolycentricIdentity() {
|
||||
SettingsActivity.getActivity()?.let {
|
||||
@@ -73,7 +79,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
}
|
||||
}
|
||||
|
||||
@FormField(R.string.show_faq, FieldForm.BUTTON, R.string.get_answers_to_common_questions, -5)
|
||||
@FormField(R.string.show_faq, FieldForm.BUTTON, R.string.get_answers_to_common_questions, -6)
|
||||
@FormFieldButton(R.drawable.ic_quiz)
|
||||
fun openFAQ() {
|
||||
try {
|
||||
@@ -83,7 +89,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
//Ignored
|
||||
}
|
||||
}
|
||||
@FormField(R.string.show_issues, FieldForm.BUTTON, R.string.a_list_of_user_reported_and_self_reported_issues, -4)
|
||||
@FormField(R.string.show_issues, FieldForm.BUTTON, R.string.a_list_of_user_reported_and_self_reported_issues, -5)
|
||||
@FormFieldButton(R.drawable.ic_data_alert)
|
||||
fun openIssues() {
|
||||
try {
|
||||
@@ -115,7 +121,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
}
|
||||
}*/
|
||||
|
||||
@FormField(R.string.manage_tabs, FieldForm.BUTTON, R.string.change_tabs_visible_on_the_home_screen, -3)
|
||||
@FormField(R.string.manage_tabs, FieldForm.BUTTON, R.string.change_tabs_visible_on_the_home_screen, -4)
|
||||
@FormFieldButton(R.drawable.ic_tabs)
|
||||
fun manageTabs() {
|
||||
try {
|
||||
@@ -129,7 +135,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
|
||||
|
||||
|
||||
@FormField(R.string.import_data, FieldForm.BUTTON, R.string.import_data_description, -2)
|
||||
@FormField(R.string.import_data, FieldForm.BUTTON, R.string.import_data_description, -3)
|
||||
@FormFieldButton(R.drawable.ic_move_up)
|
||||
fun import() {
|
||||
val act = SettingsActivity.getActivity() ?: return;
|
||||
@@ -138,7 +144,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
act.startActivity(intent);
|
||||
}
|
||||
|
||||
@FormField(R.string.link_handling, FieldForm.BUTTON, R.string.allow_grayjay_to_handle_links, -1)
|
||||
@FormField(R.string.link_handling, FieldForm.BUTTON, R.string.allow_grayjay_to_handle_links, -2)
|
||||
@FormFieldButton(R.drawable.ic_link)
|
||||
fun manageLinks() {
|
||||
try {
|
||||
@@ -148,6 +154,24 @@ class Settings : FragmentedStorageFileJson() {
|
||||
}
|
||||
}
|
||||
|
||||
/*@FormField(R.string.disable_battery_optimization, FieldForm.BUTTON, R.string.click_to_go_to_battery_optimization_settings_disabling_battery_optimization_will_prevent_the_os_from_killing_media_sessions, -1)
|
||||
@FormFieldButton(R.drawable.battery_full_24px)
|
||||
fun ignoreBatteryOptimization() {
|
||||
SettingsActivity.getActivity()?.let {
|
||||
val intent = Intent()
|
||||
val packageName = it.packageName
|
||||
val pm = it.getSystemService(POWER_SERVICE) as PowerManager;
|
||||
if (!pm.isIgnoringBatteryOptimizations(packageName)) {
|
||||
intent.setAction(android.provider.Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
|
||||
intent.setData(Uri.parse("package:$packageName"))
|
||||
it.startActivity(intent)
|
||||
UIDialogs.toast(it, "Please ignore battery optimizations for Grayjay")
|
||||
} else {
|
||||
UIDialogs.toast(it, "Battery optimizations already disabled for Grayjay")
|
||||
}
|
||||
}
|
||||
}*/
|
||||
|
||||
@FormField(R.string.language, "group", -1, 0)
|
||||
var language = LanguageSettings();
|
||||
@Serializable
|
||||
@@ -326,7 +350,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
var playback = PlaybackSettings();
|
||||
@Serializable
|
||||
class PlaybackSettings {
|
||||
@FormField(R.string.primary_language, FieldForm.DROPDOWN, -1, 0)
|
||||
@FormField(R.string.primary_language, FieldForm.DROPDOWN, -1, -1)
|
||||
@DropdownFieldOptionsId(R.array.audio_languages)
|
||||
var primaryLanguage: Int = 0;
|
||||
|
||||
@@ -353,7 +377,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
|
||||
//= context.resources.getStringArray(R.array.audio_languages)[primaryLanguage];
|
||||
|
||||
@FormField(R.string.default_playback_speed, FieldForm.DROPDOWN, -1, 1)
|
||||
@FormField(R.string.default_playback_speed, FieldForm.DROPDOWN, -1, 0)
|
||||
@DropdownFieldOptionsId(R.array.playback_speeds)
|
||||
var defaultPlaybackSpeed: Int = 3;
|
||||
fun getDefaultPlaybackSpeed(): Float = when(defaultPlaybackSpeed) {
|
||||
@@ -369,35 +393,31 @@ class Settings : FragmentedStorageFileJson() {
|
||||
else -> 1.0f;
|
||||
};
|
||||
|
||||
@FormField(R.string.preferred_quality, FieldForm.DROPDOWN, R.string.preferred_quality_description, 2)
|
||||
@FormField(R.string.preferred_quality, FieldForm.DROPDOWN, R.string.preferred_quality_description, 1)
|
||||
@DropdownFieldOptionsId(R.array.preferred_quality_array)
|
||||
var preferredQuality: Int = 0;
|
||||
|
||||
@FormField(R.string.preferred_metered_quality, FieldForm.DROPDOWN, R.string.preferred_metered_quality_description, 3)
|
||||
@FormField(R.string.preferred_metered_quality, FieldForm.DROPDOWN, R.string.preferred_metered_quality_description, 2)
|
||||
@DropdownFieldOptionsId(R.array.preferred_quality_array)
|
||||
var preferredMeteredQuality: Int = 0;
|
||||
fun getPreferredQualityPixelCount(): Int = preferedQualityToPixels(preferredQuality);
|
||||
fun getPreferredMeteredQualityPixelCount(): Int = preferedQualityToPixels(preferredMeteredQuality);
|
||||
fun getCurrentPreferredQualityPixelCount(): Int = if(!StateApp.instance.isCurrentMetered()) getPreferredQualityPixelCount() else getPreferredMeteredQualityPixelCount();
|
||||
|
||||
@FormField(R.string.preferred_preview_quality, FieldForm.DROPDOWN, R.string.preferred_preview_quality_description, 4)
|
||||
@FormField(R.string.preferred_preview_quality, FieldForm.DROPDOWN, R.string.preferred_preview_quality_description, 3)
|
||||
@DropdownFieldOptionsId(R.array.preferred_quality_array)
|
||||
var preferredPreviewQuality: Int = 5;
|
||||
fun getPreferredPreviewQualityPixelCount(): Int = preferedQualityToPixels(preferredPreviewQuality);
|
||||
|
||||
|
||||
@FormField(R.string.simplify_sources, FieldForm.TOGGLE, R.string.simplify_sources_description, 4)
|
||||
var simplifySources: Boolean = true;
|
||||
|
||||
@FormField(R.string.auto_rotate, FieldForm.DROPDOWN, -1, 5)
|
||||
@DropdownFieldOptionsId(R.array.system_enabled_disabled_array)
|
||||
var autoRotate: Int = 2;
|
||||
|
||||
fun isAutoRotate() = autoRotate == 1 || (autoRotate == 2 && StateApp.instance.getCurrentSystemAutoRotate());
|
||||
|
||||
@FormField(R.string.auto_rotate_dead_zone, FieldForm.DROPDOWN, R.string.this_prevents_the_device_from_rotating_within_the_given_amount_of_degrees, 6)
|
||||
@DropdownFieldOptionsId(R.array.auto_rotate_dead_zone)
|
||||
var autoRotateDeadZone: Int = 0;
|
||||
|
||||
fun getAutoRotateDeadZoneDegrees(): Int {
|
||||
return autoRotateDeadZone * 5;
|
||||
}
|
||||
fun isAutoRotate() = (autoRotate == 1 && !StatePlayer.instance.rotationLock) || (autoRotate == 2 && StateApp.instance.getCurrentSystemAutoRotate() && !StatePlayer.instance.rotationLock);
|
||||
|
||||
@FormField(R.string.background_behavior, FieldForm.DROPDOWN, -1, 7)
|
||||
@DropdownFieldOptionsId(R.array.player_background_behavior)
|
||||
@@ -450,6 +470,15 @@ 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.allow_under_cutout, FieldForm.TOGGLE, R.string.allow_under_cutout_description, 16)
|
||||
var allowVideoToGoUnderCutout: Boolean = true;
|
||||
}
|
||||
|
||||
@FormField(R.string.comments, "group", R.string.comments_description, 6)
|
||||
@@ -779,10 +808,10 @@ class Settings : FragmentedStorageFileJson() {
|
||||
fun export() {
|
||||
val activity = SettingsActivity.getActivity() ?: return;
|
||||
UISlideOverlays.showOverlay(activity.overlay, "Select export type", null, {},
|
||||
SlideUpMenuItem(activity, R.drawable.ic_share, "Share", "", null, {
|
||||
SlideUpMenuItem(activity, R.drawable.ic_share, "Share", "", tag = null, call = {
|
||||
StateBackup.shareExternalBackup();
|
||||
}),
|
||||
SlideUpMenuItem(activity, R.drawable.ic_download, "File", "", null, {
|
||||
SlideUpMenuItem(activity, R.drawable.ic_download, "File", "", tag = null, call = {
|
||||
StateBackup.saveExternalBackup(activity);
|
||||
})
|
||||
)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.futo.platformplayer
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.hardware.SensorManager
|
||||
import android.view.OrientationEventListener
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class SimpleOrientationListener(
|
||||
private val activity: Activity,
|
||||
private val lifecycleScope: CoroutineScope
|
||||
) {
|
||||
private var lastOrientation: Int = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||
private var lastStableOrientation: Int = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||
private val stabilityThresholdTime = 500L
|
||||
|
||||
val onOrientationChanged = Event1<Int>()
|
||||
|
||||
private val orientationListener = object : OrientationEventListener(activity, SensorManager.SENSOR_DELAY_UI) {
|
||||
override fun onOrientationChanged(orientation: Int) {
|
||||
val newOrientation = when {
|
||||
orientation in 45..134 -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE
|
||||
orientation in 135..224 -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT
|
||||
orientation in 225..314 -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
|
||||
orientation in 315..360 || orientation in 0..44 -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
else -> lastOrientation
|
||||
}
|
||||
|
||||
if (newOrientation != lastStableOrientation) {
|
||||
lastStableOrientation = newOrientation
|
||||
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
delay(stabilityThresholdTime)
|
||||
if (newOrientation == lastStableOrientation) {
|
||||
lastOrientation = newOrientation
|
||||
onOrientationChanged.emit(newOrientation)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
orientationListener.enable()
|
||||
lastOrientation = activity.resources.configuration.orientation
|
||||
}
|
||||
|
||||
fun stopListening() {
|
||||
orientationListener.disable()
|
||||
}
|
||||
}
|
||||
@@ -15,14 +15,18 @@ import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
||||
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantAudioUrlSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantVideoUrlSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
|
||||
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.models.subtitles.ISubtitleSource
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
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.models.sources.JSDashManifestRawAudioSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource
|
||||
import com.futo.platformplayer.downloads.VideoLocal
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionGroupFragment
|
||||
import com.futo.platformplayer.helpers.VideoHelper
|
||||
@@ -40,7 +44,6 @@ import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StatePlayer
|
||||
import com.futo.platformplayer.states.StatePlaylists
|
||||
import com.futo.platformplayer.states.StateSubscriptionGroups
|
||||
import com.futo.platformplayer.states.StateSubscriptions
|
||||
import com.futo.platformplayer.views.AnyAdapterView
|
||||
import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny
|
||||
import com.futo.platformplayer.views.LoaderView
|
||||
@@ -92,9 +95,17 @@ class UISlideOverlays {
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
items.addAll(listOf(
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_notifications, "Notifications", "", "notifications", {
|
||||
subscription.doNotifications = menu?.selectOption(null, "notifications", true, true) ?: subscription.doNotifications;
|
||||
}, false),
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_notifications,
|
||||
"Notifications",
|
||||
"",
|
||||
tag = "notifications",
|
||||
call = {
|
||||
subscription.doNotifications = menu?.selectOption(null, "notifications", true, true) ?: subscription.doNotifications;
|
||||
},
|
||||
invokeParent = false
|
||||
),
|
||||
if(StateSubscriptionGroups.instance.getSubscriptionGroups().isNotEmpty())
|
||||
SlideUpMenuGroup(container.context, "Subscription Groups",
|
||||
"You can select which groups this subscription is part of.",
|
||||
@@ -129,22 +140,62 @@ class UISlideOverlays {
|
||||
SlideUpMenuGroup(container.context, "Fetch Settings",
|
||||
"Depending on the platform you might not need to enable a type for it to be available.",
|
||||
-1, listOf()),
|
||||
if(capabilities.hasType(ResultCapabilities.TYPE_LIVE)) SlideUpMenuItem(container.context, R.drawable.ic_live_tv, "Livestreams", "Check for livestreams", "fetchLive", {
|
||||
subscription.doFetchLive = menu?.selectOption(null, "fetchLive", true, true) ?: subscription.doFetchLive;
|
||||
}, false) else null,
|
||||
if(capabilities.hasType(ResultCapabilities.TYPE_STREAMS)) SlideUpMenuItem(container.context, R.drawable.ic_play, "Streams", "Check for streams", "fetchStreams", {
|
||||
subscription.doFetchStreams = menu?.selectOption(null, "fetchStreams", true, true) ?: subscription.doFetchStreams;
|
||||
}, false) else null,
|
||||
if(capabilities.hasType(ResultCapabilities.TYPE_LIVE)) SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_live_tv,
|
||||
"Livestreams",
|
||||
"Check for livestreams",
|
||||
tag = "fetchLive",
|
||||
call = {
|
||||
subscription.doFetchLive = menu?.selectOption(null, "fetchLive", true, true) ?: subscription.doFetchLive;
|
||||
},
|
||||
invokeParent = false
|
||||
) else null,
|
||||
if(capabilities.hasType(ResultCapabilities.TYPE_STREAMS)) SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_play,
|
||||
"Streams",
|
||||
"Check for streams",
|
||||
tag = "fetchStreams",
|
||||
call = {
|
||||
subscription.doFetchStreams = menu?.selectOption(null, "fetchStreams", true, true) ?: subscription.doFetchStreams;
|
||||
},
|
||||
invokeParent = false
|
||||
) else null,
|
||||
if(capabilities.hasType(ResultCapabilities.TYPE_VIDEOS))
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_play, "Videos", "Check for videos", "fetchVideos", {
|
||||
subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos;
|
||||
}, false) else if(capabilities.hasType(ResultCapabilities.TYPE_MIXED) || capabilities.types.isEmpty())
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_play, "Content", "Check for content", "fetchVideos", {
|
||||
subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos;
|
||||
}, false) else null,
|
||||
if(capabilities.hasType(ResultCapabilities.TYPE_POSTS)) SlideUpMenuItem(container.context, R.drawable.ic_chat, "Posts", "Check for posts", "fetchPosts", {
|
||||
subscription.doFetchPosts = menu?.selectOption(null, "fetchPosts", true, true) ?: subscription.doFetchPosts;
|
||||
}, false) else null/*,,
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_play,
|
||||
"Videos",
|
||||
"Check for videos",
|
||||
tag = "fetchVideos",
|
||||
call = {
|
||||
subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos;
|
||||
},
|
||||
invokeParent = false
|
||||
) else if(capabilities.hasType(ResultCapabilities.TYPE_MIXED) || capabilities.types.isEmpty())
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_play,
|
||||
"Content",
|
||||
"Check for content",
|
||||
tag = "fetchVideos",
|
||||
call = {
|
||||
subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos;
|
||||
},
|
||||
invokeParent = false
|
||||
) else null,
|
||||
if(capabilities.hasType(ResultCapabilities.TYPE_POSTS)) SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_chat,
|
||||
"Posts",
|
||||
"Check for posts",
|
||||
tag = "fetchPosts",
|
||||
call = {
|
||||
subscription.doFetchPosts = menu?.selectOption(null, "fetchPosts", true, true) ?: subscription.doFetchPosts;
|
||||
},
|
||||
invokeParent = false
|
||||
) else null/*,,
|
||||
|
||||
SlideUpMenuGroup(container.context, "Actions",
|
||||
"Various things you can do with this subscription",
|
||||
@@ -243,11 +294,23 @@ class UISlideOverlays {
|
||||
masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, sourceUrl)
|
||||
|
||||
masterPlaylist.getAudioSources().forEach { it ->
|
||||
audioButtons.add(SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, listOf(it.language, it.codec).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "), it, {
|
||||
selectedAudioVariant = it
|
||||
slideUpMenuOverlay.selectOption(audioButtons, it)
|
||||
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
|
||||
}, false))
|
||||
|
||||
val estSize = VideoHelper.estimateSourceSize(it);
|
||||
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
|
||||
audioButtons.add(SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_music,
|
||||
it.name,
|
||||
listOf(it.language, it.codec).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "),
|
||||
(prefix + it.codec).trim(),
|
||||
tag = it,
|
||||
call = {
|
||||
selectedAudioVariant = it
|
||||
slideUpMenuOverlay.selectOption(audioButtons, it)
|
||||
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
|
||||
},
|
||||
invokeParent = false
|
||||
))
|
||||
}
|
||||
|
||||
/*masterPlaylist.getSubtitleSources().forEach { it ->
|
||||
@@ -259,11 +322,22 @@ class UISlideOverlays {
|
||||
}*/
|
||||
|
||||
masterPlaylist.getVideoSources().forEach {
|
||||
videoButtons.add(SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "${it.width}x${it.height}", it, {
|
||||
selectedVideoVariant = it
|
||||
slideUpMenuOverlay.selectOption(videoButtons, it)
|
||||
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
|
||||
}, false))
|
||||
val estSize = VideoHelper.estimateSourceSize(it);
|
||||
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
|
||||
videoButtons.add(SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_movie,
|
||||
it.name,
|
||||
"${it.width}x${it.height}",
|
||||
(prefix + it.codec).trim(),
|
||||
tag = it,
|
||||
call = {
|
||||
selectedVideoVariant = it
|
||||
slideUpMenuOverlay.selectOption(videoButtons, it)
|
||||
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
|
||||
},
|
||||
invokeParent = false
|
||||
))
|
||||
}
|
||||
|
||||
val newItems = arrayListOf<View>()
|
||||
@@ -322,8 +396,8 @@ class UISlideOverlays {
|
||||
|
||||
|
||||
val requiresAudio = descriptor is VideoUnMuxedSourceDescriptor;
|
||||
var selectedVideo: IVideoUrlSource? = null;
|
||||
var selectedAudio: IAudioUrlSource? = null;
|
||||
var selectedVideo: IVideoSource? = null;
|
||||
var selectedAudio: IAudioSource? = null;
|
||||
var selectedSubtitle: ISubtitleSource? = null;
|
||||
|
||||
val videoSources = descriptor.videoSources;
|
||||
@@ -342,45 +416,93 @@ class UISlideOverlays {
|
||||
}
|
||||
|
||||
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.video), videoSources,
|
||||
listOf(listOf(SlideUpMenuItem(container.context, R.drawable.ic_movie, container.context.getString(R.string.none), container.context.getString(R.string.audio_only), "none", {
|
||||
selectedVideo = null;
|
||||
menu?.selectOption(videoSources, "none");
|
||||
if(selectedAudio != null || !requiresAudio)
|
||||
menu?.setOk(container.context.getString(R.string.download));
|
||||
}, false)) +
|
||||
listOf(listOf(SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_movie,
|
||||
container.context.getString(R.string.none),
|
||||
container.context.getString(R.string.audio_only),
|
||||
tag = "none",
|
||||
call = {
|
||||
selectedVideo = null;
|
||||
menu?.selectOption(videoSources, "none");
|
||||
if(selectedAudio != null || !requiresAudio)
|
||||
menu?.setOk(container.context.getString(R.string.download));
|
||||
},
|
||||
invokeParent = false
|
||||
)) +
|
||||
videoSources
|
||||
.filter { it.isDownloadable() }
|
||||
.map {
|
||||
when (it) {
|
||||
is IVideoUrlSource -> {
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "${it.width}x${it.height}", it, {
|
||||
selectedVideo = it
|
||||
menu?.selectOption(videoSources, it);
|
||||
if(selectedAudio != null || !requiresAudio)
|
||||
menu?.setOk(container.context.getString(R.string.download));
|
||||
}, false)
|
||||
val estSize = VideoHelper.estimateSourceSize(it);
|
||||
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_movie,
|
||||
it.name,
|
||||
"${it.width}x${it.height}",
|
||||
(prefix + it.codec).trim(),
|
||||
tag = it,
|
||||
call = {
|
||||
selectedVideo = it
|
||||
menu?.selectOption(videoSources, it);
|
||||
if(selectedAudio != null || !requiresAudio)
|
||||
menu?.setOk(container.context.getString(R.string.download));
|
||||
},
|
||||
invokeParent = false
|
||||
)
|
||||
}
|
||||
|
||||
is JSDashManifestRawSource -> {
|
||||
val estSize = VideoHelper.estimateSourceSize(it);
|
||||
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_movie,
|
||||
it.name,
|
||||
"${it.width}x${it.height}",
|
||||
(prefix + it.codec).trim(),
|
||||
tag = it,
|
||||
call = {
|
||||
selectedVideo = it
|
||||
menu?.selectOption(videoSources, it);
|
||||
if(selectedAudio != null || !requiresAudio)
|
||||
menu?.setOk(container.context.getString(R.string.download));
|
||||
},
|
||||
invokeParent = false
|
||||
)
|
||||
}
|
||||
|
||||
is IHLSManifestSource -> {
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "HLS", it, {
|
||||
showHlsPicker(video, it, it.url, container)
|
||||
}, false)
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_movie,
|
||||
it.name,
|
||||
"HLS",
|
||||
tag = it,
|
||||
call = {
|
||||
showHlsPicker(video, it, it.url, container)
|
||||
},
|
||||
invokeParent = false
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
throw Exception("Unhandled source type")
|
||||
Logger.w(TAG, "Unhandled source type for UISlideOverlay download items");
|
||||
null;//throw Exception("Unhandled source type")
|
||||
}
|
||||
}
|
||||
}).flatten().toList()
|
||||
}.filterNotNull()).flatten().toList()
|
||||
));
|
||||
|
||||
if(Settings.instance.downloads.getDefaultVideoQualityPixels() > 0 && videoSources.isNotEmpty()) {
|
||||
//TODO: Add HLS support here
|
||||
selectedVideo = VideoHelper.selectBestVideoSource(
|
||||
videoSources.filter { it is IVideoUrlSource && it.isDownloadable() }.asIterable(),
|
||||
videoSources.filter { it is IVideoSource && it.isDownloadable() }.asIterable(),
|
||||
Settings.instance.downloads.getDefaultVideoQualityPixels(),
|
||||
FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS
|
||||
) as IVideoUrlSource?;
|
||||
) as IVideoSource?;
|
||||
}
|
||||
|
||||
if (audioSources != null) {
|
||||
@@ -389,43 +511,90 @@ class UISlideOverlays {
|
||||
.map {
|
||||
when (it) {
|
||||
is IAudioUrlSource -> {
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, "${it.bitrate}", it, {
|
||||
selectedAudio = it
|
||||
menu?.selectOption(audioSources, it);
|
||||
menu?.setOk(container.context.getString(R.string.download));
|
||||
}, false);
|
||||
val estSize = VideoHelper.estimateSourceSize(it);
|
||||
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_music,
|
||||
it.name,
|
||||
"${it.bitrate}",
|
||||
(prefix + it.codec).trim(),
|
||||
tag = it,
|
||||
call = {
|
||||
selectedAudio = it
|
||||
menu?.selectOption(audioSources, it);
|
||||
menu?.setOk(container.context.getString(R.string.download));
|
||||
},
|
||||
invokeParent = false
|
||||
);
|
||||
}
|
||||
|
||||
is JSDashManifestRawAudioSource -> {
|
||||
val estSize = VideoHelper.estimateSourceSize(it);
|
||||
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_music,
|
||||
it.name,
|
||||
"${it.bitrate}",
|
||||
(prefix + it.codec).trim(),
|
||||
tag = it,
|
||||
call = {
|
||||
selectedAudio = it
|
||||
menu?.selectOption(audioSources, it);
|
||||
menu?.setOk(container.context.getString(R.string.download));
|
||||
},
|
||||
invokeParent = false
|
||||
);
|
||||
}
|
||||
|
||||
is IHLSManifestAudioSource -> {
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "HLS Audio", it, {
|
||||
showHlsPicker(video, it, it.url, container)
|
||||
}, false)
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_movie,
|
||||
it.name,
|
||||
"HLS Audio",
|
||||
tag = it,
|
||||
call = {
|
||||
showHlsPicker(video, it, it.url, container)
|
||||
},
|
||||
invokeParent = false
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
throw Exception("Unhandled source type")
|
||||
Logger.w(TAG, "Unhandled source type for UISlideOverlay download items");
|
||||
null;//throw Exception("Unhandled source type")
|
||||
}
|
||||
}
|
||||
}));
|
||||
}.filterNotNull()));
|
||||
|
||||
//TODO: Add HLS support here
|
||||
selectedAudio = VideoHelper.selectBestAudioSource(audioSources.filter { it is IAudioUrlSource && it.isDownloadable() }.asIterable(),
|
||||
selectedAudio = VideoHelper.selectBestAudioSource(audioSources.filter { it is IAudioSource && it.isDownloadable() }.asIterable(),
|
||||
FutoVideoPlayerBase.PREFERED_AUDIO_CONTAINERS,
|
||||
Settings.instance.playback.getPrimaryLanguage(container.context),
|
||||
if(Settings.instance.downloads.isHighBitrateDefault()) 9999999 else 1) as IAudioUrlSource?;
|
||||
if(Settings.instance.downloads.isHighBitrateDefault()) 9999999 else 1) as IAudioSource?;
|
||||
}
|
||||
|
||||
if(contentResolver != null && subtitleSources.isNotEmpty()) {
|
||||
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.subtitles), subtitleSources, subtitleSources.map {
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_edit, it.name, "", it, {
|
||||
if (selectedSubtitle == it) {
|
||||
selectedSubtitle = null;
|
||||
menu?.selectOption(subtitleSources, null);
|
||||
} else {
|
||||
selectedSubtitle = it;
|
||||
menu?.selectOption(subtitleSources, it);
|
||||
}
|
||||
}, false);
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_edit,
|
||||
it.name,
|
||||
"",
|
||||
tag = it,
|
||||
call = {
|
||||
if (selectedSubtitle == it) {
|
||||
selectedSubtitle = null;
|
||||
menu?.selectOption(subtitleSources, null);
|
||||
} else {
|
||||
selectedSubtitle = it;
|
||||
menu?.selectOption(subtitleSources, it);
|
||||
}
|
||||
},
|
||||
invokeParent = false
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -443,6 +612,18 @@ class UISlideOverlays {
|
||||
}
|
||||
|
||||
menu.onOK.subscribe {
|
||||
val sv = selectedVideo
|
||||
if (sv is IHLSManifestSource) {
|
||||
showHlsPicker(video, sv, sv.url, container)
|
||||
return@subscribe
|
||||
}
|
||||
|
||||
val sa = selectedAudio
|
||||
if (sa is IHLSManifestAudioSource) {
|
||||
showHlsPicker(video, sa, sa.url, container)
|
||||
return@subscribe
|
||||
}
|
||||
|
||||
menu.hide();
|
||||
val subtitleToDownload = selectedSubtitle;
|
||||
if(selectedAudio != null || !requiresAudio) {
|
||||
@@ -499,8 +680,9 @@ class UISlideOverlays {
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e(TAG, "Fetching details for download failed due to: " + ex.message, ex);
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.toast(container.context.getString(R.string.failed_to_fetch_details_for_download));
|
||||
UIDialogs.toast(container.context.getString(R.string.failed_to_fetch_details_for_download) + "\n" + ex.message);
|
||||
handleUnknownDownload();
|
||||
loader.hide(true);
|
||||
}
|
||||
@@ -537,23 +719,47 @@ class UISlideOverlays {
|
||||
);
|
||||
|
||||
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.target_resolution), "Video", resolutions.map {
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_movie, it.first, it.second, it.third, {
|
||||
targetPxSize = it.third;
|
||||
menu?.selectOption("Video", it.third);
|
||||
}, false)
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_movie,
|
||||
it.first,
|
||||
it.second,
|
||||
tag = it.third,
|
||||
call = {
|
||||
targetPxSize = it.third;
|
||||
menu?.selectOption("Video", it.third);
|
||||
},
|
||||
invokeParent = false
|
||||
)
|
||||
}));
|
||||
|
||||
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.target_bitrate), "Bitrate", listOf(
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_movie, container.context.getString(R.string.low_bitrate), "", 1, {
|
||||
targetBitrate = 1;
|
||||
menu?.selectOption("Bitrate", 1);
|
||||
menu?.setOk(container.context.getString(R.string.download));
|
||||
}, false),
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_movie, container.context.getString(R.string.high_bitrate), "", 9999999, {
|
||||
targetBitrate = 9999999;
|
||||
menu?.selectOption("Bitrate", 9999999);
|
||||
menu?.setOk(container.context.getString(R.string.download));
|
||||
}, false)
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_movie,
|
||||
container.context.getString(R.string.low_bitrate),
|
||||
"",
|
||||
tag = 1,
|
||||
call = {
|
||||
targetBitrate = 1;
|
||||
menu?.selectOption("Bitrate", 1);
|
||||
menu?.setOk(container.context.getString(R.string.download));
|
||||
},
|
||||
invokeParent = false
|
||||
),
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_movie,
|
||||
container.context.getString(R.string.high_bitrate),
|
||||
"",
|
||||
tag = 9999999,
|
||||
call = {
|
||||
targetBitrate = 9999999;
|
||||
menu?.selectOption("Bitrate", 9999999);
|
||||
menu?.setOk(container.context.getString(R.string.download));
|
||||
},
|
||||
invokeParent = false
|
||||
)
|
||||
)));
|
||||
|
||||
|
||||
@@ -676,8 +882,12 @@ class UISlideOverlays {
|
||||
if (lastUpdated != null) {
|
||||
items.add(
|
||||
SlideUpMenuGroup(container.context, container.context.getString(R.string.recently_used_playlist), "recentlyusedplaylist",
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, lastUpdated.name, "${lastUpdated.videos.size} " + container.context.getString(R.string.videos), "",
|
||||
{
|
||||
SlideUpMenuItem(container.context,
|
||||
R.drawable.ic_playlist_add,
|
||||
lastUpdated.name,
|
||||
"${lastUpdated.videos.size} " + container.context.getString(R.string.videos),
|
||||
tag = "",
|
||||
call = {
|
||||
StatePlaylists.instance.addToPlaylist(lastUpdated.id, video);
|
||||
StateDownloads.instance.checkForOutdatedPlaylists();
|
||||
}))
|
||||
@@ -689,44 +899,90 @@ class UISlideOverlays {
|
||||
val watchLater = StatePlaylists.instance.getWatchLater();
|
||||
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.actions), "actions",
|
||||
(listOf(
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_download, container.context.getString(R.string.download), container.context.getString(R.string.download_the_video), "download", {
|
||||
showDownloadVideoOverlay(video, container, true);
|
||||
}, false),
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_share, container.context.getString(R.string.share), "Share the video", "share", {
|
||||
val url = if(video.shareUrl.isNotEmpty()) video.shareUrl else video.url;
|
||||
container.context.startActivity(Intent.createChooser(Intent().apply {
|
||||
action = Intent.ACTION_SEND;
|
||||
putExtra(Intent.EXTRA_TEXT, url);
|
||||
type = "text/plain";
|
||||
}, null));
|
||||
}, false),
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_visibility_off, container.context.getString(R.string.hide_creator_from_home), "", "hide_creator", {
|
||||
StateMeta.instance.addHiddenCreator(video.author.url);
|
||||
UIDialogs.toast(container.context, "[${video.author.name}] hidden, you may need to reload home");
|
||||
}))
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_download,
|
||||
container.context.getString(R.string.download),
|
||||
container.context.getString(R.string.download_the_video),
|
||||
tag = "download",
|
||||
call = {
|
||||
showDownloadVideoOverlay(video, container, true);
|
||||
},
|
||||
invokeParent = false
|
||||
),
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_share,
|
||||
container.context.getString(R.string.share),
|
||||
"Share the video",
|
||||
tag = "share",
|
||||
call = {
|
||||
val url = if(video.shareUrl.isNotEmpty()) video.shareUrl else video.url;
|
||||
container.context.startActivity(Intent.createChooser(Intent().apply {
|
||||
action = Intent.ACTION_SEND;
|
||||
putExtra(Intent.EXTRA_TEXT, url);
|
||||
type = "text/plain";
|
||||
}, null));
|
||||
},
|
||||
invokeParent = false
|
||||
),
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_visibility_off,
|
||||
container.context.getString(R.string.hide_creator_from_home),
|
||||
"",
|
||||
tag = "hide_creator",
|
||||
call = {
|
||||
StateMeta.instance.addHiddenCreator(video.author.url);
|
||||
UIDialogs.toast(container.context, "[${video.author.name}] hidden, you may need to reload home");
|
||||
}))
|
||||
+ actions)
|
||||
));
|
||||
items.add(
|
||||
SlideUpMenuGroup(container.context, container.context.getString(R.string.add_to), "addto",
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_queue_add, container.context.getString(R.string.add_to_queue), "${queue.size} " + container.context.getString(R.string.videos), "queue",
|
||||
{ StatePlayer.instance.addToQueue(video); }),
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_watchlist_add, "${container.context.getString(R.string.add_to)} " + StatePlayer.TYPE_WATCHLATER + "", "${watchLater.size} " + container.context.getString(R.string.videos), "watch later",
|
||||
{ StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video)); }),
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_history, container.context.getString(R.string.add_to_history), "Mark as watched", "history",
|
||||
{ StateHistory.instance.markAsWatched(video); }),
|
||||
SlideUpMenuItem(container.context,
|
||||
R.drawable.ic_queue_add,
|
||||
container.context.getString(R.string.add_to_queue),
|
||||
"${queue.size} " + container.context.getString(R.string.videos),
|
||||
tag = "queue",
|
||||
call = { StatePlayer.instance.addToQueue(video); }),
|
||||
SlideUpMenuItem(container.context,
|
||||
R.drawable.ic_watchlist_add,
|
||||
"${container.context.getString(R.string.add_to)} " + StatePlayer.TYPE_WATCHLATER + "",
|
||||
"${watchLater.size} " + container.context.getString(R.string.videos),
|
||||
tag = "watch later",
|
||||
call = { StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video)); }),
|
||||
SlideUpMenuItem(container.context,
|
||||
R.drawable.ic_history,
|
||||
container.context.getString(R.string.add_to_history),
|
||||
"Mark as watched",
|
||||
tag = "history",
|
||||
call = { StateHistory.instance.markAsWatched(video); }),
|
||||
));
|
||||
|
||||
val playlistItems = arrayListOf<SlideUpMenuItem>();
|
||||
playlistItems.add(SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, container.context.getString(R.string.new_playlist), container.context.getString(R.string.add_to_new_playlist), "add_to_new_playlist", {
|
||||
showCreatePlaylistOverlay(container) {
|
||||
val playlist = Playlist(it, arrayListOf(SerializedPlatformVideo.fromVideo(video)));
|
||||
StatePlaylists.instance.createOrUpdatePlaylist(playlist);
|
||||
};
|
||||
}, false))
|
||||
playlistItems.add(SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_playlist_add,
|
||||
container.context.getString(R.string.new_playlist),
|
||||
container.context.getString(R.string.add_to_new_playlist),
|
||||
tag = "add_to_new_playlist",
|
||||
call = {
|
||||
showCreatePlaylistOverlay(container) {
|
||||
val playlist = Playlist(it, arrayListOf(SerializedPlatformVideo.fromVideo(video)));
|
||||
StatePlaylists.instance.createOrUpdatePlaylist(playlist);
|
||||
};
|
||||
},
|
||||
invokeParent = false
|
||||
))
|
||||
|
||||
for (playlist in allPlaylists) {
|
||||
playlistItems.add(SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, "${container.context.getString(R.string.add_to)} " + playlist.name + "", "${playlist.videos.size} " + container.context.getString(R.string.videos), "",
|
||||
{
|
||||
playlistItems.add(SlideUpMenuItem(container.context,
|
||||
R.drawable.ic_playlist_add,
|
||||
"${container.context.getString(R.string.add_to)} " + playlist.name + "",
|
||||
"${playlist.videos.size} " + container.context.getString(R.string.videos),
|
||||
tag = "",
|
||||
call = {
|
||||
StatePlaylists.instance.addToPlaylist(playlist.id, video);
|
||||
StateDownloads.instance.checkForOutdatedPlaylists();
|
||||
}));
|
||||
@@ -748,8 +1004,12 @@ class UISlideOverlays {
|
||||
if (lastUpdated != null) {
|
||||
items.add(
|
||||
SlideUpMenuGroup(container.context, container.context.getString(R.string.recently_used_playlist), "recentlyusedplaylist",
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, lastUpdated.name, "${lastUpdated.videos.size} " + container.context.getString(R.string.videos), "",
|
||||
{
|
||||
SlideUpMenuItem(container.context,
|
||||
R.drawable.ic_playlist_add,
|
||||
lastUpdated.name,
|
||||
"${lastUpdated.videos.size} " + container.context.getString(R.string.videos),
|
||||
tag = "",
|
||||
call = {
|
||||
StatePlaylists.instance.addToPlaylist(lastUpdated.id, video);
|
||||
StateDownloads.instance.checkForOutdatedPlaylists();
|
||||
}))
|
||||
@@ -761,25 +1021,52 @@ class UISlideOverlays {
|
||||
val watchLater = StatePlaylists.instance.getWatchLater();
|
||||
items.add(
|
||||
SlideUpMenuGroup(container.context, container.context.getString(R.string.other), "other",
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_queue_add, container.context.getString(R.string.queue), "${queue.size} " + container.context.getString(R.string.videos), "queue",
|
||||
{ StatePlayer.instance.addToQueue(video); }),
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_watchlist_add, StatePlayer.TYPE_WATCHLATER, "${watchLater.size} " + container.context.getString(R.string.videos), "watch later",
|
||||
{ StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video)); }),
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_download, container.context.getString(R.string.download), container.context.getString(R.string.download_the_video), container.context.getString(R.string.download),
|
||||
{ showDownloadVideoOverlay(video, container, true); }, false))
|
||||
SlideUpMenuItem(container.context,
|
||||
R.drawable.ic_queue_add,
|
||||
container.context.getString(R.string.queue),
|
||||
"${queue.size} " + container.context.getString(R.string.videos),
|
||||
tag = "queue",
|
||||
call = { StatePlayer.instance.addToQueue(video); }),
|
||||
SlideUpMenuItem(container.context,
|
||||
R.drawable.ic_watchlist_add,
|
||||
StatePlayer.TYPE_WATCHLATER,
|
||||
"${watchLater.size} " + container.context.getString(R.string.videos),
|
||||
tag = "watch later",
|
||||
call = { StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video)); }),
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_download,
|
||||
container.context.getString(R.string.download),
|
||||
container.context.getString(R.string.download_the_video),
|
||||
tag = container.context.getString(R.string.download),
|
||||
call = { showDownloadVideoOverlay(video, container, true); },
|
||||
invokeParent = false
|
||||
))
|
||||
);
|
||||
|
||||
val playlistItems = arrayListOf<SlideUpMenuItem>();
|
||||
playlistItems.add(SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, container.context.getString(R.string.new_playlist), container.context.getString(R.string.add_to_new_playlist), "add_to_new_playlist", {
|
||||
slideUpMenuOverlayUpdated(showCreatePlaylistOverlay(container) {
|
||||
val playlist = Playlist(it, arrayListOf(SerializedPlatformVideo.fromVideo(video)));
|
||||
StatePlaylists.instance.createOrUpdatePlaylist(playlist);
|
||||
});
|
||||
}, false))
|
||||
playlistItems.add(SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_playlist_add,
|
||||
container.context.getString(R.string.new_playlist),
|
||||
container.context.getString(R.string.add_to_new_playlist),
|
||||
tag = "add_to_new_playlist",
|
||||
call = {
|
||||
slideUpMenuOverlayUpdated(showCreatePlaylistOverlay(container) {
|
||||
val playlist = Playlist(it, arrayListOf(SerializedPlatformVideo.fromVideo(video)));
|
||||
StatePlaylists.instance.createOrUpdatePlaylist(playlist);
|
||||
});
|
||||
},
|
||||
invokeParent = false
|
||||
))
|
||||
|
||||
for (playlist in allPlaylists) {
|
||||
playlistItems.add(SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, playlist.name, "${playlist.videos.size} " + container.context.getString(R.string.videos), "",
|
||||
{
|
||||
playlistItems.add(SlideUpMenuItem(container.context,
|
||||
R.drawable.ic_playlist_add,
|
||||
playlist.name,
|
||||
"${playlist.videos.size} " + container.context.getString(R.string.videos),
|
||||
tag = "",
|
||||
call = {
|
||||
StatePlaylists.instance.addToPlaylist(playlist.id, video);
|
||||
StateDownloads.instance.checkForOutdatedPlaylists();
|
||||
}));
|
||||
@@ -804,20 +1091,36 @@ class UISlideOverlays {
|
||||
|
||||
val views = arrayOf(
|
||||
hidden
|
||||
.map { btn -> SlideUpMenuItem(container.context, btn.iconResource, btn.text.text.toString(), "", "", {
|
||||
btn.handler?.invoke(btn);
|
||||
}, invokeParents) as View }.toTypedArray(),
|
||||
arrayOf(SlideUpMenuItem(container.context, R.drawable.ic_pin, container.context.getString(R.string.change_pins), container.context.getString(R.string.decide_which_buttons_should_be_pinned), "", {
|
||||
showOrderOverlay(container, container.context.getString(R.string.select_your_pins_in_order), (visible + hidden).map { Pair(it.text.text.toString(), it.tagRef!!) }) {
|
||||
val selected = it
|
||||
.map { x -> visible.find { it.tagRef == x } ?: hidden.find { it.tagRef == x } }
|
||||
.filter { it != null }
|
||||
.map { it!! }
|
||||
.toList();
|
||||
.map { btn -> SlideUpMenuItem(
|
||||
container.context,
|
||||
btn.iconResource,
|
||||
btn.text.text.toString(),
|
||||
"",
|
||||
tag = "",
|
||||
call = {
|
||||
btn.handler?.invoke(btn);
|
||||
},
|
||||
invokeParent = invokeParents
|
||||
) as View }.toTypedArray(),
|
||||
arrayOf(SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_pin,
|
||||
container.context.getString(R.string.change_pins),
|
||||
container.context.getString(R.string.decide_which_buttons_should_be_pinned),
|
||||
tag = "",
|
||||
call = {
|
||||
showOrderOverlay(container, container.context.getString(R.string.select_your_pins_in_order), (visible + hidden).map { Pair(it.text.text.toString(), it.tagRef!!) }) {
|
||||
val selected = it
|
||||
.map { x -> visible.find { it.tagRef == x } ?: hidden.find { it.tagRef == x } }
|
||||
.filter { it != null }
|
||||
.map { it!! }
|
||||
.toList();
|
||||
|
||||
onPinnedbuttons?.invoke(selected + (visible + hidden).filter { !selected.contains(it) });
|
||||
}
|
||||
}, false))
|
||||
onPinnedbuttons?.invoke(selected + (visible + hidden).filter { !selected.contains(it) });
|
||||
}
|
||||
},
|
||||
invokeParent = false
|
||||
))
|
||||
).flatten().toTypedArray();
|
||||
|
||||
return SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.more_options), null, true, *views).apply { show() };
|
||||
@@ -829,14 +1132,21 @@ class UISlideOverlays {
|
||||
var overlay: SlideUpMenuOverlay? = null;
|
||||
|
||||
overlay = SlideUpMenuOverlay(container.context, container, title, container.context.getString(R.string.save), true,
|
||||
options.map { SlideUpMenuItem(container.context, R.drawable.ic_move_up, it.first, "", it.second, {
|
||||
options.map { SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_move_up,
|
||||
it.first,
|
||||
"",
|
||||
tag = it.second,
|
||||
call = {
|
||||
if(overlay!!.selectOption(null, it.second, true, true)) {
|
||||
if(!selection.contains(it.second))
|
||||
selection.add(it.second);
|
||||
}
|
||||
else
|
||||
} else
|
||||
selection.remove(it.second);
|
||||
}, false)
|
||||
},
|
||||
invokeParent = false
|
||||
)
|
||||
});
|
||||
overlay.onOK.subscribe {
|
||||
onOrdered.invoke(selection);
|
||||
|
||||
@@ -13,6 +13,7 @@ import android.os.OperationCanceledException
|
||||
import android.util.TypedValue
|
||||
import android.view.View
|
||||
import android.view.WindowInsetsController
|
||||
import android.view.WindowManager
|
||||
import android.widget.TextView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
@@ -146,6 +147,8 @@ fun InputStream.copyToOutputStream(inputStreamLength: Long, outputStream: Output
|
||||
@Suppress("DEPRECATION")
|
||||
fun Activity.setNavigationBarColorAndIcons() {
|
||||
window.navigationBarColor = ContextCompat.getColor(this, android.R.color.black);
|
||||
if (Settings.instance.playback.allowVideoToGoUnderCutout)
|
||||
window.attributes.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
window.insetsController?.setSystemBarsAppearance(0, WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS);
|
||||
|
||||
@@ -4,11 +4,13 @@ import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.Configuration
|
||||
import android.net.Uri
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.Bundle
|
||||
import android.os.StrictMode
|
||||
import android.os.StrictMode.VmPolicy
|
||||
import android.util.Log
|
||||
import android.util.TypedValue
|
||||
import android.view.View
|
||||
@@ -21,28 +23,61 @@ import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.constraintlayout.motion.widget.MotionLayout
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentContainerView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.BuildConfig
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.casting.StateCasting
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.*
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.BrowserFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.BuyFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.ChannelFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.CommentsFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.ContentSearchResultsFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.CreatorSearchResultsFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.CreatorsFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.DownloadsFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.HistoryFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.HomeFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.ImportPlaylistsFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.ImportSubscriptionsFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.MainFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.PlaylistFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.PlaylistSearchResultsFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.PlaylistsFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.PostDetailFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.RemotePlaylistFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SourceDetailFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SourcesFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionGroupFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionGroupListFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionsFeedFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SuggestionsFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.TutorialFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.WatchLaterFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.topbar.AddTopBarFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.topbar.GeneralTopBarFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.topbar.ImportTopBarFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment
|
||||
import com.futo.platformplayer.listeners.OrientationManager
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.ImportCache
|
||||
import com.futo.platformplayer.states.*
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateBackup
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
import com.futo.platformplayer.states.StatePayment
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StatePlayer
|
||||
import com.futo.platformplayer.states.StatePlaylists
|
||||
import com.futo.platformplayer.states.StateSubscriptions
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
import com.futo.platformplayer.stores.SubscriptionStorage
|
||||
import com.futo.platformplayer.stores.v2.ManagedStore
|
||||
@@ -50,15 +85,22 @@ import com.futo.platformplayer.views.ToastView
|
||||
import com.futo.polycentric.core.ApiMethods
|
||||
import com.google.gson.JsonParser
|
||||
import com.google.zxing.integration.android.IntentIntegrator
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import java.io.PrintWriter
|
||||
import java.io.StringWriter
|
||||
import java.lang.reflect.InvocationTargetException
|
||||
import java.util.*
|
||||
import java.util.LinkedList
|
||||
import java.util.Queue
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
|
||||
|
||||
class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
|
||||
//TODO: Move to dimensions
|
||||
@@ -131,9 +173,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
|
||||
val onNavigated = Event1<MainFragment>();
|
||||
|
||||
private lateinit var _orientationManager: OrientationManager;
|
||||
var orientation: OrientationManager.Orientation = OrientationManager.Orientation.PORTRAIT
|
||||
private set;
|
||||
private var _isVisible = true;
|
||||
private var _wasStopped = false;
|
||||
|
||||
@@ -158,6 +197,15 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
}
|
||||
|
||||
constructor() : super() {
|
||||
if (BuildConfig.DEBUG) {
|
||||
StrictMode.setVmPolicy(
|
||||
VmPolicy.Builder()
|
||||
.detectLeakedClosableObjects()
|
||||
.penaltyLog()
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
ApiMethods.UserAgent = "Grayjay Android (${BuildConfig.VERSION_CODE})";
|
||||
|
||||
Thread.setDefaultUncaughtExceptionHandler { _, throwable ->
|
||||
@@ -206,7 +254,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
setContentView(R.layout.activity_main);
|
||||
setNavigationBarColorAndIcons();
|
||||
|
||||
|
||||
runBlocking {
|
||||
StatePlatform.instance.updateAvailableClients(this@MainActivity);
|
||||
}
|
||||
@@ -413,26 +460,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
.commitNow();
|
||||
|
||||
defaultTab.action(_fragBotBarMenu);
|
||||
|
||||
_orientationManager = OrientationManager(this);
|
||||
_orientationManager.onOrientationChanged.subscribe {
|
||||
orientation = it;
|
||||
Logger.i(TAG, "Orientation changed (Found ${it})");
|
||||
fragCurrent.onOrientationChanged(it);
|
||||
if(_fragVideoDetail.state == VideoDetailFragment.State.MAXIMIZED)
|
||||
_fragVideoDetail.onOrientationChanged(it);
|
||||
else if(Settings.instance.other.bypassRotationPrevention)
|
||||
{
|
||||
requestedOrientation = when(orientation) {
|
||||
OrientationManager.Orientation.PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
OrientationManager.Orientation.LANDSCAPE -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
|
||||
OrientationManager.Orientation.REVERSED_PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT
|
||||
OrientationManager.Orientation.REVERSED_LANDSCAPE -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE
|
||||
}
|
||||
}
|
||||
};
|
||||
_orientationManager.enable();
|
||||
|
||||
StateSubscriptions.instance;
|
||||
|
||||
fragCurrent.onShown(null, false);
|
||||
@@ -487,7 +514,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
|
||||
@@ -529,17 +555,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
override fun onResume() {
|
||||
super.onResume();
|
||||
Logger.v(TAG, "onResume")
|
||||
|
||||
val curOrientation = _orientationManager.orientation;
|
||||
|
||||
if(_fragVideoDetail.state == VideoDetailFragment.State.MAXIMIZED && _fragVideoDetail.lastOrientation != curOrientation) {
|
||||
Logger.i(TAG, "Orientation mismatch (Found ${curOrientation})");
|
||||
orientation = curOrientation;
|
||||
fragCurrent.onOrientationChanged(curOrientation);
|
||||
if(_fragVideoDetail.state == VideoDetailFragment.State.MAXIMIZED)
|
||||
_fragVideoDetail.onOrientationChanged(curOrientation);
|
||||
}
|
||||
|
||||
_isVisible = true;
|
||||
}
|
||||
|
||||
@@ -940,18 +955,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
override fun onRestart() {
|
||||
super.onRestart();
|
||||
Logger.i(TAG, "onRestart");
|
||||
|
||||
//Force Portrait on restart
|
||||
Logger.i(TAG, "Restarted with state ${_fragVideoDetail.state}");
|
||||
if(_fragVideoDetail.state != VideoDetailFragment.State.MAXIMIZED) {
|
||||
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT;
|
||||
WindowCompat.setDecorFitsSystemWindows(window, true)
|
||||
WindowInsetsControllerCompat(window, rootView).let { controller ->
|
||||
controller.show(WindowInsetsCompat.Type.statusBars());
|
||||
controller.show(WindowInsetsCompat.Type.systemBars())
|
||||
}
|
||||
_fragVideoDetail.onOrientationChanged(OrientationManager.Orientation.PORTRAIT);
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) {
|
||||
@@ -966,9 +969,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
override fun onDestroy() {
|
||||
super.onDestroy();
|
||||
Logger.v(TAG, "onDestroy")
|
||||
|
||||
_orientationManager.disable();
|
||||
|
||||
StateApp.instance.mainAppDestroyed(this);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.views.LoaderView
|
||||
@@ -184,12 +185,19 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
|
||||
resultLauncher.launch(intent);
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
settingsActivityClosed.emit()
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
//TODO: Temporary for solving Settings issues
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
private var _lastActivity: SettingsActivity? = null;
|
||||
|
||||
val settingsActivityClosed = Event0()
|
||||
|
||||
fun getActivity(): SettingsActivity? {
|
||||
val act = _lastActivity;
|
||||
if(act != null && !act._isFinished)
|
||||
|
||||
@@ -17,13 +17,14 @@ import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import java.security.SecureRandom
|
||||
import java.security.cert.X509Certificate
|
||||
import java.time.Duration
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.TrustManager
|
||||
import javax.net.ssl.X509TrustManager
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
open class ManagedHttpClient {
|
||||
protected val _builderTemplate: OkHttpClient.Builder;
|
||||
protected var _builderTemplate: OkHttpClient.Builder;
|
||||
|
||||
private var client: OkHttpClient;
|
||||
|
||||
@@ -32,6 +33,15 @@ open class ManagedHttpClient {
|
||||
|
||||
var user_agent = "Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0"
|
||||
|
||||
fun setTimeout(timeout: Long) {
|
||||
rebuildClient {
|
||||
it.callTimeout(Duration.ofMillis(client.callTimeoutMillis.toLong()))
|
||||
.writeTimeout(Duration.ofMillis(client.writeTimeoutMillis.toLong()))
|
||||
.readTimeout(Duration.ofMillis(client.readTimeoutMillis.toLong()))
|
||||
.connectTimeout(Duration.ofMillis(timeout));
|
||||
}
|
||||
}
|
||||
|
||||
private val trustAllCerts = arrayOf<TrustManager>(
|
||||
object: X509TrustManager {
|
||||
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) { }
|
||||
@@ -62,6 +72,15 @@ open class ManagedHttpClient {
|
||||
}.build();
|
||||
}
|
||||
|
||||
fun rebuildClient(modify: (OkHttpClient.Builder) -> OkHttpClient.Builder) {
|
||||
_builderTemplate = modify(_builderTemplate);
|
||||
client = _builderTemplate.addNetworkInterceptor { chain ->
|
||||
val request = beforeRequest(chain.request());
|
||||
val response = afterRequest(chain.proceed(request));
|
||||
return@addNetworkInterceptor response;
|
||||
}.build();
|
||||
}
|
||||
|
||||
open fun clone(): ManagedHttpClient {
|
||||
val clonedClient = ManagedHttpClient(_builderTemplate);
|
||||
clonedClient.user_agent = user_agent;
|
||||
|
||||
@@ -210,6 +210,20 @@ class HttpContext : AutoCloseable {
|
||||
}
|
||||
}
|
||||
}
|
||||
fun respondBytes(status: Int, headers: HttpHeaders, body: ByteArray? = null) {
|
||||
if(headers.get("content-length").isNullOrEmpty()) {
|
||||
if (body != null) {
|
||||
headers.put("content-length", body.size.toString());
|
||||
} else {
|
||||
headers.put("content-length", "0")
|
||||
}
|
||||
}
|
||||
respond(status, headers) { responseStream ->
|
||||
if(body != null) {
|
||||
responseStream.write(body);
|
||||
}
|
||||
}
|
||||
}
|
||||
fun respond(status: Int, headers: HttpHeaders, writing: (OutputStream)->Unit) {
|
||||
val responseStream = _responseStream ?: throw IllegalStateException("No response stream set");
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ package com.futo.platformplayer.api.http.server
|
||||
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.http.server.exceptions.EmptyRequestException
|
||||
import com.futo.platformplayer.api.http.server.handlers.HttpFuntionHandler
|
||||
import com.futo.platformplayer.api.http.server.handlers.HttpFunctionHandler
|
||||
import com.futo.platformplayer.api.http.server.handlers.HttpHandler
|
||||
import com.futo.platformplayer.api.http.server.handlers.HttpOptionsAllowHandler
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
@@ -208,20 +208,20 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) {
|
||||
|
||||
for(getMethod in getMethods)
|
||||
if(getMethod.first.parameterTypes.firstOrNull() == HttpContext::class.java && getMethod.first.parameterCount == 1)
|
||||
addHandler(HttpFuntionHandler("GET", getMethod.second.path) { getMethod.first.invoke(obj, it) }).apply {
|
||||
addHandler(HttpFunctionHandler("GET", getMethod.second.path) { getMethod.first.invoke(obj, it) }).apply {
|
||||
if(!getMethod.second.contentType.isEmpty())
|
||||
this.withContentType(getMethod.second.contentType);
|
||||
}.withContentType(getMethod.second.contentType);
|
||||
for(postMethod in postMethods)
|
||||
if(postMethod.first.parameterTypes.firstOrNull() == HttpContext::class.java && postMethod.first.parameterCount == 1)
|
||||
addHandler(HttpFuntionHandler("POST", postMethod.second.path) { postMethod.first.invoke(obj, it) }).apply {
|
||||
addHandler(HttpFunctionHandler("POST", postMethod.second.path) { postMethod.first.invoke(obj, it) }).apply {
|
||||
if(!postMethod.second.contentType.isEmpty())
|
||||
this.withContentType(postMethod.second.contentType);
|
||||
}.withContentType(postMethod.second.contentType);
|
||||
|
||||
for(getField in getFields) {
|
||||
getField.first.isAccessible = true;
|
||||
addHandler(HttpFuntionHandler("GET", getField.second.path) {
|
||||
addHandler(HttpFunctionHandler("GET", getField.second.path) {
|
||||
val value = getField.first.get(obj) as String?;
|
||||
if(value != null) {
|
||||
val headers = HttpHeaders(
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ package com.futo.platformplayer.api.http.server.handlers
|
||||
|
||||
import com.futo.platformplayer.api.http.server.HttpContext
|
||||
|
||||
class HttpFuntionHandler(method: String, path: String, val handler: (HttpContext)->Unit) : HttpHandler(method, path) {
|
||||
class HttpFunctionHandler(method: String, path: String, val handler: (HttpContext)->Unit) : HttpHandler(method, path) {
|
||||
override fun handle(httpContext: HttpContext) {
|
||||
httpContext.setResponseHeaders(this.headers);
|
||||
handler(httpContext);
|
||||
|
||||
@@ -23,7 +23,7 @@ enum class ChapterType(val value: Int) {
|
||||
companion object {
|
||||
fun fromInt(value: Int): ChapterType
|
||||
{
|
||||
val result = ChapterType.values().firstOrNull { it.value == value };
|
||||
val result = ChapterType.entries.firstOrNull { it.value == value };
|
||||
if(result == null)
|
||||
throw UnknownPlatformException(value.toString());
|
||||
return result;
|
||||
|
||||
@@ -21,7 +21,7 @@ enum class ContentType(val value: Int) {
|
||||
companion object {
|
||||
fun fromInt(value: Int): ContentType
|
||||
{
|
||||
val result = ContentType.values().firstOrNull { it.value == value };
|
||||
val result = ContentType.entries.firstOrNull { it.value == value };
|
||||
if(result == null)
|
||||
throw UnknownPlatformException(value.toString());
|
||||
return result;
|
||||
|
||||
@@ -10,7 +10,7 @@ enum class LiveEventType(val value : Int) {
|
||||
|
||||
companion object{
|
||||
fun fromInt(value : Int) : LiveEventType{
|
||||
return LiveEventType.values().first { it.value == value };
|
||||
return LiveEventType.entries.first { it.value == value };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ enum class TextType(val value: Int) {
|
||||
companion object {
|
||||
fun fromInt(value: Int): TextType
|
||||
{
|
||||
val result = TextType.values().firstOrNull { it.value == value };
|
||||
val result = TextType.entries.firstOrNull { it.value == value };
|
||||
if(result == null)
|
||||
throw IllegalArgumentException("Unknown Texttype: $value");
|
||||
return result;
|
||||
|
||||
@@ -8,7 +8,7 @@ enum class RatingType(val value : Int) {
|
||||
|
||||
companion object{
|
||||
fun fromInt(value : Int) : RatingType{
|
||||
return RatingType.values().first { it.value == value };
|
||||
return RatingType.entries.first { it.value == value };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.futo.platformplayer.api.media.platforms.js
|
||||
|
||||
class JSClientConstants {
|
||||
companion object {
|
||||
val PLUGIN_SPEC_VERSION = 2;
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -4,6 +4,7 @@ import android.net.Uri
|
||||
import com.futo.platformplayer.SignatureProvider
|
||||
import com.futo.platformplayer.api.media.Serializer
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.matchesDomain
|
||||
import com.futo.platformplayer.states.StatePlugins
|
||||
import kotlinx.serialization.Contextual
|
||||
import java.net.URL
|
||||
@@ -49,6 +50,7 @@ class SourcePluginConfig(
|
||||
var primaryClaimFieldType: Int? = null,
|
||||
var developerSubmitUrl: String? = null,
|
||||
var allowAllHttpHeaderAccess: Boolean = false,
|
||||
var maxDownloadParallelism: Int = 0
|
||||
) : IV8PluginConfig {
|
||||
|
||||
val absoluteIconUrl: String? get() = resolveAbsoluteUrl(iconUrl, sourceUrl);
|
||||
@@ -79,7 +81,7 @@ class SourcePluginConfig(
|
||||
private val _allowUrlsLower: List<String> get() {
|
||||
if(_allowUrlsLowerVal == null)
|
||||
_allowUrlsLowerVal = allowUrls.map { it.lowercase() }
|
||||
.filter { it.length > 0 && (it[0] != '*' || (_allowRegex.matches(it))) };
|
||||
.filter { it.length > 0 };
|
||||
return _allowUrlsLowerVal!!;
|
||||
};
|
||||
|
||||
@@ -172,12 +174,10 @@ class SourcePluginConfig(
|
||||
return true;
|
||||
val uri = Uri.parse(url);
|
||||
val host = uri.host?.lowercase() ?: "";
|
||||
return _allowUrlsLower.any { it == host || (it.length > 0 && it[0] == '*' && host.endsWith(it.substring(1))) };
|
||||
return _allowUrlsLower.any { it == host || (it.length > 0 && it[0] == '.' && host.matchesDomain(it)) };
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val _allowRegex = Regex("\\*\\.[a-z0-9]+\\.[a-z]+");
|
||||
|
||||
fun fromJson(json: String, sourceUrl: String? = null): SourcePluginConfig {
|
||||
val obj = Serializer.json.decodeFromString<SourcePluginConfig>(json);
|
||||
if(obj.sourceUrl == null)
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.futo.platformplayer.api.media.platforms.js.models
|
||||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.IPluginSourced
|
||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||
import com.futo.platformplayer.api.media.models.post.TextType
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.getOrThrowNullableList
|
||||
import kotlin.streams.toList
|
||||
|
||||
open class JSArticle : JSContent, IPluginSourced {
|
||||
final override val contentType: ContentType get() = ContentType.POST;
|
||||
|
||||
val summary: String;
|
||||
val thumbnails: Thumbnails?;
|
||||
val segments: List<IJSArticleSegment>;
|
||||
|
||||
constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) {
|
||||
val contextName = "PlatformPost";
|
||||
|
||||
summary = _content.getOrThrow(config, "summary", contextName);
|
||||
if(_content.has("thumbnails"))
|
||||
thumbnails = Thumbnails.fromV8(config, _content.getOrThrow(config, "thumbnails", contextName));
|
||||
else
|
||||
thumbnails = null;
|
||||
|
||||
|
||||
segments = (obj.getOrThrowNullableList<V8ValueObject>(config, "segments", contextName)
|
||||
?.map { fromV8Segment(config, it) }
|
||||
?.filterNotNull() ?: listOf());
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
fun fromV8Segment(config: SourcePluginConfig, obj: V8ValueObject): IJSArticleSegment? {
|
||||
if(!obj.has("type"))
|
||||
throw IllegalArgumentException("Object missing type field");
|
||||
return when(obj.getOrThrow<SegmentType>(config, "type", "JSArticle.Segment")) {
|
||||
SegmentType.TEXT -> JSTextSegment(config, obj);
|
||||
SegmentType.IMAGES -> JSImagesSegment(config, obj);
|
||||
else -> null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class SegmentType(i: Int) {
|
||||
UNKNOWN(0),
|
||||
TEXT(1),
|
||||
IMAGES(2)
|
||||
}
|
||||
|
||||
interface IJSArticleSegment {
|
||||
val type: SegmentType;
|
||||
}
|
||||
class JSTextSegment: IJSArticleSegment {
|
||||
override val type = SegmentType.TEXT;
|
||||
val textType: TextType;
|
||||
val content: String;
|
||||
|
||||
constructor(config: SourcePluginConfig, obj: V8ValueObject) {
|
||||
val contextName = "JSTextSegment";
|
||||
textType = TextType.fromInt((obj.getOrDefault<Int>(config, "textType", contextName, null) ?: 0));
|
||||
content = obj.getOrDefault(config, "content", contextName, "") ?: "";
|
||||
}
|
||||
}
|
||||
class JSImagesSegment: IJSArticleSegment {
|
||||
override val type = SegmentType.IMAGES;
|
||||
val images: List<String>;
|
||||
|
||||
constructor(config: SourcePluginConfig, obj: V8ValueObject) {
|
||||
val contextName = "JSTextSegment";
|
||||
images = obj.getOrThrowNullableList<String>(config, "images", contextName) ?: listOf();
|
||||
}
|
||||
}
|
||||
@@ -54,4 +54,8 @@ open class JSContent : IPlatformContent, IPluginSourced {
|
||||
|
||||
_hasGetDetails = _content.has("getDetails");
|
||||
}
|
||||
|
||||
fun getUnderlyingObject(): V8ValueObject? {
|
||||
return _content;
|
||||
}
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
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
|
||||
|
||||
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;
|
||||
this._executor = executor;
|
||||
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?
|
||||
@Throws(ScriptException::class)
|
||||
open fun executeRequest(url: String, headers: Map<String, String>): ByteArray {
|
||||
if (_executor.isClosed)
|
||||
throw IllegalStateException("Executor object is closed");
|
||||
|
||||
val result = if(_plugin is DevJSClient)
|
||||
StateDeveloper.instance.handleDevCall(_plugin.devID, "requestExecutor.executeRequest()") {
|
||||
V8Plugin.catchScriptErrors<Any>(
|
||||
_config,
|
||||
"[${_config.name}] JSRequestExecutor",
|
||||
"builder.modifyRequest()"
|
||||
) {
|
||||
_executor.invoke("executeRequest", url, headers);
|
||||
} as V8Value;
|
||||
}
|
||||
else V8Plugin.catchScriptErrors<Any>(
|
||||
_config,
|
||||
"[${_config.name}] JSRequestExecutor",
|
||||
"builder.modifyRequest()"
|
||||
) {
|
||||
_executor.invoke("executeRequest", url, headers);
|
||||
} as V8Value;
|
||||
|
||||
try {
|
||||
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<Int>(_config, "type", "JSRequestModifier");
|
||||
when(type) {
|
||||
//TODO: Buffer type?
|
||||
}
|
||||
}
|
||||
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<Any>(
|
||||
_config,
|
||||
"[${_config.name}] JSRequestExecutor",
|
||||
"builder.modifyRequest()"
|
||||
) {
|
||||
_executor.invokeVoid("cleanup", null);
|
||||
};
|
||||
}
|
||||
else V8Plugin.catchScriptErrors<Any>(
|
||||
_config,
|
||||
"[${_config.name}] JSRequestExecutor",
|
||||
"builder.modifyRequest()"
|
||||
) {
|
||||
_executor.invokeVoid("cleanup", null);
|
||||
};
|
||||
}
|
||||
|
||||
protected fun finalize() {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
//TODO: are these available..?
|
||||
@Serializable
|
||||
class ExecutorParameters {
|
||||
var rangeStart: Int = -1;
|
||||
var rangeEnd: Int = -1;
|
||||
|
||||
var segment: Int = -1;
|
||||
}
|
||||
+64
@@ -0,0 +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, IAudioSource, IJSDashManifestRawSource {
|
||||
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;
|
||||
override var manifest: String?;
|
||||
|
||||
override val hasGenerate: Boolean;
|
||||
|
||||
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH_RAW, plugin, obj) {
|
||||
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");
|
||||
}
|
||||
|
||||
override 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
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
|
||||
|
||||
interface IJSDashManifestRawSource {
|
||||
val hasGenerate: Boolean;
|
||||
var manifest: String?;
|
||||
fun generate(): String?;
|
||||
}
|
||||
open class JSDashManifestRawSource: JSSource, IVideoSource, IJSDashManifestRawSource {
|
||||
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?;
|
||||
override var manifest: String?;
|
||||
|
||||
override val hasGenerate: Boolean;
|
||||
val canMerge: Boolean;
|
||||
|
||||
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH_RAW, plugin, obj) {
|
||||
val contextName = "DashRawSource";
|
||||
val config = plugin.config;
|
||||
name = _obj.getOrThrow(config, "name", contextName);
|
||||
url = _obj.getOrThrow(config, "url", contextName);
|
||||
manifest = _obj.getOrDefault<String>(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");
|
||||
}
|
||||
|
||||
override 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("</AdaptationSet>", "</AdaptationSet>\n" + audioAdaptationSet.value)
|
||||
}
|
||||
else
|
||||
return videoDash;
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val adaptationSetRegex = Regex("<AdaptationSet.*?>.*?<\\/AdaptationSet>", RegexOption.DOT_MATCHES_ALL);
|
||||
}
|
||||
}
|
||||
+53
-8
@@ -10,10 +10,12 @@ import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
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 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
|
||||
|
||||
@@ -21,9 +23,17 @@ abstract class JSSource {
|
||||
protected val _plugin: JSClient;
|
||||
protected val _config: IV8PluginConfig;
|
||||
protected val _obj: V8ValueObject;
|
||||
|
||||
val hasRequestModifier: Boolean;
|
||||
private val _requestModifier: JSRequest?;
|
||||
|
||||
val hasRequestExecutor: Boolean;
|
||||
private val _requestExecutor: JSRequest?;
|
||||
|
||||
val requiresCustomDatasource: Boolean get() {
|
||||
return hasRequestModifier || hasRequestExecutor;
|
||||
}
|
||||
|
||||
val type : String;
|
||||
|
||||
constructor(type: String, plugin: JSClient, obj: V8ValueObject) {
|
||||
@@ -36,6 +46,11 @@ abstract class JSSource {
|
||||
JSRequest(plugin, it, null, null, true);
|
||||
}
|
||||
hasRequestModifier = _requestModifier != null || obj.has("getRequestModifier");
|
||||
|
||||
_requestExecutor = obj.getOrDefault<V8ValueObject>(_config, "requestExecutor", "JSSource.requestExecutor", null)?.let {
|
||||
JSRequest(plugin, it, null, null, true);
|
||||
}
|
||||
hasRequestExecutor = _requestExecutor != null || obj.has("getRequestExecutor");
|
||||
}
|
||||
|
||||
fun getRequestModifier(): IRequestModifier? {
|
||||
@@ -44,20 +59,38 @@ abstract class JSSource {
|
||||
return@AdhocRequestModifier _requestModifier.modify(_plugin, url, headers);
|
||||
};
|
||||
|
||||
if (!hasRequestModifier || _obj.isClosed) {
|
||||
if (!hasRequestModifier || _obj.isClosed)
|
||||
return null;
|
||||
}
|
||||
|
||||
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSVideoUrlSource", "obj.getRequestModifier()") {
|
||||
_obj.invoke("getRequestModifier", arrayOf<Any>());
|
||||
};
|
||||
|
||||
if (result !is V8ValueObject) {
|
||||
if (result !is V8ValueObject)
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSRequestModifier(_plugin, result)
|
||||
}
|
||||
open fun getRequestExecutor(): JSRequestExecutor? {
|
||||
if (!hasRequestExecutor || _obj.isClosed)
|
||||
return null;
|
||||
|
||||
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSSource", "obj.getRequestExecutor()") {
|
||||
_obj.invoke("getRequestExecutor", arrayOf<Any>());
|
||||
};
|
||||
|
||||
if (result !is V8ValueObject)
|
||||
return null;
|
||||
|
||||
return JSRequestExecutor(_plugin, result)
|
||||
}
|
||||
|
||||
fun getUnderlyingPlugin(): JSClient? {
|
||||
return _plugin;
|
||||
}
|
||||
fun getUnderlyingObject(): V8ValueObject? {
|
||||
return _obj;
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TYPE_AUDIOURL = "AudioUrlSource";
|
||||
@@ -65,33 +98,45 @@ abstract class JSSource {
|
||||
const val TYPE_AUDIO_WITH_METADATA = "AudioUrlRangeSource";
|
||||
const val TYPE_VIDEO_WITH_METADATA = "VideoUrlRangeSource";
|
||||
const val TYPE_DASH = "DashSource";
|
||||
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;
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -23,9 +23,11 @@ class JSUnMuxVideoSourceDescriptor: VideoUnMuxedSourceDescriptor {
|
||||
this.isUnMuxed = obj.getOrThrow(config, "isUnMuxed", contextName);
|
||||
this.videoSources = obj.getOrThrow<V8ValueArray>(config, "videoSources", contextName).toArray()
|
||||
.map { JSSource.fromV8Video(plugin, it as V8ValueObject) }
|
||||
.filterNotNull()
|
||||
.toTypedArray();
|
||||
this.audioSources = obj.getOrThrow<V8ValueArray>(config, "audioSources", contextName).toArray()
|
||||
.map { JSSource.fromV8Audio(plugin, it as V8ValueObject) }
|
||||
.filterNotNull()
|
||||
.toTypedArray();
|
||||
}
|
||||
}
|
||||
+1
@@ -21,6 +21,7 @@ class JSVideoSourceDescriptor : VideoMuxedSourceDescriptor {
|
||||
this.isUnMuxed = obj.getOrThrow(config, "isUnMuxed", contextName);
|
||||
this.videoSources = obj.getOrThrow<V8ValueArray>(config, "videoSources", contextName).toArray()
|
||||
.map { JSSource.fromV8Video(plugin, it as V8ValueObject) }
|
||||
.filterNotNull()
|
||||
.toTypedArray();
|
||||
}
|
||||
|
||||
|
||||
@@ -6,14 +6,17 @@ import android.net.Uri
|
||||
import android.os.Looper
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import com.futo.platformplayer.BuildConfig
|
||||
import android.util.Xml
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.http.server.HttpHeaders
|
||||
import com.futo.platformplayer.api.http.server.ManagedHttpServer
|
||||
import com.futo.platformplayer.api.http.server.handlers.HttpConstantHandler
|
||||
import com.futo.platformplayer.api.http.server.handlers.HttpFileHandler
|
||||
import com.futo.platformplayer.api.http.server.handlers.HttpFuntionHandler
|
||||
import com.futo.platformplayer.api.http.server.handlers.HttpFunctionHandler
|
||||
import com.futo.platformplayer.api.http.server.handlers.HttpProxyHandler
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource
|
||||
@@ -26,16 +29,23 @@ import com.futo.platformplayer.api.media.models.streams.sources.LocalSubtitleSou
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.LocalVideoSource
|
||||
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.JSRequestExecutor
|
||||
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.builders.DashBuilder
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.constructs.Event2
|
||||
import com.futo.platformplayer.exceptions.UnsupportedCastException
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.mdns.DnsService
|
||||
import com.futo.platformplayer.mdns.ServiceDiscoverer
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
import com.futo.platformplayer.parsers.HLS
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.stores.CastingDeviceInfoStorage
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
import com.futo.platformplayer.toUrlAddress
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
@@ -43,17 +53,15 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.net.InetAddress
|
||||
import java.net.URLDecoder
|
||||
import java.net.URLEncoder
|
||||
import java.util.UUID
|
||||
import javax.jmdns.JmDNS
|
||||
import javax.jmdns.ServiceEvent
|
||||
import javax.jmdns.ServiceListener
|
||||
import javax.jmdns.ServiceTypeListener
|
||||
|
||||
class StateCasting {
|
||||
private val _scopeIO = CoroutineScope(Dispatchers.IO);
|
||||
private val _scopeMain = CoroutineScope(Dispatchers.Main);
|
||||
private var _jmDNS: JmDNS? = null;
|
||||
private val _storage: CastingDeviceInfoStorage = FragmentedStorage.get();
|
||||
|
||||
private val _castServer = ManagedHttpServer(9999);
|
||||
@@ -70,105 +78,51 @@ class StateCasting {
|
||||
val onActiveDeviceDurationChanged = Event1<Double>();
|
||||
val onActiveDeviceVolumeChanged = Event1<Double>();
|
||||
var activeDevice: CastingDevice? = null;
|
||||
private var _videoExecutor: JSRequestExecutor? = null
|
||||
private var _audioExecutor: JSRequestExecutor? = null
|
||||
private val _client = ManagedHttpClient();
|
||||
var _resumeCastingDevice: CastingDeviceInfo? = null;
|
||||
val _serviceDiscoverer = ServiceDiscoverer(arrayOf(
|
||||
"_googlecast._tcp.local",
|
||||
"_airplay._tcp.local",
|
||||
"_fastcast._tcp.local",
|
||||
"_fcast._tcp.local"
|
||||
)) { handleServiceUpdated(it) }
|
||||
|
||||
val isCasting: Boolean get() = activeDevice != null;
|
||||
|
||||
private val _chromecastServiceListener = object : ServiceListener {
|
||||
override fun serviceAdded(event: ServiceEvent) {
|
||||
Logger.i(TAG, "ChromeCast service added: " + event.info);
|
||||
addOrUpdateDevice(event);
|
||||
}
|
||||
|
||||
override fun serviceRemoved(event: ServiceEvent) {
|
||||
Logger.i(TAG, "ChromeCast service removed: " + event.info);
|
||||
synchronized(devices) {
|
||||
val device = devices[event.info.name];
|
||||
if (device != null) {
|
||||
onDeviceRemoved.emit(device);
|
||||
private fun handleServiceUpdated(services: List<DnsService>) {
|
||||
for (s in services) {
|
||||
//TODO: Addresses IPv4 only?
|
||||
val addresses = s.addresses.toTypedArray()
|
||||
val port = s.port.toInt()
|
||||
var name = s.texts.firstOrNull { it.startsWith("md=") }?.substring("md=".length)
|
||||
if (s.name.endsWith("._googlecast._tcp.local")) {
|
||||
if (name == null) {
|
||||
name = s.name.substring(0, s.name.length - "._googlecast._tcp.local".length)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun serviceResolved(event: ServiceEvent) {
|
||||
Logger.v(TAG, "ChromeCast service resolved: " + event.info);
|
||||
addOrUpdateDevice(event);
|
||||
}
|
||||
|
||||
fun addOrUpdateDevice(event: ServiceEvent) {
|
||||
addOrUpdateChromeCastDevice(event.info.name, event.info.inetAddresses, event.info.port);
|
||||
}
|
||||
}
|
||||
|
||||
private val _airPlayServiceListener = object : ServiceListener {
|
||||
override fun serviceAdded(event: ServiceEvent) {
|
||||
Logger.i(TAG, "AirPlay service added: " + event.info);
|
||||
addOrUpdateDevice(event);
|
||||
}
|
||||
|
||||
override fun serviceRemoved(event: ServiceEvent) {
|
||||
Logger.i(TAG, "AirPlay service removed: " + event.info);
|
||||
synchronized(devices) {
|
||||
val device = devices[event.info.name];
|
||||
if (device != null) {
|
||||
onDeviceRemoved.emit(device);
|
||||
addOrUpdateChromeCastDevice(name, addresses, port)
|
||||
} else if (s.name.endsWith("._airplay._tcp.local")) {
|
||||
if (name == null) {
|
||||
name = s.name.substring(0, s.name.length - "._airplay._tcp.local".length)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun serviceResolved(event: ServiceEvent) {
|
||||
Logger.i(TAG, "AirPlay service resolved: " + event.info);
|
||||
addOrUpdateDevice(event);
|
||||
}
|
||||
|
||||
fun addOrUpdateDevice(event: ServiceEvent) {
|
||||
addOrUpdateAirPlayDevice(event.info.name, event.info.inetAddresses, event.info.port);
|
||||
}
|
||||
}
|
||||
|
||||
private val _fastCastServiceListener = object : ServiceListener {
|
||||
override fun serviceAdded(event: ServiceEvent) {
|
||||
Logger.i(TAG, "FastCast service added: " + event.info);
|
||||
addOrUpdateDevice(event);
|
||||
}
|
||||
|
||||
override fun serviceRemoved(event: ServiceEvent) {
|
||||
Logger.i(TAG, "FastCast service removed: " + event.info);
|
||||
synchronized(devices) {
|
||||
val device = devices[event.info.name];
|
||||
if (device != null) {
|
||||
onDeviceRemoved.emit(device);
|
||||
addOrUpdateAirPlayDevice(name, addresses, port)
|
||||
} else if (s.name.endsWith("._fastcast._tcp.local")) {
|
||||
if (name == null) {
|
||||
name = s.name.substring(0, s.name.length - "._fastcast._tcp.local".length)
|
||||
}
|
||||
|
||||
addOrUpdateFastCastDevice(name, addresses, port)
|
||||
} else if (s.name.endsWith("._fcast._tcp.local")) {
|
||||
if (name == null) {
|
||||
name = s.name.substring(0, s.name.length - "._fcast._tcp.local".length)
|
||||
}
|
||||
|
||||
addOrUpdateFastCastDevice(name, addresses, port)
|
||||
}
|
||||
}
|
||||
|
||||
override fun serviceResolved(event: ServiceEvent) {
|
||||
Logger.i(TAG, "FastCast service resolved: " + event.info);
|
||||
addOrUpdateDevice(event);
|
||||
}
|
||||
|
||||
fun addOrUpdateDevice(event: ServiceEvent) {
|
||||
addOrUpdateFastCastDevice(event.info.name, event.info.inetAddresses, event.info.port);
|
||||
}
|
||||
}
|
||||
|
||||
private val _serviceTypeListener = object : ServiceTypeListener {
|
||||
override fun serviceTypeAdded(event: ServiceEvent?) {
|
||||
if (event == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Service type added (name: ${event.name}, type: ${event.type})");
|
||||
}
|
||||
|
||||
override fun subTypeForServiceTypeAdded(event: ServiceEvent?) {
|
||||
if (event == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Sub type for service type added (name: ${event.name}, type: ${event.type})");
|
||||
}
|
||||
}
|
||||
|
||||
fun handleUrl(context: Context, url: String) {
|
||||
@@ -237,29 +191,30 @@ class StateCasting {
|
||||
rememberedDevices.clear();
|
||||
rememberedDevices.addAll(_storage.deviceInfos.map { deviceFromCastingDeviceInfo(it) });
|
||||
|
||||
_scopeIO.launch {
|
||||
try {
|
||||
val jmDNS = JmDNS.create(InetAddress.getLocalHost());
|
||||
jmDNS.addServiceListener("_googlecast._tcp.local.", _chromecastServiceListener);
|
||||
jmDNS.addServiceListener("_airplay._tcp.local.", _airPlayServiceListener);
|
||||
jmDNS.addServiceListener("_fastcast._tcp.local.", _fastCastServiceListener);
|
||||
jmDNS.addServiceListener("_fcast._tcp.local.", _fastCastServiceListener);
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
jmDNS.addServiceTypeListener(_serviceTypeListener);
|
||||
}
|
||||
|
||||
_jmDNS = jmDNS;
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to start casting service.", e);
|
||||
}
|
||||
}
|
||||
_castServer.start();
|
||||
enableDeveloper(true);
|
||||
|
||||
Logger.i(TAG, "CastingService started.");
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun startDiscovering() {
|
||||
try {
|
||||
_serviceDiscoverer.start()
|
||||
} catch (e: Throwable) {
|
||||
Logger.i(TAG, "Failed to start ServiceDiscoverer", e)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun stopDiscovering() {
|
||||
try {
|
||||
_serviceDiscoverer.stop()
|
||||
} catch (e: Throwable) {
|
||||
Logger.i(TAG, "Failed to stop ServiceDiscoverer", e)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun stop() {
|
||||
if (!_started)
|
||||
@@ -269,25 +224,7 @@ class StateCasting {
|
||||
|
||||
Logger.i(TAG, "CastingService stopping.")
|
||||
|
||||
val jmDNS = _jmDNS;
|
||||
if (jmDNS != null) {
|
||||
_scopeIO.launch {
|
||||
try {
|
||||
jmDNS.removeServiceListener("_googlecast._tcp.local.", _chromecastServiceListener);
|
||||
jmDNS.removeServiceListener("_airplay._tcp", _airPlayServiceListener);
|
||||
jmDNS.removeServiceListener("_fastcast._tcp.local.", _fastCastServiceListener);
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
jmDNS.removeServiceTypeListener(_serviceTypeListener);
|
||||
}
|
||||
|
||||
jmDNS.close();
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to stop mDNS.", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stopDiscovering()
|
||||
_scopeIO.cancel();
|
||||
_scopeMain.cancel();
|
||||
|
||||
@@ -437,15 +374,26 @@ class StateCasting {
|
||||
} else {
|
||||
StateApp.instance.scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
if (ad is FCastCastingDevice) {
|
||||
Logger.i(TAG, "Casting as DASH direct");
|
||||
castDashDirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed);
|
||||
} else if (ad is AirPlayCastingDevice) {
|
||||
Logger.i(TAG, "Casting as HLS indirect");
|
||||
castHlsIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed);
|
||||
val isRawDash = videoSource is JSDashManifestRawSource || audioSource is JSDashManifestRawAudioSource
|
||||
if (isRawDash) {
|
||||
Logger.i(TAG, "Casting as raw DASH");
|
||||
|
||||
try {
|
||||
castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, audioSource as JSDashManifestRawAudioSource?, subtitleSource, resumePosition, speed);
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to start casting DASH raw videoSource=${videoSource} audioSource=${audioSource}.", e);
|
||||
}
|
||||
} else {
|
||||
Logger.i(TAG, "Casting as DASH indirect");
|
||||
castDashIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed);
|
||||
if (ad is FCastCastingDevice) {
|
||||
Logger.i(TAG, "Casting as DASH direct");
|
||||
castDashDirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed);
|
||||
} else if (ad is AirPlayCastingDevice) {
|
||||
Logger.i(TAG, "Casting as HLS indirect");
|
||||
castHlsIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed);
|
||||
} else {
|
||||
Logger.i(TAG, "Casting as DASH indirect");
|
||||
castDashIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed);
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to start casting DASH videoSource=${videoSource} audioSource=${audioSource}.", e);
|
||||
@@ -454,7 +402,7 @@ class StateCasting {
|
||||
}
|
||||
} else {
|
||||
val proxyStreams = Settings.instance.casting.alwaysProxyRequests;
|
||||
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
val id = UUID.randomUUID();
|
||||
|
||||
if (videoSource is IVideoUrlSource) {
|
||||
@@ -489,6 +437,26 @@ class StateCasting {
|
||||
} else if (audioSource is LocalAudioSource) {
|
||||
Logger.i(TAG, "Casting as local audio");
|
||||
castLocalAudio(video, audioSource, resumePosition, speed);
|
||||
} else if (videoSource is JSDashManifestRawSource) {
|
||||
Logger.i(TAG, "Casting as JSDashManifestRawSource video");
|
||||
|
||||
StateApp.instance.scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, null, null, resumePosition, speed);
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to start casting DASH raw videoSource=${videoSource}.", e);
|
||||
}
|
||||
}
|
||||
} else if (audioSource is JSDashManifestRawAudioSource) {
|
||||
Logger.i(TAG, "Casting as JSDashManifestRawSource audio");
|
||||
|
||||
StateApp.instance.scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
castDashRaw(contentResolver, video, null, audioSource as JSDashManifestRawAudioSource?, null, resumePosition, speed);
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to start casting DASH raw audioSource=${audioSource}.", e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
var str = listOf(
|
||||
if(videoSource != null) "Video: ${videoSource::class.java.simpleName}" else null,
|
||||
@@ -529,7 +497,7 @@ class StateCasting {
|
||||
private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double, speed: Double?) : List<String> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
|
||||
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
val id = UUID.randomUUID();
|
||||
val videoPath = "/video-${id}"
|
||||
val videoUrl = url + videoPath;
|
||||
@@ -548,7 +516,7 @@ class StateCasting {
|
||||
private fun castLocalAudio(video: IPlatformVideoDetails, audioSource: LocalAudioSource, resumePosition: Double, speed: Double?) : List<String> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
|
||||
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
val id = UUID.randomUUID();
|
||||
val audioPath = "/audio-${id}"
|
||||
val audioUrl = url + audioPath;
|
||||
@@ -567,7 +535,7 @@ class StateCasting {
|
||||
private fun castLocalHls(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double, speed: Double?): List<String> {
|
||||
val ad = activeDevice ?: return listOf()
|
||||
|
||||
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}"
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"
|
||||
val id = UUID.randomUUID()
|
||||
|
||||
val hlsPath = "/hls-${id}"
|
||||
@@ -663,7 +631,7 @@ class StateCasting {
|
||||
private fun castLocalDash(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double, speed: Double?) : List<String> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
|
||||
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
val id = UUID.randomUUID();
|
||||
|
||||
val dashPath = "/dash-${id}"
|
||||
@@ -713,7 +681,7 @@ class StateCasting {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
val proxyStreams = Settings.instance.casting.alwaysProxyRequests || ad !is FCastCastingDevice;
|
||||
|
||||
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
val id = UUID.randomUUID();
|
||||
|
||||
val videoPath = "/video-${id}"
|
||||
@@ -771,20 +739,21 @@ class StateCasting {
|
||||
Logger.v(TAG) { "Dash manifest: $content" };
|
||||
ad.loadContent("application/dash+xml", content, resumePosition, video.duration.toDouble(), speed);
|
||||
|
||||
return listOf(videoUrl ?: "", audioUrl ?: "", subtitlesUrl ?: "", videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString()); }
|
||||
return listOf(videoUrl ?: "", audioUrl ?: "", subtitlesUrl ?: "", videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString());
|
||||
}
|
||||
|
||||
private fun castProxiedHls(video: IPlatformVideoDetails, sourceUrl: String, codec: String?, resumePosition: Double, speed: Double?): List<String> {
|
||||
_castServer.removeAllHandlers("castProxiedHlsMaster")
|
||||
|
||||
val ad = activeDevice ?: return listOf();
|
||||
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
|
||||
val id = UUID.randomUUID();
|
||||
val hlsPath = "/hls-${id}"
|
||||
val hlsUrl = url + hlsPath
|
||||
Logger.i(TAG, "HLS url: $hlsUrl");
|
||||
|
||||
_castServer.addHandlerWithAllowAllOptions(HttpFuntionHandler("GET", hlsPath) { masterContext ->
|
||||
_castServer.addHandlerWithAllowAllOptions(HttpFunctionHandler("GET", hlsPath) { masterContext ->
|
||||
_castServer.removeAllHandlers("castProxiedHlsVariant")
|
||||
|
||||
val headers = masterContext.headers.clone()
|
||||
@@ -811,7 +780,7 @@ class StateCasting {
|
||||
val proxiedVariantPlaylist = proxyVariantPlaylist(url, id, variantPlaylist, video.isLive)
|
||||
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
|
||||
masterContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
|
||||
return@HttpFuntionHandler
|
||||
return@HttpFunctionHandler
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
@@ -828,7 +797,7 @@ class StateCasting {
|
||||
val newPlaylistPath = "/hls-playlist-${playlistId}"
|
||||
val newPlaylistUrl = url + newPlaylistPath;
|
||||
|
||||
_castServer.addHandlerWithAllowAllOptions(HttpFuntionHandler("GET", newPlaylistPath) { vpContext ->
|
||||
_castServer.addHandlerWithAllowAllOptions(HttpFunctionHandler("GET", newPlaylistPath) { vpContext ->
|
||||
val vpHeaders = vpContext.headers.clone()
|
||||
vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl";
|
||||
|
||||
@@ -858,7 +827,7 @@ class StateCasting {
|
||||
val newPlaylistPath = "/hls-playlist-${playlistId}"
|
||||
newPlaylistUrl = url + newPlaylistPath
|
||||
|
||||
_castServer.addHandlerWithAllowAllOptions(HttpFuntionHandler("GET", newPlaylistPath) { vpContext ->
|
||||
_castServer.addHandlerWithAllowAllOptions(HttpFunctionHandler("GET", newPlaylistPath) { vpContext ->
|
||||
val vpHeaders = vpContext.headers.clone()
|
||||
vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl";
|
||||
|
||||
@@ -947,7 +916,7 @@ class StateCasting {
|
||||
|
||||
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 url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
val id = UUID.randomUUID();
|
||||
|
||||
val hlsPath = "/hls-${id}"
|
||||
@@ -1077,7 +1046,7 @@ class StateCasting {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
val proxyStreams = Settings.instance.casting.alwaysProxyRequests || ad !is FCastCastingDevice;
|
||||
|
||||
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
val id = UUID.randomUUID();
|
||||
|
||||
val dashPath = "/dash-${id}"
|
||||
@@ -1151,6 +1120,166 @@ class StateCasting {
|
||||
return listOf(dashUrl, videoUrl ?: "", audioUrl ?: "", subtitlesUrl ?: "", videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString());
|
||||
}
|
||||
|
||||
private fun cleanExecutors() {
|
||||
if (_videoExecutor != null) {
|
||||
_videoExecutor?.cleanup()
|
||||
_videoExecutor = null
|
||||
}
|
||||
|
||||
if (_audioExecutor != null) {
|
||||
_audioExecutor?.cleanup()
|
||||
_audioExecutor = null
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
private suspend fun castDashRaw(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: JSDashManifestRawSource?, audioSource: JSDashManifestRawAudioSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List<String> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
|
||||
cleanExecutors()
|
||||
_castServer.removeAllHandlers("castDashRaw")
|
||||
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
val id = UUID.randomUUID();
|
||||
|
||||
val dashPath = "/dash-${id}"
|
||||
val videoPath = "/video-${id}"
|
||||
val audioPath = "/audio-${id}"
|
||||
val subtitlePath = "/subtitle-${id}"
|
||||
|
||||
val dashUrl = url + dashPath;
|
||||
Logger.i(TAG, "DASH url: $dashUrl");
|
||||
|
||||
val videoUrl = url + videoPath
|
||||
val audioUrl = url + audioPath
|
||||
|
||||
val subtitlesUri = if (subtitleSource != null) withContext(Dispatchers.IO) {
|
||||
return@withContext subtitleSource.getSubtitlesURI();
|
||||
} else null;
|
||||
|
||||
var subtitlesUrl: String? = null;
|
||||
if (subtitlesUri != null) {
|
||||
if(subtitlesUri.scheme == "file") {
|
||||
var content: String? = null;
|
||||
val inputStream = contentResolver.openInputStream(subtitlesUri);
|
||||
inputStream?.use { stream ->
|
||||
val reader = stream.bufferedReader();
|
||||
content = reader.use { it.readText() };
|
||||
}
|
||||
|
||||
if (content != null) {
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpConstantHandler("GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt")
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("cast");
|
||||
}
|
||||
|
||||
subtitlesUrl = url + subtitlePath;
|
||||
} else {
|
||||
subtitlesUrl = subtitlesUri.toString();
|
||||
}
|
||||
}
|
||||
|
||||
var dashContent = withContext(Dispatchers.IO) {
|
||||
//TODO: Include subtitlesURl in the future
|
||||
return@withContext if (audioSource != null && videoSource != null) {
|
||||
JSDashManifestMergingRawSource(videoSource, audioSource).generate()
|
||||
} else if (audioSource != null) {
|
||||
audioSource.generate()
|
||||
} else if (videoSource != null) {
|
||||
videoSource.generate()
|
||||
} else {
|
||||
Logger.e(TAG, "Expected at least audio or video to be set")
|
||||
null
|
||||
}
|
||||
} ?: throw Exception("Dash is null")
|
||||
|
||||
for (representation in representationRegex.findAll(dashContent)) {
|
||||
val mediaType = representation.groups[1]?.value ?: throw Exception("Media type should be found")
|
||||
dashContent = mediaInitializationRegex.replace(dashContent) {
|
||||
if (it.range.first < representation.range.first || it.range.last > representation.range.last) {
|
||||
return@replace it.value
|
||||
}
|
||||
|
||||
if (mediaType.startsWith("video/")) {
|
||||
return@replace "${it.groups[1]!!.value}=\"${videoUrl}?url=${URLEncoder.encode(it.groups[2]!!.value, "UTF-8").replace("%24Number%24", "\$Number\$")}&mediaType=${URLEncoder.encode(mediaType, "UTF-8")}\""
|
||||
} else if (mediaType.startsWith("audio/")) {
|
||||
return@replace "${it.groups[1]!!.value}=\"${audioUrl}?url=${URLEncoder.encode(it.groups[2]!!.value, "UTF-8").replace("%24Number%24", "\$Number\$")}&mediaType=${URLEncoder.encode(mediaType, "UTF-8")}\""
|
||||
} else {
|
||||
throw Exception("Expected audio or video")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (videoSource != null && !videoSource.hasRequestExecutor) {
|
||||
throw Exception("Video source without request executor not supported")
|
||||
}
|
||||
|
||||
if (audioSource != null && !audioSource.hasRequestExecutor) {
|
||||
throw Exception("Audio source without request executor not supported")
|
||||
}
|
||||
|
||||
if (audioSource != null && audioSource.hasRequestExecutor) {
|
||||
_audioExecutor = audioSource.getRequestExecutor()
|
||||
}
|
||||
|
||||
if (videoSource != null && videoSource.hasRequestExecutor) {
|
||||
_videoExecutor = videoSource.getRequestExecutor()
|
||||
}
|
||||
|
||||
//TOOD: Else also handle the non request executor case, perhaps add ?url=$originalUrl to the query parameters, ... propagate this for all other flows also
|
||||
|
||||
Logger.v(TAG) { "Dash manifest: $dashContent" };
|
||||
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpConstantHandler("GET", dashPath, dashContent,
|
||||
"application/dash+xml")
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("castDashRaw");
|
||||
|
||||
if (videoSource != null) {
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpFunctionHandler("GET", videoPath) { httpContext ->
|
||||
val originalUrl = httpContext.query["url"]?.let { URLDecoder.decode(it, "UTF-8") } ?: return@HttpFunctionHandler
|
||||
val mediaType = httpContext.query["mediaType"]?.let { URLDecoder.decode(it, "UTF-8") } ?: return@HttpFunctionHandler
|
||||
|
||||
val videoExecutor = _videoExecutor;
|
||||
if (videoExecutor != null) {
|
||||
val data = videoExecutor.executeRequest(originalUrl, httpContext.headers)
|
||||
httpContext.respondBytes(200, HttpHeaders().apply {
|
||||
put("Content-Type", mediaType)
|
||||
}, data);
|
||||
} else {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
}.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("castDashRaw");
|
||||
}
|
||||
if (audioSource != null) {
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpFunctionHandler("GET", audioPath) { httpContext ->
|
||||
val originalUrl = httpContext.query["url"]?.let { URLDecoder.decode(it, "UTF-8") } ?: return@HttpFunctionHandler
|
||||
val mediaType = httpContext.query["mediaType"]?.let { URLDecoder.decode(it, "UTF-8") } ?: return@HttpFunctionHandler
|
||||
|
||||
val audioExecutor = _audioExecutor;
|
||||
if (audioExecutor != null) {
|
||||
val data = audioExecutor.executeRequest(originalUrl, httpContext.headers)
|
||||
httpContext.respondBytes(200, HttpHeaders().apply {
|
||||
put("Content-Type", mediaType)
|
||||
}, data);
|
||||
} else {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
}.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("castDashRaw");
|
||||
}
|
||||
|
||||
Logger.i(TAG, "added new castDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath).");
|
||||
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble(), speed);
|
||||
|
||||
return listOf()
|
||||
}
|
||||
|
||||
private fun deviceFromCastingDeviceInfo(deviceInfo: CastingDeviceInfo): CastingDevice {
|
||||
return when (deviceInfo.type) {
|
||||
CastProtocolType.CHROMECAST -> {
|
||||
@@ -1245,7 +1374,7 @@ class StateCasting {
|
||||
}
|
||||
} else {
|
||||
val newDevice = deviceFactory();
|
||||
devices[name] = newDevice;
|
||||
this.devices[name] = newDevice;
|
||||
|
||||
invokeEvents = {
|
||||
onDeviceAdded.emit(newDevice);
|
||||
@@ -1259,7 +1388,7 @@ class StateCasting {
|
||||
fun enableDeveloper(enableDev: Boolean){
|
||||
_castServer.removeAllHandlers("dev");
|
||||
if(enableDev) {
|
||||
_castServer.addHandler(HttpFuntionHandler("GET", "/dashPlayer") { context ->
|
||||
_castServer.addHandler(HttpFunctionHandler("GET", "/dashPlayer") { context ->
|
||||
if (context.query.containsKey("dashUrl")) {
|
||||
val dashUrl = context.query["dashUrl"];
|
||||
val html = "<div>\n" +
|
||||
@@ -1299,6 +1428,9 @@ class StateCasting {
|
||||
companion object {
|
||||
val instance: StateCasting = StateCasting();
|
||||
|
||||
private val representationRegex = Regex("<Representation .*?mimeType=\"(.*?)\".*?>(.*?)<\\/Representation>", RegexOption.DOT_MATCHES_ALL)
|
||||
private val mediaInitializationRegex = Regex("(media|initiali[sz]ation)=\"([^\"]+)\"", RegexOption.DOT_MATCHES_ALL);
|
||||
|
||||
private val TAG = "StateCasting";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -104,6 +104,8 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
|
||||
super.show();
|
||||
Logger.i(TAG, "Dialog shown.");
|
||||
|
||||
StateCasting.instance.startDiscovering()
|
||||
|
||||
(_imageLoader.drawable as Animatable?)?.start();
|
||||
|
||||
_devices.clear();
|
||||
@@ -169,6 +171,7 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
|
||||
|
||||
(_imageLoader.drawable as Animatable?)?.stop();
|
||||
|
||||
StateCasting.instance.stopDiscovering()
|
||||
StateCasting.instance.onDeviceAdded.remove(this);
|
||||
StateCasting.instance.onDeviceChanged.remove(this);
|
||||
StateCasting.instance.onDeviceRemoved.remove(this);
|
||||
|
||||
@@ -12,6 +12,8 @@ import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescri
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.AudioUrlSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
|
||||
@@ -25,6 +27,14 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideoDetails
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSRequestExecutor
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSVideo
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.IJSDashManifestRawSource
|
||||
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.JSSource
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.exceptions.DownloadException
|
||||
import com.futo.platformplayer.helpers.FileHelper.Companion.sanitizeFileName
|
||||
@@ -34,6 +44,7 @@ import com.futo.platformplayer.parsers.HLS
|
||||
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
||||
import com.futo.platformplayer.states.StateDownloads
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StatePlugins
|
||||
import com.futo.platformplayer.toHumanBitrate
|
||||
import com.futo.platformplayer.toHumanBytesSpeed
|
||||
import hasAnySource
|
||||
@@ -46,9 +57,12 @@ import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Contextual
|
||||
import kotlinx.serialization.Transient
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.lang.Thread.sleep
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.Executors
|
||||
@@ -56,6 +70,7 @@ import java.util.concurrent.ForkJoinPool
|
||||
import java.util.concurrent.ForkJoinTask
|
||||
import java.util.concurrent.ThreadLocalRandom
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlin.time.times
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
class VideoDownload {
|
||||
@@ -71,12 +86,50 @@ class VideoDownload {
|
||||
|
||||
var targetPixelCount: Long? = null;
|
||||
var targetBitrate: Long? = null;
|
||||
var targetVideoName: String? = null;
|
||||
var targetAudioName: String? = null;
|
||||
|
||||
var videoSource: VideoUrlSource?;
|
||||
var audioSource: AudioUrlSource?;
|
||||
@Contextual
|
||||
@Transient
|
||||
val videoSourceToUse: IVideoSource? get () = if(requiresLiveVideoSource) videoSourceLive as IVideoSource? else videoSource as IVideoSource?;
|
||||
@Contextual
|
||||
@Transient
|
||||
val audioSourceToUse: IAudioSource? get () = if(requiresLiveAudioSource) audioSourceLive as IAudioSource? else audioSource as IAudioSource?;
|
||||
|
||||
var requireVideoSource: Boolean = false;
|
||||
var requireAudioSource: Boolean = false;
|
||||
|
||||
@Contextual
|
||||
@Transient
|
||||
val isVideoDownloadReady: Boolean get() = !requireVideoSource ||
|
||||
((requiresLiveVideoSource && isLiveVideoSourceValid) || (!requiresLiveVideoSource && videoSource != null));
|
||||
@Contextual
|
||||
@Transient
|
||||
val isAudioDownloadReady: Boolean get() = !requireAudioSource ||
|
||||
((requiresLiveAudioSource && isLiveAudioSourceValid) || (!requiresLiveAudioSource && audioSource != null));
|
||||
|
||||
|
||||
var subtitleSource: SubtitleRawSource?;
|
||||
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
|
||||
var prepareTime: OffsetDateTime? = null;
|
||||
|
||||
var requiresLiveVideoSource: Boolean = false;
|
||||
@Contextual
|
||||
@kotlinx.serialization.Transient
|
||||
var videoSourceLive: JSSource? = null;
|
||||
val isLiveVideoSourceValid get() = videoSourceLive?.getUnderlyingObject()?.isClosed?.let { !it } ?: false;
|
||||
|
||||
var requiresLiveAudioSource: Boolean = false;
|
||||
@Contextual
|
||||
@kotlinx.serialization.Transient
|
||||
var audioSourceLive: JSSource? = null;
|
||||
val isLiveAudioSourceValid get() = audioSourceLive?.getUnderlyingObject()?.isClosed?.let { !it } ?: false;
|
||||
|
||||
var hasVideoRequestExecutor: Boolean = false;
|
||||
var hasAudioRequestExecutor: Boolean = false;
|
||||
|
||||
var progress: Double = 0.0;
|
||||
var isCancelled = false;
|
||||
|
||||
@@ -118,14 +171,32 @@ class VideoDownload {
|
||||
this.subtitleSource = null;
|
||||
this.targetPixelCount = targetPixelCount;
|
||||
this.targetBitrate = targetBitrate;
|
||||
this.hasVideoRequestExecutor = video is JSSource && video.hasRequestExecutor;
|
||||
this.requiresLiveVideoSource = false;
|
||||
this.requiresLiveAudioSource = false;
|
||||
this.targetVideoName = videoSource?.name;
|
||||
this.requireVideoSource = targetPixelCount != null
|
||||
this.requireAudioSource = targetBitrate != null; //TODO: May not be a valid check.. can only be determined after live fetch?
|
||||
}
|
||||
constructor(video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: SubtitleRawSource?) {
|
||||
constructor(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: SubtitleRawSource?) {
|
||||
this.video = SerializedPlatformVideo.fromVideo(video);
|
||||
this.videoDetails = SerializedPlatformVideoDetails.fromVideo(video, if (subtitleSource != null) listOf(subtitleSource) else listOf());
|
||||
this.videoSource = VideoUrlSource.fromUrlSource(videoSource);
|
||||
this.audioSource = AudioUrlSource.fromUrlSource(audioSource);
|
||||
this.videoSource = if(videoSource is IVideoUrlSource) VideoUrlSource.fromUrlSource(videoSource) else null;
|
||||
this.audioSource = if(audioSource is IAudioUrlSource) AudioUrlSource.fromUrlSource(audioSource) else null;
|
||||
this.videoSourceLive = if(videoSource is JSSource) videoSource else null;
|
||||
this.audioSourceLive = if(audioSource is JSSource) audioSource else null;
|
||||
this.subtitleSource = subtitleSource;
|
||||
this.prepareTime = OffsetDateTime.now();
|
||||
this.hasVideoRequestExecutor = videoSource is JSSource && videoSource.hasRequestExecutor;
|
||||
this.hasAudioRequestExecutor = audioSource is JSSource && audioSource.hasRequestExecutor;
|
||||
this.requiresLiveVideoSource = this.hasVideoRequestExecutor || (videoSource is JSDashManifestRawSource && videoSource.hasGenerate);
|
||||
this.requiresLiveAudioSource = this.hasAudioRequestExecutor || (audioSource is JSDashManifestRawAudioSource && audioSource.hasGenerate);
|
||||
this.targetVideoName = videoSource?.name;
|
||||
this.targetAudioName = audioSource?.name;
|
||||
this.targetPixelCount = if(videoSource != null) (videoSource.width * videoSource.height).toLong() else null;
|
||||
this.targetBitrate = if(audioSource != null) audioSource.bitrate.toLong() else null;
|
||||
this.requireVideoSource = videoSource != null;
|
||||
this.requireAudioSource = audioSource != null;
|
||||
}
|
||||
|
||||
fun withGroup(groupType: String, groupID: String): VideoDownload {
|
||||
@@ -156,9 +227,21 @@ class VideoDownload {
|
||||
|
||||
suspend fun prepare(client: ManagedHttpClient) {
|
||||
Logger.i(TAG, "VideoDownload Prepare [${name}]");
|
||||
|
||||
//If live sources are required, ensure a live object is present
|
||||
if(requiresLiveVideoSource && !isLiveVideoSourceValid) {
|
||||
videoDetails = null;
|
||||
videoSource = null;
|
||||
videoSourceLive = null;
|
||||
}
|
||||
if(requiresLiveAudioSource && !isLiveAudioSourceValid) {
|
||||
videoDetails = null;
|
||||
audioSource = null;
|
||||
videoSourceLive = null;
|
||||
}
|
||||
if(video == null && videoDetails == null)
|
||||
throw IllegalStateException("Missing information for download to complete");
|
||||
if(targetPixelCount == null && targetBitrate == null && videoSource == null && audioSource == null)
|
||||
if(targetPixelCount == null && targetBitrate == null && videoSource == null && audioSource == null && targetVideoName == null && targetAudioName == null)
|
||||
throw IllegalStateException("No sources or query values set");
|
||||
|
||||
//Fetch full video object and determine source
|
||||
@@ -192,23 +275,35 @@ class VideoDownload {
|
||||
videoSources.add(source)
|
||||
}
|
||||
}
|
||||
var vsource: IVideoSource? = null;
|
||||
|
||||
val vsource = VideoHelper.selectBestVideoSource(videoSources, targetPixelCount!!.toInt(), arrayOf())
|
||||
if(targetVideoName != null)
|
||||
vsource = videoSources.find { x -> x.isDownloadable() && x.name == targetVideoName };
|
||||
if(vsource == null && targetPixelCount == null)
|
||||
throw IllegalStateException("Could not find comparable downloadable video stream (No target pixel count)");
|
||||
if(vsource == null)
|
||||
vsource = VideoHelper.selectBestVideoSource(videoSources, targetPixelCount!!.toInt(), arrayOf())
|
||||
// ?: throw IllegalStateException("Could not find a valid video source for video");
|
||||
if(vsource != null) {
|
||||
if (vsource is IVideoUrlSource)
|
||||
videoSource = VideoUrlSource.fromUrlSource(vsource)
|
||||
else
|
||||
throw DownloadException("Video source is not supported for downloading (yet)", false);
|
||||
|
||||
if(vsource == null) {
|
||||
videoSource = null;
|
||||
if(original.video.videoSources.size == 0)
|
||||
requireVideoSource = false;
|
||||
}
|
||||
else if(vsource is IVideoUrlSource)
|
||||
videoSource = VideoUrlSource.fromUrlSource(vsource)
|
||||
else if(vsource is JSSource && requiresLiveVideoSource)
|
||||
videoSourceLive = vsource;
|
||||
else
|
||||
throw DownloadException("Video source is not supported for downloading (yet) [" + vsource?.javaClass?.name + "]", false);
|
||||
}
|
||||
|
||||
if(audioSource == null && targetBitrate != null) {
|
||||
val audioSources = arrayListOf<IAudioSource>()
|
||||
var audioSources = mutableListOf<IAudioSource>()
|
||||
val video = original.video
|
||||
if (video is VideoUnMuxedSourceDescriptor) {
|
||||
for (source in video.audioSources) {
|
||||
if (source is IHLSManifestSource) {
|
||||
if (source is IHLSManifestAudioSource) {
|
||||
try {
|
||||
val playlistResponse = client.get(source.url)
|
||||
if (playlistResponse.isOk) {
|
||||
@@ -226,25 +321,43 @@ class VideoDownload {
|
||||
}
|
||||
}
|
||||
|
||||
val asource = VideoHelper.selectBestAudioSource(audioSources, arrayOf(), null, targetBitrate)
|
||||
?: if(videoSource != null ) null
|
||||
else throw DownloadException("Could not find a valid video or audio source for download")
|
||||
var asource: IAudioSource? = null;
|
||||
if(targetAudioName != null) {
|
||||
val filteredAudioSources = audioSources.filter { x -> x.isDownloadable() && x.name == targetAudioName }.toTypedArray();
|
||||
if(filteredAudioSources.size == 1)
|
||||
asource = filteredAudioSources.first();
|
||||
else if(filteredAudioSources.size > 1)
|
||||
audioSources = filteredAudioSources.toMutableList();
|
||||
}
|
||||
if(asource == null && targetBitrate == null)
|
||||
throw IllegalStateException("Could not find comparable downloadable video stream (No target bitrate)");
|
||||
if(asource == null)
|
||||
asource = VideoHelper.selectBestAudioSource(audioSources, arrayOf(), null, targetBitrate)
|
||||
?: if(videoSource != null ) null
|
||||
else throw DownloadException("Could not find a valid video or audio source for download")
|
||||
if(asource == null) {
|
||||
audioSource = null;
|
||||
if(!original.video.isUnMuxed || original.video.videoSources.size == 0)
|
||||
requireVideoSource = false;
|
||||
}
|
||||
else if(asource is IAudioUrlSource)
|
||||
audioSource = AudioUrlSource.fromUrlSource(asource)
|
||||
else if(asource is JSSource && requiresLiveAudioSource)
|
||||
audioSourceLive = asource;
|
||||
else
|
||||
throw DownloadException("Audio source is not supported for downloading (yet)", false);
|
||||
throw DownloadException("Audio source is not supported for downloading (yet) [" + asource?.javaClass?.name + "]", false);
|
||||
}
|
||||
|
||||
if(videoSource == null && audioSource == null)
|
||||
throw DownloadException("No valid sources found for video/audio");
|
||||
if(!isVideoDownloadReady)
|
||||
throw DownloadException("No valid sources found for video");
|
||||
if(!isAudioDownloadReady)
|
||||
throw DownloadException("No valid sources found for audio");
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun download(context: Context, client: ManagedHttpClient, onProgress: ((Double) -> Unit)? = null) = coroutineScope {
|
||||
Logger.i(TAG, "VideoDownload Download [${name}]");
|
||||
if(videoDetails == null || (videoSource == null && audioSource == null))
|
||||
if(videoDetails == null || (videoSourceToUse == null && audioSourceToUse == null))
|
||||
throw IllegalStateException("Missing information for download to complete");
|
||||
val downloadDir = StateDownloads.instance.getDownloadsDirectory();
|
||||
|
||||
@@ -253,12 +366,19 @@ class VideoDownload {
|
||||
|
||||
if(isCancelled) throw CancellationException("Download got cancelled");
|
||||
|
||||
if(videoSource != null) {
|
||||
videoFileName = "${videoDetails!!.id.value!!} [${videoSource!!.width}x${videoSource!!.height}].${videoContainerToExtension(videoSource!!.container)}".sanitizeFileName();
|
||||
val actualVideoSource = if(requiresLiveVideoSource && videoSourceLive is IVideoSource)
|
||||
videoSourceLive as IVideoSource?;
|
||||
else videoSource;
|
||||
val actualAudioSource = if(requiresLiveAudioSource && audioSourceLive is IAudioSource)
|
||||
audioSourceLive as IAudioSource?;
|
||||
else audioSource;
|
||||
|
||||
if(actualVideoSource != null) {
|
||||
videoFileName = "${videoDetails!!.id.value!!} [${actualVideoSource!!.width}x${actualVideoSource!!.height}].${videoContainerToExtension(actualVideoSource!!.container)}".sanitizeFileName();
|
||||
videoFilePath = File(downloadDir, videoFileName!!).absolutePath;
|
||||
}
|
||||
if(audioSource != null) {
|
||||
audioFileName = "${videoDetails!!.id.value!!} [${audioSource!!.language}-${audioSource!!.bitrate}].${audioContainerToExtension(audioSource!!.container)}".sanitizeFileName();
|
||||
if(actualAudioSource != null) {
|
||||
audioFileName = "${videoDetails!!.id.value!!} [${actualAudioSource!!.language}-${actualAudioSource!!.bitrate}].${audioContainerToExtension(actualAudioSource!!.container)}".sanitizeFileName();
|
||||
audioFilePath = File(downloadDir, audioFileName!!).absolutePath;
|
||||
}
|
||||
if(subtitleSource != null) {
|
||||
@@ -273,10 +393,11 @@ class VideoDownload {
|
||||
var lastAudioLength: Long = 0;
|
||||
var lastAudioRead: Long = 0;
|
||||
|
||||
if(videoSource != null) {
|
||||
if(actualVideoSource != null) {
|
||||
sourcesToDownload.add(async {
|
||||
Logger.i(TAG, "Started downloading video");
|
||||
|
||||
var lastEmit = 0L;
|
||||
val progressCallback = { length: Long, totalRead: Long, speed: Long ->
|
||||
synchronized(progressLock) {
|
||||
lastVideoLength = length;
|
||||
@@ -289,23 +410,34 @@ class VideoDownload {
|
||||
val total = lastVideoRead + lastAudioRead;
|
||||
if(totalLength > 0) {
|
||||
val percentage = (total / totalLength.toDouble());
|
||||
onProgress?.invoke(percentage);
|
||||
progress = percentage;
|
||||
onProgressChanged.emit(percentage);
|
||||
|
||||
val now = System.currentTimeMillis();
|
||||
if(now - lastEmit > 200) {
|
||||
lastEmit = System.currentTimeMillis();
|
||||
onProgress?.invoke(percentage);
|
||||
onProgressChanged.emit(percentage);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
videoFileSize = when (videoSource!!.container) {
|
||||
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Video", client, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
|
||||
else -> downloadFileSource("Video", client, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
|
||||
if(actualVideoSource is IVideoUrlSource)
|
||||
videoFileSize = when (videoSource!!.container) {
|
||||
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Video", client, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
|
||||
else -> downloadFileSource("Video", client, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
|
||||
}
|
||||
else if(actualVideoSource is JSDashManifestRawSource) {
|
||||
videoFileSize = downloadDashFileSource("Video", client, actualVideoSource, File(downloadDir, videoFileName!!), progressCallback);
|
||||
}
|
||||
else throw NotImplementedError("NotImplemented video download: " + actualVideoSource.javaClass.name);
|
||||
});
|
||||
}
|
||||
if(audioSource != null) {
|
||||
if(actualAudioSource != null) {
|
||||
sourcesToDownload.add(async {
|
||||
Logger.i(TAG, "Started downloading audio");
|
||||
|
||||
var lastEmit = 0L;
|
||||
val progressCallback = { length: Long, totalRead: Long, speed: Long ->
|
||||
synchronized(progressLock) {
|
||||
lastAudioLength = length;
|
||||
@@ -318,17 +450,27 @@ class VideoDownload {
|
||||
val total = lastVideoRead + lastAudioRead;
|
||||
if(totalLength > 0) {
|
||||
val percentage = (total / totalLength.toDouble());
|
||||
onProgress?.invoke(percentage);
|
||||
progress = percentage;
|
||||
onProgressChanged.emit(percentage);
|
||||
|
||||
val now = System.currentTimeMillis();
|
||||
if(now - lastEmit > 200) {
|
||||
lastEmit = System.currentTimeMillis();
|
||||
onProgress?.invoke(percentage);
|
||||
onProgressChanged.emit(percentage);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
audioFileSize = when (audioSource!!.container) {
|
||||
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Audio", client, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
|
||||
else -> downloadFileSource("Audio", client, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
|
||||
if(actualAudioSource is IAudioUrlSource)
|
||||
audioFileSize = when (audioSource!!.container) {
|
||||
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Audio", client, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
|
||||
else -> downloadFileSource("Audio", client, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
|
||||
}
|
||||
else if(actualAudioSource is JSDashManifestRawAudioSource) {
|
||||
audioFileSize = downloadDashFileSource("Audio", client, actualAudioSource, File(downloadDir, audioFileName!!), progressCallback);
|
||||
}
|
||||
else throw NotImplementedError("NotImplemented audio download: " + actualAudioSource.javaClass.name);
|
||||
});
|
||||
}
|
||||
if (subtitleSource != null) {
|
||||
@@ -398,15 +540,20 @@ class VideoDownload {
|
||||
|
||||
Logger.i(TAG, "Download '$name' segment $index Sequential");
|
||||
val segmentFile = File(context.cacheDir, "segment-${UUID.randomUUID()}")
|
||||
segmentFiles.add(segmentFile)
|
||||
val outputStream = segmentFile.outputStream()
|
||||
try {
|
||||
segmentFiles.add(segmentFile)
|
||||
|
||||
val segmentLength = downloadSource_Sequential(client, segmentFile.outputStream(), segment.uri) { segmentLength, totalRead, lastSpeed ->
|
||||
val averageSegmentLength = if (index == 0) segmentLength else downloadedTotalLength / index
|
||||
val expectedTotalLength = averageSegmentLength * (variantPlaylist.segments.size - 1) + segmentLength
|
||||
onProgress(expectedTotalLength, downloadedTotalLength + totalRead, lastSpeed)
|
||||
val segmentLength = downloadSource_Sequential(client, outputStream, segment.uri) { segmentLength, totalRead, lastSpeed ->
|
||||
val averageSegmentLength = if (index == 0) segmentLength else downloadedTotalLength / index
|
||||
val expectedTotalLength = averageSegmentLength * (variantPlaylist.segments.size - 1) + segmentLength
|
||||
onProgress(expectedTotalLength, downloadedTotalLength + totalRead, lastSpeed)
|
||||
}
|
||||
|
||||
downloadedTotalLength += segmentLength
|
||||
} finally {
|
||||
outputStream.close()
|
||||
}
|
||||
|
||||
downloadedTotalLength += segmentLength
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Combining segments into $targetFile");
|
||||
@@ -473,6 +620,86 @@ class VideoDownload {
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadDashFileSource(name: String, client: ManagedHttpClient, source: IJSDashManifestRawSource, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||
if(targetFile.exists())
|
||||
targetFile.delete();
|
||||
|
||||
targetFile.createNewFile();
|
||||
|
||||
val sourceLength: Long?;
|
||||
val fileStream = FileOutputStream(targetFile);
|
||||
|
||||
try{
|
||||
var manifest = source.manifest;
|
||||
if(source.hasGenerate)
|
||||
manifest = source.generate();
|
||||
if(manifest == null)
|
||||
throw IllegalStateException("No manifest after generation");
|
||||
|
||||
//TODO: Temporary naive assume single-sourced dash
|
||||
val foundTemplate = REGEX_DASH_TEMPLATE.find(manifest);
|
||||
if(foundTemplate == null || foundTemplate.groupValues.size != 3)
|
||||
throw IllegalStateException("No SegmentTemplate found in manifest (unsupported dash?)");
|
||||
val foundTemplateUrl = foundTemplate.groupValues[1];
|
||||
val foundCues = REGEX_DASH_CUE.findAll(foundTemplate.groupValues[2]);
|
||||
if(foundCues.count() <= 0)
|
||||
throw IllegalStateException("No Cues found in manifest (unsupported dash?)");
|
||||
|
||||
val executor = if(source is JSSource && source.hasRequestExecutor)
|
||||
source.getRequestExecutor();
|
||||
else
|
||||
null;
|
||||
val speedTracker = SpeedTracker(1000);
|
||||
|
||||
Logger.i(TAG, "Download $name Dash, CueCount: " + foundCues.count().toString());
|
||||
|
||||
var written = 0;
|
||||
var indexCounter = 0;
|
||||
onProgress(foundCues.count().toLong(), 0, 0);
|
||||
for(cue in foundCues) {
|
||||
val t = cue.groupValues[1];
|
||||
val d = cue.groupValues[2];
|
||||
|
||||
val url = foundTemplateUrl.replace("\$Number\$", indexCounter.toString());
|
||||
|
||||
val data = if(executor != null)
|
||||
executor.executeRequest(url, mapOf());
|
||||
else {
|
||||
val resp = client.get(url, mutableMapOf());
|
||||
if(!resp.isOk)
|
||||
throw IllegalStateException("Dash request failed for index " + indexCounter.toString() + ", with code: " + resp.code.toString());
|
||||
resp.body!!.bytes()
|
||||
}
|
||||
fileStream.write(data, 0, data.size);
|
||||
speedTracker.addWork(data.size.toLong());
|
||||
written += data.size;
|
||||
|
||||
onProgress(foundCues.count().toLong(), indexCounter.toLong(), speedTracker.lastSpeed);
|
||||
|
||||
indexCounter++;
|
||||
}
|
||||
sourceLength = written.toLong();
|
||||
|
||||
Logger.i(TAG, "$name downloadSource Finished");
|
||||
}
|
||||
catch(ioex: IOException) {
|
||||
if(targetFile.exists() ?: false)
|
||||
targetFile.delete();
|
||||
if(ioex.message?.contains("ENOSPC") ?: false)
|
||||
throw Exception("Not enough space on device", ioex);
|
||||
else
|
||||
throw ioex;
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
if(targetFile.exists() ?: false)
|
||||
targetFile.delete();
|
||||
throw ex;
|
||||
}
|
||||
finally {
|
||||
fileStream.close();
|
||||
}
|
||||
return sourceLength!!;
|
||||
}
|
||||
private fun downloadFileSource(name: String, client: ManagedHttpClient, videoUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||
if(targetFile.exists())
|
||||
targetFile.delete();
|
||||
@@ -484,17 +711,25 @@ class VideoDownload {
|
||||
|
||||
try{
|
||||
val head = client.tryHead(videoUrl);
|
||||
val relatedPlugin = (video?.url ?: videoDetails?.url)?.let { StatePlatform.instance.getContentClient(it) }?.let { if(it is JSClient) it else null };
|
||||
if(Settings.instance.downloads.byteRangeDownload && head?.containsKey("accept-ranges") == true && head.containsKey("content-length"))
|
||||
{
|
||||
val concurrency = Settings.instance.downloads.getByteRangeThreadCount();
|
||||
Logger.i(TAG, "Download $name ByteRange Parallel (${concurrency})");
|
||||
val maxParallel = if(relatedPlugin != null && relatedPlugin.config.maxDownloadParallelism > 0)
|
||||
relatedPlugin.config.maxDownloadParallelism else 99;
|
||||
val concurrency = Math.min(maxParallel, Settings.instance.downloads.getByteRangeThreadCount());
|
||||
Logger.i(TAG, "Download $name ByteRange Parallel (${concurrency}): " + videoUrl);
|
||||
sourceLength = head["content-length"]!!.toLong();
|
||||
onProgress(sourceLength, 0, 0);
|
||||
downloadSource_Ranges(name, client, fileStream, videoUrl, sourceLength, 1024*512, concurrency, onProgress);
|
||||
}
|
||||
else {
|
||||
Logger.i(TAG, "Download $name Sequential");
|
||||
sourceLength = downloadSource_Sequential(client, fileStream, videoUrl, onProgress);
|
||||
try {
|
||||
sourceLength = downloadSource_Sequential(client, fileStream, videoUrl, onProgress);
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to download sequentially (url = $videoUrl)")
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
Logger.i(TAG, "$name downloadSource Finished");
|
||||
@@ -518,17 +753,19 @@ class VideoDownload {
|
||||
return sourceLength!!;
|
||||
}
|
||||
private fun downloadSource_Sequential(client: ManagedHttpClient, fileStream: FileOutputStream, url: String, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||
val progressRate: Int = 4096 * 25;
|
||||
val progressRate: Int = 4096 * 5;
|
||||
var lastProgressCount: Int = 0;
|
||||
val speedRate: Int = 4096 * 25;
|
||||
val speedRate: Int = 4096 * 5;
|
||||
var readSinceLastSpeedTest: Long = 0;
|
||||
var timeSinceLastSpeedTest: Long = System.currentTimeMillis();
|
||||
|
||||
var lastSpeed: Long = 0;
|
||||
|
||||
val result = client.get(url);
|
||||
if (!result.isOk)
|
||||
if (!result.isOk) {
|
||||
result.body?.close()
|
||||
throw IllegalStateException("Failed to download source. Web[${result.code}] Error");
|
||||
}
|
||||
if (result.body == null)
|
||||
throw IllegalStateException("Failed to download source. Web[${result.code}] No response");
|
||||
|
||||
@@ -536,41 +773,114 @@ class VideoDownload {
|
||||
val sourceStream = result.body.byteStream();
|
||||
|
||||
var totalRead: Long = 0;
|
||||
var read: Int;
|
||||
try {
|
||||
var read: Int;
|
||||
val buffer = ByteArray(4096);
|
||||
|
||||
val buffer = ByteArray(4096);
|
||||
do {
|
||||
read = sourceStream.read(buffer);
|
||||
if (read < 0)
|
||||
break;
|
||||
|
||||
do {
|
||||
read = sourceStream.read(buffer);
|
||||
if (read < 0)
|
||||
break;
|
||||
fileStream.write(buffer, 0, read);
|
||||
|
||||
fileStream.write(buffer, 0, read);
|
||||
totalRead += read;
|
||||
|
||||
totalRead += read;
|
||||
readSinceLastSpeedTest += read;
|
||||
if (totalRead.toDouble() / progressRate > lastProgressCount) {
|
||||
onProgress(sourceLength, totalRead, lastSpeed);
|
||||
lastProgressCount++;
|
||||
}
|
||||
if (readSinceLastSpeedTest > speedRate) {
|
||||
val lastSpeedTime = timeSinceLastSpeedTest;
|
||||
timeSinceLastSpeedTest = System.currentTimeMillis();
|
||||
val timeSince = timeSinceLastSpeedTest - lastSpeedTime;
|
||||
if (timeSince > 0)
|
||||
lastSpeed = (readSinceLastSpeedTest / (timeSince / 1000.0)).toLong();
|
||||
readSinceLastSpeedTest = 0;
|
||||
}
|
||||
|
||||
readSinceLastSpeedTest += read;
|
||||
if (totalRead / progressRate > lastProgressCount) {
|
||||
onProgress(sourceLength, totalRead, lastSpeed);
|
||||
lastProgressCount++;
|
||||
}
|
||||
if (readSinceLastSpeedTest > speedRate) {
|
||||
val lastSpeedTime = timeSinceLastSpeedTest;
|
||||
timeSinceLastSpeedTest = System.currentTimeMillis();
|
||||
val timeSince = timeSinceLastSpeedTest - lastSpeedTime;
|
||||
if (timeSince > 0)
|
||||
lastSpeed = (readSinceLastSpeedTest / (timeSince / 1000.0)).toLong();
|
||||
readSinceLastSpeedTest = 0;
|
||||
}
|
||||
if (isCancelled)
|
||||
throw CancellationException("Cancelled");
|
||||
} while (read > 0);
|
||||
} finally {
|
||||
sourceStream.close()
|
||||
result.body.close()
|
||||
}
|
||||
|
||||
if (isCancelled)
|
||||
throw CancellationException("Cancelled");
|
||||
} while (read > 0);
|
||||
|
||||
lastSpeed = 0;
|
||||
onProgress(sourceLength, totalRead, 0);
|
||||
return sourceLength;
|
||||
}
|
||||
/*private fun downloadSource_Sequential(client: ManagedHttpClient, fileStream: FileOutputStream, url: String, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||
val progressRate: Int = 4096 * 25
|
||||
var lastProgressCount: Int = 0
|
||||
val speedRate: Int = 4096 * 25
|
||||
var readSinceLastSpeedTest: Long = 0
|
||||
var timeSinceLastSpeedTest: Long = System.currentTimeMillis()
|
||||
|
||||
var lastSpeed: Long = 0
|
||||
|
||||
var totalRead: Long = 0
|
||||
var sourceLength: Long
|
||||
val buffer = ByteArray(4096)
|
||||
|
||||
var isPartialDownload = false
|
||||
var result: ManagedHttpClient.Response? = null
|
||||
do {
|
||||
result = client.get(url, if (isPartialDownload) hashMapOf("Range" to "bytes=$totalRead-") else hashMapOf())
|
||||
if (isPartialDownload) {
|
||||
if (result.code != 206)
|
||||
throw IllegalStateException("Failed to download source, byte range fallback failed. Web[${result.code}] Error")
|
||||
} else {
|
||||
if (!result.isOk)
|
||||
throw IllegalStateException("Failed to download source. Web[${result.code}] Error")
|
||||
}
|
||||
if (result.body == null)
|
||||
throw IllegalStateException("Failed to download source. Web[${result.code}] No response")
|
||||
|
||||
isPartialDownload = true
|
||||
sourceLength = result.body!!.contentLength()
|
||||
val sourceStream = result.body!!.byteStream()
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
val read = sourceStream.read(buffer)
|
||||
if (read <= 0) {
|
||||
break
|
||||
}
|
||||
|
||||
fileStream.write(buffer, 0, read)
|
||||
|
||||
totalRead += read
|
||||
readSinceLastSpeedTest += read
|
||||
|
||||
if (totalRead / progressRate > lastProgressCount) {
|
||||
onProgress(sourceLength, totalRead, lastSpeed)
|
||||
lastProgressCount++
|
||||
}
|
||||
if (readSinceLastSpeedTest > speedRate) {
|
||||
val lastSpeedTime = timeSinceLastSpeedTest
|
||||
timeSinceLastSpeedTest = System.currentTimeMillis()
|
||||
val timeSince = timeSinceLastSpeedTest - lastSpeedTime
|
||||
if (timeSince > 0)
|
||||
lastSpeed = (readSinceLastSpeedTest / (timeSince / 1000.0)).toLong()
|
||||
readSinceLastSpeedTest = 0
|
||||
}
|
||||
|
||||
if (isCancelled)
|
||||
throw CancellationException("Cancelled")
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Sequential download was interrupted, trying to fallback to byte ranges", e)
|
||||
} finally {
|
||||
sourceStream.close()
|
||||
result.body?.close()
|
||||
}
|
||||
} while (totalRead < sourceLength)
|
||||
|
||||
onProgress(sourceLength, totalRead, 0)
|
||||
return sourceLength
|
||||
}*/
|
||||
private fun downloadSource_Ranges(name: String, client: ManagedHttpClient, fileStream: FileOutputStream, url: String, sourceLength: Long, rangeSize: Int, concurrency: Int = 1, onProgress: (Long, Long, Long) -> Unit) {
|
||||
val progressRate: Int = 4096 * 5;
|
||||
var lastProgressCount: Int = 0;
|
||||
@@ -643,23 +953,47 @@ class VideoDownload {
|
||||
return tasks.map { it.get() };
|
||||
}
|
||||
private fun requestByteRange(client: ManagedHttpClient, url: String, rangeStart: Long, rangeEnd: Long): Triple<ByteArray, Long, Long> {
|
||||
val toRead = rangeEnd - rangeStart;
|
||||
val req = client.get(url, mutableMapOf(Pair("Range", "bytes=${rangeStart}-${rangeEnd}")));
|
||||
if(!req.isOk)
|
||||
throw IllegalStateException("Range request failed Code [${req.code}] due to: ${req.message}");
|
||||
if(req.body == null)
|
||||
throw IllegalStateException("Range request failed, No body");
|
||||
val read = req.body.contentLength();
|
||||
var retryCount = 0
|
||||
var lastException: Throwable? = null
|
||||
|
||||
if(read < toRead)
|
||||
throw IllegalStateException("Byte-Range request attempted to provide less (${read} < ${toRead})");
|
||||
while (retryCount <= 3) {
|
||||
try {
|
||||
val toRead = rangeEnd - rangeStart;
|
||||
val req = client.get(url, mutableMapOf(Pair("Range", "bytes=${rangeStart}-${rangeEnd}")));
|
||||
if (!req.isOk) {
|
||||
val bodyString = req.body?.string()
|
||||
req.body?.close()
|
||||
throw IllegalStateException("Range request failed Code [${req.code}] due to: ${req.message}");
|
||||
}
|
||||
if (req.body == null)
|
||||
throw IllegalStateException("Range request failed, No body");
|
||||
val read = req.body.contentLength();
|
||||
|
||||
return Triple(req.body.bytes(), rangeStart, rangeEnd);
|
||||
if (read < toRead)
|
||||
throw IllegalStateException("Byte-Range request attempted to provide less (${read} < ${toRead})");
|
||||
|
||||
return Triple(req.body.bytes(), rangeStart, rangeEnd);
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to download range (url=${url} bytes=${rangeStart}-${rangeEnd})", e)
|
||||
|
||||
retryCount++
|
||||
lastException = e
|
||||
|
||||
sleep(when (retryCount) {
|
||||
1 -> 1000 + ((Math.random() * 300.0).toLong() - 150)
|
||||
2 -> 2000 + ((Math.random() * 300.0).toLong() - 150)
|
||||
3 -> 4000 + ((Math.random() * 300.0).toLong() - 150)
|
||||
else -> 1000 + ((Math.random() * 300.0).toLong() - 150)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
throw lastException!!
|
||||
}
|
||||
|
||||
fun validate() {
|
||||
Logger.i(TAG, "VideoDownload Validate [${name}]");
|
||||
if(videoSource != null) {
|
||||
if(videoSourceToUse != null) {
|
||||
if(videoFilePath == null)
|
||||
throw IllegalStateException("Missing video file name after download");
|
||||
val expectedFile = File(videoFilePath!!);
|
||||
@@ -670,7 +1004,7 @@ class VideoDownload {
|
||||
throw IllegalStateException("Expected size [${videoFileSize}], but found ${expectedFile.length()}");
|
||||
}
|
||||
}
|
||||
if(audioSource != null) {
|
||||
if(audioSourceToUse != null) {
|
||||
if(audioFilePath == null)
|
||||
throw IllegalStateException("Missing audio file name after download");
|
||||
val expectedFile = File(audioFilePath!!);
|
||||
@@ -692,15 +1026,15 @@ class VideoDownload {
|
||||
fun complete() {
|
||||
Logger.i(TAG, "VideoDownload Complete [${name}]");
|
||||
val existing = StateDownloads.instance.getCachedVideo(id);
|
||||
val localVideoSource = videoFilePath?.let { LocalVideoSource.fromSource(videoSource!!, it, videoFileSize ?: 0) };
|
||||
val localAudioSource = audioFilePath?.let { LocalAudioSource.fromSource(audioSource!!, it, audioFileSize ?: 0) };
|
||||
val localVideoSource = videoFilePath?.let { LocalVideoSource.fromSource(videoSourceToUse!!, it, videoFileSize ?: 0) };
|
||||
val localAudioSource = audioFilePath?.let { LocalAudioSource.fromSource(audioSourceToUse!!, it, audioFileSize ?: 0) };
|
||||
val localSubtitleSource = subtitleFilePath?.let { LocalSubtitleSource.fromSource(subtitleSource!!, it) };
|
||||
|
||||
if(localVideoSource != null && videoSource != null && videoSource is IStreamMetaDataSource)
|
||||
localVideoSource.streamMetaData = (videoSource as IStreamMetaDataSource).streamMetaData;
|
||||
if(localVideoSource != null && videoSourceToUse != null && videoSourceToUse is IStreamMetaDataSource)
|
||||
localVideoSource.streamMetaData = (videoSourceToUse as IStreamMetaDataSource).streamMetaData;
|
||||
|
||||
if(localAudioSource != null && audioSource != null && audioSource is IStreamMetaDataSource)
|
||||
localAudioSource.streamMetaData = (audioSource as IStreamMetaDataSource).streamMetaData;
|
||||
if(localAudioSource != null && audioSourceToUse != null && audioSourceToUse is IStreamMetaDataSource)
|
||||
localAudioSource.streamMetaData = (audioSourceToUse as IStreamMetaDataSource).streamMetaData;
|
||||
|
||||
if(existing != null) {
|
||||
existing.videoSerialized = videoDetails!!;
|
||||
@@ -757,6 +1091,9 @@ class VideoDownload {
|
||||
const val GROUP_PLAYLIST = "Playlist";
|
||||
const val GROUP_WATCHLATER= "WatchLater";
|
||||
|
||||
val REGEX_DASH_TEMPLATE = Regex("<SegmentTemplate .*?media=\"(.*?)\".*?>(.*?)<\\/SegmentTemplate>", RegexOption.DOT_MATCHES_ALL);
|
||||
val REGEX_DASH_CUE = Regex("<S .*?t=\"([0-9]*?)\".*?d=\"([0-9]*?)\".*?\\/>", RegexOption.DOT_MATCHES_ALL);
|
||||
|
||||
fun videoContainerToExtension(container: String): String? {
|
||||
if (container.contains("video/mp4") || container == "application/vnd.apple.mpegurl")
|
||||
return "mp4";
|
||||
@@ -803,4 +1140,27 @@ class VideoDownload {
|
||||
return "subtitle";
|
||||
}
|
||||
}
|
||||
|
||||
class SpeedTracker {
|
||||
private val segmentStart: Long;
|
||||
private val intervalMs: Long;
|
||||
private var workDone: Long;
|
||||
var lastSpeed: Long;
|
||||
constructor(intervalMs: Long) {
|
||||
segmentStart = System.currentTimeMillis();
|
||||
this.intervalMs = intervalMs;
|
||||
this.workDone = 0;
|
||||
this.lastSpeed = 0;
|
||||
}
|
||||
fun addWork(work: Long) {
|
||||
val now = System.currentTimeMillis();
|
||||
if((now - segmentStart) > intervalMs)
|
||||
{
|
||||
lastSpeed = workDone;
|
||||
workDone = 0;
|
||||
}
|
||||
workDone += work;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import com.caoccao.javet.exceptions.JavetException
|
||||
import com.caoccao.javet.exceptions.JavetExecutionException
|
||||
import com.caoccao.javet.interop.V8Host
|
||||
import com.caoccao.javet.interop.V8Runtime
|
||||
import com.caoccao.javet.interop.options.V8Flags
|
||||
import com.caoccao.javet.interop.options.V8RuntimeOptions
|
||||
import com.caoccao.javet.values.V8Value
|
||||
import com.caoccao.javet.values.primitive.V8ValueBoolean
|
||||
import com.caoccao.javet.values.primitive.V8ValueInteger
|
||||
@@ -133,9 +135,10 @@ class V8Plugin {
|
||||
synchronized(_runtimeLock) {
|
||||
if (_runtime != null)
|
||||
return;
|
||||
|
||||
//V8RuntimeOptions.V8_FLAGS.setUseStrict(true);
|
||||
val host = V8Host.getV8Instance();
|
||||
val options = host.jsRuntimeType.getRuntimeOptions();
|
||||
|
||||
_runtime = host.createV8Runtime(options);
|
||||
if (!host.isIsolateCreated)
|
||||
throw IllegalStateException("Isolate not created");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -68,6 +68,10 @@ class PackageDOMParser : V8Package {
|
||||
return result;
|
||||
}
|
||||
@V8Property
|
||||
fun parentElement(): DOMNode? {
|
||||
return parentNode();
|
||||
}
|
||||
@V8Property
|
||||
fun attributes(): Map<String, String> = _element.attributes().associate { Pair(it.key, it.value) }
|
||||
@V8Property
|
||||
fun innerHTML(): String = _element.html();
|
||||
@@ -76,6 +80,8 @@ class PackageDOMParser : V8Package {
|
||||
@V8Property
|
||||
fun textContent(): String = _element.text();
|
||||
@V8Property
|
||||
fun tagName(): String = _element.tagName().uppercase();
|
||||
@V8Property
|
||||
fun text(): String = _element.text().ifEmpty { data() };
|
||||
@V8Property
|
||||
fun data(): String = _element.data();
|
||||
|
||||
@@ -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,44 @@ class PackageHttp: V8Package {
|
||||
}
|
||||
|
||||
@V8Function
|
||||
fun request(method: String, url: String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false) : BridgeHttpResponse {
|
||||
fun request(method: String, url: String, headers: MutableMap<String, String> = 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<String, String> = HashMap(), useAuth: Boolean = false) : BridgeHttpResponse {
|
||||
fun requestWithBody(method: String, url: String, body:String, headers: MutableMap<String, String> = 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<String, String> = HashMap(), useAuth: Boolean = false) : BridgeHttpResponse {
|
||||
fun GET(url: String, headers: MutableMap<String, String> = 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<String, String> = HashMap(), useAuth: Boolean = false) : BridgeHttpResponse {
|
||||
return if(useAuth)
|
||||
_packageClientAuth.POST(url, body, headers)
|
||||
fun POST(url: String, body: Any, headers: MutableMap<String, String> = 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 String)
|
||||
return client.POST(url, body, 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 +129,19 @@ class PackageHttp: V8Package {
|
||||
}
|
||||
}
|
||||
|
||||
interface IBridgeHttpResponse {
|
||||
val url: String;
|
||||
val code: Int;
|
||||
val headers: Map<String, List<String>>?;
|
||||
}
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
class BridgeHttpResponse(val url: String, val code: Int, val body: String?, val headers: Map<String, List<String>>? = null) : IV8Convertable {
|
||||
class BridgeHttpStringResponse(
|
||||
override val url: String,
|
||||
override val code: Int, val
|
||||
body: String?,
|
||||
override val headers: Map<String, List<String>>? = null) : IV8Convertable, IBridgeHttpResponse {
|
||||
|
||||
val isOk = code >= 200 && code < 300;
|
||||
|
||||
override fun toV8(runtime: V8Runtime): V8Value? {
|
||||
@@ -125,6 +154,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<String, List<String>>?;
|
||||
|
||||
val isOk: Boolean;
|
||||
|
||||
constructor(url: String, code: Int, body: ByteArray? = null, headers: Map<String, List<String>>? = 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 +207,12 @@ class PackageHttp: V8Package {
|
||||
fun POST(url: String, body: String, headers: MutableMap<String, String> = 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 +235,14 @@ class PackageHttp: V8Package {
|
||||
|
||||
//Finalizer
|
||||
@V8Function
|
||||
fun execute(): List<BridgeHttpResponse> {
|
||||
fun execute(): List<IBridgeHttpResponse?> {
|
||||
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();
|
||||
@@ -230,65 +298,116 @@ class PackageHttp: V8Package {
|
||||
if(_client is JSHttpClient)
|
||||
_client.doAllowNewCookies = allow;
|
||||
}
|
||||
@V8Function
|
||||
fun setTimeout(timeoutMs: Int) {
|
||||
if(_client is JSHttpClient) {
|
||||
_client.setTimeout(timeoutMs.toLong());
|
||||
}
|
||||
}
|
||||
|
||||
@V8Function
|
||||
fun request(method: String, url: String, headers: MutableMap<String, String> = HashMap()) : BridgeHttpResponse {
|
||||
fun request(method: String, url: String, headers: MutableMap<String, String> = 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<String, String> = HashMap()) : BridgeHttpResponse {
|
||||
fun requestWithBody(method: String, url: String, body:String, headers: MutableMap<String, String> = 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<String, String> = HashMap()) : BridgeHttpResponse {
|
||||
fun GET(url: String, headers: MutableMap<String, String> = 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<String, String> = HashMap()) : BridgeHttpResponse {
|
||||
fun POST(url: String, body: String, headers: MutableMap<String, String> = 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<String, String> = 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 +507,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 +633,25 @@ class PackageHttp: V8Package {
|
||||
val url: String,
|
||||
val headers: MutableMap<String, String>,
|
||||
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";
|
||||
|
||||
+2
-2
@@ -221,8 +221,8 @@ class CommentsFragment : MainFragment() {
|
||||
|
||||
Logger.i(TAG, "onAuthorClick: " + c.author.id.value);
|
||||
if(c.author.id.value?.startsWith("polycentric://") ?: false) {
|
||||
//val navUrl = "https://harbor.social/" + c.author.id.value?.substring("polycentric://".length);
|
||||
val navUrl = "https://polycentric.io/user/" + c.author.id.value?.substring("polycentric://".length);
|
||||
val navUrl = "https://harbor.social/" + c.author.id.value?.substring("polycentric://".length);
|
||||
//val navUrl = "https://polycentric.io/user/" + c.author.id.value?.substring("polycentric://".length);
|
||||
_fragment.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(navUrl)))
|
||||
//_fragment.navigate<BrowserFragment>(navUrl);
|
||||
}
|
||||
|
||||
+13
-4
@@ -118,8 +118,13 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
|
||||
|
||||
private fun showVideoOptionsOverlay(content: IPlatformVideo) {
|
||||
_overlayContainer.let {
|
||||
_videoOptionsOverlay = UISlideOverlays.showVideoOptionsOverlay(content, it, SlideUpMenuItem(context, R.drawable.ic_visibility_off, context.getString(R.string.hide), context.getString(R.string.hide_from_home), "hide",
|
||||
{ StateMeta.instance.addHiddenVideo(content.url);
|
||||
_videoOptionsOverlay = UISlideOverlays.showVideoOptionsOverlay(content, it, SlideUpMenuItem(
|
||||
context,
|
||||
R.drawable.ic_visibility_off,
|
||||
context.getString(R.string.hide),
|
||||
context.getString(R.string.hide_from_home),
|
||||
tag = "hide",
|
||||
call = { StateMeta.instance.addHiddenVideo(content.url);
|
||||
if (fragment is HomeFragment) {
|
||||
val removeIndex = recyclerData.results.indexOf(content);
|
||||
if (removeIndex >= 0) {
|
||||
@@ -128,8 +133,12 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
|
||||
}
|
||||
}
|
||||
}),
|
||||
SlideUpMenuItem(context, R.drawable.ic_playlist, context.getString(R.string.play_feed_as_queue), context.getString(R.string.play_entire_feed), "playFeed",
|
||||
{
|
||||
SlideUpMenuItem(context,
|
||||
R.drawable.ic_playlist,
|
||||
context.getString(R.string.play_feed_as_queue),
|
||||
context.getString(R.string.play_entire_feed),
|
||||
tag = "playFeed",
|
||||
call = {
|
||||
val newQueue = listOf(content) + recyclerData.results
|
||||
.filterIsInstance<IPlatformVideo>()
|
||||
.filter { it != content };
|
||||
|
||||
@@ -8,7 +8,6 @@ import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.fragment.mainactivity.MainActivityFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.topbar.TopFragment
|
||||
import com.futo.platformplayer.listeners.OrientationManager
|
||||
|
||||
abstract class MainFragment : MainActivityFragment() {
|
||||
open val isMainView: Boolean = false;
|
||||
@@ -46,10 +45,6 @@ abstract class MainFragment : MainActivityFragment() {
|
||||
|
||||
}
|
||||
|
||||
open fun onOrientationChanged(orientation: OrientationManager.Orientation) {
|
||||
|
||||
}
|
||||
|
||||
open fun onBackPressed(): Boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
+25
-13
@@ -109,19 +109,31 @@ class PlaylistFragment : MainFragment() {
|
||||
val reconstruction = StatePlaylists.instance.playlistStore.getReconstructionString(playlist);
|
||||
|
||||
UISlideOverlays.showOverlay(overlayContainer, context.getString(R.string.playlist) + " [${playlist.name}]", null, {},
|
||||
SlideUpMenuItem(context, R.drawable.ic_list, context.getString(R.string.share_as_text), context.getString(R.string.share_as_a_list_of_video_urls), 1, {
|
||||
_fragment.startActivity(ShareCompat.IntentBuilder(context)
|
||||
.setType("text/plain")
|
||||
.setText(reconstruction)
|
||||
.intent);
|
||||
}),
|
||||
SlideUpMenuItem(context, R.drawable.ic_move_up, context.getString(R.string.share_as_import), context.getString(R.string.share_as_a_import_file_for_grayjay), 2, {
|
||||
val shareUri = StatePlaylists.instance.createPlaylistShareJsonUri(context, playlist);
|
||||
_fragment.startActivity(ShareCompat.IntentBuilder(context)
|
||||
.setType("application/json")
|
||||
.setStream(shareUri)
|
||||
.intent);
|
||||
})
|
||||
SlideUpMenuItem(
|
||||
context,
|
||||
R.drawable.ic_list,
|
||||
context.getString(R.string.share_as_text),
|
||||
context.getString(R.string.share_as_a_list_of_video_urls),
|
||||
tag = 1,
|
||||
call = {
|
||||
_fragment.startActivity(ShareCompat.IntentBuilder(context)
|
||||
.setType("text/plain")
|
||||
.setText(reconstruction)
|
||||
.intent);
|
||||
}),
|
||||
SlideUpMenuItem(
|
||||
context,
|
||||
R.drawable.ic_move_up,
|
||||
context.getString(R.string.share_as_import),
|
||||
context.getString(R.string.share_as_a_import_file_for_grayjay),
|
||||
tag = 2,
|
||||
call = {
|
||||
val shareUri = StatePlaylists.instance.createPlaylistShareJsonUri(context, playlist);
|
||||
_fragment.startActivity(ShareCompat.IntentBuilder(context)
|
||||
.setType("application/json")
|
||||
.setStream(shareUri)
|
||||
.intent);
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+110
-119
@@ -3,30 +3,34 @@ package com.futo.platformplayer.fragment.mainactivity.main
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.content.res.Configuration
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowInsets
|
||||
import android.view.WindowInsetsController
|
||||
import androidx.constraintlayout.motion.widget.MotionLayout
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.SimpleOrientationListener
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.activities.SettingsActivity
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
||||
import com.futo.platformplayer.casting.CastConnectionState
|
||||
import com.futo.platformplayer.casting.StateCasting
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.listeners.OrientationManager
|
||||
import com.futo.platformplayer.listeners.AutoRotateChangeListener
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.PlatformVideoWithTime
|
||||
import com.futo.platformplayer.models.UrlVideoWithTime
|
||||
import com.futo.platformplayer.states.StatePlayer
|
||||
import com.futo.platformplayer.views.containers.SingleViewTouchableMotionLayout
|
||||
|
||||
|
||||
class VideoDetailFragment : MainFragment {
|
||||
override val isMainView : Boolean = false;
|
||||
override val hasBottomBar: Boolean = true;
|
||||
@@ -37,6 +41,9 @@ class VideoDetailFragment : MainFragment {
|
||||
|
||||
private var _viewDetail : VideoDetailView? = null;
|
||||
private var _view : SingleViewTouchableMotionLayout? = null;
|
||||
private lateinit var _autoRotateChangeListener: AutoRotateChangeListener
|
||||
private lateinit var _orientationListener: SimpleOrientationListener
|
||||
private var _currentOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||
|
||||
var isFullscreen : Boolean = false;
|
||||
val onFullscreenChanged = Event1<Boolean>();
|
||||
@@ -45,16 +52,21 @@ class VideoDetailFragment : MainFragment {
|
||||
var isInPictureInPicture : Boolean = false
|
||||
private set;
|
||||
|
||||
var state: State = State.CLOSED;
|
||||
private var _state: State = State.CLOSED
|
||||
|
||||
var state: State
|
||||
get() = _state
|
||||
set(value) {
|
||||
_state = value
|
||||
onStateChanged(value)
|
||||
}
|
||||
|
||||
val currentUrl get() = _viewDetail?.currentUrl;
|
||||
|
||||
val onMinimize = Event0();
|
||||
val onTransitioning = Event1<Boolean>();
|
||||
val onMaximized = Event0();
|
||||
|
||||
var lastOrientation : OrientationManager.Orientation = OrientationManager.Orientation.PORTRAIT
|
||||
private set;
|
||||
|
||||
private var _isInitialMaximize = true;
|
||||
|
||||
private val _maximizeProgress get() = _view?.progress ?: 0.0f;
|
||||
@@ -74,6 +86,29 @@ class VideoDetailFragment : MainFragment {
|
||||
_viewDetail?.prevVideo(true);
|
||||
}
|
||||
|
||||
private fun onStateChanged(state: VideoDetailFragment.State) {
|
||||
updateOrientation()
|
||||
}
|
||||
|
||||
private fun updateOrientation() {
|
||||
val isMaximized = state == State.MAXIMIZED
|
||||
val isFullScreenPortraitAllowed = Settings.instance.playback.fullscreenPortrait;
|
||||
val currentOrientation = _currentOrientation
|
||||
val isFs = isFullscreen
|
||||
|
||||
if (isFs && isMaximized) {
|
||||
if (isFullScreenPortraitAllowed) {
|
||||
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR
|
||||
} else {
|
||||
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||
}
|
||||
} else {
|
||||
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT
|
||||
}
|
||||
|
||||
Log.i(TAG, "updateOrientation (isFs = ${isFs}, currentOrientation = ${currentOrientation}, isMaximized = ${isMaximized}, isFullScreenPortraitAllowed = ${isFullScreenPortraitAllowed}) resulted in requested orientation ${activity?.requestedOrientation}");
|
||||
}
|
||||
|
||||
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
|
||||
super.onShownWithView(parameter, isBack);
|
||||
Logger.i(TAG, "onShownWithView parameter=$parameter")
|
||||
@@ -99,49 +134,6 @@ class VideoDetailFragment : MainFragment {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onOrientationChanged(orientation: OrientationManager.Orientation) {
|
||||
super.onOrientationChanged(orientation);
|
||||
|
||||
if(!_isActive || state != State.MAXIMIZED)
|
||||
return;
|
||||
|
||||
var newOrientation = orientation;
|
||||
val d = StateCasting.instance.activeDevice;
|
||||
if (d != null && d.connectionState == CastConnectionState.CONNECTED) {
|
||||
newOrientation = OrientationManager.Orientation.PORTRAIT;
|
||||
} else if(StatePlayer.instance.rotationLock) {
|
||||
return;
|
||||
}
|
||||
|
||||
if(Settings.instance.other.bypassRotationPrevention && orientation == OrientationManager.Orientation.PORTRAIT)
|
||||
changeOrientation(OrientationManager.Orientation.PORTRAIT);
|
||||
|
||||
if(lastOrientation == newOrientation)
|
||||
return;
|
||||
|
||||
activity?.let {
|
||||
if (isFullscreen) {
|
||||
if (Settings.instance.playback.fullscreenPortrait) {
|
||||
changeOrientation(newOrientation);
|
||||
} else {
|
||||
if(newOrientation == OrientationManager.Orientation.REVERSED_LANDSCAPE && it.requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE)
|
||||
changeOrientation(OrientationManager.Orientation.REVERSED_LANDSCAPE);
|
||||
else if(newOrientation == OrientationManager.Orientation.LANDSCAPE && it.requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE)
|
||||
changeOrientation(OrientationManager.Orientation.LANDSCAPE);
|
||||
else if(Settings.instance.playback.isAutoRotate() && (newOrientation == OrientationManager.Orientation.PORTRAIT || newOrientation == OrientationManager.Orientation.REVERSED_PORTRAIT)) {
|
||||
_viewDetail?.setFullscreen(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
if(Settings.instance.playback.isAutoRotate() && (lastOrientation == OrientationManager.Orientation.PORTRAIT || lastOrientation == OrientationManager.Orientation.REVERSED_PORTRAIT)) {
|
||||
lastOrientation = newOrientation;
|
||||
_viewDetail?.setFullscreen(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
lastOrientation = newOrientation;
|
||||
}
|
||||
override fun onBackPressed(): Boolean {
|
||||
Logger.i(TAG, "onBackPressed")
|
||||
|
||||
@@ -155,6 +147,7 @@ class VideoDetailFragment : MainFragment {
|
||||
closeVideoDetails();
|
||||
return true;
|
||||
}
|
||||
|
||||
override fun onHide() {
|
||||
super.onHide();
|
||||
}
|
||||
@@ -164,7 +157,7 @@ class VideoDetailFragment : MainFragment {
|
||||
_viewDetail?.preventPictureInPicture = true;
|
||||
}
|
||||
|
||||
fun minimizeVideoDetail(){
|
||||
fun minimizeVideoDetail() {
|
||||
_viewDetail?.setFullscreen(false);
|
||||
if(_view != null)
|
||||
_view!!.transitionToStart();
|
||||
@@ -266,7 +259,6 @@ class VideoDetailFragment : MainFragment {
|
||||
override fun onTransitionTrigger(p0: MotionLayout?, p1: Int, p2: Boolean, p3: Float) { }
|
||||
});
|
||||
|
||||
context
|
||||
_view?.let {
|
||||
if (it.progress >= 0.5 && it.progress < 1.0)
|
||||
maximizeVideoDetail();
|
||||
@@ -274,9 +266,40 @@ class VideoDetailFragment : MainFragment {
|
||||
minimizeVideoDetail();
|
||||
}
|
||||
|
||||
_loadUrlOnCreate?.let { _viewDetail?.setVideo(it.url, it.timeSeconds, it.playWhenReady) };
|
||||
_autoRotateChangeListener = AutoRotateChangeListener(requireContext(), Handler()) { _ ->
|
||||
updateOrientation()
|
||||
}
|
||||
|
||||
_loadUrlOnCreate?.let { _viewDetail?.setVideo(it.url, it.timeSeconds, it.playWhenReady) };
|
||||
maximizeVideoDetail();
|
||||
|
||||
SettingsActivity.settingsActivityClosed.subscribe(this) {
|
||||
updateOrientation()
|
||||
}
|
||||
|
||||
StatePlayer.instance.onRotationLockChanged.subscribe(this) {
|
||||
updateOrientation()
|
||||
}
|
||||
|
||||
_orientationListener = SimpleOrientationListener(requireActivity(), lifecycleScope)
|
||||
_orientationListener.onOrientationChanged.subscribe {
|
||||
_currentOrientation = it
|
||||
Logger.i(TAG, "Current orientation changed (_currentOrientation = ${_currentOrientation})")
|
||||
|
||||
if (Settings.instance.playback.isAutoRotate()) {
|
||||
if (state == State.MAXIMIZED && !isFullscreen && (it == ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE || it == ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE)) {
|
||||
_viewDetail?.setFullscreen(true)
|
||||
return@subscribe
|
||||
}
|
||||
|
||||
if (state == State.MAXIMIZED && isFullscreen && !Settings.instance.playback.fullscreenPortrait && (it == ActivityInfo.SCREEN_ORIENTATION_PORTRAIT || it == ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT)) {
|
||||
_viewDetail?.setFullscreen(false)
|
||||
return@subscribe
|
||||
}
|
||||
}
|
||||
|
||||
updateOrientation()
|
||||
}
|
||||
return _view!!;
|
||||
}
|
||||
|
||||
@@ -334,11 +357,6 @@ class VideoDetailFragment : MainFragment {
|
||||
}
|
||||
}
|
||||
|
||||
val realOrientation = if(activity is MainActivity) (activity as MainActivity).orientation else lastOrientation;
|
||||
Logger.i(TAG, "Real orientation on boot ${realOrientation}, lastOrientation: ${lastOrientation}");
|
||||
if(realOrientation != lastOrientation)
|
||||
onOrientationChanged(realOrientation);
|
||||
|
||||
StateCasting.instance.onResume();
|
||||
}
|
||||
override fun onPause() {
|
||||
@@ -380,6 +398,12 @@ class VideoDetailFragment : MainFragment {
|
||||
override fun onDestroyMainView() {
|
||||
super.onDestroyMainView();
|
||||
Logger.v(TAG, "onDestroyMainView");
|
||||
_autoRotateChangeListener?.unregister()
|
||||
_orientationListener.stopListening()
|
||||
|
||||
SettingsActivity.settingsActivityClosed.remove(this)
|
||||
StatePlayer.instance.onRotationLockChanged.remove(this)
|
||||
|
||||
_viewDetail?.let {
|
||||
_viewDetail = null;
|
||||
it.onDestroy();
|
||||
@@ -387,13 +411,6 @@ class VideoDetailFragment : MainFragment {
|
||||
_view = null;
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, _ ->
|
||||
onOrientationChanged(lastOrientation);
|
||||
};
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
|
||||
@@ -409,63 +426,37 @@ class VideoDetailFragment : MainFragment {
|
||||
onMaximized.clear();
|
||||
}
|
||||
|
||||
private fun onFullscreenChanged(fullscreen : Boolean) {
|
||||
activity?.let {
|
||||
if (fullscreen) {
|
||||
if (Settings.instance.playback.fullscreenPortrait) {
|
||||
changeOrientation(lastOrientation);
|
||||
} else {
|
||||
var orient = lastOrientation;
|
||||
if(orient == OrientationManager.Orientation.PORTRAIT || orient == OrientationManager.Orientation.REVERSED_PORTRAIT)
|
||||
orient = OrientationManager.Orientation.LANDSCAPE;
|
||||
changeOrientation(orient);
|
||||
}
|
||||
}
|
||||
else
|
||||
changeOrientation(OrientationManager.Orientation.PORTRAIT);
|
||||
|
||||
private fun hideSystemUI() {
|
||||
WindowCompat.setDecorFitsSystemWindows(requireActivity().window, false)
|
||||
activity?.window?.insetsController?.let { controller ->
|
||||
controller.hide(WindowInsets.Type.statusBars())
|
||||
controller.hide(WindowInsets.Type.systemBars())
|
||||
controller.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
}
|
||||
}
|
||||
|
||||
private fun showSystemUI() {
|
||||
WindowCompat.setDecorFitsSystemWindows(requireActivity().window, true)
|
||||
activity?.window?.insetsController?.let { controller ->
|
||||
controller.show(WindowInsets.Type.statusBars())
|
||||
controller.show(WindowInsets.Type.systemBars())
|
||||
controller.systemBarsBehavior = WindowInsetsController.BEHAVIOR_DEFAULT
|
||||
}
|
||||
}
|
||||
|
||||
private fun onFullscreenChanged(fullscreen : Boolean) {
|
||||
isFullscreen = fullscreen;
|
||||
onFullscreenChanged.emit(isFullscreen);
|
||||
_view?.allowMotion = !fullscreen;
|
||||
}
|
||||
private fun changeOrientation(orientation: OrientationManager.Orientation) {
|
||||
Logger.i(TAG, "Orientation Change:" + orientation.name);
|
||||
activity?.let {
|
||||
when (orientation) {
|
||||
OrientationManager.Orientation.LANDSCAPE -> {
|
||||
it.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE;
|
||||
_view?.allowMotion = false;
|
||||
|
||||
WindowCompat.setDecorFitsSystemWindows(requireActivity().window, false)
|
||||
WindowInsetsControllerCompat(it.window, _viewDetail!!).let { controller ->
|
||||
controller.hide(WindowInsetsCompat.Type.statusBars());
|
||||
controller.hide(WindowInsetsCompat.Type.systemBars());
|
||||
controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE;
|
||||
}
|
||||
}
|
||||
OrientationManager.Orientation.REVERSED_LANDSCAPE -> {
|
||||
it.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE;
|
||||
_view?.allowMotion = false;
|
||||
|
||||
WindowCompat.setDecorFitsSystemWindows(requireActivity().window, false)
|
||||
WindowInsetsControllerCompat(it.window, _viewDetail!!).let { controller ->
|
||||
controller.hide(WindowInsetsCompat.Type.statusBars());
|
||||
controller.hide(WindowInsetsCompat.Type.systemBars());
|
||||
controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE;
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
it.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT;
|
||||
_view?.allowMotion = true;
|
||||
|
||||
WindowCompat.setDecorFitsSystemWindows(it.window, true)
|
||||
WindowInsetsControllerCompat(it.window, _viewDetail!!).let { controller ->
|
||||
controller.show(WindowInsetsCompat.Type.statusBars());
|
||||
controller.show(WindowInsetsCompat.Type.systemBars())
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isFullscreen) {
|
||||
hideSystemUI()
|
||||
} else {
|
||||
showSystemUI()
|
||||
}
|
||||
|
||||
updateOrientation();
|
||||
_view?.allowMotion = !fullscreen;
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
+53
-23
@@ -23,7 +23,6 @@ import android.view.View
|
||||
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
||||
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
import android.view.WindowManager
|
||||
import android.webkit.WebView
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
@@ -59,8 +58,6 @@ import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
|
||||
import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
|
||||
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
|
||||
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.DashManifestSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.HLSManifestSource
|
||||
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.IHLSManifestAudioSource
|
||||
@@ -75,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
|
||||
@@ -115,6 +113,7 @@ import com.futo.platformplayer.stores.FragmentedStorage
|
||||
import com.futo.platformplayer.stores.StringArrayStorage
|
||||
import com.futo.platformplayer.stores.db.types.DBHistory
|
||||
import com.futo.platformplayer.toHumanBitrate
|
||||
import com.futo.platformplayer.toHumanBytesSize
|
||||
import com.futo.platformplayer.toHumanNowDiffString
|
||||
import com.futo.platformplayer.toHumanNumber
|
||||
import com.futo.platformplayer.toHumanTime
|
||||
@@ -661,8 +660,8 @@ class VideoDetailView : ConstraintLayout {
|
||||
|
||||
Logger.i(TAG, "onAuthorClick: " + c.author.id.value);
|
||||
if(c.author.id.value?.startsWith("polycentric://") ?: false) {
|
||||
//val navUrl = "https://harbor.social/" + c.author.id.value?.substring("polycentric://".length);
|
||||
val navUrl = "https://polycentric.io/user/" + c.author.id.value?.substring("polycentric://".length);
|
||||
val navUrl = "https://harbor.social/" + c.author.id.value?.substring("polycentric://".length);
|
||||
//val navUrl = "https://polycentric.io/user/" + c.author.id.value?.substring("polycentric://".length);
|
||||
fragment.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(navUrl)))
|
||||
//_container_content_browser.goto(navUrl);
|
||||
//switchContentView(_container_content_browser);
|
||||
@@ -695,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);
|
||||
@@ -1674,7 +1674,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
_didTriggerDatasourceErrroCount++;
|
||||
|
||||
UIDialogs.toast("Block detected, attempting bypass");
|
||||
|
||||
//return;
|
||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
val newDetails = StatePlatform.instance.getContentDetails(currentVideo.url, true).await();
|
||||
val previousVideoSource = _lastVideoSource;
|
||||
@@ -1808,7 +1808,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
}
|
||||
}
|
||||
|
||||
val doDedup = false;
|
||||
val doDedup = Settings.instance.playback.simplifySources;
|
||||
|
||||
val bestVideoSources = if(doDedup) (videoSources?.map { it.height * it.width }
|
||||
?.distinct()
|
||||
@@ -1851,40 +1851,56 @@ class VideoDetailView : ConstraintLayout {
|
||||
SlideUpMenuGroup(this.context, context.getString(R.string.offline_video), "video",
|
||||
*localVideoSources
|
||||
.map {
|
||||
SlideUpMenuItem(this.context, R.drawable.ic_movie, it.name, "${it.width}x${it.height}", it,
|
||||
{ handleSelectVideoTrack(it) });
|
||||
SlideUpMenuItem(this.context,
|
||||
R.drawable.ic_movie,
|
||||
it.name,
|
||||
"${it.width}x${it.height}",
|
||||
tag = it,
|
||||
call = { handleSelectVideoTrack(it) });
|
||||
}.toList().toTypedArray())
|
||||
else null,
|
||||
if(localAudioSource?.isNotEmpty() == true)
|
||||
SlideUpMenuGroup(this.context, context.getString(R.string.offline_audio), "audio",
|
||||
*localAudioSource
|
||||
.map {
|
||||
SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), it,
|
||||
{ handleSelectAudioTrack(it) });
|
||||
SlideUpMenuItem(this.context,
|
||||
R.drawable.ic_music,
|
||||
it.name,
|
||||
it.bitrate.toHumanBitrate(),
|
||||
tag = it,
|
||||
call = { handleSelectAudioTrack(it) });
|
||||
}.toList().toTypedArray())
|
||||
else null,
|
||||
if(localSubtitleSources?.isNotEmpty() == true)
|
||||
SlideUpMenuGroup(this.context, context.getString(R.string.offline_subtitles), "subtitles",
|
||||
*localSubtitleSources
|
||||
.map {
|
||||
SlideUpMenuItem(this.context, R.drawable.ic_edit, it.name, "", it,
|
||||
{ handleSelectSubtitleTrack(it) })
|
||||
SlideUpMenuItem(this.context, R.drawable.ic_edit, it.name, "", tag = it,
|
||||
call = { handleSelectSubtitleTrack(it) })
|
||||
}.toList().toTypedArray())
|
||||
else null,
|
||||
if(liveStreamVideoFormats?.isEmpty() == false)
|
||||
SlideUpMenuGroup(this.context, context.getString(R.string.stream_video), "video",
|
||||
*liveStreamVideoFormats
|
||||
.map {
|
||||
SlideUpMenuItem(this.context, R.drawable.ic_movie, it.label ?: it.containerMimeType ?: it.bitrate.toString(), "${it.width}x${it.height}", it,
|
||||
{ _player.selectVideoTrack(it.height) });
|
||||
SlideUpMenuItem(this.context,
|
||||
R.drawable.ic_movie,
|
||||
it.label ?: it.containerMimeType ?: it.bitrate.toString(),
|
||||
"${it.width}x${it.height}",
|
||||
tag = it,
|
||||
call = { _player.selectVideoTrack(it.height) });
|
||||
}.toList().toTypedArray())
|
||||
else null,
|
||||
if(liveStreamAudioFormats?.isEmpty() == false)
|
||||
SlideUpMenuGroup(this.context, context.getString(R.string.stream_audio), "audio",
|
||||
*liveStreamAudioFormats
|
||||
.map {
|
||||
SlideUpMenuItem(this.context, R.drawable.ic_music, "${it.label ?: it.containerMimeType} ${it.bitrate}", "", it,
|
||||
{ _player.selectAudioTrack(it.bitrate) });
|
||||
SlideUpMenuItem(this.context,
|
||||
R.drawable.ic_music,
|
||||
"${it.label ?: it.containerMimeType} ${it.bitrate}",
|
||||
"",
|
||||
tag = it,
|
||||
call = { _player.selectAudioTrack(it.bitrate) });
|
||||
}.toList().toTypedArray())
|
||||
else null,
|
||||
|
||||
@@ -1892,24 +1908,38 @@ class VideoDetailView : ConstraintLayout {
|
||||
SlideUpMenuGroup(this.context, context.getString(R.string.video), "video",
|
||||
*bestVideoSources
|
||||
.map {
|
||||
SlideUpMenuItem(this.context, R.drawable.ic_movie, it!!.name, if (it.width > 0 && it.height > 0) "${it.width}x${it.height}" else "", it,
|
||||
{ handleSelectVideoTrack(it) });
|
||||
val estSize = VideoHelper.estimateSourceSize(it);
|
||||
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
|
||||
SlideUpMenuItem(this.context,
|
||||
R.drawable.ic_movie,
|
||||
it!!.name,
|
||||
if (it.width > 0 && it.height > 0) "${it.width}x${it.height}" else "",
|
||||
(prefix + it.codec.trim()).trim(),
|
||||
tag = it,
|
||||
call = { handleSelectVideoTrack(it) });
|
||||
}.toList().toTypedArray())
|
||||
else null,
|
||||
if(bestAudioSources.isNotEmpty())
|
||||
SlideUpMenuGroup(this.context, context.getString(R.string.audio), "audio",
|
||||
*bestAudioSources
|
||||
.map {
|
||||
SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), it,
|
||||
{ handleSelectAudioTrack(it) });
|
||||
val estSize = VideoHelper.estimateSourceSize(it);
|
||||
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
|
||||
SlideUpMenuItem(this.context,
|
||||
R.drawable.ic_music,
|
||||
it.name,
|
||||
it.bitrate.toHumanBitrate(),
|
||||
(prefix + it.codec.trim()).trim(),
|
||||
tag = it,
|
||||
call = { handleSelectAudioTrack(it) });
|
||||
}.toList().toTypedArray())
|
||||
else null,
|
||||
if(video?.subtitles?.isNotEmpty() == true)
|
||||
SlideUpMenuGroup(this.context, context.getString(R.string.subtitles), "subtitles",
|
||||
*video.subtitles
|
||||
.map {
|
||||
SlideUpMenuItem(this.context, R.drawable.ic_edit, it.name, "", it,
|
||||
{ handleSelectSubtitleTrack(it) })
|
||||
SlideUpMenuItem(this.context, R.drawable.ic_edit, it.name, "", tag = it,
|
||||
call = { handleSelectSubtitleTrack(it) })
|
||||
}.toList().toTypedArray())
|
||||
else null);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ 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.models.video.IPlatformVideoDetails
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSAudioUrlRangeSource
|
||||
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.JSSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSVideoUrlRangeSource
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.others.Language
|
||||
@@ -44,8 +47,8 @@ class VideoHelper {
|
||||
return false
|
||||
}
|
||||
|
||||
fun isDownloadable(source: IVideoSource) = source is IVideoUrlSource || source is IHLSManifestSource;
|
||||
fun isDownloadable(source: IAudioSource) = (source is IAudioUrlSource || source is IHLSManifestAudioSource) && source !is IAudioUrlWidevineSource
|
||||
fun isDownloadable(source: IVideoSource) = source is IVideoUrlSource || source is IHLSManifestSource || source is JSDashManifestRawSource;
|
||||
fun isDownloadable(source: IAudioSource) = (source is IAudioUrlSource || source is IHLSManifestAudioSource || source is JSDashManifestRawAudioSource) && source !is IAudioUrlWidevineSource
|
||||
|
||||
fun selectBestVideoSource(desc: IVideoSourceDescriptor, desiredPixelCount : Int, prefContainers : Array<String>) : IVideoSource? = selectBestVideoSource(desc.videoSources.toList(), desiredPixelCount, prefContainers);
|
||||
fun selectBestVideoSource(sources: Iterable<IVideoSource>, desiredPixelCount : Int, prefContainers : Array<String>) : IVideoSource? {
|
||||
@@ -186,5 +189,25 @@ class VideoHelper {
|
||||
return@Resolver dataSpec;
|
||||
})).createMediaSource(manifest, MediaItem.Builder().setUri(Uri.parse(audioSource.getAudioUrl())).build())
|
||||
}
|
||||
|
||||
|
||||
fun estimateSourceSize(source: IVideoSource?): Int {
|
||||
if(source == null) return 0;
|
||||
if(source is IVideoSource) {
|
||||
if(source.bitrate ?: 0 <= 0 || source.duration.toInt() == 0)
|
||||
return 0;
|
||||
return (source.duration / 8).toInt() * source.bitrate!!;
|
||||
}
|
||||
else return 0;
|
||||
}
|
||||
fun estimateSourceSize(source: IAudioSource?): Int {
|
||||
if(source == null) return 0;
|
||||
if(source is IAudioSource) {
|
||||
if(source.bitrate <= 0 || source.duration?.toInt() ?: 0 == 0)
|
||||
return 0;
|
||||
return (source.duration!! / 8).toInt() * source.bitrate;
|
||||
}
|
||||
else return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.futo.platformplayer.listeners
|
||||
|
||||
import android.content.Context
|
||||
import android.database.ContentObserver
|
||||
import android.os.Handler
|
||||
import android.provider.Settings
|
||||
|
||||
class AutoRotateObserver(handler: Handler, private val onChangeCallback: () -> Unit) : ContentObserver(handler) {
|
||||
override fun onChange(selfChange: Boolean) {
|
||||
super.onChange(selfChange)
|
||||
onChangeCallback()
|
||||
}
|
||||
}
|
||||
|
||||
class AutoRotateChangeListener(context: Context, handler: Handler, private val onAutoRotateChanged: (Boolean) -> Unit) {
|
||||
|
||||
private val contentResolver = context.contentResolver
|
||||
private val autoRotateObserver = AutoRotateObserver(handler) {
|
||||
val isAutoRotateEnabled = isAutoRotateEnabled()
|
||||
onAutoRotateChanged(isAutoRotateEnabled)
|
||||
}
|
||||
|
||||
init {
|
||||
contentResolver.registerContentObserver(
|
||||
Settings.System.getUriFor(Settings.System.ACCELEROMETER_ROTATION),
|
||||
false,
|
||||
autoRotateObserver
|
||||
)
|
||||
}
|
||||
|
||||
fun unregister() {
|
||||
contentResolver.unregisterContentObserver(autoRotateObserver)
|
||||
}
|
||||
|
||||
private fun isAutoRotateEnabled(): Boolean {
|
||||
return Settings.System.getInt(
|
||||
contentResolver,
|
||||
Settings.System.ACCELEROMETER_ROTATION,
|
||||
0
|
||||
) == 1
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package com.futo.platformplayer.listeners
|
||||
|
||||
import android.content.Context
|
||||
import android.view.OrientationEventListener
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
|
||||
class OrientationManager : OrientationEventListener {
|
||||
|
||||
val onOrientationChanged = Event1<Orientation>();
|
||||
|
||||
var orientation : Orientation = Orientation.PORTRAIT;
|
||||
|
||||
constructor(context: Context) : super(context) { }
|
||||
|
||||
//TODO: Something weird is going on here
|
||||
//TODO: Old implementation felt pretty good for me, but now with 0 deadzone still feels bad, even though code should be identical?
|
||||
override fun onOrientationChanged(orientationAnglep: Int) {
|
||||
if (orientationAnglep == -1) return
|
||||
|
||||
val deadZone = Settings.instance.playback.getAutoRotateDeadZoneDegrees()
|
||||
val isInDeadZone = when (orientation) {
|
||||
Orientation.PORTRAIT -> orientationAnglep in 0 until (60 - deadZone) || orientationAnglep in (300 + deadZone) .. 360
|
||||
Orientation.REVERSED_LANDSCAPE -> orientationAnglep in (60 + deadZone) until (140 - deadZone)
|
||||
Orientation.REVERSED_PORTRAIT -> orientationAnglep in (140 + deadZone) until (220 - deadZone)
|
||||
Orientation.LANDSCAPE -> orientationAnglep in (220 + deadZone) until (300 - deadZone)
|
||||
}
|
||||
|
||||
if (isInDeadZone) {
|
||||
return;
|
||||
}
|
||||
|
||||
val newOrientation = when (orientationAnglep) {
|
||||
in 60 until 140 -> Orientation.REVERSED_LANDSCAPE
|
||||
in 140 until 220 -> Orientation.REVERSED_PORTRAIT
|
||||
in 220 until 300 -> Orientation.LANDSCAPE
|
||||
else -> Orientation.PORTRAIT
|
||||
}
|
||||
|
||||
Logger.i("OrientationManager", "Orientation=$newOrientation orientationAnglep=$orientationAnglep");
|
||||
|
||||
if (newOrientation != orientation) {
|
||||
orientation = newOrientation
|
||||
onOrientationChanged.emit(newOrientation)
|
||||
}
|
||||
}
|
||||
|
||||
//TODO: Perhaps just use ActivityInfo orientations instead..
|
||||
enum class Orientation {
|
||||
PORTRAIT,
|
||||
LANDSCAPE,
|
||||
REVERSED_PORTRAIT,
|
||||
REVERSED_LANDSCAPE
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.futo.platformplayer.mdns
|
||||
|
||||
data class BroadcastService(
|
||||
val deviceName: String,
|
||||
val serviceName: String,
|
||||
val port: UShort,
|
||||
val ttl: UInt,
|
||||
val weight: UShort,
|
||||
val priority: UShort,
|
||||
val texts: List<String>? = null
|
||||
)
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.futo.platformplayer.mdns
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
enum class QueryResponse(val value: Byte) {
|
||||
Query(0),
|
||||
Response(1)
|
||||
}
|
||||
|
||||
enum class DnsOpcode(val value: Byte) {
|
||||
StandardQuery(0),
|
||||
InverseQuery(1),
|
||||
ServerStatusRequest(2)
|
||||
}
|
||||
|
||||
enum class DnsResponseCode(val value: Byte) {
|
||||
NoError(0),
|
||||
FormatError(1),
|
||||
ServerFailure(2),
|
||||
NameError(3),
|
||||
NotImplemented(4),
|
||||
Refused(5)
|
||||
}
|
||||
|
||||
data class DnsPacketHeader(
|
||||
val identifier: UShort,
|
||||
val queryResponse: Int,
|
||||
val opcode: Int,
|
||||
val authoritativeAnswer: Boolean,
|
||||
val truncated: Boolean,
|
||||
val recursionDesired: Boolean,
|
||||
val recursionAvailable: Boolean,
|
||||
val answerAuthenticated: Boolean,
|
||||
val nonAuthenticatedData: Boolean,
|
||||
val responseCode: DnsResponseCode
|
||||
)
|
||||
|
||||
data class DnsPacket(
|
||||
val header: DnsPacketHeader,
|
||||
val questions: List<DnsQuestion>,
|
||||
val answers: List<DnsResourceRecord>,
|
||||
val authorities: List<DnsResourceRecord>,
|
||||
val additionals: List<DnsResourceRecord>
|
||||
) {
|
||||
companion object {
|
||||
fun parse(data: ByteArray): DnsPacket {
|
||||
val span = data.asUByteArray()
|
||||
val flags = (span[2].toInt() shl 8 or span[3].toInt()).toUShort()
|
||||
val questionCount = (span[4].toInt() shl 8 or span[5].toInt()).toUShort()
|
||||
val answerCount = (span[6].toInt() shl 8 or span[7].toInt()).toUShort()
|
||||
val authorityCount = (span[8].toInt() shl 8 or span[9].toInt()).toUShort()
|
||||
val additionalCount = (span[10].toInt() shl 8 or span[11].toInt()).toUShort()
|
||||
|
||||
var position = 12
|
||||
|
||||
val questions = List(questionCount.toInt()) {
|
||||
DnsQuestion.parse(data, position).also { position = it.second }
|
||||
}.map { it.first }
|
||||
|
||||
val answers = List(answerCount.toInt()) {
|
||||
DnsResourceRecord.parse(data, position).also { position = it.second }
|
||||
}.map { it.first }
|
||||
|
||||
val authorities = List(authorityCount.toInt()) {
|
||||
DnsResourceRecord.parse(data, position).also { position = it.second }
|
||||
}.map { it.first }
|
||||
|
||||
val additionals = List(additionalCount.toInt()) {
|
||||
DnsResourceRecord.parse(data, position).also { position = it.second }
|
||||
}.map { it.first }
|
||||
|
||||
return DnsPacket(
|
||||
header = DnsPacketHeader(
|
||||
identifier = (span[0].toInt() shl 8 or span[1].toInt()).toUShort(),
|
||||
queryResponse = ((flags.toUInt() shr 15) and 0b1u).toInt(),
|
||||
opcode = ((flags.toUInt() shr 11) and 0b1111u).toInt(),
|
||||
authoritativeAnswer = (flags.toInt() shr 10) and 0b1 != 0,
|
||||
truncated = (flags.toInt() shr 9) and 0b1 != 0,
|
||||
recursionDesired = (flags.toInt() shr 8) and 0b1 != 0,
|
||||
recursionAvailable = (flags.toInt() shr 7) and 0b1 != 0,
|
||||
answerAuthenticated = (flags.toInt() shr 5) and 0b1 != 0,
|
||||
nonAuthenticatedData = (flags.toInt() shr 4) and 0b1 != 0,
|
||||
responseCode = DnsResponseCode.entries[flags.toInt() and 0b1111]
|
||||
),
|
||||
questions = questions,
|
||||
answers = answers,
|
||||
authorities = authorities,
|
||||
additionals = additionals
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.futo.platformplayer.mdns
|
||||
|
||||
import com.futo.platformplayer.mdns.Extensions.readDomainName
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
|
||||
enum class QuestionType(val value: UShort) {
|
||||
A(1u),
|
||||
NS(2u),
|
||||
MD(3u),
|
||||
MF(4u),
|
||||
CNAME(5u),
|
||||
SOA(6u),
|
||||
MB(7u),
|
||||
MG(8u),
|
||||
MR(9u),
|
||||
NULL(10u),
|
||||
WKS(11u),
|
||||
PTR(12u),
|
||||
HINFO(13u),
|
||||
MINFO(14u),
|
||||
MX(15u),
|
||||
TXT(16u),
|
||||
RP(17u),
|
||||
AFSDB(18u),
|
||||
SIG(24u),
|
||||
KEY(25u),
|
||||
AAAA(28u),
|
||||
LOC(29u),
|
||||
SRV(33u),
|
||||
NAPTR(35u),
|
||||
KX(36u),
|
||||
CERT(37u),
|
||||
DNAME(39u),
|
||||
APL(42u),
|
||||
DS(43u),
|
||||
SSHFP(44u),
|
||||
IPSECKEY(45u),
|
||||
RRSIG(46u),
|
||||
NSEC(47u),
|
||||
DNSKEY(48u),
|
||||
DHCID(49u),
|
||||
NSEC3(50u),
|
||||
NSEC3PARAM(51u),
|
||||
TSLA(52u),
|
||||
SMIMEA(53u),
|
||||
HIP(55u),
|
||||
CDS(59u),
|
||||
CDNSKEY(60u),
|
||||
OPENPGPKEY(61u),
|
||||
CSYNC(62u),
|
||||
ZONEMD(63u),
|
||||
SVCB(64u),
|
||||
HTTPS(65u),
|
||||
EUI48(108u),
|
||||
EUI64(109u),
|
||||
TKEY(249u),
|
||||
TSIG(250u),
|
||||
URI(256u),
|
||||
CAA(257u),
|
||||
TA(32768u),
|
||||
DLV(32769u),
|
||||
AXFR(252u),
|
||||
IXFR(251u),
|
||||
OPT(41u),
|
||||
MAILB(253u),
|
||||
MALA(254u),
|
||||
All(252u)
|
||||
}
|
||||
|
||||
enum class QuestionClass(val value: UShort) {
|
||||
IN(1u),
|
||||
CS(2u),
|
||||
CH(3u),
|
||||
HS(4u),
|
||||
All(255u)
|
||||
}
|
||||
|
||||
data class DnsQuestion(
|
||||
override val name: String,
|
||||
override val type: Int,
|
||||
override val clazz: Int,
|
||||
val queryUnicast: Boolean
|
||||
) : DnsResourceRecordBase(name, type, clazz) {
|
||||
companion object {
|
||||
fun parse(data: ByteArray, startPosition: Int): Pair<DnsQuestion, Int> {
|
||||
val span = data.asUByteArray()
|
||||
var position = startPosition
|
||||
val qname = span.readDomainName(position).also { position = it.second }
|
||||
val qtype = (span[position].toInt() shl 8 or span[position + 1].toInt()).toUShort()
|
||||
position += 2
|
||||
val qclass = (span[position].toInt() shl 8 or span[position + 1].toInt()).toUShort()
|
||||
position += 2
|
||||
|
||||
return DnsQuestion(
|
||||
name = qname.first,
|
||||
type = qtype.toInt(),
|
||||
queryUnicast = ((qclass.toInt() shr 15) and 0b1) != 0,
|
||||
clazz = qclass.toInt() and 0b111111111111111
|
||||
) to position
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
open class DnsResourceRecordBase(
|
||||
open val name: String,
|
||||
open val type: Int,
|
||||
open val clazz: Int
|
||||
)
|
||||
@@ -0,0 +1,514 @@
|
||||
package com.futo.platformplayer.mdns
|
||||
|
||||
import com.futo.platformplayer.mdns.Extensions.readDomainName
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.nio.charset.StandardCharsets
|
||||
import kotlin.math.pow
|
||||
import java.net.InetAddress
|
||||
|
||||
data class PTRRecord(val domainName: String)
|
||||
|
||||
data class ARecord(val address: InetAddress)
|
||||
|
||||
data class AAAARecord(val address: InetAddress)
|
||||
|
||||
data class MXRecord(val preference: UShort, val exchange: String)
|
||||
|
||||
data class CNAMERecord(val cname: String)
|
||||
|
||||
data class TXTRecord(val texts: List<String>)
|
||||
|
||||
data class SOARecord(
|
||||
val primaryNameServer: String,
|
||||
val responsibleAuthorityMailbox: String,
|
||||
val serialNumber: Int,
|
||||
val refreshInterval: Int,
|
||||
val retryInterval: Int,
|
||||
val expiryLimit: Int,
|
||||
val minimumTTL: Int
|
||||
)
|
||||
|
||||
data class SRVRecord(val priority: UShort, val weight: UShort, val port: UShort, val target: String)
|
||||
|
||||
data class NSRecord(val nameServer: String)
|
||||
|
||||
data class CAARecord(val flags: Byte, val tag: String, val value: String)
|
||||
|
||||
data class HINFORecord(val cpu: String, val os: String)
|
||||
|
||||
data class RPRecord(val mailbox: String, val txtDomainName: String)
|
||||
|
||||
|
||||
data class AFSDBRecord(val subtype: UShort, val hostname: String)
|
||||
data class LOCRecord(
|
||||
val version: Byte,
|
||||
val size: Double,
|
||||
val horizontalPrecision: Double,
|
||||
val verticalPrecision: Double,
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
val altitude: Double
|
||||
) {
|
||||
companion object {
|
||||
fun decodeSizeOrPrecision(coded: Byte): Double {
|
||||
val baseValue = (coded.toInt() shr 4) and 0x0F
|
||||
val exponent = coded.toInt() and 0x0F
|
||||
return baseValue * 10.0.pow(exponent.toDouble())
|
||||
}
|
||||
|
||||
fun decodeLatitudeOrLongitude(coded: Int): Double {
|
||||
val arcSeconds = coded / 1E3
|
||||
return arcSeconds / 3600.0
|
||||
}
|
||||
|
||||
fun decodeAltitude(coded: Int): Double {
|
||||
return (coded / 100.0) - 100000.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class NAPTRRecord(
|
||||
val order: UShort,
|
||||
val preference: UShort,
|
||||
val flags: String,
|
||||
val services: String,
|
||||
val regexp: String,
|
||||
val replacement: String
|
||||
)
|
||||
|
||||
data class RRSIGRecord(
|
||||
val typeCovered: UShort,
|
||||
val algorithm: Byte,
|
||||
val labels: Byte,
|
||||
val originalTTL: UInt,
|
||||
val signatureExpiration: UInt,
|
||||
val signatureInception: UInt,
|
||||
val keyTag: UShort,
|
||||
val signersName: String,
|
||||
val signature: ByteArray
|
||||
)
|
||||
|
||||
data class KXRecord(val preference: UShort, val exchanger: String)
|
||||
|
||||
data class CERTRecord(val type: UShort, val keyTag: UShort, val algorithm: Byte, val certificate: ByteArray)
|
||||
|
||||
|
||||
|
||||
data class DNAMERecord(val target: String)
|
||||
|
||||
data class DSRecord(val keyTag: UShort, val algorithm: Byte, val digestType: Byte, val digest: ByteArray)
|
||||
|
||||
data class SSHFPRecord(val algorithm: Byte, val fingerprintType: Byte, val fingerprint: ByteArray)
|
||||
|
||||
data class TLSARecord(val usage: Byte, val selector: Byte, val matchingType: Byte, val certificateAssociationData: ByteArray)
|
||||
|
||||
data class SMIMEARecord(val usage: Byte, val selector: Byte, val matchingType: Byte, val certificateAssociationData: ByteArray)
|
||||
|
||||
data class URIRecord(val priority: UShort, val weight: UShort, val target: String)
|
||||
|
||||
data class NSECRecord(val ownerName: String, val typeBitMaps: List<Pair<Byte, ByteArray>>)
|
||||
data class NSEC3Record(
|
||||
val hashAlgorithm: Byte,
|
||||
val flags: Byte,
|
||||
val iterations: UShort,
|
||||
val salt: ByteArray,
|
||||
val nextHashedOwnerName: ByteArray,
|
||||
val typeBitMaps: List<UShort>
|
||||
)
|
||||
|
||||
data class NSEC3PARAMRecord(val hashAlgorithm: Byte, val flags: Byte, val iterations: UShort, val salt: ByteArray)
|
||||
data class SPFRecord(val texts: List<String>)
|
||||
data class TKEYRecord(
|
||||
val algorithm: String,
|
||||
val inception: UInt,
|
||||
val expiration: UInt,
|
||||
val mode: UShort,
|
||||
val error: UShort,
|
||||
val keyData: ByteArray,
|
||||
val otherData: ByteArray
|
||||
)
|
||||
|
||||
data class TSIGRecord(
|
||||
val algorithmName: String,
|
||||
val timeSigned: UInt,
|
||||
val fudge: UShort,
|
||||
val mac: ByteArray,
|
||||
val originalID: UShort,
|
||||
val error: UShort,
|
||||
val otherData: ByteArray
|
||||
)
|
||||
|
||||
data class OPTRecordOption(val code: UShort, val data: ByteArray)
|
||||
data class OPTRecord(val options: List<OPTRecordOption>)
|
||||
|
||||
class DnsReader(private val data: ByteArray, private var position: Int = 0, private val length: Int = data.size) {
|
||||
|
||||
private val endPosition: Int = position + length
|
||||
|
||||
fun readDomainName(): String {
|
||||
return data.asUByteArray().readDomainName(position).also { position = it.second }.first
|
||||
}
|
||||
|
||||
fun readDouble(): Double {
|
||||
checkRemainingBytes(Double.SIZE_BYTES)
|
||||
val result = ByteBuffer.wrap(data, position, Double.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).double
|
||||
position += Double.SIZE_BYTES
|
||||
return result
|
||||
}
|
||||
|
||||
fun readInt16(): Short {
|
||||
checkRemainingBytes(Short.SIZE_BYTES)
|
||||
val result = ByteBuffer.wrap(data, position, Short.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).short
|
||||
position += Short.SIZE_BYTES
|
||||
return result
|
||||
}
|
||||
|
||||
fun readInt32(): Int {
|
||||
checkRemainingBytes(Int.SIZE_BYTES)
|
||||
val result = ByteBuffer.wrap(data, position, Int.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).int
|
||||
position += Int.SIZE_BYTES
|
||||
return result
|
||||
}
|
||||
|
||||
fun readInt64(): Long {
|
||||
checkRemainingBytes(Long.SIZE_BYTES)
|
||||
val result = ByteBuffer.wrap(data, position, Long.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).long
|
||||
position += Long.SIZE_BYTES
|
||||
return result
|
||||
}
|
||||
|
||||
fun readSingle(): Float {
|
||||
checkRemainingBytes(Float.SIZE_BYTES)
|
||||
val result = ByteBuffer.wrap(data, position, Float.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).float
|
||||
position += Float.SIZE_BYTES
|
||||
return result
|
||||
}
|
||||
|
||||
fun readByte(): Byte {
|
||||
checkRemainingBytes(Byte.SIZE_BYTES)
|
||||
return data[position++]
|
||||
}
|
||||
|
||||
fun readBytes(length: Int): ByteArray {
|
||||
checkRemainingBytes(length)
|
||||
return ByteArray(length).also { data.copyInto(it, startIndex = position, endIndex = position + length) }
|
||||
.also { position += length }
|
||||
}
|
||||
|
||||
fun readUInt16(): UShort {
|
||||
checkRemainingBytes(Short.SIZE_BYTES)
|
||||
val result = ByteBuffer.wrap(data, position, Short.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).short.toUShort()
|
||||
position += Short.SIZE_BYTES
|
||||
return result
|
||||
}
|
||||
|
||||
fun readUInt32(): UInt {
|
||||
checkRemainingBytes(Int.SIZE_BYTES)
|
||||
val result = ByteBuffer.wrap(data, position, Int.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).int.toUInt()
|
||||
position += Int.SIZE_BYTES
|
||||
return result
|
||||
}
|
||||
|
||||
fun readUInt64(): ULong {
|
||||
checkRemainingBytes(Long.SIZE_BYTES)
|
||||
val result = ByteBuffer.wrap(data, position, Long.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).long.toULong()
|
||||
position += Long.SIZE_BYTES
|
||||
return result
|
||||
}
|
||||
|
||||
fun readString(): String {
|
||||
val length = data[position++].toInt()
|
||||
checkRemainingBytes(length)
|
||||
return String(data, position, length, StandardCharsets.UTF_8).also { position += length }
|
||||
}
|
||||
|
||||
private fun checkRemainingBytes(requiredBytes: Int) {
|
||||
if (position + requiredBytes > endPosition) throw IndexOutOfBoundsException()
|
||||
}
|
||||
|
||||
fun readRPRecord(): RPRecord {
|
||||
return RPRecord(readDomainName(), readDomainName())
|
||||
}
|
||||
|
||||
fun readKXRecord(): KXRecord {
|
||||
val preference = readUInt16()
|
||||
val exchanger = readDomainName()
|
||||
return KXRecord(preference, exchanger)
|
||||
}
|
||||
|
||||
fun readCERTRecord(): CERTRecord {
|
||||
val type = readUInt16()
|
||||
val keyTag = readUInt16()
|
||||
val algorithm = readByte()
|
||||
val certificateLength = readUInt16().toInt() - 5
|
||||
val certificate = readBytes(certificateLength)
|
||||
return CERTRecord(type, keyTag, algorithm, certificate)
|
||||
}
|
||||
|
||||
fun readPTRRecord(): PTRRecord {
|
||||
return PTRRecord(readDomainName())
|
||||
}
|
||||
|
||||
fun readARecord(): ARecord {
|
||||
val address = readBytes(4)
|
||||
return ARecord(InetAddress.getByAddress(address))
|
||||
}
|
||||
|
||||
fun readAAAARecord(): AAAARecord {
|
||||
val address = readBytes(16)
|
||||
return AAAARecord(InetAddress.getByAddress(address))
|
||||
}
|
||||
|
||||
fun readMXRecord(): MXRecord {
|
||||
val preference = readUInt16()
|
||||
val exchange = readDomainName()
|
||||
return MXRecord(preference, exchange)
|
||||
}
|
||||
|
||||
fun readCNAMERecord(): CNAMERecord {
|
||||
return CNAMERecord(readDomainName())
|
||||
}
|
||||
|
||||
fun readTXTRecord(): TXTRecord {
|
||||
val texts = mutableListOf<String>()
|
||||
while (position < endPosition) {
|
||||
val textLength = data[position++].toInt()
|
||||
checkRemainingBytes(textLength)
|
||||
val text = String(data, position, textLength, StandardCharsets.UTF_8)
|
||||
texts.add(text)
|
||||
position += textLength
|
||||
}
|
||||
return TXTRecord(texts)
|
||||
}
|
||||
|
||||
fun readSOARecord(): SOARecord {
|
||||
val primaryNameServer = readDomainName()
|
||||
val responsibleAuthorityMailbox = readDomainName()
|
||||
val serialNumber = readInt32()
|
||||
val refreshInterval = readInt32()
|
||||
val retryInterval = readInt32()
|
||||
val expiryLimit = readInt32()
|
||||
val minimumTTL = readInt32()
|
||||
return SOARecord(primaryNameServer, responsibleAuthorityMailbox, serialNumber, refreshInterval, retryInterval, expiryLimit, minimumTTL)
|
||||
}
|
||||
|
||||
fun readSRVRecord(): SRVRecord {
|
||||
val priority = readUInt16()
|
||||
val weight = readUInt16()
|
||||
val port = readUInt16()
|
||||
val target = readDomainName()
|
||||
return SRVRecord(priority, weight, port, target)
|
||||
}
|
||||
|
||||
fun readNSRecord(): NSRecord {
|
||||
return NSRecord(readDomainName())
|
||||
}
|
||||
|
||||
fun readCAARecord(): CAARecord {
|
||||
val length = readUInt16().toInt()
|
||||
val flags = readByte()
|
||||
val tagLength = readByte().toInt()
|
||||
val tag = String(data, position, tagLength, StandardCharsets.US_ASCII).also { position += tagLength }
|
||||
val valueLength = length - 1 - 1 - tagLength
|
||||
val value = String(data, position, valueLength, StandardCharsets.US_ASCII).also { position += valueLength }
|
||||
return CAARecord(flags, tag, value)
|
||||
}
|
||||
|
||||
fun readHINFORecord(): HINFORecord {
|
||||
val cpuLength = readByte().toInt()
|
||||
val cpu = String(data, position, cpuLength, StandardCharsets.US_ASCII).also { position += cpuLength }
|
||||
val osLength = readByte().toInt()
|
||||
val os = String(data, position, osLength, StandardCharsets.US_ASCII).also { position += osLength }
|
||||
return HINFORecord(cpu, os)
|
||||
}
|
||||
|
||||
fun readAFSDBRecord(): AFSDBRecord {
|
||||
return AFSDBRecord(readUInt16(), readDomainName())
|
||||
}
|
||||
|
||||
fun readLOCRecord(): LOCRecord {
|
||||
val version = readByte()
|
||||
val size = LOCRecord.decodeSizeOrPrecision(readByte())
|
||||
val horizontalPrecision = LOCRecord.decodeSizeOrPrecision(readByte())
|
||||
val verticalPrecision = LOCRecord.decodeSizeOrPrecision(readByte())
|
||||
val latitudeCoded = readInt32()
|
||||
val longitudeCoded = readInt32()
|
||||
val altitudeCoded = readInt32()
|
||||
val latitude = LOCRecord.decodeLatitudeOrLongitude(latitudeCoded)
|
||||
val longitude = LOCRecord.decodeLatitudeOrLongitude(longitudeCoded)
|
||||
val altitude = LOCRecord.decodeAltitude(altitudeCoded)
|
||||
return LOCRecord(version, size, horizontalPrecision, verticalPrecision, latitude, longitude, altitude)
|
||||
}
|
||||
|
||||
fun readNAPTRRecord(): NAPTRRecord {
|
||||
val order = readUInt16()
|
||||
val preference = readUInt16()
|
||||
val flags = readString()
|
||||
val services = readString()
|
||||
val regexp = readString()
|
||||
val replacement = readDomainName()
|
||||
return NAPTRRecord(order, preference, flags, services, regexp, replacement)
|
||||
}
|
||||
|
||||
fun readDNAMERecord(): DNAMERecord {
|
||||
return DNAMERecord(readDomainName())
|
||||
}
|
||||
|
||||
fun readDSRecord(): DSRecord {
|
||||
val keyTag = readUInt16()
|
||||
val algorithm = readByte()
|
||||
val digestType = readByte()
|
||||
val digestLength = readUInt16().toInt() - 4
|
||||
val digest = readBytes(digestLength)
|
||||
return DSRecord(keyTag, algorithm, digestType, digest)
|
||||
}
|
||||
|
||||
fun readSSHFPRecord(): SSHFPRecord {
|
||||
val algorithm = readByte()
|
||||
val fingerprintType = readByte()
|
||||
val fingerprintLength = readUInt16().toInt() - 2
|
||||
val fingerprint = readBytes(fingerprintLength)
|
||||
return SSHFPRecord(algorithm, fingerprintType, fingerprint)
|
||||
}
|
||||
|
||||
fun readTLSARecord(): TLSARecord {
|
||||
val usage = readByte()
|
||||
val selector = readByte()
|
||||
val matchingType = readByte()
|
||||
val dataLength = readUInt16().toInt() - 3
|
||||
val certificateAssociationData = readBytes(dataLength)
|
||||
return TLSARecord(usage, selector, matchingType, certificateAssociationData)
|
||||
}
|
||||
|
||||
fun readSMIMEARecord(): SMIMEARecord {
|
||||
val usage = readByte()
|
||||
val selector = readByte()
|
||||
val matchingType = readByte()
|
||||
val dataLength = readUInt16().toInt() - 3
|
||||
val certificateAssociationData = readBytes(dataLength)
|
||||
return SMIMEARecord(usage, selector, matchingType, certificateAssociationData)
|
||||
}
|
||||
|
||||
fun readURIRecord(): URIRecord {
|
||||
val priority = readUInt16()
|
||||
val weight = readUInt16()
|
||||
val length = readUInt16().toInt()
|
||||
val target = String(data, position, length, StandardCharsets.US_ASCII).also { position += length }
|
||||
return URIRecord(priority, weight, target)
|
||||
}
|
||||
|
||||
fun readRRSIGRecord(): RRSIGRecord {
|
||||
val typeCovered = readUInt16()
|
||||
val algorithm = readByte()
|
||||
val labels = readByte()
|
||||
val originalTTL = readUInt32()
|
||||
val signatureExpiration = readUInt32()
|
||||
val signatureInception = readUInt32()
|
||||
val keyTag = readUInt16()
|
||||
val signersName = readDomainName()
|
||||
val signatureLength = readUInt16().toInt()
|
||||
val signature = readBytes(signatureLength)
|
||||
return RRSIGRecord(
|
||||
typeCovered,
|
||||
algorithm,
|
||||
labels,
|
||||
originalTTL,
|
||||
signatureExpiration,
|
||||
signatureInception,
|
||||
keyTag,
|
||||
signersName,
|
||||
signature
|
||||
)
|
||||
}
|
||||
|
||||
fun readNSECRecord(): NSECRecord {
|
||||
val ownerName = readDomainName()
|
||||
val typeBitMaps = mutableListOf<Pair<Byte, ByteArray>>()
|
||||
while (position < endPosition) {
|
||||
val windowBlock = readByte()
|
||||
val bitmapLength = readByte().toInt()
|
||||
val bitmap = readBytes(bitmapLength)
|
||||
typeBitMaps.add(windowBlock to bitmap)
|
||||
}
|
||||
return NSECRecord(ownerName, typeBitMaps)
|
||||
}
|
||||
|
||||
fun readNSEC3Record(): NSEC3Record {
|
||||
val hashAlgorithm = readByte()
|
||||
val flags = readByte()
|
||||
val iterations = readUInt16()
|
||||
val saltLength = readByte().toInt()
|
||||
val salt = readBytes(saltLength)
|
||||
val hashLength = readByte().toInt()
|
||||
val nextHashedOwnerName = readBytes(hashLength)
|
||||
val bitMapLength = readUInt16().toInt()
|
||||
val typeBitMaps = mutableListOf<UShort>()
|
||||
val endPos = position + bitMapLength
|
||||
while (position < endPos) {
|
||||
typeBitMaps.add(readUInt16())
|
||||
}
|
||||
return NSEC3Record(hashAlgorithm, flags, iterations, salt, nextHashedOwnerName, typeBitMaps)
|
||||
}
|
||||
|
||||
fun readNSEC3PARAMRecord(): NSEC3PARAMRecord {
|
||||
val hashAlgorithm = readByte()
|
||||
val flags = readByte()
|
||||
val iterations = readUInt16()
|
||||
val saltLength = readByte().toInt()
|
||||
val salt = readBytes(saltLength)
|
||||
return NSEC3PARAMRecord(hashAlgorithm, flags, iterations, salt)
|
||||
}
|
||||
|
||||
|
||||
fun readSPFRecord(): SPFRecord {
|
||||
val length = readUInt16().toInt()
|
||||
val texts = mutableListOf<String>()
|
||||
val endPos = position + length
|
||||
while (position < endPos) {
|
||||
val textLength = readByte().toInt()
|
||||
val text = String(data, position, textLength, StandardCharsets.US_ASCII).also { position += textLength }
|
||||
texts.add(text)
|
||||
}
|
||||
return SPFRecord(texts)
|
||||
}
|
||||
|
||||
fun readTKEYRecord(): TKEYRecord {
|
||||
val algorithm = readDomainName()
|
||||
val inception = readUInt32()
|
||||
val expiration = readUInt32()
|
||||
val mode = readUInt16()
|
||||
val error = readUInt16()
|
||||
val keySize = readUInt16().toInt()
|
||||
val keyData = readBytes(keySize)
|
||||
val otherSize = readUInt16().toInt()
|
||||
val otherData = readBytes(otherSize)
|
||||
return TKEYRecord(algorithm, inception, expiration, mode, error, keyData, otherData)
|
||||
}
|
||||
|
||||
fun readTSIGRecord(): TSIGRecord {
|
||||
val algorithmName = readDomainName()
|
||||
val timeSigned = readUInt32()
|
||||
val fudge = readUInt16()
|
||||
val macSize = readUInt16().toInt()
|
||||
val mac = readBytes(macSize)
|
||||
val originalID = readUInt16()
|
||||
val error = readUInt16()
|
||||
val otherSize = readUInt16().toInt()
|
||||
val otherData = readBytes(otherSize)
|
||||
return TSIGRecord(algorithmName, timeSigned, fudge, mac, originalID, error, otherData)
|
||||
}
|
||||
|
||||
|
||||
|
||||
fun readOPTRecord(): OPTRecord {
|
||||
val options = mutableListOf<OPTRecordOption>()
|
||||
while (position < endPosition) {
|
||||
val optionCode = readUInt16()
|
||||
val optionLength = readUInt16().toInt()
|
||||
val optionData = readBytes(optionLength)
|
||||
options.add(OPTRecordOption(optionCode, optionData))
|
||||
}
|
||||
return OPTRecord(options)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.futo.platformplayer.mdns
|
||||
|
||||
import com.futo.platformplayer.mdns.Extensions.readDomainName
|
||||
|
||||
enum class ResourceRecordType(val value: UShort) {
|
||||
None(0u),
|
||||
A(1u),
|
||||
NS(2u),
|
||||
MD(3u),
|
||||
MF(4u),
|
||||
CNAME(5u),
|
||||
SOA(6u),
|
||||
MB(7u),
|
||||
MG(8u),
|
||||
MR(9u),
|
||||
NULL(10u),
|
||||
WKS(11u),
|
||||
PTR(12u),
|
||||
HINFO(13u),
|
||||
MINFO(14u),
|
||||
MX(15u),
|
||||
TXT(16u),
|
||||
RP(17u),
|
||||
AFSDB(18u),
|
||||
SIG(24u),
|
||||
KEY(25u),
|
||||
AAAA(28u),
|
||||
LOC(29u),
|
||||
SRV(33u),
|
||||
NAPTR(35u),
|
||||
KX(36u),
|
||||
CERT(37u),
|
||||
DNAME(39u),
|
||||
APL(42u),
|
||||
DS(43u),
|
||||
SSHFP(44u),
|
||||
IPSECKEY(45u),
|
||||
RRSIG(46u),
|
||||
NSEC(47u),
|
||||
DNSKEY(48u),
|
||||
DHCID(49u),
|
||||
NSEC3(50u),
|
||||
NSEC3PARAM(51u),
|
||||
TSLA(52u),
|
||||
SMIMEA(53u),
|
||||
HIP(55u),
|
||||
CDS(59u),
|
||||
CDNSKEY(60u),
|
||||
OPENPGPKEY(61u),
|
||||
CSYNC(62u),
|
||||
ZONEMD(63u),
|
||||
SVCB(64u),
|
||||
HTTPS(65u),
|
||||
EUI48(108u),
|
||||
EUI64(109u),
|
||||
TKEY(249u),
|
||||
TSIG(250u),
|
||||
URI(256u),
|
||||
CAA(257u),
|
||||
TA(32768u),
|
||||
DLV(32769u),
|
||||
AXFR(252u),
|
||||
IXFR(251u),
|
||||
OPT(41u)
|
||||
}
|
||||
|
||||
enum class ResourceRecordClass(val value: UShort) {
|
||||
IN(1u),
|
||||
CS(2u),
|
||||
CH(3u),
|
||||
HS(4u)
|
||||
}
|
||||
|
||||
data class DnsResourceRecord(
|
||||
override val name: String,
|
||||
override val type: Int,
|
||||
override val clazz: Int,
|
||||
val timeToLive: UInt,
|
||||
val cacheFlush: Boolean,
|
||||
val dataPosition: Int = -1,
|
||||
val dataLength: Int = -1,
|
||||
private val data: ByteArray? = null
|
||||
) : DnsResourceRecordBase(name, type, clazz) {
|
||||
|
||||
companion object {
|
||||
fun parse(data: ByteArray, startPosition: Int): Pair<DnsResourceRecord, Int> {
|
||||
val span = data.asUByteArray()
|
||||
var position = startPosition
|
||||
val name = span.readDomainName(position).also { position = it.second }
|
||||
val type = (span[position].toInt() shl 8 or span[position + 1].toInt()).toUShort()
|
||||
position += 2
|
||||
val clazz = (span[position].toInt() shl 8 or span[position + 1].toInt()).toUShort()
|
||||
position += 2
|
||||
val ttl = (span[position].toInt() shl 24 or (span[position + 1].toInt() shl 16) or
|
||||
(span[position + 2].toInt() shl 8) or span[position + 3].toInt()).toUInt()
|
||||
position += 4
|
||||
val rdlength = (span[position].toInt() shl 8 or span[position + 1].toInt()).toUShort()
|
||||
val rdposition = position + 2
|
||||
position += 2 + rdlength.toInt()
|
||||
|
||||
return DnsResourceRecord(
|
||||
name = name.first,
|
||||
type = type.toInt(),
|
||||
clazz = clazz.toInt() and 0b1111111_11111111,
|
||||
timeToLive = ttl,
|
||||
cacheFlush = ((clazz.toInt() shr 15) and 0b1) != 0,
|
||||
dataPosition = rdposition,
|
||||
dataLength = rdlength.toInt(),
|
||||
data = data
|
||||
) to position
|
||||
}
|
||||
}
|
||||
|
||||
fun getDataReader(): DnsReader {
|
||||
return DnsReader(data!!, dataPosition, dataLength)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package com.futo.platformplayer.mdns
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
class DnsWriter {
|
||||
private val data = mutableListOf<Byte>()
|
||||
private val namePositions = mutableMapOf<String, Int>()
|
||||
|
||||
fun toByteArray(): ByteArray = data.toByteArray()
|
||||
|
||||
fun writePacket(
|
||||
header: DnsPacketHeader,
|
||||
questionCount: Int? = null, questionWriter: ((DnsWriter, Int) -> Unit)? = null,
|
||||
answerCount: Int? = null, answerWriter: ((DnsWriter, Int) -> Unit)? = null,
|
||||
authorityCount: Int? = null, authorityWriter: ((DnsWriter, Int) -> Unit)? = null,
|
||||
additionalsCount: Int? = null, additionalWriter: ((DnsWriter, Int) -> Unit)? = null
|
||||
) {
|
||||
if (questionCount != null && questionWriter == null || questionCount == null && questionWriter != null)
|
||||
throw Exception("When question count is given, question writer should also be given.")
|
||||
if (answerCount != null && answerWriter == null || answerCount == null && answerWriter != null)
|
||||
throw Exception("When answer count is given, answer writer should also be given.")
|
||||
if (authorityCount != null && authorityWriter == null || authorityCount == null && authorityWriter != null)
|
||||
throw Exception("When authority count is given, authority writer should also be given.")
|
||||
if (additionalsCount != null && additionalWriter == null || additionalsCount == null && additionalWriter != null)
|
||||
throw Exception("When additionals count is given, additional writer should also be given.")
|
||||
|
||||
writeHeader(header, questionCount ?: 0, answerCount ?: 0, authorityCount ?: 0, additionalsCount ?: 0)
|
||||
|
||||
repeat(questionCount ?: 0) { questionWriter?.invoke(this, it) }
|
||||
repeat(answerCount ?: 0) { answerWriter?.invoke(this, it) }
|
||||
repeat(authorityCount ?: 0) { authorityWriter?.invoke(this, it) }
|
||||
repeat(additionalsCount ?: 0) { additionalWriter?.invoke(this, it) }
|
||||
}
|
||||
|
||||
fun writeHeader(header: DnsPacketHeader, questionCount: Int, answerCount: Int, authorityCount: Int, additionalsCount: Int) {
|
||||
write(header.identifier)
|
||||
|
||||
var flags: UShort = 0u
|
||||
flags = flags or ((header.queryResponse.toUInt() and 0xFFFFu) shl 15).toUShort()
|
||||
flags = flags or ((header.opcode.toUInt() and 0xFFFFu) shl 11).toUShort()
|
||||
flags = flags or ((if (header.authoritativeAnswer) 1u else 0u) shl 10).toUShort()
|
||||
flags = flags or ((if (header.truncated) 1u else 0u) shl 9).toUShort()
|
||||
flags = flags or ((if (header.recursionDesired) 1u else 0u) shl 8).toUShort()
|
||||
flags = flags or ((if (header.recursionAvailable) 1u else 0u) shl 7).toUShort()
|
||||
flags = flags or ((if (header.answerAuthenticated) 1u else 0u) shl 5).toUShort()
|
||||
flags = flags or ((if (header.nonAuthenticatedData) 1u else 0u) shl 4).toUShort()
|
||||
flags = flags or header.responseCode.value.toUShort()
|
||||
write(flags)
|
||||
|
||||
write(questionCount.toUShort())
|
||||
write(answerCount.toUShort())
|
||||
write(authorityCount.toUShort())
|
||||
write(additionalsCount.toUShort())
|
||||
}
|
||||
|
||||
fun writeDomainName(name: String) {
|
||||
synchronized(namePositions) {
|
||||
val labels = name.split('.')
|
||||
for (label in labels) {
|
||||
val nameAtOffset = name.substring(name.indexOf(label))
|
||||
if (namePositions.containsKey(nameAtOffset)) {
|
||||
val position = namePositions[nameAtOffset]!!
|
||||
val pointer = (0b11000000_00000000 or position).toUShort()
|
||||
write(pointer)
|
||||
return
|
||||
}
|
||||
if (label.isNotEmpty()) {
|
||||
val labelBytes = label.toByteArray(StandardCharsets.UTF_8)
|
||||
val nameStartPos = data.size
|
||||
write(labelBytes.size.toByte())
|
||||
write(labelBytes)
|
||||
namePositions[nameAtOffset] = nameStartPos
|
||||
}
|
||||
}
|
||||
write(0.toByte()) // End of domain name
|
||||
}
|
||||
}
|
||||
|
||||
fun write(value: DnsResourceRecord, dataWriter: (DnsWriter) -> Unit) {
|
||||
writeDomainName(value.name)
|
||||
write(value.type.toUShort())
|
||||
val cls = ((if (value.cacheFlush) 1u else 0u) shl 15).toUShort() or value.clazz.toUShort()
|
||||
write(cls)
|
||||
write(value.timeToLive)
|
||||
|
||||
val lengthOffset = data.size
|
||||
write(0.toUShort())
|
||||
dataWriter(this)
|
||||
val rdLength = data.size - lengthOffset - 2
|
||||
val rdLengthBytes = ByteBuffer.allocate(2).order(ByteOrder.BIG_ENDIAN).putShort(rdLength.toShort()).array()
|
||||
data[lengthOffset] = rdLengthBytes[0]
|
||||
data[lengthOffset + 1] = rdLengthBytes[1]
|
||||
}
|
||||
|
||||
fun write(value: DnsQuestion) {
|
||||
writeDomainName(value.name)
|
||||
write(value.type.toUShort())
|
||||
write(((if (value.queryUnicast) 1u else 0u shl 15).toUShort() or value.clazz.toUShort()))
|
||||
}
|
||||
|
||||
fun write(value: Double) {
|
||||
val bytes = ByteBuffer.allocate(Double.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putDouble(value).array()
|
||||
write(bytes)
|
||||
}
|
||||
|
||||
fun write(value: Short) {
|
||||
val bytes = ByteBuffer.allocate(Short.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putShort(value).array()
|
||||
write(bytes)
|
||||
}
|
||||
|
||||
fun write(value: Int) {
|
||||
val bytes = ByteBuffer.allocate(Int.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putInt(value).array()
|
||||
write(bytes)
|
||||
}
|
||||
|
||||
fun write(value: Long) {
|
||||
val bytes = ByteBuffer.allocate(Long.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putLong(value).array()
|
||||
write(bytes)
|
||||
}
|
||||
|
||||
fun write(value: Float) {
|
||||
val bytes = ByteBuffer.allocate(Float.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putFloat(value).array()
|
||||
write(bytes)
|
||||
}
|
||||
|
||||
fun write(value: Byte) {
|
||||
data.add(value)
|
||||
}
|
||||
|
||||
fun write(value: ByteArray) {
|
||||
data.addAll(value.asIterable())
|
||||
}
|
||||
|
||||
fun write(value: ByteArray, offset: Int, length: Int) {
|
||||
data.addAll(value.slice(offset until offset + length))
|
||||
}
|
||||
|
||||
fun write(value: UShort) {
|
||||
val bytes = ByteBuffer.allocate(Short.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putShort(value.toShort()).array()
|
||||
write(bytes)
|
||||
}
|
||||
|
||||
fun write(value: UInt) {
|
||||
val bytes = ByteBuffer.allocate(Int.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putInt(value.toInt()).array()
|
||||
write(bytes)
|
||||
}
|
||||
|
||||
fun write(value: ULong) {
|
||||
val bytes = ByteBuffer.allocate(Long.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putLong(value.toLong()).array()
|
||||
write(bytes)
|
||||
}
|
||||
|
||||
fun write(value: String) {
|
||||
val bytes = value.toByteArray(StandardCharsets.UTF_8)
|
||||
write(bytes.size.toByte())
|
||||
write(bytes)
|
||||
}
|
||||
|
||||
fun write(value: PTRRecord) {
|
||||
writeDomainName(value.domainName)
|
||||
}
|
||||
|
||||
fun write(value: ARecord) {
|
||||
val bytes = value.address.address
|
||||
if (bytes.size != 4) throw Exception("Unexpected amount of address bytes.")
|
||||
write(bytes)
|
||||
}
|
||||
|
||||
fun write(value: AAAARecord) {
|
||||
val bytes = value.address.address
|
||||
if (bytes.size != 16) throw Exception("Unexpected amount of address bytes.")
|
||||
write(bytes)
|
||||
}
|
||||
|
||||
fun write(value: TXTRecord) {
|
||||
value.texts.forEach {
|
||||
val bytes = it.toByteArray(StandardCharsets.UTF_8)
|
||||
write(bytes.size.toByte())
|
||||
write(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
fun write(value: SRVRecord) {
|
||||
write(value.priority)
|
||||
write(value.weight)
|
||||
write(value.port)
|
||||
writeDomainName(value.target)
|
||||
}
|
||||
|
||||
fun write(value: NSECRecord) {
|
||||
writeDomainName(value.ownerName)
|
||||
value.typeBitMaps.forEach { (windowBlock, bitmap) ->
|
||||
write(windowBlock)
|
||||
write(bitmap.size.toByte())
|
||||
write(bitmap)
|
||||
}
|
||||
}
|
||||
|
||||
fun write(value: OPTRecord) {
|
||||
value.options.forEach { option ->
|
||||
write(option.code)
|
||||
write(option.data.size.toUShort())
|
||||
write(option.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.futo.platformplayer.mdns
|
||||
|
||||
import android.util.Log
|
||||
|
||||
object Extensions {
|
||||
fun ByteArray.toByteDump(): String {
|
||||
val result = StringBuilder()
|
||||
for (i in indices) {
|
||||
result.append(String.format("%02X ", this[i]))
|
||||
|
||||
if ((i + 1) % 16 == 0 || i == size - 1) {
|
||||
val padding = 3 * (16 - (i % 16 + 1))
|
||||
if (i == size - 1 && (i + 1) % 16 != 0) result.append(" ".repeat(padding))
|
||||
|
||||
result.append("; ")
|
||||
val start = i - (i % 16)
|
||||
val end = minOf(i, size - 1)
|
||||
for (j in start..end) {
|
||||
val ch = if (this[j] in 32..127) this[j].toChar() else '.'
|
||||
result.append(ch)
|
||||
}
|
||||
if (i != size - 1) result.appendLine()
|
||||
}
|
||||
}
|
||||
return result.toString()
|
||||
}
|
||||
|
||||
fun UByteArray.readDomainName(startPosition: Int): Pair<String, Int> {
|
||||
var position = startPosition
|
||||
return readDomainName(position, 0)
|
||||
}
|
||||
|
||||
private fun UByteArray.readDomainName(position: Int, depth: Int = 0): Pair<String, Int> {
|
||||
if (depth > 16) throw Exception("Exceeded maximum recursion depth in DNS packet. Possible circular reference.")
|
||||
|
||||
val domainParts = mutableListOf<String>()
|
||||
var newPosition = position
|
||||
|
||||
while (true) {
|
||||
if (newPosition < 0)
|
||||
println()
|
||||
|
||||
val length = this[newPosition].toUByte()
|
||||
if ((length and 0b11000000u).toUInt() == 0b11000000u) {
|
||||
val offset = (((length and 0b00111111u).toUInt()) shl 8) or this[newPosition + 1].toUInt()
|
||||
val (part, _) = this.readDomainName(offset.toInt(), depth + 1)
|
||||
domainParts.add(part)
|
||||
newPosition += 2
|
||||
break
|
||||
} else if (length.toUInt() == 0u) {
|
||||
newPosition++
|
||||
break
|
||||
} else {
|
||||
newPosition++
|
||||
val part = String(this.asByteArray(), newPosition, length.toInt(), Charsets.UTF_8)
|
||||
domainParts.add(part)
|
||||
newPosition += length.toInt()
|
||||
}
|
||||
}
|
||||
|
||||
return domainParts.joinToString(".") to newPosition
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
package com.futo.platformplayer.mdns
|
||||
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import kotlinx.coroutines.*
|
||||
import java.net.*
|
||||
import java.util.*
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import kotlin.concurrent.withLock
|
||||
|
||||
class MDNSListener {
|
||||
companion object {
|
||||
private val TAG = "MDNSListener"
|
||||
const val MulticastPort = 5353
|
||||
val MulticastAddressIPv4: InetAddress = InetAddress.getByName("224.0.0.251")
|
||||
val MulticastAddressIPv6: InetAddress = InetAddress.getByName("FF02::FB")
|
||||
val MdnsEndpointIPv6: InetSocketAddress = InetSocketAddress(MulticastAddressIPv6, MulticastPort)
|
||||
val MdnsEndpointIPv4: InetSocketAddress = InetSocketAddress(MulticastAddressIPv4, MulticastPort)
|
||||
}
|
||||
|
||||
private val _lockObject = ReentrantLock()
|
||||
private var _receiver4: MulticastSocket? = null
|
||||
private var _receiver6: MulticastSocket? = null
|
||||
private val _senders = mutableListOf<MulticastSocket>()
|
||||
private val _nicMonitor = NICMonitor()
|
||||
private val _serviceRecordAggregator = ServiceRecordAggregator()
|
||||
private var _started = false
|
||||
private var _threadReceiver4: Thread? = null
|
||||
private var _threadReceiver6: Thread? = null
|
||||
private var _scope: CoroutineScope? = null
|
||||
|
||||
var onPacket: ((DnsPacket) -> Unit)? = null
|
||||
var onServicesUpdated: ((List<DnsService>) -> Unit)? = null
|
||||
|
||||
private val _recordLockObject = ReentrantLock()
|
||||
private val _recordsA = mutableListOf<Pair<DnsResourceRecord, ARecord>>()
|
||||
private val _recordsAAAA = mutableListOf<Pair<DnsResourceRecord, AAAARecord>>()
|
||||
private val _recordsPTR = mutableListOf<Pair<DnsResourceRecord, PTRRecord>>()
|
||||
private val _recordsTXT = mutableListOf<Pair<DnsResourceRecord, TXTRecord>>()
|
||||
private val _recordsSRV = mutableListOf<Pair<DnsResourceRecord, SRVRecord>>()
|
||||
private val _services = mutableListOf<BroadcastService>()
|
||||
|
||||
init {
|
||||
_nicMonitor.added = { onNicsAdded(it) }
|
||||
_nicMonitor.removed = { onNicsRemoved(it) }
|
||||
_serviceRecordAggregator.onServicesUpdated = { onServicesUpdated?.invoke(it) }
|
||||
}
|
||||
|
||||
fun start() {
|
||||
if (_started) throw Exception("Already running.")
|
||||
_started = true
|
||||
|
||||
_scope = CoroutineScope(Dispatchers.IO);
|
||||
|
||||
Logger.i(TAG, "Starting")
|
||||
_lockObject.withLock {
|
||||
val receiver4 = MulticastSocket(null).apply {
|
||||
reuseAddress = true
|
||||
bind(InetSocketAddress(InetAddress.getByName("0.0.0.0"), MulticastPort))
|
||||
}
|
||||
_receiver4 = receiver4
|
||||
|
||||
val receiver6 = MulticastSocket(null).apply {
|
||||
reuseAddress = true
|
||||
bind(InetSocketAddress(InetAddress.getByName("::"), MulticastPort))
|
||||
}
|
||||
_receiver6 = receiver6
|
||||
|
||||
_nicMonitor.start()
|
||||
_serviceRecordAggregator.start()
|
||||
onNicsAdded(_nicMonitor.current)
|
||||
|
||||
_threadReceiver4 = Thread {
|
||||
receiveLoop(receiver4)
|
||||
}.apply { start() }
|
||||
|
||||
_threadReceiver6 = Thread {
|
||||
receiveLoop(receiver6)
|
||||
}.apply { start() }
|
||||
}
|
||||
}
|
||||
|
||||
fun queryServices(names: Array<String>) {
|
||||
if (names.isEmpty()) throw IllegalArgumentException("At least one name must be specified.")
|
||||
|
||||
val writer = DnsWriter()
|
||||
writer.writePacket(
|
||||
DnsPacketHeader(
|
||||
identifier = 0u,
|
||||
queryResponse = QueryResponse.Query.value.toInt(),
|
||||
opcode = DnsOpcode.StandardQuery.value.toInt(),
|
||||
truncated = false,
|
||||
nonAuthenticatedData = false,
|
||||
recursionDesired = false,
|
||||
answerAuthenticated = false,
|
||||
authoritativeAnswer = false,
|
||||
recursionAvailable = false,
|
||||
responseCode = DnsResponseCode.NoError
|
||||
),
|
||||
questionCount = names.size,
|
||||
questionWriter = { w, i ->
|
||||
w.write(
|
||||
DnsQuestion(
|
||||
name = names[i],
|
||||
type = QuestionType.PTR.value.toInt(),
|
||||
clazz = QuestionClass.IN.value.toInt(),
|
||||
queryUnicast = false
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
send(writer.toByteArray())
|
||||
}
|
||||
|
||||
private fun send(data: ByteArray) {
|
||||
_lockObject.withLock {
|
||||
for (sender in _senders) {
|
||||
try {
|
||||
val endPoint = if (sender.localAddress is Inet4Address) MdnsEndpointIPv4 else MdnsEndpointIPv6
|
||||
sender.send(DatagramPacket(data, data.size, endPoint))
|
||||
} catch (e: Exception) {
|
||||
Logger.i(TAG, "Failed to send on ${sender.localSocketAddress}: ${e.message}.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun queryAllQuestions(names: Array<String>) {
|
||||
if (names.isEmpty()) throw IllegalArgumentException("At least one name must be specified.")
|
||||
|
||||
val questions = names.flatMap { _serviceRecordAggregator.getAllQuestions(it) }
|
||||
questions.groupBy { it.name }.forEach { (_, questionsForHost) ->
|
||||
val writer = DnsWriter()
|
||||
writer.writePacket(
|
||||
DnsPacketHeader(
|
||||
identifier = 0u,
|
||||
queryResponse = QueryResponse.Query.value.toInt(),
|
||||
opcode = DnsOpcode.StandardQuery.value.toInt(),
|
||||
truncated = false,
|
||||
nonAuthenticatedData = false,
|
||||
recursionDesired = false,
|
||||
answerAuthenticated = false,
|
||||
authoritativeAnswer = false,
|
||||
recursionAvailable = false,
|
||||
responseCode = DnsResponseCode.NoError
|
||||
),
|
||||
questionCount = questionsForHost.size,
|
||||
questionWriter = { w, i -> w.write(questionsForHost[i]) }
|
||||
)
|
||||
send(writer.toByteArray())
|
||||
}
|
||||
}
|
||||
|
||||
private fun onNicsAdded(nics: List<NetworkInterface>) {
|
||||
_lockObject.withLock {
|
||||
if (!_started) return
|
||||
|
||||
val addresses = nics.flatMap { nic ->
|
||||
nic.interfaceAddresses.map { it.address }
|
||||
.filter { it is Inet4Address || (it is Inet6Address && it.isLinkLocalAddress) }
|
||||
}
|
||||
|
||||
addresses.forEach { address ->
|
||||
Logger.i(TAG, "New address discovered $address")
|
||||
|
||||
try {
|
||||
when (address) {
|
||||
is Inet4Address -> {
|
||||
_receiver4?.let { receiver4 ->
|
||||
//receiver4.setOption(StandardSocketOptions.IP_MULTICAST_IF, NetworkInterface.getByInetAddress(address))
|
||||
receiver4.joinGroup(InetSocketAddress(MulticastAddressIPv4, MulticastPort), NetworkInterface.getByInetAddress(address))
|
||||
}
|
||||
|
||||
val sender = MulticastSocket(null).apply {
|
||||
reuseAddress = true
|
||||
bind(InetSocketAddress(address, MulticastPort))
|
||||
joinGroup(InetSocketAddress(MulticastAddressIPv4, MulticastPort), NetworkInterface.getByInetAddress(address))
|
||||
}
|
||||
_senders.add(sender)
|
||||
}
|
||||
|
||||
is Inet6Address -> {
|
||||
_receiver6?.let { receiver6 ->
|
||||
//receiver6.setOption(StandardSocketOptions.IP_MULTICAST_IF, NetworkInterface.getByInetAddress(address))
|
||||
receiver6.joinGroup(InetSocketAddress(MulticastAddressIPv6, MulticastPort), NetworkInterface.getByInetAddress(address))
|
||||
}
|
||||
|
||||
val sender = MulticastSocket(null).apply {
|
||||
reuseAddress = true
|
||||
bind(InetSocketAddress(address, MulticastPort))
|
||||
joinGroup(InetSocketAddress(MulticastAddressIPv6, MulticastPort), NetworkInterface.getByInetAddress(address))
|
||||
}
|
||||
_senders.add(sender)
|
||||
}
|
||||
|
||||
else -> throw UnsupportedOperationException("Address type ${address.javaClass.name} is not supported.")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.i(TAG, "Exception occurred when processing added address $address: ${e.message}.")
|
||||
// Close the socket if there was an error
|
||||
(_senders.lastOrNull() as? MulticastSocket)?.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (nics.isNotEmpty()) {
|
||||
try {
|
||||
updateBroadcastRecords()
|
||||
broadcastRecords()
|
||||
} catch (e: Exception) {
|
||||
Logger.i(TAG, "Exception occurred when broadcasting records: ${e.message}.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onNicsRemoved(nics: List<NetworkInterface>) {
|
||||
_lockObject.withLock {
|
||||
if (!_started) return
|
||||
//TODO: Cleanup?
|
||||
}
|
||||
|
||||
if (nics.isNotEmpty()) {
|
||||
try {
|
||||
updateBroadcastRecords()
|
||||
broadcastRecords()
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Exception occurred when broadcasting records", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun receiveLoop(client: DatagramSocket) {
|
||||
Logger.i(TAG, "Started receive loop")
|
||||
|
||||
val buffer = ByteArray(8972)
|
||||
val packet = DatagramPacket(buffer, buffer.size)
|
||||
while (_started) {
|
||||
try {
|
||||
client.receive(packet)
|
||||
handleResult(packet)
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "An exception occurred while handling UDP result:", e)
|
||||
}
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Stopped receive loop")
|
||||
}
|
||||
|
||||
fun broadcastService(
|
||||
deviceName: String,
|
||||
serviceName: String,
|
||||
port: UShort,
|
||||
ttl: UInt = 120u,
|
||||
weight: UShort = 0u,
|
||||
priority: UShort = 0u,
|
||||
texts: List<String>? = null
|
||||
) {
|
||||
_recordLockObject.withLock {
|
||||
_services.add(
|
||||
BroadcastService(
|
||||
deviceName = deviceName,
|
||||
port = port,
|
||||
priority = priority,
|
||||
serviceName = serviceName,
|
||||
texts = texts,
|
||||
ttl = ttl,
|
||||
weight = weight
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
updateBroadcastRecords()
|
||||
broadcastRecords()
|
||||
}
|
||||
|
||||
private fun updateBroadcastRecords() {
|
||||
_recordLockObject.withLock {
|
||||
_recordsSRV.clear()
|
||||
_recordsPTR.clear()
|
||||
_recordsA.clear()
|
||||
_recordsAAAA.clear()
|
||||
_recordsTXT.clear()
|
||||
|
||||
_services.forEach { service ->
|
||||
val id = UUID.randomUUID().toString()
|
||||
val deviceDomainName = "${service.deviceName}.${service.serviceName}"
|
||||
val addressName = "$id.local"
|
||||
|
||||
_recordsSRV.add(
|
||||
DnsResourceRecord(
|
||||
clazz = ResourceRecordClass.IN.value.toInt(),
|
||||
type = ResourceRecordType.SRV.value.toInt(),
|
||||
timeToLive = service.ttl,
|
||||
name = deviceDomainName,
|
||||
cacheFlush = false
|
||||
) to SRVRecord(
|
||||
target = addressName,
|
||||
port = service.port,
|
||||
priority = service.priority,
|
||||
weight = service.weight
|
||||
)
|
||||
)
|
||||
|
||||
_recordsPTR.add(
|
||||
DnsResourceRecord(
|
||||
clazz = ResourceRecordClass.IN.value.toInt(),
|
||||
type = ResourceRecordType.PTR.value.toInt(),
|
||||
timeToLive = service.ttl,
|
||||
name = service.serviceName,
|
||||
cacheFlush = false
|
||||
) to PTRRecord(
|
||||
domainName = deviceDomainName
|
||||
)
|
||||
)
|
||||
|
||||
val addresses = _nicMonitor.current.flatMap { nic ->
|
||||
nic.interfaceAddresses.map { it.address }
|
||||
}
|
||||
|
||||
addresses.forEach { address ->
|
||||
when (address) {
|
||||
is Inet4Address -> _recordsA.add(
|
||||
DnsResourceRecord(
|
||||
clazz = ResourceRecordClass.IN.value.toInt(),
|
||||
type = ResourceRecordType.A.value.toInt(),
|
||||
timeToLive = service.ttl,
|
||||
name = addressName,
|
||||
cacheFlush = false
|
||||
) to ARecord(
|
||||
address = address
|
||||
)
|
||||
)
|
||||
|
||||
is Inet6Address -> _recordsAAAA.add(
|
||||
DnsResourceRecord(
|
||||
clazz = ResourceRecordClass.IN.value.toInt(),
|
||||
type = ResourceRecordType.AAAA.value.toInt(),
|
||||
timeToLive = service.ttl,
|
||||
name = addressName,
|
||||
cacheFlush = false
|
||||
) to AAAARecord(
|
||||
address = address
|
||||
)
|
||||
)
|
||||
|
||||
else -> Logger.i(TAG, "Invalid address type: $address.")
|
||||
}
|
||||
}
|
||||
|
||||
if (service.texts != null) {
|
||||
_recordsTXT.add(
|
||||
DnsResourceRecord(
|
||||
clazz = ResourceRecordClass.IN.value.toInt(),
|
||||
type = ResourceRecordType.TXT.value.toInt(),
|
||||
timeToLive = service.ttl,
|
||||
name = deviceDomainName,
|
||||
cacheFlush = false
|
||||
) to TXTRecord(
|
||||
texts = service.texts
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun broadcastRecords(questions: List<DnsQuestion>? = null) {
|
||||
val writer = DnsWriter()
|
||||
_recordLockObject.withLock {
|
||||
val recordsA: List<Pair<DnsResourceRecord, ARecord>>
|
||||
val recordsAAAA: List<Pair<DnsResourceRecord, AAAARecord>>
|
||||
val recordsPTR: List<Pair<DnsResourceRecord, PTRRecord>>
|
||||
val recordsTXT: List<Pair<DnsResourceRecord, TXTRecord>>
|
||||
val recordsSRV: List<Pair<DnsResourceRecord, SRVRecord>>
|
||||
|
||||
if (questions != null) {
|
||||
recordsA = _recordsA.filter { r -> questions.any { q -> q.name == r.first.name && q.clazz == r.first.clazz && q.type == r.first.type } }
|
||||
recordsAAAA = _recordsAAAA.filter { r -> questions.any { q -> q.name == r.first.name && q.clazz == r.first.clazz && q.type == r.first.type } }
|
||||
recordsPTR = _recordsPTR.filter { r -> questions.any { q -> q.name == r.first.name && q.clazz == r.first.clazz && q.type == r.first.type } }
|
||||
recordsSRV = _recordsSRV.filter { r -> questions.any { q -> q.name == r.first.name && q.clazz == r.first.clazz && q.type == r.first.type } }
|
||||
recordsTXT = _recordsTXT.filter { r -> questions.any { q -> q.name == r.first.name && q.clazz == r.first.clazz && q.type == r.first.type } }
|
||||
} else {
|
||||
recordsA = _recordsA
|
||||
recordsAAAA = _recordsAAAA
|
||||
recordsPTR = _recordsPTR
|
||||
recordsSRV = _recordsSRV
|
||||
recordsTXT = _recordsTXT
|
||||
}
|
||||
|
||||
val answerCount = recordsA.size + recordsAAAA.size + recordsPTR.size + recordsSRV.size + recordsTXT.size
|
||||
if (answerCount < 1) return
|
||||
|
||||
val txtOffset = recordsA.size + recordsAAAA.size + recordsPTR.size + recordsSRV.size
|
||||
val srvOffset = recordsA.size + recordsAAAA.size + recordsPTR.size
|
||||
val ptrOffset = recordsA.size + recordsAAAA.size
|
||||
val aaaaOffset = recordsA.size
|
||||
|
||||
writer.writePacket(
|
||||
DnsPacketHeader(
|
||||
identifier = 0u,
|
||||
queryResponse = QueryResponse.Response.value.toInt(),
|
||||
opcode = DnsOpcode.StandardQuery.value.toInt(),
|
||||
truncated = false,
|
||||
nonAuthenticatedData = false,
|
||||
recursionDesired = false,
|
||||
answerAuthenticated = false,
|
||||
authoritativeAnswer = true,
|
||||
recursionAvailable = false,
|
||||
responseCode = DnsResponseCode.NoError
|
||||
),
|
||||
answerCount = answerCount,
|
||||
answerWriter = { w, i ->
|
||||
when {
|
||||
i >= txtOffset -> {
|
||||
val record = recordsTXT[i - txtOffset]
|
||||
w.write(record.first) { it.write(record.second) }
|
||||
}
|
||||
|
||||
i >= srvOffset -> {
|
||||
val record = recordsSRV[i - srvOffset]
|
||||
w.write(record.first) { it.write(record.second) }
|
||||
}
|
||||
|
||||
i >= ptrOffset -> {
|
||||
val record = recordsPTR[i - ptrOffset]
|
||||
w.write(record.first) { it.write(record.second) }
|
||||
}
|
||||
|
||||
i >= aaaaOffset -> {
|
||||
val record = recordsAAAA[i - aaaaOffset]
|
||||
w.write(record.first) { it.write(record.second) }
|
||||
}
|
||||
|
||||
else -> {
|
||||
val record = recordsA[i]
|
||||
w.write(record.first) { it.write(record.second) }
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
send(writer.toByteArray())
|
||||
}
|
||||
|
||||
private fun handleResult(result: DatagramPacket) {
|
||||
try {
|
||||
val packet = DnsPacket.parse(result.data)
|
||||
if (packet.questions.isNotEmpty()) {
|
||||
_scope?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
broadcastRecords(packet.questions)
|
||||
} catch (e: Throwable) {
|
||||
Logger.i(TAG, "Broadcasting records failed", e)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
_serviceRecordAggregator.add(packet)
|
||||
onPacket?.invoke(packet)
|
||||
} catch (e: Exception) {
|
||||
Logger.v(TAG, "Failed to handle packet: ${Base64.getEncoder().encodeToString(result.data.slice(IntRange(0, result.length - 1)).toByteArray())}", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
_lockObject.withLock {
|
||||
_started = false
|
||||
|
||||
_scope?.cancel()
|
||||
_scope = null
|
||||
|
||||
_nicMonitor.stop()
|
||||
_serviceRecordAggregator.stop()
|
||||
|
||||
_receiver4?.close()
|
||||
_receiver4 = null
|
||||
|
||||
_receiver6?.close()
|
||||
_receiver6 = null
|
||||
|
||||
_senders.forEach { it.close() }
|
||||
_senders.clear()
|
||||
}
|
||||
|
||||
_threadReceiver4?.join()
|
||||
_threadReceiver4 = null
|
||||
|
||||
_threadReceiver6?.join()
|
||||
_threadReceiver6 = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.futo.platformplayer.mdns
|
||||
|
||||
import kotlinx.coroutines.*
|
||||
import java.net.NetworkInterface
|
||||
|
||||
class NICMonitor {
|
||||
private val lockObject = Any()
|
||||
private val nics = mutableListOf<NetworkInterface>()
|
||||
private var cts: Job? = null
|
||||
|
||||
val current: List<NetworkInterface>
|
||||
get() = synchronized(nics) { nics.toList() }
|
||||
|
||||
var added: ((List<NetworkInterface>) -> Unit)? = null
|
||||
var removed: ((List<NetworkInterface>) -> Unit)? = null
|
||||
|
||||
fun start() {
|
||||
synchronized(lockObject) {
|
||||
if (cts != null) throw Exception("Already started.")
|
||||
|
||||
cts = CoroutineScope(Dispatchers.Default).launch {
|
||||
loopAsync()
|
||||
}
|
||||
}
|
||||
|
||||
nics.clear()
|
||||
nics.addAll(getCurrentInterfaces().toList())
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
synchronized(lockObject) {
|
||||
cts?.cancel()
|
||||
cts = null
|
||||
}
|
||||
|
||||
synchronized(nics) {
|
||||
nics.clear()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loopAsync() {
|
||||
while (cts?.isActive == true) {
|
||||
try {
|
||||
val currentNics = getCurrentInterfaces().toList()
|
||||
removed?.invoke(nics.filter { k -> currentNics.none { n -> k.name == n.name } })
|
||||
added?.invoke(currentNics.filter { nic -> nics.none { k -> k.name == nic.name } })
|
||||
|
||||
synchronized(nics) {
|
||||
nics.clear()
|
||||
nics.addAll(currentNics)
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
// Ignored
|
||||
}
|
||||
delay(5000)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getCurrentInterfaces(): List<NetworkInterface> {
|
||||
val nics = NetworkInterface.getNetworkInterfaces().toList()
|
||||
.filter { it.isUp && !it.isLoopback }
|
||||
|
||||
return if (nics.isNotEmpty()) nics else NetworkInterface.getNetworkInterfaces().toList()
|
||||
.filter { it.isUp }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.futo.platformplayer.mdns
|
||||
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import java.lang.Thread.sleep
|
||||
|
||||
class ServiceDiscoverer(names: Array<String>, private val _onServicesUpdated: (List<DnsService>) -> Unit) {
|
||||
private val _names: Array<String>
|
||||
private var _listener: MDNSListener? = null
|
||||
private var _started = false
|
||||
private var _thread: Thread? = null
|
||||
|
||||
init {
|
||||
if (names.isEmpty()) throw IllegalArgumentException("At least one name must be specified.")
|
||||
_names = names
|
||||
}
|
||||
|
||||
fun broadcastService(
|
||||
deviceName: String,
|
||||
serviceName: String,
|
||||
port: UShort,
|
||||
ttl: UInt = 120u,
|
||||
weight: UShort = 0u,
|
||||
priority: UShort = 0u,
|
||||
texts: List<String>? = null
|
||||
) {
|
||||
_listener?.let {
|
||||
it.broadcastService(deviceName, serviceName, port, ttl, weight, priority, texts)
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
_started = false
|
||||
_listener?.stop()
|
||||
_listener = null
|
||||
_thread?.join()
|
||||
_thread = null
|
||||
}
|
||||
|
||||
fun start() {
|
||||
if (_started) throw Exception("Already running.")
|
||||
_started = true
|
||||
|
||||
val listener = MDNSListener()
|
||||
_listener = listener
|
||||
listener.onServicesUpdated = { _onServicesUpdated?.invoke(it) }
|
||||
listener.start()
|
||||
|
||||
_thread = Thread {
|
||||
try {
|
||||
sleep(2000)
|
||||
|
||||
while (_started) {
|
||||
listener.queryServices(_names)
|
||||
sleep(2000)
|
||||
listener.queryAllQuestions(_names)
|
||||
sleep(2000)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.i(TAG, "Exception in loop thread", e)
|
||||
stop()
|
||||
}
|
||||
}.apply { start() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = "ServiceDiscoverer"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package com.futo.platformplayer.mdns
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import java.net.InetAddress
|
||||
import java.util.Date
|
||||
|
||||
data class DnsService(
|
||||
var name: String,
|
||||
var target: String,
|
||||
var port: UShort,
|
||||
val addresses: MutableList<InetAddress> = mutableListOf(),
|
||||
val pointers: MutableList<String> = mutableListOf(),
|
||||
val texts: MutableList<String> = mutableListOf()
|
||||
)
|
||||
|
||||
data class CachedDnsAddressRecord(
|
||||
val expirationTime: Date,
|
||||
val address: InetAddress
|
||||
)
|
||||
|
||||
data class CachedDnsTxtRecord(
|
||||
val expirationTime: Date,
|
||||
val texts: List<String>
|
||||
)
|
||||
|
||||
data class CachedDnsPtrRecord(
|
||||
val expirationTime: Date,
|
||||
val target: String
|
||||
)
|
||||
|
||||
data class CachedDnsSrvRecord(
|
||||
val expirationTime: Date,
|
||||
val service: SRVRecord
|
||||
)
|
||||
|
||||
class ServiceRecordAggregator {
|
||||
private val _lockObject = Any()
|
||||
private val _cachedAddressRecords = mutableMapOf<String, MutableList<CachedDnsAddressRecord>>()
|
||||
private val _cachedTxtRecords = mutableMapOf<String, CachedDnsTxtRecord>()
|
||||
private val _cachedPtrRecords = mutableMapOf<String, MutableList<CachedDnsPtrRecord>>()
|
||||
private val _cachedSrvRecords = mutableMapOf<String, CachedDnsSrvRecord>()
|
||||
private val _currentServices = mutableListOf<DnsService>()
|
||||
private var _cts: Job? = null
|
||||
|
||||
var onServicesUpdated: ((List<DnsService>) -> Unit)? = null
|
||||
|
||||
fun start() {
|
||||
synchronized(_lockObject) {
|
||||
if (_cts != null) throw Exception("Already started.")
|
||||
|
||||
_cts = CoroutineScope(Dispatchers.Default).launch {
|
||||
while (isActive) {
|
||||
val now = Date()
|
||||
synchronized(_currentServices) {
|
||||
_cachedAddressRecords.forEach { it.value.removeAll { record -> now.after(record.expirationTime) } }
|
||||
_cachedTxtRecords.entries.removeIf { now.after(it.value.expirationTime) }
|
||||
_cachedSrvRecords.entries.removeIf { now.after(it.value.expirationTime) }
|
||||
_cachedPtrRecords.forEach { it.value.removeAll { record -> now.after(record.expirationTime) } }
|
||||
|
||||
val newServices = getCurrentServices()
|
||||
_currentServices.clear()
|
||||
_currentServices.addAll(newServices)
|
||||
}
|
||||
|
||||
onServicesUpdated?.invoke(_currentServices)
|
||||
delay(5000)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
synchronized(_lockObject) {
|
||||
_cts?.cancel()
|
||||
_cts = null
|
||||
}
|
||||
}
|
||||
|
||||
fun add(packet: DnsPacket) {
|
||||
val dnsResourceRecords = packet.answers + packet.additionals + packet.authorities
|
||||
val txtRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.TXT.value.toInt() }.map { it to it.getDataReader().readTXTRecord() }
|
||||
val aRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.A.value.toInt() }.map { it to it.getDataReader().readARecord() }
|
||||
val aaaaRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.AAAA.value.toInt() }.map { it to it.getDataReader().readAAAARecord() }
|
||||
val srvRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.SRV.value.toInt() }.map { it to it.getDataReader().readSRVRecord() }
|
||||
val ptrRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.PTR.value.toInt() }.map { it to it.getDataReader().readPTRRecord() }
|
||||
|
||||
/*val builder = StringBuilder()
|
||||
builder.appendLine("Received records:")
|
||||
srvRecords.forEach { builder.appendLine(" ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: (Port: ${it.second.port}, Target: ${it.second.target}, Priority: ${it.second.priority}, Weight: ${it.second.weight})") }
|
||||
ptrRecords.forEach { builder.appendLine(" ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: ${it.second.domainName}") }
|
||||
txtRecords.forEach { builder.appendLine(" ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: ${it.second.texts.joinToString(", ")}") }
|
||||
aRecords.forEach { builder.appendLine(" ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: ${it.second.address}") }
|
||||
aaaaRecords.forEach { builder.appendLine(" ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: ${it.second.address}") }
|
||||
synchronized(lockObject) {
|
||||
// Save to file if necessary
|
||||
}*/
|
||||
|
||||
val currentServices: MutableList<DnsService>
|
||||
synchronized(this._currentServices) {
|
||||
ptrRecords.forEach { record ->
|
||||
val cachedPtrRecord = _cachedPtrRecords.getOrPut(record.first.name) { mutableListOf() }
|
||||
val newPtrRecord = CachedDnsPtrRecord(Date(System.currentTimeMillis() + record.first.timeToLive.toLong() * 1000L), record.second.domainName)
|
||||
cachedPtrRecord.replaceOrAdd(newPtrRecord) { it.target == record.second.domainName }
|
||||
|
||||
aRecords.forEach { aRecord ->
|
||||
val cachedARecord = _cachedAddressRecords.getOrPut(aRecord.first.name) { mutableListOf() }
|
||||
val newARecord = CachedDnsAddressRecord(Date(System.currentTimeMillis() + aRecord.first.timeToLive.toLong() * 1000L), aRecord.second.address)
|
||||
cachedARecord.replaceOrAdd(newARecord) { it.address == newARecord.address }
|
||||
}
|
||||
|
||||
aaaaRecords.forEach { aaaaRecord ->
|
||||
val cachedAaaaRecord = _cachedAddressRecords.getOrPut(aaaaRecord.first.name) { mutableListOf() }
|
||||
val newAaaaRecord = CachedDnsAddressRecord(Date(System.currentTimeMillis() + aaaaRecord.first.timeToLive.toLong() * 1000L), aaaaRecord.second.address)
|
||||
cachedAaaaRecord.replaceOrAdd(newAaaaRecord) { it.address == newAaaaRecord.address }
|
||||
}
|
||||
}
|
||||
|
||||
txtRecords.forEach { txtRecord ->
|
||||
_cachedTxtRecords[txtRecord.first.name] = CachedDnsTxtRecord(Date(System.currentTimeMillis() + txtRecord.first.timeToLive.toLong() * 1000L), txtRecord.second.texts)
|
||||
}
|
||||
|
||||
srvRecords.forEach { srvRecord ->
|
||||
_cachedSrvRecords[srvRecord.first.name] = CachedDnsSrvRecord(Date(System.currentTimeMillis() + srvRecord.first.timeToLive.toLong() * 1000L), srvRecord.second)
|
||||
}
|
||||
|
||||
currentServices = getCurrentServices()
|
||||
this._currentServices.clear()
|
||||
this._currentServices.addAll(currentServices)
|
||||
}
|
||||
|
||||
onServicesUpdated?.invoke(currentServices)
|
||||
}
|
||||
|
||||
fun getAllQuestions(serviceName: String): List<DnsQuestion> {
|
||||
val questions = mutableListOf<DnsQuestion>()
|
||||
synchronized(_currentServices) {
|
||||
val servicePtrRecords = _cachedPtrRecords[serviceName] ?: return emptyList()
|
||||
|
||||
val ptrWithoutSrvRecord = servicePtrRecords.filterNot { _cachedSrvRecords.containsKey(it.target) }.map { it.target }
|
||||
questions.addAll(ptrWithoutSrvRecord.flatMap { s ->
|
||||
listOf(
|
||||
DnsQuestion(
|
||||
name = s,
|
||||
type = QuestionType.SRV.value.toInt(),
|
||||
clazz = QuestionClass.IN.value.toInt(),
|
||||
queryUnicast = false
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
val incompleteCurrentServices = _currentServices.filter { it.addresses.isEmpty() && it.name.endsWith(serviceName) }
|
||||
questions.addAll(incompleteCurrentServices.flatMap { s ->
|
||||
listOf(
|
||||
DnsQuestion(
|
||||
name = s.name,
|
||||
type = QuestionType.TXT.value.toInt(),
|
||||
clazz = QuestionClass.IN.value.toInt(),
|
||||
queryUnicast = false
|
||||
),
|
||||
DnsQuestion(
|
||||
name = s.target,
|
||||
type = QuestionType.A.value.toInt(),
|
||||
clazz = QuestionClass.IN.value.toInt(),
|
||||
queryUnicast = false
|
||||
),
|
||||
DnsQuestion(
|
||||
name = s.target,
|
||||
type = QuestionType.AAAA.value.toInt(),
|
||||
clazz = QuestionClass.IN.value.toInt(),
|
||||
queryUnicast = false
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
return questions
|
||||
}
|
||||
|
||||
private fun getCurrentServices(): MutableList<DnsService> {
|
||||
val currentServices = _cachedSrvRecords.map { (key, value) ->
|
||||
DnsService(
|
||||
name = key,
|
||||
target = value.service.target,
|
||||
port = value.service.port
|
||||
)
|
||||
}.toMutableList()
|
||||
|
||||
currentServices.forEach { service ->
|
||||
_cachedAddressRecords[service.target]?.let {
|
||||
service.addresses.addAll(it.map { record -> record.address })
|
||||
}
|
||||
}
|
||||
|
||||
currentServices.forEach { service ->
|
||||
service.pointers.addAll(_cachedPtrRecords.filter { it.value.any { ptr -> ptr.target == service.name } }.map { it.key })
|
||||
}
|
||||
|
||||
currentServices.forEach { service ->
|
||||
_cachedTxtRecords[service.name]?.let {
|
||||
service.texts.addAll(it.texts)
|
||||
}
|
||||
}
|
||||
|
||||
return currentServices
|
||||
}
|
||||
|
||||
private inline fun <T> MutableList<T>.replaceOrAdd(newElement: T, predicate: (T) -> Boolean) {
|
||||
val index = indexOfFirst(predicate)
|
||||
if (index >= 0) {
|
||||
this[index] = newElement
|
||||
} else {
|
||||
add(newElement)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import com.futo.platformplayer.constructs.Event1
|
||||
|
||||
|
||||
class MediaControlReceiver : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
val act = intent?.getStringExtra(EXTRA_MEDIA_ACTION);
|
||||
Logger.i(TAG, "Received MediaControl Event $act");
|
||||
|
||||
@@ -28,7 +28,12 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.OkHttpClient
|
||||
import java.net.InetAddress
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.net.SocketException
|
||||
import java.time.Duration
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
class DownloadService : Service() {
|
||||
@@ -44,7 +49,12 @@ class DownloadService : Service() {
|
||||
private var _notificationManager: NotificationManager? = null;
|
||||
private var _notificationChannel: NotificationChannel? = null;
|
||||
|
||||
private val _client = ManagedHttpClient();
|
||||
private val _client = ManagedHttpClient(OkHttpClient.Builder()
|
||||
//.proxy(Proxy(Proxy.Type.HTTP, InetSocketAddress(InetAddress.getByName("192.168.1.175"), 8081)))
|
||||
.readTimeout(Duration.ofMinutes(0))
|
||||
.writeTimeout(Duration.ofMinutes(0))
|
||||
.connectTimeout(Duration.ofSeconds(100))
|
||||
.callTimeout(Duration.ofMinutes(0)))
|
||||
|
||||
private var _started = false;
|
||||
|
||||
@@ -183,14 +193,19 @@ class DownloadService : Service() {
|
||||
Logger.w(TAG, "Video Download [${download.name}] expired, re-preparing");
|
||||
download.videoDetails = null;
|
||||
|
||||
if(download.targetVideoName == null && download.videoSource != null)
|
||||
download.targetVideoName = download.videoSource!!.name;
|
||||
if(download.targetPixelCount == null && download.videoSource != null)
|
||||
download.targetPixelCount = (download.videoSource!!.width * download.videoSource!!.height).toLong();
|
||||
download.videoSource = null;
|
||||
|
||||
if(download.targetAudioName == null && download.audioSource != null)
|
||||
download.targetAudioName = download.audioSource!!.name;
|
||||
if(download.targetBitrate == null && download.audioSource != null)
|
||||
download.targetBitrate = download.audioSource!!.bitrate.toLong();
|
||||
download.audioSource = null;
|
||||
}
|
||||
if(download.videoDetails == null || (download.videoSource == null && download.audioSource == null))
|
||||
if(download.videoDetails == null || (!download.isVideoDownloadReady || !download.isAudioDownloadReady))
|
||||
download.changeState(VideoDownload.State.PREPARING);
|
||||
notifyDownload(download);
|
||||
|
||||
@@ -207,7 +222,7 @@ class DownloadService : Service() {
|
||||
download.progress = progress;
|
||||
|
||||
val currentTime = System.currentTimeMillis();
|
||||
if (currentTime - lastNotifyTime > 500) {
|
||||
if (currentTime - lastNotifyTime > 800) {
|
||||
notifyDownload(download);
|
||||
lastNotifyTime = currentTime;
|
||||
}
|
||||
|
||||
@@ -58,6 +58,8 @@ class MediaPlaybackService : Service() {
|
||||
private var _focusRequest: AudioFocusRequest? = null;
|
||||
private var _audioFocusLossTime_ms: Long? = null
|
||||
private var _playbackState = PlaybackStateCompat.STATE_NONE;
|
||||
private val _handler = Handler(Looper.getMainLooper())
|
||||
private val _audioFocusRunnable = Runnable { setAudioFocus(false) }
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
Logger.v(TAG, "onStartCommand");
|
||||
@@ -98,7 +100,7 @@ class MediaPlaybackService : Service() {
|
||||
_mediaSession?.setPlaybackState(PlaybackStateCompat.Builder()
|
||||
.setState(PlaybackStateCompat.STATE_PLAYING, 0, 1f)
|
||||
.build());
|
||||
_mediaSession?.setCallback(object: MediaSessionCompat.Callback() {
|
||||
_mediaSession?.setCallback(object : MediaSessionCompat.Callback() {
|
||||
override fun onSeekTo(pos: Long) {
|
||||
super.onSeekTo(pos)
|
||||
Logger.i(TAG, "Media session callback onSeekTo(pos = $pos)");
|
||||
@@ -120,7 +122,9 @@ class MediaPlaybackService : Service() {
|
||||
override fun onStop() {
|
||||
super.onStop();
|
||||
Logger.i(TAG, "Media session callback onStop()");
|
||||
MediaControlReceiver.onCloseReceived.emit();
|
||||
//MediaControlReceiver.onCloseReceived.emit();
|
||||
MediaControlReceiver.onPauseReceived.emit();
|
||||
updateMediaSession( null);
|
||||
}
|
||||
|
||||
override fun onSkipToPrevious() {
|
||||
@@ -159,6 +163,8 @@ class MediaPlaybackService : Service() {
|
||||
|
||||
val focusRequest = _focusRequest;
|
||||
if (focusRequest != null) {
|
||||
Logger.i(TAG, "Audio focus abandoned")
|
||||
_handler.removeCallbacks(_audioFocusRunnable)
|
||||
_audioManager?.abandonAudioFocusRequest(focusRequest);
|
||||
_focusRequest = null;
|
||||
}
|
||||
@@ -340,22 +346,46 @@ class MediaPlaybackService : Service() {
|
||||
}
|
||||
|
||||
//TODO: (TBD) This code probably more fitting inside FutoVideoPlayer, as this service is generally only used for global events
|
||||
private fun setAudioFocus() {
|
||||
Log.i(TAG, "Requested audio focus.");
|
||||
private fun setAudioFocus(createFocusRequest: Boolean = true) {
|
||||
_handler.removeCallbacks(_audioFocusRunnable)
|
||||
|
||||
val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
|
||||
.setAcceptsDelayedFocusGain(true)
|
||||
.setOnAudioFocusChangeListener(_audioFocusChangeListener)
|
||||
.build()
|
||||
if (_hasFocus) {
|
||||
Log.i(TAG, "Skipped trying to get audio focus because audio focus is already obtained.");
|
||||
return;
|
||||
}
|
||||
|
||||
_focusRequest = focusRequest;
|
||||
val result = _audioManager?.requestAudioFocus(focusRequest)
|
||||
if (_focusRequest == null) {
|
||||
if (!createFocusRequest) {
|
||||
Log.i(TAG, "Skipped trying to get audio focus because createFocusRequest = false and no focus request exists.");
|
||||
return;
|
||||
}
|
||||
|
||||
val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
|
||||
.setAcceptsDelayedFocusGain(true)
|
||||
.setOnAudioFocusChangeListener(_audioFocusChangeListener)
|
||||
.build()
|
||||
|
||||
_focusRequest = focusRequest;
|
||||
Log.i(TAG, "Created audio focus request.");
|
||||
}
|
||||
|
||||
Log.i(TAG, "Requesting audio focus.");
|
||||
|
||||
val result = _audioManager?.requestAudioFocus(_focusRequest!!)
|
||||
Log.i(TAG, "Audio focus request result $result");
|
||||
if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
|
||||
//TODO: Handle when not possible to get audio focus
|
||||
_hasFocus = true;
|
||||
Log.i(TAG, "Audio focus received");
|
||||
} else if (result == AudioManager.AUDIOFOCUS_REQUEST_DELAYED) {
|
||||
_hasFocus = false
|
||||
Log.i(TAG, "Audio focus delayed, waiting for focus")
|
||||
} else {
|
||||
_hasFocus = false
|
||||
Log.i(TAG, "Audio focus not granted, retrying in 1 second")
|
||||
_handler.postDelayed(_audioFocusRunnable, 1000)
|
||||
}
|
||||
|
||||
Log.i(TAG, "Audio focus requested.");
|
||||
}
|
||||
|
||||
private val _audioFocusChangeListener =
|
||||
@@ -363,8 +393,7 @@ class MediaPlaybackService : Service() {
|
||||
try {
|
||||
when (focusChange) {
|
||||
AudioManager.AUDIOFOCUS_GAIN -> {
|
||||
//Do not start playing on gaining audo focus
|
||||
//MediaControlReceiver.onPlayReceived.emit();
|
||||
_handler.removeCallbacks(_audioFocusRunnable)
|
||||
_hasFocus = true;
|
||||
Log.i(TAG, "Audio focus gained (restartPlaybackAfterLoss = ${Settings.instance.playback.restartPlaybackAfterLoss}, _audioFocusLossTime_ms = $_audioFocusLossTime_ms)");
|
||||
|
||||
@@ -383,7 +412,6 @@ class MediaPlaybackService : Service() {
|
||||
}
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
|
||||
MediaControlReceiver.onPauseReceived.emit();
|
||||
if (_playbackState != PlaybackStateCompat.STATE_PAUSED &&
|
||||
_playbackState != PlaybackStateCompat.STATE_STOPPED &&
|
||||
_playbackState != PlaybackStateCompat.STATE_NONE &&
|
||||
@@ -391,10 +419,13 @@ class MediaPlaybackService : Service() {
|
||||
_audioFocusLossTime_ms = System.currentTimeMillis()
|
||||
}
|
||||
|
||||
Log.i(TAG, "Audio focus transient loss");
|
||||
_hasFocus = false;
|
||||
MediaControlReceiver.onPauseReceived.emit();
|
||||
Log.i(TAG, "Audio focus transient loss (_audioFocusLossTime_ms = ${_audioFocusLossTime_ms})");
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
|
||||
Log.i(TAG, "Audio focus transient loss, can duck");
|
||||
_hasFocus = true;
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_LOSS -> {
|
||||
if (_playbackState != PlaybackStateCompat.STATE_PAUSED &&
|
||||
@@ -407,16 +438,6 @@ class MediaPlaybackService : Service() {
|
||||
_hasFocus = false;
|
||||
MediaControlReceiver.onPauseReceived.emit();
|
||||
Log.i(TAG, "Audio focus lost");
|
||||
|
||||
val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
||||
val runningAppProcesses = activityManager.runningAppProcesses
|
||||
for (processInfo in runningAppProcesses) {
|
||||
// Check the importance of the running app process
|
||||
if (processInfo.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND) {
|
||||
// This app is in the foreground, which might have caused the loss of audio focus
|
||||
Log.i("AudioFocus", "App ${processInfo.processName} might have caused the loss of audio focus")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch(ex: Throwable) {
|
||||
|
||||
@@ -8,7 +8,9 @@ import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.media.PlatformID
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource
|
||||
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.models.streams.sources.LocalAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.LocalSubtitleSource
|
||||
@@ -334,7 +336,7 @@ class StateDownloads {
|
||||
fun download(video: IPlatformVideo, targetPixelcount: Long?, targetBitrate: Long?) {
|
||||
download(VideoDownload(video, targetPixelcount, targetBitrate));
|
||||
}
|
||||
fun download(video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: SubtitleRawSource?) {
|
||||
fun download(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: SubtitleRawSource?) {
|
||||
download(VideoDownload(video, videoSource, audioSource, subtitleSource));
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,13 @@ class StatePlayer {
|
||||
private var _thumbnailExoPlayer : PlayerManager? = null;
|
||||
|
||||
//Video Status
|
||||
var rotationLock : Boolean = false;
|
||||
var rotationLock: Boolean = false
|
||||
get() = field
|
||||
set(value) {
|
||||
field = value
|
||||
onRotationLockChanged.emit(value)
|
||||
}
|
||||
val onRotationLockChanged = Event1<Boolean>()
|
||||
var loopVideo : Boolean = false;
|
||||
|
||||
val isPlaying: Boolean get() = _exoplayer?.player?.playWhenReady ?: false;
|
||||
|
||||
@@ -16,7 +16,7 @@ enum class FeedStyle(val value: Int) {
|
||||
|
||||
fun fromInt(value: Int): FeedStyle
|
||||
{
|
||||
val result = FeedStyle.values().firstOrNull { it.value == value };
|
||||
val result = FeedStyle.entries.firstOrNull { it.value == value };
|
||||
if(result == null)
|
||||
throw UnknownPlatformException(value.toString());
|
||||
return result;
|
||||
|
||||
@@ -13,6 +13,10 @@ annotation class FormField(val title: Int, val type: String, val subtitle: Int =
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
annotation class FormFieldWarning(val messageRes: Int)
|
||||
|
||||
@Target(AnnotationTarget.FIELD, AnnotationTarget.PROPERTY)
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
annotation class FormFieldHint(val messageRes: Int)
|
||||
|
||||
interface IField {
|
||||
var descriptor: FormField?;
|
||||
val obj : Any?;
|
||||
|
||||
@@ -293,6 +293,12 @@ class FieldForm : LinearLayout {
|
||||
}, UIDialogs.ActionStyle.PRIMARY));
|
||||
}
|
||||
}
|
||||
val hint = propertyMap[field.field]?.findAnnotation<FormFieldHint>();
|
||||
if(hint != null){
|
||||
field.onChanged.subscribe { f, value, oldValue ->
|
||||
UIDialogs.appToast(context.getString(hint.messageRes), false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+23
-3
@@ -6,14 +6,17 @@ import android.view.LayoutInflater
|
||||
import android.widget.ImageView
|
||||
import android.widget.RelativeLayout
|
||||
import android.widget.TextView
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.view.isVisible
|
||||
import com.futo.platformplayer.R
|
||||
|
||||
class SlideUpMenuItem : RelativeLayout {
|
||||
class SlideUpMenuItem : ConstraintLayout {
|
||||
|
||||
private lateinit var _root: RelativeLayout;
|
||||
private lateinit var _root: ConstraintLayout;
|
||||
private lateinit var _image: ImageView;
|
||||
private lateinit var _text: TextView;
|
||||
private lateinit var _subtext: TextView;
|
||||
private lateinit var _description: TextView;
|
||||
|
||||
var selectedOption: Boolean = false;
|
||||
|
||||
@@ -25,11 +28,27 @@ class SlideUpMenuItem : RelativeLayout {
|
||||
init();
|
||||
}
|
||||
|
||||
constructor(context: Context, imageRes: Int = 0, mainText: String, subText: String = "", tag: Any?, call: (()->Unit)? = null, invokeParent: Boolean = true): super(context){
|
||||
constructor(
|
||||
context: Context,
|
||||
imageRes: Int = 0,
|
||||
mainText: String,
|
||||
subText: String = "",
|
||||
description: String? = "",
|
||||
tag: Any?,
|
||||
call: (() -> Unit)? = null,
|
||||
invokeParent: Boolean = true
|
||||
): super(context){
|
||||
init();
|
||||
_image.setImageResource(imageRes);
|
||||
_text.text = mainText;
|
||||
_subtext.text = subText;
|
||||
|
||||
if(description.isNullOrEmpty())
|
||||
_description.isVisible = false;
|
||||
else {
|
||||
_description.text = description;
|
||||
_description.isVisible = true;
|
||||
}
|
||||
this.itemTag = tag;
|
||||
|
||||
if (call != null) {
|
||||
@@ -48,6 +67,7 @@ class SlideUpMenuItem : RelativeLayout {
|
||||
_image = findViewById(R.id.slide_up_menu_item_image);
|
||||
_text = findViewById(R.id.slide_up_menu_item_text);
|
||||
_subtext = findViewById(R.id.slide_up_menu_item_subtext);
|
||||
_description = findViewById(R.id.slide_up_menu_item_description);
|
||||
|
||||
setOptionSelected(false);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import android.widget.TextView
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.view.setMargins
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.PlaybackParameters
|
||||
import androidx.media3.common.VideoSize
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
@@ -123,7 +124,8 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
||||
private var _currentChapterLoopId: Int = 0;
|
||||
private var _currentChapter: IChapter? = null;
|
||||
private var _promptedForPermissions: Boolean = false;
|
||||
|
||||
@UnstableApi
|
||||
private var _desiredResizeModePortrait: Int = AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||
|
||||
//Events
|
||||
val onMinimize = Event1<FutoVideoPlayer>();
|
||||
@@ -567,6 +569,8 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
fun setFullScreen(fullScreen: Boolean) {
|
||||
updateRotateLock()
|
||||
|
||||
if (isFullScreen == fullScreen) {
|
||||
return;
|
||||
}
|
||||
@@ -591,7 +595,7 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
||||
|
||||
gestureControl.hideControls();
|
||||
//videoControlsBar.visibility = View.VISIBLE;
|
||||
_videoView.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM;
|
||||
_videoView.resizeMode = _desiredResizeModePortrait;
|
||||
|
||||
videoControls.show();
|
||||
_videoControls_fullscreen.hideImmediately();
|
||||
@@ -728,9 +732,10 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
||||
Log.d(TAG, "WEIRD HEIGHT DETECTED: ${_lastSourceFit}, Width: ${w}, Height: ${h}, VWidth: ${viewWidth}");
|
||||
}
|
||||
if(_lastSourceFit != determinedHeight)
|
||||
_videoView.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT;
|
||||
_desiredResizeModePortrait = AspectRatioFrameLayout.RESIZE_MODE_FIT;
|
||||
else
|
||||
_videoView.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM;
|
||||
_desiredResizeModePortrait = AspectRatioFrameLayout.RESIZE_MODE_ZOOM;
|
||||
_videoView.resizeMode = _desiredResizeModePortrait
|
||||
}
|
||||
|
||||
val marginBottom = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 7f, resources.displayMetrics).toInt();
|
||||
@@ -759,7 +764,7 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
||||
}
|
||||
|
||||
fun updateRotateLock() {
|
||||
if(!Settings.instance.playback.isAutoRotate()) {
|
||||
if(Settings.instance.playback.autoRotate == 0) {
|
||||
_control_rotate_lock.visibility = View.GONE;
|
||||
_control_rotate_lock_fullscreen.visibility = View.GONE;
|
||||
}
|
||||
|
||||
@@ -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,37 @@ 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;
|
||||
}
|
||||
|
||||
val didSetVideo = swapSourceInternal(videoSourceUsed, play, resume);
|
||||
val didSetAudio = swapSourceInternal(audioSourceUsed, play, resume);
|
||||
if(!keepSubtitles)
|
||||
_lastSubtitleMediaSource = null;
|
||||
return loadSelectedSources(play, resume);
|
||||
if(didSetVideo && didSetAudio)
|
||||
return loadSelectedSources(play, resume);
|
||||
else
|
||||
return true;
|
||||
}
|
||||
fun swapSource(videoSource: IVideoSource?, resume: Boolean = true, play: Boolean = true): Boolean {
|
||||
swapSourceInternal(videoSource);
|
||||
return loadSelectedSources(play, resume);
|
||||
var videoSourceUsed = videoSource;
|
||||
if(videoSource is JSDashManifestRawSource && lastVideoSource is JSDashManifestMergingRawSource)
|
||||
videoSourceUsed = JSDashManifestMergingRawSource(videoSource, (lastVideoSource as JSDashManifestMergingRawSource).audio);
|
||||
val didSet = swapSourceInternal(videoSourceUsed, play, resume);
|
||||
if(didSet)
|
||||
return loadSelectedSources(play, resume);
|
||||
else
|
||||
return true;
|
||||
}
|
||||
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), play, resume);
|
||||
else
|
||||
swapSourceInternal(audioSource, play, resume);
|
||||
return loadSelectedSources(play, resume);
|
||||
}
|
||||
|
||||
@@ -381,30 +412,34 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
||||
}
|
||||
|
||||
|
||||
private fun swapSourceInternal(videoSource: IVideoSource?) {
|
||||
private fun swapSourceInternal(videoSource: IVideoSource?, play: Boolean, resume: Boolean): Boolean {
|
||||
_lastGeneratedDash = null;
|
||||
when(videoSource) {
|
||||
is LocalVideoSource -> swapVideoSourceLocal(videoSource);
|
||||
is JSVideoUrlRangeSource -> swapVideoSourceUrlRange(videoSource);
|
||||
is IDashManifestSource -> swapVideoSourceDash(videoSource);
|
||||
is IHLSManifestSource -> swapVideoSourceHLS(videoSource);
|
||||
is IVideoUrlSource -> swapVideoSourceUrl(videoSource);
|
||||
null -> _lastVideoMediaSource = null;
|
||||
val didSet = when(videoSource) {
|
||||
is LocalVideoSource -> { swapVideoSourceLocal(videoSource); true; }
|
||||
is JSVideoUrlRangeSource -> { swapVideoSourceUrlRange(videoSource); true; }
|
||||
is IDashManifestSource -> { swapVideoSourceDash(videoSource); true;}
|
||||
is JSDashManifestRawSource -> swapVideoSourceDashRaw(videoSource, play, resume);
|
||||
is IHLSManifestSource -> { swapVideoSourceHLS(videoSource); true; }
|
||||
is IVideoUrlSource -> { swapVideoSourceUrl(videoSource); true; }
|
||||
null -> { _lastVideoMediaSource = null; true;}
|
||||
else -> throw IllegalArgumentException("Unsupported video source [${videoSource.javaClass.simpleName}]");
|
||||
}
|
||||
lastVideoSource = videoSource;
|
||||
return didSet;
|
||||
}
|
||||
private fun swapSourceInternal(audioSource: IAudioSource?) {
|
||||
when(audioSource) {
|
||||
is LocalAudioSource -> swapAudioSourceLocal(audioSource);
|
||||
is JSAudioUrlRangeSource -> swapAudioSourceUrlRange(audioSource);
|
||||
is JSHLSManifestAudioSource -> swapAudioSourceHLS(audioSource);
|
||||
is IAudioUrlWidevineSource -> swapAudioSourceUrlWidevine(audioSource)
|
||||
is IAudioUrlSource -> swapAudioSourceUrl(audioSource);
|
||||
null -> _lastAudioMediaSource = null;
|
||||
private fun swapSourceInternal(audioSource: IAudioSource?, play: Boolean, resume: Boolean): Boolean {
|
||||
val didSet = when(audioSource) {
|
||||
is LocalAudioSource -> {swapAudioSourceLocal(audioSource); true; }
|
||||
is JSAudioUrlRangeSource -> { swapAudioSourceUrlRange(audioSource); true; }
|
||||
is JSHLSManifestAudioSource -> { swapAudioSourceHLS(audioSource); true; }
|
||||
is JSDashManifestRawAudioSource -> swapAudioSourceDashRaw(audioSource, play, resume);
|
||||
is IAudioUrlWidevineSource -> { swapAudioSourceUrlWidevine(audioSource); true; }
|
||||
is IAudioUrlSource -> { swapAudioSourceUrl(audioSource); true; }
|
||||
null -> { _lastAudioMediaSource = null; true; }
|
||||
else -> throw IllegalArgumentException("Unsupported video source [${audioSource.javaClass.simpleName}]");
|
||||
}
|
||||
lastAudioSource = audioSource;
|
||||
return didSet;
|
||||
}
|
||||
|
||||
//Video loads
|
||||
@@ -441,7 +476,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun swapVideoSourceUrl(videoSource: IVideoUrlSource) {
|
||||
Logger.i(TAG, "Loading VideoSource [Url]");
|
||||
val dataSource = if(videoSource is JSSource && videoSource.hasRequestModifier)
|
||||
val dataSource = if(videoSource is JSSource && videoSource.requiresCustomDatasource)
|
||||
videoSource.getHttpDataSourceFactory()
|
||||
else
|
||||
DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT);
|
||||
@@ -451,7 +486,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun swapVideoSourceDash(videoSource: IDashManifestSource) {
|
||||
Logger.i(TAG, "Loading VideoSource [Dash]");
|
||||
val dataSource = if(videoSource is JSSource && videoSource.hasRequestModifier)
|
||||
val dataSource = if(videoSource is JSSource && (videoSource.requiresCustomDatasource))
|
||||
videoSource.getHttpDataSourceFactory()
|
||||
else
|
||||
DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT);
|
||||
@@ -459,9 +494,60 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
||||
.createMediaSource(MediaItem.fromUri(videoSource.url))
|
||||
}
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun swapVideoSourceDashRaw(videoSource: JSDashManifestRawSource, play: Boolean, resume: Boolean): Boolean {
|
||||
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)
|
||||
)
|
||||
)
|
||||
);
|
||||
if(lastVideoSource == videoSource || (videoSource is JSDashManifestMergingRawSource && videoSource.video == lastVideoSource));
|
||||
loadSelectedSources(play, resume);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e(TAG, "DashRaw generator failed", ex);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
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))));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun swapVideoSourceHLS(videoSource: IHLSManifestSource) {
|
||||
Logger.i(TAG, "Loading VideoSource [HLS]");
|
||||
val dataSource = if(videoSource is JSSource && videoSource.hasRequestModifier)
|
||||
val dataSource = if(videoSource is JSSource && videoSource.requiresCustomDatasource)
|
||||
videoSource.getHttpDataSourceFactory()
|
||||
else
|
||||
DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT);
|
||||
@@ -503,7 +589,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun swapAudioSourceUrl(audioSource: IAudioUrlSource) {
|
||||
Logger.i(TAG, "Loading AudioSource [Url]");
|
||||
val dataSource = if(audioSource is JSSource && audioSource.hasRequestModifier)
|
||||
val dataSource = if(audioSource is JSSource && audioSource.requiresCustomDatasource)
|
||||
audioSource.getHttpDataSourceFactory()
|
||||
else
|
||||
DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT);
|
||||
@@ -513,7 +599,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun swapAudioSourceHLS(audioSource: IHLSManifestAudioSource) {
|
||||
Logger.i(TAG, "Loading AudioSource [HLS]");
|
||||
val dataSource = if(audioSource is JSSource && audioSource.hasRequestModifier)
|
||||
val dataSource = if(audioSource is JSSource && audioSource.requiresCustomDatasource)
|
||||
audioSource.getHttpDataSourceFactory()
|
||||
else
|
||||
DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT);
|
||||
@@ -521,10 +607,42 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
||||
.createMediaSource(MediaItem.fromUri(audioSource.url));
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun swapAudioSourceDashRaw(audioSource: JSDashManifestRawAudioSource, play: Boolean, resume: Boolean): Boolean {
|
||||
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(play, resume);
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
else {
|
||||
_lastVideoMediaSource = DashMediaSource.Factory(dataSource)
|
||||
.createMediaSource(
|
||||
DashManifestParser().parse(
|
||||
Uri.parse(audioSource.url),
|
||||
ByteArrayInputStream(audioSource.manifest?.toByteArray() ?: ByteArray(0))
|
||||
)
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun swapAudioSourceUrlWidevine(audioSource: IAudioUrlWidevineSource) {
|
||||
Logger.i(TAG, "Loading AudioSource [UrlWidevine]")
|
||||
val dataSource = if (audioSource is JSSource && audioSource.hasRequestModifier)
|
||||
val dataSource = if (audioSource is JSSource && audioSource.requiresCustomDatasource)
|
||||
audioSource.getHttpDataSourceFactory()
|
||||
else
|
||||
DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT)
|
||||
@@ -574,28 +692,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 +746,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 +828,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<String> 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<String> 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");
|
||||
}
|
||||
|
||||
+172
-101
@@ -13,6 +13,8 @@ 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;
|
||||
import androidx.media3.common.PlaybackException;
|
||||
@@ -26,12 +28,16 @@ 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;
|
||||
import com.google.common.collect.ImmutableMap;
|
||||
import com.google.common.collect.Sets;
|
||||
import com.google.common.net.HttpHeaders;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InterruptedIOException;
|
||||
@@ -67,6 +73,9 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource {
|
||||
private boolean allowCrossProtocolRedirects;
|
||||
private boolean keepPostFor302Redirects;
|
||||
@Nullable private IRequestModifier requestModifier = null;
|
||||
@Nullable public JSRequestExecutor requestExecutor = null;
|
||||
@Nullable public JSRequestExecutor requestExecutor2 = null;
|
||||
|
||||
|
||||
/** Creates an instance. */
|
||||
public Factory() {
|
||||
@@ -93,6 +102,30 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource {
|
||||
this.requestModifier = requestModifier;
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* Sets the request executor that will be used.
|
||||
*
|
||||
* <p>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 setRequestExecutor(@Nullable JSRequestExecutor requestExecutor) {
|
||||
this.requestExecutor = requestExecutor;
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* Sets the secondary request executor that will be used.
|
||||
*
|
||||
* <p>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.
|
||||
@@ -199,7 +232,9 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource {
|
||||
defaultRequestProperties,
|
||||
contentTypePredicate,
|
||||
keepPostFor302Redirects,
|
||||
requestModifier);
|
||||
requestModifier,
|
||||
requestExecutor,
|
||||
requestExecutor2);
|
||||
if (transferListener != null) {
|
||||
dataSource.addTransferListener(transferListener);
|
||||
}
|
||||
@@ -235,6 +270,10 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource {
|
||||
private long bytesToRead;
|
||||
private long bytesRead;
|
||||
@Nullable private IRequestModifier requestModifier;
|
||||
@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,
|
||||
@@ -244,7 +283,9 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource {
|
||||
@Nullable RequestProperties defaultRequestProperties,
|
||||
@Nullable Predicate<String> contentTypePredicate,
|
||||
boolean keepPostFor302Redirects,
|
||||
@Nullable IRequestModifier requestModifier) {
|
||||
@Nullable IRequestModifier requestModifier,
|
||||
@Nullable JSRequestExecutor requestExecutor,
|
||||
@Nullable JSRequestExecutor requestExecutor2) {
|
||||
super(/* isNetwork= */ true);
|
||||
this.userAgent = userAgent;
|
||||
this.connectTimeoutMillis = connectTimeoutMillis;
|
||||
@@ -255,12 +296,14 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource {
|
||||
this.requestProperties = new RequestProperties();
|
||||
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
|
||||
@@ -310,119 +353,147 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource {
|
||||
bytesToRead = 0;
|
||||
transferInitializing(dataSpec);
|
||||
|
||||
String responseMessage;
|
||||
HttpURLConnection connection;
|
||||
try {
|
||||
this.connection = makeConnection(dataSpec);
|
||||
connection = this.connection;
|
||||
responseCode = connection.getResponseCode();
|
||||
responseMessage = connection.getResponseMessage();
|
||||
} catch (IOException e) {
|
||||
closeConnectionQuietly();
|
||||
throw HttpDataSourceException.createForIOException(
|
||||
e, dataSpec, HttpDataSourceException.TYPE_OPEN);
|
||||
}
|
||||
//Use executor 2 if it matches the urlPrefix
|
||||
JSRequestExecutor executor = (requestExecutor2 != null && requestExecutor2.getUrlPrefix() != null && dataSpec.uri.toString().startsWith(requestExecutor2.getUrlPrefix())) ?
|
||||
requestExecutor2 : requestExecutor;
|
||||
|
||||
// Check for a valid response code.
|
||||
if (responseCode < 200 || responseCode > 299) {
|
||||
Map<String, List<String>> headers = connection.getHeaderFields();
|
||||
if (responseCode == 416) {
|
||||
long documentSize = HttpUtil.getDocumentSize(connection.getHeaderField(HttpHeaders.CONTENT_RANGE));
|
||||
if (dataSpec.position == documentSize) {
|
||||
opened = true;
|
||||
transferStarted(dataSpec);
|
||||
return dataSpec.length != C.LENGTH_UNSET ? dataSpec.length : 0;
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable InputStream errorStream = connection.getErrorStream();
|
||||
byte[] errorResponseBody;
|
||||
if(executor != null) {
|
||||
try {
|
||||
errorResponseBody =
|
||||
errorStream != null ? Util.toByteArray(errorStream) : Util.EMPTY_BYTE_ARRAY;
|
||||
Logger.Companion.i(TAG, "Executor for " + dataSpec.uri.toString(), null);
|
||||
byte[] data = executor.executeRequest(dataSpec.uri.toString(), dataSpec.httpRequestHeaders);
|
||||
Logger.Companion.i(TAG, "Executor result for " + dataSpec.uri.toString() + " : " + data.length, null);
|
||||
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;
|
||||
HttpURLConnection connection;
|
||||
try {
|
||||
this.connection = makeConnection(dataSpec);
|
||||
connection = this.connection;
|
||||
responseCode = connection.getResponseCode();
|
||||
responseMessage = connection.getResponseMessage();
|
||||
} catch (IOException e) {
|
||||
errorResponseBody = Util.EMPTY_BYTE_ARRAY;
|
||||
closeConnectionQuietly();
|
||||
throw HttpDataSourceException.createForIOException(
|
||||
e, dataSpec, HttpDataSourceException.TYPE_OPEN);
|
||||
}
|
||||
closeConnectionQuietly();
|
||||
@Nullable
|
||||
IOException cause = responseCode == 416
|
||||
? new DataSourceException(PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE)
|
||||
: null;
|
||||
|
||||
throw new InvalidResponseCodeException(
|
||||
responseCode, responseMessage, cause, headers, dataSpec, errorResponseBody);
|
||||
}
|
||||
// Check for a valid response code.
|
||||
if (responseCode < 200 || responseCode > 299) {
|
||||
Map<String, List<String>> headers = connection.getHeaderFields();
|
||||
if (responseCode == 416) {
|
||||
long documentSize = HttpUtil.getDocumentSize(connection.getHeaderField(HttpHeaders.CONTENT_RANGE));
|
||||
if (dataSpec.position == documentSize) {
|
||||
opened = true;
|
||||
transferStarted(dataSpec);
|
||||
return dataSpec.length != C.LENGTH_UNSET ? dataSpec.length : 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for a valid content type.
|
||||
String contentType = connection.getContentType();
|
||||
if (contentTypePredicate != null && !contentTypePredicate.apply(contentType)) {
|
||||
closeConnectionQuietly();
|
||||
throw new InvalidContentTypeException(contentType, dataSpec);
|
||||
}
|
||||
@Nullable InputStream errorStream = connection.getErrorStream();
|
||||
byte[] errorResponseBody;
|
||||
try {
|
||||
errorResponseBody =
|
||||
errorStream != null ? Util.toByteArray(errorStream) : Util.EMPTY_BYTE_ARRAY;
|
||||
} catch (IOException e) {
|
||||
errorResponseBody = Util.EMPTY_BYTE_ARRAY;
|
||||
}
|
||||
closeConnectionQuietly();
|
||||
@Nullable
|
||||
IOException cause = responseCode == 416
|
||||
? new DataSourceException(PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE)
|
||||
: null;
|
||||
|
||||
// If we requested a range starting from a non-zero position and received a 200 rather than a
|
||||
// 206, then the server does not support partial requests. We'll need to manually skip to the
|
||||
// requested position.
|
||||
long bytesToSkip;
|
||||
if (requestModifier != null && !requestModifier.getAllowByteSkip()) {
|
||||
bytesToSkip = 0;
|
||||
} else {
|
||||
bytesToSkip = responseCode == 200 && dataSpec.position != 0 ? dataSpec.position : 0;
|
||||
}
|
||||
throw new InvalidResponseCodeException(
|
||||
responseCode, responseMessage, cause, headers, dataSpec, errorResponseBody);
|
||||
}
|
||||
|
||||
// Determine the length of the data to be read, after skipping.
|
||||
boolean isCompressed = isCompressed(connection);
|
||||
if (!isCompressed) {
|
||||
if (dataSpec.length != C.LENGTH_UNSET) {
|
||||
bytesToRead = dataSpec.length;
|
||||
// Check for a valid content type.
|
||||
String contentType = connection.getContentType();
|
||||
if (contentTypePredicate != null && !contentTypePredicate.apply(contentType)) {
|
||||
closeConnectionQuietly();
|
||||
throw new InvalidContentTypeException(contentType, dataSpec);
|
||||
}
|
||||
|
||||
// If we requested a range starting from a non-zero position and received a 200 rather than a
|
||||
// 206, then the server does not support partial requests. We'll need to manually skip to the
|
||||
// requested position.
|
||||
long bytesToSkip;
|
||||
if (requestModifier != null && !requestModifier.getAllowByteSkip()) {
|
||||
bytesToSkip = 0;
|
||||
} else {
|
||||
long contentLength = HttpUtil.getContentLength(
|
||||
connection.getHeaderField(HttpHeaders.CONTENT_LENGTH),
|
||||
connection.getHeaderField(HttpHeaders.CONTENT_RANGE)
|
||||
);
|
||||
|
||||
bytesToRead = contentLength != C.LENGTH_UNSET ? (contentLength - bytesToSkip) : C.LENGTH_UNSET;
|
||||
bytesToSkip = responseCode == 200 && dataSpec.position != 0 ? dataSpec.position : 0;
|
||||
}
|
||||
} else {
|
||||
// Gzip is enabled. If the server opts to use gzip then the content length in the response
|
||||
// will be that of the compressed data, which isn't what we want. Always use the dataSpec
|
||||
// length in this case.
|
||||
bytesToRead = dataSpec.length;
|
||||
}
|
||||
|
||||
try {
|
||||
inputStream = connection.getInputStream();
|
||||
if (isCompressed) {
|
||||
inputStream = new GZIPInputStream(inputStream);
|
||||
// Determine the length of the data to be read, after skipping.
|
||||
boolean isCompressed = isCompressed(connection);
|
||||
if (!isCompressed) {
|
||||
if (dataSpec.length != C.LENGTH_UNSET) {
|
||||
bytesToRead = dataSpec.length;
|
||||
} else {
|
||||
long contentLength = HttpUtil.getContentLength(
|
||||
connection.getHeaderField(HttpHeaders.CONTENT_LENGTH),
|
||||
connection.getHeaderField(HttpHeaders.CONTENT_RANGE)
|
||||
);
|
||||
|
||||
bytesToRead = contentLength != C.LENGTH_UNSET ? (contentLength - bytesToSkip) : C.LENGTH_UNSET;
|
||||
}
|
||||
} else {
|
||||
// Gzip is enabled. If the server opts to use gzip then the content length in the response
|
||||
// will be that of the compressed data, which isn't what we want. Always use the dataSpec
|
||||
// length in this case.
|
||||
bytesToRead = dataSpec.length;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
closeConnectionQuietly();
|
||||
throw new HttpDataSourceException(
|
||||
e,
|
||||
dataSpec,
|
||||
PlaybackException.ERROR_CODE_IO_UNSPECIFIED,
|
||||
HttpDataSourceException.TYPE_OPEN);
|
||||
}
|
||||
|
||||
opened = true;
|
||||
transferStarted(dataSpec);
|
||||
|
||||
try {
|
||||
skipFully(bytesToSkip, dataSpec);
|
||||
} catch (IOException e) {
|
||||
closeConnectionQuietly();
|
||||
|
||||
if (e instanceof HttpDataSourceException) {
|
||||
throw (HttpDataSourceException) e;
|
||||
try {
|
||||
inputStream = connection.getInputStream();
|
||||
if (isCompressed) {
|
||||
inputStream = new GZIPInputStream(inputStream);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
closeConnectionQuietly();
|
||||
throw new HttpDataSourceException(
|
||||
e,
|
||||
dataSpec,
|
||||
PlaybackException.ERROR_CODE_IO_UNSPECIFIED,
|
||||
HttpDataSourceException.TYPE_OPEN);
|
||||
}
|
||||
throw new HttpDataSourceException(
|
||||
e,
|
||||
dataSpec,
|
||||
PlaybackException.ERROR_CODE_IO_UNSPECIFIED,
|
||||
HttpDataSourceException.TYPE_OPEN);
|
||||
}
|
||||
|
||||
return bytesToRead;
|
||||
opened = true;
|
||||
transferStarted(dataSpec);
|
||||
|
||||
try {
|
||||
skipFully(bytesToSkip, dataSpec);
|
||||
} catch (IOException e) {
|
||||
closeConnectionQuietly();
|
||||
|
||||
if (e instanceof HttpDataSourceException) {
|
||||
throw (HttpDataSourceException) e;
|
||||
}
|
||||
throw new HttpDataSourceException(
|
||||
e,
|
||||
dataSpec,
|
||||
PlaybackException.ERROR_CODE_IO_UNSPECIFIED,
|
||||
HttpDataSourceException.TYPE_OPEN);
|
||||
}
|
||||
|
||||
return bytesToRead;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M320,880Q303,880 291.5,868.5Q280,857 280,840L280,200Q280,183 291.5,171.5Q303,160 320,160L400,160L400,80L560,80L560,160L640,160Q657,160 668.5,171.5Q680,183 680,200L680,840Q680,857 668.5,868.5Q657,880 640,880L320,880Z"/>
|
||||
</vector>
|
||||
@@ -52,6 +52,7 @@
|
||||
android:fontFamily="monospace"
|
||||
android:text=""
|
||||
android:textAlignment="center"
|
||||
android:maxHeight="120dp"
|
||||
android:layout_marginStart="30dp"
|
||||
android:layout_marginEnd="30dp"
|
||||
android:layout_marginTop="5dp"
|
||||
|
||||
@@ -1,44 +1,81 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_marginTop="10dp"
|
||||
android:background="@drawable/background_slide_up_option"
|
||||
android:id="@+id/slide_up_menu_item_root"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/slide_up_menu_item_image"
|
||||
android:layout_width="20dp"
|
||||
android:layout_height="20dp"
|
||||
android:layout_marginLeft="15dp"
|
||||
android:scaleType="fitCenter"
|
||||
app:srcCompat="@drawable/ic_share" />
|
||||
<TextView
|
||||
android:id="@+id/slide_up_menu_item_text"
|
||||
android:layout_width="wrap_content"
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_alignParentLeft="true"
|
||||
android:layout_marginLeft="45dp"
|
||||
tools:text="Cat videos"
|
||||
android:textSize="11dp"
|
||||
android:layout_marginTop="2dp"
|
||||
android:fontFamily="@font/inter_light"
|
||||
android:textColor="@color/white" />
|
||||
<TextView
|
||||
android:id="@+id/slide_up_menu_item_subtext"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_alignParentRight="true"
|
||||
android:layout_marginRight="15dp"
|
||||
tools:text="3 videos"
|
||||
android:textSize="11dp"
|
||||
android:layout_marginTop="2dp"
|
||||
android:fontFamily="@font/inter_light"
|
||||
android:textColor="#ACACAC" />
|
||||
android:id="@+id/slide_up_menu_item_root"
|
||||
android:layout_marginTop="10dp"
|
||||
android:background="@drawable/background_slide_up_option"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
<ImageView
|
||||
android:id="@+id/slide_up_menu_item_image"
|
||||
android:layout_width="20dp"
|
||||
android:layout_height="20dp"
|
||||
android:layout_marginLeft="15dp"
|
||||
android:scaleType="fitCenter"
|
||||
android:layout_gravity="center_vertical"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:srcCompat="@drawable/ic_share" />
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingTop="12dp"
|
||||
android:paddingBottom="12dp"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintLeft_toRightOf="@id/slide_up_menu_item_image"
|
||||
app:layout_constraintRight_toLeftOf="@id/slide_up_menu_item_subtext"
|
||||
android:gravity="start"
|
||||
android:orientation="vertical">
|
||||
<TextView
|
||||
android:id="@+id/slide_up_menu_item_text"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_alignParentLeft="true"
|
||||
android:layout_marginLeft="20dp"
|
||||
tools:text="Cat videos"
|
||||
android:textSize="11dp"
|
||||
android:layout_marginTop="2dp"
|
||||
android:fontFamily="@font/inter_light"
|
||||
android:textColor="@color/white" />
|
||||
<TextView
|
||||
android:id="@+id/slide_up_menu_item_description"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_alignParentLeft="true"
|
||||
android:layout_marginLeft="20dp"
|
||||
tools:text="test Description"
|
||||
android:textSize="11dp"
|
||||
android:layout_marginTop="2dp"
|
||||
android:fontFamily="@font/inter_light"
|
||||
android:textColor="#ACACAC" />
|
||||
</LinearLayout>
|
||||
<TextView
|
||||
android:id="@+id/slide_up_menu_item_subtext"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_alignParentRight="true"
|
||||
android:layout_marginRight="15dp"
|
||||
tools:text="3 videos"
|
||||
android:textSize="11dp"
|
||||
android:layout_marginTop="2dp"
|
||||
android:fontFamily="@font/inter_light"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
android:textColor="#ACACAC" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
</RelativeLayout>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -285,6 +285,8 @@
|
||||
<string name="attempt_to_utilize_byte_ranges">Attempt to utilize byte ranges</string>
|
||||
<string name="auto_update">Auto Update</string>
|
||||
<string name="auto_rotate">Auto-Rotate</string>
|
||||
<string name="simplify_sources">Simplify sources</string>
|
||||
<string name="simplify_sources_description">Deduplicate sources by resolution so that only more relevant sources are visible.</string>
|
||||
<string name="auto_rotate_dead_zone">Auto-Rotate Dead Zone</string>
|
||||
<string name="automatic_backup">Automatic Backup</string>
|
||||
<string name="background_behavior">Background Behavior</string>
|
||||
@@ -371,6 +373,12 @@
|
||||
<string name="system_volume_descr">Gesture controls adjust system volume</string>
|
||||
<string name="live_chat_webview">Live Chat Webview</string>
|
||||
<string name="full_screen_portrait">Fullscreen portrait</string>
|
||||
<string name="prefer_webm">Prefer Webm Video Codecs</string>
|
||||
<string name="prefer_webm_description">If player should prefer Webm codecs (vp9/opus) over mp4 codecs (h264/AAC), may result in worse compatibility.</string>
|
||||
<string name="prefer_webm_audio">Prefer Webm Audio Codecs</string>
|
||||
<string name="prefer_webm_audio_description">If player should prefer Webm codecs (opus) over mp4 codecs (AAC), may result in worse compatibility.</string>
|
||||
<string name="allow_under_cutout">Allow video under cutout</string>
|
||||
<string name="allow_under_cutout_description">Allow video to go underneath the screen cutout in full-screen.\nMay require restart</string>
|
||||
<string name="allow_full_screen_portrait">Allow fullscreen portrait</string>
|
||||
<string name="background_switch_audio">Switch to Audio in Background</string>
|
||||
<string name="background_switch_audio_description">Optimize bandwidth usage by switching to audio-only stream in background if available, may cause stutter</string>
|
||||
@@ -393,6 +401,7 @@
|
||||
<string name="can_be_disabled_when_you_are_experiencing_issues">Can be disabled when you are experiencing issues</string>
|
||||
<string name="bypass_rotation_prevention_description">Allows for rotation on non-video views.\nWARNING: Not designed for it</string>
|
||||
<string name="bypass_rotation_prevention_warning">This may cause unexpected behavior, and is mostly untested.</string>
|
||||
<string name="changing_this_field_requires_restart">Changing this field will require an app restart.</string>
|
||||
<string name="player">Player</string>
|
||||
<string name="plugins">Plugins</string>
|
||||
<string name="preferred_casting_quality">Preferred Casting Quality</string>
|
||||
@@ -768,6 +777,8 @@
|
||||
<string name="zoom">Zoom</string>
|
||||
<string name="check_to_see_if_an_update_is_available">Check to see if an update is available.</string>
|
||||
<string name="scroll_to_top">Scroll to top</string>
|
||||
<string name="disable_battery_optimization">Disable Battery Optimization</string>
|
||||
<string name="click_to_go_to_battery_optimization_settings_disabling_battery_optimization_will_prevent_the_os_from_killing_media_sessions">Click to go to battery optimization settings. Disabling battery optimization will prevent the OS from killing media sessions.</string>
|
||||
<string-array name="home_screen_array">
|
||||
<item>Recommendations</item>
|
||||
<item>Subscriptions</item>
|
||||
|
||||
@@ -33,6 +33,11 @@
|
||||
<data android:host="bilibili.com" />
|
||||
<data android:host="bilibili.tv" />
|
||||
<data android:host="spotify.com" />
|
||||
<data android:host="dailymotion.com" />
|
||||
<data android:host="www.dailymotion.com" />
|
||||
<data android:host="bitchute.com" />
|
||||
<data android:host="www.bitchute.com" />
|
||||
<data android:host="old.bitchute.com" />
|
||||
<data android:pathPrefix="/" />
|
||||
</intent-filter>
|
||||
<intent-filter android:autoVerify="true">
|
||||
@@ -57,6 +62,11 @@
|
||||
<data android:host="bilibili.com" />
|
||||
<data android:host="bilibili.tv" />
|
||||
<data android:host="spotify.com" />
|
||||
<data android:host="dailymotion.com" />
|
||||
<data android:host="www.dailymotion.com" />
|
||||
<data android:host="bitchute.com" />
|
||||
<data android:host="www.bitchute.com" />
|
||||
<data android:host="old.bitchute.com" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
Submodule app/src/stable/assets/sources/bilibili updated: 2c279cb123...31490e10f9
Submodule
+1
Submodule app/src/stable/assets/sources/bitchute added at 872bfa2d75
+1
Submodule app/src/stable/assets/sources/dailymotion added at 069aa3d31a
Submodule app/src/stable/assets/sources/rumble updated: bedbc4a989...cbfe372bcc
Submodule app/src/stable/assets/sources/spotify updated: c700081466...4d554e9388
Submodule app/src/stable/assets/sources/youtube updated: 546d862342...a4766ec223
@@ -10,7 +10,9 @@
|
||||
"aac9e9f0-24b5-11ee-be56-0242ac120002": "sources/patreon/PatreonConfig.json",
|
||||
"9d703ff5-c556-4962-a990-4f000829cb87": "sources/nebula/NebulaConfig.json",
|
||||
"cf8ea74d-ad9b-489e-a083-539b6aa8648c": "sources/bilibili/build/BiliBiliConfig.json",
|
||||
"4e365633-6d3f-4267-8941-fdc36631d813": "sources/spotify/build/SpotifyConfig.json"
|
||||
"4e365633-6d3f-4267-8941-fdc36631d813": "sources/spotify/build/SpotifyConfig.json",
|
||||
"9c87e8db-e75d-48f4-afe5-2d203d4b95c5": "sources/dailymotion/build/DailymotionConfig.json",
|
||||
"e8b1ad5f-0c6d-497d-a5fa-0a785a16d902": "sources/bitchute/BitchuteConfig.json"
|
||||
},
|
||||
"SOURCES_EMBEDDED_DEFAULT": [
|
||||
"35ae969a-a7db-11ed-afa1-0242ac120002"
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
package com.futo.platformplayer
|
||||
|
||||
import com.futo.platformplayer.mdns.DnsOpcode
|
||||
import com.futo.platformplayer.mdns.DnsPacket
|
||||
import com.futo.platformplayer.mdns.DnsPacketHeader
|
||||
import com.futo.platformplayer.mdns.DnsQuestion
|
||||
import com.futo.platformplayer.mdns.DnsReader
|
||||
import com.futo.platformplayer.mdns.DnsResponseCode
|
||||
import com.futo.platformplayer.mdns.DnsWriter
|
||||
import com.futo.platformplayer.mdns.QueryResponse
|
||||
import com.futo.platformplayer.mdns.QuestionClass
|
||||
import com.futo.platformplayer.mdns.QuestionType
|
||||
import com.futo.platformplayer.mdns.ResourceRecordClass
|
||||
import com.futo.platformplayer.mdns.ResourceRecordType
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import junit.framework.TestCase.assertTrue
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.net.InetAddress
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
|
||||
|
||||
class MdnsTests {
|
||||
|
||||
@Test
|
||||
fun `BasicOperation`() {
|
||||
val expectedData = byteArrayOf(
|
||||
0x00, 0x01,
|
||||
0x00, 0x00, 0x00, 0x02,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03,
|
||||
0x00, 0x01,
|
||||
0x00, 0x00, 0x00, 0x02,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03
|
||||
)
|
||||
|
||||
val writer = DnsWriter()
|
||||
writer.write(1.toUShort())
|
||||
writer.write(2.toUInt())
|
||||
writer.write(3.toULong())
|
||||
writer.write(1.toShort())
|
||||
writer.write(2)
|
||||
writer.write(3L)
|
||||
|
||||
assertContentEquals(expectedData, writer.toByteArray())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `DnsQuestionFormat`() {
|
||||
val expectedBytes = ubyteArrayOf(
|
||||
0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x01u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x08u, 0x5fu, 0x61u, 0x69u, 0x72u, 0x70u, 0x6cu, 0x61u, 0x79u, 0x04u, 0x5fu, 0x74u, 0x63u, 0x70u, 0x05u, 0x6cu, 0x6fu, 0x63u, 0x61u, 0x6cu, 0x00u, 0x00u, 0x0cu, 0x00u, 0x01u
|
||||
).asByteArray()
|
||||
|
||||
val writer = DnsWriter()
|
||||
writer.writePacket(
|
||||
header = DnsPacketHeader(
|
||||
identifier = 0.toUShort(),
|
||||
queryResponse = QueryResponse.Query.value.toInt(),
|
||||
opcode = DnsOpcode.StandardQuery.value.toInt(),
|
||||
authoritativeAnswer = false,
|
||||
truncated = false,
|
||||
recursionDesired = false,
|
||||
recursionAvailable = false,
|
||||
answerAuthenticated = false,
|
||||
nonAuthenticatedData = false,
|
||||
responseCode = DnsResponseCode.NoError
|
||||
),
|
||||
questionCount = 1,
|
||||
questionWriter = { w, _ ->
|
||||
w.write(DnsQuestion(
|
||||
name = "_airplay._tcp.local",
|
||||
type = QuestionType.PTR.value.toInt(),
|
||||
clazz = QuestionClass.IN.value.toInt(),
|
||||
queryUnicast = false
|
||||
))
|
||||
}
|
||||
)
|
||||
|
||||
assertContentEquals(expectedBytes, writer.toByteArray())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `BeyondTests`() {
|
||||
val data = byteArrayOf(
|
||||
0x00, 0x01,
|
||||
0x00, 0x00, 0x00, 0x02,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03,
|
||||
0x00, 0x01,
|
||||
0x00, 0x00, 0x00, 0x02,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03
|
||||
)
|
||||
|
||||
val reader = DnsReader(data)
|
||||
assertEquals(1, reader.readInt16())
|
||||
assertEquals(2, reader.readInt32())
|
||||
assertEquals(3L, reader.readInt64())
|
||||
assertEquals(1.toUShort(), reader.readUInt16())
|
||||
assertEquals(2.toUInt(), reader.readUInt32())
|
||||
assertEquals(3.toULong(), reader.readUInt64())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ParseDnsPrinter`() {
|
||||
val data = ubyteArrayOf(
|
||||
0x00u, 0x00u,
|
||||
0x84u, 0x00u, 0x00u, 0x00u, 0x00u, 0x01u, 0x00u, 0x00u, 0x00u, 0x06u, 0x04u, 0x5fu, 0x69u, 0x70u, 0x70u, 0x04u,
|
||||
0x5fu, 0x74u, 0x63u, 0x70u, 0x05u, 0x6cu, 0x6fu, 0x63u, 0x61u, 0x6cu, 0x00u, 0x00u, 0x0cu, 0x00u, 0x01u, 0x00u,
|
||||
0x00u, 0x11u, 0x94u, 0x00u, 0x1eu, 0x1bu, 0x42u, 0x72u, 0x6fu, 0x74u, 0x68u, 0x65u, 0x72u, 0x20u, 0x44u, 0x43u,
|
||||
0x50u, 0x2du, 0x4cu, 0x33u, 0x35u, 0x35u, 0x30u, 0x43u, 0x44u, 0x57u, 0x20u, 0x73u, 0x65u, 0x72u, 0x69u, 0x65u,
|
||||
0x73u, 0xc0u, 0x0cu, 0xc0u, 0x27u, 0x00u, 0x10u, 0x80u, 0x01u, 0x00u, 0x00u, 0x11u, 0x94u, 0x02u, 0x53u, 0x09u,
|
||||
0x74u, 0x78u, 0x74u, 0x76u, 0x65u, 0x72u, 0x73u, 0x3du, 0x31u, 0x08u, 0x71u, 0x74u, 0x6fu, 0x74u, 0x61u, 0x6cu,
|
||||
0x3du, 0x31u, 0x42u, 0x70u, 0x64u, 0x6cu, 0x3du, 0x61u, 0x70u, 0x70u, 0x6cu, 0x69u, 0x63u, 0x61u, 0x74u, 0x69u,
|
||||
0x6fu, 0x6eu, 0x2fu, 0x6fu, 0x63u, 0x74u, 0x65u, 0x74u, 0x2du, 0x73u, 0x74u, 0x72u, 0x65u, 0x61u, 0x6du, 0x2cu,
|
||||
0x69u, 0x6du, 0x61u, 0x67u, 0x65u, 0x2fu, 0x75u, 0x72u, 0x66u, 0x2cu, 0x69u, 0x6du, 0x61u, 0x67u, 0x65u, 0x2fu,
|
||||
0x6au, 0x70u, 0x65u, 0x67u, 0x2cu, 0x69u, 0x6du, 0x61u, 0x67u, 0x65u, 0x2fu, 0x70u, 0x77u, 0x67u, 0x2du, 0x72u,
|
||||
0x61u, 0x73u, 0x74u, 0x65u, 0x72u, 0x0cu, 0x72u, 0x70u, 0x3du, 0x69u, 0x70u, 0x70u, 0x2fu, 0x70u, 0x72u, 0x69u,
|
||||
0x6eu, 0x74u, 0x05u, 0x6eu, 0x6fu, 0x74u, 0x65u, 0x3du, 0x1eu, 0x74u, 0x79u, 0x3du, 0x42u, 0x72u, 0x6fu, 0x74u,
|
||||
0x68u, 0x65u, 0x72u, 0x20u, 0x44u, 0x43u, 0x50u, 0x2du, 0x4cu, 0x33u, 0x35u, 0x35u, 0x30u, 0x43u, 0x44u, 0x57u,
|
||||
0x20u, 0x73u, 0x65u, 0x72u, 0x69u, 0x65u, 0x73u, 0x25u, 0x70u, 0x72u, 0x6fu, 0x64u, 0x75u, 0x63u, 0x74u, 0x3du,
|
||||
0x28u, 0x42u, 0x72u, 0x6fu, 0x74u, 0x68u, 0x65u, 0x72u, 0x20u, 0x44u, 0x43u, 0x50u, 0x2du, 0x4cu, 0x33u, 0x35u,
|
||||
0x35u, 0x30u, 0x43u, 0x44u, 0x57u, 0x20u, 0x73u, 0x65u, 0x72u, 0x69u, 0x65u, 0x73u, 0x29u, 0x3cu, 0x61u, 0x64u,
|
||||
0x6du, 0x69u, 0x6eu, 0x75u, 0x72u, 0x6cu, 0x3du, 0x68u, 0x74u, 0x74u, 0x70u, 0x3au, 0x2fu, 0x2fu, 0x42u, 0x52u,
|
||||
0x57u, 0x31u, 0x30u, 0x35u, 0x42u, 0x41u, 0x44u, 0x34u, 0x41u, 0x31u, 0x35u, 0x37u, 0x30u, 0x2eu, 0x6cu, 0x6fu,
|
||||
0x63u, 0x61u, 0x6cu, 0x2eu, 0x2fu, 0x6eu, 0x65u, 0x74u, 0x2fu, 0x6eu, 0x65u, 0x74u, 0x2fu, 0x61u, 0x69u, 0x72u,
|
||||
0x70u, 0x72u, 0x69u, 0x6eu, 0x74u, 0x2eu, 0x68u, 0x74u, 0x6du, 0x6cu, 0x0bu, 0x70u, 0x72u, 0x69u, 0x6fu, 0x72u,
|
||||
0x69u, 0x74u, 0x79u, 0x3du, 0x32u, 0x35u, 0x0fu, 0x75u, 0x73u, 0x62u, 0x5fu, 0x4du, 0x46u, 0x47u, 0x3du, 0x42u,
|
||||
0x72u, 0x6fu, 0x74u, 0x68u, 0x65u, 0x72u, 0x1bu, 0x75u, 0x73u, 0x62u, 0x5fu, 0x4du, 0x44u, 0x4cu, 0x3du, 0x44u,
|
||||
0x43u, 0x50u, 0x2du, 0x4cu, 0x33u, 0x35u, 0x35u, 0x30u, 0x43u, 0x44u, 0x57u, 0x20u, 0x73u, 0x65u, 0x72u, 0x69u,
|
||||
0x65u, 0x73u, 0x19u, 0x75u, 0x73u, 0x62u, 0x5fu, 0x43u, 0x4du, 0x44u, 0x3du, 0x50u, 0x4au, 0x4cu, 0x2cu, 0x50u,
|
||||
0x43u, 0x4cu, 0x2cu, 0x50u, 0x43u, 0x4cu, 0x58u, 0x4cu, 0x2cu, 0x55u, 0x52u, 0x46u, 0x07u, 0x43u, 0x6fu, 0x6cu,
|
||||
0x6fu, 0x72u, 0x3du, 0x54u, 0x08u, 0x43u, 0x6fu, 0x70u, 0x69u, 0x65u, 0x73u, 0x3du, 0x54u, 0x08u, 0x44u, 0x75u,
|
||||
0x70u, 0x6cu, 0x65u, 0x78u, 0x3du, 0x54u, 0x05u, 0x46u, 0x61u, 0x78u, 0x3du, 0x46u, 0x06u, 0x53u, 0x63u, 0x61u,
|
||||
0x6eu, 0x3du, 0x54u, 0x0du, 0x50u, 0x61u, 0x70u, 0x65u, 0x72u, 0x43u, 0x75u, 0x73u, 0x74u, 0x6fu, 0x6du, 0x3du,
|
||||
0x54u, 0x08u, 0x42u, 0x69u, 0x6eu, 0x61u, 0x72u, 0x79u, 0x3du, 0x54u, 0x0du, 0x54u, 0x72u, 0x61u, 0x6eu, 0x73u,
|
||||
0x70u, 0x61u, 0x72u, 0x65u, 0x6eu, 0x74u, 0x3du, 0x54u, 0x06u, 0x54u, 0x42u, 0x43u, 0x50u, 0x3du, 0x46u, 0x3eu,
|
||||
0x55u, 0x52u, 0x46u, 0x3du, 0x53u, 0x52u, 0x47u, 0x42u, 0x32u, 0x34u, 0x2cu, 0x57u, 0x38u, 0x2cu, 0x43u, 0x50u,
|
||||
0x31u, 0x2cu, 0x49u, 0x53u, 0x34u, 0x2du, 0x31u, 0x2cu, 0x4du, 0x54u, 0x31u, 0x2du, 0x33u, 0x2du, 0x34u, 0x2du,
|
||||
0x35u, 0x2du, 0x38u, 0x2du, 0x31u, 0x31u, 0x2cu, 0x4fu, 0x42u, 0x31u, 0x30u, 0x2cu, 0x50u, 0x51u, 0x34u, 0x2cu,
|
||||
0x52u, 0x53u, 0x36u, 0x30u, 0x30u, 0x2cu, 0x56u, 0x31u, 0x2eu, 0x34u, 0x2cu, 0x44u, 0x4du, 0x31u, 0x25u, 0x6bu,
|
||||
0x69u, 0x6eu, 0x64u, 0x3du, 0x64u, 0x6fu, 0x63u, 0x75u, 0x6du, 0x65u, 0x6eu, 0x74u, 0x2cu, 0x65u, 0x6eu, 0x76u,
|
||||
0x65u, 0x6cu, 0x6fu, 0x70u, 0x65u, 0x2cu, 0x6cu, 0x61u, 0x62u, 0x65u, 0x6cu, 0x2cu, 0x70u, 0x6fu, 0x73u, 0x74u,
|
||||
0x63u, 0x61u, 0x72u, 0x64u, 0x11u, 0x50u, 0x61u, 0x70u, 0x65u, 0x72u, 0x4du, 0x61u, 0x78u, 0x3du, 0x6cu, 0x65u,
|
||||
0x67u, 0x61u, 0x6cu, 0x2du, 0x41u, 0x34u, 0x29u, 0x55u, 0x55u, 0x49u, 0x44u, 0x3du, 0x65u, 0x33u, 0x32u, 0x34u,
|
||||
0x38u, 0x30u, 0x30u, 0x30u, 0x2du, 0x38u, 0x30u, 0x63u, 0x65u, 0x2du, 0x31u, 0x31u, 0x64u, 0x62u, 0x2du, 0x38u,
|
||||
0x30u, 0x30u, 0x30u, 0x2du, 0x33u, 0x63u, 0x32u, 0x61u, 0x66u, 0x34u, 0x61u, 0x61u, 0x63u, 0x30u, 0x61u, 0x34u,
|
||||
0x0cu, 0x70u, 0x72u, 0x69u, 0x6eu, 0x74u, 0x5fu, 0x77u, 0x66u, 0x64u, 0x73u, 0x3du, 0x54u, 0x14u, 0x6du, 0x6fu,
|
||||
0x70u, 0x72u, 0x69u, 0x61u, 0x2du, 0x63u, 0x65u, 0x72u, 0x74u, 0x69u, 0x66u, 0x69u, 0x65u, 0x64u, 0x3du, 0x31u,
|
||||
0x2eu, 0x33u, 0x0fu, 0x42u, 0x52u, 0x57u, 0x31u, 0x30u, 0x35u, 0x42u, 0x41u, 0x44u, 0x34u, 0x41u, 0x31u, 0x35u,
|
||||
0x37u, 0x30u, 0xc0u, 0x16u, 0x00u, 0x01u, 0x80u, 0x01u, 0x00u, 0x00u, 0x00u, 0x78u, 0x00u, 0x04u, 0xc0u, 0xa8u,
|
||||
0x01u, 0xc5u, 0xc2u, 0xa4u, 0x00u, 0x1cu, 0x80u, 0x01u, 0x00u, 0x00u, 0x00u, 0x78u, 0x00u, 0x10u, 0xfeu, 0x80u,
|
||||
0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x12u, 0x5bu, 0xadu, 0xffu, 0xfeu, 0x4au, 0x15u, 0x70u, 0xc0u, 0x27u,
|
||||
0x00u, 0x21u, 0x80u, 0x01u, 0x00u, 0x00u, 0x00u, 0x78u, 0x00u, 0x08u, 0x00u, 0x00u, 0x00u, 0x00u, 0x02u, 0x77u,
|
||||
0xc2u, 0xa4u, 0xc0u, 0x27u, 0x00u, 0x2fu, 0x80u, 0x01u, 0x00u, 0x00u, 0x11u, 0x94u, 0x00u, 0x09u, 0xc0u, 0x27u,
|
||||
0x00u, 0x05u, 0x00u, 0x00u, 0x80u, 0x00u, 0x40u, 0xc2u, 0xa4u, 0x00u, 0x2fu, 0x80u, 0x01u, 0x00u, 0x00u, 0x00u,
|
||||
0x78u, 0x00u, 0x08u, 0xc2u, 0xa4u, 0x00u, 0x04u, 0x40u, 0x00u, 0x00u, 0x08u
|
||||
)
|
||||
|
||||
val packet = DnsPacket.parse(data.asByteArray())
|
||||
assertEquals(QueryResponse.Response.value.toInt(), packet.header.queryResponse)
|
||||
assertEquals(DnsOpcode.StandardQuery.value.toInt(), packet.header.opcode)
|
||||
assertTrue(packet.header.authoritativeAnswer)
|
||||
assertEquals(false, packet.header.truncated)
|
||||
assertEquals(false, packet.header.recursionDesired)
|
||||
assertEquals(false, packet.header.recursionAvailable)
|
||||
assertEquals(false, packet.header.answerAuthenticated)
|
||||
assertEquals(false, packet.header.nonAuthenticatedData)
|
||||
assertEquals(DnsResponseCode.NoError, packet.header.responseCode)
|
||||
assertEquals(0, packet.questions.size)
|
||||
assertEquals(1, packet.answers.size)
|
||||
assertEquals(0, packet.authorities.size)
|
||||
assertEquals(6, packet.additionals.size)
|
||||
|
||||
val firstAnswer = packet.answers[0]
|
||||
assertEquals("_ipp._tcp.local", firstAnswer.name)
|
||||
assertEquals(ResourceRecordType.PTR.value.toInt(), firstAnswer.type)
|
||||
assertEquals(ResourceRecordClass.IN.value.toInt(), firstAnswer.clazz)
|
||||
assertEquals(false, firstAnswer.cacheFlush)
|
||||
assertEquals(4500u, firstAnswer.timeToLive)
|
||||
assertEquals(30, firstAnswer.dataLength)
|
||||
assertEquals("Brother DCP-L3550CDW series._ipp._tcp.local", firstAnswer.getDataReader().readPTRRecord().domainName)
|
||||
|
||||
val firstAdditional = packet.additionals[0]
|
||||
assertEquals("Brother DCP-L3550CDW series._ipp._tcp.local", firstAdditional.name)
|
||||
assertEquals(ResourceRecordType.TXT.value.toInt(), firstAdditional.type)
|
||||
assertEquals(ResourceRecordClass.IN.value.toInt(), firstAdditional.clazz)
|
||||
assertEquals(true, firstAdditional.cacheFlush)
|
||||
assertEquals(4500u, firstAdditional.timeToLive)
|
||||
assertEquals(595, firstAdditional.dataLength)
|
||||
|
||||
val txtRecord = firstAdditional.getDataReader().readTXTRecord()
|
||||
assertContentEquals(arrayOf(
|
||||
"txtvers=1",
|
||||
"qtotal=1",
|
||||
"pdl=application/octet-stream,image/urf,image/jpeg,image/pwg-raster",
|
||||
"rp=ipp/print",
|
||||
"note=",
|
||||
"ty=Brother DCP-L3550CDW series",
|
||||
"product=(Brother DCP-L3550CDW series)",
|
||||
"adminurl=http://BRW105BAD4A1570.local./net/net/airprint.html",
|
||||
"priority=25",
|
||||
"usb_MFG=Brother",
|
||||
"usb_MDL=DCP-L3550CDW series",
|
||||
"usb_CMD=PJL,PCL,PCLXL,URF",
|
||||
"Color=T",
|
||||
"Copies=T",
|
||||
"Duplex=T",
|
||||
"Fax=F",
|
||||
"Scan=T",
|
||||
"PaperCustom=T",
|
||||
"Binary=T",
|
||||
"Transparent=T",
|
||||
"TBCP=F",
|
||||
"URF=SRGB24,W8,CP1,IS4-1,MT1-3-4-5-8-11,OB10,PQ4,RS600,V1.4,DM1",
|
||||
"kind=document,envelope,label,postcard",
|
||||
"PaperMax=legal-A4",
|
||||
"UUID=e3248000-80ce-11db-8000-3c2af4aac0a4",
|
||||
"print_wfds=T",
|
||||
"mopria-certified=1.3"
|
||||
), txtRecord.texts.toTypedArray())
|
||||
|
||||
val aRecord = packet.additionals[1].getDataReader().readARecord()
|
||||
assertEquals(InetAddress.getByName("192.168.1.197"), aRecord.address)
|
||||
|
||||
val aaaaRecord = packet.additionals[2].getDataReader().readAAAARecord()
|
||||
assertEquals(InetAddress.getByName("fe80::125b:adff:fe4a:1570"), aaaaRecord.address)
|
||||
|
||||
val srvRecord = packet.additionals[3].getDataReader().readSRVRecord()
|
||||
assertEquals("BRW105BAD4A1570.local", srvRecord.target)
|
||||
assertEquals(0, srvRecord.weight.toInt())
|
||||
assertEquals(0, srvRecord.priority.toInt())
|
||||
assertEquals(631, srvRecord.port.toInt())
|
||||
|
||||
val nSECRecord = packet.additionals[4].getDataReader().readNSECRecord()
|
||||
assertEquals("Brother DCP-L3550CDW series._ipp._tcp.local", nSECRecord.ownerName)
|
||||
assertEquals(1, nSECRecord.typeBitMaps.size)
|
||||
assertEquals(0, nSECRecord.typeBitMaps[0].first)
|
||||
assertContentEquals(byteArrayOf(0, 0, 128.toByte(), 0, 64), nSECRecord.typeBitMaps[0].second)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ParseSamsungTV`() {
|
||||
val data = loadByteArray("samsung-airplay.hex")
|
||||
val packet = DnsPacket.parse(data)
|
||||
assertEquals(QueryResponse.Response.value.toInt(), packet.header.queryResponse)
|
||||
assertEquals(DnsOpcode.StandardQuery.value.toInt(), packet.header.opcode)
|
||||
assertTrue(packet.header.authoritativeAnswer)
|
||||
assertEquals(false, packet.header.truncated)
|
||||
assertEquals(false, packet.header.recursionDesired)
|
||||
assertEquals(false, packet.header.recursionAvailable)
|
||||
assertEquals(false, packet.header.answerAuthenticated)
|
||||
assertEquals(false, packet.header.nonAuthenticatedData)
|
||||
assertEquals(DnsResponseCode.NoError, packet.header.responseCode)
|
||||
assertEquals(0, packet.questions.size)
|
||||
assertEquals(6, packet.answers.size)
|
||||
assertEquals(0, packet.authorities.size)
|
||||
assertEquals(4, packet.additionals.size)
|
||||
|
||||
assertEquals("9.1.168.192.in-addr.arpa", packet.answers[0].name)
|
||||
assertEquals(ResourceRecordType.PTR.value.toInt(), packet.answers[0].type)
|
||||
assertEquals(ResourceRecordClass.IN.value.toInt(), packet.answers[0].clazz)
|
||||
assertTrue(packet.answers[0].cacheFlush)
|
||||
assertEquals(120u, packet.answers[0].timeToLive)
|
||||
assertEquals(15, packet.answers[0].dataLength)
|
||||
assertEquals("Samsung.local", packet.answers[0].getDataReader().readPTRRecord().domainName)
|
||||
|
||||
val txtRecord = packet.answers[1].getDataReader().readTXTRecord()
|
||||
assertContentEquals(arrayOf(
|
||||
"acl=0",
|
||||
"deviceid=D4:9D:C0:2F:52:16",
|
||||
"features=0x7F8AD0,0x38BCB46",
|
||||
"rsf=0x3",
|
||||
"fv=p20.0.1",
|
||||
"flags=0x244",
|
||||
"model=URU8000",
|
||||
"manufacturer=Samsung",
|
||||
"serialNumber=0EQC3HDM900064X",
|
||||
"protovers=1.1",
|
||||
"srcvers=377.17.24.6",
|
||||
"pi=ED:0C:A5:ED:10:08",
|
||||
"psi=00000000-0000-0000-0000-ED0CA5ED1008",
|
||||
"gid=00000000-0000-0000-0000-ED0CA5ED1008",
|
||||
"gcgl=0",
|
||||
"pk=d25488cbff1334756165cd7229a235475ef591f2595f38ed251d46b8a4d2345d"
|
||||
), txtRecord.texts.toTypedArray())
|
||||
|
||||
val srvRecord = packet.answers[4].getDataReader().readSRVRecord()
|
||||
assertEquals(33482, srvRecord.port.toInt())
|
||||
assertEquals(0, srvRecord.priority.toInt())
|
||||
assertEquals(0, srvRecord.weight.toInt())
|
||||
assertEquals("Samsung.local", srvRecord.target)
|
||||
|
||||
val aRecord = packet.answers[5].getDataReader().readARecord()
|
||||
assertEquals(InetAddress.getByName("192.168.1.9"), aRecord.address)
|
||||
|
||||
val nSECRecord = packet.additionals[0].getDataReader().readNSECRecord()
|
||||
assertEquals("9.1.168.192.in-addr.arpa", nSECRecord.ownerName)
|
||||
assertEquals(1, nSECRecord.typeBitMaps.size)
|
||||
assertEquals(0, nSECRecord.typeBitMaps[0].first)
|
||||
assertContentEquals(byteArrayOf(0, 8), nSECRecord.typeBitMaps[0].second)
|
||||
|
||||
val optRecord = packet.additionals[3].getDataReader().readOPTRecord()
|
||||
assertEquals(1, optRecord.options.size)
|
||||
assertEquals(65001, optRecord.options[0].code.toInt())
|
||||
assertEquals(5, optRecord.options[0].data.size)
|
||||
assertContentEquals(byteArrayOf(0, 0, 116, 206.toByte(), 97), optRecord.options[0].data)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `UnicodeTest`() {
|
||||
val data = ubyteArrayOf(
|
||||
0x00u, 0x00u, 0x84u, 0x00u, 0x00u, 0x00u, 0x00u, 0x01u, 0x00u, 0x00u, 0x00u, 0x01u, 0x15u, 0x41u, 0x69u, 0x64u,
|
||||
0x61u, 0x6Eu, 0xE2u, 0x80u, 0x99u, 0x73u, 0x20u, 0x4Du, 0x61u, 0x63u, 0x42u, 0x6Fu, 0x6Fu, 0x6Bu, 0x20u, 0x50u,
|
||||
0x72u, 0x6Fu, 0x0Fu, 0x5Fu, 0x63u, 0x6Fu, 0x6Du, 0x70u, 0x61u, 0x6Eu, 0x69u, 0x6Fu, 0x6Eu, 0x2Du, 0x6Cu, 0x69u,
|
||||
0x6Eu, 0x6Bu, 0x04u, 0x5Fu, 0x74u, 0x63u, 0x70u, 0x05u, 0x6Cu, 0x6Fu, 0x63u, 0x61u, 0x6Cu, 0x00u, 0x00u, 0x10u,
|
||||
0x80u, 0x01u, 0x00u, 0x00u, 0x11u, 0x94u, 0x00u, 0x5Bu, 0x16u, 0x72u, 0x70u, 0x42u, 0x41u, 0x3Du, 0x30u, 0x33u,
|
||||
0x3Au, 0x43u, 0x32u, 0x3Au, 0x33u, 0x33u, 0x3Au, 0x38u, 0x36u, 0x3Au, 0x33u, 0x43u, 0x3Au, 0x45u, 0x45u, 0x11u,
|
||||
0x72u, 0x70u, 0x41u, 0x44u, 0x3Du, 0x66u, 0x33u, 0x33u, 0x37u, 0x61u, 0x38u, 0x61u, 0x32u, 0x38u, 0x64u, 0x35u,
|
||||
0x31u, 0x0Cu, 0x72u, 0x70u, 0x46u, 0x6Cu, 0x3Du, 0x30u, 0x78u, 0x32u, 0x30u, 0x30u, 0x30u, 0x30u, 0x11u, 0x72u,
|
||||
0x70u, 0x48u, 0x4Eu, 0x3Du, 0x31u, 0x66u, 0x66u, 0x64u, 0x64u, 0x64u, 0x66u, 0x33u, 0x63u, 0x39u, 0x65u, 0x33u,
|
||||
0x07u, 0x72u, 0x70u, 0x4Du, 0x61u, 0x63u, 0x3Du, 0x30u, 0x0Au, 0x72u, 0x70u, 0x56u, 0x72u, 0x3Du, 0x33u, 0x36u,
|
||||
0x30u, 0x2Eu, 0x34u, 0xC0u, 0x0Cu, 0x00u, 0x2Fu, 0x80u, 0x01u, 0x00u, 0x00u, 0x11u, 0x94u, 0x00u, 0x09u, 0xC0u,
|
||||
0x0Cu, 0x00u, 0x05u, 0x00u, 0x00u, 0x80u, 0x00u, 0x40u
|
||||
)
|
||||
|
||||
val packet = DnsPacket.parse(data.asByteArray())
|
||||
assertEquals("Aidan’s MacBook Pro._companion-link._tcp.local", packet.additionals[0].name)
|
||||
}
|
||||
|
||||
/*@Test
|
||||
fun `TestReadDomainName`() {
|
||||
val data = ubyteArrayOf(
|
||||
0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x04u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x0Bu, 0x5Fu, 0x67u, 0x6Fu,
|
||||
0x6Fu, 0x67u, 0x6Cu, 0x65u, 0x63u, 0x61u, 0x73u, 0x74u, 0x04u, 0x5Fu, 0x74u, 0x63u, 0x70u, 0x05u, 0x6Cu, 0x6Fu,
|
||||
0x63u, 0x61u, 0x6Cu, 0xC0u, 0x0Cu, 0x00u, 0x0Cu, 0x00u, 0x01u, 0x08u, 0x5Fu, 0x61u, 0x69u, 0x72u, 0x70u, 0x6Cu,
|
||||
0x61u, 0x79u, 0xC0u, 0x18u, 0x00u, 0x0Cu, 0x00u, 0x01u, 0x09u, 0x5Fu, 0x66u, 0x61u, 0x73u, 0x74u, 0x63u, 0x61u,
|
||||
0x73u, 0x74u, 0xC0u, 0x18u, 0x00u, 0x0Cu, 0x00u, 0x01u, 0x06u, 0x5Fu, 0x66u, 0x63u, 0x61u, 0x73u, 0x74u, 0xC0u,
|
||||
0x18u, 0x00u, 0x0Cu, 0x00u, 0x01u
|
||||
)
|
||||
|
||||
val packet = DnsPacket.parse(data.asByteArray())
|
||||
println()
|
||||
}*/
|
||||
|
||||
private fun loadByteArray(name: String): ByteArray {
|
||||
javaClass.classLoader.getResourceAsStream(name).use { input ->
|
||||
requireNotNull(input) { "File not found: $name" }
|
||||
val result = ByteArrayOutputStream()
|
||||
val buffer = ByteArray(4096)
|
||||
var length: Int
|
||||
|
||||
while ((input.read(buffer).also { length = it }) > 0) {
|
||||
result.write(buffer, 0, length)
|
||||
}
|
||||
return result.toByteArray()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ReserializeDnsPrinter`() {
|
||||
val data = loadByteArray("samsung-airplay.hex")
|
||||
val packet = DnsPacket.parse(data)
|
||||
val writer = DnsWriter()
|
||||
writer.writePacket(
|
||||
header = packet.header,
|
||||
questionCount = packet.questions.size,
|
||||
questionWriter = { _, _ -> },
|
||||
answerCount = packet.answers.size,
|
||||
answerWriter = { w, i ->
|
||||
w.write(packet.answers[i]) { v ->
|
||||
val reader = packet.answers[i].getDataReader()
|
||||
when (i) {
|
||||
0, 2, 3 -> v.write(reader.readPTRRecord())
|
||||
1 -> v.write(reader.readTXTRecord())
|
||||
4 -> v.write(reader.readSRVRecord())
|
||||
5 -> v.write(reader.readARecord())
|
||||
}
|
||||
}
|
||||
},
|
||||
authorityCount = packet.authorities.size,
|
||||
authorityWriter = { _, _ -> },
|
||||
additionalsCount = packet.additionals.size,
|
||||
additionalWriter = { w, i ->
|
||||
w.write(packet.additionals[i]) { v ->
|
||||
val reader = packet.additionals[i].getDataReader()
|
||||
when (i) {
|
||||
0, 1, 2 -> v.write(reader.readNSECRecord())
|
||||
3 -> v.write(reader.readOPTRecord())
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
assertContentEquals(data, writer.toByteArray())
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.futo.platformplayer
|
||||
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import junit.framework.TestCase.assertFalse
|
||||
import junit.framework.TestCase.assertTrue
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Test
|
||||
@@ -52,4 +54,21 @@ class UtilityTests {
|
||||
assertEquals("\ud83d\udc80\ud83d\udd14", "\\ud83d\\udc80\\ud83d\\udd14".decodeUnicode())
|
||||
assertEquals("String with a slash (/) in it", "String with a slash (/) in it".decodeUnicode())
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testMatchDomain() {
|
||||
//TLD
|
||||
assertTrue("test.abc.com".matchesDomain(".abc.com"))
|
||||
assertTrue("abc.com".matchesDomain("abc.com"))
|
||||
assertFalse("test.abc.com".matchesDomain("abc.com"))
|
||||
assertThrows(IllegalStateException::class.java, { "test.uk".matchesDomain(".uk") });
|
||||
|
||||
|
||||
//SLD
|
||||
assertTrue("abc.co.uk".matchesDomain("abc.co.uk"))
|
||||
assertTrue("test.abc.co.uk".matchesDomain("test.abc.co.uk"))
|
||||
assertTrue("test.abc.co.uk".matchesDomain(".abc.co.uk"))
|
||||
assertThrows(IllegalStateException::class.java, { "test.abc.co.uk".matchesDomain(".co.uk") });
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -33,6 +33,11 @@
|
||||
<data android:host="bilibili.com" />
|
||||
<data android:host="bilibili.tv" />
|
||||
<data android:host="spotify.com" />
|
||||
<data android:host="dailymotion.com" />
|
||||
<data android:host="www.dailymotion.com" />
|
||||
<data android:host="bitchute.com" />
|
||||
<data android:host="www.bitchute.com" />
|
||||
<data android:host="old.bitchute.com" />
|
||||
<data android:pathPrefix="/" />
|
||||
</intent-filter>
|
||||
<intent-filter android:autoVerify="true">
|
||||
@@ -57,6 +62,11 @@
|
||||
<data android:host="bilibili.com" />
|
||||
<data android:host="bilibili.tv" />
|
||||
<data android:host="spotify.com" />
|
||||
<data android:host="dailymotion.com" />
|
||||
<data android:host="www.dailymotion.com" />
|
||||
<data android:host="bitchute.com" />
|
||||
<data android:host="www.bitchute.com" />
|
||||
<data android:host="old.bitchute.com" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
Submodule app/src/unstable/assets/sources/bilibili updated: 2c279cb123...31490e10f9
Submodule
+1
Submodule app/src/unstable/assets/sources/bitchute added at 872bfa2d75
+1
Submodule app/src/unstable/assets/sources/dailymotion added at 069aa3d31a
Submodule app/src/unstable/assets/sources/rumble updated: bedbc4a989...cbfe372bcc
Submodule app/src/unstable/assets/sources/spotify updated: c700081466...4d554e9388
Submodule app/src/unstable/assets/sources/youtube updated: 546d862342...a4766ec223
@@ -10,7 +10,9 @@
|
||||
"aac9e9f0-24b5-11ee-be56-0242ac120002": "sources/patreon/PatreonConfig.json",
|
||||
"9d703ff5-c556-4962-a990-4f000829cb87": "sources/nebula/NebulaConfig.json",
|
||||
"cf8ea74d-ad9b-489e-a083-539b6aa8648c": "sources/bilibili/build/BiliBiliConfig.json",
|
||||
"4e365633-6d3f-4267-8941-fdc36631d813": "sources/spotify/build/SpotifyConfig.json"
|
||||
"4e365633-6d3f-4267-8941-fdc36631d813": "sources/spotify/build/SpotifyConfig.json",
|
||||
"9c87e8db-e75d-48f4-afe5-2d203d4b95c5": "sources/dailymotion/build/DailymotionConfig.json",
|
||||
"e8b1ad5f-0c6d-497d-a5fa-0a785a16d902": "sources/bitchute/BitchuteConfig.json"
|
||||
},
|
||||
"SOURCES_EMBEDDED_DEFAULT": [
|
||||
"35ae969a-a7db-11ed-afa1-0242ac120002"
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
plugins {
|
||||
id 'com.android.application' version '8.2.0' apply false
|
||||
id 'com.android.library' version '8.2.0' apply false
|
||||
id 'com.android.application' version '8.5.2' apply false
|
||||
id 'com.android.library' version '8.5.2' apply false
|
||||
id 'org.jetbrains.kotlin.android' version '1.9.0' apply false
|
||||
id 'com.google.protobuf' version '0.9.4' apply false
|
||||
id 'com.google.devtools.ksp' version '1.9.0-1.0.13' apply false
|
||||
|
||||
+1
-1
Submodule dep/futopay updated: 4268917697...c8992e6a0e
+1
-1
Submodule dep/polycentricandroid updated: cedbb52d33...a7063a300c
+1
-1
@@ -1,6 +1,6 @@
|
||||
#Fri Nov 11 13:25:09 CET 2022
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
|
||||
distributionPath=wrapper/dists
|
||||
zipStorePath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
Reference in New Issue
Block a user