mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-16 04:52:39 +02:00
Compare commits
123 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cb085acbff | |||
| c3d7df166b | |||
| d312062125 | |||
| e2453192aa | |||
| 0f4e4a7d97 | |||
| f20a708b36 | |||
| 8c4e511883 | |||
| a4a3b8d664 | |||
| bf6530ea81 | |||
| 4a80c2aab1 | |||
| 527bbfe43f | |||
| d8e1edb60b | |||
| 245b5f74c0 | |||
| e9a1f63415 | |||
| ec370dd94b | |||
| e39d862ef3 | |||
| 7b065654aa | |||
| 918b2bbe96 | |||
| e529a3d34d | |||
| 5475778d67 | |||
| c6a3ff0a53 | |||
| cf3587f504 | |||
| d42f104884 | |||
| 6a43568369 | |||
| 85c9cd0a6e | |||
| be5920cfae | |||
| 3d25d94a77 | |||
| fe97850835 | |||
| dab9decd89 | |||
| 854651aa71 | |||
| fdd1af3287 | |||
| 0bf92b6aff | |||
| d9403bf4da | |||
| 716d8caf4d | |||
| 0f0f368a75 | |||
| ff8d7558d4 | |||
| 66f9824b68 | |||
| 44a6e5da38 | |||
| de5a4aa5f3 | |||
| e8007082a7 | |||
| 3c70c5a366 | |||
| eb6e79b055 | |||
| ea59f8dccb | |||
| aef1c584e5 | |||
| c4ce671a87 | |||
| e8a79c87ab | |||
| 249e77a5d3 | |||
| 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 | |||
| 72efb21439 | |||
| aa8790ebdb | |||
| 6d491052ee | |||
| 87ff4691ce | |||
| 34d76e79ed | |||
| 31b43da96f | |||
| 0540e673e2 | |||
| 4e88a63809 | |||
| f7581f8a65 | |||
| e87a1c079c |
+12
@@ -70,3 +70,15 @@
|
|||||||
[submodule "app/src/unstable/assets/sources/spotify"]
|
[submodule "app/src/unstable/assets/sources/spotify"]
|
||||||
path = app/src/unstable/assets/sources/spotify
|
path = app/src/unstable/assets/sources/spotify
|
||||||
url = ../plugins/spotify.git
|
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 'com.android.application'
|
||||||
id 'org.jetbrains.kotlin.android'
|
id 'org.jetbrains.kotlin.android'
|
||||||
id 'org.jetbrains.kotlin.plugin.serialization' version '1.9.21'
|
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 'com.google.protobuf'
|
||||||
id 'kotlin-parcelize'
|
id 'kotlin-parcelize'
|
||||||
id 'com.google.devtools.ksp'
|
id 'com.google.devtools.ksp'
|
||||||
@@ -144,9 +144,19 @@ android {
|
|||||||
buildFeatures {
|
buildFeatures {
|
||||||
buildConfig true
|
buildConfig true
|
||||||
}
|
}
|
||||||
|
sourceSets {
|
||||||
|
main {
|
||||||
|
assets {
|
||||||
|
srcDirs 'src/main/assets', 'src/tests/assets', 'src/test/assets'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
implementation 'com.google.dagger:dagger:2.48'
|
||||||
|
implementation 'androidx.test:monitor:1.7.2'
|
||||||
|
annotationProcessor 'com.google.dagger:dagger-compiler:2.48'
|
||||||
|
|
||||||
//Core
|
//Core
|
||||||
implementation 'androidx.core:core-ktx:1.12.0'
|
implementation 'androidx.core:core-ktx:1.12.0'
|
||||||
@@ -184,7 +194,6 @@ dependencies {
|
|||||||
implementation 'androidx.media:media:1.7.0'
|
implementation 'androidx.media:media:1.7.0'
|
||||||
|
|
||||||
//Other
|
//Other
|
||||||
implementation 'org.jmdns:jmdns:3.5.1'
|
|
||||||
implementation 'org.jsoup:jsoup:1.15.3'
|
implementation 'org.jsoup:jsoup:1.15.3'
|
||||||
implementation 'com.google.android.flexbox:flexbox:3.0.0'
|
implementation 'com.google.android.flexbox:flexbox:3.0.0'
|
||||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.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="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||||
<uses-permission android:name="com.android.alarm.permission.SET_ALARM"/>
|
<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.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_MEDIA_PLAYBACK"/>
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
|
||||||
<uses-permission android:name="android.permission.WRITE_SETTINGS" tools:ignore="ProtectedPermissions"/>
|
<uses-permission android:name="android.permission.WRITE_SETTINGS" tools:ignore="ProtectedPermissions"/>
|
||||||
@@ -50,7 +51,7 @@
|
|||||||
android:name=".activities.MainActivity"
|
android:name=".activities.MainActivity"
|
||||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout"
|
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:screenOrientation="portrait"
|
android:screenOrientation="sensorPortrait"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar"
|
android:theme="@style/Theme.FutoVideo.NoActionBar"
|
||||||
android:launchMode="singleTask"
|
android:launchMode="singleTask"
|
||||||
android:resizeableActivity="true"
|
android:resizeableActivity="true"
|
||||||
@@ -152,27 +153,27 @@
|
|||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.SettingsActivity"
|
android:name=".activities.SettingsActivity"
|
||||||
android:screenOrientation="portrait"
|
android:screenOrientation="sensorPortrait"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.DeveloperActivity"
|
android:name=".activities.DeveloperActivity"
|
||||||
android:screenOrientation="portrait"
|
android:screenOrientation="sensorPortrait"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.ExceptionActivity"
|
android:name=".activities.ExceptionActivity"
|
||||||
android:screenOrientation="portrait"
|
android:screenOrientation="sensorPortrait"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.CaptchaActivity"
|
android:name=".activities.CaptchaActivity"
|
||||||
android:screenOrientation="portrait"
|
android:screenOrientation="sensorPortrait"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.LoginActivity"
|
android:name=".activities.LoginActivity"
|
||||||
android:screenOrientation="portrait"
|
android:screenOrientation="sensorPortrait"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.AddSourceActivity"
|
android:name=".activities.AddSourceActivity"
|
||||||
android:screenOrientation="portrait"
|
android:screenOrientation="sensorPortrait"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar">
|
android:theme="@style/Theme.FutoVideo.NoActionBar">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
@@ -186,44 +187,44 @@
|
|||||||
</activity>
|
</activity>
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.AddSourceOptionsActivity"
|
android:name=".activities.AddSourceOptionsActivity"
|
||||||
android:screenOrientation="portrait"
|
android:screenOrientation="sensorPortrait"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.PolycentricHomeActivity"
|
android:name=".activities.PolycentricHomeActivity"
|
||||||
android:screenOrientation="portrait"
|
android:screenOrientation="sensorPortrait"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.PolycentricBackupActivity"
|
android:name=".activities.PolycentricBackupActivity"
|
||||||
android:screenOrientation="portrait"
|
android:screenOrientation="sensorPortrait"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.PolycentricCreateProfileActivity"
|
android:name=".activities.PolycentricCreateProfileActivity"
|
||||||
android:screenOrientation="portrait"
|
android:screenOrientation="sensorPortrait"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.PolycentricProfileActivity"
|
android:name=".activities.PolycentricProfileActivity"
|
||||||
android:screenOrientation="portrait"
|
android:screenOrientation="sensorPortrait"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.PolycentricWhyActivity"
|
android:name=".activities.PolycentricWhyActivity"
|
||||||
android:screenOrientation="portrait"
|
android:screenOrientation="sensorPortrait"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.PolycentricImportProfileActivity"
|
android:name=".activities.PolycentricImportProfileActivity"
|
||||||
android:screenOrientation="portrait"
|
android:screenOrientation="sensorPortrait"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.ManageTabsActivity"
|
android:name=".activities.ManageTabsActivity"
|
||||||
android:screenOrientation="portrait"
|
android:screenOrientation="sensorPortrait"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.QRCaptureActivity"
|
android:name=".activities.QRCaptureActivity"
|
||||||
android:screenOrientation="portrait"
|
android:screenOrientation="sensorPortrait"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.FCastGuideActivity"
|
android:name=".activities.FCastGuideActivity"
|
||||||
android:screenOrientation="portrait"
|
android:screenOrientation="sensorPortrait"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -201,7 +201,7 @@ class PlatformContent {
|
|||||||
obj = obj ?? {};
|
obj = obj ?? {};
|
||||||
this.id = obj.id ?? PlatformID(); //PlatformID
|
this.id = obj.id ?? PlatformID(); //PlatformID
|
||||||
this.name = obj.name ?? ""; //string
|
this.name = obj.name ?? ""; //string
|
||||||
this.thumbnails = obj.thumbnails; //Thumbnail[]
|
this.thumbnails = obj.thumbnails ?? new Thumbnails([]); //Thumbnail[]
|
||||||
this.author = obj.author; //PlatformAuthorLink
|
this.author = obj.author; //PlatformAuthorLink
|
||||||
this.datetime = obj.datetime ?? obj.uploadDate ?? 0; //OffsetDateTime (Long)
|
this.datetime = obj.datetime ?? obj.uploadDate ?? 0; //OffsetDateTime (Long)
|
||||||
this.url = obj.url ?? ""; //String
|
this.url = obj.url ?? ""; //String
|
||||||
@@ -278,12 +278,49 @@ class PlatformPostDetails extends PlatformPost {
|
|||||||
super(obj);
|
super(obj);
|
||||||
obj = obj ?? {};
|
obj = obj ?? {};
|
||||||
this.plugin_type = "PlatformPostDetails";
|
this.plugin_type = "PlatformPostDetails";
|
||||||
this.rating = obj.rating ?? RatingLikes(-1);
|
this.rating = obj.rating ?? new RatingLikes(-1);
|
||||||
this.textType = obj.textType ?? 0;
|
this.textType = obj.textType ?? 0;
|
||||||
this.content = obj.content ?? "";
|
this.content = obj.content ?? "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class PlatformArticleDetails extends PlatformContent {
|
||||||
|
constructor(obj) {
|
||||||
|
super(obj, 3);
|
||||||
|
obj = obj ?? {};
|
||||||
|
this.plugin_type = "PlatformArticleDetails";
|
||||||
|
this.rating = obj.rating ?? new RatingLikes(-1);
|
||||||
|
this.summary = obj.summary ?? "";
|
||||||
|
this.segments = obj.segments ?? [];
|
||||||
|
this.thumbnails = obj.thumbnails ?? new Thumbnails([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class ArticleSegment {
|
||||||
|
constructor(type) {
|
||||||
|
this.type = type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class ArticleTextSegment extends ArticleSegment {
|
||||||
|
constructor(content, textType) {
|
||||||
|
super(1);
|
||||||
|
this.textType = textType;
|
||||||
|
this.content = content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class ArticleImagesSegment extends ArticleSegment {
|
||||||
|
constructor(images) {
|
||||||
|
super(2);
|
||||||
|
this.images = images;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class ArticleNestedSegment extends ArticleSegment {
|
||||||
|
constructor(nested) {
|
||||||
|
super(9);
|
||||||
|
this.nested = nested;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
//Sources
|
//Sources
|
||||||
class VideoSourceDescriptor {
|
class VideoSourceDescriptor {
|
||||||
constructor(obj) {
|
constructor(obj) {
|
||||||
@@ -406,6 +443,39 @@ class DashSource {
|
|||||||
this.requestModifier = obj.requestModifier;
|
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 {
|
class RequestModifier {
|
||||||
constructor(obj) {
|
constructor(obj) {
|
||||||
@@ -762,3 +832,99 @@ class URLSearchParams {
|
|||||||
return searchString;
|
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,122 @@
|
|||||||
|
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) {
|
||||||
|
try {
|
||||||
|
delay(stabilityThresholdTime)
|
||||||
|
if (newOrientation == lastStableOrientation) {
|
||||||
|
lastOrientation = newOrientation
|
||||||
|
onOrientationChanged.emit(newOrientation)
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.i(TAG, "Failed to trigger onOrientationChanged", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val TAG = "AdvancedOrientationListener"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,10 @@ fun IAudioSource.isDownloadable(): Boolean = VideoHelper.isDownloadable(this);
|
|||||||
@UnstableApi
|
@UnstableApi
|
||||||
fun JSSource.getHttpDataSourceFactory(): HttpDataSource.Factory {
|
fun JSSource.getHttpDataSourceFactory(): HttpDataSource.Factory {
|
||||||
val requestModifier = getRequestModifier();
|
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);
|
JSHttpDataSource.Factory().setRequestModifier(requestModifier);
|
||||||
} else {
|
} else {
|
||||||
DefaultHttpDataSource.Factory();
|
DefaultHttpDataSource.Factory();
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,9 @@
|
|||||||
package com.futo.platformplayer
|
package com.futo.platformplayer
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import java.net.Inet4Address
|
||||||
|
import java.net.Inet6Address
|
||||||
|
import java.net.InetAddress
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
import java.net.URISyntaxException
|
import java.net.URISyntaxException
|
||||||
import java.net.URLEncoder
|
import java.net.URLEncoder
|
||||||
@@ -25,4 +28,18 @@ fun String?.yesNoToBoolean(): Boolean {
|
|||||||
|
|
||||||
fun Boolean?.toYesNo(): String {
|
fun Boolean?.toYesNo(): String {
|
||||||
return if (this == true) "YES" else "NO"
|
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.ActivityNotFoundException
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Context.POWER_SERVICE
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.PowerManager
|
||||||
import android.webkit.CookieManager
|
import android.webkit.CookieManager
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.futo.platformplayer.activities.MainActivity
|
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.StateCache
|
||||||
import com.futo.platformplayer.states.StateMeta
|
import com.futo.platformplayer.states.StateMeta
|
||||||
import com.futo.platformplayer.states.StatePayment
|
import com.futo.platformplayer.states.StatePayment
|
||||||
|
import com.futo.platformplayer.states.StatePlayer
|
||||||
import com.futo.platformplayer.states.StatePolycentric
|
import com.futo.platformplayer.states.StatePolycentric
|
||||||
import com.futo.platformplayer.states.StateUpdate
|
import com.futo.platformplayer.states.StateUpdate
|
||||||
import com.futo.platformplayer.stores.FragmentedStorage
|
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.FormFieldButton
|
||||||
import com.futo.platformplayer.views.fields.FormFieldWarning
|
import com.futo.platformplayer.views.fields.FormFieldWarning
|
||||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
|
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.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -44,6 +49,7 @@ import kotlinx.serialization.json.Json
|
|||||||
import java.io.File
|
import java.io.File
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class MenuBottomBarSetting(val id: Int, var enabled: Boolean);
|
data class MenuBottomBarSetting(val id: Int, var enabled: Boolean);
|
||||||
|
|
||||||
@@ -57,7 +63,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
@Transient
|
@Transient
|
||||||
val onTabsChanged = Event0();
|
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)
|
@FormFieldButton(R.drawable.ic_person)
|
||||||
fun managePolycentricIdentity() {
|
fun managePolycentricIdentity() {
|
||||||
SettingsActivity.getActivity()?.let {
|
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)
|
@FormFieldButton(R.drawable.ic_quiz)
|
||||||
fun openFAQ() {
|
fun openFAQ() {
|
||||||
try {
|
try {
|
||||||
@@ -83,7 +89,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
//Ignored
|
//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)
|
@FormFieldButton(R.drawable.ic_data_alert)
|
||||||
fun openIssues() {
|
fun openIssues() {
|
||||||
try {
|
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)
|
@FormFieldButton(R.drawable.ic_tabs)
|
||||||
fun manageTabs() {
|
fun manageTabs() {
|
||||||
try {
|
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)
|
@FormFieldButton(R.drawable.ic_move_up)
|
||||||
fun import() {
|
fun import() {
|
||||||
val act = SettingsActivity.getActivity() ?: return;
|
val act = SettingsActivity.getActivity() ?: return;
|
||||||
@@ -138,7 +144,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
act.startActivity(intent);
|
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)
|
@FormFieldButton(R.drawable.ic_link)
|
||||||
fun manageLinks() {
|
fun manageLinks() {
|
||||||
try {
|
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)
|
@FormField(R.string.language, "group", -1, 0)
|
||||||
var language = LanguageSettings();
|
var language = LanguageSettings();
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -326,7 +350,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
var playback = PlaybackSettings();
|
var playback = PlaybackSettings();
|
||||||
@Serializable
|
@Serializable
|
||||||
class PlaybackSettings {
|
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)
|
@DropdownFieldOptionsId(R.array.audio_languages)
|
||||||
var primaryLanguage: Int = 0;
|
var primaryLanguage: Int = 0;
|
||||||
|
|
||||||
@@ -353,7 +377,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
|
|
||||||
//= context.resources.getStringArray(R.array.audio_languages)[primaryLanguage];
|
//= 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)
|
@DropdownFieldOptionsId(R.array.playback_speeds)
|
||||||
var defaultPlaybackSpeed: Int = 3;
|
var defaultPlaybackSpeed: Int = 3;
|
||||||
fun getDefaultPlaybackSpeed(): Float = when(defaultPlaybackSpeed) {
|
fun getDefaultPlaybackSpeed(): Float = when(defaultPlaybackSpeed) {
|
||||||
@@ -369,35 +393,31 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
else -> 1.0f;
|
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)
|
@DropdownFieldOptionsId(R.array.preferred_quality_array)
|
||||||
var preferredQuality: Int = 0;
|
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)
|
@DropdownFieldOptionsId(R.array.preferred_quality_array)
|
||||||
var preferredMeteredQuality: Int = 0;
|
var preferredMeteredQuality: Int = 0;
|
||||||
fun getPreferredQualityPixelCount(): Int = preferedQualityToPixels(preferredQuality);
|
fun getPreferredQualityPixelCount(): Int = preferedQualityToPixels(preferredQuality);
|
||||||
fun getPreferredMeteredQualityPixelCount(): Int = preferedQualityToPixels(preferredMeteredQuality);
|
fun getPreferredMeteredQualityPixelCount(): Int = preferedQualityToPixels(preferredMeteredQuality);
|
||||||
fun getCurrentPreferredQualityPixelCount(): Int = if(!StateApp.instance.isCurrentMetered()) getPreferredQualityPixelCount() else getPreferredMeteredQualityPixelCount();
|
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)
|
@DropdownFieldOptionsId(R.array.preferred_quality_array)
|
||||||
var preferredPreviewQuality: Int = 5;
|
var preferredPreviewQuality: Int = 5;
|
||||||
fun getPreferredPreviewQualityPixelCount(): Int = preferedQualityToPixels(preferredPreviewQuality);
|
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)
|
@FormField(R.string.auto_rotate, FieldForm.DROPDOWN, -1, 5)
|
||||||
@DropdownFieldOptionsId(R.array.system_enabled_disabled_array)
|
@DropdownFieldOptionsId(R.array.system_enabled_disabled_array)
|
||||||
var autoRotate: Int = 2;
|
var autoRotate: Int = 2;
|
||||||
|
|
||||||
fun isAutoRotate() = autoRotate == 1 || (autoRotate == 2 && StateApp.instance.getCurrentSystemAutoRotate());
|
fun isAutoRotate() = (autoRotate == 1 && !StatePlayer.instance.rotationLock) || (autoRotate == 2 && StateApp.instance.getCurrentSystemAutoRotate() && !StatePlayer.instance.rotationLock);
|
||||||
|
|
||||||
@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;
|
|
||||||
}
|
|
||||||
|
|
||||||
@FormField(R.string.background_behavior, FieldForm.DROPDOWN, -1, 7)
|
@FormField(R.string.background_behavior, FieldForm.DROPDOWN, -1, 7)
|
||||||
@DropdownFieldOptionsId(R.array.player_background_behavior)
|
@DropdownFieldOptionsId(R.array.player_background_behavior)
|
||||||
@@ -450,18 +470,52 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
|
|
||||||
@FormField(R.string.full_screen_portrait, FieldForm.TOGGLE, R.string.allow_full_screen_portrait, 13)
|
@FormField(R.string.full_screen_portrait, FieldForm.TOGGLE, R.string.allow_full_screen_portrait, 13)
|
||||||
var fullscreenPortrait: Boolean = false;
|
var fullscreenPortrait: Boolean = false;
|
||||||
|
|
||||||
|
@FormField(R.string.reverse_portrait, FieldForm.TOGGLE, R.string.reverse_portrait_description, 14)
|
||||||
|
var reversePortrait: Boolean = false;
|
||||||
|
|
||||||
|
@FormField(R.string.rotation_zone, FieldForm.DROPDOWN, R.string.rotation_zone_description, 15)
|
||||||
|
@DropdownFieldOptionsId(R.array.rotation_zone)
|
||||||
|
var rotationZone: Int = 2;
|
||||||
|
|
||||||
|
@FormField(R.string.stability_threshold_time, FieldForm.DROPDOWN, R.string.stability_threshold_time_description, 16)
|
||||||
|
@DropdownFieldOptionsId(R.array.rotation_threshold_time)
|
||||||
|
var stabilityThresholdTime: Int = 1;
|
||||||
|
|
||||||
|
@FormField(R.string.full_autorotate_lock, FieldForm.TOGGLE, R.string.full_autorotate_lock_description, 17)
|
||||||
|
var fullAutorotateLock: Boolean = false;
|
||||||
|
|
||||||
|
@FormField(R.string.prefer_webm, FieldForm.TOGGLE, R.string.prefer_webm_description, 18)
|
||||||
|
var preferWebmVideo: Boolean = false;
|
||||||
|
@FormField(R.string.prefer_webm_audio, FieldForm.TOGGLE, R.string.prefer_webm_audio_description, 19)
|
||||||
|
var preferWebmAudio: Boolean = false;
|
||||||
|
|
||||||
|
@FormField(R.string.allow_under_cutout, FieldForm.TOGGLE, R.string.allow_under_cutout_description, 20)
|
||||||
|
var allowVideoToGoUnderCutout: Boolean = true;
|
||||||
|
|
||||||
|
@FormField(R.string.autoplay, FieldForm.TOGGLE, R.string.autoplay, 21)
|
||||||
|
var autoplay: Boolean = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@FormField(R.string.comments, "group", R.string.comments_description, 6)
|
@FormField(R.string.comments, "group", R.string.comments_description, 6)
|
||||||
var comments = CommentSettings();
|
var comments = CommentSettings();
|
||||||
@Serializable
|
@Serializable
|
||||||
class CommentSettings {
|
class CommentSettings {
|
||||||
|
var didAskPolycentricDefault: Boolean = false;
|
||||||
|
|
||||||
@FormField(R.string.default_comment_section, FieldForm.DROPDOWN, -1, 0)
|
@FormField(R.string.default_comment_section, FieldForm.DROPDOWN, -1, 0)
|
||||||
@DropdownFieldOptionsId(R.array.comment_sections)
|
@DropdownFieldOptionsId(R.array.comment_sections)
|
||||||
var defaultCommentSection: Int = 0;
|
var defaultCommentSection: Int = 2;
|
||||||
|
|
||||||
|
@FormField(R.string.default_recommendations, FieldForm.TOGGLE, R.string.default_recommendations_description, 0)
|
||||||
|
var recommendationsDefault: Boolean = false;
|
||||||
|
|
||||||
|
@FormField(R.string.hide_recommendations, FieldForm.TOGGLE, R.string.hide_recommendations_description, 0)
|
||||||
|
var hideRecommendations: Boolean = false;
|
||||||
|
|
||||||
@FormField(R.string.bad_reputation_comments_fading, FieldForm.TOGGLE, R.string.bad_reputation_comments_fading_description, 0)
|
@FormField(R.string.bad_reputation_comments_fading, FieldForm.TOGGLE, R.string.bad_reputation_comments_fading_description, 0)
|
||||||
var badReputationCommentsFading: Boolean = true;
|
var badReputationCommentsFading: Boolean = true;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@FormField(R.string.downloads, "group", R.string.configure_downloading_of_videos, 7)
|
@FormField(R.string.downloads, "group", R.string.configure_downloading_of_videos, 7)
|
||||||
@@ -510,7 +564,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
class Browsing {
|
class Browsing {
|
||||||
@FormField(R.string.enable_video_cache, FieldForm.TOGGLE, R.string.cache_to_quickly_load_previously_fetched_videos, 0)
|
@FormField(R.string.enable_video_cache, FieldForm.TOGGLE, R.string.cache_to_quickly_load_previously_fetched_videos, 0)
|
||||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||||
var videoCache: Boolean = true;
|
var videoCache: Boolean = false; //Temporary default disabled to prevent ui freeze?
|
||||||
}
|
}
|
||||||
|
|
||||||
@FormField(R.string.casting, "group", R.string.configure_casting, 9)
|
@FormField(R.string.casting, "group", R.string.configure_casting, 9)
|
||||||
@@ -779,10 +833,10 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
fun export() {
|
fun export() {
|
||||||
val activity = SettingsActivity.getActivity() ?: return;
|
val activity = SettingsActivity.getActivity() ?: return;
|
||||||
UISlideOverlays.showOverlay(activity.overlay, "Select export type", null, {},
|
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();
|
StateBackup.shareExternalBackup();
|
||||||
}),
|
}),
|
||||||
SlideUpMenuItem(activity, R.drawable.ic_download, "File", "", null, {
|
SlideUpMenuItem(activity, R.drawable.ic_download, "File", "", tag = null, call = {
|
||||||
StateBackup.saveExternalBackup(activity);
|
StateBackup.saveExternalBackup(activity);
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import androidx.work.WorkManager
|
|||||||
import com.caoccao.javet.values.primitive.V8ValueInteger
|
import com.caoccao.javet.values.primitive.V8ValueInteger
|
||||||
import com.caoccao.javet.values.primitive.V8ValueString
|
import com.caoccao.javet.values.primitive.V8ValueString
|
||||||
import com.futo.platformplayer.activities.DeveloperActivity
|
import com.futo.platformplayer.activities.DeveloperActivity
|
||||||
|
import com.futo.platformplayer.activities.MainActivity
|
||||||
import com.futo.platformplayer.activities.SettingsActivity
|
import com.futo.platformplayer.activities.SettingsActivity
|
||||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||||
@@ -234,13 +235,17 @@ class SettingsDev : FragmentedStorageFileJson() {
|
|||||||
R.string.test_background_worker_description, 4)
|
R.string.test_background_worker_description, 4)
|
||||||
fun triggerBackgroundUpdate() {
|
fun triggerBackgroundUpdate() {
|
||||||
val act = SettingsActivity.getActivity()!!;
|
val act = SettingsActivity.getActivity()!!;
|
||||||
UIDialogs.toast(SettingsActivity.getActivity()!!, "Starting test background worker");
|
try {
|
||||||
|
UIDialogs.toast(SettingsActivity.getActivity()!!, "Starting test background worker");
|
||||||
|
|
||||||
val wm = WorkManager.getInstance(act);
|
val wm = WorkManager.getInstance(act);
|
||||||
val req = OneTimeWorkRequestBuilder<BackgroundWorker>()
|
val req = OneTimeWorkRequestBuilder<BackgroundWorker>()
|
||||||
.setInputData(Data.Builder().putBoolean("bypassMainCheck", true).build())
|
.setInputData(Data.Builder().putBoolean("bypassMainCheck", true).build())
|
||||||
.build();
|
.build();
|
||||||
wm.enqueue(req);
|
wm.enqueue(req);
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
UIDialogs.showGeneralErrorDialog(act, "Failed to trigger background update", e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@FormField(R.string.clear_channel_cache, FieldForm.BUTTON,
|
@FormField(R.string.clear_channel_cache, FieldForm.BUTTON,
|
||||||
R.string.test_background_worker_description, 4)
|
R.string.test_background_worker_description, 4)
|
||||||
@@ -491,6 +496,13 @@ class SettingsDev : FragmentedStorageFileJson() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@FormField(R.string.test_playback, FieldForm.BUTTON,
|
||||||
|
R.string.test_playback, 1)
|
||||||
|
fun testPlayback(context: Context) {
|
||||||
|
context.startActivity(MainActivity.getActionIntent(context, "TEST_PLAYBACK"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
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 com.futo.platformplayer.logging.Logger
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
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 var _currentJob: Job? = null
|
||||||
|
|
||||||
|
val onOrientationChanged = Event1<Int>()
|
||||||
|
|
||||||
|
private val orientationListener = object : OrientationEventListener(activity, SensorManager.SENSOR_DELAY_UI) {
|
||||||
|
override fun onOrientationChanged(orientation: Int) {
|
||||||
|
//val rotationZone = 45
|
||||||
|
val stabilityThresholdTime = when (Settings.instance.playback.stabilityThresholdTime) {
|
||||||
|
0 -> 100L
|
||||||
|
1 -> 500L
|
||||||
|
2 -> 750L
|
||||||
|
3 -> 1000L
|
||||||
|
4 -> 1500L
|
||||||
|
5 -> 2000L
|
||||||
|
else -> 500L
|
||||||
|
}
|
||||||
|
|
||||||
|
val rotationZone = when (Settings.instance.playback.rotationZone) {
|
||||||
|
0 -> 15
|
||||||
|
1 -> 30
|
||||||
|
2 -> 45
|
||||||
|
else -> 45
|
||||||
|
}
|
||||||
|
|
||||||
|
val newOrientation = when {
|
||||||
|
orientation in (90 - rotationZone)..(90 + rotationZone - 1) -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE
|
||||||
|
orientation in (180 - rotationZone)..(180 + rotationZone - 1) -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT
|
||||||
|
orientation in (270 - rotationZone)..(270 + rotationZone - 1) -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
|
||||||
|
orientation in (360 - rotationZone)..(360 + rotationZone - 1) || orientation in 0..(rotationZone - 1) -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||||
|
else -> lastOrientation
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newOrientation != lastStableOrientation) {
|
||||||
|
lastStableOrientation = newOrientation
|
||||||
|
|
||||||
|
_currentJob?.cancel()
|
||||||
|
_currentJob = lifecycleScope.launch(Dispatchers.Main) {
|
||||||
|
try {
|
||||||
|
delay(stabilityThresholdTime)
|
||||||
|
if (newOrientation == lastStableOrientation) {
|
||||||
|
lastOrientation = newOrientation
|
||||||
|
onOrientationChanged.emit(newOrientation)
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.i(TAG, "Failed to trigger onOrientationChanged", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
orientationListener.enable()
|
||||||
|
lastOrientation = activity.resources.configuration.orientation
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopListening() {
|
||||||
|
_currentJob?.cancel()
|
||||||
|
_currentJob = null
|
||||||
|
orientationListener.disable()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val TAG = "SimpleOrientationListener"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.text.method.ScrollingMovementMethod
|
||||||
import android.util.TypedValue
|
import android.util.TypedValue
|
||||||
import android.view.Gravity
|
import android.view.Gravity
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
@@ -223,18 +224,20 @@ class UIDialogs {
|
|||||||
this.visibility = View.GONE;
|
this.visibility = View.GONE;
|
||||||
else {
|
else {
|
||||||
this.text = code;
|
this.text = code;
|
||||||
|
this.movementMethod = ScrollingMovementMethod.getInstance();
|
||||||
this.visibility = View.VISIBLE;
|
this.visibility = View.VISIBLE;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
view.findViewById<LinearLayout>(R.id.dialog_buttons).apply {
|
view.findViewById<LinearLayout>(R.id.dialog_buttons).apply {
|
||||||
|
val center = actions.any { it?.center == true };
|
||||||
val buttons = actions.map<Action, TextView> { act ->
|
val buttons = actions.map<Action, TextView> { act ->
|
||||||
val buttonView = TextView(context);
|
val buttonView = TextView(context);
|
||||||
val dp10 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 10f, resources.displayMetrics).toInt();
|
val dp10 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 10f, resources.displayMetrics).toInt();
|
||||||
val dp28 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 28f, resources.displayMetrics).toInt();
|
val dp28 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 28f, resources.displayMetrics).toInt();
|
||||||
val dp14 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 14.0f, resources.displayMetrics).toInt();
|
val dp14 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 14.0f, resources.displayMetrics).toInt();
|
||||||
buttonView.layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
|
buttonView.layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
|
||||||
if(actions.size > 1)
|
this.marginStart = if(actions.size >= 2) dp14 / 2 else dp28 / 2;
|
||||||
this.marginEnd = if(actions.size > 2) dp14 else dp28;
|
this.marginEnd = if(actions.size >= 2) dp14 / 2 else dp28 / 2;
|
||||||
};
|
};
|
||||||
buttonView.setTextColor(Color.WHITE);
|
buttonView.setTextColor(Color.WHITE);
|
||||||
buttonView.textSize = 14f;
|
buttonView.textSize = 14f;
|
||||||
@@ -256,7 +259,7 @@ class UIDialogs {
|
|||||||
|
|
||||||
return@map buttonView;
|
return@map buttonView;
|
||||||
};
|
};
|
||||||
if(actions.size <= 1)
|
if(actions.size <= 1 || center)
|
||||||
this.gravity = Gravity.CENTER;
|
this.gravity = Gravity.CENTER;
|
||||||
else
|
else
|
||||||
this.gravity = Gravity.END;
|
this.gravity = Gravity.END;
|
||||||
@@ -507,11 +510,13 @@ class UIDialogs {
|
|||||||
val text: String;
|
val text: String;
|
||||||
val action: ()->Unit;
|
val action: ()->Unit;
|
||||||
val style: ActionStyle;
|
val style: ActionStyle;
|
||||||
|
var center: Boolean;
|
||||||
|
|
||||||
constructor(text: String, action: ()->Unit, style: ActionStyle = ActionStyle.NONE) {
|
constructor(text: String, action: ()->Unit, style: ActionStyle = ActionStyle.NONE, center: Boolean = false) {
|
||||||
this.text = text;
|
this.text = text;
|
||||||
this.action = action;
|
this.action = action;
|
||||||
this.style = style;
|
this.style = style;
|
||||||
|
this.center = center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
enum class ActionStyle {
|
enum class ActionStyle {
|
||||||
|
|||||||
@@ -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.VideoUnMuxedSourceDescriptor
|
||||||
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantAudioUrlSource
|
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.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.IAudioUrlSource
|
||||||
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudioSource
|
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.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.streams.sources.IVideoUrlSource
|
||||||
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
|
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.IPlatformVideo
|
||||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
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.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.downloads.VideoLocal
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionGroupFragment
|
import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionGroupFragment
|
||||||
import com.futo.platformplayer.helpers.VideoHelper
|
import com.futo.platformplayer.helpers.VideoHelper
|
||||||
@@ -34,12 +38,12 @@ import com.futo.platformplayer.models.SubscriptionGroup
|
|||||||
import com.futo.platformplayer.parsers.HLS
|
import com.futo.platformplayer.parsers.HLS
|
||||||
import com.futo.platformplayer.states.StateApp
|
import com.futo.platformplayer.states.StateApp
|
||||||
import com.futo.platformplayer.states.StateDownloads
|
import com.futo.platformplayer.states.StateDownloads
|
||||||
|
import com.futo.platformplayer.states.StateHistory
|
||||||
import com.futo.platformplayer.states.StateMeta
|
import com.futo.platformplayer.states.StateMeta
|
||||||
import com.futo.platformplayer.states.StatePlatform
|
import com.futo.platformplayer.states.StatePlatform
|
||||||
import com.futo.platformplayer.states.StatePlayer
|
import com.futo.platformplayer.states.StatePlayer
|
||||||
import com.futo.platformplayer.states.StatePlaylists
|
import com.futo.platformplayer.states.StatePlaylists
|
||||||
import com.futo.platformplayer.states.StateSubscriptionGroups
|
import com.futo.platformplayer.states.StateSubscriptionGroups
|
||||||
import com.futo.platformplayer.states.StateSubscriptions
|
|
||||||
import com.futo.platformplayer.views.AnyAdapterView
|
import com.futo.platformplayer.views.AnyAdapterView
|
||||||
import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny
|
import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny
|
||||||
import com.futo.platformplayer.views.LoaderView
|
import com.futo.platformplayer.views.LoaderView
|
||||||
@@ -91,9 +95,17 @@ class UISlideOverlays {
|
|||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
items.addAll(listOf(
|
items.addAll(listOf(
|
||||||
SlideUpMenuItem(container.context, R.drawable.ic_notifications, "Notifications", "", "notifications", {
|
SlideUpMenuItem(
|
||||||
subscription.doNotifications = menu?.selectOption(null, "notifications", true, true) ?: subscription.doNotifications;
|
container.context,
|
||||||
}, false),
|
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())
|
if(StateSubscriptionGroups.instance.getSubscriptionGroups().isNotEmpty())
|
||||||
SlideUpMenuGroup(container.context, "Subscription Groups",
|
SlideUpMenuGroup(container.context, "Subscription Groups",
|
||||||
"You can select which groups this subscription is part of.",
|
"You can select which groups this subscription is part of.",
|
||||||
@@ -128,22 +140,62 @@ class UISlideOverlays {
|
|||||||
SlideUpMenuGroup(container.context, "Fetch Settings",
|
SlideUpMenuGroup(container.context, "Fetch Settings",
|
||||||
"Depending on the platform you might not need to enable a type for it to be available.",
|
"Depending on the platform you might not need to enable a type for it to be available.",
|
||||||
-1, listOf()),
|
-1, listOf()),
|
||||||
if(capabilities.hasType(ResultCapabilities.TYPE_LIVE)) SlideUpMenuItem(container.context, R.drawable.ic_live_tv, "Livestreams", "Check for livestreams", "fetchLive", {
|
if(capabilities.hasType(ResultCapabilities.TYPE_LIVE)) SlideUpMenuItem(
|
||||||
subscription.doFetchLive = menu?.selectOption(null, "fetchLive", true, true) ?: subscription.doFetchLive;
|
container.context,
|
||||||
}, false) else null,
|
R.drawable.ic_live_tv,
|
||||||
if(capabilities.hasType(ResultCapabilities.TYPE_STREAMS)) SlideUpMenuItem(container.context, R.drawable.ic_play, "Streams", "Check for streams", "fetchStreams", {
|
"Livestreams",
|
||||||
subscription.doFetchStreams = menu?.selectOption(null, "fetchStreams", true, true) ?: subscription.doFetchStreams;
|
"Check for livestreams",
|
||||||
}, false) else null,
|
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))
|
if(capabilities.hasType(ResultCapabilities.TYPE_VIDEOS))
|
||||||
SlideUpMenuItem(container.context, R.drawable.ic_play, "Videos", "Check for videos", "fetchVideos", {
|
SlideUpMenuItem(
|
||||||
subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos;
|
container.context,
|
||||||
}, false) else if(capabilities.hasType(ResultCapabilities.TYPE_MIXED) || capabilities.types.isEmpty())
|
R.drawable.ic_play,
|
||||||
SlideUpMenuItem(container.context, R.drawable.ic_play, "Content", "Check for content", "fetchVideos", {
|
"Videos",
|
||||||
subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos;
|
"Check for videos",
|
||||||
}, false) else null,
|
tag = "fetchVideos",
|
||||||
if(capabilities.hasType(ResultCapabilities.TYPE_POSTS)) SlideUpMenuItem(container.context, R.drawable.ic_chat, "Posts", "Check for posts", "fetchPosts", {
|
call = {
|
||||||
subscription.doFetchPosts = menu?.selectOption(null, "fetchPosts", true, true) ?: subscription.doFetchPosts;
|
subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos;
|
||||||
}, false) else null/*,,
|
},
|
||||||
|
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",
|
SlideUpMenuGroup(container.context, "Actions",
|
||||||
"Various things you can do with this subscription",
|
"Various things you can do with this subscription",
|
||||||
@@ -242,11 +294,23 @@ class UISlideOverlays {
|
|||||||
masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, sourceUrl)
|
masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, sourceUrl)
|
||||||
|
|
||||||
masterPlaylist.getAudioSources().forEach { it ->
|
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
|
val estSize = VideoHelper.estimateSourceSize(it);
|
||||||
slideUpMenuOverlay.selectOption(audioButtons, it)
|
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
|
||||||
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
|
audioButtons.add(SlideUpMenuItem(
|
||||||
}, false))
|
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 ->
|
/*masterPlaylist.getSubtitleSources().forEach { it ->
|
||||||
@@ -258,11 +322,22 @@ class UISlideOverlays {
|
|||||||
}*/
|
}*/
|
||||||
|
|
||||||
masterPlaylist.getVideoSources().forEach {
|
masterPlaylist.getVideoSources().forEach {
|
||||||
videoButtons.add(SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "${it.width}x${it.height}", it, {
|
val estSize = VideoHelper.estimateSourceSize(it);
|
||||||
selectedVideoVariant = it
|
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
|
||||||
slideUpMenuOverlay.selectOption(videoButtons, it)
|
videoButtons.add(SlideUpMenuItem(
|
||||||
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
|
container.context,
|
||||||
}, false))
|
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>()
|
val newItems = arrayListOf<View>()
|
||||||
@@ -321,8 +396,8 @@ class UISlideOverlays {
|
|||||||
|
|
||||||
|
|
||||||
val requiresAudio = descriptor is VideoUnMuxedSourceDescriptor;
|
val requiresAudio = descriptor is VideoUnMuxedSourceDescriptor;
|
||||||
var selectedVideo: IVideoUrlSource? = null;
|
var selectedVideo: IVideoSource? = null;
|
||||||
var selectedAudio: IAudioUrlSource? = null;
|
var selectedAudio: IAudioSource? = null;
|
||||||
var selectedSubtitle: ISubtitleSource? = null;
|
var selectedSubtitle: ISubtitleSource? = null;
|
||||||
|
|
||||||
val videoSources = descriptor.videoSources;
|
val videoSources = descriptor.videoSources;
|
||||||
@@ -341,45 +416,93 @@ class UISlideOverlays {
|
|||||||
}
|
}
|
||||||
|
|
||||||
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.video), videoSources,
|
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", {
|
listOf(listOf(SlideUpMenuItem(
|
||||||
selectedVideo = null;
|
container.context,
|
||||||
menu?.selectOption(videoSources, "none");
|
R.drawable.ic_movie,
|
||||||
if(selectedAudio != null || !requiresAudio)
|
container.context.getString(R.string.none),
|
||||||
menu?.setOk(container.context.getString(R.string.download));
|
container.context.getString(R.string.audio_only),
|
||||||
}, false)) +
|
tag = "none",
|
||||||
|
call = {
|
||||||
|
selectedVideo = null;
|
||||||
|
menu?.selectOption(videoSources, "none");
|
||||||
|
if(selectedAudio != null || !requiresAudio)
|
||||||
|
menu?.setOk(container.context.getString(R.string.download));
|
||||||
|
},
|
||||||
|
invokeParent = false
|
||||||
|
)) +
|
||||||
videoSources
|
videoSources
|
||||||
.filter { it.isDownloadable() }
|
.filter { it.isDownloadable() }
|
||||||
.map {
|
.map {
|
||||||
when (it) {
|
when (it) {
|
||||||
is IVideoUrlSource -> {
|
is IVideoUrlSource -> {
|
||||||
SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "${it.width}x${it.height}", it, {
|
val estSize = VideoHelper.estimateSourceSize(it);
|
||||||
selectedVideo = it
|
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
|
||||||
menu?.selectOption(videoSources, it);
|
SlideUpMenuItem(
|
||||||
if(selectedAudio != null || !requiresAudio)
|
container.context,
|
||||||
menu?.setOk(container.context.getString(R.string.download));
|
R.drawable.ic_movie,
|
||||||
}, false)
|
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 -> {
|
is IHLSManifestSource -> {
|
||||||
SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "HLS", it, {
|
SlideUpMenuItem(
|
||||||
showHlsPicker(video, it, it.url, container)
|
container.context,
|
||||||
}, false)
|
R.drawable.ic_movie,
|
||||||
|
it.name,
|
||||||
|
"HLS",
|
||||||
|
tag = it,
|
||||||
|
call = {
|
||||||
|
showHlsPicker(video, it, it.url, container)
|
||||||
|
},
|
||||||
|
invokeParent = false
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
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()) {
|
if(Settings.instance.downloads.getDefaultVideoQualityPixels() > 0 && videoSources.isNotEmpty()) {
|
||||||
//TODO: Add HLS support here
|
//TODO: Add HLS support here
|
||||||
selectedVideo = VideoHelper.selectBestVideoSource(
|
selectedVideo = VideoHelper.selectBestVideoSource(
|
||||||
videoSources.filter { it is IVideoUrlSource && it.isDownloadable() }.asIterable(),
|
videoSources.filter { it is IVideoSource && it.isDownloadable() }.asIterable(),
|
||||||
Settings.instance.downloads.getDefaultVideoQualityPixels(),
|
Settings.instance.downloads.getDefaultVideoQualityPixels(),
|
||||||
FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS
|
FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS
|
||||||
) as IVideoUrlSource?;
|
) as IVideoSource?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (audioSources != null) {
|
if (audioSources != null) {
|
||||||
@@ -388,43 +511,90 @@ class UISlideOverlays {
|
|||||||
.map {
|
.map {
|
||||||
when (it) {
|
when (it) {
|
||||||
is IAudioUrlSource -> {
|
is IAudioUrlSource -> {
|
||||||
SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, "${it.bitrate}", it, {
|
val estSize = VideoHelper.estimateSourceSize(it);
|
||||||
selectedAudio = it
|
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
|
||||||
menu?.selectOption(audioSources, it);
|
SlideUpMenuItem(
|
||||||
menu?.setOk(container.context.getString(R.string.download));
|
container.context,
|
||||||
}, false);
|
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 -> {
|
is IHLSManifestAudioSource -> {
|
||||||
SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "HLS Audio", it, {
|
SlideUpMenuItem(
|
||||||
showHlsPicker(video, it, it.url, container)
|
container.context,
|
||||||
}, false)
|
R.drawable.ic_movie,
|
||||||
|
it.name,
|
||||||
|
"HLS Audio",
|
||||||
|
tag = it,
|
||||||
|
call = {
|
||||||
|
showHlsPicker(video, it, it.url, container)
|
||||||
|
},
|
||||||
|
invokeParent = false
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
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
|
//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,
|
FutoVideoPlayerBase.PREFERED_AUDIO_CONTAINERS,
|
||||||
Settings.instance.playback.getPrimaryLanguage(container.context),
|
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()) {
|
if(contentResolver != null && subtitleSources.isNotEmpty()) {
|
||||||
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.subtitles), subtitleSources, subtitleSources.map {
|
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.subtitles), subtitleSources, subtitleSources.map {
|
||||||
SlideUpMenuItem(container.context, R.drawable.ic_edit, it.name, "", it, {
|
SlideUpMenuItem(
|
||||||
if (selectedSubtitle == it) {
|
container.context,
|
||||||
selectedSubtitle = null;
|
R.drawable.ic_edit,
|
||||||
menu?.selectOption(subtitleSources, null);
|
it.name,
|
||||||
} else {
|
"",
|
||||||
selectedSubtitle = it;
|
tag = it,
|
||||||
menu?.selectOption(subtitleSources, it);
|
call = {
|
||||||
}
|
if (selectedSubtitle == it) {
|
||||||
}, false);
|
selectedSubtitle = null;
|
||||||
|
menu?.selectOption(subtitleSources, null);
|
||||||
|
} else {
|
||||||
|
selectedSubtitle = it;
|
||||||
|
menu?.selectOption(subtitleSources, it);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
invokeParent = false
|
||||||
|
);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -442,6 +612,18 @@ class UISlideOverlays {
|
|||||||
}
|
}
|
||||||
|
|
||||||
menu.onOK.subscribe {
|
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();
|
menu.hide();
|
||||||
val subtitleToDownload = selectedSubtitle;
|
val subtitleToDownload = selectedSubtitle;
|
||||||
if(selectedAudio != null || !requiresAudio) {
|
if(selectedAudio != null || !requiresAudio) {
|
||||||
@@ -498,8 +680,9 @@ class UISlideOverlays {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch(ex: Throwable) {
|
catch(ex: Throwable) {
|
||||||
|
Logger.e(TAG, "Fetching details for download failed due to: " + ex.message, ex);
|
||||||
withContext(Dispatchers.Main) {
|
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();
|
handleUnknownDownload();
|
||||||
loader.hide(true);
|
loader.hide(true);
|
||||||
}
|
}
|
||||||
@@ -536,23 +719,47 @@ class UISlideOverlays {
|
|||||||
);
|
);
|
||||||
|
|
||||||
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.target_resolution), "Video", resolutions.map {
|
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, {
|
SlideUpMenuItem(
|
||||||
targetPxSize = it.third;
|
container.context,
|
||||||
menu?.selectOption("Video", it.third);
|
R.drawable.ic_movie,
|
||||||
}, false)
|
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(
|
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, {
|
SlideUpMenuItem(
|
||||||
targetBitrate = 1;
|
container.context,
|
||||||
menu?.selectOption("Bitrate", 1);
|
R.drawable.ic_movie,
|
||||||
menu?.setOk(container.context.getString(R.string.download));
|
container.context.getString(R.string.low_bitrate),
|
||||||
}, false),
|
"",
|
||||||
SlideUpMenuItem(container.context, R.drawable.ic_movie, container.context.getString(R.string.high_bitrate), "", 9999999, {
|
tag = 1,
|
||||||
targetBitrate = 9999999;
|
call = {
|
||||||
menu?.selectOption("Bitrate", 9999999);
|
targetBitrate = 1;
|
||||||
menu?.setOk(container.context.getString(R.string.download));
|
menu?.selectOption("Bitrate", 1);
|
||||||
}, false)
|
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
|
||||||
|
)
|
||||||
)));
|
)));
|
||||||
|
|
||||||
|
|
||||||
@@ -675,8 +882,12 @@ class UISlideOverlays {
|
|||||||
if (lastUpdated != null) {
|
if (lastUpdated != null) {
|
||||||
items.add(
|
items.add(
|
||||||
SlideUpMenuGroup(container.context, container.context.getString(R.string.recently_used_playlist), "recentlyusedplaylist",
|
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);
|
StatePlaylists.instance.addToPlaylist(lastUpdated.id, video);
|
||||||
StateDownloads.instance.checkForOutdatedPlaylists();
|
StateDownloads.instance.checkForOutdatedPlaylists();
|
||||||
}))
|
}))
|
||||||
@@ -688,42 +899,90 @@ class UISlideOverlays {
|
|||||||
val watchLater = StatePlaylists.instance.getWatchLater();
|
val watchLater = StatePlaylists.instance.getWatchLater();
|
||||||
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.actions), "actions",
|
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.actions), "actions",
|
||||||
(listOf(
|
(listOf(
|
||||||
SlideUpMenuItem(container.context, R.drawable.ic_download, container.context.getString(R.string.download), container.context.getString(R.string.download_the_video), "download", {
|
SlideUpMenuItem(
|
||||||
showDownloadVideoOverlay(video, container, true);
|
container.context,
|
||||||
}, false),
|
R.drawable.ic_download,
|
||||||
SlideUpMenuItem(container.context, R.drawable.ic_share, container.context.getString(R.string.share), "Share the video", "share", {
|
container.context.getString(R.string.download),
|
||||||
val url = if(video.shareUrl.isNotEmpty()) video.shareUrl else video.url;
|
container.context.getString(R.string.download_the_video),
|
||||||
container.context.startActivity(Intent.createChooser(Intent().apply {
|
tag = "download",
|
||||||
action = Intent.ACTION_SEND;
|
call = {
|
||||||
putExtra(Intent.EXTRA_TEXT, url);
|
showDownloadVideoOverlay(video, container, true);
|
||||||
type = "text/plain";
|
},
|
||||||
}, null));
|
invokeParent = false
|
||||||
}, false),
|
),
|
||||||
SlideUpMenuItem(container.context, R.drawable.ic_visibility_off, container.context.getString(R.string.hide_creator_from_home), "", "hide_creator", {
|
SlideUpMenuItem(
|
||||||
StateMeta.instance.addHiddenCreator(video.author.url);
|
container.context,
|
||||||
UIDialogs.toast(container.context, "[${video.author.name}] hidden, you may need to reload home");
|
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)
|
+ actions)
|
||||||
));
|
));
|
||||||
items.add(
|
items.add(
|
||||||
SlideUpMenuGroup(container.context, container.context.getString(R.string.add_to), "addto",
|
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",
|
SlideUpMenuItem(container.context,
|
||||||
{ StatePlayer.instance.addToQueue(video); }),
|
R.drawable.ic_queue_add,
|
||||||
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",
|
container.context.getString(R.string.add_to_queue),
|
||||||
{ StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video)); })
|
"${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>();
|
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", {
|
playlistItems.add(SlideUpMenuItem(
|
||||||
showCreatePlaylistOverlay(container) {
|
container.context,
|
||||||
val playlist = Playlist(it, arrayListOf(SerializedPlatformVideo.fromVideo(video)));
|
R.drawable.ic_playlist_add,
|
||||||
StatePlaylists.instance.createOrUpdatePlaylist(playlist);
|
container.context.getString(R.string.new_playlist),
|
||||||
};
|
container.context.getString(R.string.add_to_new_playlist),
|
||||||
}, false))
|
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) {
|
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);
|
StatePlaylists.instance.addToPlaylist(playlist.id, video);
|
||||||
StateDownloads.instance.checkForOutdatedPlaylists();
|
StateDownloads.instance.checkForOutdatedPlaylists();
|
||||||
}));
|
}));
|
||||||
@@ -745,8 +1004,12 @@ class UISlideOverlays {
|
|||||||
if (lastUpdated != null) {
|
if (lastUpdated != null) {
|
||||||
items.add(
|
items.add(
|
||||||
SlideUpMenuGroup(container.context, container.context.getString(R.string.recently_used_playlist), "recentlyusedplaylist",
|
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);
|
StatePlaylists.instance.addToPlaylist(lastUpdated.id, video);
|
||||||
StateDownloads.instance.checkForOutdatedPlaylists();
|
StateDownloads.instance.checkForOutdatedPlaylists();
|
||||||
}))
|
}))
|
||||||
@@ -758,25 +1021,52 @@ class UISlideOverlays {
|
|||||||
val watchLater = StatePlaylists.instance.getWatchLater();
|
val watchLater = StatePlaylists.instance.getWatchLater();
|
||||||
items.add(
|
items.add(
|
||||||
SlideUpMenuGroup(container.context, container.context.getString(R.string.other), "other",
|
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",
|
SlideUpMenuItem(container.context,
|
||||||
{ StatePlayer.instance.addToQueue(video); }),
|
R.drawable.ic_queue_add,
|
||||||
SlideUpMenuItem(container.context, R.drawable.ic_watchlist_add, StatePlayer.TYPE_WATCHLATER, "${watchLater.size} " + container.context.getString(R.string.videos), "watch later",
|
container.context.getString(R.string.queue),
|
||||||
{ StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video)); }),
|
"${queue.size} " + container.context.getString(R.string.videos),
|
||||||
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),
|
tag = "queue",
|
||||||
{ showDownloadVideoOverlay(video, container, true); }, false))
|
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>();
|
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", {
|
playlistItems.add(SlideUpMenuItem(
|
||||||
slideUpMenuOverlayUpdated(showCreatePlaylistOverlay(container) {
|
container.context,
|
||||||
val playlist = Playlist(it, arrayListOf(SerializedPlatformVideo.fromVideo(video)));
|
R.drawable.ic_playlist_add,
|
||||||
StatePlaylists.instance.createOrUpdatePlaylist(playlist);
|
container.context.getString(R.string.new_playlist),
|
||||||
});
|
container.context.getString(R.string.add_to_new_playlist),
|
||||||
}, false))
|
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) {
|
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);
|
StatePlaylists.instance.addToPlaylist(playlist.id, video);
|
||||||
StateDownloads.instance.checkForOutdatedPlaylists();
|
StateDownloads.instance.checkForOutdatedPlaylists();
|
||||||
}));
|
}));
|
||||||
@@ -801,20 +1091,36 @@ class UISlideOverlays {
|
|||||||
|
|
||||||
val views = arrayOf(
|
val views = arrayOf(
|
||||||
hidden
|
hidden
|
||||||
.map { btn -> SlideUpMenuItem(container.context, btn.iconResource, btn.text.text.toString(), "", "", {
|
.map { btn -> SlideUpMenuItem(
|
||||||
btn.handler?.invoke(btn);
|
container.context,
|
||||||
}, invokeParents) as View }.toTypedArray(),
|
btn.iconResource,
|
||||||
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), "", {
|
btn.text.text.toString(),
|
||||||
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
|
tag = "",
|
||||||
.map { x -> visible.find { it.tagRef == x } ?: hidden.find { it.tagRef == x } }
|
call = {
|
||||||
.filter { it != null }
|
btn.handler?.invoke(btn);
|
||||||
.map { it!! }
|
},
|
||||||
.toList();
|
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) });
|
onPinnedbuttons?.invoke(selected + (visible + hidden).filter { !selected.contains(it) });
|
||||||
}
|
}
|
||||||
}, false))
|
},
|
||||||
|
invokeParent = false
|
||||||
|
))
|
||||||
).flatten().toTypedArray();
|
).flatten().toTypedArray();
|
||||||
|
|
||||||
return SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.more_options), null, true, *views).apply { show() };
|
return SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.more_options), null, true, *views).apply { show() };
|
||||||
@@ -826,14 +1132,21 @@ class UISlideOverlays {
|
|||||||
var overlay: SlideUpMenuOverlay? = null;
|
var overlay: SlideUpMenuOverlay? = null;
|
||||||
|
|
||||||
overlay = SlideUpMenuOverlay(container.context, container, title, container.context.getString(R.string.save), true,
|
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(overlay!!.selectOption(null, it.second, true, true)) {
|
||||||
if(!selection.contains(it.second))
|
if(!selection.contains(it.second))
|
||||||
selection.add(it.second);
|
selection.add(it.second);
|
||||||
}
|
} else
|
||||||
else
|
|
||||||
selection.remove(it.second);
|
selection.remove(it.second);
|
||||||
}, false)
|
},
|
||||||
|
invokeParent = false
|
||||||
|
)
|
||||||
});
|
});
|
||||||
overlay.onOK.subscribe {
|
overlay.onOK.subscribe {
|
||||||
onOrdered.invoke(selection);
|
onOrdered.invoke(selection);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import android.os.OperationCanceledException
|
|||||||
import android.util.TypedValue
|
import android.util.TypedValue
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.WindowInsetsController
|
import android.view.WindowInsetsController
|
||||||
|
import android.view.WindowManager
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
|
|||||||
@@ -4,15 +4,19 @@ import android.annotation.SuppressLint
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
|
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
import android.content.pm.ActivityInfo
|
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.net.wifi.WifiManager
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.os.StrictMode
|
||||||
|
import android.os.StrictMode.VmPolicy
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.util.TypedValue
|
import android.util.TypedValue
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import android.view.WindowManager
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
|
import android.widget.ImageView
|
||||||
import androidx.activity.result.ActivityResult
|
import androidx.activity.result.ActivityResult
|
||||||
import androidx.activity.result.ActivityResultLauncher
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
@@ -20,30 +24,61 @@ import androidx.appcompat.app.AppCompatActivity
|
|||||||
import androidx.constraintlayout.motion.widget.MotionLayout
|
import androidx.constraintlayout.motion.widget.MotionLayout
|
||||||
import androidx.core.app.ActivityCompat
|
import androidx.core.app.ActivityCompat
|
||||||
import androidx.core.content.ContextCompat
|
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.core.view.isVisible
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.FragmentContainerView
|
import androidx.fragment.app.FragmentContainerView
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.futo.platformplayer.*
|
import com.futo.platformplayer.BuildConfig
|
||||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
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.casting.StateCasting
|
||||||
import com.futo.platformplayer.constructs.Event1
|
import com.futo.platformplayer.constructs.Event1
|
||||||
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
|
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.AddTopBarFragment
|
||||||
import com.futo.platformplayer.fragment.mainactivity.topbar.GeneralTopBarFragment
|
import com.futo.platformplayer.fragment.mainactivity.topbar.GeneralTopBarFragment
|
||||||
import com.futo.platformplayer.fragment.mainactivity.topbar.ImportTopBarFragment
|
import com.futo.platformplayer.fragment.mainactivity.topbar.ImportTopBarFragment
|
||||||
import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFragment
|
import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFragment
|
||||||
import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment
|
import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment
|
||||||
import com.futo.platformplayer.listeners.OrientationManager
|
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.models.ImportCache
|
import com.futo.platformplayer.models.ImportCache
|
||||||
import com.futo.platformplayer.models.UrlVideoWithTime
|
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||||
import com.futo.platformplayer.states.*
|
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.FragmentedStorage
|
||||||
import com.futo.platformplayer.stores.SubscriptionStorage
|
import com.futo.platformplayer.stores.SubscriptionStorage
|
||||||
import com.futo.platformplayer.stores.v2.ManagedStore
|
import com.futo.platformplayer.stores.v2.ManagedStore
|
||||||
@@ -51,15 +86,22 @@ import com.futo.platformplayer.views.ToastView
|
|||||||
import com.futo.polycentric.core.ApiMethods
|
import com.futo.polycentric.core.ApiMethods
|
||||||
import com.google.gson.JsonParser
|
import com.google.gson.JsonParser
|
||||||
import com.google.zxing.integration.android.IntentIntegrator
|
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 kotlinx.serialization.json.Json
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.PrintWriter
|
import java.io.PrintWriter
|
||||||
import java.io.StringWriter
|
import java.io.StringWriter
|
||||||
import java.lang.reflect.InvocationTargetException
|
import java.lang.reflect.InvocationTargetException
|
||||||
import java.util.*
|
import java.util.LinkedList
|
||||||
|
import java.util.Queue
|
||||||
import java.util.concurrent.ConcurrentLinkedQueue
|
import java.util.concurrent.ConcurrentLinkedQueue
|
||||||
|
|
||||||
|
|
||||||
class MainActivity : AppCompatActivity, IWithResultLauncher {
|
class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||||
|
|
||||||
//TODO: Move to dimensions
|
//TODO: Move to dimensions
|
||||||
@@ -79,6 +121,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
private lateinit var _fragContainerVideoDetail: FragmentContainerView;
|
private lateinit var _fragContainerVideoDetail: FragmentContainerView;
|
||||||
private lateinit var _fragContainerOverlay: FrameLayout;
|
private lateinit var _fragContainerOverlay: FrameLayout;
|
||||||
|
|
||||||
|
//Views
|
||||||
|
private lateinit var _buttonIncognito: ImageView;
|
||||||
|
|
||||||
//Frags TopBar
|
//Frags TopBar
|
||||||
lateinit var _fragTopBarGeneral: GeneralTopBarFragment;
|
lateinit var _fragTopBarGeneral: GeneralTopBarFragment;
|
||||||
lateinit var _fragTopBarSearch: SearchTopBarFragment;
|
lateinit var _fragTopBarSearch: SearchTopBarFragment;
|
||||||
@@ -129,9 +174,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
|
|
||||||
val onNavigated = Event1<MainFragment>();
|
val onNavigated = Event1<MainFragment>();
|
||||||
|
|
||||||
private lateinit var _orientationManager: OrientationManager;
|
|
||||||
var orientation: OrientationManager.Orientation = OrientationManager.Orientation.PORTRAIT
|
|
||||||
private set;
|
|
||||||
private var _isVisible = true;
|
private var _isVisible = true;
|
||||||
private var _wasStopped = false;
|
private var _wasStopped = false;
|
||||||
|
|
||||||
@@ -156,6 +198,15 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
constructor() : super() {
|
constructor() : super() {
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
StrictMode.setVmPolicy(
|
||||||
|
VmPolicy.Builder()
|
||||||
|
.detectLeakedClosableObjects()
|
||||||
|
.penaltyLog()
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
ApiMethods.UserAgent = "Grayjay Android (${BuildConfig.VERSION_CODE})";
|
ApiMethods.UserAgent = "Grayjay Android (${BuildConfig.VERSION_CODE})";
|
||||||
|
|
||||||
Thread.setDefaultUncaughtExceptionHandler { _, throwable ->
|
Thread.setDefaultUncaughtExceptionHandler { _, throwable ->
|
||||||
@@ -203,6 +254,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
setContentView(R.layout.activity_main);
|
setContentView(R.layout.activity_main);
|
||||||
setNavigationBarColorAndIcons();
|
setNavigationBarColorAndIcons();
|
||||||
|
if (Settings.instance.playback.allowVideoToGoUnderCutout)
|
||||||
|
window.attributes.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
|
||||||
|
|
||||||
runBlocking {
|
runBlocking {
|
||||||
StatePlatform.instance.updateAvailableClients(this@MainActivity);
|
StatePlatform.instance.updateAvailableClients(this@MainActivity);
|
||||||
@@ -290,6 +343,52 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
updateSegmentPaddings();
|
updateSegmentPaddings();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
_buttonIncognito = findViewById(R.id.incognito_button);
|
||||||
|
_buttonIncognito.elevation = -99f;
|
||||||
|
_buttonIncognito.alpha = 0f;
|
||||||
|
StateApp.instance.privateModeChanged.subscribe {
|
||||||
|
//Messing with visibility causes some issues with layout ordering?
|
||||||
|
if(it) {
|
||||||
|
_buttonIncognito.elevation = 99f;
|
||||||
|
_buttonIncognito.alpha = 1f;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
_buttonIncognito.elevation = -99f;
|
||||||
|
_buttonIncognito.alpha = 0f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_buttonIncognito.setOnClickListener {
|
||||||
|
if(!StateApp.instance.privateMode)
|
||||||
|
return@setOnClickListener;
|
||||||
|
UIDialogs.showDialog(this, R.drawable.ic_disabled_visible_purple, "Disable Privacy Mode",
|
||||||
|
"Do you want to disable privacy mode? New videos will be tracked again.", null, 0,
|
||||||
|
UIDialogs.Action("Cancel", {
|
||||||
|
StateApp.instance.setPrivacyMode(true);
|
||||||
|
}, UIDialogs.ActionStyle.NONE),
|
||||||
|
UIDialogs.Action("Disable", {
|
||||||
|
StateApp.instance.setPrivacyMode(false);
|
||||||
|
}, UIDialogs.ActionStyle.DANGEROUS));
|
||||||
|
};
|
||||||
|
_fragVideoDetail.onFullscreenChanged.subscribe {
|
||||||
|
Logger.i(TAG, "onFullscreenChanged ${it}");
|
||||||
|
|
||||||
|
if(it) {
|
||||||
|
_buttonIncognito.elevation = -99f;
|
||||||
|
_buttonIncognito.alpha = 0f;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
if(StateApp.instance.privateMode) {
|
||||||
|
_buttonIncognito.elevation = 99f;
|
||||||
|
_buttonIncognito.alpha = 1f;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
_buttonIncognito.elevation = -99f;
|
||||||
|
_buttonIncognito.alpha = 0f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
StatePlayer.instance.also {
|
StatePlayer.instance.also {
|
||||||
it.onQueueChanged.subscribe { shouldSwapCurrentItem ->
|
it.onQueueChanged.subscribe { shouldSwapCurrentItem ->
|
||||||
if (!shouldSwapCurrentItem) {
|
if (!shouldSwapCurrentItem) {
|
||||||
@@ -364,26 +463,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
.commitNow();
|
.commitNow();
|
||||||
|
|
||||||
defaultTab.action(_fragBotBarMenu);
|
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;
|
StateSubscriptions.instance;
|
||||||
|
|
||||||
fragCurrent.onShown(null, false);
|
fragCurrent.onShown(null, false);
|
||||||
@@ -438,7 +517,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
|
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
|
||||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
|
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
|
||||||
@@ -480,17 +558,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume();
|
super.onResume();
|
||||||
Logger.v(TAG, "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;
|
_isVisible = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -538,6 +605,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
"IMPORT_OPTIONS" -> {
|
"IMPORT_OPTIONS" -> {
|
||||||
UIDialogs.showImportOptionsDialog(this);
|
UIDialogs.showImportOptionsDialog(this);
|
||||||
}
|
}
|
||||||
|
"ACTION" -> {
|
||||||
|
val action = intent.getStringExtra("ACTION");
|
||||||
|
StateDeveloper.instance.testState = "TestPlayback";
|
||||||
|
StateDeveloper.instance.testPlayback();
|
||||||
|
}
|
||||||
"TAB" -> {
|
"TAB" -> {
|
||||||
when(intent.getStringExtra("TAB")){
|
when(intent.getStringExtra("TAB")){
|
||||||
"Sources" -> {
|
"Sources" -> {
|
||||||
@@ -886,18 +958,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
override fun onRestart() {
|
override fun onRestart() {
|
||||||
super.onRestart();
|
super.onRestart();
|
||||||
Logger.i(TAG, "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) {
|
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) {
|
||||||
@@ -912,9 +972,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy();
|
super.onDestroy();
|
||||||
Logger.v(TAG, "onDestroy")
|
Logger.v(TAG, "onDestroy")
|
||||||
|
|
||||||
_orientationManager.disable();
|
|
||||||
|
|
||||||
StateApp.instance.mainAppDestroyed(this);
|
StateApp.instance.mainAppDestroyed(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1180,6 +1237,13 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
sourcesIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
|
sourcesIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
|
||||||
return sourcesIntent;
|
return sourcesIntent;
|
||||||
}
|
}
|
||||||
|
fun getActionIntent(context: Context, action: String) : Intent {
|
||||||
|
val sourcesIntent = Intent(context, MainActivity::class.java);
|
||||||
|
sourcesIntent.action = "ACTION";
|
||||||
|
sourcesIntent.putExtra("ACTION", action);
|
||||||
|
sourcesIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
|
||||||
|
return sourcesIntent;
|
||||||
|
}
|
||||||
|
|
||||||
fun getImportOptionsIntent(context: Context): Intent {
|
fun getImportOptionsIntent(context: Context): Intent {
|
||||||
val sourcesIntent = Intent(context, MainActivity::class.java);
|
val sourcesIntent = Intent(context, MainActivity::class.java);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import androidx.core.app.ActivityCompat
|
|||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.futo.platformplayer.*
|
import com.futo.platformplayer.*
|
||||||
|
import com.futo.platformplayer.constructs.Event0
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.states.StateApp
|
import com.futo.platformplayer.states.StateApp
|
||||||
import com.futo.platformplayer.views.LoaderView
|
import com.futo.platformplayer.views.LoaderView
|
||||||
@@ -184,12 +185,19 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
|
|||||||
resultLauncher.launch(intent);
|
resultLauncher.launch(intent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
settingsActivityClosed.emit()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
//TODO: Temporary for solving Settings issues
|
//TODO: Temporary for solving Settings issues
|
||||||
@SuppressLint("StaticFieldLeak")
|
@SuppressLint("StaticFieldLeak")
|
||||||
private var _lastActivity: SettingsActivity? = null;
|
private var _lastActivity: SettingsActivity? = null;
|
||||||
|
|
||||||
|
val settingsActivityClosed = Event0()
|
||||||
|
|
||||||
fun getActivity(): SettingsActivity? {
|
fun getActivity(): SettingsActivity? {
|
||||||
val act = _lastActivity;
|
val act = _lastActivity;
|
||||||
if(act != null && !act._isFinished)
|
if(act != null && !act._isFinished)
|
||||||
|
|||||||
@@ -17,13 +17,14 @@ import okhttp3.WebSocket
|
|||||||
import okhttp3.WebSocketListener
|
import okhttp3.WebSocketListener
|
||||||
import java.security.SecureRandom
|
import java.security.SecureRandom
|
||||||
import java.security.cert.X509Certificate
|
import java.security.cert.X509Certificate
|
||||||
|
import java.time.Duration
|
||||||
import javax.net.ssl.SSLContext
|
import javax.net.ssl.SSLContext
|
||||||
import javax.net.ssl.TrustManager
|
import javax.net.ssl.TrustManager
|
||||||
import javax.net.ssl.X509TrustManager
|
import javax.net.ssl.X509TrustManager
|
||||||
import kotlin.system.measureTimeMillis
|
import kotlin.system.measureTimeMillis
|
||||||
|
|
||||||
open class ManagedHttpClient {
|
open class ManagedHttpClient {
|
||||||
protected val _builderTemplate: OkHttpClient.Builder;
|
protected var _builderTemplate: OkHttpClient.Builder;
|
||||||
|
|
||||||
private var client: OkHttpClient;
|
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"
|
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>(
|
private val trustAllCerts = arrayOf<TrustManager>(
|
||||||
object: X509TrustManager {
|
object: X509TrustManager {
|
||||||
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) { }
|
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) { }
|
||||||
@@ -62,6 +72,15 @@ open class ManagedHttpClient {
|
|||||||
}.build();
|
}.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 {
|
open fun clone(): ManagedHttpClient {
|
||||||
val clonedClient = ManagedHttpClient(_builderTemplate);
|
val clonedClient = ManagedHttpClient(_builderTemplate);
|
||||||
clonedClient.user_agent = user_agent;
|
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) {
|
fun respond(status: Int, headers: HttpHeaders, writing: (OutputStream)->Unit) {
|
||||||
val responseStream = _responseStream ?: throw IllegalStateException("No response stream set");
|
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.ManagedHttpClient
|
||||||
import com.futo.platformplayer.api.http.server.exceptions.EmptyRequestException
|
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.HttpHandler
|
||||||
import com.futo.platformplayer.api.http.server.handlers.HttpOptionsAllowHandler
|
import com.futo.platformplayer.api.http.server.handlers.HttpOptionsAllowHandler
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
@@ -208,20 +208,20 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) {
|
|||||||
|
|
||||||
for(getMethod in getMethods)
|
for(getMethod in getMethods)
|
||||||
if(getMethod.first.parameterTypes.firstOrNull() == HttpContext::class.java && getMethod.first.parameterCount == 1)
|
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())
|
if(!getMethod.second.contentType.isEmpty())
|
||||||
this.withContentType(getMethod.second.contentType);
|
this.withContentType(getMethod.second.contentType);
|
||||||
}.withContentType(getMethod.second.contentType);
|
}.withContentType(getMethod.second.contentType);
|
||||||
for(postMethod in postMethods)
|
for(postMethod in postMethods)
|
||||||
if(postMethod.first.parameterTypes.firstOrNull() == HttpContext::class.java && postMethod.first.parameterCount == 1)
|
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())
|
if(!postMethod.second.contentType.isEmpty())
|
||||||
this.withContentType(postMethod.second.contentType);
|
this.withContentType(postMethod.second.contentType);
|
||||||
}.withContentType(postMethod.second.contentType);
|
}.withContentType(postMethod.second.contentType);
|
||||||
|
|
||||||
for(getField in getFields) {
|
for(getField in getFields) {
|
||||||
getField.first.isAccessible = true;
|
getField.first.isAccessible = true;
|
||||||
addHandler(HttpFuntionHandler("GET", getField.second.path) {
|
addHandler(HttpFunctionHandler("GET", getField.second.path) {
|
||||||
val value = getField.first.get(obj) as String?;
|
val value = getField.first.get(obj) as String?;
|
||||||
if(value != null) {
|
if(value != null) {
|
||||||
val headers = HttpHeaders(
|
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
|
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) {
|
override fun handle(httpContext: HttpContext) {
|
||||||
httpContext.setResponseHeaders(this.headers);
|
httpContext.setResponseHeaders(this.headers);
|
||||||
handler(httpContext);
|
handler(httpContext);
|
||||||
|
|||||||
@@ -13,13 +13,15 @@ class PlatformClientPool {
|
|||||||
private val _pool: HashMap<JSClient, Int> = hashMapOf();
|
private val _pool: HashMap<JSClient, Int> = hashMapOf();
|
||||||
private var _poolCounter = 0;
|
private var _poolCounter = 0;
|
||||||
private val _poolName: String?;
|
private val _poolName: String?;
|
||||||
|
private val _privatePool: Boolean;
|
||||||
|
|
||||||
var isDead: Boolean = false
|
var isDead: Boolean = false
|
||||||
private set;
|
private set;
|
||||||
val onDead = Event2<JSClient, PlatformClientPool>();
|
val onDead = Event2<JSClient, PlatformClientPool>();
|
||||||
|
|
||||||
constructor(parentClient: IPlatformClient, name: String? = null) {
|
constructor(parentClient: IPlatformClient, name: String? = null, privatePool: Boolean = false) {
|
||||||
_poolName = name;
|
_poolName = name;
|
||||||
|
_privatePool = privatePool;
|
||||||
if(parentClient !is JSClient)
|
if(parentClient !is JSClient)
|
||||||
throw IllegalArgumentException("Pooling only supported for JSClients right now");
|
throw IllegalArgumentException("Pooling only supported for JSClients right now");
|
||||||
Logger.i(TAG, "Pool for ${parentClient.name} was started");
|
Logger.i(TAG, "Pool for ${parentClient.name} was started");
|
||||||
@@ -51,7 +53,7 @@ class PlatformClientPool {
|
|||||||
reserved = _pool.keys.find { !it.isBusy };
|
reserved = _pool.keys.find { !it.isBusy };
|
||||||
if(reserved == null && _pool.size < capacity) {
|
if(reserved == null && _pool.size < capacity) {
|
||||||
Logger.i(TAG, "Started additional [${_parent.name}] client in pool [${_poolName}] (${_pool.size + 1}/${capacity})");
|
Logger.i(TAG, "Started additional [${_parent.name}] client in pool [${_poolName}] (${_pool.size + 1}/${capacity})");
|
||||||
reserved = _parent.getCopy();
|
reserved = _parent.getCopy(_privatePool);
|
||||||
|
|
||||||
reserved?.onCaptchaException?.subscribe { client, ex ->
|
reserved?.onCaptchaException?.subscribe { client, ex ->
|
||||||
StateApp.instance.handleCaptchaException(client, ex);
|
StateApp.instance.handleCaptchaException(client, ex);
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ class PlatformMultiClientPool {
|
|||||||
private val _clientPools: HashMap<IPlatformClient, PlatformClientPool> = hashMapOf();
|
private val _clientPools: HashMap<IPlatformClient, PlatformClientPool> = hashMapOf();
|
||||||
|
|
||||||
private var _isFake = false;
|
private var _isFake = false;
|
||||||
|
private var _privatePool = false;
|
||||||
|
|
||||||
constructor(name: String, maxCap: Int = -1) {
|
constructor(name: String, maxCap: Int = -1, isPrivatePool: Boolean = false) {
|
||||||
_name = name;
|
_name = name;
|
||||||
_maxCap = if(maxCap > 0)
|
_maxCap = if(maxCap > 0)
|
||||||
maxCap
|
maxCap
|
||||||
else 99;
|
else 99;
|
||||||
|
_privatePool = isPrivatePool;
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getClientPooled(parentClient: IPlatformClient, capacity: Int = _maxCap): IPlatformClient {
|
fun getClientPooled(parentClient: IPlatformClient, capacity: Int = _maxCap): IPlatformClient {
|
||||||
@@ -19,7 +21,7 @@ class PlatformMultiClientPool {
|
|||||||
return parentClient;
|
return parentClient;
|
||||||
val pool = synchronized(_clientPools) {
|
val pool = synchronized(_clientPools) {
|
||||||
if(!_clientPools.containsKey(parentClient))
|
if(!_clientPools.containsKey(parentClient))
|
||||||
_clientPools[parentClient] = PlatformClientPool(parentClient, _name).apply {
|
_clientPools[parentClient] = PlatformClientPool(parentClient, _name, _privatePool).apply {
|
||||||
this.onDead.subscribe { _, pool ->
|
this.onDead.subscribe { _, pool ->
|
||||||
synchronized(_clientPools) {
|
synchronized(_clientPools) {
|
||||||
if(_clientPools[parentClient] == pool)
|
if(_clientPools[parentClient] == pool)
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ open class PlatformAuthorLink {
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
val UNKNOWN = PlatformAuthorLink(PlatformID.NONE, "Unknown", "", null, null);
|
||||||
|
|
||||||
fun fromV8(config: SourcePluginConfig, value: V8ValueObject): PlatformAuthorLink {
|
fun fromV8(config: SourcePluginConfig, value: V8ValueObject): PlatformAuthorLink {
|
||||||
if(value.has("membershipUrl"))
|
if(value.has("membershipUrl"))
|
||||||
return PlatformAuthorMembershipLink.fromV8(config, value);
|
return PlatformAuthorMembershipLink.fromV8(config, value);
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ enum class ChapterType(val value: Int) {
|
|||||||
companion object {
|
companion object {
|
||||||
fun fromInt(value: Int): ChapterType
|
fun fromInt(value: Int): ChapterType
|
||||||
{
|
{
|
||||||
val result = ChapterType.values().firstOrNull { it.value == value };
|
val result = ChapterType.entries.firstOrNull { it.value == value };
|
||||||
if(result == null)
|
if(result == null)
|
||||||
throw UnknownPlatformException(value.toString());
|
throw UnknownPlatformException(value.toString());
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ enum class ContentType(val value: Int) {
|
|||||||
companion object {
|
companion object {
|
||||||
fun fromInt(value: Int): ContentType
|
fun fromInt(value: Int): ContentType
|
||||||
{
|
{
|
||||||
val result = ContentType.values().firstOrNull { it.value == value };
|
val result = ContentType.entries.firstOrNull { it.value == value };
|
||||||
if(result == null)
|
if(result == null)
|
||||||
throw UnknownPlatformException(value.toString());
|
throw UnknownPlatformException(value.toString());
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ enum class LiveEventType(val value : Int) {
|
|||||||
|
|
||||||
companion object{
|
companion object{
|
||||||
fun fromInt(value : Int) : LiveEventType{
|
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 {
|
companion object {
|
||||||
fun fromInt(value: Int): TextType
|
fun fromInt(value: Int): TextType
|
||||||
{
|
{
|
||||||
val result = TextType.values().firstOrNull { it.value == value };
|
val result = TextType.entries.firstOrNull { it.value == value };
|
||||||
if(result == null)
|
if(result == null)
|
||||||
throw IllegalArgumentException("Unknown Texttype: $value");
|
throw IllegalArgumentException("Unknown Texttype: $value");
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ enum class RatingType(val value : Int) {
|
|||||||
|
|
||||||
companion object{
|
companion object{
|
||||||
fun fromInt(value : Int) : RatingType{
|
fun fromInt(value : Int) : RatingType{
|
||||||
return RatingType.values().first { it.value == value };
|
return RatingType.entries.first { it.value == value };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -54,8 +54,8 @@ class DevJSClient : JSClient {
|
|||||||
return DevJSClient(context, config, _devScript, _auth, _captcha, devID, descriptor.settings);
|
return DevJSClient(context, config, _devScript, _auth, _captcha, devID, descriptor.settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getCopy(): JSClient {
|
override fun getCopy(privateCopy: Boolean): JSClient {
|
||||||
return DevJSClient(_context, descriptor, _script, _auth, _captcha, saveState(), devID);
|
return DevJSClient(_context, descriptor, _script, if(!privateCopy) _auth else null, _captcha, saveState(), devID);
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun initialize() {
|
override fun initialize() {
|
||||||
|
|||||||
@@ -164,13 +164,16 @@ open class JSClient : IPlatformClient {
|
|||||||
|
|
||||||
_plugin.changeAllowDevSubmit(descriptor.appSettings.allowDeveloperSubmit);
|
_plugin.changeAllowDevSubmit(descriptor.appSettings.allowDeveloperSubmit);
|
||||||
}
|
}
|
||||||
constructor(context: Context, descriptor: SourcePluginDescriptor, saveState: String?, script: String) {
|
constructor(context: Context, descriptor: SourcePluginDescriptor, saveState: String?, script: String, withoutCredentials: Boolean = false) {
|
||||||
this._context = context;
|
this._context = context;
|
||||||
this.config = descriptor.config;
|
this.config = descriptor.config;
|
||||||
icon = StatePlatform.instance.getPlatformIcon(config.id) ?: ImageVariable(config.absoluteIconUrl, null, null);
|
icon = StatePlatform.instance.getPlatformIcon(config.id) ?: ImageVariable(config.absoluteIconUrl, null, null);
|
||||||
this.descriptor = descriptor;
|
this.descriptor = descriptor;
|
||||||
_injectedSaveState = saveState;
|
_injectedSaveState = saveState;
|
||||||
_auth = descriptor.getAuth();
|
if(!withoutCredentials)
|
||||||
|
_auth = descriptor.getAuth();
|
||||||
|
else
|
||||||
|
_auth = null;
|
||||||
_captcha = descriptor.getCaptchaData();
|
_captcha = descriptor.getCaptchaData();
|
||||||
flags = descriptor.flags.toTypedArray();
|
flags = descriptor.flags.toTypedArray();
|
||||||
|
|
||||||
@@ -190,8 +193,8 @@ open class JSClient : IPlatformClient {
|
|||||||
_plugin.changeAllowDevSubmit(descriptor.appSettings.allowDeveloperSubmit);
|
_plugin.changeAllowDevSubmit(descriptor.appSettings.allowDeveloperSubmit);
|
||||||
}
|
}
|
||||||
|
|
||||||
open fun getCopy(): JSClient {
|
open fun getCopy(withoutCredentials: Boolean = false): JSClient {
|
||||||
return JSClient(_context, descriptor, saveState(), _script);
|
return JSClient(_context, descriptor, saveState(), _script, withoutCredentials);
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getUnderlyingPlugin(): V8Plugin {
|
fun getUnderlyingPlugin(): V8Plugin {
|
||||||
@@ -234,7 +237,8 @@ open class JSClient : IPlatformClient {
|
|||||||
hasGetLiveChatWindow = plugin.executeBoolean("!!source.getLiveChatWindow") ?: false,
|
hasGetLiveChatWindow = plugin.executeBoolean("!!source.getLiveChatWindow") ?: false,
|
||||||
hasGetContentChapters = plugin.executeBoolean("!!source.getContentChapters") ?: false,
|
hasGetContentChapters = plugin.executeBoolean("!!source.getContentChapters") ?: false,
|
||||||
hasPeekChannelContents = plugin.executeBoolean("!!source.peekChannelContents") ?: false,
|
hasPeekChannelContents = plugin.executeBoolean("!!source.peekChannelContents") ?: false,
|
||||||
hasGetChannelPlaylists = plugin.executeBoolean("!!source.getChannelPlaylists") ?: false
|
hasGetChannelPlaylists = plugin.executeBoolean("!!source.getChannelPlaylists") ?: false,
|
||||||
|
hasGetContentRecommendations = plugin.executeBoolean("!!source.getContentRecommendations") ?: false
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.futo.platformplayer.api.media.platforms.js
|
||||||
|
|
||||||
|
class JSClientConstants {
|
||||||
|
companion object {
|
||||||
|
val PLUGIN_SPEC_VERSION = 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-2
@@ -4,7 +4,9 @@ import android.net.Uri
|
|||||||
import com.futo.platformplayer.SignatureProvider
|
import com.futo.platformplayer.SignatureProvider
|
||||||
import com.futo.platformplayer.api.media.Serializer
|
import com.futo.platformplayer.api.media.Serializer
|
||||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||||
|
import com.futo.platformplayer.matchesDomain
|
||||||
import com.futo.platformplayer.states.StatePlugins
|
import com.futo.platformplayer.states.StatePlugins
|
||||||
|
import kotlinx.serialization.Contextual
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@@ -48,6 +50,7 @@ class SourcePluginConfig(
|
|||||||
var primaryClaimFieldType: Int? = null,
|
var primaryClaimFieldType: Int? = null,
|
||||||
var developerSubmitUrl: String? = null,
|
var developerSubmitUrl: String? = null,
|
||||||
var allowAllHttpHeaderAccess: Boolean = false,
|
var allowAllHttpHeaderAccess: Boolean = false,
|
||||||
|
var maxDownloadParallelism: Int = 0
|
||||||
) : IV8PluginConfig {
|
) : IV8PluginConfig {
|
||||||
|
|
||||||
val absoluteIconUrl: String? get() = resolveAbsoluteUrl(iconUrl, sourceUrl);
|
val absoluteIconUrl: String? get() = resolveAbsoluteUrl(iconUrl, sourceUrl);
|
||||||
@@ -77,7 +80,8 @@ class SourcePluginConfig(
|
|||||||
private var _allowUrlsLowerVal: List<String>? = null;
|
private var _allowUrlsLowerVal: List<String>? = null;
|
||||||
private val _allowUrlsLower: List<String> get() {
|
private val _allowUrlsLower: List<String> get() {
|
||||||
if(_allowUrlsLowerVal == null)
|
if(_allowUrlsLowerVal == null)
|
||||||
_allowUrlsLowerVal = allowUrls.map { it.lowercase() };
|
_allowUrlsLowerVal = allowUrls.map { it.lowercase() }
|
||||||
|
.filter { it.length > 0 };
|
||||||
return _allowUrlsLowerVal!!;
|
return _allowUrlsLowerVal!!;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -170,7 +174,7 @@ class SourcePluginConfig(
|
|||||||
return true;
|
return true;
|
||||||
val uri = Uri.parse(url);
|
val uri = Uri.parse(url);
|
||||||
val host = uri.host?.lowercase() ?: "";
|
val host = uri.host?.lowercase() ?: "";
|
||||||
return _allowUrlsLower.any { it == host };
|
return _allowUrlsLower.any { it == host || (it.length > 0 && it[0] == '.' && host.matchesDomain(it)) };
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
+1
@@ -16,6 +16,7 @@ interface IJSContentDetails: IPlatformContent {
|
|||||||
return when(ContentType.fromInt(type)) {
|
return when(ContentType.fromInt(type)) {
|
||||||
ContentType.MEDIA -> JSVideoDetails(plugin, obj);
|
ContentType.MEDIA -> JSVideoDetails(plugin, obj);
|
||||||
ContentType.POST -> JSPostDetails(plugin.config, obj);
|
ContentType.POST -> JSPostDetails(plugin.config, obj);
|
||||||
|
ContentType.ARTICLE -> JSArticleDetails(plugin, obj);
|
||||||
else -> throw NotImplementedError("Unknown content type ${type}");
|
else -> throw NotImplementedError("Unknown content type ${type}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+162
@@ -0,0 +1,162 @@
|
|||||||
|
package com.futo.platformplayer.api.media.platforms.js.models
|
||||||
|
|
||||||
|
import com.caoccao.javet.values.reference.V8ValueObject
|
||||||
|
import com.futo.platformplayer.api.media.IPlatformClient
|
||||||
|
import com.futo.platformplayer.api.media.IPluginSourced
|
||||||
|
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||||
|
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
||||||
|
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||||
|
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||||
|
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
|
||||||
|
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
|
||||||
|
import com.futo.platformplayer.api.media.models.post.TextType
|
||||||
|
import com.futo.platformplayer.api.media.models.ratings.IRating
|
||||||
|
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
|
||||||
|
import com.futo.platformplayer.api.media.platforms.js.DevJSClient
|
||||||
|
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||||
|
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||||
|
import com.futo.platformplayer.api.media.structures.IPager
|
||||||
|
import com.futo.platformplayer.getOrDefault
|
||||||
|
import com.futo.platformplayer.getOrThrow
|
||||||
|
import com.futo.platformplayer.getOrThrowNullableList
|
||||||
|
import com.futo.platformplayer.states.StateDeveloper
|
||||||
|
|
||||||
|
open class JSArticleDetails : JSContent, IPluginSourced, IPlatformContentDetails {
|
||||||
|
final override val contentType: ContentType get() = ContentType.ARTICLE;
|
||||||
|
|
||||||
|
private val _hasGetComments: Boolean;
|
||||||
|
private val _hasGetContentRecommendations: Boolean;
|
||||||
|
|
||||||
|
val rating: IRating;
|
||||||
|
|
||||||
|
val summary: String;
|
||||||
|
val thumbnails: Thumbnails?;
|
||||||
|
val segments: List<IJSArticleSegment>;
|
||||||
|
|
||||||
|
constructor(client: JSClient, obj: V8ValueObject): super(client.config, obj) {
|
||||||
|
val contextName = "PlatformPost";
|
||||||
|
|
||||||
|
rating = obj.getOrDefault<V8ValueObject>(client.config, "rating", contextName, null)?.let { IRating.fromV8(client.config, it, contextName) } ?: RatingLikes(0);
|
||||||
|
summary = _content.getOrThrow(client.config, "summary", contextName);
|
||||||
|
if(_content.has("thumbnails"))
|
||||||
|
thumbnails = Thumbnails.fromV8(client.config, _content.getOrThrow(client.config, "thumbnails", contextName));
|
||||||
|
else
|
||||||
|
thumbnails = null;
|
||||||
|
|
||||||
|
|
||||||
|
segments = (obj.getOrThrowNullableList<V8ValueObject>(client.config, "segments", contextName)
|
||||||
|
?.map { fromV8Segment(client, it) }
|
||||||
|
?.filterNotNull() ?: listOf());
|
||||||
|
|
||||||
|
_hasGetComments = _content.has("getComments");
|
||||||
|
_hasGetContentRecommendations = _content.has("getContentRecommendations");
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? {
|
||||||
|
if(!_hasGetComments || _content.isClosed)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if(client is DevJSClient)
|
||||||
|
return StateDeveloper.instance.handleDevCall(client.devID, "videoDetail.getComments()") {
|
||||||
|
return@handleDevCall getCommentsJS(client);
|
||||||
|
}
|
||||||
|
else if(client is JSClient)
|
||||||
|
return getCommentsJS(client);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getPlaybackTracker(): IPlaybackTracker? = null;
|
||||||
|
|
||||||
|
override fun getContentRecommendations(client: IPlatformClient): IPager<IPlatformContent>? {
|
||||||
|
if(!_hasGetContentRecommendations || _content.isClosed)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if(client is DevJSClient)
|
||||||
|
return StateDeveloper.instance.handleDevCall(client.devID, "postDetail.getContentRecommendations()") {
|
||||||
|
return@handleDevCall getContentRecommendationsJS(client);
|
||||||
|
}
|
||||||
|
else if(client is JSClient)
|
||||||
|
return getContentRecommendationsJS(client);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getContentRecommendationsJS(client: JSClient): JSContentPager {
|
||||||
|
val contentPager = _content.invoke<V8ValueObject>("getContentRecommendations", arrayOf<Any>());
|
||||||
|
return JSContentPager(_pluginConfig, client, contentPager);
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getCommentsJS(client: JSClient): JSCommentPager {
|
||||||
|
val commentPager = _content.invoke<V8ValueObject>("getComments", arrayOf<Any>());
|
||||||
|
return JSCommentPager(_pluginConfig, client, commentPager);
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromV8Segment(client: JSClient, obj: V8ValueObject): IJSArticleSegment? {
|
||||||
|
if(!obj.has("type"))
|
||||||
|
throw IllegalArgumentException("Object missing type field");
|
||||||
|
return when(SegmentType.fromInt(obj.getOrThrow(client.config, "type", "JSArticle.Segment"))) {
|
||||||
|
SegmentType.TEXT -> JSTextSegment(client, obj);
|
||||||
|
SegmentType.IMAGES -> JSImagesSegment(client, obj);
|
||||||
|
SegmentType.NESTED -> JSNestedSegment(client, obj);
|
||||||
|
else -> null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class SegmentType(val value: Int) {
|
||||||
|
UNKNOWN(0),
|
||||||
|
TEXT(1),
|
||||||
|
IMAGES(2),
|
||||||
|
|
||||||
|
NESTED(9);
|
||||||
|
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromInt(value: Int): SegmentType
|
||||||
|
{
|
||||||
|
val result = SegmentType.entries.firstOrNull { it.value == value };
|
||||||
|
if(result == null)
|
||||||
|
throw IllegalArgumentException("Unknown Texttype: $value");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IJSArticleSegment {
|
||||||
|
val type: SegmentType;
|
||||||
|
}
|
||||||
|
class JSTextSegment: IJSArticleSegment {
|
||||||
|
override val type = SegmentType.TEXT;
|
||||||
|
val textType: TextType;
|
||||||
|
val content: String;
|
||||||
|
|
||||||
|
constructor(client: JSClient, obj: V8ValueObject) {
|
||||||
|
val contextName = "JSTextSegment";
|
||||||
|
textType = TextType.fromInt((obj.getOrDefault<Int>(client.config, "textType", contextName, null) ?: 0));
|
||||||
|
content = obj.getOrDefault(client.config, "content", contextName, "") ?: "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class JSImagesSegment: IJSArticleSegment {
|
||||||
|
override val type = SegmentType.IMAGES;
|
||||||
|
val images: List<String>;
|
||||||
|
val caption: String;
|
||||||
|
|
||||||
|
constructor(client: JSClient, obj: V8ValueObject) {
|
||||||
|
val contextName = "JSTextSegment";
|
||||||
|
images = obj.getOrThrowNullableList<String>(client.config, "images", contextName) ?: listOf();
|
||||||
|
caption = obj.getOrDefault(client.config, "caption", contextName, "") ?: "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class JSNestedSegment: IJSArticleSegment {
|
||||||
|
override val type = SegmentType.NESTED;
|
||||||
|
val nested: IPlatformContent;
|
||||||
|
|
||||||
|
constructor(client: JSClient, obj: V8ValueObject) {
|
||||||
|
val contextName = "JSNestedSegment";
|
||||||
|
val nestedObj = obj.getOrThrow<V8ValueObject>(client.config, "nested", contextName, false);
|
||||||
|
nested = IJSContent.fromV8(client, nestedObj);
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-1
@@ -42,7 +42,12 @@ open class JSContent : IPlatformContent, IPluginSourced {
|
|||||||
|
|
||||||
id = PlatformID.fromV8(_pluginConfig, _content.getOrThrow(config, "id", contextName));
|
id = PlatformID.fromV8(_pluginConfig, _content.getOrThrow(config, "id", contextName));
|
||||||
name = HtmlCompat.fromHtml(_content.getOrThrow<String>(config, "name", contextName).decodeUnicode(), HtmlCompat.FROM_HTML_MODE_LEGACY).toString();
|
name = HtmlCompat.fromHtml(_content.getOrThrow<String>(config, "name", contextName).decodeUnicode(), HtmlCompat.FROM_HTML_MODE_LEGACY).toString();
|
||||||
author = PlatformAuthorLink.fromV8(_pluginConfig, _content.getOrThrow(config, "author", contextName));
|
|
||||||
|
val authorObj = _content.getOrDefault<V8ValueObject>(config, "author", contextName, null);
|
||||||
|
if(authorObj != null)
|
||||||
|
author = PlatformAuthorLink.fromV8(_pluginConfig, authorObj);
|
||||||
|
else
|
||||||
|
author = PlatformAuthorLink.UNKNOWN;
|
||||||
|
|
||||||
val datetimeInt = _content.getOrThrow<Int>(config, "datetime", contextName).toLong();
|
val datetimeInt = _content.getOrThrow<Int>(config, "datetime", contextName).toLong();
|
||||||
if(datetimeInt == 0.toLong())
|
if(datetimeInt == 0.toLong())
|
||||||
@@ -54,4 +59,8 @@ open class JSContent : IPlatformContent, IPluginSourced {
|
|||||||
|
|
||||||
_hasGetDetails = _content.has("getDetails");
|
_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;
|
||||||
|
}
|
||||||
+5
@@ -35,4 +35,9 @@ class JSAudioUrlRangeSource : JSAudioUrlSource, IStreamMetaDataSource {
|
|||||||
indexEnd = _obj.getOrDefault(config, "indexEnd", contextName, null);
|
indexEnd = _obj.getOrDefault(config, "indexEnd", contextName, null);
|
||||||
audioChannels = _obj.getOrDefault(config, "audioChannels", contextName, 2) ?: 2;
|
audioChannels = _obj.getOrDefault(config, "audioChannels", contextName, 2) ?: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun toString(): String {
|
||||||
|
return "RangeSource(url=[${getAudioUrl()}], itagId=[${itagId}], initStart=[${initStart}], initEnd=[${initEnd}], indexStart=[${indexStart}], indexEnd=[${indexEnd}]))";
|
||||||
|
return super.toString()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+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.models.streams.sources.IVideoSource
|
||||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
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.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.api.media.platforms.js.models.JSRequestModifier
|
||||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||||
import com.futo.platformplayer.engine.V8Plugin
|
import com.futo.platformplayer.engine.V8Plugin
|
||||||
import com.futo.platformplayer.getOrDefault
|
import com.futo.platformplayer.getOrDefault
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.orNull
|
import com.futo.platformplayer.orNull
|
||||||
import com.futo.platformplayer.views.video.datasources.JSHttpDataSource
|
import com.futo.platformplayer.views.video.datasources.JSHttpDataSource
|
||||||
|
|
||||||
@@ -21,9 +23,17 @@ abstract class JSSource {
|
|||||||
protected val _plugin: JSClient;
|
protected val _plugin: JSClient;
|
||||||
protected val _config: IV8PluginConfig;
|
protected val _config: IV8PluginConfig;
|
||||||
protected val _obj: V8ValueObject;
|
protected val _obj: V8ValueObject;
|
||||||
|
|
||||||
val hasRequestModifier: Boolean;
|
val hasRequestModifier: Boolean;
|
||||||
private val _requestModifier: JSRequest?;
|
private val _requestModifier: JSRequest?;
|
||||||
|
|
||||||
|
val hasRequestExecutor: Boolean;
|
||||||
|
private val _requestExecutor: JSRequest?;
|
||||||
|
|
||||||
|
val requiresCustomDatasource: Boolean get() {
|
||||||
|
return hasRequestModifier || hasRequestExecutor;
|
||||||
|
}
|
||||||
|
|
||||||
val type : String;
|
val type : String;
|
||||||
|
|
||||||
constructor(type: String, plugin: JSClient, obj: V8ValueObject) {
|
constructor(type: String, plugin: JSClient, obj: V8ValueObject) {
|
||||||
@@ -36,6 +46,11 @@ abstract class JSSource {
|
|||||||
JSRequest(plugin, it, null, null, true);
|
JSRequest(plugin, it, null, null, true);
|
||||||
}
|
}
|
||||||
hasRequestModifier = _requestModifier != null || obj.has("getRequestModifier");
|
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? {
|
fun getRequestModifier(): IRequestModifier? {
|
||||||
@@ -44,20 +59,38 @@ abstract class JSSource {
|
|||||||
return@AdhocRequestModifier _requestModifier.modify(_plugin, url, headers);
|
return@AdhocRequestModifier _requestModifier.modify(_plugin, url, headers);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!hasRequestModifier || _obj.isClosed) {
|
if (!hasRequestModifier || _obj.isClosed)
|
||||||
return null;
|
return null;
|
||||||
}
|
|
||||||
|
|
||||||
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSVideoUrlSource", "obj.getRequestModifier()") {
|
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSVideoUrlSource", "obj.getRequestModifier()") {
|
||||||
_obj.invoke("getRequestModifier", arrayOf<Any>());
|
_obj.invoke("getRequestModifier", arrayOf<Any>());
|
||||||
};
|
};
|
||||||
|
|
||||||
if (result !is V8ValueObject) {
|
if (result !is V8ValueObject)
|
||||||
return null;
|
return null;
|
||||||
}
|
|
||||||
|
|
||||||
return JSRequestModifier(_plugin, result)
|
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 {
|
companion object {
|
||||||
const val TYPE_AUDIOURL = "AudioUrlSource";
|
const val TYPE_AUDIOURL = "AudioUrlSource";
|
||||||
@@ -65,33 +98,45 @@ abstract class JSSource {
|
|||||||
const val TYPE_AUDIO_WITH_METADATA = "AudioUrlRangeSource";
|
const val TYPE_AUDIO_WITH_METADATA = "AudioUrlRangeSource";
|
||||||
const val TYPE_VIDEO_WITH_METADATA = "VideoUrlRangeSource";
|
const val TYPE_VIDEO_WITH_METADATA = "VideoUrlRangeSource";
|
||||||
const val TYPE_DASH = "DashSource";
|
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_HLS = "HLSSource";
|
||||||
const val TYPE_AUDIOURL_WIDEVINE = "AudioUrlWidevineSource"
|
const val TYPE_AUDIOURL_WIDEVINE = "AudioUrlWidevineSource"
|
||||||
|
|
||||||
fun fromV8VideoNullable(plugin: JSClient, obj: V8Value?) : IVideoSource? = obj.orNull { fromV8Video(plugin, it as V8ValueObject) };
|
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");
|
val type = obj.getString("plugin_type");
|
||||||
return when(type) {
|
return when(type) {
|
||||||
TYPE_VIDEOURL -> JSVideoUrlSource(plugin, obj);
|
TYPE_VIDEOURL -> JSVideoUrlSource(plugin, obj);
|
||||||
TYPE_VIDEO_WITH_METADATA -> JSVideoUrlRangeSource(plugin, obj);
|
TYPE_VIDEO_WITH_METADATA -> JSVideoUrlRangeSource(plugin, obj);
|
||||||
TYPE_HLS -> fromV8HLS(plugin, obj);
|
TYPE_HLS -> fromV8HLS(plugin, obj);
|
||||||
TYPE_DASH -> fromV8Dash(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 fromV8DashNullable(plugin: JSClient, obj: V8Value?) : JSDashManifestSource? = obj.orNull { fromV8Dash(plugin, it as V8ValueObject) };
|
||||||
fun fromV8Dash(plugin: JSClient, obj: V8ValueObject) : JSDashManifestSource = JSDashManifestSource(plugin, obj);
|
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 fromV8HLSNullable(plugin: JSClient, obj: V8Value?) : JSHLSManifestSource? = obj.orNull { fromV8HLS(plugin, it as V8ValueObject) };
|
||||||
fun fromV8HLS(plugin: JSClient, obj: V8ValueObject) : JSHLSManifestSource = JSHLSManifestSource(plugin, obj);
|
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");
|
val type = obj.getString("plugin_type");
|
||||||
return when(type) {
|
return when(type) {
|
||||||
TYPE_HLS -> JSHLSManifestAudioSource.fromV8HLS(plugin, obj);
|
TYPE_HLS -> JSHLSManifestAudioSource.fromV8HLS(plugin, obj);
|
||||||
TYPE_AUDIOURL -> JSAudioUrlSource(plugin, obj);
|
TYPE_AUDIOURL -> JSAudioUrlSource(plugin, obj);
|
||||||
|
TYPE_DASH_RAW_AUDIO -> fromV8DashRawAudio(plugin, obj);
|
||||||
TYPE_AUDIOURL_WIDEVINE -> JSAudioUrlWidevineSource(plugin, obj);
|
TYPE_AUDIOURL_WIDEVINE -> JSAudioUrlWidevineSource(plugin, obj);
|
||||||
TYPE_AUDIO_WITH_METADATA -> JSAudioUrlRangeSource(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.isUnMuxed = obj.getOrThrow(config, "isUnMuxed", contextName);
|
||||||
this.videoSources = obj.getOrThrow<V8ValueArray>(config, "videoSources", contextName).toArray()
|
this.videoSources = obj.getOrThrow<V8ValueArray>(config, "videoSources", contextName).toArray()
|
||||||
.map { JSSource.fromV8Video(plugin, it as V8ValueObject) }
|
.map { JSSource.fromV8Video(plugin, it as V8ValueObject) }
|
||||||
|
.filterNotNull()
|
||||||
.toTypedArray();
|
.toTypedArray();
|
||||||
this.audioSources = obj.getOrThrow<V8ValueArray>(config, "audioSources", contextName).toArray()
|
this.audioSources = obj.getOrThrow<V8ValueArray>(config, "audioSources", contextName).toArray()
|
||||||
.map { JSSource.fromV8Audio(plugin, it as V8ValueObject) }
|
.map { JSSource.fromV8Audio(plugin, it as V8ValueObject) }
|
||||||
|
.filterNotNull()
|
||||||
.toTypedArray();
|
.toTypedArray();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1
@@ -21,6 +21,7 @@ class JSVideoSourceDescriptor : VideoMuxedSourceDescriptor {
|
|||||||
this.isUnMuxed = obj.getOrThrow(config, "isUnMuxed", contextName);
|
this.isUnMuxed = obj.getOrThrow(config, "isUnMuxed", contextName);
|
||||||
this.videoSources = obj.getOrThrow<V8ValueArray>(config, "videoSources", contextName).toArray()
|
this.videoSources = obj.getOrThrow<V8ValueArray>(config, "videoSources", contextName).toArray()
|
||||||
.map { JSSource.fromV8Video(plugin, it as V8ValueObject) }
|
.map { JSSource.fromV8Video(plugin, it as V8ValueObject) }
|
||||||
|
.filterNotNull()
|
||||||
.toTypedArray();
|
.toTypedArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+5
@@ -33,4 +33,9 @@ class JSVideoUrlRangeSource : JSVideoUrlSource, IStreamMetaDataSource {
|
|||||||
indexStart = _obj.getOrDefault(config, "indexStart", contextName, null);
|
indexStart = _obj.getOrDefault(config, "indexStart", contextName, null);
|
||||||
indexEnd = _obj.getOrDefault(config, "indexEnd", contextName, null);
|
indexEnd = _obj.getOrDefault(config, "indexEnd", contextName, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun toString(): String {
|
||||||
|
return "RangeSource(url=[${getVideoUrl()}], itagId=[${itagId}], initStart=[${initStart}], initEnd=[${initEnd}], indexStart=[${indexStart}], indexEnd=[${indexEnd}]))";
|
||||||
|
return super.toString()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -6,14 +6,17 @@ import android.net.Uri
|
|||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import android.util.Log
|
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.Settings
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
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.ManagedHttpServer
|
||||||
import com.futo.platformplayer.api.http.server.handlers.HttpConstantHandler
|
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.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.http.server.handlers.HttpProxyHandler
|
||||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
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.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.streams.sources.LocalVideoSource
|
||||||
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
|
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
|
||||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
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.builders.DashBuilder
|
||||||
import com.futo.platformplayer.constructs.Event1
|
import com.futo.platformplayer.constructs.Event1
|
||||||
import com.futo.platformplayer.constructs.Event2
|
import com.futo.platformplayer.constructs.Event2
|
||||||
import com.futo.platformplayer.exceptions.UnsupportedCastException
|
import com.futo.platformplayer.exceptions.UnsupportedCastException
|
||||||
import com.futo.platformplayer.logging.Logger
|
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.models.CastingDeviceInfo
|
||||||
import com.futo.platformplayer.parsers.HLS
|
import com.futo.platformplayer.parsers.HLS
|
||||||
import com.futo.platformplayer.states.StateApp
|
import com.futo.platformplayer.states.StateApp
|
||||||
import com.futo.platformplayer.stores.CastingDeviceInfoStorage
|
import com.futo.platformplayer.stores.CastingDeviceInfoStorage
|
||||||
import com.futo.platformplayer.stores.FragmentedStorage
|
import com.futo.platformplayer.stores.FragmentedStorage
|
||||||
|
import com.futo.platformplayer.toUrlAddress
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
@@ -43,17 +53,15 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
import java.net.InetAddress
|
import java.net.InetAddress
|
||||||
|
import java.net.URLDecoder
|
||||||
|
import java.net.URLEncoder
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.jmdns.JmDNS
|
|
||||||
import javax.jmdns.ServiceEvent
|
|
||||||
import javax.jmdns.ServiceListener
|
|
||||||
import javax.jmdns.ServiceTypeListener
|
|
||||||
|
|
||||||
class StateCasting {
|
class StateCasting {
|
||||||
private val _scopeIO = CoroutineScope(Dispatchers.IO);
|
private val _scopeIO = CoroutineScope(Dispatchers.IO);
|
||||||
private val _scopeMain = CoroutineScope(Dispatchers.Main);
|
private val _scopeMain = CoroutineScope(Dispatchers.Main);
|
||||||
private var _jmDNS: JmDNS? = null;
|
|
||||||
private val _storage: CastingDeviceInfoStorage = FragmentedStorage.get();
|
private val _storage: CastingDeviceInfoStorage = FragmentedStorage.get();
|
||||||
|
|
||||||
private val _castServer = ManagedHttpServer(9999);
|
private val _castServer = ManagedHttpServer(9999);
|
||||||
@@ -70,105 +78,51 @@ class StateCasting {
|
|||||||
val onActiveDeviceDurationChanged = Event1<Double>();
|
val onActiveDeviceDurationChanged = Event1<Double>();
|
||||||
val onActiveDeviceVolumeChanged = Event1<Double>();
|
val onActiveDeviceVolumeChanged = Event1<Double>();
|
||||||
var activeDevice: CastingDevice? = null;
|
var activeDevice: CastingDevice? = null;
|
||||||
|
private var _videoExecutor: JSRequestExecutor? = null
|
||||||
|
private var _audioExecutor: JSRequestExecutor? = null
|
||||||
private val _client = ManagedHttpClient();
|
private val _client = ManagedHttpClient();
|
||||||
var _resumeCastingDevice: CastingDeviceInfo? = null;
|
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;
|
val isCasting: Boolean get() = activeDevice != null;
|
||||||
|
|
||||||
private val _chromecastServiceListener = object : ServiceListener {
|
private fun handleServiceUpdated(services: List<DnsService>) {
|
||||||
override fun serviceAdded(event: ServiceEvent) {
|
for (s in services) {
|
||||||
Logger.i(TAG, "ChromeCast service added: " + event.info);
|
//TODO: Addresses IPv4 only?
|
||||||
addOrUpdateDevice(event);
|
val addresses = s.addresses.toTypedArray()
|
||||||
}
|
val port = s.port.toInt()
|
||||||
|
var name = s.texts.firstOrNull { it.startsWith("md=") }?.substring("md=".length)
|
||||||
override fun serviceRemoved(event: ServiceEvent) {
|
if (s.name.endsWith("._googlecast._tcp.local")) {
|
||||||
Logger.i(TAG, "ChromeCast service removed: " + event.info);
|
if (name == null) {
|
||||||
synchronized(devices) {
|
name = s.name.substring(0, s.name.length - "._googlecast._tcp.local".length)
|
||||||
val device = devices[event.info.name];
|
|
||||||
if (device != null) {
|
|
||||||
onDeviceRemoved.emit(device);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun serviceResolved(event: ServiceEvent) {
|
addOrUpdateChromeCastDevice(name, addresses, port)
|
||||||
Logger.v(TAG, "ChromeCast service resolved: " + event.info);
|
} else if (s.name.endsWith("._airplay._tcp.local")) {
|
||||||
addOrUpdateDevice(event);
|
if (name == null) {
|
||||||
}
|
name = s.name.substring(0, s.name.length - "._airplay._tcp.local".length)
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun serviceResolved(event: ServiceEvent) {
|
addOrUpdateAirPlayDevice(name, addresses, port)
|
||||||
Logger.i(TAG, "AirPlay service resolved: " + event.info);
|
} else if (s.name.endsWith("._fastcast._tcp.local")) {
|
||||||
addOrUpdateDevice(event);
|
if (name == null) {
|
||||||
}
|
name = s.name.substring(0, s.name.length - "._fastcast._tcp.local".length)
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
fun handleUrl(context: Context, url: String) {
|
||||||
@@ -237,29 +191,30 @@ class StateCasting {
|
|||||||
rememberedDevices.clear();
|
rememberedDevices.clear();
|
||||||
rememberedDevices.addAll(_storage.deviceInfos.map { deviceFromCastingDeviceInfo(it) });
|
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();
|
_castServer.start();
|
||||||
enableDeveloper(true);
|
enableDeveloper(true);
|
||||||
|
|
||||||
Logger.i(TAG, "CastingService started.");
|
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
|
@Synchronized
|
||||||
fun stop() {
|
fun stop() {
|
||||||
if (!_started)
|
if (!_started)
|
||||||
@@ -269,25 +224,7 @@ class StateCasting {
|
|||||||
|
|
||||||
Logger.i(TAG, "CastingService stopping.")
|
Logger.i(TAG, "CastingService stopping.")
|
||||||
|
|
||||||
val jmDNS = _jmDNS;
|
stopDiscovering()
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_scopeIO.cancel();
|
_scopeIO.cancel();
|
||||||
_scopeMain.cancel();
|
_scopeMain.cancel();
|
||||||
|
|
||||||
@@ -437,15 +374,26 @@ class StateCasting {
|
|||||||
} else {
|
} else {
|
||||||
StateApp.instance.scope.launch(Dispatchers.IO) {
|
StateApp.instance.scope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
if (ad is FCastCastingDevice) {
|
val isRawDash = videoSource is JSDashManifestRawSource || audioSource is JSDashManifestRawAudioSource
|
||||||
Logger.i(TAG, "Casting as DASH direct");
|
if (isRawDash) {
|
||||||
castDashDirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed);
|
Logger.i(TAG, "Casting as raw DASH");
|
||||||
} else if (ad is AirPlayCastingDevice) {
|
|
||||||
Logger.i(TAG, "Casting as HLS indirect");
|
try {
|
||||||
castHlsIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed);
|
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 {
|
} else {
|
||||||
Logger.i(TAG, "Casting as DASH indirect");
|
if (ad is FCastCastingDevice) {
|
||||||
castDashIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed);
|
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) {
|
} catch (e: Throwable) {
|
||||||
Logger.e(TAG, "Failed to start casting DASH videoSource=${videoSource} audioSource=${audioSource}.", e);
|
Logger.e(TAG, "Failed to start casting DASH videoSource=${videoSource} audioSource=${audioSource}.", e);
|
||||||
@@ -454,7 +402,7 @@ class StateCasting {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val proxyStreams = Settings.instance.casting.alwaysProxyRequests;
|
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();
|
val id = UUID.randomUUID();
|
||||||
|
|
||||||
if (videoSource is IVideoUrlSource) {
|
if (videoSource is IVideoUrlSource) {
|
||||||
@@ -489,6 +437,26 @@ class StateCasting {
|
|||||||
} else if (audioSource is LocalAudioSource) {
|
} else if (audioSource is LocalAudioSource) {
|
||||||
Logger.i(TAG, "Casting as local audio");
|
Logger.i(TAG, "Casting as local audio");
|
||||||
castLocalAudio(video, audioSource, resumePosition, speed);
|
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 {
|
} else {
|
||||||
var str = listOf(
|
var str = listOf(
|
||||||
if(videoSource != null) "Video: ${videoSource::class.java.simpleName}" else null,
|
if(videoSource != null) "Video: ${videoSource::class.java.simpleName}" else null,
|
||||||
@@ -529,7 +497,7 @@ class StateCasting {
|
|||||||
private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double, speed: Double?) : List<String> {
|
private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double, speed: Double?) : List<String> {
|
||||||
val ad = activeDevice ?: return listOf();
|
val ad = activeDevice ?: return listOf();
|
||||||
|
|
||||||
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
|
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||||
val id = UUID.randomUUID();
|
val id = UUID.randomUUID();
|
||||||
val videoPath = "/video-${id}"
|
val videoPath = "/video-${id}"
|
||||||
val videoUrl = url + videoPath;
|
val videoUrl = url + videoPath;
|
||||||
@@ -548,7 +516,7 @@ class StateCasting {
|
|||||||
private fun castLocalAudio(video: IPlatformVideoDetails, audioSource: LocalAudioSource, resumePosition: Double, speed: Double?) : List<String> {
|
private fun castLocalAudio(video: IPlatformVideoDetails, audioSource: LocalAudioSource, resumePosition: Double, speed: Double?) : List<String> {
|
||||||
val ad = activeDevice ?: return listOf();
|
val ad = activeDevice ?: return listOf();
|
||||||
|
|
||||||
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
|
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||||
val id = UUID.randomUUID();
|
val id = UUID.randomUUID();
|
||||||
val audioPath = "/audio-${id}"
|
val audioPath = "/audio-${id}"
|
||||||
val audioUrl = url + audioPath;
|
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> {
|
private fun castLocalHls(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double, speed: Double?): List<String> {
|
||||||
val ad = activeDevice ?: return listOf()
|
val ad = activeDevice ?: return listOf()
|
||||||
|
|
||||||
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}"
|
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"
|
||||||
val id = UUID.randomUUID()
|
val id = UUID.randomUUID()
|
||||||
|
|
||||||
val hlsPath = "/hls-${id}"
|
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> {
|
private fun castLocalDash(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double, speed: Double?) : List<String> {
|
||||||
val ad = activeDevice ?: return listOf();
|
val ad = activeDevice ?: return listOf();
|
||||||
|
|
||||||
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
|
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||||
val id = UUID.randomUUID();
|
val id = UUID.randomUUID();
|
||||||
|
|
||||||
val dashPath = "/dash-${id}"
|
val dashPath = "/dash-${id}"
|
||||||
@@ -713,7 +681,7 @@ class StateCasting {
|
|||||||
val ad = activeDevice ?: return listOf();
|
val ad = activeDevice ?: return listOf();
|
||||||
val proxyStreams = Settings.instance.casting.alwaysProxyRequests || ad !is FCastCastingDevice;
|
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 id = UUID.randomUUID();
|
||||||
|
|
||||||
val videoPath = "/video-${id}"
|
val videoPath = "/video-${id}"
|
||||||
@@ -771,20 +739,21 @@ class StateCasting {
|
|||||||
Logger.v(TAG) { "Dash manifest: $content" };
|
Logger.v(TAG) { "Dash manifest: $content" };
|
||||||
ad.loadContent("application/dash+xml", content, resumePosition, video.duration.toDouble(), speed);
|
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> {
|
private fun castProxiedHls(video: IPlatformVideoDetails, sourceUrl: String, codec: String?, resumePosition: Double, speed: Double?): List<String> {
|
||||||
_castServer.removeAllHandlers("castProxiedHlsMaster")
|
_castServer.removeAllHandlers("castProxiedHlsMaster")
|
||||||
|
|
||||||
val ad = activeDevice ?: return listOf();
|
val ad = activeDevice ?: return listOf();
|
||||||
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
|
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||||
|
|
||||||
val id = UUID.randomUUID();
|
val id = UUID.randomUUID();
|
||||||
val hlsPath = "/hls-${id}"
|
val hlsPath = "/hls-${id}"
|
||||||
val hlsUrl = url + hlsPath
|
val hlsUrl = url + hlsPath
|
||||||
Logger.i(TAG, "HLS url: $hlsUrl");
|
Logger.i(TAG, "HLS url: $hlsUrl");
|
||||||
|
|
||||||
_castServer.addHandlerWithAllowAllOptions(HttpFuntionHandler("GET", hlsPath) { masterContext ->
|
_castServer.addHandlerWithAllowAllOptions(HttpFunctionHandler("GET", hlsPath) { masterContext ->
|
||||||
_castServer.removeAllHandlers("castProxiedHlsVariant")
|
_castServer.removeAllHandlers("castProxiedHlsVariant")
|
||||||
|
|
||||||
val headers = masterContext.headers.clone()
|
val headers = masterContext.headers.clone()
|
||||||
@@ -811,7 +780,7 @@ class StateCasting {
|
|||||||
val proxiedVariantPlaylist = proxyVariantPlaylist(url, id, variantPlaylist, video.isLive)
|
val proxiedVariantPlaylist = proxyVariantPlaylist(url, id, variantPlaylist, video.isLive)
|
||||||
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
|
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
|
||||||
masterContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
|
masterContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
|
||||||
return@HttpFuntionHandler
|
return@HttpFunctionHandler
|
||||||
} else {
|
} else {
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
@@ -828,7 +797,7 @@ class StateCasting {
|
|||||||
val newPlaylistPath = "/hls-playlist-${playlistId}"
|
val newPlaylistPath = "/hls-playlist-${playlistId}"
|
||||||
val newPlaylistUrl = url + newPlaylistPath;
|
val newPlaylistUrl = url + newPlaylistPath;
|
||||||
|
|
||||||
_castServer.addHandlerWithAllowAllOptions(HttpFuntionHandler("GET", newPlaylistPath) { vpContext ->
|
_castServer.addHandlerWithAllowAllOptions(HttpFunctionHandler("GET", newPlaylistPath) { vpContext ->
|
||||||
val vpHeaders = vpContext.headers.clone()
|
val vpHeaders = vpContext.headers.clone()
|
||||||
vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl";
|
vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl";
|
||||||
|
|
||||||
@@ -858,7 +827,7 @@ class StateCasting {
|
|||||||
val newPlaylistPath = "/hls-playlist-${playlistId}"
|
val newPlaylistPath = "/hls-playlist-${playlistId}"
|
||||||
newPlaylistUrl = url + newPlaylistPath
|
newPlaylistUrl = url + newPlaylistPath
|
||||||
|
|
||||||
_castServer.addHandlerWithAllowAllOptions(HttpFuntionHandler("GET", newPlaylistPath) { vpContext ->
|
_castServer.addHandlerWithAllowAllOptions(HttpFunctionHandler("GET", newPlaylistPath) { vpContext ->
|
||||||
val vpHeaders = vpContext.headers.clone()
|
val vpHeaders = vpContext.headers.clone()
|
||||||
vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl";
|
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> {
|
private suspend fun castHlsIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List<String> {
|
||||||
val ad = activeDevice ?: return listOf();
|
val ad = activeDevice ?: return listOf();
|
||||||
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
|
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||||
val id = UUID.randomUUID();
|
val id = UUID.randomUUID();
|
||||||
|
|
||||||
val hlsPath = "/hls-${id}"
|
val hlsPath = "/hls-${id}"
|
||||||
@@ -1077,7 +1046,7 @@ class StateCasting {
|
|||||||
val ad = activeDevice ?: return listOf();
|
val ad = activeDevice ?: return listOf();
|
||||||
val proxyStreams = Settings.instance.casting.alwaysProxyRequests || ad !is FCastCastingDevice;
|
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 id = UUID.randomUUID();
|
||||||
|
|
||||||
val dashPath = "/dash-${id}"
|
val dashPath = "/dash-${id}"
|
||||||
@@ -1151,6 +1120,166 @@ class StateCasting {
|
|||||||
return listOf(dashUrl, videoUrl ?: "", audioUrl ?: "", subtitlesUrl ?: "", videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString());
|
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 {
|
private fun deviceFromCastingDeviceInfo(deviceInfo: CastingDeviceInfo): CastingDevice {
|
||||||
return when (deviceInfo.type) {
|
return when (deviceInfo.type) {
|
||||||
CastProtocolType.CHROMECAST -> {
|
CastProtocolType.CHROMECAST -> {
|
||||||
@@ -1245,7 +1374,7 @@ class StateCasting {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val newDevice = deviceFactory();
|
val newDevice = deviceFactory();
|
||||||
devices[name] = newDevice;
|
this.devices[name] = newDevice;
|
||||||
|
|
||||||
invokeEvents = {
|
invokeEvents = {
|
||||||
onDeviceAdded.emit(newDevice);
|
onDeviceAdded.emit(newDevice);
|
||||||
@@ -1259,7 +1388,7 @@ class StateCasting {
|
|||||||
fun enableDeveloper(enableDev: Boolean){
|
fun enableDeveloper(enableDev: Boolean){
|
||||||
_castServer.removeAllHandlers("dev");
|
_castServer.removeAllHandlers("dev");
|
||||||
if(enableDev) {
|
if(enableDev) {
|
||||||
_castServer.addHandler(HttpFuntionHandler("GET", "/dashPlayer") { context ->
|
_castServer.addHandler(HttpFunctionHandler("GET", "/dashPlayer") { context ->
|
||||||
if (context.query.containsKey("dashUrl")) {
|
if (context.query.containsKey("dashUrl")) {
|
||||||
val dashUrl = context.query["dashUrl"];
|
val dashUrl = context.query["dashUrl"];
|
||||||
val html = "<div>\n" +
|
val html = "<div>\n" +
|
||||||
@@ -1299,6 +1428,9 @@ class StateCasting {
|
|||||||
companion object {
|
companion object {
|
||||||
val instance: StateCasting = StateCasting();
|
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";
|
private val TAG = "StateCasting";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,10 +25,8 @@ import com.futo.platformplayer.states.StateDeveloper
|
|||||||
import com.futo.platformplayer.states.StatePlatform
|
import com.futo.platformplayer.states.StatePlatform
|
||||||
import com.google.gson.ExclusionStrategy
|
import com.google.gson.ExclusionStrategy
|
||||||
import com.google.gson.FieldAttributes
|
import com.google.gson.FieldAttributes
|
||||||
import com.google.gson.Gson
|
|
||||||
import com.google.gson.GsonBuilder
|
import com.google.gson.GsonBuilder
|
||||||
import com.google.gson.JsonArray
|
import com.google.gson.JsonArray
|
||||||
import com.google.gson.JsonElement
|
|
||||||
import com.google.gson.JsonParser
|
import com.google.gson.JsonParser
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
@@ -573,7 +571,7 @@ class DeveloperEndpoints(private val context: Context) {
|
|||||||
val resp = _client.get(body.url!!, body.headers);
|
val resp = _client.get(body.url!!, body.headers);
|
||||||
|
|
||||||
context.respondCode(200,
|
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"));
|
context.query.getOrDefault("CT", "text/plain"));
|
||||||
}
|
}
|
||||||
catch(ex: Exception) {
|
catch(ex: Exception) {
|
||||||
|
|||||||
@@ -104,6 +104,8 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
|
|||||||
super.show();
|
super.show();
|
||||||
Logger.i(TAG, "Dialog shown.");
|
Logger.i(TAG, "Dialog shown.");
|
||||||
|
|
||||||
|
StateCasting.instance.startDiscovering()
|
||||||
|
|
||||||
(_imageLoader.drawable as Animatable?)?.start();
|
(_imageLoader.drawable as Animatable?)?.start();
|
||||||
|
|
||||||
_devices.clear();
|
_devices.clear();
|
||||||
@@ -169,6 +171,7 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
|
|||||||
|
|
||||||
(_imageLoader.drawable as Animatable?)?.stop();
|
(_imageLoader.drawable as Animatable?)?.stop();
|
||||||
|
|
||||||
|
StateCasting.instance.stopDiscovering()
|
||||||
StateCasting.instance.onDeviceAdded.remove(this);
|
StateCasting.instance.onDeviceAdded.remove(this);
|
||||||
StateCasting.instance.onDeviceChanged.remove(this);
|
StateCasting.instance.onDeviceChanged.remove(this);
|
||||||
StateCasting.instance.onDeviceRemoved.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.AudioUrlSource
|
||||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
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.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.IHLSManifestSource
|
||||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
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.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.IPlatformVideoDetails
|
||||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
||||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideoDetails
|
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.constructs.Event1
|
||||||
import com.futo.platformplayer.exceptions.DownloadException
|
import com.futo.platformplayer.exceptions.DownloadException
|
||||||
import com.futo.platformplayer.helpers.FileHelper.Companion.sanitizeFileName
|
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.serializers.OffsetDateTimeNullableSerializer
|
||||||
import com.futo.platformplayer.states.StateDownloads
|
import com.futo.platformplayer.states.StateDownloads
|
||||||
import com.futo.platformplayer.states.StatePlatform
|
import com.futo.platformplayer.states.StatePlatform
|
||||||
|
import com.futo.platformplayer.states.StatePlugins
|
||||||
import com.futo.platformplayer.toHumanBitrate
|
import com.futo.platformplayer.toHumanBitrate
|
||||||
import com.futo.platformplayer.toHumanBytesSpeed
|
import com.futo.platformplayer.toHumanBytesSpeed
|
||||||
import hasAnySource
|
import hasAnySource
|
||||||
@@ -46,9 +57,12 @@ import kotlinx.coroutines.awaitAll
|
|||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.Contextual
|
||||||
|
import kotlinx.serialization.Transient
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
import java.lang.Thread.sleep
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
@@ -56,6 +70,7 @@ import java.util.concurrent.ForkJoinPool
|
|||||||
import java.util.concurrent.ForkJoinTask
|
import java.util.concurrent.ForkJoinTask
|
||||||
import java.util.concurrent.ThreadLocalRandom
|
import java.util.concurrent.ThreadLocalRandom
|
||||||
import kotlin.coroutines.resumeWithException
|
import kotlin.coroutines.resumeWithException
|
||||||
|
import kotlin.time.times
|
||||||
|
|
||||||
@kotlinx.serialization.Serializable
|
@kotlinx.serialization.Serializable
|
||||||
class VideoDownload {
|
class VideoDownload {
|
||||||
@@ -71,12 +86,50 @@ class VideoDownload {
|
|||||||
|
|
||||||
var targetPixelCount: Long? = null;
|
var targetPixelCount: Long? = null;
|
||||||
var targetBitrate: Long? = null;
|
var targetBitrate: Long? = null;
|
||||||
|
var targetVideoName: String? = null;
|
||||||
|
var targetAudioName: String? = null;
|
||||||
|
|
||||||
var videoSource: VideoUrlSource?;
|
var videoSource: VideoUrlSource?;
|
||||||
var audioSource: AudioUrlSource?;
|
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?;
|
var subtitleSource: SubtitleRawSource?;
|
||||||
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
|
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
|
||||||
var prepareTime: OffsetDateTime? = null;
|
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 progress: Double = 0.0;
|
||||||
var isCancelled = false;
|
var isCancelled = false;
|
||||||
|
|
||||||
@@ -118,14 +171,32 @@ class VideoDownload {
|
|||||||
this.subtitleSource = null;
|
this.subtitleSource = null;
|
||||||
this.targetPixelCount = targetPixelCount;
|
this.targetPixelCount = targetPixelCount;
|
||||||
this.targetBitrate = targetBitrate;
|
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.video = SerializedPlatformVideo.fromVideo(video);
|
||||||
this.videoDetails = SerializedPlatformVideoDetails.fromVideo(video, if (subtitleSource != null) listOf(subtitleSource) else listOf());
|
this.videoDetails = SerializedPlatformVideoDetails.fromVideo(video, if (subtitleSource != null) listOf(subtitleSource) else listOf());
|
||||||
this.videoSource = VideoUrlSource.fromUrlSource(videoSource);
|
this.videoSource = if(videoSource is IVideoUrlSource) VideoUrlSource.fromUrlSource(videoSource) else null;
|
||||||
this.audioSource = AudioUrlSource.fromUrlSource(audioSource);
|
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.subtitleSource = subtitleSource;
|
||||||
this.prepareTime = OffsetDateTime.now();
|
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 {
|
fun withGroup(groupType: String, groupID: String): VideoDownload {
|
||||||
@@ -156,9 +227,21 @@ class VideoDownload {
|
|||||||
|
|
||||||
suspend fun prepare(client: ManagedHttpClient) {
|
suspend fun prepare(client: ManagedHttpClient) {
|
||||||
Logger.i(TAG, "VideoDownload Prepare [${name}]");
|
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)
|
if(video == null && videoDetails == null)
|
||||||
throw IllegalStateException("Missing information for download to complete");
|
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");
|
throw IllegalStateException("No sources or query values set");
|
||||||
|
|
||||||
//Fetch full video object and determine source
|
//Fetch full video object and determine source
|
||||||
@@ -192,23 +275,35 @@ class VideoDownload {
|
|||||||
videoSources.add(source)
|
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");
|
// ?: throw IllegalStateException("Could not find a valid video source for video");
|
||||||
if(vsource != null) {
|
|
||||||
if (vsource is IVideoUrlSource)
|
if(vsource == null) {
|
||||||
videoSource = VideoUrlSource.fromUrlSource(vsource)
|
videoSource = null;
|
||||||
else
|
if(original.video.videoSources.size == 0)
|
||||||
throw DownloadException("Video source is not supported for downloading (yet)", false);
|
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) {
|
if(audioSource == null && targetBitrate != null) {
|
||||||
val audioSources = arrayListOf<IAudioSource>()
|
var audioSources = mutableListOf<IAudioSource>()
|
||||||
val video = original.video
|
val video = original.video
|
||||||
if (video is VideoUnMuxedSourceDescriptor) {
|
if (video is VideoUnMuxedSourceDescriptor) {
|
||||||
for (source in video.audioSources) {
|
for (source in video.audioSources) {
|
||||||
if (source is IHLSManifestSource) {
|
if (source is IHLSManifestAudioSource) {
|
||||||
try {
|
try {
|
||||||
val playlistResponse = client.get(source.url)
|
val playlistResponse = client.get(source.url)
|
||||||
if (playlistResponse.isOk) {
|
if (playlistResponse.isOk) {
|
||||||
@@ -226,25 +321,43 @@ class VideoDownload {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val asource = VideoHelper.selectBestAudioSource(audioSources, arrayOf(), null, targetBitrate)
|
var asource: IAudioSource? = null;
|
||||||
?: if(videoSource != null ) null
|
if(targetAudioName != null) {
|
||||||
else throw DownloadException("Could not find a valid video or audio source for download")
|
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)
|
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;
|
audioSource = null;
|
||||||
|
if(!original.video.isUnMuxed || original.video.videoSources.size == 0)
|
||||||
|
requireVideoSource = false;
|
||||||
|
}
|
||||||
else if(asource is IAudioUrlSource)
|
else if(asource is IAudioUrlSource)
|
||||||
audioSource = AudioUrlSource.fromUrlSource(asource)
|
audioSource = AudioUrlSource.fromUrlSource(asource)
|
||||||
|
else if(asource is JSSource && requiresLiveAudioSource)
|
||||||
|
audioSourceLive = asource;
|
||||||
else
|
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)
|
if(!isVideoDownloadReady)
|
||||||
throw DownloadException("No valid sources found for video/audio");
|
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 {
|
suspend fun download(context: Context, client: ManagedHttpClient, onProgress: ((Double) -> Unit)? = null) = coroutineScope {
|
||||||
Logger.i(TAG, "VideoDownload Download [${name}]");
|
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");
|
throw IllegalStateException("Missing information for download to complete");
|
||||||
val downloadDir = StateDownloads.instance.getDownloadsDirectory();
|
val downloadDir = StateDownloads.instance.getDownloadsDirectory();
|
||||||
|
|
||||||
@@ -253,12 +366,19 @@ class VideoDownload {
|
|||||||
|
|
||||||
if(isCancelled) throw CancellationException("Download got cancelled");
|
if(isCancelled) throw CancellationException("Download got cancelled");
|
||||||
|
|
||||||
if(videoSource != null) {
|
val actualVideoSource = if(requiresLiveVideoSource && videoSourceLive is IVideoSource)
|
||||||
videoFileName = "${videoDetails!!.id.value!!} [${videoSource!!.width}x${videoSource!!.height}].${videoContainerToExtension(videoSource!!.container)}".sanitizeFileName();
|
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;
|
videoFilePath = File(downloadDir, videoFileName!!).absolutePath;
|
||||||
}
|
}
|
||||||
if(audioSource != null) {
|
if(actualAudioSource != null) {
|
||||||
audioFileName = "${videoDetails!!.id.value!!} [${audioSource!!.language}-${audioSource!!.bitrate}].${audioContainerToExtension(audioSource!!.container)}".sanitizeFileName();
|
audioFileName = "${videoDetails!!.id.value!!} [${actualAudioSource!!.language}-${actualAudioSource!!.bitrate}].${audioContainerToExtension(actualAudioSource!!.container)}".sanitizeFileName();
|
||||||
audioFilePath = File(downloadDir, audioFileName!!).absolutePath;
|
audioFilePath = File(downloadDir, audioFileName!!).absolutePath;
|
||||||
}
|
}
|
||||||
if(subtitleSource != null) {
|
if(subtitleSource != null) {
|
||||||
@@ -273,10 +393,11 @@ class VideoDownload {
|
|||||||
var lastAudioLength: Long = 0;
|
var lastAudioLength: Long = 0;
|
||||||
var lastAudioRead: Long = 0;
|
var lastAudioRead: Long = 0;
|
||||||
|
|
||||||
if(videoSource != null) {
|
if(actualVideoSource != null) {
|
||||||
sourcesToDownload.add(async {
|
sourcesToDownload.add(async {
|
||||||
Logger.i(TAG, "Started downloading video");
|
Logger.i(TAG, "Started downloading video");
|
||||||
|
|
||||||
|
var lastEmit = 0L;
|
||||||
val progressCallback = { length: Long, totalRead: Long, speed: Long ->
|
val progressCallback = { length: Long, totalRead: Long, speed: Long ->
|
||||||
synchronized(progressLock) {
|
synchronized(progressLock) {
|
||||||
lastVideoLength = length;
|
lastVideoLength = length;
|
||||||
@@ -289,23 +410,34 @@ class VideoDownload {
|
|||||||
val total = lastVideoRead + lastAudioRead;
|
val total = lastVideoRead + lastAudioRead;
|
||||||
if(totalLength > 0) {
|
if(totalLength > 0) {
|
||||||
val percentage = (total / totalLength.toDouble());
|
val percentage = (total / totalLength.toDouble());
|
||||||
onProgress?.invoke(percentage);
|
|
||||||
progress = 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) {
|
if(actualVideoSource is IVideoUrlSource)
|
||||||
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Video", client, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
|
videoFileSize = when (videoSource!!.container) {
|
||||||
else -> downloadFileSource("Video", client, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
|
"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 {
|
sourcesToDownload.add(async {
|
||||||
Logger.i(TAG, "Started downloading audio");
|
Logger.i(TAG, "Started downloading audio");
|
||||||
|
|
||||||
|
var lastEmit = 0L;
|
||||||
val progressCallback = { length: Long, totalRead: Long, speed: Long ->
|
val progressCallback = { length: Long, totalRead: Long, speed: Long ->
|
||||||
synchronized(progressLock) {
|
synchronized(progressLock) {
|
||||||
lastAudioLength = length;
|
lastAudioLength = length;
|
||||||
@@ -318,17 +450,27 @@ class VideoDownload {
|
|||||||
val total = lastVideoRead + lastAudioRead;
|
val total = lastVideoRead + lastAudioRead;
|
||||||
if(totalLength > 0) {
|
if(totalLength > 0) {
|
||||||
val percentage = (total / totalLength.toDouble());
|
val percentage = (total / totalLength.toDouble());
|
||||||
onProgress?.invoke(percentage);
|
|
||||||
progress = 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) {
|
if(actualAudioSource is IAudioUrlSource)
|
||||||
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Audio", client, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
|
audioFileSize = when (audioSource!!.container) {
|
||||||
else -> downloadFileSource("Audio", client, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
|
"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) {
|
if (subtitleSource != null) {
|
||||||
@@ -398,15 +540,20 @@ class VideoDownload {
|
|||||||
|
|
||||||
Logger.i(TAG, "Download '$name' segment $index Sequential");
|
Logger.i(TAG, "Download '$name' segment $index Sequential");
|
||||||
val segmentFile = File(context.cacheDir, "segment-${UUID.randomUUID()}")
|
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 segmentLength = downloadSource_Sequential(client, outputStream, segment.uri) { segmentLength, totalRead, lastSpeed ->
|
||||||
val averageSegmentLength = if (index == 0) segmentLength else downloadedTotalLength / index
|
val averageSegmentLength = if (index == 0) segmentLength else downloadedTotalLength / index
|
||||||
val expectedTotalLength = averageSegmentLength * (variantPlaylist.segments.size - 1) + segmentLength
|
val expectedTotalLength = averageSegmentLength * (variantPlaylist.segments.size - 1) + segmentLength
|
||||||
onProgress(expectedTotalLength, downloadedTotalLength + totalRead, lastSpeed)
|
onProgress(expectedTotalLength, downloadedTotalLength + totalRead, lastSpeed)
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadedTotalLength += segmentLength
|
||||||
|
} finally {
|
||||||
|
outputStream.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadedTotalLength += segmentLength
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.i(TAG, "Combining segments into $targetFile");
|
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 {
|
private fun downloadFileSource(name: String, client: ManagedHttpClient, videoUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||||
if(targetFile.exists())
|
if(targetFile.exists())
|
||||||
targetFile.delete();
|
targetFile.delete();
|
||||||
@@ -484,17 +711,25 @@ class VideoDownload {
|
|||||||
|
|
||||||
try{
|
try{
|
||||||
val head = client.tryHead(videoUrl);
|
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"))
|
if(Settings.instance.downloads.byteRangeDownload && head?.containsKey("accept-ranges") == true && head.containsKey("content-length"))
|
||||||
{
|
{
|
||||||
val concurrency = Settings.instance.downloads.getByteRangeThreadCount();
|
val maxParallel = if(relatedPlugin != null && relatedPlugin.config.maxDownloadParallelism > 0)
|
||||||
Logger.i(TAG, "Download $name ByteRange Parallel (${concurrency})");
|
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();
|
sourceLength = head["content-length"]!!.toLong();
|
||||||
onProgress(sourceLength, 0, 0);
|
onProgress(sourceLength, 0, 0);
|
||||||
downloadSource_Ranges(name, client, fileStream, videoUrl, sourceLength, 1024*512, concurrency, onProgress);
|
downloadSource_Ranges(name, client, fileStream, videoUrl, sourceLength, 1024*512, concurrency, onProgress);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
Logger.i(TAG, "Download $name Sequential");
|
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");
|
Logger.i(TAG, "$name downloadSource Finished");
|
||||||
@@ -518,17 +753,19 @@ class VideoDownload {
|
|||||||
return sourceLength!!;
|
return sourceLength!!;
|
||||||
}
|
}
|
||||||
private fun downloadSource_Sequential(client: ManagedHttpClient, fileStream: FileOutputStream, url: String, onProgress: (Long, Long, Long) -> Unit): Long {
|
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;
|
var lastProgressCount: Int = 0;
|
||||||
val speedRate: Int = 4096 * 25;
|
val speedRate: Int = 4096 * 5;
|
||||||
var readSinceLastSpeedTest: Long = 0;
|
var readSinceLastSpeedTest: Long = 0;
|
||||||
var timeSinceLastSpeedTest: Long = System.currentTimeMillis();
|
var timeSinceLastSpeedTest: Long = System.currentTimeMillis();
|
||||||
|
|
||||||
var lastSpeed: Long = 0;
|
var lastSpeed: Long = 0;
|
||||||
|
|
||||||
val result = client.get(url);
|
val result = client.get(url);
|
||||||
if (!result.isOk)
|
if (!result.isOk) {
|
||||||
|
result.body?.close()
|
||||||
throw IllegalStateException("Failed to download source. Web[${result.code}] Error");
|
throw IllegalStateException("Failed to download source. Web[${result.code}] Error");
|
||||||
|
}
|
||||||
if (result.body == null)
|
if (result.body == null)
|
||||||
throw IllegalStateException("Failed to download source. Web[${result.code}] No response");
|
throw IllegalStateException("Failed to download source. Web[${result.code}] No response");
|
||||||
|
|
||||||
@@ -536,41 +773,114 @@ class VideoDownload {
|
|||||||
val sourceStream = result.body.byteStream();
|
val sourceStream = result.body.byteStream();
|
||||||
|
|
||||||
var totalRead: Long = 0;
|
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 {
|
fileStream.write(buffer, 0, read);
|
||||||
read = sourceStream.read(buffer);
|
|
||||||
if (read < 0)
|
|
||||||
break;
|
|
||||||
|
|
||||||
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 (isCancelled)
|
||||||
if (totalRead / progressRate > lastProgressCount) {
|
throw CancellationException("Cancelled");
|
||||||
onProgress(sourceLength, totalRead, lastSpeed);
|
} while (read > 0);
|
||||||
lastProgressCount++;
|
} finally {
|
||||||
}
|
sourceStream.close()
|
||||||
if (readSinceLastSpeedTest > speedRate) {
|
result.body.close()
|
||||||
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);
|
|
||||||
|
|
||||||
lastSpeed = 0;
|
|
||||||
onProgress(sourceLength, totalRead, 0);
|
onProgress(sourceLength, totalRead, 0);
|
||||||
return sourceLength;
|
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) {
|
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;
|
val progressRate: Int = 4096 * 5;
|
||||||
var lastProgressCount: Int = 0;
|
var lastProgressCount: Int = 0;
|
||||||
@@ -643,23 +953,47 @@ class VideoDownload {
|
|||||||
return tasks.map { it.get() };
|
return tasks.map { it.get() };
|
||||||
}
|
}
|
||||||
private fun requestByteRange(client: ManagedHttpClient, url: String, rangeStart: Long, rangeEnd: Long): Triple<ByteArray, Long, Long> {
|
private fun requestByteRange(client: ManagedHttpClient, url: String, rangeStart: Long, rangeEnd: Long): Triple<ByteArray, Long, Long> {
|
||||||
val toRead = rangeEnd - rangeStart;
|
var retryCount = 0
|
||||||
val req = client.get(url, mutableMapOf(Pair("Range", "bytes=${rangeStart}-${rangeEnd}")));
|
var lastException: Throwable? = null
|
||||||
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();
|
|
||||||
|
|
||||||
if(read < toRead)
|
while (retryCount <= 3) {
|
||||||
throw IllegalStateException("Byte-Range request attempted to provide less (${read} < ${toRead})");
|
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() {
|
fun validate() {
|
||||||
Logger.i(TAG, "VideoDownload Validate [${name}]");
|
Logger.i(TAG, "VideoDownload Validate [${name}]");
|
||||||
if(videoSource != null) {
|
if(videoSourceToUse != null) {
|
||||||
if(videoFilePath == null)
|
if(videoFilePath == null)
|
||||||
throw IllegalStateException("Missing video file name after download");
|
throw IllegalStateException("Missing video file name after download");
|
||||||
val expectedFile = File(videoFilePath!!);
|
val expectedFile = File(videoFilePath!!);
|
||||||
@@ -670,7 +1004,7 @@ class VideoDownload {
|
|||||||
throw IllegalStateException("Expected size [${videoFileSize}], but found ${expectedFile.length()}");
|
throw IllegalStateException("Expected size [${videoFileSize}], but found ${expectedFile.length()}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if(audioSource != null) {
|
if(audioSourceToUse != null) {
|
||||||
if(audioFilePath == null)
|
if(audioFilePath == null)
|
||||||
throw IllegalStateException("Missing audio file name after download");
|
throw IllegalStateException("Missing audio file name after download");
|
||||||
val expectedFile = File(audioFilePath!!);
|
val expectedFile = File(audioFilePath!!);
|
||||||
@@ -692,15 +1026,15 @@ class VideoDownload {
|
|||||||
fun complete() {
|
fun complete() {
|
||||||
Logger.i(TAG, "VideoDownload Complete [${name}]");
|
Logger.i(TAG, "VideoDownload Complete [${name}]");
|
||||||
val existing = StateDownloads.instance.getCachedVideo(id);
|
val existing = StateDownloads.instance.getCachedVideo(id);
|
||||||
val localVideoSource = videoFilePath?.let { LocalVideoSource.fromSource(videoSource!!, it, videoFileSize ?: 0) };
|
val localVideoSource = videoFilePath?.let { LocalVideoSource.fromSource(videoSourceToUse!!, it, videoFileSize ?: 0) };
|
||||||
val localAudioSource = audioFilePath?.let { LocalAudioSource.fromSource(audioSource!!, it, audioFileSize ?: 0) };
|
val localAudioSource = audioFilePath?.let { LocalAudioSource.fromSource(audioSourceToUse!!, it, audioFileSize ?: 0) };
|
||||||
val localSubtitleSource = subtitleFilePath?.let { LocalSubtitleSource.fromSource(subtitleSource!!, it) };
|
val localSubtitleSource = subtitleFilePath?.let { LocalSubtitleSource.fromSource(subtitleSource!!, it) };
|
||||||
|
|
||||||
if(localVideoSource != null && videoSource != null && videoSource is IStreamMetaDataSource)
|
if(localVideoSource != null && videoSourceToUse != null && videoSourceToUse is IStreamMetaDataSource)
|
||||||
localVideoSource.streamMetaData = (videoSource as IStreamMetaDataSource).streamMetaData;
|
localVideoSource.streamMetaData = (videoSourceToUse as IStreamMetaDataSource).streamMetaData;
|
||||||
|
|
||||||
if(localAudioSource != null && audioSource != null && audioSource is IStreamMetaDataSource)
|
if(localAudioSource != null && audioSourceToUse != null && audioSourceToUse is IStreamMetaDataSource)
|
||||||
localAudioSource.streamMetaData = (audioSource as IStreamMetaDataSource).streamMetaData;
|
localAudioSource.streamMetaData = (audioSourceToUse as IStreamMetaDataSource).streamMetaData;
|
||||||
|
|
||||||
if(existing != null) {
|
if(existing != null) {
|
||||||
existing.videoSerialized = videoDetails!!;
|
existing.videoSerialized = videoDetails!!;
|
||||||
@@ -757,6 +1091,9 @@ class VideoDownload {
|
|||||||
const val GROUP_PLAYLIST = "Playlist";
|
const val GROUP_PLAYLIST = "Playlist";
|
||||||
const val GROUP_WATCHLATER= "WatchLater";
|
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? {
|
fun videoContainerToExtension(container: String): String? {
|
||||||
if (container.contains("video/mp4") || container == "application/vnd.apple.mpegurl")
|
if (container.contains("video/mp4") || container == "application/vnd.apple.mpegurl")
|
||||||
return "mp4";
|
return "mp4";
|
||||||
@@ -803,4 +1140,27 @@ class VideoDownload {
|
|||||||
return "subtitle";
|
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.exceptions.JavetExecutionException
|
||||||
import com.caoccao.javet.interop.V8Host
|
import com.caoccao.javet.interop.V8Host
|
||||||
import com.caoccao.javet.interop.V8Runtime
|
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.V8Value
|
||||||
import com.caoccao.javet.values.primitive.V8ValueBoolean
|
import com.caoccao.javet.values.primitive.V8ValueBoolean
|
||||||
import com.caoccao.javet.values.primitive.V8ValueInteger
|
import com.caoccao.javet.values.primitive.V8ValueInteger
|
||||||
@@ -133,9 +135,10 @@ class V8Plugin {
|
|||||||
synchronized(_runtimeLock) {
|
synchronized(_runtimeLock) {
|
||||||
if (_runtime != null)
|
if (_runtime != null)
|
||||||
return;
|
return;
|
||||||
|
//V8RuntimeOptions.V8_FLAGS.setUseStrict(true);
|
||||||
val host = V8Host.getV8Instance();
|
val host = V8Host.getV8Instance();
|
||||||
val options = host.jsRuntimeType.getRuntimeOptions();
|
val options = host.jsRuntimeType.getRuntimeOptions();
|
||||||
|
|
||||||
_runtime = host.createV8Runtime(options);
|
_runtime = host.createV8Runtime(options);
|
||||||
if (!host.isIsolateCreated)
|
if (!host.isIsolateCreated)
|
||||||
throw IllegalStateException("Isolate not created");
|
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.V8Function
|
||||||
import com.caoccao.javet.annotations.V8Property
|
import com.caoccao.javet.annotations.V8Property
|
||||||
|
import com.caoccao.javet.values.V8Value
|
||||||
import com.futo.platformplayer.BuildConfig
|
import com.futo.platformplayer.BuildConfig
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.states.StateDeveloper
|
import com.futo.platformplayer.states.StateDeveloper
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
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.SourcePluginConfig
|
||||||
import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient
|
import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient
|
||||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||||
@@ -49,9 +51,20 @@ class PackageBridge : V8Package {
|
|||||||
fun buildFlavor(): String {
|
fun buildFlavor(): String {
|
||||||
return BuildConfig.FLAVOR;
|
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
|
@V8Function
|
||||||
fun toast(str: String) {
|
fun toast(str: String) {
|
||||||
|
Logger.i(TAG, "Plugin toast [${_config.name}]: ${str}");
|
||||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||||
try {
|
try {
|
||||||
UIDialogs.toast(str);
|
UIDialogs.toast(str);
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ class PackageDOMParser : V8Package {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@V8Property
|
@V8Property
|
||||||
|
fun parentElement(): DOMNode? {
|
||||||
|
return parentNode();
|
||||||
|
}
|
||||||
|
@V8Property
|
||||||
fun attributes(): Map<String, String> = _element.attributes().associate { Pair(it.key, it.value) }
|
fun attributes(): Map<String, String> = _element.attributes().associate { Pair(it.key, it.value) }
|
||||||
@V8Property
|
@V8Property
|
||||||
fun innerHTML(): String = _element.html();
|
fun innerHTML(): String = _element.html();
|
||||||
@@ -76,6 +80,8 @@ class PackageDOMParser : V8Package {
|
|||||||
@V8Property
|
@V8Property
|
||||||
fun textContent(): String = _element.text();
|
fun textContent(): String = _element.text();
|
||||||
@V8Property
|
@V8Property
|
||||||
|
fun tagName(): String = _element.tagName().uppercase();
|
||||||
|
@V8Property
|
||||||
fun text(): String = _element.text().ifEmpty { data() };
|
fun text(): String = _element.text().ifEmpty { data() };
|
||||||
@V8Property
|
@V8Property
|
||||||
fun data(): String = _element.data();
|
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.enums.V8ProxyMode
|
||||||
import com.caoccao.javet.interop.V8Runtime
|
import com.caoccao.javet.interop.V8Runtime
|
||||||
import com.caoccao.javet.values.V8Value
|
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.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.http.ManagedHttpClient
|
||||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||||
import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient
|
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.IV8Convertable
|
||||||
import com.futo.platformplayer.engine.internal.V8BindObject
|
import com.futo.platformplayer.engine.internal.V8BindObject
|
||||||
import com.futo.platformplayer.logging.Logger
|
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 java.net.SocketTimeoutException
|
||||||
import kotlin.streams.asSequence
|
import kotlin.streams.asSequence
|
||||||
|
|
||||||
@@ -64,33 +71,44 @@ class PackageHttp: V8Package {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@V8Function
|
@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)
|
return if(useAuth)
|
||||||
_packageClientAuth.request(method, url, headers)
|
_packageClientAuth.request(method, url, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING)
|
||||||
else
|
else
|
||||||
_packageClient.request(method, url, headers);
|
_packageClient.request(method, url, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING);
|
||||||
}
|
}
|
||||||
|
|
||||||
@V8Function
|
@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)
|
return if(useAuth)
|
||||||
_packageClientAuth.requestWithBody(method, url, body, headers)
|
_packageClientAuth.requestWithBody(method, url, body, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING)
|
||||||
else
|
else
|
||||||
_packageClient.requestWithBody(method, url, body, headers);
|
_packageClient.requestWithBody(method, url, body, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING);
|
||||||
}
|
}
|
||||||
@V8Function
|
@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)
|
return if(useAuth)
|
||||||
_packageClientAuth.GET(url, headers)
|
_packageClientAuth.GET(url, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING)
|
||||||
else
|
else
|
||||||
_packageClient.GET(url, headers);
|
_packageClient.GET(url, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
|
||||||
}
|
}
|
||||||
@V8Function
|
@V8Function
|
||||||
fun POST(url: String, body: String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false) : BridgeHttpResponse {
|
fun POST(url: String, body: Any, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false, useByteResponse: Boolean = false) : IBridgeHttpResponse {
|
||||||
return if(useAuth)
|
|
||||||
_packageClientAuth.POST(url, body, headers)
|
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
|
else
|
||||||
_packageClient.POST(url, body, headers);
|
throw NotImplementedError("Body type " + body?.javaClass?.name?.toString() + " not implemented for POST");
|
||||||
}
|
}
|
||||||
|
|
||||||
@V8Function
|
@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
|
@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;
|
val isOk = code >= 200 && code < 300;
|
||||||
|
|
||||||
override fun toV8(runtime: V8Runtime): V8Value? {
|
override fun toV8(runtime: V8Runtime): V8Value? {
|
||||||
@@ -125,6 +154,37 @@ class PackageHttp: V8Package {
|
|||||||
return obj;
|
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.
|
//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)
|
@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
|
fun POST(url: String, body: String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false) : BatchBuilder
|
||||||
= clientPOST(_package.getDefaultClient(useAuth), url, body, headers);
|
= 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
|
//Client-specific
|
||||||
|
|
||||||
@V8Function
|
@V8Function
|
||||||
@@ -169,12 +235,14 @@ class PackageHttp: V8Package {
|
|||||||
|
|
||||||
//Finalizer
|
//Finalizer
|
||||||
@V8Function
|
@V8Function
|
||||||
fun execute(): List<BridgeHttpResponse> {
|
fun execute(): List<IBridgeHttpResponse?> {
|
||||||
return _reqs.parallelStream().map {
|
return _reqs.parallelStream().map {
|
||||||
|
if(it.second.method == "DUMMY")
|
||||||
|
return@map null;
|
||||||
if(it.second.body != 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
|
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()
|
.asSequence()
|
||||||
.toList();
|
.toList();
|
||||||
@@ -230,65 +298,116 @@ class PackageHttp: V8Package {
|
|||||||
if(_client is JSHttpClient)
|
if(_client is JSHttpClient)
|
||||||
_client.doAllowNewCookies = allow;
|
_client.doAllowNewCookies = allow;
|
||||||
}
|
}
|
||||||
|
@V8Function
|
||||||
|
fun setTimeout(timeoutMs: Int) {
|
||||||
|
if(_client is JSHttpClient) {
|
||||||
|
_client.setTimeout(timeoutMs.toLong());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@V8Function
|
@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);
|
applyDefaultHeaders(headers);
|
||||||
return logExceptions {
|
return logExceptions {
|
||||||
return@logExceptions catchHttp {
|
return@logExceptions catchHttp {
|
||||||
val client = _client;
|
val client = _client;
|
||||||
//logRequest(method, url, headers, null);
|
//logRequest(method, url, headers, null);
|
||||||
val resp = client.requestMethod(method, url, headers);
|
val resp = client.requestMethod(method, url, headers);
|
||||||
val responseBody = resp.body?.string();
|
|
||||||
//logResponse(method, url, resp.code, resp.headers, responseBody);
|
//logResponse(method, url, resp.code, resp.headers, responseBody);
|
||||||
return@catchHttp BridgeHttpResponse(resp.url, resp.code, responseBody, sanitizeResponseHeaders(resp.headers,
|
return@catchHttp when(returnType) {
|
||||||
_client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess));
|
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
|
@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);
|
applyDefaultHeaders(headers);
|
||||||
return logExceptions {
|
return logExceptions {
|
||||||
catchHttp {
|
catchHttp {
|
||||||
val client = _client;
|
val client = _client;
|
||||||
//logRequest(method, url, headers, body);
|
//logRequest(method, url, headers, body);
|
||||||
val resp = client.requestMethod(method, url, body, headers);
|
val resp = client.requestMethod(method, url, body, headers);
|
||||||
val responseBody = resp.body?.string();
|
|
||||||
//logResponse(method, url, resp.code, resp.headers, responseBody);
|
//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
|
@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);
|
applyDefaultHeaders(headers);
|
||||||
return logExceptions {
|
return logExceptions {
|
||||||
catchHttp {
|
catchHttp {
|
||||||
val client = _client;
|
val client = _client;
|
||||||
//logRequest("GET", url, headers, null);
|
//logRequest("GET", url, headers, null);
|
||||||
val resp = client.get(url, headers);
|
val resp = client.get(url, headers);
|
||||||
val responseBody = resp.body?.string();
|
//val responseBody = resp.body?.string();
|
||||||
//logResponse("GET", url, resp.code, resp.headers, responseBody);
|
//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
|
@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);
|
applyDefaultHeaders(headers);
|
||||||
return logExceptions {
|
return logExceptions {
|
||||||
catchHttp {
|
catchHttp {
|
||||||
val client = _client;
|
val client = _client;
|
||||||
//logRequest("POST", url, headers, body);
|
//logRequest("POST", url, headers, body);
|
||||||
val resp = client.post(url, body, headers);
|
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);
|
//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{
|
try{
|
||||||
return handle();
|
return handle();
|
||||||
}
|
}
|
||||||
//Forward timeouts
|
//Forward timeouts
|
||||||
catch(ex: SocketTimeoutException) {
|
catch(ex: SocketTimeoutException) {
|
||||||
return BridgeHttpResponse("", 408, null);
|
return BridgeHttpStringResponse("", 408, null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -514,20 +633,25 @@ class PackageHttp: V8Package {
|
|||||||
val url: String,
|
val url: String,
|
||||||
val headers: MutableMap<String, String>,
|
val headers: MutableMap<String, String>,
|
||||||
val body: String? = null,
|
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{
|
try{
|
||||||
return handle();
|
return handle();
|
||||||
}
|
}
|
||||||
//Forward timeouts
|
//Forward timeouts
|
||||||
catch(ex: SocketTimeoutException) {
|
catch(ex: SocketTimeoutException) {
|
||||||
return BridgeHttpResponse("", 408, null);
|
return BridgeHttpStringResponse("", 408, null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
enum class ReturnType(val value: Int) {
|
||||||
|
STRING(0),
|
||||||
|
BYTES(1);
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "PackageHttp";
|
private const val TAG = "PackageHttp";
|
||||||
|
|||||||
+32
-6
@@ -16,6 +16,7 @@ import androidx.core.animation.doOnEnd
|
|||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.Settings
|
import com.futo.platformplayer.Settings
|
||||||
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.activities.MainActivity
|
import com.futo.platformplayer.activities.MainActivity
|
||||||
import com.futo.platformplayer.activities.SettingsActivity
|
import com.futo.platformplayer.activities.SettingsActivity
|
||||||
import com.futo.platformplayer.dp
|
import com.futo.platformplayer.dp
|
||||||
@@ -222,6 +223,13 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
|||||||
buttons.removeAt(faqIndex)
|
buttons.removeAt(faqIndex)
|
||||||
buttons.add(if (buttons.size == 1) 1 else 0, button)
|
buttons.add(if (buttons.size == 1) 1 else 0, button)
|
||||||
}
|
}
|
||||||
|
//Force privacy to be third
|
||||||
|
val privacyIndex = buttons.indexOfFirst { b -> b.id == 96 };
|
||||||
|
if (privacyIndex != -1) {
|
||||||
|
val button = buttons[privacyIndex]
|
||||||
|
buttons.removeAt(privacyIndex)
|
||||||
|
buttons.add(if (buttons.size == 2) 2 else 1, button)
|
||||||
|
}
|
||||||
|
|
||||||
for (data in buttons) {
|
for (data in buttons) {
|
||||||
val button = MenuButton(context, data, _fragment, true);
|
val button = MenuButton(context, data, _fragment, true);
|
||||||
@@ -302,9 +310,6 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
|||||||
if (!StatePayment.instance.hasPaid) {
|
if (!StatePayment.instance.hasPaid) {
|
||||||
newCurrentButtonDefinitions.add(ButtonDefinition(98, R.drawable.ic_paid, R.drawable.ic_paid_filled, R.string.buy, canToggle = false, { it.currentMain is BuyFragment }, { it.navigate<BuyFragment>() }))
|
newCurrentButtonDefinitions.add(ButtonDefinition(98, R.drawable.ic_paid, R.drawable.ic_paid_filled, R.string.buy, canToggle = false, { it.currentMain is BuyFragment }, { it.navigate<BuyFragment>() }))
|
||||||
}
|
}
|
||||||
newCurrentButtonDefinitions.add(ButtonDefinition(97, R.drawable.ic_quiz, R.drawable.ic_quiz_fill, R.string.faq, canToggle = false, { false }, {
|
|
||||||
it.navigate<BrowserFragment>(Settings.URL_FAQ);
|
|
||||||
}))
|
|
||||||
|
|
||||||
//Add conditional buttons here, when you add a conditional button, be sure to add the register and unregister events for when the button needs to be updated
|
//Add conditional buttons here, when you add a conditional button, be sure to add the register and unregister events for when the button needs to be updated
|
||||||
|
|
||||||
@@ -350,16 +355,37 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
|||||||
|
|
||||||
//Add configurable buttons here
|
//Add configurable buttons here
|
||||||
var buttonDefinitions = listOf(
|
var buttonDefinitions = listOf(
|
||||||
ButtonDefinition(0, R.drawable.ic_home, R.drawable.ic_home_filled, R.string.home, canToggle = true, { it.currentMain is HomeFragment }, { it.navigate<HomeFragment>() }),
|
ButtonDefinition(0, R.drawable.ic_home, R.drawable.ic_home_filled, R.string.home, canToggle = true, { it.currentMain is HomeFragment }, {
|
||||||
|
val currentMain = it.currentMain
|
||||||
|
if (currentMain is HomeFragment) {
|
||||||
|
currentMain.scrollToTop(false)
|
||||||
|
currentMain.reloadFeed()
|
||||||
|
} else {
|
||||||
|
it.navigate<HomeFragment>()
|
||||||
|
}
|
||||||
|
}),
|
||||||
ButtonDefinition(1, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscriptions, canToggle = true, { it.currentMain is SubscriptionsFeedFragment }, { it.navigate<SubscriptionsFeedFragment>() }),
|
ButtonDefinition(1, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscriptions, canToggle = true, { it.currentMain is SubscriptionsFeedFragment }, { it.navigate<SubscriptionsFeedFragment>() }),
|
||||||
ButtonDefinition(2, R.drawable.ic_creators, R.drawable.ic_creators_filled, R.string.creators, canToggle = false, { it.currentMain is CreatorsFragment }, { it.navigate<CreatorsFragment>() }),
|
ButtonDefinition(2, R.drawable.ic_creators, R.drawable.ic_creators_filled, R.string.creators, canToggle = false, { it.currentMain is CreatorsFragment }, { it.navigate<CreatorsFragment>() }),
|
||||||
ButtonDefinition(3, R.drawable.ic_sources, R.drawable.ic_sources_filled, R.string.sources, canToggle = false, { it.currentMain is SourcesFragment }, { it.navigate<SourcesFragment>() }),
|
ButtonDefinition(3, R.drawable.ic_sources, R.drawable.ic_sources_filled, R.string.sources, canToggle = true, { it.currentMain is SourcesFragment }, { it.navigate<SourcesFragment>() }),
|
||||||
ButtonDefinition(4, R.drawable.ic_playlist, R.drawable.ic_playlist_filled, R.string.playlists, canToggle = false, { it.currentMain is PlaylistsFragment }, { it.navigate<PlaylistsFragment>() }),
|
ButtonDefinition(4, R.drawable.ic_playlist, R.drawable.ic_playlist_filled, R.string.playlists, canToggle = false, { it.currentMain is PlaylistsFragment }, { it.navigate<PlaylistsFragment>() }),
|
||||||
ButtonDefinition(5, R.drawable.ic_history, R.drawable.ic_history, R.string.history, canToggle = false, { it.currentMain is HistoryFragment }, { it.navigate<HistoryFragment>() }),
|
ButtonDefinition(5, R.drawable.ic_history, R.drawable.ic_history, R.string.history, canToggle = false, { it.currentMain is HistoryFragment }, { it.navigate<HistoryFragment>() }),
|
||||||
ButtonDefinition(6, R.drawable.ic_download, R.drawable.ic_download, R.string.downloads, canToggle = false, { it.currentMain is DownloadsFragment }, { it.navigate<DownloadsFragment>() }),
|
ButtonDefinition(6, R.drawable.ic_download, R.drawable.ic_download, R.string.downloads, canToggle = false, { it.currentMain is DownloadsFragment }, { it.navigate<DownloadsFragment>() }),
|
||||||
ButtonDefinition(8, R.drawable.ic_chat, R.drawable.ic_chat_filled, R.string.comments, canToggle = true, { it.currentMain is CommentsFragment }, { it.navigate<CommentsFragment>() }),
|
ButtonDefinition(8, R.drawable.ic_chat, R.drawable.ic_chat_filled, R.string.comments, canToggle = true, { it.currentMain is CommentsFragment }, { it.navigate<CommentsFragment>() }),
|
||||||
ButtonDefinition(9, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscription_group_menu, canToggle = true, { it.currentMain is SubscriptionGroupListFragment }, { it.navigate<SubscriptionGroupListFragment>() }),
|
ButtonDefinition(9, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscription_group_menu, canToggle = true, { it.currentMain is SubscriptionGroupListFragment }, { it.navigate<SubscriptionGroupListFragment>() }),
|
||||||
ButtonDefinition(10, R.drawable.ic_help_square, R.drawable.ic_help_square_fill, R.string.tutorials, canToggle = true, { it.currentMain is TutorialFragment }, { it.navigate<TutorialFragment>() }),
|
ButtonDefinition(10, R.drawable.ic_help_square, R.drawable.ic_help_square_fill, R.string.tutorials, canToggle = true, { it.currentMain is TutorialFragment }, { it.navigate<TutorialFragment>() }),
|
||||||
|
ButtonDefinition(11, R.drawable.ic_quiz, R.drawable.ic_quiz_fill, R.string.faq, canToggle = true, { false }, {
|
||||||
|
it.navigate<BrowserFragment>(Settings.URL_FAQ)
|
||||||
|
}),
|
||||||
|
ButtonDefinition(12, R.drawable.ic_disabled_visible, R.drawable.ic_disabled_visible, R.string.privacy_mode, canToggle = true, { false }, {
|
||||||
|
UIDialogs.showDialog(it.requireContext(), R.drawable.ic_disabled_visible_purple, "Privacy Mode",
|
||||||
|
"All requests will be processed anonymously (unauthenticated), playback and history tracking will be disabled.\n\nTap the icon to disable.", null, 0,
|
||||||
|
UIDialogs.Action("Cancel", {
|
||||||
|
StateApp.instance.setPrivacyMode(false);
|
||||||
|
}, UIDialogs.ActionStyle.NONE),
|
||||||
|
UIDialogs.Action("Enable", {
|
||||||
|
StateApp.instance.setPrivacyMode(true);
|
||||||
|
}, UIDialogs.ActionStyle.PRIMARY));
|
||||||
|
}),
|
||||||
ButtonDefinition(7, R.drawable.ic_settings, R.drawable.ic_settings_filled, R.string.settings, canToggle = false, { false }, {
|
ButtonDefinition(7, R.drawable.ic_settings, R.drawable.ic_settings_filled, R.string.settings, canToggle = false, { false }, {
|
||||||
val c = it.context ?: return@ButtonDefinition;
|
val c = it.context ?: return@ButtonDefinition;
|
||||||
Logger.i(TAG, "settings preventPictureInPicture()");
|
Logger.i(TAG, "settings preventPictureInPicture()");
|
||||||
@@ -370,7 +396,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
|||||||
c.overridePendingTransition(R.anim.slide_in_up, R.anim.slide_darken);
|
c.overridePendingTransition(R.anim.slide_in_up, R.anim.slide_darken);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
//98 is reversed for buy button
|
//98 is reserved for buy button
|
||||||
//99 is reserved for more button
|
//99 is reserved for more button
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -221,8 +221,8 @@ class CommentsFragment : MainFragment() {
|
|||||||
|
|
||||||
Logger.i(TAG, "onAuthorClick: " + c.author.id.value);
|
Logger.i(TAG, "onAuthorClick: " + c.author.id.value);
|
||||||
if(c.author.id.value?.startsWith("polycentric://") ?: false) {
|
if(c.author.id.value?.startsWith("polycentric://") ?: false) {
|
||||||
//val navUrl = "https://harbor.social/" + 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);
|
//val navUrl = "https://polycentric.io/user/" + c.author.id.value?.substring("polycentric://".length);
|
||||||
_fragment.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(navUrl)))
|
_fragment.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(navUrl)))
|
||||||
//_fragment.navigate<BrowserFragment>(navUrl);
|
//_fragment.navigate<BrowserFragment>(navUrl);
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-4
@@ -118,8 +118,13 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
|
|||||||
|
|
||||||
private fun showVideoOptionsOverlay(content: IPlatformVideo) {
|
private fun showVideoOptionsOverlay(content: IPlatformVideo) {
|
||||||
_overlayContainer.let {
|
_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",
|
_videoOptionsOverlay = UISlideOverlays.showVideoOptionsOverlay(content, it, SlideUpMenuItem(
|
||||||
{ StateMeta.instance.addHiddenVideo(content.url);
|
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) {
|
if (fragment is HomeFragment) {
|
||||||
val removeIndex = recyclerData.results.indexOf(content);
|
val removeIndex = recyclerData.results.indexOf(content);
|
||||||
if (removeIndex >= 0) {
|
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
|
val newQueue = listOf(content) + recyclerData.results
|
||||||
.filterIsInstance<IPlatformVideo>()
|
.filterIsInstance<IPlatformVideo>()
|
||||||
.filter { it != content };
|
.filter { it != content };
|
||||||
|
|||||||
+26
-8
@@ -46,6 +46,14 @@ class HomeFragment : MainFragment() {
|
|||||||
private var _view: HomeView? = null;
|
private var _view: HomeView? = null;
|
||||||
private var _cachedRecyclerData: FeedView.RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null;
|
private var _cachedRecyclerData: FeedView.RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null;
|
||||||
|
|
||||||
|
fun reloadFeed() {
|
||||||
|
_view?.reloadFeed()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun scrollToTop(smooth: Boolean) {
|
||||||
|
_view?.scrollToTop(smooth)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
|
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
|
||||||
super.onShownWithView(parameter, isBack);
|
super.onShownWithView(parameter, isBack);
|
||||||
_view?.onShown();
|
_view?.onShown();
|
||||||
@@ -138,17 +146,12 @@ class HomeFragment : MainFragment() {
|
|||||||
fun onShown() {
|
fun onShown() {
|
||||||
val lastClients = recyclerData.lastClients;
|
val lastClients = recyclerData.lastClients;
|
||||||
val clients = StatePlatform.instance.getSortedEnabledClient().filter { if (it is JSClient) it.enableInHome else true };
|
val clients = StatePlatform.instance.getSortedEnabledClient().filter { if (it is JSClient) it.enableInHome else true };
|
||||||
|
|
||||||
val feedstyleChanged = recyclerData.loadedFeedStyle != feedStyle;
|
val feedstyleChanged = recyclerData.loadedFeedStyle != feedStyle;
|
||||||
val clientsChanged = lastClients == null || lastClients.size != clients.size || !lastClients.containsAll(clients);
|
val clientsChanged = lastClients == null || lastClients.size != clients.size || !lastClients.containsAll(clients);
|
||||||
val outdated = recyclerData.lastLoad.getNowDiffSeconds() > 60;
|
Logger.i(TAG, "onShown (recyclerData.loadedFeedStyle=${recyclerData.loadedFeedStyle}, recyclerData.lastLoad=${recyclerData.lastLoad}, feedstyleChanged=$feedstyleChanged, clientsChanged=$clientsChanged)")
|
||||||
Logger.i(TAG, "onShown (recyclerData.loadedFeedStyle=${recyclerData.loadedFeedStyle}, recyclerData.lastLoad=${recyclerData.lastLoad}, feedstyleChanged=$feedstyleChanged, clientsChanged=$clientsChanged, outdated=$outdated)")
|
|
||||||
|
|
||||||
if(feedstyleChanged || outdated || clientsChanged) {
|
if(feedstyleChanged || clientsChanged) {
|
||||||
recyclerData.lastLoad = OffsetDateTime.now();
|
reloadFeed()
|
||||||
recyclerData.loadedFeedStyle = feedStyle;
|
|
||||||
recyclerData.lastClients = clients;
|
|
||||||
loadResults();
|
|
||||||
} else {
|
} else {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -156,6 +159,21 @@ class HomeFragment : MainFragment() {
|
|||||||
finishRefreshLayoutLoader();
|
finishRefreshLayoutLoader();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun scrollToTop(smooth: Boolean) {
|
||||||
|
if (smooth) {
|
||||||
|
_recyclerResults.smoothScrollToPosition(0)
|
||||||
|
} else {
|
||||||
|
_recyclerResults.scrollToPosition(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reloadFeed() {
|
||||||
|
recyclerData.lastLoad = OffsetDateTime.now();
|
||||||
|
recyclerData.loadedFeedStyle = feedStyle;
|
||||||
|
recyclerData.lastClients = StatePlatform.instance.getSortedEnabledClient().filter { if (it is JSClient) it.enableInHome else true };
|
||||||
|
loadResults();
|
||||||
|
}
|
||||||
|
|
||||||
override fun getEmptyPagerView(): View? {
|
override fun getEmptyPagerView(): View? {
|
||||||
val dp10 = 10.dp(resources);
|
val dp10 = 10.dp(resources);
|
||||||
val dp30 = 30.dp(resources);
|
val dp30 = 30.dp(resources);
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import com.futo.platformplayer.activities.MainActivity
|
|||||||
import com.futo.platformplayer.constructs.Event1
|
import com.futo.platformplayer.constructs.Event1
|
||||||
import com.futo.platformplayer.fragment.mainactivity.MainActivityFragment
|
import com.futo.platformplayer.fragment.mainactivity.MainActivityFragment
|
||||||
import com.futo.platformplayer.fragment.mainactivity.topbar.TopFragment
|
import com.futo.platformplayer.fragment.mainactivity.topbar.TopFragment
|
||||||
import com.futo.platformplayer.listeners.OrientationManager
|
|
||||||
|
|
||||||
abstract class MainFragment : MainActivityFragment() {
|
abstract class MainFragment : MainActivityFragment() {
|
||||||
open val isMainView: Boolean = false;
|
open val isMainView: Boolean = false;
|
||||||
@@ -46,10 +45,6 @@ abstract class MainFragment : MainActivityFragment() {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
open fun onOrientationChanged(orientation: OrientationManager.Orientation) {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
open fun onBackPressed(): Boolean {
|
open fun onBackPressed(): Boolean {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
+53
-19
@@ -109,19 +109,31 @@ class PlaylistFragment : MainFragment() {
|
|||||||
val reconstruction = StatePlaylists.instance.playlistStore.getReconstructionString(playlist);
|
val reconstruction = StatePlaylists.instance.playlistStore.getReconstructionString(playlist);
|
||||||
|
|
||||||
UISlideOverlays.showOverlay(overlayContainer, context.getString(R.string.playlist) + " [${playlist.name}]", null, {},
|
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, {
|
SlideUpMenuItem(
|
||||||
_fragment.startActivity(ShareCompat.IntentBuilder(context)
|
context,
|
||||||
.setType("text/plain")
|
R.drawable.ic_list,
|
||||||
.setText(reconstruction)
|
context.getString(R.string.share_as_text),
|
||||||
.intent);
|
context.getString(R.string.share_as_a_list_of_video_urls),
|
||||||
}),
|
tag = 1,
|
||||||
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, {
|
call = {
|
||||||
val shareUri = StatePlaylists.instance.createPlaylistShareJsonUri(context, playlist);
|
_fragment.startActivity(ShareCompat.IntentBuilder(context)
|
||||||
_fragment.startActivity(ShareCompat.IntentBuilder(context)
|
.setType("text/plain")
|
||||||
.setType("application/json")
|
.setText(reconstruction)
|
||||||
.setStream(shareUri)
|
.intent);
|
||||||
.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);
|
||||||
|
})
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -144,6 +156,14 @@ class PlaylistFragment : MainFragment() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun copyPlaylist(playlist: Playlist) {
|
||||||
|
StatePlaylists.instance.playlistStore.save(playlist)
|
||||||
|
_fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(
|
||||||
|
arrayListOf()
|
||||||
|
)
|
||||||
|
UIDialogs.toast("Playlist saved")
|
||||||
|
}
|
||||||
|
|
||||||
fun onShown(parameter: Any?) {
|
fun onShown(parameter: Any?) {
|
||||||
_taskLoadPlaylist.cancel()
|
_taskLoadPlaylist.cancel()
|
||||||
|
|
||||||
@@ -158,14 +178,10 @@ class PlaylistFragment : MainFragment() {
|
|||||||
setButtonDownloadVisible(true)
|
setButtonDownloadVisible(true)
|
||||||
setButtonEditVisible(true)
|
setButtonEditVisible(true)
|
||||||
|
|
||||||
if (!StatePlaylists.instance.playlistStore.getItems().contains(parameter)) {
|
if (!StatePlaylists.instance.playlistStore.hasItem { it.id == parameter.id }) {
|
||||||
_fragment.topBar?.assume<NavigationTopBarFragment>()
|
_fragment.topBar?.assume<NavigationTopBarFragment>()
|
||||||
?.setMenuItems(arrayListOf(Pair(R.drawable.ic_copy) {
|
?.setMenuItems(arrayListOf(Pair(R.drawable.ic_copy) {
|
||||||
StatePlaylists.instance.playlistStore.save(parameter)
|
copyPlaylist(parameter)
|
||||||
_fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(
|
|
||||||
arrayListOf()
|
|
||||||
)
|
|
||||||
UIDialogs.toast("Playlist saved")
|
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -230,6 +246,15 @@ class PlaylistFragment : MainFragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun download() {
|
private fun download() {
|
||||||
|
val playlist = _playlist ?: return
|
||||||
|
if (!StatePlaylists.instance.playlistStore.hasItem { it.id == playlist.id }) {
|
||||||
|
UIDialogs.showConfirmationDialog(context, "Playlist must be saved to download", {
|
||||||
|
copyPlaylist(playlist)
|
||||||
|
download()
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
_playlist?.let {
|
_playlist?.let {
|
||||||
UISlideOverlays.showDownloadPlaylistOverlay(it, overlayContainer);
|
UISlideOverlays.showDownloadPlaylistOverlay(it, overlayContainer);
|
||||||
}
|
}
|
||||||
@@ -254,6 +279,15 @@ class PlaylistFragment : MainFragment() {
|
|||||||
override fun canEdit(): Boolean { return _playlist != null; }
|
override fun canEdit(): Boolean { return _playlist != null; }
|
||||||
|
|
||||||
override fun onEditClick() {
|
override fun onEditClick() {
|
||||||
|
val playlist = _playlist ?: return
|
||||||
|
if (!StatePlaylists.instance.playlistStore.hasItem { it.id == playlist.id }) {
|
||||||
|
UIDialogs.showConfirmationDialog(context, "Playlist must be saved to edit the name", {
|
||||||
|
copyPlaylist(playlist)
|
||||||
|
onEditClick()
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
_editPlaylistNameInput?.activate();
|
_editPlaylistNameInput?.activate();
|
||||||
_editPlaylistOverlay?.show();
|
_editPlaylistOverlay?.show();
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-31
@@ -9,6 +9,7 @@ import android.view.LayoutInflater
|
|||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.view.ViewPropertyAnimator
|
import android.view.ViewPropertyAnimator
|
||||||
|
import android.widget.Button
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
import android.widget.ImageButton
|
import android.widget.ImageButton
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
@@ -19,6 +20,7 @@ import androidx.core.view.children
|
|||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
|
import com.futo.platformplayer.Settings
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.api.media.PlatformID
|
import com.futo.platformplayer.api.media.PlatformID
|
||||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||||
@@ -135,10 +137,7 @@ class PostDetailFragment : MainFragment {
|
|||||||
private val _imageDislikeIcon: ImageView;
|
private val _imageDislikeIcon: ImageView;
|
||||||
private val _textDislikes: TextView;
|
private val _textDislikes: TextView;
|
||||||
|
|
||||||
private val _textComments: TextView;
|
|
||||||
private val _textCommentType: TextView;
|
|
||||||
private val _addCommentView: AddCommentView;
|
private val _addCommentView: AddCommentView;
|
||||||
private val _toggleCommentType: Toggle;
|
|
||||||
|
|
||||||
private val _rating: PillRatingLikesDislikes;
|
private val _rating: PillRatingLikesDislikes;
|
||||||
|
|
||||||
@@ -152,6 +151,10 @@ class PostDetailFragment : MainFragment {
|
|||||||
|
|
||||||
private val _commentsList: CommentsList;
|
private val _commentsList: CommentsList;
|
||||||
|
|
||||||
|
private var _commentType: Boolean? = null;
|
||||||
|
private val _buttonPolycentric: Button
|
||||||
|
private val _buttonPlatform: Button
|
||||||
|
|
||||||
private val _taskLoadPost = if(!isInEditMode) TaskHandler<String, IPlatformPostDetails>(
|
private val _taskLoadPost = if(!isInEditMode) TaskHandler<String, IPlatformPostDetails>(
|
||||||
StateApp.instance.scopeGetter,
|
StateApp.instance.scopeGetter,
|
||||||
{
|
{
|
||||||
@@ -198,9 +201,6 @@ class PostDetailFragment : MainFragment {
|
|||||||
_textDislikes = findViewById(R.id.text_dislikes);
|
_textDislikes = findViewById(R.id.text_dislikes);
|
||||||
|
|
||||||
_commentsList = findViewById(R.id.comments_list);
|
_commentsList = findViewById(R.id.comments_list);
|
||||||
_textCommentType = findViewById(R.id.text_comment_type);
|
|
||||||
_toggleCommentType = findViewById(R.id.toggle_comment_type);
|
|
||||||
_textComments = findViewById(R.id.text_comments);
|
|
||||||
_addCommentView = findViewById(R.id.add_comment_view);
|
_addCommentView = findViewById(R.id.add_comment_view);
|
||||||
|
|
||||||
_rating = findViewById(R.id.rating);
|
_rating = findViewById(R.id.rating);
|
||||||
@@ -213,6 +213,9 @@ class PostDetailFragment : MainFragment {
|
|||||||
|
|
||||||
_repliesOverlay = findViewById(R.id.replies_overlay);
|
_repliesOverlay = findViewById(R.id.replies_overlay);
|
||||||
|
|
||||||
|
_buttonPolycentric = findViewById(R.id.button_polycentric)
|
||||||
|
_buttonPlatform = findViewById(R.id.button_platform)
|
||||||
|
|
||||||
_textContent.setPlatformPlayerLinkMovementMethod(context);
|
_textContent.setPlatformPlayerLinkMovementMethod(context);
|
||||||
|
|
||||||
_buttonSubscribe.onSubscribed.subscribe {
|
_buttonSubscribe.onSubscribed.subscribe {
|
||||||
@@ -224,9 +227,10 @@ class PostDetailFragment : MainFragment {
|
|||||||
root.removeView(layoutTop);
|
root.removeView(layoutTop);
|
||||||
_commentsList.setPrependedView(layoutTop);
|
_commentsList.setPrependedView(layoutTop);
|
||||||
|
|
||||||
|
/*TODO: Why is this here?
|
||||||
_commentsList.onCommentsLoaded.subscribe {
|
_commentsList.onCommentsLoaded.subscribe {
|
||||||
updateCommentType(false);
|
updateCommentType(false);
|
||||||
};
|
};*/
|
||||||
|
|
||||||
_commentsList.onRepliesClick.subscribe { c ->
|
_commentsList.onRepliesClick.subscribe { c ->
|
||||||
val replyCount = c.replyCount ?: 0;
|
val replyCount = c.replyCount ?: 0;
|
||||||
@@ -237,7 +241,7 @@ class PostDetailFragment : MainFragment {
|
|||||||
|
|
||||||
if (c is PolycentricPlatformComment) {
|
if (c is PolycentricPlatformComment) {
|
||||||
var parentComment: PolycentricPlatformComment = c;
|
var parentComment: PolycentricPlatformComment = c;
|
||||||
_repliesOverlay.load(_toggleCommentType.value, metadata, c.contextUrl, c.reference, c,
|
_repliesOverlay.load(_commentType!!, metadata, c.contextUrl, c.reference, c,
|
||||||
{ StatePolycentric.instance.getCommentPager(c.contextUrl, c.reference) },
|
{ StatePolycentric.instance.getCommentPager(c.contextUrl, c.reference) },
|
||||||
{
|
{
|
||||||
val newComment = parentComment.cloneWithUpdatedReplyCount((parentComment.replyCount ?: 0) + 1);
|
val newComment = parentComment.cloneWithUpdatedReplyCount((parentComment.replyCount ?: 0) + 1);
|
||||||
@@ -245,22 +249,23 @@ class PostDetailFragment : MainFragment {
|
|||||||
parentComment = newComment;
|
parentComment = newComment;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
_repliesOverlay.load(_toggleCommentType.value, metadata, null, null, c, { StatePlatform.instance.getSubComments(c) });
|
_repliesOverlay.load(_commentType!!, metadata, null, null, c, { StatePlatform.instance.getSubComments(c) });
|
||||||
}
|
}
|
||||||
|
|
||||||
setRepliesOverlayVisible(isVisible = true, animate = true);
|
setRepliesOverlayVisible(isVisible = true, animate = true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (StatePolycentric.instance.enabled) {
|
||||||
|
_buttonPolycentric.setOnClickListener {
|
||||||
|
updateCommentType(false)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_buttonPolycentric.visibility = View.GONE
|
||||||
|
}
|
||||||
|
|
||||||
_toggleCommentType.onValueChanged.subscribe {
|
_buttonPlatform.setOnClickListener {
|
||||||
updateCommentType(true);
|
updateCommentType(true)
|
||||||
};
|
}
|
||||||
|
|
||||||
_textCommentType.setOnClickListener {
|
|
||||||
_toggleCommentType.setValue(!_toggleCommentType.value, true);
|
|
||||||
updateCommentType(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
_layoutMonetization.visibility = View.GONE;
|
_layoutMonetization.visibility = View.GONE;
|
||||||
|
|
||||||
_buttonSupport.setOnClickListener {
|
_buttonSupport.setOnClickListener {
|
||||||
@@ -432,7 +437,7 @@ class PostDetailFragment : MainFragment {
|
|||||||
_taskLoadPolycentricProfile.cancel();
|
_taskLoadPolycentricProfile.cancel();
|
||||||
_version++;
|
_version++;
|
||||||
|
|
||||||
_toggleCommentType.setValue(false, false);
|
updateCommentType(null)
|
||||||
_url = null;
|
_url = null;
|
||||||
_post = null;
|
_post = null;
|
||||||
_postOverview = null;
|
_postOverview = null;
|
||||||
@@ -476,7 +481,8 @@ class PostDetailFragment : MainFragment {
|
|||||||
_addCommentView.setContext(value.url, Models.referenceFromBuffer(value.url.toByteArray()));
|
_addCommentView.setContext(value.url, Models.referenceFromBuffer(value.url.toByteArray()));
|
||||||
}
|
}
|
||||||
|
|
||||||
updateCommentType(true);
|
val commentType = !Settings.instance.other.polycentricEnabled || Settings.instance.comments.defaultCommentSection == 1
|
||||||
|
updateCommentType(commentType, true);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -679,20 +685,29 @@ class PostDetailFragment : MainFragment {
|
|||||||
_commentsList.load(false) { StatePolycentric.instance.getCommentPager(post!!.url, ref, listOfNotNull(extraBytesRef)); };
|
_commentsList.load(false) { StatePolycentric.instance.getCommentPager(post!!.url, ref, listOfNotNull(extraBytesRef)); };
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateCommentType(reloadComments: Boolean) {
|
private fun updateCommentType(commentType: Boolean?, forceReload: Boolean = false) {
|
||||||
if (_toggleCommentType.value) {
|
val changed = commentType != _commentType
|
||||||
_textCommentType.text = "Platform";
|
_commentType = commentType
|
||||||
_addCommentView.visibility = View.GONE;
|
|
||||||
|
|
||||||
if (reloadComments) {
|
if (commentType == null) {
|
||||||
fetchComments();
|
_buttonPlatform.setTextColor(resources.getColor(R.color.gray_ac))
|
||||||
}
|
_buttonPolycentric.setTextColor(resources.getColor(R.color.gray_ac))
|
||||||
} else {
|
} else {
|
||||||
_textCommentType.text = "Polycentric";
|
_buttonPlatform.setTextColor(resources.getColor(if (commentType) R.color.white else R.color.gray_ac))
|
||||||
_addCommentView.visibility = View.VISIBLE;
|
_buttonPolycentric.setTextColor(resources.getColor(if (!commentType) R.color.white else R.color.gray_ac))
|
||||||
|
|
||||||
if (reloadComments) {
|
if (commentType) {
|
||||||
fetchPolycentricComments()
|
_addCommentView.visibility = View.GONE;
|
||||||
|
|
||||||
|
if (forceReload || changed) {
|
||||||
|
fetchComments();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_addCommentView.visibility = View.VISIBLE;
|
||||||
|
|
||||||
|
if (forceReload || changed) {
|
||||||
|
fetchPolycentricComments()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-7
@@ -397,23 +397,43 @@ class SourceDetailFragment : MainFragment() {
|
|||||||
UIDialogs.Action("Cancel", {}, UIDialogs.ActionStyle.NONE),
|
UIDialogs.Action("Cancel", {}, UIDialogs.ActionStyle.NONE),
|
||||||
UIDialogs.Action("Login", {
|
UIDialogs.Action("Login", {
|
||||||
LoginActivity.showLogin(StateApp.instance.context, config) {
|
LoginActivity.showLogin(StateApp.instance.context, config) {
|
||||||
StatePlugins.instance.setPluginAuth(config.id, it);
|
try {
|
||||||
reloadSource(config.id);
|
StatePlugins.instance.setPluginAuth(config.id, it);
|
||||||
|
reloadSource(config.id);
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||||
|
context?.let { c -> UIDialogs.showGeneralErrorDialog(c, "Failed to set plugin authentication (loginSource, loginWarning)", e) }
|
||||||
|
}
|
||||||
|
Logger.e(TAG, "Failed to set plugin authentication (loginSource, loginWarning)", e)
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, UIDialogs.ActionStyle.PRIMARY))
|
}, UIDialogs.ActionStyle.PRIMARY))
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
LoginActivity.showLogin(StateApp.instance.context, config) {
|
LoginActivity.showLogin(StateApp.instance.context, config) {
|
||||||
StatePlugins.instance.setPluginAuth(config.id, it);
|
try {
|
||||||
reloadSource(config.id);
|
StatePlugins.instance.setPluginAuth(config.id, it);
|
||||||
|
reloadSource(config.id);
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||||
|
context?.let { c -> UIDialogs.showGeneralErrorDialog(c, "Failed to set plugin authentication (loginSource)", e) }
|
||||||
|
}
|
||||||
|
Logger.e(TAG, "Failed to set plugin authentication (loginSource)", e)
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
private fun logoutSource(clear: Boolean = true) {
|
private fun logoutSource(clear: Boolean = true) {
|
||||||
val config = _config ?: return;
|
val config = _config ?: return;
|
||||||
|
|
||||||
StatePlugins.instance.setPluginAuth(config.id, null);
|
try {
|
||||||
reloadSource(config.id);
|
StatePlugins.instance.setPluginAuth(config.id, null);
|
||||||
|
reloadSource(config.id);
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||||
|
context?.let { c -> UIDialogs.showGeneralErrorDialog(c, "Failed to clear plugin authentication", e) }
|
||||||
|
}
|
||||||
|
Logger.e(TAG, "Failed to clear plugin authentication", e)
|
||||||
|
}
|
||||||
|
|
||||||
//TODO: Maybe add a dialog option..
|
//TODO: Maybe add a dialog option..
|
||||||
if(Settings.instance.plugins.clearCookiesOnLogout && clear) {
|
if(Settings.instance.plugins.clearCookiesOnLogout && clear) {
|
||||||
|
|||||||
+8
-2
@@ -117,8 +117,14 @@ class SuggestionsFragment : MainFragment {
|
|||||||
} else if (_searchType == SearchType.PLAYLIST) {
|
} else if (_searchType == SearchType.PLAYLIST) {
|
||||||
navigate<PlaylistSearchResultsFragment>(it);
|
navigate<PlaylistSearchResultsFragment>(it);
|
||||||
} else {
|
} else {
|
||||||
if(it.isHttpUrl())
|
if(it.isHttpUrl()) {
|
||||||
navigate<VideoDetailFragment>(it);
|
if(StatePlatform.instance.hasEnabledPlaylistClient(it))
|
||||||
|
navigate<RemotePlaylistFragment>(it);
|
||||||
|
else if(StatePlatform.instance.hasEnabledChannelClient(it))
|
||||||
|
navigate<ChannelFragment>(it);
|
||||||
|
else
|
||||||
|
navigate<VideoDetailFragment>(it);
|
||||||
|
}
|
||||||
else
|
else
|
||||||
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(it, SearchType.VIDEO, _channelUrl));
|
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(it, SearchType.VIDEO, _channelUrl));
|
||||||
}
|
}
|
||||||
|
|||||||
+182
-117
@@ -2,31 +2,37 @@ package com.futo.platformplayer.fragment.mainactivity.main
|
|||||||
|
|
||||||
import android.content.pm.ActivityInfo
|
import android.content.pm.ActivityInfo
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.os.Handler
|
||||||
|
import android.util.Log
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import android.view.WindowInsets
|
||||||
|
import android.view.WindowInsetsController
|
||||||
|
import android.view.WindowManager
|
||||||
import androidx.constraintlayout.motion.widget.MotionLayout
|
import androidx.constraintlayout.motion.widget.MotionLayout
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.core.view.WindowInsetsControllerCompat
|
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.Settings
|
import com.futo.platformplayer.Settings
|
||||||
|
import com.futo.platformplayer.SimpleOrientationListener
|
||||||
import com.futo.platformplayer.UIDialogs
|
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.IPlatformVideo
|
||||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
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.casting.StateCasting
|
||||||
import com.futo.platformplayer.constructs.Event0
|
import com.futo.platformplayer.constructs.Event0
|
||||||
import com.futo.platformplayer.constructs.Event1
|
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.logging.Logger
|
||||||
import com.futo.platformplayer.models.PlatformVideoWithTime
|
import com.futo.platformplayer.models.PlatformVideoWithTime
|
||||||
import com.futo.platformplayer.models.UrlVideoWithTime
|
import com.futo.platformplayer.models.UrlVideoWithTime
|
||||||
import com.futo.platformplayer.states.StatePlayer
|
import com.futo.platformplayer.states.StatePlayer
|
||||||
import com.futo.platformplayer.views.containers.SingleViewTouchableMotionLayout
|
import com.futo.platformplayer.views.containers.SingleViewTouchableMotionLayout
|
||||||
|
|
||||||
|
|
||||||
class VideoDetailFragment : MainFragment {
|
class VideoDetailFragment : MainFragment {
|
||||||
override val isMainView : Boolean = false;
|
override val isMainView : Boolean = false;
|
||||||
override val hasBottomBar: Boolean = true;
|
override val hasBottomBar: Boolean = true;
|
||||||
@@ -37,23 +43,32 @@ class VideoDetailFragment : MainFragment {
|
|||||||
|
|
||||||
private var _viewDetail : VideoDetailView? = null;
|
private var _viewDetail : VideoDetailView? = null;
|
||||||
private var _view : SingleViewTouchableMotionLayout? = 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;
|
var isFullscreen : Boolean = false;
|
||||||
|
val onFullscreenChanged = Event1<Boolean>();
|
||||||
var isTransitioning : Boolean = false
|
var isTransitioning : Boolean = false
|
||||||
private set;
|
private set;
|
||||||
var isInPictureInPicture : Boolean = false
|
var isInPictureInPicture : Boolean = false
|
||||||
private set;
|
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 currentUrl get() = _viewDetail?.currentUrl;
|
||||||
|
|
||||||
val onMinimize = Event0();
|
val onMinimize = Event0();
|
||||||
val onTransitioning = Event1<Boolean>();
|
val onTransitioning = Event1<Boolean>();
|
||||||
val onMaximized = Event0();
|
val onMaximized = Event0();
|
||||||
|
|
||||||
var lastOrientation : OrientationManager.Orientation = OrientationManager.Orientation.PORTRAIT
|
|
||||||
private set;
|
|
||||||
|
|
||||||
private var _isInitialMaximize = true;
|
private var _isInitialMaximize = true;
|
||||||
|
|
||||||
private val _maximizeProgress get() = _view?.progress ?: 0.0f;
|
private val _maximizeProgress get() = _view?.progress ?: 0.0f;
|
||||||
@@ -73,6 +88,67 @@ class VideoDetailFragment : MainFragment {
|
|||||||
_viewDetail?.prevVideo(true);
|
_viewDetail?.prevVideo(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun onStateChanged(state: VideoDetailFragment.State) {
|
||||||
|
updateOrientation()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateOrientation() {
|
||||||
|
val a = activity ?: return
|
||||||
|
val isMaximized = state == State.MAXIMIZED
|
||||||
|
val isFullScreenPortraitAllowed = Settings.instance.playback.fullscreenPortrait;
|
||||||
|
val bypassRotationPrevention = Settings.instance.other.bypassRotationPrevention;
|
||||||
|
val fullAutorotateLock = Settings.instance.playback.fullAutorotateLock
|
||||||
|
val currentRequestedOrientation = a.requestedOrientation
|
||||||
|
var currentOrientation = if (_currentOrientation == -1) currentRequestedOrientation else _currentOrientation
|
||||||
|
if (currentOrientation == ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT && !Settings.instance.playback.reversePortrait)
|
||||||
|
currentOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||||
|
|
||||||
|
val isAutoRotate = Settings.instance.playback.isAutoRotate()
|
||||||
|
val isFs = isFullscreen
|
||||||
|
|
||||||
|
if (fullAutorotateLock) {
|
||||||
|
if (isFs && isMaximized) {
|
||||||
|
if (isFullScreenPortraitAllowed) {
|
||||||
|
if (isAutoRotate) {
|
||||||
|
a.requestedOrientation = currentOrientation
|
||||||
|
}
|
||||||
|
} else if (currentOrientation == ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE || currentOrientation == ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE) {
|
||||||
|
if (isAutoRotate || currentOrientation != currentRequestedOrientation && (currentRequestedOrientation == ActivityInfo.SCREEN_ORIENTATION_PORTRAIT || currentRequestedOrientation == ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT)) {
|
||||||
|
a.requestedOrientation = currentOrientation
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
a.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
|
||||||
|
}
|
||||||
|
} else if (bypassRotationPrevention) {
|
||||||
|
a.requestedOrientation = currentOrientation
|
||||||
|
} else if (currentOrientation == ActivityInfo.SCREEN_ORIENTATION_PORTRAIT || currentOrientation == ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT) {
|
||||||
|
a.requestedOrientation = currentOrientation
|
||||||
|
} else {
|
||||||
|
a.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (isFs && isMaximized) {
|
||||||
|
if (isFullScreenPortraitAllowed) {
|
||||||
|
a.requestedOrientation = currentOrientation
|
||||||
|
} else if (currentOrientation == ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE || currentOrientation == ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE) {
|
||||||
|
a.requestedOrientation = currentOrientation
|
||||||
|
} else if (currentRequestedOrientation == ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE || currentRequestedOrientation == ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE) {
|
||||||
|
//Don't change anything
|
||||||
|
} else {
|
||||||
|
a.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
|
||||||
|
}
|
||||||
|
} else if (bypassRotationPrevention) {
|
||||||
|
a.requestedOrientation = currentOrientation
|
||||||
|
} else if (currentOrientation == ActivityInfo.SCREEN_ORIENTATION_PORTRAIT || currentOrientation == ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT) {
|
||||||
|
a.requestedOrientation = currentOrientation
|
||||||
|
} else {
|
||||||
|
a.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.i(TAG, "updateOrientation (isFs = ${isFs}, currentOrientation = ${currentOrientation}, fullAutorotateLock = ${fullAutorotateLock}, currentRequestedOrientation = ${currentRequestedOrientation}, isMaximized = ${isMaximized}, isAutoRotate = ${isAutoRotate}, isFullScreenPortraitAllowed = ${isFullScreenPortraitAllowed}) resulted in requested orientation ${activity?.requestedOrientation}");
|
||||||
|
}
|
||||||
|
|
||||||
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
|
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
|
||||||
super.onShownWithView(parameter, isBack);
|
super.onShownWithView(parameter, isBack);
|
||||||
Logger.i(TAG, "onShownWithView parameter=$parameter")
|
Logger.i(TAG, "onShownWithView parameter=$parameter")
|
||||||
@@ -98,49 +174,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 {
|
override fun onBackPressed(): Boolean {
|
||||||
Logger.i(TAG, "onBackPressed")
|
Logger.i(TAG, "onBackPressed")
|
||||||
|
|
||||||
@@ -154,6 +187,7 @@ class VideoDetailFragment : MainFragment {
|
|||||||
closeVideoDetails();
|
closeVideoDetails();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onHide() {
|
override fun onHide() {
|
||||||
super.onHide();
|
super.onHide();
|
||||||
}
|
}
|
||||||
@@ -163,7 +197,7 @@ class VideoDetailFragment : MainFragment {
|
|||||||
_viewDetail?.preventPictureInPicture = true;
|
_viewDetail?.preventPictureInPicture = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
fun minimizeVideoDetail(){
|
fun minimizeVideoDetail() {
|
||||||
_viewDetail?.setFullscreen(false);
|
_viewDetail?.setFullscreen(false);
|
||||||
if(_view != null)
|
if(_view != null)
|
||||||
_view!!.transitionToStart();
|
_view!!.transitionToStart();
|
||||||
@@ -265,7 +299,6 @@ class VideoDetailFragment : MainFragment {
|
|||||||
override fun onTransitionTrigger(p0: MotionLayout?, p1: Int, p2: Boolean, p3: Float) { }
|
override fun onTransitionTrigger(p0: MotionLayout?, p1: Int, p2: Boolean, p3: Float) { }
|
||||||
});
|
});
|
||||||
|
|
||||||
context
|
|
||||||
_view?.let {
|
_view?.let {
|
||||||
if (it.progress >= 0.5 && it.progress < 1.0)
|
if (it.progress >= 0.5 && it.progress < 1.0)
|
||||||
maximizeVideoDetail();
|
maximizeVideoDetail();
|
||||||
@@ -273,12 +306,55 @@ class VideoDetailFragment : MainFragment {
|
|||||||
minimizeVideoDetail();
|
minimizeVideoDetail();
|
||||||
}
|
}
|
||||||
|
|
||||||
_loadUrlOnCreate?.let { _viewDetail?.setVideo(it.url, it.timeSeconds, it.playWhenReady) };
|
_autoRotateChangeListener = AutoRotateChangeListener(requireContext(), Handler()) { _ ->
|
||||||
|
if (updateAutoFullscreen()) {
|
||||||
|
return@AutoRotateChangeListener
|
||||||
|
}
|
||||||
|
updateOrientation()
|
||||||
|
}
|
||||||
|
|
||||||
|
_loadUrlOnCreate?.let { _viewDetail?.setVideo(it.url, it.timeSeconds, it.playWhenReady) };
|
||||||
maximizeVideoDetail();
|
maximizeVideoDetail();
|
||||||
|
|
||||||
|
SettingsActivity.settingsActivityClosed.subscribe(this) {
|
||||||
|
updateOrientation()
|
||||||
|
}
|
||||||
|
|
||||||
|
StatePlayer.instance.onRotationLockChanged.subscribe(this) {
|
||||||
|
if (updateAutoFullscreen()) {
|
||||||
|
return@subscribe
|
||||||
|
}
|
||||||
|
updateOrientation()
|
||||||
|
}
|
||||||
|
|
||||||
|
_orientationListener = SimpleOrientationListener(requireActivity(), lifecycleScope)
|
||||||
|
_orientationListener.onOrientationChanged.subscribe {
|
||||||
|
_currentOrientation = it
|
||||||
|
Logger.i(TAG, "Current orientation changed (_currentOrientation = ${_currentOrientation})")
|
||||||
|
|
||||||
|
if (updateAutoFullscreen()) {
|
||||||
|
return@subscribe
|
||||||
|
}
|
||||||
|
updateOrientation()
|
||||||
|
}
|
||||||
return _view!!;
|
return _view!!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun updateAutoFullscreen(): Boolean {
|
||||||
|
if (Settings.instance.playback.isAutoRotate()) {
|
||||||
|
if (state == State.MAXIMIZED && !isFullscreen && (_currentOrientation == ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE || _currentOrientation == ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE)) {
|
||||||
|
_viewDetail?.setFullscreen(true)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state == State.MAXIMIZED && isFullscreen && !Settings.instance.playback.fullscreenPortrait && (_currentOrientation == ActivityInfo.SCREEN_ORIENTATION_PORTRAIT || _currentOrientation == ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT)) {
|
||||||
|
_viewDetail?.setFullscreen(false)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
fun onUserLeaveHint() {
|
fun onUserLeaveHint() {
|
||||||
val viewDetail = _viewDetail;
|
val viewDetail = _viewDetail;
|
||||||
Logger.i(TAG, "onUserLeaveHint preventPictureInPicture=${viewDetail?.preventPictureInPicture} isCasting=${StateCasting.instance.isCasting} isBackgroundPictureInPicture=${Settings.instance.playback.isBackgroundPictureInPicture()} allowBackground=${viewDetail?.allowBackground}");
|
Logger.i(TAG, "onUserLeaveHint preventPictureInPicture=${viewDetail?.preventPictureInPicture} isCasting=${StateCasting.instance.isCasting} isBackgroundPictureInPicture=${Settings.instance.playback.isBackgroundPictureInPicture()} allowBackground=${viewDetail?.allowBackground}");
|
||||||
@@ -333,11 +409,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();
|
StateCasting.instance.onResume();
|
||||||
}
|
}
|
||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
@@ -379,6 +450,12 @@ class VideoDetailFragment : MainFragment {
|
|||||||
override fun onDestroyMainView() {
|
override fun onDestroyMainView() {
|
||||||
super.onDestroyMainView();
|
super.onDestroyMainView();
|
||||||
Logger.v(TAG, "onDestroyMainView");
|
Logger.v(TAG, "onDestroyMainView");
|
||||||
|
_autoRotateChangeListener?.unregister()
|
||||||
|
_orientationListener.stopListening()
|
||||||
|
|
||||||
|
SettingsActivity.settingsActivityClosed.remove(this)
|
||||||
|
StatePlayer.instance.onRotationLockChanged.remove(this)
|
||||||
|
|
||||||
_viewDetail?.let {
|
_viewDetail?.let {
|
||||||
_viewDetail = null;
|
_viewDetail = null;
|
||||||
it.onDestroy();
|
it.onDestroy();
|
||||||
@@ -386,13 +463,6 @@ class VideoDetailFragment : MainFragment {
|
|||||||
_view = null;
|
_view = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState);
|
|
||||||
StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, _ ->
|
|
||||||
onOrientationChanged(lastOrientation);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
|
|
||||||
@@ -408,64 +478,59 @@ class VideoDetailFragment : MainFragment {
|
|||||||
onMaximized.clear();
|
onMaximized.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onFullscreenChanged(fullscreen : Boolean) {
|
private fun hideSystemUI() {
|
||||||
activity?.let {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
if (fullscreen) {
|
WindowCompat.setDecorFitsSystemWindows(requireActivity().window, false)
|
||||||
if (Settings.instance.playback.fullscreenPortrait) {
|
activity?.window?.insetsController?.let { controller ->
|
||||||
changeOrientation(lastOrientation);
|
controller.hide(WindowInsets.Type.statusBars())
|
||||||
} else {
|
controller.hide(WindowInsets.Type.systemBars())
|
||||||
var orient = lastOrientation;
|
controller.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||||
if(orient == OrientationManager.Orientation.PORTRAIT || orient == OrientationManager.Orientation.REVERSED_PORTRAIT)
|
|
||||||
orient = OrientationManager.Orientation.LANDSCAPE;
|
|
||||||
changeOrientation(orient);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else
|
} else {
|
||||||
changeOrientation(OrientationManager.Orientation.PORTRAIT);
|
@Suppress("DEPRECATION")
|
||||||
|
activity?.window?.setFlags(
|
||||||
|
WindowManager.LayoutParams.FLAG_FULLSCREEN,
|
||||||
|
WindowManager.LayoutParams.FLAG_FULLSCREEN
|
||||||
|
)
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
activity?.window?.decorView?.systemUiVisibility = (
|
||||||
|
View.SYSTEM_UI_FLAG_FULLSCREEN
|
||||||
|
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
|
||||||
|
or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||||
|
)
|
||||||
}
|
}
|
||||||
isFullscreen = fullscreen;
|
|
||||||
_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)
|
private fun showSystemUI() {
|
||||||
WindowInsetsControllerCompat(it.window, _viewDetail!!).let { controller ->
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
controller.hide(WindowInsetsCompat.Type.statusBars());
|
WindowCompat.setDecorFitsSystemWindows(requireActivity().window, true)
|
||||||
controller.hide(WindowInsetsCompat.Type.systemBars());
|
activity?.window?.insetsController?.let { controller ->
|
||||||
controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE;
|
controller.show(WindowInsets.Type.statusBars())
|
||||||
}
|
controller.show(WindowInsets.Type.systemBars())
|
||||||
}
|
controller.systemBarsBehavior = WindowInsetsController.BEHAVIOR_DEFAULT
|
||||||
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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN)
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
activity?.window?.decorView?.systemUiVisibility = View.SYSTEM_UI_FLAG_VISIBLE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun onFullscreenChanged(fullscreen : Boolean) {
|
||||||
|
isFullscreen = fullscreen;
|
||||||
|
onFullscreenChanged.emit(isFullscreen);
|
||||||
|
|
||||||
|
if (isFullscreen) {
|
||||||
|
hideSystemUI()
|
||||||
|
} else {
|
||||||
|
showSystemUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
updateOrientation();
|
||||||
|
_view?.allowMotion = !fullscreen;
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val TAG = "VideoDetailFragment";
|
private val TAG = "VideoDetailFragment";
|
||||||
|
|
||||||
|
|||||||
+306
-90
@@ -23,7 +23,7 @@ import android.view.View
|
|||||||
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
|
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import android.webkit.WebView
|
import android.widget.Button
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
import android.widget.ImageButton
|
import android.widget.ImageButton
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
@@ -53,14 +53,13 @@ import com.futo.platformplayer.api.media.models.PlatformAuthorMembershipLink
|
|||||||
import com.futo.platformplayer.api.media.models.chapters.ChapterType
|
import com.futo.platformplayer.api.media.models.chapters.ChapterType
|
||||||
import com.futo.platformplayer.api.media.models.chapters.IChapter
|
import com.futo.platformplayer.api.media.models.chapters.IChapter
|
||||||
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
|
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
|
||||||
|
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||||
import com.futo.platformplayer.api.media.models.live.ILiveChatWindowDescriptor
|
import com.futo.platformplayer.api.media.models.live.ILiveChatWindowDescriptor
|
||||||
import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
|
import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
|
||||||
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
|
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.RatingLikeDislikes
|
||||||
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
|
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.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.IAudioSource
|
||||||
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
|
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.IHLSManifestAudioSource
|
||||||
@@ -102,8 +101,10 @@ import com.futo.platformplayer.selectBestImage
|
|||||||
import com.futo.platformplayer.states.AnnouncementType
|
import com.futo.platformplayer.states.AnnouncementType
|
||||||
import com.futo.platformplayer.states.StateAnnouncement
|
import com.futo.platformplayer.states.StateAnnouncement
|
||||||
import com.futo.platformplayer.states.StateApp
|
import com.futo.platformplayer.states.StateApp
|
||||||
|
import com.futo.platformplayer.states.StateDeveloper
|
||||||
import com.futo.platformplayer.states.StateDownloads
|
import com.futo.platformplayer.states.StateDownloads
|
||||||
import com.futo.platformplayer.states.StateHistory
|
import com.futo.platformplayer.states.StateHistory
|
||||||
|
import com.futo.platformplayer.states.StateMeta
|
||||||
import com.futo.platformplayer.states.StatePlatform
|
import com.futo.platformplayer.states.StatePlatform
|
||||||
import com.futo.platformplayer.states.StatePlayer
|
import com.futo.platformplayer.states.StatePlayer
|
||||||
import com.futo.platformplayer.states.StatePlaylists
|
import com.futo.platformplayer.states.StatePlaylists
|
||||||
@@ -114,15 +115,18 @@ import com.futo.platformplayer.stores.FragmentedStorage
|
|||||||
import com.futo.platformplayer.stores.StringArrayStorage
|
import com.futo.platformplayer.stores.StringArrayStorage
|
||||||
import com.futo.platformplayer.stores.db.types.DBHistory
|
import com.futo.platformplayer.stores.db.types.DBHistory
|
||||||
import com.futo.platformplayer.toHumanBitrate
|
import com.futo.platformplayer.toHumanBitrate
|
||||||
|
import com.futo.platformplayer.toHumanBytesSize
|
||||||
import com.futo.platformplayer.toHumanNowDiffString
|
import com.futo.platformplayer.toHumanNowDiffString
|
||||||
import com.futo.platformplayer.toHumanNumber
|
import com.futo.platformplayer.toHumanNumber
|
||||||
import com.futo.platformplayer.toHumanTime
|
import com.futo.platformplayer.toHumanTime
|
||||||
|
import com.futo.platformplayer.views.FeedStyle
|
||||||
|
import com.futo.platformplayer.views.LoaderView
|
||||||
import com.futo.platformplayer.views.MonetizationView
|
import com.futo.platformplayer.views.MonetizationView
|
||||||
|
import com.futo.platformplayer.views.adapters.feedtypes.PreviewVideoView
|
||||||
import com.futo.platformplayer.views.behavior.TouchInterceptFrameLayout
|
import com.futo.platformplayer.views.behavior.TouchInterceptFrameLayout
|
||||||
import com.futo.platformplayer.views.casting.CastView
|
import com.futo.platformplayer.views.casting.CastView
|
||||||
import com.futo.platformplayer.views.comments.AddCommentView
|
import com.futo.platformplayer.views.comments.AddCommentView
|
||||||
import com.futo.platformplayer.views.others.CreatorThumbnail
|
import com.futo.platformplayer.views.others.CreatorThumbnail
|
||||||
import com.futo.platformplayer.views.others.Toggle
|
|
||||||
import com.futo.platformplayer.views.overlays.DescriptionOverlay
|
import com.futo.platformplayer.views.overlays.DescriptionOverlay
|
||||||
import com.futo.platformplayer.views.overlays.LiveChatOverlay
|
import com.futo.platformplayer.views.overlays.LiveChatOverlay
|
||||||
import com.futo.platformplayer.views.overlays.QueueEditorOverlay
|
import com.futo.platformplayer.views.overlays.QueueEditorOverlay
|
||||||
@@ -156,6 +160,8 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.Dispatcher
|
||||||
|
import org.w3c.dom.Text
|
||||||
import userpackage.Protocol
|
import userpackage.Protocol
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
@@ -226,10 +232,8 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
|
|
||||||
var preventPictureInPicture: Boolean = false;
|
var preventPictureInPicture: Boolean = false;
|
||||||
|
|
||||||
private val _textComments: TextView;
|
|
||||||
private val _textCommentType: TextView;
|
|
||||||
private val _addCommentView: AddCommentView;
|
private val _addCommentView: AddCommentView;
|
||||||
private val _toggleCommentType: Toggle;
|
private var _tabIndex: Int? = null;
|
||||||
|
|
||||||
private val _layoutSkip: LinearLayout;
|
private val _layoutSkip: LinearLayout;
|
||||||
private val _textSkip: TextView;
|
private val _textSkip: TextView;
|
||||||
@@ -237,6 +241,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
private val _layoutResume: LinearLayout;
|
private val _layoutResume: LinearLayout;
|
||||||
private var _jobHideResume: Job? = null;
|
private var _jobHideResume: Job? = null;
|
||||||
private val _layoutPlayerContainer: TouchInterceptFrameLayout;
|
private val _layoutPlayerContainer: TouchInterceptFrameLayout;
|
||||||
|
private val _layoutChangeBottomSection: LinearLayout;
|
||||||
|
|
||||||
//Overlays
|
//Overlays
|
||||||
private val _overlayContainer: FrameLayout;
|
private val _overlayContainer: FrameLayout;
|
||||||
@@ -260,12 +265,16 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
private val _layoutRating: LinearLayout;
|
private val _layoutRating: LinearLayout;
|
||||||
private val _imageDislikeIcon: ImageView;
|
private val _imageDislikeIcon: ImageView;
|
||||||
private val _imageLikeIcon: ImageView;
|
private val _imageLikeIcon: ImageView;
|
||||||
private val _layoutToggleCommentSection: LinearLayout;
|
|
||||||
|
|
||||||
private val _monetization: MonetizationView;
|
private val _monetization: MonetizationView;
|
||||||
|
|
||||||
private val _buttonMore: RoundButton;
|
private val _buttonMore: RoundButton;
|
||||||
|
|
||||||
|
private val _buttonPolycentric: Button
|
||||||
|
private val _buttonPlatform: Button
|
||||||
|
private val _buttonRecommended: Button
|
||||||
|
private val _layoutRecommended: LinearLayout
|
||||||
|
|
||||||
private var _didStop: Boolean = false;
|
private var _didStop: Boolean = false;
|
||||||
private var _onPauseCalled = false;
|
private var _onPauseCalled = false;
|
||||||
private var _lastVideoSource: IVideoSource? = null;
|
private var _lastVideoSource: IVideoSource? = null;
|
||||||
@@ -281,6 +290,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
private var _commentsCount = 0;
|
private var _commentsCount = 0;
|
||||||
private var _polycentricProfile: PolycentricCache.CachedPolycentricProfile? = null;
|
private var _polycentricProfile: PolycentricCache.CachedPolycentricProfile? = null;
|
||||||
private var _slideUpOverlay: SlideUpMenuOverlay? = null;
|
private var _slideUpOverlay: SlideUpMenuOverlay? = null;
|
||||||
|
private var _autoplayVideo: IPlatformVideo? = null
|
||||||
|
|
||||||
//Events
|
//Events
|
||||||
val onMinimize = Event0();
|
val onMinimize = Event0();
|
||||||
@@ -335,9 +345,8 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
_overlay_loading_spinner = findViewById(R.id.videodetail_loader);
|
_overlay_loading_spinner = findViewById(R.id.videodetail_loader);
|
||||||
_rating = findViewById(R.id.videodetail_rating);
|
_rating = findViewById(R.id.videodetail_rating);
|
||||||
_upNext = findViewById(R.id.up_next);
|
_upNext = findViewById(R.id.up_next);
|
||||||
_textCommentType = findViewById(R.id.text_comment_type);
|
_layoutChangeBottomSection = findViewById(R.id.layout_change_bottom_section);
|
||||||
_toggleCommentType = findViewById(R.id.toggle_comment_type);
|
_layoutRecommended = findViewById(R.id.layout_recommended)
|
||||||
_layoutToggleCommentSection = findViewById(R.id.layout_toggle_comment_section);
|
|
||||||
|
|
||||||
_overlayContainer = findViewById(R.id.overlay_container);
|
_overlayContainer = findViewById(R.id.overlay_container);
|
||||||
_overlay_quality_container = findViewById(R.id.videodetail_quality_overview);
|
_overlay_quality_container = findViewById(R.id.videodetail_quality_overview);
|
||||||
@@ -359,7 +368,6 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
_container_content_support = findViewById(R.id.videodetail_container_support);
|
_container_content_support = findViewById(R.id.videodetail_container_support);
|
||||||
_container_content_browser = findViewById(R.id.videodetail_container_webview)
|
_container_content_browser = findViewById(R.id.videodetail_container_webview)
|
||||||
|
|
||||||
_textComments = findViewById(R.id.text_comments);
|
|
||||||
_addCommentView = findViewById(R.id.add_comment_view);
|
_addCommentView = findViewById(R.id.add_comment_view);
|
||||||
_commentsList = findViewById(R.id.comments_list);
|
_commentsList = findViewById(R.id.comments_list);
|
||||||
|
|
||||||
@@ -376,6 +384,10 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
_imageLikeIcon = findViewById(R.id.image_like_icon);
|
_imageLikeIcon = findViewById(R.id.image_like_icon);
|
||||||
_imageDislikeIcon = findViewById(R.id.image_dislike_icon);
|
_imageDislikeIcon = findViewById(R.id.image_dislike_icon);
|
||||||
|
|
||||||
|
_buttonPolycentric = findViewById(R.id.button_polycentric)
|
||||||
|
_buttonPlatform = findViewById(R.id.button_platform)
|
||||||
|
_buttonRecommended = findViewById(R.id.button_recommended)
|
||||||
|
|
||||||
_monetization = findViewById(R.id.monetization);
|
_monetization = findViewById(R.id.monetization);
|
||||||
_player.attachPlayer();
|
_player.attachPlayer();
|
||||||
|
|
||||||
@@ -429,17 +441,26 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
|
|
||||||
_commentsList.onCommentsLoaded.subscribe { count ->
|
_commentsList.onCommentsLoaded.subscribe { count ->
|
||||||
_commentsCount = count;
|
_commentsCount = count;
|
||||||
updateCommentType(false);
|
//TODO: Why is this here ? updateTabs(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
_toggleCommentType.onValueChanged.subscribe {
|
if (StatePolycentric.instance.enabled) {
|
||||||
updateCommentType(true);
|
_buttonPolycentric.setOnClickListener {
|
||||||
};
|
setTabIndex(0);
|
||||||
|
StateMeta.instance.setLastCommentSection(0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_buttonPolycentric.visibility = View.GONE
|
||||||
|
}
|
||||||
|
|
||||||
_textCommentType.setOnClickListener {
|
_buttonRecommended.setOnClickListener {
|
||||||
_toggleCommentType.setValue(!_toggleCommentType.value, true);
|
setTabIndex(2)
|
||||||
updateCommentType(true);
|
}
|
||||||
};
|
|
||||||
|
_buttonPlatform.setOnClickListener {
|
||||||
|
setTabIndex(1)
|
||||||
|
StateMeta.instance.setLastCommentSection(1);
|
||||||
|
}
|
||||||
|
|
||||||
val layoutTop: LinearLayout = findViewById(R.id.layout_top);
|
val layoutTop: LinearLayout = findViewById(R.id.layout_top);
|
||||||
_container_content_main.removeView(layoutTop);
|
_container_content_main.removeView(layoutTop);
|
||||||
@@ -660,8 +681,8 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
|
|
||||||
Logger.i(TAG, "onAuthorClick: " + c.author.id.value);
|
Logger.i(TAG, "onAuthorClick: " + c.author.id.value);
|
||||||
if(c.author.id.value?.startsWith("polycentric://") ?: false) {
|
if(c.author.id.value?.startsWith("polycentric://") ?: false) {
|
||||||
//val navUrl = "https://harbor.social/" + 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);
|
//val navUrl = "https://polycentric.io/user/" + c.author.id.value?.substring("polycentric://".length);
|
||||||
fragment.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(navUrl)))
|
fragment.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(navUrl)))
|
||||||
//_container_content_browser.goto(navUrl);
|
//_container_content_browser.goto(navUrl);
|
||||||
//switchContentView(_container_content_browser);
|
//switchContentView(_container_content_browser);
|
||||||
@@ -676,7 +697,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
|
|
||||||
if (c is PolycentricPlatformComment) {
|
if (c is PolycentricPlatformComment) {
|
||||||
var parentComment: PolycentricPlatformComment = c;
|
var parentComment: PolycentricPlatformComment = c;
|
||||||
_container_content_replies.load(_toggleCommentType.value, metadata, c.contextUrl, c.reference, c,
|
_container_content_replies.load(if (_tabIndex!! == 0) false else true, metadata, c.contextUrl, c.reference, c,
|
||||||
{ StatePolycentric.instance.getCommentPager(c.contextUrl, c.reference) },
|
{ StatePolycentric.instance.getCommentPager(c.contextUrl, c.reference) },
|
||||||
{
|
{
|
||||||
val newComment = parentComment.cloneWithUpdatedReplyCount((parentComment.replyCount ?: 0) + 1);
|
val newComment = parentComment.cloneWithUpdatedReplyCount((parentComment.replyCount ?: 0) + 1);
|
||||||
@@ -684,7 +705,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
parentComment = newComment;
|
parentComment = newComment;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
_container_content_replies.load(_toggleCommentType.value, metadata, null, null, c, { StatePlatform.instance.getSubComments(c) });
|
_container_content_replies.load(if (_tabIndex!! == 0) false else true, metadata, null, null, c, { StatePlatform.instance.getSubComments(c) });
|
||||||
}
|
}
|
||||||
switchContentView(_container_content_replies);
|
switchContentView(_container_content_replies);
|
||||||
};
|
};
|
||||||
@@ -694,11 +715,23 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
_lastAudioSource = null;
|
_lastAudioSource = null;
|
||||||
_lastSubtitleSource = null;
|
_lastSubtitleSource = null;
|
||||||
video = null;
|
video = null;
|
||||||
|
_player.clear();
|
||||||
cleanupPlaybackTracker();
|
cleanupPlaybackTracker();
|
||||||
Logger.i(TAG, "Keep screen on unset onClose")
|
Logger.i(TAG, "Keep screen on unset onClose")
|
||||||
fragment.activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
fragment.activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
StatePlayer.instance.autoplayChanged.subscribe(this) {
|
||||||
|
if (it) {
|
||||||
|
val url = _url
|
||||||
|
val autoPlayVideo = _autoplayVideo
|
||||||
|
if (url != null && autoPlayVideo == null) {
|
||||||
|
_taskLoadRecommendations.cancel()
|
||||||
|
_taskLoadRecommendations.run(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_layoutResume.setOnClickListener {
|
_layoutResume.setOnClickListener {
|
||||||
handleSeek(_historicalPosition * 1000);
|
handleSeek(_historicalPosition * 1000);
|
||||||
|
|
||||||
@@ -985,6 +1018,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
_container_content_queue.cleanup();
|
_container_content_queue.cleanup();
|
||||||
_container_content_description.cleanup();
|
_container_content_description.cleanup();
|
||||||
_container_content_support.cleanup();
|
_container_content_support.cleanup();
|
||||||
|
StatePlayer.instance.autoplayChanged.remove(this)
|
||||||
StateCasting.instance.onActiveDevicePlayChanged.remove(this);
|
StateCasting.instance.onActiveDevicePlayChanged.remove(this);
|
||||||
StateCasting.instance.onActiveDeviceTimeChanged.remove(this);
|
StateCasting.instance.onActiveDeviceTimeChanged.remove(this);
|
||||||
StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this);
|
StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this);
|
||||||
@@ -1022,7 +1056,6 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
setDescription("".fixHtmlWhitespace());
|
setDescription("".fixHtmlWhitespace());
|
||||||
_descriptionContainer.visibility = View.GONE;
|
_descriptionContainer.visibility = View.GONE;
|
||||||
_player.clear();
|
_player.clear();
|
||||||
_textComments.visibility = View.INVISIBLE;
|
|
||||||
_commentsList.clear();
|
_commentsList.clear();
|
||||||
|
|
||||||
_lastVideoSource = null;
|
_lastVideoSource = null;
|
||||||
@@ -1046,7 +1079,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
setLastPositionMilliseconds(_videoResumePositionMilliseconds, false);
|
setLastPositionMilliseconds(_videoResumePositionMilliseconds, false);
|
||||||
_addCommentView.setContext(null, null);
|
_addCommentView.setContext(null, null);
|
||||||
|
|
||||||
_toggleCommentType.setValue(false, false);
|
setTabIndex(0)
|
||||||
_commentsList.clear();
|
_commentsList.clear();
|
||||||
|
|
||||||
setEmpty();
|
setEmpty();
|
||||||
@@ -1082,16 +1115,17 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
this.video = null;
|
this.video = null;
|
||||||
cleanupPlaybackTracker();
|
cleanupPlaybackTracker();
|
||||||
_searchVideo = video;
|
_searchVideo = video;
|
||||||
|
_autoplayVideo = null
|
||||||
|
Logger.i(TAG, "Autoplay video cleared (setVideoOverview)")
|
||||||
_videoResumePositionMilliseconds = resumeSeconds * 1000;
|
_videoResumePositionMilliseconds = resumeSeconds * 1000;
|
||||||
setLastPositionMilliseconds(_videoResumePositionMilliseconds, false);
|
setLastPositionMilliseconds(_videoResumePositionMilliseconds, false);
|
||||||
_addCommentView.setContext(null, null);
|
_addCommentView.setContext(null, null);
|
||||||
|
|
||||||
_toggleCommentType.setValue(false, false);
|
setTabIndex(null)
|
||||||
|
|
||||||
_title.text = video.name;
|
_title.text = video.name;
|
||||||
_rating.visibility = View.GONE;
|
_rating.visibility = View.GONE;
|
||||||
_layoutRating.visibility = View.GONE;
|
_layoutRating.visibility = View.GONE;
|
||||||
_textComments.visibility = View.VISIBLE;
|
|
||||||
|
|
||||||
_minimize_title.text = video.name;
|
_minimize_title.text = video.name;
|
||||||
_minimize_meta.text = video.author.name;
|
_minimize_meta.text = video.author.name;
|
||||||
@@ -1170,6 +1204,10 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
//@OptIn(ExperimentalCoroutinesApi::class)
|
//@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
fun setVideoDetails(videoDetail: IPlatformVideoDetails, newVideo: Boolean = false) {
|
fun setVideoDetails(videoDetail: IPlatformVideoDetails, newVideo: Boolean = false) {
|
||||||
Logger.i(TAG, "setVideoDetails (${videoDetail.name})")
|
Logger.i(TAG, "setVideoDetails (${videoDetail.name})")
|
||||||
|
_didTriggerDatasourceErrroCount = 0;
|
||||||
|
_didTriggerDatasourceError = false;
|
||||||
|
_autoplayVideo = null
|
||||||
|
Logger.i(TAG, "Autoplay video cleared (setVideoDetails)")
|
||||||
|
|
||||||
if(newVideo && this.video?.url == videoDetail.url)
|
if(newVideo && this.video?.url == videoDetail.url)
|
||||||
return;
|
return;
|
||||||
@@ -1236,18 +1274,25 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
}*/
|
}*/
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
val stopwatch = com.futo.platformplayer.debug.Stopwatch()
|
if(!StateApp.instance.privateMode) {
|
||||||
var tracker = video.getPlaybackTracker()
|
val stopwatch = com.futo.platformplayer.debug.Stopwatch()
|
||||||
Logger.i(TAG, "video.getPlaybackTracker took ${stopwatch.elapsedMs}ms")
|
var tracker = video.getPlaybackTracker()
|
||||||
|
Logger.i(TAG, "video.getPlaybackTracker took ${stopwatch.elapsedMs}ms")
|
||||||
|
|
||||||
if (tracker == null) {
|
if (tracker == null) {
|
||||||
stopwatch.reset()
|
stopwatch.reset()
|
||||||
tracker = StatePlatform.instance.getPlaybackTracker(video.url);
|
tracker = StatePlatform.instance.getPlaybackTracker(video.url);
|
||||||
Logger.i(TAG, "StatePlatform.instance.getPlaybackTracker took ${stopwatch.elapsedMs}ms")
|
Logger.i(
|
||||||
|
TAG,
|
||||||
|
"StatePlatform.instance.getPlaybackTracker took ${stopwatch.elapsedMs}ms"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (me.video == video)
|
||||||
|
me._playbackTracker = tracker;
|
||||||
}
|
}
|
||||||
|
else if(me.video == video)
|
||||||
if(me.video == video)
|
me._playbackTracker = null;
|
||||||
me._playbackTracker = tracker;
|
|
||||||
}
|
}
|
||||||
catch(ex: Throwable) {
|
catch(ex: Throwable) {
|
||||||
Logger.e(TAG, "Playback tracker failed", ex);
|
Logger.e(TAG, "Playback tracker failed", ex);
|
||||||
@@ -1267,13 +1312,19 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
_player.setMetadata(video.name, video.author.name);
|
_player.setMetadata(video.name, video.author.name);
|
||||||
|
|
||||||
if (video is TutorialFragment.TutorialVideo) {
|
if (video is TutorialFragment.TutorialVideo) {
|
||||||
_toggleCommentType.setValue(false, false);
|
setTabIndex(0, true)
|
||||||
} else {
|
} else {
|
||||||
_toggleCommentType.setValue(!Settings.instance.other.polycentricEnabled || Settings.instance.comments.defaultCommentSection == 1, false);
|
if (Settings.instance.comments.recommendationsDefault && !Settings.instance.comments.hideRecommendations) {
|
||||||
|
setTabIndex(2, true)
|
||||||
|
} else {
|
||||||
|
when(Settings.instance.comments.defaultCommentSection) {
|
||||||
|
0 -> if(Settings.instance.other.polycentricEnabled) setTabIndex(0, true) else setTabIndex(1, true);
|
||||||
|
1 -> setTabIndex(1, true);
|
||||||
|
2 -> setTabIndex(StateMeta.instance.getLastCommentSection(), true)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateCommentType(true);
|
|
||||||
|
|
||||||
//UI
|
//UI
|
||||||
_title.text = video.name;
|
_title.text = video.name;
|
||||||
_channelName.text = video.author.name;
|
_channelName.text = video.author.name;
|
||||||
@@ -1300,6 +1351,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
setDescription(video.description.fixHtmlLinks());
|
setDescription(video.description.fixHtmlLinks());
|
||||||
_creatorThumbnail.setThumbnail(video.author.thumbnail, false);
|
_creatorThumbnail.setThumbnail(video.author.thumbnail, false);
|
||||||
|
|
||||||
|
|
||||||
val cachedPolycentricProfile = PolycentricCache.instance.getCachedProfile(video.author.url, true);
|
val cachedPolycentricProfile = PolycentricCache.instance.getCachedProfile(video.author.url, true);
|
||||||
if (cachedPolycentricProfile != null) {
|
if (cachedPolycentricProfile != null) {
|
||||||
setPolycentricProfile(cachedPolycentricProfile, animate = false);
|
setPolycentricProfile(cachedPolycentricProfile, animate = false);
|
||||||
@@ -1451,12 +1503,15 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
StatePlayer.instance.startOrUpdateMediaSession(context, video);
|
StatePlayer.instance.startOrUpdateMediaSession(context, video);
|
||||||
StatePlayer.instance.setCurrentlyPlaying(video);
|
StatePlayer.instance.setCurrentlyPlaying(video);
|
||||||
|
|
||||||
|
_liveChat?.stop();
|
||||||
|
_liveChat = null;
|
||||||
if(video.isLive && video.live != null) {
|
if(video.isLive && video.live != null) {
|
||||||
loadLiveChat(video);
|
loadLiveChat(video);
|
||||||
}
|
}
|
||||||
if(video.isLive && video.live == null && !video.video.videoSources.any())
|
if(video.isLive && video.live == null && !video.video.videoSources.any())
|
||||||
startLiveTry(video);
|
startLiveTry(video);
|
||||||
|
|
||||||
|
|
||||||
_player.updateNextPrevious();
|
_player.updateNextPrevious();
|
||||||
updateMoreButtons();
|
updateMoreButtons();
|
||||||
|
|
||||||
@@ -1465,13 +1520,18 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
_buttonMore.visibility = View.GONE
|
_buttonMore.visibility = View.GONE
|
||||||
_buttonPins.visibility = View.GONE
|
_buttonPins.visibility = View.GONE
|
||||||
_layoutRating.visibility = View.GONE
|
_layoutRating.visibility = View.GONE
|
||||||
_layoutToggleCommentSection.visibility = View.GONE
|
_layoutChangeBottomSection.visibility = View.GONE
|
||||||
} else {
|
} else {
|
||||||
_buttonSubscribe.visibility = View.VISIBLE
|
_buttonSubscribe.visibility = View.VISIBLE
|
||||||
_buttonMore.visibility = View.VISIBLE
|
_buttonMore.visibility = View.VISIBLE
|
||||||
_buttonPins.visibility = View.VISIBLE
|
_buttonPins.visibility = View.VISIBLE
|
||||||
_layoutRating.visibility = View.VISIBLE
|
_layoutRating.visibility = View.VISIBLE
|
||||||
_layoutToggleCommentSection.visibility = View.VISIBLE
|
_layoutChangeBottomSection.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
|
||||||
|
if (StatePlayer.instance.autoplay) {
|
||||||
|
_taskLoadRecommendations.cancel()
|
||||||
|
_taskLoadRecommendations.run(videoDetail.url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fun loadLiveChat(video: IPlatformVideoDetails) {
|
fun loadLiveChat(video: IPlatformVideoDetails) {
|
||||||
@@ -1582,7 +1642,6 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
});
|
});
|
||||||
else
|
else
|
||||||
_player.setArtwork(null);
|
_player.setArtwork(null);
|
||||||
|
|
||||||
_player.setSource(videoSource, audioSource, _playWhenReady, false);
|
_player.setSource(videoSource, audioSource, _playWhenReady, false);
|
||||||
if(subtitleSource != null)
|
if(subtitleSource != null)
|
||||||
_player.swapSubtitles(fragment.lifecycleScope, subtitleSource);
|
_player.swapSubtitles(fragment.lifecycleScope, subtitleSource);
|
||||||
@@ -1647,6 +1706,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var _didTriggerDatasourceErrroCount = 0;
|
||||||
private var _didTriggerDatasourceError = false;
|
private var _didTriggerDatasourceError = false;
|
||||||
private fun onDataSourceError(exception: Throwable) {
|
private fun onDataSourceError(exception: Throwable) {
|
||||||
Logger.e(TAG, "onDataSourceError", exception);
|
Logger.e(TAG, "onDataSourceError", exception);
|
||||||
@@ -1656,26 +1716,49 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
return;
|
return;
|
||||||
val config = currentVideo.sourceConfig;
|
val config = currentVideo.sourceConfig;
|
||||||
|
|
||||||
if(!_didTriggerDatasourceError) {
|
if(_didTriggerDatasourceErrroCount <= 3) {
|
||||||
_didTriggerDatasourceError = true;
|
_didTriggerDatasourceError = true;
|
||||||
|
_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;
|
||||||
|
val previousAudioSource = _lastAudioSource;
|
||||||
|
|
||||||
|
if(newDetails is IPlatformVideoDetails) {
|
||||||
|
val newVideoSource = if(previousVideoSource != null)
|
||||||
|
VideoHelper.selectBestVideoSource(newDetails.video, previousVideoSource.height * previousVideoSource.width, FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS);
|
||||||
|
else null;
|
||||||
|
val newAudioSource = if(previousAudioSource != null)
|
||||||
|
VideoHelper.selectBestAudioSource(newDetails.video, FutoVideoPlayerBase.PREFERED_AUDIO_CONTAINERS, previousAudioSource.language, previousAudioSource.bitrate.toLong());
|
||||||
|
else null;
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
video = newDetails;
|
||||||
|
_player.setSource(newVideoSource, newAudioSource, true, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if(_didTriggerDatasourceErrroCount > 3) {
|
||||||
UIDialogs.showDialog(context, R.drawable.ic_error_pred,
|
UIDialogs.showDialog(context, R.drawable.ic_error_pred,
|
||||||
context.getString(R.string.media_error),
|
context.getString(R.string.media_error),
|
||||||
context.getString(R.string.the_media_source_encountered_an_unauthorized_error_this_might_be_solved_by_a_plugin_reload_would_you_like_to_reload_experimental),
|
context.getString(R.string.the_media_source_encountered_an_unauthorized_error_this_might_be_solved_by_a_plugin_reload_would_you_like_to_reload_experimental),
|
||||||
null,
|
null,
|
||||||
0,
|
0,
|
||||||
UIDialogs.Action(context.getString(R.string.no), { _didTriggerDatasourceError = false }),
|
UIDialogs.Action(context.getString(R.string.no), { _didTriggerDatasourceError = false }),
|
||||||
UIDialogs.Action(context.getString(R.string.yes), {
|
UIDialogs.Action(context.getString(R.string.yes), {
|
||||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
StatePlatform.instance.reloadClient(context, config.id);
|
StatePlatform.instance.reloadClient(context, config.id);
|
||||||
reloadVideo();
|
reloadVideo();
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Logger.e(TAG, "Failed to reload video.", e)
|
Logger.e(TAG, "Failed to reload video.", e)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, UIDialogs.ActionStyle.PRIMARY)
|
}
|
||||||
);
|
}, UIDialogs.ActionStyle.PRIMARY)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1718,6 +1801,14 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
fun nextVideo(forceLoop: Boolean = false, withoutRemoval: Boolean = false, bypassVideoLoop: Boolean = false): Boolean {
|
fun nextVideo(forceLoop: Boolean = false, withoutRemoval: Boolean = false, bypassVideoLoop: Boolean = false): Boolean {
|
||||||
Logger.i(TAG, "nextVideo")
|
Logger.i(TAG, "nextVideo")
|
||||||
var next = StatePlayer.instance.nextQueueItem(withoutRemoval || _player.duration < 100 || (_player.position.toFloat() / _player.duration) < 0.9, bypassVideoLoop);
|
var next = StatePlayer.instance.nextQueueItem(withoutRemoval || _player.duration < 100 || (_player.position.toFloat() / _player.duration) < 0.9, bypassVideoLoop);
|
||||||
|
val autoplayVideo = _autoplayVideo
|
||||||
|
if (next == null && autoplayVideo != null && StatePlayer.instance.autoplay) {
|
||||||
|
Logger.i(TAG, "Found autoplay video!")
|
||||||
|
StatePlayer.instance.setAutoplayed(autoplayVideo.url)
|
||||||
|
next = autoplayVideo
|
||||||
|
}
|
||||||
|
_autoplayVideo = null
|
||||||
|
Logger.i(TAG, "Autoplay video cleared (nextVideo)")
|
||||||
if(next == null && forceLoop)
|
if(next == null && forceLoop)
|
||||||
next = StatePlayer.instance.restartQueue();
|
next = StatePlayer.instance.restartQueue();
|
||||||
if(next != null) {
|
if(next != null) {
|
||||||
@@ -1772,19 +1863,21 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val bestVideoSources = (videoSources?.map { it.height * it.width }
|
val doDedup = Settings.instance.playback.simplifySources;
|
||||||
|
|
||||||
|
val bestVideoSources = if(doDedup) (videoSources?.map { it.height * it.width }
|
||||||
?.distinct()
|
?.distinct()
|
||||||
?.map { x -> VideoHelper.selectBestVideoSource(videoSources.filter { x == it.height * it.width }, -1, FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) }
|
?.map { x -> VideoHelper.selectBestVideoSource(videoSources.filter { x == it.height * it.width }, -1, FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) }
|
||||||
?.plus(videoSources.filter { it is IHLSManifestSource || it is IDashManifestSource }))
|
?.plus(videoSources.filter { it is IHLSManifestSource || it is IDashManifestSource }))
|
||||||
?.distinct()
|
?.distinct()
|
||||||
?.filter { it != null }
|
?.filter { it != null }
|
||||||
?.toList() ?: listOf();
|
?.toList() ?: listOf() else videoSources?.toList() ?: listOf()
|
||||||
val bestAudioContainer = audioSources?.let { VideoHelper.selectBestAudioSource(it, FutoVideoPlayerBase.PREFERED_AUDIO_CONTAINERS)?.container };
|
val bestAudioContainer = audioSources?.let { VideoHelper.selectBestAudioSource(it, FutoVideoPlayerBase.PREFERED_AUDIO_CONTAINERS)?.container };
|
||||||
val bestAudioSources = audioSources
|
val bestAudioSources = if(doDedup) audioSources
|
||||||
?.filter { it.container == bestAudioContainer }
|
?.filter { it.container == bestAudioContainer }
|
||||||
?.plus(audioSources.filter { it is IHLSManifestAudioSource || it is IDashManifestSource })
|
?.plus(audioSources.filter { it is IHLSManifestAudioSource || it is IDashManifestSource })
|
||||||
?.distinct()
|
?.distinct()
|
||||||
?.toList() ?: listOf();
|
?.toList() ?: listOf() else audioSources?.toList() ?: listOf();
|
||||||
|
|
||||||
val canSetSpeed = !_isCasting || StateCasting.instance.activeDevice?.canSetSpeed == true
|
val canSetSpeed = !_isCasting || StateCasting.instance.activeDevice?.canSetSpeed == true
|
||||||
val currentPlaybackRate = if (_isCasting) StateCasting.instance.activeDevice?.speed else _player.getPlaybackRate()
|
val currentPlaybackRate = if (_isCasting) StateCasting.instance.activeDevice?.speed else _player.getPlaybackRate()
|
||||||
@@ -1813,40 +1906,56 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
SlideUpMenuGroup(this.context, context.getString(R.string.offline_video), "video",
|
SlideUpMenuGroup(this.context, context.getString(R.string.offline_video), "video",
|
||||||
*localVideoSources
|
*localVideoSources
|
||||||
.map {
|
.map {
|
||||||
SlideUpMenuItem(this.context, R.drawable.ic_movie, it.name, "${it.width}x${it.height}", it,
|
SlideUpMenuItem(this.context,
|
||||||
{ handleSelectVideoTrack(it) });
|
R.drawable.ic_movie,
|
||||||
|
it.name,
|
||||||
|
"${it.width}x${it.height}",
|
||||||
|
tag = it,
|
||||||
|
call = { handleSelectVideoTrack(it) });
|
||||||
}.toList().toTypedArray())
|
}.toList().toTypedArray())
|
||||||
else null,
|
else null,
|
||||||
if(localAudioSource?.isNotEmpty() == true)
|
if(localAudioSource?.isNotEmpty() == true)
|
||||||
SlideUpMenuGroup(this.context, context.getString(R.string.offline_audio), "audio",
|
SlideUpMenuGroup(this.context, context.getString(R.string.offline_audio), "audio",
|
||||||
*localAudioSource
|
*localAudioSource
|
||||||
.map {
|
.map {
|
||||||
SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), it,
|
SlideUpMenuItem(this.context,
|
||||||
{ handleSelectAudioTrack(it) });
|
R.drawable.ic_music,
|
||||||
|
it.name,
|
||||||
|
it.bitrate.toHumanBitrate(),
|
||||||
|
tag = it,
|
||||||
|
call = { handleSelectAudioTrack(it) });
|
||||||
}.toList().toTypedArray())
|
}.toList().toTypedArray())
|
||||||
else null,
|
else null,
|
||||||
if(localSubtitleSources?.isNotEmpty() == true)
|
if(localSubtitleSources?.isNotEmpty() == true)
|
||||||
SlideUpMenuGroup(this.context, context.getString(R.string.offline_subtitles), "subtitles",
|
SlideUpMenuGroup(this.context, context.getString(R.string.offline_subtitles), "subtitles",
|
||||||
*localSubtitleSources
|
*localSubtitleSources
|
||||||
.map {
|
.map {
|
||||||
SlideUpMenuItem(this.context, R.drawable.ic_edit, it.name, "", it,
|
SlideUpMenuItem(this.context, R.drawable.ic_edit, it.name, "", tag = it,
|
||||||
{ handleSelectSubtitleTrack(it) })
|
call = { handleSelectSubtitleTrack(it) })
|
||||||
}.toList().toTypedArray())
|
}.toList().toTypedArray())
|
||||||
else null,
|
else null,
|
||||||
if(liveStreamVideoFormats?.isEmpty() == false)
|
if(liveStreamVideoFormats?.isEmpty() == false)
|
||||||
SlideUpMenuGroup(this.context, context.getString(R.string.stream_video), "video",
|
SlideUpMenuGroup(this.context, context.getString(R.string.stream_video), "video",
|
||||||
*liveStreamVideoFormats
|
*liveStreamVideoFormats
|
||||||
.map {
|
.map {
|
||||||
SlideUpMenuItem(this.context, R.drawable.ic_movie, it.label ?: it.containerMimeType ?: it.bitrate.toString(), "${it.width}x${it.height}", it,
|
SlideUpMenuItem(this.context,
|
||||||
{ _player.selectVideoTrack(it.height) });
|
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())
|
}.toList().toTypedArray())
|
||||||
else null,
|
else null,
|
||||||
if(liveStreamAudioFormats?.isEmpty() == false)
|
if(liveStreamAudioFormats?.isEmpty() == false)
|
||||||
SlideUpMenuGroup(this.context, context.getString(R.string.stream_audio), "audio",
|
SlideUpMenuGroup(this.context, context.getString(R.string.stream_audio), "audio",
|
||||||
*liveStreamAudioFormats
|
*liveStreamAudioFormats
|
||||||
.map {
|
.map {
|
||||||
SlideUpMenuItem(this.context, R.drawable.ic_music, "${it.label ?: it.containerMimeType} ${it.bitrate}", "", it,
|
SlideUpMenuItem(this.context,
|
||||||
{ _player.selectAudioTrack(it.bitrate) });
|
R.drawable.ic_music,
|
||||||
|
"${it.label ?: it.containerMimeType} ${it.bitrate}",
|
||||||
|
"",
|
||||||
|
tag = it,
|
||||||
|
call = { _player.selectAudioTrack(it.bitrate) });
|
||||||
}.toList().toTypedArray())
|
}.toList().toTypedArray())
|
||||||
else null,
|
else null,
|
||||||
|
|
||||||
@@ -1854,24 +1963,38 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
SlideUpMenuGroup(this.context, context.getString(R.string.video), "video",
|
SlideUpMenuGroup(this.context, context.getString(R.string.video), "video",
|
||||||
*bestVideoSources
|
*bestVideoSources
|
||||||
.map {
|
.map {
|
||||||
SlideUpMenuItem(this.context, R.drawable.ic_movie, it!!.name, if (it.width > 0 && it.height > 0) "${it.width}x${it.height}" else "", it,
|
val estSize = VideoHelper.estimateSourceSize(it);
|
||||||
{ handleSelectVideoTrack(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())
|
}.toList().toTypedArray())
|
||||||
else null,
|
else null,
|
||||||
if(bestAudioSources.isNotEmpty())
|
if(bestAudioSources.isNotEmpty())
|
||||||
SlideUpMenuGroup(this.context, context.getString(R.string.audio), "audio",
|
SlideUpMenuGroup(this.context, context.getString(R.string.audio), "audio",
|
||||||
*bestAudioSources
|
*bestAudioSources
|
||||||
.map {
|
.map {
|
||||||
SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), it,
|
val estSize = VideoHelper.estimateSourceSize(it);
|
||||||
{ handleSelectAudioTrack(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())
|
}.toList().toTypedArray())
|
||||||
else null,
|
else null,
|
||||||
if(video?.subtitles?.isNotEmpty() == true)
|
if(video?.subtitles?.isNotEmpty() == true)
|
||||||
SlideUpMenuGroup(this.context, context.getString(R.string.subtitles), "subtitles",
|
SlideUpMenuGroup(this.context, context.getString(R.string.subtitles), "subtitles",
|
||||||
*video.subtitles
|
*video.subtitles
|
||||||
.map {
|
.map {
|
||||||
SlideUpMenuItem(this.context, R.drawable.ic_edit, it.name, "", it,
|
SlideUpMenuItem(this.context, R.drawable.ic_edit, it.name, "", tag = it,
|
||||||
{ handleSelectSubtitleTrack(it) })
|
call = { handleSelectSubtitleTrack(it) })
|
||||||
}.toList().toTypedArray())
|
}.toList().toTypedArray())
|
||||||
else null);
|
else null);
|
||||||
}
|
}
|
||||||
@@ -2203,24 +2326,93 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateCommentType(reloadComments: Boolean) {
|
private fun setTabIndex(index: Int?, forceReload: Boolean = false) {
|
||||||
if (_toggleCommentType.value) {
|
Logger.i(TAG, "setTabIndex (index: ${index}, forceReload: ${forceReload})")
|
||||||
_textCommentType.text = "Platform";
|
val changed = _tabIndex != index || forceReload
|
||||||
_addCommentView.visibility = View.GONE;
|
if (!changed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (reloadComments) {
|
val recommendationsHidden = Settings.instance.comments.hideRecommendations
|
||||||
fetchComments();
|
_buttonRecommended.visibility = if (recommendationsHidden) View.GONE else View.VISIBLE
|
||||||
}
|
|
||||||
} else {
|
|
||||||
_textCommentType.text = "Polycentric";
|
|
||||||
_addCommentView.visibility = View.VISIBLE;
|
|
||||||
|
|
||||||
if (reloadComments) {
|
_taskLoadRecommendations.cancel()
|
||||||
fetchPolycentricComments()
|
_tabIndex = index
|
||||||
}
|
_buttonRecommended.setTextColor(resources.getColor(if (index == 2) R.color.white else R.color.gray_ac))
|
||||||
|
_buttonPlatform.setTextColor(resources.getColor(if (index == 1) R.color.white else R.color.gray_ac))
|
||||||
|
_buttonPolycentric.setTextColor(resources.getColor(if (index == 0) R.color.white else R.color.gray_ac))
|
||||||
|
_layoutRecommended.removeAllViews()
|
||||||
|
|
||||||
|
if (index == null) {
|
||||||
|
_addCommentView.visibility = View.GONE
|
||||||
|
_commentsList.clear()
|
||||||
|
_layoutRecommended.visibility = View.GONE
|
||||||
|
} else if (index == 0) {
|
||||||
|
_addCommentView.visibility = View.VISIBLE
|
||||||
|
_layoutRecommended.visibility = View.GONE
|
||||||
|
fetchPolycentricComments()
|
||||||
|
} else if (index == 1) {
|
||||||
|
_addCommentView.visibility = View.GONE
|
||||||
|
_layoutRecommended.visibility = View.GONE
|
||||||
|
fetchComments()
|
||||||
|
} else if (index == 2) {
|
||||||
|
_addCommentView.visibility = View.GONE
|
||||||
|
_layoutRecommended.visibility = View.VISIBLE
|
||||||
|
_commentsList.clear()
|
||||||
|
|
||||||
|
_layoutRecommended.addView(LoaderView(context).apply {
|
||||||
|
layoutParams = LinearLayout.LayoutParams(60.dp(resources), 60.dp(resources))
|
||||||
|
start()
|
||||||
|
})
|
||||||
|
_taskLoadRecommendations.run(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun setRecommendations(results: List<IPlatformVideo>?, message: String? = null) {
|
||||||
|
if (results != null && StatePlayer.instance.autoplay) {
|
||||||
|
_autoplayVideo = results.firstOrNull { !StatePlayer.instance.wasAutoplayed(it.url) }
|
||||||
|
Logger.i(TAG, "Autoplay video set (url = ${_autoplayVideo?.url})")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_tabIndex == 2) {
|
||||||
|
_layoutRecommended.removeAllViews()
|
||||||
|
if (results == null || results.isEmpty()) {
|
||||||
|
_layoutRecommended.addView(TextView(context).apply {
|
||||||
|
layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply {
|
||||||
|
setMargins(20.dp(resources), 20.dp(resources), 20.dp(resources), 20.dp(resources))
|
||||||
|
}
|
||||||
|
textAlignment = TEXT_ALIGNMENT_CENTER
|
||||||
|
textSize = 14.0f
|
||||||
|
text = message
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (result in results) {
|
||||||
|
_layoutRecommended.addView(PreviewVideoView(context, FeedStyle.THUMBNAIL, null, false).apply {
|
||||||
|
layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
|
||||||
|
bind(result)
|
||||||
|
|
||||||
|
hideAddTo()
|
||||||
|
|
||||||
|
onVideoClicked.subscribe { video, _ ->
|
||||||
|
fragment.navigate<VideoDetailFragment>(video).maximizeVideoDetail()
|
||||||
|
}
|
||||||
|
|
||||||
|
onChannelClicked.subscribe {
|
||||||
|
fragment.navigate<ChannelFragment>(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
onAddToWatchLaterClicked.subscribe(this) {
|
||||||
|
if(it is IPlatformVideo) {
|
||||||
|
StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(it));
|
||||||
|
UIDialogs.toast("Added to watch later\n[${it.name}]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
//Picture2Picture
|
//Picture2Picture
|
||||||
fun startPictureInPicture() {
|
fun startPictureInPicture() {
|
||||||
@@ -2312,6 +2504,15 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateTracker(positionMilliseconds, isPlaying, false);
|
updateTracker(positionMilliseconds, isPlaying, false);
|
||||||
|
|
||||||
|
if(StateDeveloper.instance.isPlaybackTesting) {
|
||||||
|
if((positionMilliseconds > 1000 * 65 || positionMilliseconds > (video!!.duration * 1000 - 1000))) {
|
||||||
|
StateDeveloper.instance.testPlayback();
|
||||||
|
}
|
||||||
|
else if(video!!.duration > 70 && positionMilliseconds < 10000) {
|
||||||
|
handleSeek(55000);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateTracker(positionMs: Long, isPlaying: Boolean, forceUpdate: Boolean = false) {
|
private fun updateTracker(positionMs: Long, isPlaying: Boolean, forceUpdate: Boolean = false) {
|
||||||
@@ -2557,6 +2758,21 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
}
|
}
|
||||||
} else TaskHandler(IPlatformVideoDetails::class.java, {fragment.lifecycleScope});
|
} else TaskHandler(IPlatformVideoDetails::class.java, {fragment.lifecycleScope});
|
||||||
|
|
||||||
|
private val _taskLoadRecommendations = TaskHandler<String?, IPager<IPlatformContent>?>(StateApp.instance.scopeGetter, {
|
||||||
|
video?.let { v ->
|
||||||
|
if (v is VideoLocal) {
|
||||||
|
StatePlatform.instance.getContentRecommendations(v.url)
|
||||||
|
} else {
|
||||||
|
video?.getContentRecommendations(StatePlatform.instance.getContentClient(v.url))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.success { setRecommendations(it?.getResults()?.filter { it is IPlatformVideo }?.map { it as IPlatformVideo }, "No recommendations found") }
|
||||||
|
.exception<Throwable> {
|
||||||
|
setRecommendations(null, it.message)
|
||||||
|
Logger.w(TAG, "Failed to load recommendations.", it);
|
||||||
|
};
|
||||||
|
|
||||||
private val _taskLoadPolycentricProfile = TaskHandler<PlatformID, PolycentricCache.CachedPolycentricProfile?>(StateApp.instance.scopeGetter, { PolycentricCache.instance.getProfileAsync(it) })
|
private val _taskLoadPolycentricProfile = TaskHandler<PlatformID, PolycentricCache.CachedPolycentricProfile?>(StateApp.instance.scopeGetter, { PolycentricCache.instance.getProfileAsync(it) })
|
||||||
.success { it -> setPolycentricProfile(it, animate = true) }
|
.success { it -> setPolycentricProfile(it, animate = true) }
|
||||||
.exception<Throwable> {
|
.exception<Throwable> {
|
||||||
|
|||||||
@@ -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.streams.sources.IVideoUrlSource
|
||||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
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.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.api.media.platforms.js.models.sources.JSVideoUrlRangeSource
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.others.Language
|
import com.futo.platformplayer.others.Language
|
||||||
@@ -44,8 +47,8 @@ class VideoHelper {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isDownloadable(source: IVideoSource) = source is IVideoUrlSource || source is IHLSManifestSource;
|
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 IAudioUrlWidevineSource
|
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(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? {
|
fun selectBestVideoSource(sources: Iterable<IVideoSource>, desiredPixelCount : Int, prefContainers : Array<String>) : IVideoSource? {
|
||||||
@@ -186,5 +189,25 @@ class VideoHelper {
|
|||||||
return@Resolver dataSpec;
|
return@Resolver dataSpec;
|
||||||
})).createMediaSource(manifest, MediaItem.Builder().setUri(Uri.parse(audioSource.getAudioUrl())).build())
|
})).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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import com.futo.platformplayer.api.media.platforms.js.SourcePluginAuthConfig
|
|||||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||||
import com.futo.platformplayer.constructs.Event1
|
import com.futo.platformplayer.constructs.Event1
|
||||||
import com.futo.platformplayer.constructs.Event2
|
import com.futo.platformplayer.constructs.Event2
|
||||||
|
import com.futo.platformplayer.getSubdomainWildcardQuery
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.matchesDomain
|
import com.futo.platformplayer.matchesDomain
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
@@ -109,8 +110,9 @@ class LoginWebViewClient : WebViewClient {
|
|||||||
//TODO: For now we assume cookies are legit for all subdomains of a top-level domain, this is the most common scenario anyway
|
//TODO: For now we assume cookies are legit for all subdomains of a top-level domain, this is the most common scenario anyway
|
||||||
val cookieString = CookieManager.getInstance().getCookie(request.url.toString());
|
val cookieString = CookieManager.getInstance().getCookie(request.url.toString());
|
||||||
if(cookieString != null) {
|
if(cookieString != null) {
|
||||||
val domainParts = domain!!.split(".");
|
//val domainParts = domain!!.split(".");
|
||||||
val cookieDomain = "." + domainParts.drop(domainParts.size - 2).joinToString(".");
|
//val cookieDomain = "." + domainParts.drop(domainParts.size - 2).joinToString(".");
|
||||||
|
val cookieDomain = domain!!.getSubdomainWildcardQuery();
|
||||||
if(_pluginConfig == null || _pluginConfig.allowUrls.any { it == "everywhere" || it.lowercase().matchesDomain(cookieDomain) })
|
if(_pluginConfig == null || _pluginConfig.allowUrls.any { it == "everywhere" || it.lowercase().matchesDomain(cookieDomain) })
|
||||||
_authConfig.cookiesToFind?.let { cookiesToFind ->
|
_authConfig.cookiesToFind?.let { cookiesToFind ->
|
||||||
val cookies = cookieString.split(";");
|
val cookies = cookieString.split(";");
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.futo.platformplayer.others
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.webkit.CookieManager
|
import android.webkit.CookieManager
|
||||||
import android.webkit.WebResourceRequest
|
import android.webkit.WebResourceRequest
|
||||||
|
import com.futo.platformplayer.getSubdomainWildcardQuery
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.matchesDomain
|
import com.futo.platformplayer.matchesDomain
|
||||||
|
|
||||||
@@ -64,8 +65,8 @@ class WebViewRequirementExtractor {
|
|||||||
//TODO: For now we assume cookies are legit for all subdomains of a top-level domain, this is the most common scenario anyway
|
//TODO: For now we assume cookies are legit for all subdomains of a top-level domain, this is the most common scenario anyway
|
||||||
val cookieString = CookieManager.getInstance().getCookie(request.url.toString());
|
val cookieString = CookieManager.getInstance().getCookie(request.url.toString());
|
||||||
if(cookieString != null) {
|
if(cookieString != null) {
|
||||||
val domainParts = domain!!.split(".");
|
//val domainParts = domain!!.split(".");
|
||||||
val cookieDomain = "." + domainParts.drop(domainParts.size - 2).joinToString(".");
|
val cookieDomain = domain!!.getSubdomainWildcardQuery()//"." + domainParts.drop(domainParts.size - 2).joinToString(".");
|
||||||
if(allowedUrls.any { it == "everywhere" || it.lowercase().matchesDomain(cookieDomain) })
|
if(allowedUrls.any { it == "everywhere" || it.lowercase().matchesDomain(cookieDomain) })
|
||||||
cookiesToFind?.let { cookiesToFind ->
|
cookiesToFind?.let { cookiesToFind ->
|
||||||
val cookies = cookieString.split(";");
|
val cookies = cookieString.split(";");
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import com.futo.platformplayer.constructs.Event1
|
|||||||
|
|
||||||
|
|
||||||
class MediaControlReceiver : BroadcastReceiver() {
|
class MediaControlReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
override fun onReceive(context: Context?, intent: Intent?) {
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
val act = intent?.getStringExtra(EXTRA_MEDIA_ACTION);
|
val act = intent?.getStringExtra(EXTRA_MEDIA_ACTION);
|
||||||
Logger.i(TAG, "Received MediaControl Event $act");
|
Logger.i(TAG, "Received MediaControl Event $act");
|
||||||
|
|||||||
@@ -28,7 +28,12 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.launch
|
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.net.SocketException
|
||||||
|
import java.time.Duration
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
|
|
||||||
class DownloadService : Service() {
|
class DownloadService : Service() {
|
||||||
@@ -44,7 +49,12 @@ class DownloadService : Service() {
|
|||||||
private var _notificationManager: NotificationManager? = null;
|
private var _notificationManager: NotificationManager? = null;
|
||||||
private var _notificationChannel: NotificationChannel? = 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;
|
private var _started = false;
|
||||||
|
|
||||||
@@ -183,14 +193,19 @@ class DownloadService : Service() {
|
|||||||
Logger.w(TAG, "Video Download [${download.name}] expired, re-preparing");
|
Logger.w(TAG, "Video Download [${download.name}] expired, re-preparing");
|
||||||
download.videoDetails = null;
|
download.videoDetails = null;
|
||||||
|
|
||||||
|
if(download.targetVideoName == null && download.videoSource != null)
|
||||||
|
download.targetVideoName = download.videoSource!!.name;
|
||||||
if(download.targetPixelCount == null && download.videoSource != null)
|
if(download.targetPixelCount == null && download.videoSource != null)
|
||||||
download.targetPixelCount = (download.videoSource!!.width * download.videoSource!!.height).toLong();
|
download.targetPixelCount = (download.videoSource!!.width * download.videoSource!!.height).toLong();
|
||||||
download.videoSource = null;
|
download.videoSource = null;
|
||||||
|
|
||||||
|
if(download.targetAudioName == null && download.audioSource != null)
|
||||||
|
download.targetAudioName = download.audioSource!!.name;
|
||||||
if(download.targetBitrate == null && download.audioSource != null)
|
if(download.targetBitrate == null && download.audioSource != null)
|
||||||
download.targetBitrate = download.audioSource!!.bitrate.toLong();
|
download.targetBitrate = download.audioSource!!.bitrate.toLong();
|
||||||
download.audioSource = null;
|
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);
|
download.changeState(VideoDownload.State.PREPARING);
|
||||||
notifyDownload(download);
|
notifyDownload(download);
|
||||||
|
|
||||||
@@ -207,7 +222,7 @@ class DownloadService : Service() {
|
|||||||
download.progress = progress;
|
download.progress = progress;
|
||||||
|
|
||||||
val currentTime = System.currentTimeMillis();
|
val currentTime = System.currentTimeMillis();
|
||||||
if (currentTime - lastNotifyTime > 500) {
|
if (currentTime - lastNotifyTime > 800) {
|
||||||
notifyDownload(download);
|
notifyDownload(download);
|
||||||
lastNotifyTime = currentTime;
|
lastNotifyTime = currentTime;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,18 +55,15 @@ class MediaPlaybackService : Service() {
|
|||||||
private var _notificationChannel: NotificationChannel? = null;
|
private var _notificationChannel: NotificationChannel? = null;
|
||||||
private var _mediaSession: MediaSessionCompat? = null;
|
private var _mediaSession: MediaSessionCompat? = null;
|
||||||
private var _hasFocus: Boolean = false;
|
private var _hasFocus: Boolean = false;
|
||||||
|
private var _isTransientLoss: Boolean = false;
|
||||||
private var _focusRequest: AudioFocusRequest? = null;
|
private var _focusRequest: AudioFocusRequest? = null;
|
||||||
private var _audioFocusLossTime_ms: Long? = null
|
private var _audioFocusLossTime_ms: Long? = null
|
||||||
private var _playbackState = PlaybackStateCompat.STATE_NONE;
|
private var _playbackState = PlaybackStateCompat.STATE_NONE;
|
||||||
|
private var _lastAudioFocusAttempt_ms: Long? = null
|
||||||
private val _updateIntervalMs: Long = 5 * 60 * 1000
|
private val isPlaying get() = _playbackState != PlaybackStateCompat.STATE_PAUSED &&
|
||||||
private val _handler: Handler = Handler(Looper.getMainLooper())
|
_playbackState != PlaybackStateCompat.STATE_STOPPED &&
|
||||||
private val _updateRunnable: Runnable = object : Runnable {
|
_playbackState != PlaybackStateCompat.STATE_NONE &&
|
||||||
override fun run() {
|
_playbackState != PlaybackStateCompat.STATE_ERROR
|
||||||
updateMediaSession(null)
|
|
||||||
_handler.postDelayed(this, _updateIntervalMs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
Logger.v(TAG, "onStartCommand");
|
Logger.v(TAG, "onStartCommand");
|
||||||
@@ -85,8 +82,6 @@ class MediaPlaybackService : Service() {
|
|||||||
|
|
||||||
_callOnStarted?.invoke(this);
|
_callOnStarted?.invoke(this);
|
||||||
_instance = this;
|
_instance = this;
|
||||||
|
|
||||||
_handler.postDelayed(_updateRunnable, _updateIntervalMs)
|
|
||||||
}
|
}
|
||||||
catch(ex: Throwable) {
|
catch(ex: Throwable) {
|
||||||
Logger.e(TAG, "Failed to start MediaPlaybackService due to: " + ex.message, ex);
|
Logger.e(TAG, "Failed to start MediaPlaybackService due to: " + ex.message, ex);
|
||||||
@@ -109,7 +104,7 @@ class MediaPlaybackService : Service() {
|
|||||||
_mediaSession?.setPlaybackState(PlaybackStateCompat.Builder()
|
_mediaSession?.setPlaybackState(PlaybackStateCompat.Builder()
|
||||||
.setState(PlaybackStateCompat.STATE_PLAYING, 0, 1f)
|
.setState(PlaybackStateCompat.STATE_PLAYING, 0, 1f)
|
||||||
.build());
|
.build());
|
||||||
_mediaSession?.setCallback(object: MediaSessionCompat.Callback() {
|
_mediaSession?.setCallback(object : MediaSessionCompat.Callback() {
|
||||||
override fun onSeekTo(pos: Long) {
|
override fun onSeekTo(pos: Long) {
|
||||||
super.onSeekTo(pos)
|
super.onSeekTo(pos)
|
||||||
Logger.i(TAG, "Media session callback onSeekTo(pos = $pos)");
|
Logger.i(TAG, "Media session callback onSeekTo(pos = $pos)");
|
||||||
@@ -131,7 +126,9 @@ class MediaPlaybackService : Service() {
|
|||||||
override fun onStop() {
|
override fun onStop() {
|
||||||
super.onStop();
|
super.onStop();
|
||||||
Logger.i(TAG, "Media session callback onStop()");
|
Logger.i(TAG, "Media session callback onStop()");
|
||||||
MediaControlReceiver.onCloseReceived.emit();
|
//MediaControlReceiver.onCloseReceived.emit();
|
||||||
|
MediaControlReceiver.onPauseReceived.emit();
|
||||||
|
updateMediaSession( null);
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSkipToPrevious() {
|
override fun onSkipToPrevious() {
|
||||||
@@ -156,7 +153,6 @@ class MediaPlaybackService : Service() {
|
|||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
Logger.v(TAG, "onDestroy");
|
Logger.v(TAG, "onDestroy");
|
||||||
_instance = null;
|
_instance = null;
|
||||||
_handler.removeCallbacks(_updateRunnable)
|
|
||||||
MediaControlReceiver.onPauseReceived.emit();
|
MediaControlReceiver.onPauseReceived.emit();
|
||||||
super.onDestroy();
|
super.onDestroy();
|
||||||
}
|
}
|
||||||
@@ -169,12 +165,7 @@ class MediaPlaybackService : Service() {
|
|||||||
Logger.v(TAG, "closeMediaSession");
|
Logger.v(TAG, "closeMediaSession");
|
||||||
stopForeground(STOP_FOREGROUND_REMOVE);
|
stopForeground(STOP_FOREGROUND_REMOVE);
|
||||||
|
|
||||||
val focusRequest = _focusRequest;
|
abandonAudioFocus()
|
||||||
if (focusRequest != null) {
|
|
||||||
_audioManager?.abandonAudioFocusRequest(focusRequest);
|
|
||||||
_focusRequest = null;
|
|
||||||
}
|
|
||||||
_hasFocus = false;
|
|
||||||
|
|
||||||
val notifManager = _notificationManager;
|
val notifManager = _notificationManager;
|
||||||
Logger.i(TAG, "Cancelling playback notification (notifManager: ${notifManager != null})");
|
Logger.i(TAG, "Cancelling playback notification (notifManager: ${notifManager != null})");
|
||||||
@@ -192,10 +183,12 @@ class MediaPlaybackService : Service() {
|
|||||||
Logger.v(TAG, "updateMediaSession");
|
Logger.v(TAG, "updateMediaSession");
|
||||||
var isUpdating = false;
|
var isUpdating = false;
|
||||||
val video: IPlatformVideo;
|
val video: IPlatformVideo;
|
||||||
|
var lastBitmap: Bitmap? = null
|
||||||
if(videoUpdated == null) {
|
if(videoUpdated == null) {
|
||||||
val notifLastVideo = _notif_last_video ?: return;
|
val notifLastVideo = _notif_last_video ?: return;
|
||||||
video = notifLastVideo;
|
video = notifLastVideo;
|
||||||
isUpdating = true;
|
isUpdating = true;
|
||||||
|
lastBitmap = _notif_last_bitmap;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
video = videoUpdated;
|
video = videoUpdated;
|
||||||
@@ -208,6 +201,7 @@ class MediaPlaybackService : Service() {
|
|||||||
.putString(MediaMetadata.METADATA_KEY_ARTIST, video.author.name)
|
.putString(MediaMetadata.METADATA_KEY_ARTIST, video.author.name)
|
||||||
.putString(MediaMetadata.METADATA_KEY_TITLE, video.name)
|
.putString(MediaMetadata.METADATA_KEY_TITLE, video.name)
|
||||||
.putLong(MediaMetadata.METADATA_KEY_DURATION, video.duration * 1000)
|
.putLong(MediaMetadata.METADATA_KEY_DURATION, video.duration * 1000)
|
||||||
|
.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, lastBitmap)
|
||||||
.build());
|
.build());
|
||||||
|
|
||||||
val thumbnail = video.thumbnails.getHQThumbnail();
|
val thumbnail = video.thumbnails.getHQThumbnail();
|
||||||
@@ -223,8 +217,16 @@ class MediaPlaybackService : Service() {
|
|||||||
.load(thumbnail)
|
.load(thumbnail)
|
||||||
.into(object: CustomTarget<Bitmap>() {
|
.into(object: CustomTarget<Bitmap>() {
|
||||||
override fun onResourceReady(resource: Bitmap,transition: Transition<in Bitmap>?) {
|
override fun onResourceReady(resource: Bitmap,transition: Transition<in Bitmap>?) {
|
||||||
if(tag == _notif_last_video)
|
if(tag == _notif_last_video) {
|
||||||
notifyMediaSession(video, resource)
|
notifyMediaSession(video, resource)
|
||||||
|
_mediaSession?.setMetadata(
|
||||||
|
MediaMetadataCompat.Builder()
|
||||||
|
.putString(MediaMetadata.METADATA_KEY_ARTIST, video.author.name)
|
||||||
|
.putString(MediaMetadata.METADATA_KEY_TITLE, video.name)
|
||||||
|
.putLong(MediaMetadata.METADATA_KEY_DURATION, video.duration * 1000)
|
||||||
|
.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, resource)
|
||||||
|
.build());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
override fun onLoadCleared(placeholder: Drawable?) {
|
override fun onLoadCleared(placeholder: Drawable?) {
|
||||||
if(tag == _notif_last_video)
|
if(tag == _notif_last_video)
|
||||||
@@ -345,29 +347,73 @@ class MediaPlaybackService : Service() {
|
|||||||
.setState(state, pos, 1f, SystemClock.elapsedRealtime())
|
.setState(state, pos, 1f, SystemClock.elapsedRealtime())
|
||||||
.build());
|
.build());
|
||||||
|
|
||||||
if(_focusRequest == null)
|
|
||||||
setAudioFocus();
|
|
||||||
|
|
||||||
_playbackState = state;
|
_playbackState = state;
|
||||||
|
try {
|
||||||
|
setAudioFocus()
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to set audio focus", e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//TODO: (TBD) This code probably more fitting inside FutoVideoPlayer, as this service is generally only used for global events
|
//TODO: (TBD) This code probably more fitting inside FutoVideoPlayer, as this service is generally only used for global events
|
||||||
private fun setAudioFocus() {
|
private fun setAudioFocus() {
|
||||||
Log.i(TAG, "Requested audio focus.");
|
if (!isPlaying) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
|
if (_hasFocus || _isTransientLoss) {
|
||||||
.setAcceptsDelayedFocusGain(true)
|
return;
|
||||||
.setOnAudioFocusChangeListener(_audioFocusChangeListener)
|
}
|
||||||
.build()
|
|
||||||
|
|
||||||
_focusRequest = focusRequest;
|
val now = System.currentTimeMillis()
|
||||||
val result = _audioManager?.requestAudioFocus(focusRequest)
|
val lastAudioFocusAttempt_ms = _lastAudioFocusAttempt_ms
|
||||||
|
if (lastAudioFocusAttempt_ms == null || now - lastAudioFocusAttempt_ms > 1000) {
|
||||||
|
_lastAudioFocusAttempt_ms = now
|
||||||
|
} else {
|
||||||
|
Log.v(TAG, "Skipped trying to get audio focus because gaining audio focus was recently attempted.");
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_focusRequest == null) {
|
||||||
|
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");
|
Log.i(TAG, "Audio focus request result $result");
|
||||||
if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
|
if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
|
||||||
//TODO: Handle when not possible to get audio focus
|
_hasFocus = true
|
||||||
_hasFocus = true;
|
_isTransientLoss = false
|
||||||
Log.i(TAG, "Audio focus received");
|
Log.i(TAG, "Audio focus received");
|
||||||
|
} else if (result == AudioManager.AUDIOFOCUS_REQUEST_DELAYED) {
|
||||||
|
_hasFocus = false
|
||||||
|
_isTransientLoss = false
|
||||||
|
Log.i(TAG, "Audio focus delayed, waiting for focus")
|
||||||
|
} else {
|
||||||
|
_hasFocus = false
|
||||||
|
_isTransientLoss = false
|
||||||
|
Log.i(TAG, "Audio focus not granted, retrying later")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Log.i(TAG, "Audio focus requested.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun abandonAudioFocus() {
|
||||||
|
val focusRequest = _focusRequest;
|
||||||
|
if (focusRequest != null) {
|
||||||
|
Logger.i(TAG, "Audio focus abandoned")
|
||||||
|
_audioManager?.abandonAudioFocusRequest(focusRequest);
|
||||||
|
_focusRequest = null;
|
||||||
|
}
|
||||||
|
_hasFocus = false;
|
||||||
|
_isTransientLoss = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private val _audioFocusChangeListener =
|
private val _audioFocusChangeListener =
|
||||||
@@ -375,19 +421,19 @@ class MediaPlaybackService : Service() {
|
|||||||
try {
|
try {
|
||||||
when (focusChange) {
|
when (focusChange) {
|
||||||
AudioManager.AUDIOFOCUS_GAIN -> {
|
AudioManager.AUDIOFOCUS_GAIN -> {
|
||||||
//Do not start playing on gaining audo focus
|
|
||||||
//MediaControlReceiver.onPlayReceived.emit();
|
|
||||||
_hasFocus = true;
|
_hasFocus = true;
|
||||||
Log.i(TAG, "Audio focus gained (restartPlaybackAfterLoss = ${Settings.instance.playback.restartPlaybackAfterLoss}, _audioFocusLossTime_ms = $_audioFocusLossTime_ms)");
|
_isTransientLoss = false;
|
||||||
|
|
||||||
|
val audioFocusLossDuration = _audioFocusLossTime_ms?.let { System.currentTimeMillis() - it }
|
||||||
|
_audioFocusLossTime_ms = null
|
||||||
|
Log.i(TAG, "Audio focus gained (restartPlaybackAfterLoss = ${Settings.instance.playback.restartPlaybackAfterLoss}, _audioFocusLossTime_ms = $_audioFocusLossTime_ms, audioFocusLossDuration = ${audioFocusLossDuration})");
|
||||||
|
|
||||||
if (Settings.instance.playback.restartPlaybackAfterLoss == 1) {
|
if (Settings.instance.playback.restartPlaybackAfterLoss == 1) {
|
||||||
val lossTime_ms = _audioFocusLossTime_ms
|
if (audioFocusLossDuration != null && audioFocusLossDuration < 1000 * 10) {
|
||||||
if (lossTime_ms != null && System.currentTimeMillis() - lossTime_ms < 1000 * 10) {
|
|
||||||
MediaControlReceiver.onPlayReceived.emit()
|
MediaControlReceiver.onPlayReceived.emit()
|
||||||
}
|
}
|
||||||
} else if (Settings.instance.playback.restartPlaybackAfterLoss == 2) {
|
} else if (Settings.instance.playback.restartPlaybackAfterLoss == 2) {
|
||||||
val lossTime_ms = _audioFocusLossTime_ms
|
if (audioFocusLossDuration != null && audioFocusLossDuration < 1000 * 30) {
|
||||||
if (lossTime_ms != null && System.currentTimeMillis() - lossTime_ms < 1000 * 30) {
|
|
||||||
MediaControlReceiver.onPlayReceived.emit()
|
MediaControlReceiver.onPlayReceived.emit()
|
||||||
}
|
}
|
||||||
} else if (Settings.instance.playback.restartPlaybackAfterLoss == 3) {
|
} else if (Settings.instance.playback.restartPlaybackAfterLoss == 3) {
|
||||||
@@ -395,40 +441,32 @@ class MediaPlaybackService : Service() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
|
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
|
||||||
MediaControlReceiver.onPauseReceived.emit();
|
_audioFocusLossTime_ms = if (isPlaying) {
|
||||||
if (_playbackState != PlaybackStateCompat.STATE_PAUSED &&
|
System.currentTimeMillis()
|
||||||
_playbackState != PlaybackStateCompat.STATE_STOPPED &&
|
} else {
|
||||||
_playbackState != PlaybackStateCompat.STATE_NONE &&
|
null
|
||||||
_playbackState != PlaybackStateCompat.STATE_ERROR) {
|
|
||||||
_audioFocusLossTime_ms = System.currentTimeMillis()
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.i(TAG, "Audio focus transient loss");
|
|
||||||
}
|
|
||||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
|
|
||||||
Log.i(TAG, "Audio focus transient loss, can duck");
|
|
||||||
}
|
|
||||||
AudioManager.AUDIOFOCUS_LOSS -> {
|
|
||||||
if (_playbackState != PlaybackStateCompat.STATE_PAUSED &&
|
|
||||||
_playbackState != PlaybackStateCompat.STATE_STOPPED &&
|
|
||||||
_playbackState != PlaybackStateCompat.STATE_NONE &&
|
|
||||||
_playbackState != PlaybackStateCompat.STATE_ERROR) {
|
|
||||||
_audioFocusLossTime_ms = System.currentTimeMillis()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_hasFocus = false;
|
_hasFocus = false;
|
||||||
|
_isTransientLoss = true;
|
||||||
MediaControlReceiver.onPauseReceived.emit();
|
MediaControlReceiver.onPauseReceived.emit();
|
||||||
Log.i(TAG, "Audio focus lost");
|
Log.i(TAG, "Audio focus transient loss (_audioFocusLossTime_ms = ${_audioFocusLossTime_ms})");
|
||||||
|
}
|
||||||
val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
|
||||||
val runningAppProcesses = activityManager.runningAppProcesses
|
Log.i(TAG, "Audio focus transient loss, can duck");
|
||||||
for (processInfo in runningAppProcesses) {
|
_hasFocus = true;
|
||||||
// Check the importance of the running app process
|
_isTransientLoss = true;
|
||||||
if (processInfo.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND) {
|
}
|
||||||
// This app is in the foreground, which might have caused the loss of audio focus
|
AudioManager.AUDIOFOCUS_LOSS -> {
|
||||||
Log.i("AudioFocus", "App ${processInfo.processName} might have caused the loss of audio focus")
|
_audioFocusLossTime_ms = if (isPlaying) {
|
||||||
}
|
System.currentTimeMillis()
|
||||||
|
} else {
|
||||||
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MediaControlReceiver.onPauseReceived.emit();
|
||||||
|
abandonAudioFocus();
|
||||||
|
Log.i(TAG, "Audio focus lost");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch(ex: Throwable) {
|
} catch(ex: Throwable) {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import com.futo.platformplayer.api.media.platforms.js.JSClient
|
|||||||
import com.futo.platformplayer.background.BackgroundWorker
|
import com.futo.platformplayer.background.BackgroundWorker
|
||||||
import com.futo.platformplayer.casting.StateCasting
|
import com.futo.platformplayer.casting.StateCasting
|
||||||
import com.futo.platformplayer.constructs.Event0
|
import com.futo.platformplayer.constructs.Event0
|
||||||
|
import com.futo.platformplayer.constructs.Event1
|
||||||
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.HomeFragment
|
import com.futo.platformplayer.fragment.mainactivity.main.HomeFragment
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.SourceDetailFragment
|
import com.futo.platformplayer.fragment.mainactivity.main.SourceDetailFragment
|
||||||
@@ -56,6 +57,18 @@ class StateApp {
|
|||||||
|
|
||||||
val sessionId = UUID.randomUUID().toString();
|
val sessionId = UUID.randomUUID().toString();
|
||||||
|
|
||||||
|
var privateMode: Boolean = false
|
||||||
|
get(){
|
||||||
|
return field;
|
||||||
|
}
|
||||||
|
private set(value) {
|
||||||
|
field = value;
|
||||||
|
}
|
||||||
|
val privateModeChanged = Event1<Boolean>();
|
||||||
|
fun setPrivacyMode(value: Boolean) {
|
||||||
|
privateMode = value;
|
||||||
|
privateModeChanged.emit(privateMode);
|
||||||
|
}
|
||||||
|
|
||||||
fun getExternalGeneralDirectory(context: Context): DocumentFile? {
|
fun getExternalGeneralDirectory(context: Context): DocumentFile? {
|
||||||
val generalUri = Settings.instance.storage.getStorageGeneralUri();
|
val generalUri = Settings.instance.storage.getStorageGeneralUri();
|
||||||
@@ -599,6 +612,20 @@ class StateApp {
|
|||||||
Settings.instance.didFirstStart = true;
|
Settings.instance.didFirstStart = true;
|
||||||
Settings.instance.save();
|
Settings.instance.save();
|
||||||
}
|
}
|
||||||
|
/*
|
||||||
|
if(!Settings.instance.comments.didAskPolycentricDefault) {
|
||||||
|
UIDialogs.showDialog(context, R.drawable.neopass, "Default Comment Section", "Grayjay supports 2 comment sections, the Platform comments and Polycentric comments. You can easily toggle between them, but which would you like to be selected by default? This choice can be changed in settings.\n\nPolycentric is still under active development.", null, 1,
|
||||||
|
UIDialogs.Action("Polycentric", {
|
||||||
|
Settings.instance.comments.didAskPolycentricDefault = true;
|
||||||
|
Settings.instance.comments.defaultCommentSection = 0;
|
||||||
|
Settings.instance.save();
|
||||||
|
}, UIDialogs.ActionStyle.PRIMARY, true),
|
||||||
|
UIDialogs.Action("Platform", {
|
||||||
|
Settings.instance.comments.didAskPolycentricDefault = true;
|
||||||
|
Settings.instance.comments.defaultCommentSection = 1;
|
||||||
|
Settings.instance.save();
|
||||||
|
}, UIDialogs.ActionStyle.PRIMARY, true))
|
||||||
|
}*/
|
||||||
if(Settings.instance.backup.shouldAutomaticBackup()) {
|
if(Settings.instance.backup.shouldAutomaticBackup()) {
|
||||||
try {
|
try {
|
||||||
StateBackup.startAutomaticBackup();
|
StateBackup.startAutomaticBackup();
|
||||||
@@ -614,21 +641,26 @@ class StateApp {
|
|||||||
|
|
||||||
|
|
||||||
fun scheduleBackgroundWork(context: Context, active: Boolean = true, intervalMinutes: Int = 60 * 12) {
|
fun scheduleBackgroundWork(context: Context, active: Boolean = true, intervalMinutes: Int = 60 * 12) {
|
||||||
val wm = WorkManager.getInstance(context);
|
try {
|
||||||
|
val wm = WorkManager.getInstance(context);
|
||||||
|
|
||||||
if(active) {
|
if(active) {
|
||||||
if(BuildConfig.DEBUG)
|
if(BuildConfig.DEBUG)
|
||||||
UIDialogs.toast(context, "Scheduling background every ${intervalMinutes} minutes");
|
UIDialogs.toast(context, "Scheduling background every ${intervalMinutes} minutes");
|
||||||
|
|
||||||
val req = PeriodicWorkRequest.Builder(BackgroundWorker::class.java, intervalMinutes.toLong(), TimeUnit.MINUTES, 5, TimeUnit.MINUTES)
|
val req = PeriodicWorkRequest.Builder(BackgroundWorker::class.java, intervalMinutes.toLong(), TimeUnit.MINUTES, 5, TimeUnit.MINUTES)
|
||||||
.setConstraints(Constraints.Builder()
|
.setConstraints(Constraints.Builder()
|
||||||
.setRequiredNetworkType(NetworkType.UNMETERED)
|
.setRequiredNetworkType(NetworkType.UNMETERED)
|
||||||
.build())
|
.build())
|
||||||
.build();
|
.build();
|
||||||
wm.enqueueUniquePeriodicWork("backgroundSubscriptions", ExistingPeriodicWorkPolicy.UPDATE, req);
|
wm.enqueueUniquePeriodicWork("backgroundSubscriptions", ExistingPeriodicWorkPolicy.UPDATE, req);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
wm.cancelAllWork();
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to schedule background subscription updates.", e)
|
||||||
|
UIDialogs.toast(context, "Background subscription update failed: " + e.message)
|
||||||
}
|
}
|
||||||
else
|
|
||||||
wm.cancelAllWork();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -64,8 +64,20 @@ class StateCache {
|
|||||||
Logger.i(TAG, "Subscriptions CachePager get pagers");
|
Logger.i(TAG, "Subscriptions CachePager get pagers");
|
||||||
val pagers: List<IPager<IPlatformContent>>;
|
val pagers: List<IPager<IPlatformContent>>;
|
||||||
|
|
||||||
|
val splitAmount = 900;
|
||||||
val timeCacheRetrieving = measureTimeMillis {
|
val timeCacheRetrieving = measureTimeMillis {
|
||||||
pagers = listOf(getAllChannelCachePager(allUrls));
|
if(allUrls.size > splitAmount) {
|
||||||
|
var done = 0;
|
||||||
|
var subsetPagers = mutableListOf<IPager<IPlatformContent>>();
|
||||||
|
while(done < allUrls.size) {
|
||||||
|
val subsetUrls = allUrls.subList(done, Math.min(allUrls.size - 1, done + splitAmount));
|
||||||
|
subsetPagers.add(getAllChannelCachePager(subsetUrls));
|
||||||
|
done += splitAmount;
|
||||||
|
}
|
||||||
|
pagers = subsetPagers;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
pagers = listOf(getAllChannelCachePager(allUrls));
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.i(TAG, "Subscriptions CachePager compiling (retrieved in ${timeCacheRetrieving}ms)");
|
Logger.i(TAG, "Subscriptions CachePager compiling (retrieved in ${timeCacheRetrieving}ms)");
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
package com.futo.platformplayer.states
|
package com.futo.platformplayer.states
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import com.futo.platformplayer.SettingsDev
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
|
import com.futo.platformplayer.activities.MainActivity
|
||||||
import com.futo.platformplayer.api.http.server.ManagedHttpServer
|
import com.futo.platformplayer.api.http.server.ManagedHttpServer
|
||||||
|
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||||
|
import com.futo.platformplayer.api.media.structures.IPager
|
||||||
|
import com.futo.platformplayer.api.media.structures.PlatformContentPager
|
||||||
import com.futo.platformplayer.developer.DeveloperEndpoints
|
import com.futo.platformplayer.developer.DeveloperEndpoints
|
||||||
import com.futo.platformplayer.engine.exceptions.ScriptExecutionException
|
import com.futo.platformplayer.engine.exceptions.ScriptExecutionException
|
||||||
|
import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailView
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlin.system.measureTimeMillis
|
import kotlin.system.measureTimeMillis
|
||||||
|
|
||||||
/***
|
/***
|
||||||
@@ -23,6 +31,12 @@ class StateDeveloper {
|
|||||||
|
|
||||||
var devProxy: DevProxySettings? = null;
|
var devProxy: DevProxySettings? = null;
|
||||||
|
|
||||||
|
var testState: String? = null;
|
||||||
|
val isPlaybackTesting: Boolean get() {
|
||||||
|
return SettingsDev.instance.developerMode && testState == "TestPlayback";
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
fun initializeDev(id: String) {
|
fun initializeDev(id: String) {
|
||||||
currentDevID = id;
|
currentDevID = id;
|
||||||
synchronized(_devLogs) {
|
synchronized(_devLogs) {
|
||||||
@@ -135,6 +149,37 @@ class StateDeveloper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private var homePager: IPager<IPlatformContent>? = null;
|
||||||
|
private var pagerIndex = 0;
|
||||||
|
fun testPlayback(){
|
||||||
|
val mainActivity = if(StateApp.instance.isMainActive) StateApp.instance.context as MainActivity else return;
|
||||||
|
StateApp.instance.scope.launch(Dispatchers.IO) {
|
||||||
|
if(homePager == null)
|
||||||
|
homePager = StatePlatform.instance.getHome();
|
||||||
|
var pager = homePager ?: return@launch;
|
||||||
|
pagerIndex++;
|
||||||
|
val video = if(pager.getResults().size <= pagerIndex) {
|
||||||
|
if(!pager.hasMorePages()) {
|
||||||
|
homePager = StatePlatform.instance.getHome();
|
||||||
|
pager = homePager as IPager<IPlatformContent>;
|
||||||
|
}
|
||||||
|
pager.nextPage();
|
||||||
|
pagerIndex = 0;
|
||||||
|
val results = pager.getResults();
|
||||||
|
if(results.size <= 0)
|
||||||
|
null;
|
||||||
|
else
|
||||||
|
results[0];
|
||||||
|
}
|
||||||
|
else
|
||||||
|
pager.getResults()[pagerIndex];
|
||||||
|
|
||||||
|
StateApp.instance.scope.launch(Dispatchers.Main) {
|
||||||
|
mainActivity.navigate(mainActivity._fragVideoDetail, video);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val DEV_ID = "DEV";
|
const val DEV_ID = "DEV";
|
||||||
|
|
||||||
@@ -152,6 +197,7 @@ class StateDeveloper {
|
|||||||
it._server?.stop();
|
it._server?.stop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@kotlinx.serialization.Serializable
|
@kotlinx.serialization.Serializable
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import com.futo.platformplayer.Settings
|
|||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||||
import com.futo.platformplayer.api.media.PlatformID
|
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.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.IVideoUrlSource
|
||||||
import com.futo.platformplayer.api.media.models.streams.sources.LocalAudioSource
|
import com.futo.platformplayer.api.media.models.streams.sources.LocalAudioSource
|
||||||
import com.futo.platformplayer.api.media.models.streams.sources.LocalSubtitleSource
|
import com.futo.platformplayer.api.media.models.streams.sources.LocalSubtitleSource
|
||||||
@@ -334,7 +336,7 @@ class StateDownloads {
|
|||||||
fun download(video: IPlatformVideo, targetPixelcount: Long?, targetBitrate: Long?) {
|
fun download(video: IPlatformVideo, targetPixelcount: Long?, targetBitrate: Long?) {
|
||||||
download(VideoDownload(video, targetPixelcount, targetBitrate));
|
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));
|
download(VideoDownload(video, videoSource, audioSource, subtitleSource));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ class StateHistory {
|
|||||||
return historyIndex[url];
|
return historyIndex[url];
|
||||||
}
|
}
|
||||||
fun getHistoryByVideo(video: IPlatformVideo, create: Boolean = false, watchDate: OffsetDateTime? = null): DBHistory.Index? {
|
fun getHistoryByVideo(video: IPlatformVideo, create: Boolean = false, watchDate: OffsetDateTime? = null): DBHistory.Index? {
|
||||||
|
if(StateApp.instance.privateMode)
|
||||||
|
return null;
|
||||||
val existing = historyIndex[video.url];
|
val existing = historyIndex[video.url];
|
||||||
var result: DBHistory.Index? = null;
|
var result: DBHistory.Index? = null;
|
||||||
if(existing != null) {
|
if(existing != null) {
|
||||||
@@ -113,6 +115,19 @@ class StateHistory {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun markAsWatched(video: IPlatformVideo) {
|
||||||
|
try {
|
||||||
|
val history = getHistoryByVideo(video, true, OffsetDateTime.now());
|
||||||
|
if (history != null) {
|
||||||
|
updateHistoryPosition(video, history, true, Math.max(1, video.duration - 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch(ex: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to mark as watched", ex);
|
||||||
|
UIDialogs.toast("Failed to mark as watched\n" + ex.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun removeHistory(url: String) {
|
fun removeHistory(url: String) {
|
||||||
val hist = getHistoryIndexByUrl(url);
|
val hist = getHistoryIndexByUrl(url);
|
||||||
if(hist != null)
|
if(hist != null)
|
||||||
|
|||||||
@@ -2,11 +2,29 @@ package com.futo.platformplayer.states
|
|||||||
|
|
||||||
import com.futo.platformplayer.stores.FragmentedStorage
|
import com.futo.platformplayer.stores.FragmentedStorage
|
||||||
import com.futo.platformplayer.stores.StringHashSetStorage
|
import com.futo.platformplayer.stores.StringHashSetStorage
|
||||||
|
import com.futo.platformplayer.stores.StringStorage
|
||||||
|
|
||||||
class StateMeta {
|
class StateMeta {
|
||||||
val hiddenVideos = FragmentedStorage.get<StringHashSetStorage>("hiddenVideos");
|
val hiddenVideos = FragmentedStorage.get<StringHashSetStorage>("hiddenVideos");
|
||||||
val hiddenCreators = FragmentedStorage.get<StringHashSetStorage>("hiddenCreators");
|
val hiddenCreators = FragmentedStorage.get<StringHashSetStorage>("hiddenCreators");
|
||||||
|
|
||||||
|
val lastCommentSection = FragmentedStorage.get<StringStorage>("defaultCommentSection");
|
||||||
|
|
||||||
|
fun getLastCommentSection(): Int{
|
||||||
|
return when(lastCommentSection.value){
|
||||||
|
"Polycentric" -> 0;
|
||||||
|
"Platform" -> 1;
|
||||||
|
else -> 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun setLastCommentSection(value: Int) {
|
||||||
|
when(value) {
|
||||||
|
0 -> lastCommentSection.setAndSave("Polycentric");
|
||||||
|
1 -> lastCommentSection.setAndSave("Platform");
|
||||||
|
else -> lastCommentSection.setAndSave("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun isVideoHidden(videoUrl: String) : Boolean {
|
fun isVideoHidden(videoUrl: String) : Boolean {
|
||||||
return hiddenVideos.contains(videoUrl);
|
return hiddenVideos.contains(videoUrl);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ class StatePlatform {
|
|||||||
private val _channelClientPool = PlatformMultiClientPool("Channels", 15); //Used primarily for subscription/background channel fetches
|
private val _channelClientPool = PlatformMultiClientPool("Channels", 15); //Used primarily for subscription/background channel fetches
|
||||||
private val _trackerClientPool = PlatformMultiClientPool("Trackers", 1); //Used exclusively for playback trackers
|
private val _trackerClientPool = PlatformMultiClientPool("Trackers", 1); //Used exclusively for playback trackers
|
||||||
private val _liveEventClientPool = PlatformMultiClientPool("LiveEvents", 1); //Used exclusively for live events
|
private val _liveEventClientPool = PlatformMultiClientPool("LiveEvents", 1); //Used exclusively for live events
|
||||||
|
private val _privateClientPool = PlatformMultiClientPool("Private", 2, true); //Used primarily for calls if in incognito mode
|
||||||
|
|
||||||
|
|
||||||
private val _icons : HashMap<String, ImageVariable> = HashMap();
|
private val _icons : HashMap<String, ImageVariable> = HashMap();
|
||||||
@@ -109,13 +110,24 @@ class StatePlatform {
|
|||||||
//Batched Requests
|
//Batched Requests
|
||||||
private val _batchTaskGetVideoDetails: BatchedTaskHandler<String, IPlatformContentDetails> = BatchedTaskHandler<String, IPlatformContentDetails>(_scope,
|
private val _batchTaskGetVideoDetails: BatchedTaskHandler<String, IPlatformContentDetails> = BatchedTaskHandler<String, IPlatformContentDetails>(_scope,
|
||||||
{ url ->
|
{ url ->
|
||||||
|
|
||||||
Logger.i(StatePlatform::class.java.name, "Fetching video details [${url}]");
|
Logger.i(StatePlatform::class.java.name, "Fetching video details [${url}]");
|
||||||
_enabledClients.find { it.isContentDetailsUrl(url) }?.let {
|
if(!StateApp.instance.privateMode) {
|
||||||
_mainClientPool.getClientPooled(it).getContentDetails(url)
|
_enabledClients.find { it.isContentDetailsUrl(url) }?.let {
|
||||||
} ?: throw NoPlatformClientException("No client enabled that supports this url ($url)");
|
_mainClientPool.getClientPooled(it).getContentDetails(url)
|
||||||
|
}
|
||||||
|
?: throw NoPlatformClientException("No client enabled that supports this url ($url)");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Logger.i(TAG, "Fetching details with private client");
|
||||||
|
_enabledClients.find { it.isContentDetailsUrl(url) }?.let {
|
||||||
|
_privateClientPool.getClientPooled(it).getContentDetails(url)
|
||||||
|
}
|
||||||
|
?: throw NoPlatformClientException("No client enabled that supports this url ($url)");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
if(!Settings.instance.browsing.videoCache)
|
if(!Settings.instance.browsing.videoCache || StateApp.instance.privateMode)
|
||||||
return@BatchedTaskHandler null;
|
return@BatchedTaskHandler null;
|
||||||
else {
|
else {
|
||||||
val cached = synchronized(_cache) { _cache.get(it); } ?: return@BatchedTaskHandler null;
|
val cached = synchronized(_cache) { _cache.get(it); } ?: return@BatchedTaskHandler null;
|
||||||
@@ -131,7 +143,7 @@ class StatePlatform {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ para, result ->
|
{ para, result ->
|
||||||
if(!Settings.instance.browsing.videoCache || (result is IPlatformVideo && result.isLive))
|
if(!Settings.instance.browsing.videoCache || (result is IPlatformVideo && result.isLive) || StateApp.instance.privateMode)
|
||||||
return@BatchedTaskHandler
|
return@BatchedTaskHandler
|
||||||
else {
|
else {
|
||||||
Logger.i(TAG, "Caching [${para}]");
|
Logger.i(TAG, "Caching [${para}]");
|
||||||
@@ -197,7 +209,17 @@ class StatePlatform {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if(_availableClients.distinctBy { it.id }.count() < _availableClients.size) {
|
if(_availableClients.distinctBy { it.id }.count() < _availableClients.size) {
|
||||||
throw IllegalStateException("Attempted to add 2 clients with the same ID");
|
val dups = _availableClients.filter { x-> _availableClients.count { it.id == x.id } > 1 };
|
||||||
|
val overrideClients = _availableClients.distinctBy { it.id }
|
||||||
|
_availableClients.clear();
|
||||||
|
_availableClients.addAll(overrideClients);
|
||||||
|
|
||||||
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||||
|
UIDialogs.showDialog(context, R.drawable.ic_error_pred, "Duplicate plugin ids detected", "This can cause unexpected behavior, ideally uninstall duplicate plugins (ids)",
|
||||||
|
dups.map { it.name }.joinToString("\n"), 0, UIDialogs.Action("Ok", { }));
|
||||||
|
}
|
||||||
|
|
||||||
|
//throw IllegalStateException("Attempted to add 2 clients with the same ID");
|
||||||
}
|
}
|
||||||
|
|
||||||
enabled = _enabledClientsPersistent.getAllValues()
|
enabled = _enabledClientsPersistent.getAllValues()
|
||||||
@@ -871,7 +893,10 @@ class StatePlatform {
|
|||||||
if(!client.capabilities.hasGetComments)
|
if(!client.capabilities.hasGetComments)
|
||||||
return EmptyPager();
|
return EmptyPager();
|
||||||
|
|
||||||
return client.fromPool(_mainClientPool).getComments(url);
|
if(!StateApp.instance.privateMode)
|
||||||
|
return client.fromPool(_mainClientPool).getComments(url);
|
||||||
|
else
|
||||||
|
return client.fromPool(_privateClientPool).getComments(url);
|
||||||
}
|
}
|
||||||
fun getSubComments(comment: IPlatformComment): IPager<IPlatformComment> {
|
fun getSubComments(comment: IPlatformComment): IPager<IPlatformComment> {
|
||||||
Logger.i(TAG, "Platform - getSubComments");
|
Logger.i(TAG, "Platform - getSubComments");
|
||||||
@@ -882,7 +907,11 @@ class StatePlatform {
|
|||||||
fun getLiveEvents(url: String): IPager<IPlatformLiveEvent>? {
|
fun getLiveEvents(url: String): IPager<IPlatformLiveEvent>? {
|
||||||
Logger.i(TAG, "Platform - getLiveChat");
|
Logger.i(TAG, "Platform - getLiveChat");
|
||||||
var client = getContentClient(url);
|
var client = getContentClient(url);
|
||||||
return client.fromPool(_liveEventClientPool).getLiveEvents(url);
|
|
||||||
|
if(!StateApp.instance.privateMode)
|
||||||
|
return client.fromPool(_liveEventClientPool).getLiveEvents(url);
|
||||||
|
else
|
||||||
|
return client.fromPool(_privateClientPool).getLiveEvents(url);
|
||||||
}
|
}
|
||||||
fun getLiveChatWindow(url: String): ILiveChatWindowDescriptor? {
|
fun getLiveChatWindow(url: String): ILiveChatWindowDescriptor? {
|
||||||
Logger.i(TAG, "Platform - getLiveChat");
|
Logger.i(TAG, "Platform - getLiveChat");
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import androidx.media3.exoplayer.DefaultLoadControl
|
|||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
import androidx.media3.exoplayer.upstream.DefaultAllocator
|
import androidx.media3.exoplayer.upstream.DefaultAllocator
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
|
import com.futo.platformplayer.Settings
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
|
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
|
||||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||||
@@ -38,7 +39,40 @@ class StatePlayer {
|
|||||||
private var _thumbnailExoPlayer : PlayerManager? = null;
|
private var _thumbnailExoPlayer : PlayerManager? = null;
|
||||||
|
|
||||||
//Video Status
|
//Video Status
|
||||||
var rotationLock : Boolean = false;
|
var rotationLock: Boolean = false
|
||||||
|
get() = field
|
||||||
|
set(value) {
|
||||||
|
field = value
|
||||||
|
onRotationLockChanged.emit(value)
|
||||||
|
}
|
||||||
|
val onRotationLockChanged = Event1<Boolean>()
|
||||||
|
var autoplay: Boolean = Settings.instance.playback.autoplay
|
||||||
|
get() = field
|
||||||
|
set(value) {
|
||||||
|
if (field != value)
|
||||||
|
_autoplayed.clear()
|
||||||
|
field = value
|
||||||
|
autoplayChanged.emit(value)
|
||||||
|
}
|
||||||
|
private val _autoplayed = hashSetOf<String>()
|
||||||
|
fun wasAutoplayed(url: String?): Boolean {
|
||||||
|
if (url == null) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
synchronized(_autoplayed) {
|
||||||
|
return _autoplayed.contains(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun setAutoplayed(url: String?) {
|
||||||
|
if (url == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
synchronized(_autoplayed) {
|
||||||
|
_autoplayed.add(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val autoplayChanged = Event1<Boolean>()
|
||||||
var loopVideo : Boolean = false;
|
var loopVideo : Boolean = false;
|
||||||
|
|
||||||
val isPlaying: Boolean get() = _exoplayer?.player?.playWhenReady ?: false;
|
val isPlaying: Boolean get() = _exoplayer?.player?.playWhenReady ?: false;
|
||||||
@@ -132,6 +166,12 @@ class StatePlayer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun isUrlInQueue(url : String) : Boolean {
|
||||||
|
synchronized(_queue) {
|
||||||
|
return _queue.any { it.url == url };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun getQueueType() : String {
|
fun getQueueType() : String {
|
||||||
return _queueType;
|
return _queueType;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,8 +61,17 @@ class StatePlaylists {
|
|||||||
}
|
}
|
||||||
fun updateWatchLater(updated: List<SerializedPlatformVideo>) {
|
fun updateWatchLater(updated: List<SerializedPlatformVideo>) {
|
||||||
synchronized(_watchlistStore) {
|
synchronized(_watchlistStore) {
|
||||||
_watchlistStore.deleteAll();
|
//_watchlistStore.deleteAll();
|
||||||
_watchlistStore.saveAllAsync(updated);
|
val existing = _watchlistStore.getItems();
|
||||||
|
val toAdd = updated.filter { u -> !existing.any { u.url == it.url } };
|
||||||
|
val toRemove = existing.filter { u -> !updated.any { u.url == it.url } };
|
||||||
|
Logger.i(TAG, "WatchLater changed:\nTo Add:\n" +
|
||||||
|
(if(toAdd.size == 0) "None" else toAdd.map { " + " + it.name }.joinToString("\n")) +
|
||||||
|
"\nTo Remove:\n" +
|
||||||
|
(if(toRemove.size == 0) "None" else toRemove.map { " - " + it.name }.joinToString("\n")));
|
||||||
|
for(remove in toRemove)
|
||||||
|
_watchlistStore.delete(remove);
|
||||||
|
_watchlistStore.saveAllAsync(toAdd);
|
||||||
_watchlistOrderStore.set(*updated.map { it.url }.toTypedArray());
|
_watchlistOrderStore.set(*updated.map { it.url }.toTypedArray());
|
||||||
_watchlistOrderStore.save();
|
_watchlistOrderStore.save();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import com.futo.platformplayer.api.media.platforms.js.SourceAuth
|
|||||||
import com.futo.platformplayer.api.media.platforms.js.SourceCaptchaData
|
import com.futo.platformplayer.api.media.platforms.js.SourceCaptchaData
|
||||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor
|
import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor
|
||||||
|
import com.futo.platformplayer.fragment.mainactivity.main.SourceDetailFragment
|
||||||
|
import com.futo.platformplayer.fragment.mainactivity.main.SourceDetailFragment.Companion
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.models.ImageVariable
|
import com.futo.platformplayer.models.ImageVariable
|
||||||
import com.futo.platformplayer.stores.FragmentedStorage
|
import com.futo.platformplayer.stores.FragmentedStorage
|
||||||
@@ -128,7 +130,15 @@ class StatePlugins {
|
|||||||
return false;
|
return false;
|
||||||
|
|
||||||
LoginActivity.showLogin(context, config) {
|
LoginActivity.showLogin(context, config) {
|
||||||
StatePlugins.instance.setPluginAuth(config.id, it);
|
try {
|
||||||
|
StatePlugins.instance.setPluginAuth(config.id, it);
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||||
|
UIDialogs.showGeneralErrorDialog(context, "Failed to set plugin authentication (loginPlugin)", e)
|
||||||
|
}
|
||||||
|
Logger.e(SourceDetailFragment.TAG, "Failed to set plugin authentication (loginPlugin)", e)
|
||||||
|
return@showLogin
|
||||||
|
}
|
||||||
|
|
||||||
StateApp.instance.scope.launch(Dispatchers.IO) {
|
StateApp.instance.scope.launch(Dispatchers.IO) {
|
||||||
StatePlatform.instance.reloadClient(context, id);
|
StatePlatform.instance.reloadClient(context, id);
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ import com.futo.platformplayer.states.StateApp
|
|||||||
import com.futo.platformplayer.stores.v2.JsonStoreSerializer
|
import com.futo.platformplayer.stores.v2.JsonStoreSerializer
|
||||||
import com.futo.platformplayer.stores.v2.StoreSerializer
|
import com.futo.platformplayer.stores.v2.StoreSerializer
|
||||||
import kotlinx.serialization.KSerializer
|
import kotlinx.serialization.KSerializer
|
||||||
import java.lang.IllegalArgumentException
|
|
||||||
import java.lang.reflect.Field
|
import java.lang.reflect.Field
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import java.util.concurrent.ConcurrentMap
|
import java.util.concurrent.ConcurrentMap
|
||||||
|
import kotlin.IllegalArgumentException
|
||||||
import kotlin.reflect.KClass
|
import kotlin.reflect.KClass
|
||||||
import kotlin.reflect.KProperty
|
import kotlin.reflect.KProperty
|
||||||
import kotlin.reflect.KType
|
import kotlin.reflect.KType
|
||||||
@@ -318,8 +318,12 @@ class ManagedDBStore<I: ManagedDBIndex<T>, T, D: ManagedDBDatabase<T, I, DA>, DA
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val inLimit = 990;
|
||||||
fun <X> queryInPager(field: KProperty<*>, obj: List<String>, pageSize: Int, convert: (I)->X): IPager<X> = queryInPager(validateFieldName(field), obj, pageSize, convert);
|
fun <X> queryInPager(field: KProperty<*>, obj: List<String>, pageSize: Int, convert: (I)->X): IPager<X> = queryInPager(validateFieldName(field), obj, pageSize, convert);
|
||||||
fun <X> queryInPager(field: String, obj: List<String>, pageSize: Int, convert: (I)->X): IPager<X> {
|
fun <X> queryInPager(field: String, obj: List<String>, pageSize: Int, convert: (I)->X): IPager<X> {
|
||||||
|
if(obj.size > inLimit) {
|
||||||
|
throw IllegalArgumentException("Too many objects requested (IN query), create subqueries of ${inLimit}");
|
||||||
|
}
|
||||||
return AdhocPager({
|
return AdhocPager({
|
||||||
queryInPage(field, obj, it - 1, pageSize).map(convert);
|
queryInPage(field, obj, it - 1, pageSize).map(convert);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ enum class FeedStyle(val value: Int) {
|
|||||||
|
|
||||||
fun fromInt(value: Int): FeedStyle
|
fun fromInt(value: Int): FeedStyle
|
||||||
{
|
{
|
||||||
val result = FeedStyle.values().firstOrNull { it.value == value };
|
val result = FeedStyle.entries.firstOrNull { it.value == value };
|
||||||
if(result == null)
|
if(result == null)
|
||||||
throw UnknownPlatformException(value.toString());
|
throw UnknownPlatformException(value.toString());
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
+17
-5
@@ -130,6 +130,11 @@ open class PreviewVideoView : LinearLayout {
|
|||||||
_button_add_to_watch_later.setOnClickListener { currentVideo?.let { onAddToWatchLaterClicked.emit(it); } }
|
_button_add_to_watch_later.setOnClickListener { currentVideo?.let { onAddToWatchLaterClicked.emit(it); } }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun hideAddTo() {
|
||||||
|
_button_add_to.visibility = View.GONE
|
||||||
|
_button_add_to_queue.visibility = View.GONE
|
||||||
|
}
|
||||||
|
|
||||||
protected open fun inflate(feedStyle: FeedStyle) {
|
protected open fun inflate(feedStyle: FeedStyle) {
|
||||||
inflate(context, when(feedStyle) {
|
inflate(context, when(feedStyle) {
|
||||||
FeedStyle.PREVIEW -> R.layout.list_video_preview
|
FeedStyle.PREVIEW -> R.layout.list_video_preview
|
||||||
@@ -165,11 +170,18 @@ open class PreviewVideoView : LinearLayout {
|
|||||||
|
|
||||||
_imageNeopassChannel?.visibility = View.GONE;
|
_imageNeopassChannel?.visibility = View.GONE;
|
||||||
_creatorThumbnail?.setThumbnail(content.author.thumbnail, false);
|
_creatorThumbnail?.setThumbnail(content.author.thumbnail, false);
|
||||||
_imageChannel?.let {
|
|
||||||
Glide.with(_imageChannel)
|
val thumbnail = content.author.thumbnail
|
||||||
.load(content.author.thumbnail)
|
if (thumbnail != null) {
|
||||||
.placeholder(R.drawable.placeholder_channel_thumbnail)
|
_imageChannel?.visibility = View.VISIBLE
|
||||||
.into(_imageChannel);
|
_imageChannel?.let {
|
||||||
|
Glide.with(_imageChannel)
|
||||||
|
.load(content.author.thumbnail)
|
||||||
|
.placeholder(R.drawable.placeholder_channel_thumbnail)
|
||||||
|
.into(_imageChannel);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_imageChannel?.visibility = View.GONE
|
||||||
}
|
}
|
||||||
|
|
||||||
_textChannelName.text = content.author.name
|
_textChannelName.text = content.author.name
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ annotation class FormField(val title: Int, val type: String, val subtitle: Int =
|
|||||||
@Retention(AnnotationRetention.RUNTIME)
|
@Retention(AnnotationRetention.RUNTIME)
|
||||||
annotation class FormFieldWarning(val messageRes: Int)
|
annotation class FormFieldWarning(val messageRes: Int)
|
||||||
|
|
||||||
|
@Target(AnnotationTarget.FIELD, AnnotationTarget.PROPERTY)
|
||||||
|
@Retention(AnnotationRetention.RUNTIME)
|
||||||
|
annotation class FormFieldHint(val messageRes: Int)
|
||||||
|
|
||||||
interface IField {
|
interface IField {
|
||||||
var descriptor: FormField?;
|
var descriptor: FormField?;
|
||||||
val obj : Any?;
|
val obj : Any?;
|
||||||
|
|||||||
@@ -293,6 +293,12 @@ class FieldForm : LinearLayout {
|
|||||||
}, UIDialogs.ActionStyle.PRIMARY));
|
}, 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.ImageView
|
||||||
import android.widget.RelativeLayout
|
import android.widget.RelativeLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
import androidx.core.view.isVisible
|
||||||
import com.futo.platformplayer.R
|
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 _image: ImageView;
|
||||||
private lateinit var _text: TextView;
|
private lateinit var _text: TextView;
|
||||||
private lateinit var _subtext: TextView;
|
private lateinit var _subtext: TextView;
|
||||||
|
private lateinit var _description: TextView;
|
||||||
|
|
||||||
var selectedOption: Boolean = false;
|
var selectedOption: Boolean = false;
|
||||||
|
|
||||||
@@ -25,11 +28,27 @@ class SlideUpMenuItem : RelativeLayout {
|
|||||||
init();
|
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();
|
init();
|
||||||
_image.setImageResource(imageRes);
|
_image.setImageResource(imageRes);
|
||||||
_text.text = mainText;
|
_text.text = mainText;
|
||||||
_subtext.text = subText;
|
_subtext.text = subText;
|
||||||
|
|
||||||
|
if(description.isNullOrEmpty())
|
||||||
|
_description.isVisible = false;
|
||||||
|
else {
|
||||||
|
_description.text = description;
|
||||||
|
_description.isVisible = true;
|
||||||
|
}
|
||||||
this.itemTag = tag;
|
this.itemTag = tag;
|
||||||
|
|
||||||
if (call != null) {
|
if (call != null) {
|
||||||
@@ -48,6 +67,7 @@ class SlideUpMenuItem : RelativeLayout {
|
|||||||
_image = findViewById(R.id.slide_up_menu_item_image);
|
_image = findViewById(R.id.slide_up_menu_item_image);
|
||||||
_text = findViewById(R.id.slide_up_menu_item_text);
|
_text = findViewById(R.id.slide_up_menu_item_text);
|
||||||
_subtext = findViewById(R.id.slide_up_menu_item_subtext);
|
_subtext = findViewById(R.id.slide_up_menu_item_subtext);
|
||||||
|
_description = findViewById(R.id.slide_up_menu_item_description);
|
||||||
|
|
||||||
setOptionSelected(false);
|
setOptionSelected(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -254,6 +254,7 @@ class CommentsList : ConstraintLayout {
|
|||||||
|
|
||||||
fun clear() {
|
fun clear() {
|
||||||
cancel();
|
cancel();
|
||||||
|
setLoading(false);
|
||||||
_comments.clear();
|
_comments.clear();
|
||||||
_commentsPager = null;
|
_commentsPager = null;
|
||||||
_adapterComments.notifyDataSetChanged();
|
_adapterComments.notifyDataSetChanged();
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ import android.widget.ImageButton
|
|||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.constraintlayout.widget.ConstraintLayout
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.view.setMargins
|
import androidx.core.view.setMargins
|
||||||
|
import androidx.media3.common.C
|
||||||
import androidx.media3.common.PlaybackParameters
|
import androidx.media3.common.PlaybackParameters
|
||||||
import androidx.media3.common.VideoSize
|
import androidx.media3.common.VideoSize
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
@@ -73,6 +75,7 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
|||||||
|
|
||||||
//Custom buttons
|
//Custom buttons
|
||||||
private val _control_fullscreen: ImageButton;
|
private val _control_fullscreen: ImageButton;
|
||||||
|
private val _control_autoplay: ImageButton;
|
||||||
private val _control_videosettings: ImageButton;
|
private val _control_videosettings: ImageButton;
|
||||||
private val _control_minimize: ImageButton;
|
private val _control_minimize: ImageButton;
|
||||||
private val _control_rotate_lock: ImageButton;
|
private val _control_rotate_lock: ImageButton;
|
||||||
@@ -91,6 +94,7 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
|||||||
private val _control_videosettings_fullscreen: ImageButton;
|
private val _control_videosettings_fullscreen: ImageButton;
|
||||||
private val _control_minimize_fullscreen: ImageButton;
|
private val _control_minimize_fullscreen: ImageButton;
|
||||||
private val _control_rotate_lock_fullscreen: ImageButton;
|
private val _control_rotate_lock_fullscreen: ImageButton;
|
||||||
|
private val _control_autoplay_fullscreen: ImageButton;
|
||||||
private val _control_loop_fullscreen: ImageButton;
|
private val _control_loop_fullscreen: ImageButton;
|
||||||
private val _control_cast_fullscreen: ImageButton;
|
private val _control_cast_fullscreen: ImageButton;
|
||||||
private val _control_play_fullscreen: ImageButton;
|
private val _control_play_fullscreen: ImageButton;
|
||||||
@@ -123,7 +127,8 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
|||||||
private var _currentChapterLoopId: Int = 0;
|
private var _currentChapterLoopId: Int = 0;
|
||||||
private var _currentChapter: IChapter? = null;
|
private var _currentChapter: IChapter? = null;
|
||||||
private var _promptedForPermissions: Boolean = false;
|
private var _promptedForPermissions: Boolean = false;
|
||||||
|
@UnstableApi
|
||||||
|
private var _desiredResizeModePortrait: Int = AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||||
|
|
||||||
//Events
|
//Events
|
||||||
val onMinimize = Event1<FutoVideoPlayer>();
|
val onMinimize = Event1<FutoVideoPlayer>();
|
||||||
@@ -147,6 +152,7 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
|||||||
|
|
||||||
videoControls = findViewById(R.id.video_player_controller);
|
videoControls = findViewById(R.id.video_player_controller);
|
||||||
_control_fullscreen = videoControls.findViewById(R.id.button_fullscreen);
|
_control_fullscreen = videoControls.findViewById(R.id.button_fullscreen);
|
||||||
|
_control_autoplay = videoControls.findViewById(R.id.button_autoplay);
|
||||||
_control_videosettings = videoControls.findViewById(R.id.button_settings);
|
_control_videosettings = videoControls.findViewById(R.id.button_settings);
|
||||||
_control_minimize = videoControls.findViewById(R.id.button_minimize);
|
_control_minimize = videoControls.findViewById(R.id.button_minimize);
|
||||||
_control_rotate_lock = videoControls.findViewById(R.id.button_rotate_lock);
|
_control_rotate_lock = videoControls.findViewById(R.id.button_rotate_lock);
|
||||||
@@ -162,6 +168,7 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
|||||||
_control_duration = videoControls.findViewById(R.id.text_duration);
|
_control_duration = videoControls.findViewById(R.id.text_duration);
|
||||||
|
|
||||||
_videoControls_fullscreen = findViewById(R.id.video_player_controller_fullscreen);
|
_videoControls_fullscreen = findViewById(R.id.video_player_controller_fullscreen);
|
||||||
|
_control_autoplay_fullscreen = _videoControls_fullscreen.findViewById(R.id.button_autoplay);
|
||||||
_control_fullscreen_fullscreen = _videoControls_fullscreen.findViewById(R.id.button_fullscreen);
|
_control_fullscreen_fullscreen = _videoControls_fullscreen.findViewById(R.id.button_fullscreen);
|
||||||
_control_minimize_fullscreen = _videoControls_fullscreen.findViewById(R.id.button_minimize);
|
_control_minimize_fullscreen = _videoControls_fullscreen.findViewById(R.id.button_minimize);
|
||||||
_control_videosettings_fullscreen = _videoControls_fullscreen.findViewById(R.id.button_settings);
|
_control_videosettings_fullscreen = _videoControls_fullscreen.findViewById(R.id.button_settings);
|
||||||
@@ -384,6 +391,18 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
|||||||
UIDialogs.showCastingDialog(context);
|
UIDialogs.showCastingDialog(context);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
_control_autoplay.setOnClickListener {
|
||||||
|
StatePlayer.instance.autoplay = !StatePlayer.instance.autoplay;
|
||||||
|
updateAutoplayButton()
|
||||||
|
}
|
||||||
|
updateAutoplayButton()
|
||||||
|
|
||||||
|
_control_autoplay_fullscreen.setOnClickListener {
|
||||||
|
StatePlayer.instance.autoplay = !StatePlayer.instance.autoplay;
|
||||||
|
updateAutoplayButton()
|
||||||
|
}
|
||||||
|
updateAutoplayButton()
|
||||||
|
|
||||||
val progressUpdateListener = { position: Long, bufferedPosition: Long ->
|
val progressUpdateListener = { position: Long, bufferedPosition: Long ->
|
||||||
val currentTime = position.formatDuration()
|
val currentTime = position.formatDuration()
|
||||||
val currentDuration = duration.formatDuration()
|
val currentDuration = duration.formatDuration()
|
||||||
@@ -431,6 +450,11 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun updateAutoplayButton() {
|
||||||
|
_control_autoplay.setColorFilter(ContextCompat.getColor(context, if (StatePlayer.instance.autoplay) com.futo.futopay.R.color.primary else R.color.white))
|
||||||
|
_control_autoplay_fullscreen.setColorFilter(ContextCompat.getColor(context, if (StatePlayer.instance.autoplay) com.futo.futopay.R.color.primary else R.color.white))
|
||||||
|
}
|
||||||
|
|
||||||
private fun setSystemBrightness(brightness: Float) {
|
private fun setSystemBrightness(brightness: Float) {
|
||||||
Log.i(TAG, "setSystemBrightness $brightness")
|
Log.i(TAG, "setSystemBrightness $brightness")
|
||||||
if (android.provider.Settings.System.canWrite(context)) {
|
if (android.provider.Settings.System.canWrite(context)) {
|
||||||
@@ -567,6 +591,8 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
|||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
fun setFullScreen(fullScreen: Boolean) {
|
fun setFullScreen(fullScreen: Boolean) {
|
||||||
|
updateRotateLock()
|
||||||
|
|
||||||
if (isFullScreen == fullScreen) {
|
if (isFullScreen == fullScreen) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -591,7 +617,7 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
|||||||
|
|
||||||
gestureControl.hideControls();
|
gestureControl.hideControls();
|
||||||
//videoControlsBar.visibility = View.VISIBLE;
|
//videoControlsBar.visibility = View.VISIBLE;
|
||||||
_videoView.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM;
|
_videoView.resizeMode = _desiredResizeModePortrait;
|
||||||
|
|
||||||
videoControls.show();
|
videoControls.show();
|
||||||
_videoControls_fullscreen.hideImmediately();
|
_videoControls_fullscreen.hideImmediately();
|
||||||
@@ -728,9 +754,10 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
|||||||
Log.d(TAG, "WEIRD HEIGHT DETECTED: ${_lastSourceFit}, Width: ${w}, Height: ${h}, VWidth: ${viewWidth}");
|
Log.d(TAG, "WEIRD HEIGHT DETECTED: ${_lastSourceFit}, Width: ${w}, Height: ${h}, VWidth: ${viewWidth}");
|
||||||
}
|
}
|
||||||
if(_lastSourceFit != determinedHeight)
|
if(_lastSourceFit != determinedHeight)
|
||||||
_videoView.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT;
|
_desiredResizeModePortrait = AspectRatioFrameLayout.RESIZE_MODE_FIT;
|
||||||
else
|
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();
|
val marginBottom = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 7f, resources.displayMetrics).toInt();
|
||||||
@@ -759,7 +786,7 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun updateRotateLock() {
|
fun updateRotateLock() {
|
||||||
if(!Settings.instance.playback.isAutoRotate()) {
|
if(Settings.instance.playback.autoRotate == 0) {
|
||||||
_control_rotate_lock.visibility = View.GONE;
|
_control_rotate_lock.visibility = View.GONE;
|
||||||
_control_rotate_lock_fullscreen.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.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
|
import android.util.Xml
|
||||||
import android.widget.RelativeLayout
|
import android.widget.RelativeLayout
|
||||||
import androidx.annotation.OptIn
|
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
|
||||||
|
import androidx.media3.common.C.Encoding
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
import androidx.media3.common.PlaybackException
|
import androidx.media3.common.PlaybackException
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
@@ -17,6 +22,8 @@ import androidx.media3.datasource.DefaultHttpDataSource
|
|||||||
import androidx.media3.datasource.HttpDataSource
|
import androidx.media3.datasource.HttpDataSource
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
import androidx.media3.exoplayer.dash.DashMediaSource
|
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.drm.DefaultDrmSessionManagerProvider
|
||||||
import androidx.media3.exoplayer.hls.HlsMediaSource
|
import androidx.media3.exoplayer.hls.HlsMediaSource
|
||||||
import androidx.media3.exoplayer.source.MediaSource
|
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.subtitles.ISubtitleSource
|
||||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
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.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.JSHLSManifestAudioSource
|
||||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
|
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.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.logging.Logger
|
||||||
import com.futo.platformplayer.states.StateApp
|
import com.futo.platformplayer.states.StateApp
|
||||||
import com.futo.platformplayer.video.PlayerManager
|
import com.futo.platformplayer.video.PlayerManager
|
||||||
|
import com.futo.platformplayer.views.video.datasources.JSHttpDataSource
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import getHttpDataSourceFactory
|
import getHttpDataSourceFactory
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
|
||||||
@@ -319,18 +331,37 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
|||||||
swapSources(videoSource, audioSource,false, play, keepSubtitles);
|
swapSources(videoSource, audioSource,false, play, keepSubtitles);
|
||||||
}
|
}
|
||||||
fun swapSources(videoSource: IVideoSource?, audioSource: IAudioSource?, resume: Boolean = true, play: Boolean = true, keepSubtitles: Boolean = false): Boolean {
|
fun swapSources(videoSource: IVideoSource?, audioSource: IAudioSource?, resume: Boolean = true, play: Boolean = true, keepSubtitles: Boolean = false): Boolean {
|
||||||
swapSourceInternal(videoSource);
|
var videoSourceUsed = videoSource;
|
||||||
swapSourceInternal(audioSource);
|
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)
|
if(!keepSubtitles)
|
||||||
_lastSubtitleMediaSource = null;
|
_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 {
|
fun swapSource(videoSource: IVideoSource?, resume: Boolean = true, play: Boolean = true): Boolean {
|
||||||
swapSourceInternal(videoSource);
|
var videoSourceUsed = videoSource;
|
||||||
return loadSelectedSources(play, resume);
|
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 {
|
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);
|
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;
|
_lastGeneratedDash = null;
|
||||||
when(videoSource) {
|
val didSet = when(videoSource) {
|
||||||
is LocalVideoSource -> swapVideoSourceLocal(videoSource);
|
is LocalVideoSource -> { swapVideoSourceLocal(videoSource); true; }
|
||||||
is JSVideoUrlRangeSource -> swapVideoSourceUrlRange(videoSource);
|
is JSVideoUrlRangeSource -> { swapVideoSourceUrlRange(videoSource); true; }
|
||||||
is IDashManifestSource -> swapVideoSourceDash(videoSource);
|
is IDashManifestSource -> { swapVideoSourceDash(videoSource); true;}
|
||||||
is IHLSManifestSource -> swapVideoSourceHLS(videoSource);
|
is JSDashManifestRawSource -> swapVideoSourceDashRaw(videoSource, play, resume);
|
||||||
is IVideoUrlSource -> swapVideoSourceUrl(videoSource);
|
is IHLSManifestSource -> { swapVideoSourceHLS(videoSource); true; }
|
||||||
null -> _lastVideoMediaSource = null;
|
is IVideoUrlSource -> { swapVideoSourceUrl(videoSource); true; }
|
||||||
|
null -> { _lastVideoMediaSource = null; true;}
|
||||||
else -> throw IllegalArgumentException("Unsupported video source [${videoSource.javaClass.simpleName}]");
|
else -> throw IllegalArgumentException("Unsupported video source [${videoSource.javaClass.simpleName}]");
|
||||||
}
|
}
|
||||||
lastVideoSource = videoSource;
|
lastVideoSource = videoSource;
|
||||||
|
return didSet;
|
||||||
}
|
}
|
||||||
private fun swapSourceInternal(audioSource: IAudioSource?) {
|
private fun swapSourceInternal(audioSource: IAudioSource?, play: Boolean, resume: Boolean): Boolean {
|
||||||
when(audioSource) {
|
val didSet = when(audioSource) {
|
||||||
is LocalAudioSource -> swapAudioSourceLocal(audioSource);
|
is LocalAudioSource -> {swapAudioSourceLocal(audioSource); true; }
|
||||||
is JSAudioUrlRangeSource -> swapAudioSourceUrlRange(audioSource);
|
is JSAudioUrlRangeSource -> { swapAudioSourceUrlRange(audioSource); true; }
|
||||||
is JSHLSManifestAudioSource -> swapAudioSourceHLS(audioSource);
|
is JSHLSManifestAudioSource -> { swapAudioSourceHLS(audioSource); true; }
|
||||||
is IAudioUrlWidevineSource -> swapAudioSourceUrlWidevine(audioSource)
|
is JSDashManifestRawAudioSource -> swapAudioSourceDashRaw(audioSource, play, resume);
|
||||||
is IAudioUrlSource -> swapAudioSourceUrl(audioSource);
|
is IAudioUrlWidevineSource -> { swapAudioSourceUrlWidevine(audioSource); true; }
|
||||||
null -> _lastAudioMediaSource = null;
|
is IAudioUrlSource -> { swapAudioSourceUrl(audioSource); true; }
|
||||||
|
null -> { _lastAudioMediaSource = null; true; }
|
||||||
else -> throw IllegalArgumentException("Unsupported video source [${audioSource.javaClass.simpleName}]");
|
else -> throw IllegalArgumentException("Unsupported video source [${audioSource.javaClass.simpleName}]");
|
||||||
}
|
}
|
||||||
lastAudioSource = audioSource;
|
lastAudioSource = audioSource;
|
||||||
|
return didSet;
|
||||||
}
|
}
|
||||||
|
|
||||||
//Video loads
|
//Video loads
|
||||||
@@ -441,7 +476,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
|||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
private fun swapVideoSourceUrl(videoSource: IVideoUrlSource) {
|
private fun swapVideoSourceUrl(videoSource: IVideoUrlSource) {
|
||||||
Logger.i(TAG, "Loading VideoSource [Url]");
|
Logger.i(TAG, "Loading VideoSource [Url]");
|
||||||
val dataSource = if(videoSource is JSSource && videoSource.hasRequestModifier)
|
val dataSource = if(videoSource is JSSource && videoSource.requiresCustomDatasource)
|
||||||
videoSource.getHttpDataSourceFactory()
|
videoSource.getHttpDataSourceFactory()
|
||||||
else
|
else
|
||||||
DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT);
|
DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT);
|
||||||
@@ -451,7 +486,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
|||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
private fun swapVideoSourceDash(videoSource: IDashManifestSource) {
|
private fun swapVideoSourceDash(videoSource: IDashManifestSource) {
|
||||||
Logger.i(TAG, "Loading VideoSource [Dash]");
|
Logger.i(TAG, "Loading VideoSource [Dash]");
|
||||||
val dataSource = if(videoSource is JSSource && videoSource.hasRequestModifier)
|
val dataSource = if(videoSource is JSSource && (videoSource.requiresCustomDatasource))
|
||||||
videoSource.getHttpDataSourceFactory()
|
videoSource.getHttpDataSourceFactory()
|
||||||
else
|
else
|
||||||
DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT);
|
DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT);
|
||||||
@@ -459,9 +494,60 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
|||||||
.createMediaSource(MediaItem.fromUri(videoSource.url))
|
.createMediaSource(MediaItem.fromUri(videoSource.url))
|
||||||
}
|
}
|
||||||
@OptIn(UnstableApi::class)
|
@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) {
|
private fun swapVideoSourceHLS(videoSource: IHLSManifestSource) {
|
||||||
Logger.i(TAG, "Loading VideoSource [HLS]");
|
Logger.i(TAG, "Loading VideoSource [HLS]");
|
||||||
val dataSource = if(videoSource is JSSource && videoSource.hasRequestModifier)
|
val dataSource = if(videoSource is JSSource && videoSource.requiresCustomDatasource)
|
||||||
videoSource.getHttpDataSourceFactory()
|
videoSource.getHttpDataSourceFactory()
|
||||||
else
|
else
|
||||||
DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT);
|
DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT);
|
||||||
@@ -503,7 +589,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
|||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
private fun swapAudioSourceUrl(audioSource: IAudioUrlSource) {
|
private fun swapAudioSourceUrl(audioSource: IAudioUrlSource) {
|
||||||
Logger.i(TAG, "Loading AudioSource [Url]");
|
Logger.i(TAG, "Loading AudioSource [Url]");
|
||||||
val dataSource = if(audioSource is JSSource && audioSource.hasRequestModifier)
|
val dataSource = if(audioSource is JSSource && audioSource.requiresCustomDatasource)
|
||||||
audioSource.getHttpDataSourceFactory()
|
audioSource.getHttpDataSourceFactory()
|
||||||
else
|
else
|
||||||
DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT);
|
DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT);
|
||||||
@@ -513,7 +599,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
|||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
private fun swapAudioSourceHLS(audioSource: IHLSManifestAudioSource) {
|
private fun swapAudioSourceHLS(audioSource: IHLSManifestAudioSource) {
|
||||||
Logger.i(TAG, "Loading AudioSource [HLS]");
|
Logger.i(TAG, "Loading AudioSource [HLS]");
|
||||||
val dataSource = if(audioSource is JSSource && audioSource.hasRequestModifier)
|
val dataSource = if(audioSource is JSSource && audioSource.requiresCustomDatasource)
|
||||||
audioSource.getHttpDataSourceFactory()
|
audioSource.getHttpDataSourceFactory()
|
||||||
else
|
else
|
||||||
DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT);
|
DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT);
|
||||||
@@ -521,10 +607,42 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
|||||||
.createMediaSource(MediaItem.fromUri(audioSource.url));
|
.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)
|
@OptIn(UnstableApi::class)
|
||||||
private fun swapAudioSourceUrlWidevine(audioSource: IAudioUrlWidevineSource) {
|
private fun swapAudioSourceUrlWidevine(audioSource: IAudioUrlWidevineSource) {
|
||||||
Logger.i(TAG, "Loading AudioSource [UrlWidevine]")
|
Logger.i(TAG, "Loading AudioSource [UrlWidevine]")
|
||||||
val dataSource = if (audioSource is JSSource && audioSource.hasRequestModifier)
|
val dataSource = if (audioSource is JSSource && audioSource.requiresCustomDatasource)
|
||||||
audioSource.getHttpDataSourceFactory()
|
audioSource.getHttpDataSourceFactory()
|
||||||
else
|
else
|
||||||
DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT)
|
DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT)
|
||||||
@@ -574,28 +692,37 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
|||||||
val sourceAudio = _lastAudioMediaSource;
|
val sourceAudio = _lastAudioMediaSource;
|
||||||
val sourceSubs = _lastSubtitleMediaSource;
|
val sourceSubs = _lastSubtitleMediaSource;
|
||||||
|
|
||||||
val sources = listOf(sourceVideo, sourceAudio, sourceSubs).filter { it != null }.map { it!! }.toTypedArray()
|
|
||||||
|
|
||||||
beforeSourceChanged();
|
beforeSourceChanged();
|
||||||
|
|
||||||
_mediaSource = if(sources.size == 1) {
|
val source = mergeMediaSources(sourceVideo, sourceAudio, sourceSubs);
|
||||||
Logger.i(TAG, "Using single source mode")
|
if(source == null)
|
||||||
(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();
|
|
||||||
return false;
|
return false;
|
||||||
}
|
_mediaSource = source;
|
||||||
|
|
||||||
reloadMediaSource(play, resume);
|
reloadMediaSource(play, resume);
|
||||||
return true;
|
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)
|
@OptIn(UnstableApi::class)
|
||||||
private fun reloadMediaSource(play: Boolean = false, resume: Boolean = true) {
|
private fun reloadMediaSource(play: Boolean = false, resume: Boolean = true) {
|
||||||
val player = exoPlayer ?: return
|
val player = exoPlayer ?: return
|
||||||
@@ -619,6 +746,10 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
|||||||
fun clear() {
|
fun clear() {
|
||||||
exoPlayer?.player?.stop();
|
exoPlayer?.player?.stop();
|
||||||
exoPlayer?.player?.clearMediaItems();
|
exoPlayer?.player?.clearMediaItems();
|
||||||
|
_lastVideoMediaSource = null;
|
||||||
|
_lastAudioMediaSource = null;
|
||||||
|
_lastSubtitleMediaSource = null;
|
||||||
|
_mediaSource = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stop(){
|
fun stop(){
|
||||||
@@ -645,13 +776,14 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
|||||||
|
|
||||||
when (error.errorCode) {
|
when (error.errorCode) {
|
||||||
PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS -> {
|
PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS -> {
|
||||||
|
Logger.w(TAG, "ERROR_CODE_IO_BAD_HTTP_STATUS ${error.cause?.javaClass?.simpleName}");
|
||||||
if(error.cause is HttpDataSource.InvalidResponseCodeException) {
|
if(error.cause is HttpDataSource.InvalidResponseCodeException) {
|
||||||
val cause = error.cause as HttpDataSource.InvalidResponseCodeException
|
val cause = error.cause as HttpDataSource.InvalidResponseCodeException
|
||||||
|
|
||||||
Logger.v(TAG, null) {
|
Logger.w(TAG, null) {
|
||||||
"ERROR BAD HTTP ${cause.responseCode},\n" +
|
"ERROR BAD HTTP ${cause.responseCode},\n" +
|
||||||
"Video Source: ${V8RemoteObject.gsonStandard.toJson(lastVideoSource)}\n" +
|
"Video Source: ${lastVideoSource?.toString()}\n" +
|
||||||
"Audio Source: ${V8RemoteObject.gsonStandard.toJson(lastAudioSource)}\n" +
|
"Audio Source: ${lastAudioSource?.toString()}\n" +
|
||||||
"Dash: ${_lastGeneratedDash}"
|
"Dash: ${_lastGeneratedDash}"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -696,8 +828,15 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
|||||||
companion object {
|
companion object {
|
||||||
val DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0";
|
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_VIDEO_CONTAINERS_MP4Pref = arrayOf("video/mp4", "video/webm", "video/3gpp");
|
||||||
val PREFERED_AUDIO_CONTAINERS = arrayOf("audio/mp3", "audio/mp4", "audio/webm", "audio/opus");
|
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");
|
val SUPPORTED_SUBTITLES = hashSetOf("text/vtt", "application/x-subrip");
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user