mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-29 11:03:01 +02:00
Compare commits
138 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 547fe7bc13 | |||
| 678305e366 | |||
| 9f07673d85 | |||
| 19429263a9 | |||
| 986652adab | |||
| 4d93a58d5d | |||
| 817c90f3af | |||
| 77348b3787 | |||
| 31e26d03c6 | |||
| 1ef566ab16 | |||
| 7597f5136c | |||
| 9a2a70622f | |||
| 4fc33411fd | |||
| a9bb900994 | |||
| 8c1a18d8b4 | |||
| 14ae5f1572 | |||
| ed40994600 | |||
| 90e8c35b19 | |||
| 4d017ad357 | |||
| 2ca2a9db23 | |||
| 713d46c781 | |||
| 0429665173 | |||
| ac05edca77 | |||
| ad3dacf68f | |||
| 91a8996c11 | |||
| 40c4a51a2b | |||
| f8e0aaf4d2 | |||
| ad97b5a406 | |||
| b0e0c1b75f | |||
| b1fce443e9 | |||
| 66f8711055 | |||
| b7c123c281 | |||
| 9481bbf3f1 | |||
| 43ec7e821b | |||
| ca3454afbe | |||
| 1edc8aabf8 | |||
| 91060faac9 | |||
| 17027ba364 | |||
| 8569eaa5db | |||
| d32d817e0a | |||
| a0f4cc760c | |||
| 5247997ea5 | |||
| 453030d561 | |||
| e080702a52 | |||
| 3909343adc | |||
| dc76934d0e | |||
| 6cf47d592a | |||
| 1507c70729 | |||
| d6a23ac0de | |||
| 17df396672 | |||
| 0c5ba0cd39 | |||
| 183aeb18a0 | |||
| 8d08e19cd2 | |||
| a882d04d26 | |||
| c4d06c1ba2 | |||
| 4dfcd47901 | |||
| 4c0c1abb4b | |||
| 6f44071186 | |||
| 29910a2698 | |||
| b5da0d4462 | |||
| 99fb9b3462 | |||
| 5f0a89d13b | |||
| f311561e6f | |||
| 2fc944ddd9 | |||
| a2970b86ee | |||
| ac9a51f105 | |||
| 90dca2537a | |||
| 4df227147c | |||
| 1fb55dca0a | |||
| 3d7b347e49 | |||
| 769ec9f59a | |||
| dee310de3d | |||
| 0af4bad906 | |||
| 4731673ba3 | |||
| 8745221cbd | |||
| 742d95440e | |||
| 180b320cd7 | |||
| cc8dffc485 | |||
| a64fd2cf35 | |||
| 4aceb364d9 | |||
| 76d9bac0ec | |||
| 2b8dc41d0d | |||
| 33430c538c | |||
| 03e9cb398b | |||
| 2aef2ebec1 | |||
| 5e5fffbf97 | |||
| 51ac604e31 | |||
| 4e49b5bc63 | |||
| 658cbc5e00 | |||
| 2ceb4c5644 | |||
| 940bed2cee | |||
| 2738954af7 | |||
| db5aaf0b84 | |||
| e1abb7f8ae | |||
| 3310ac6008 | |||
| 09879c83e9 | |||
| 7aa8b6bc14 | |||
| cac8a8fde4 | |||
| 82f214f155 | |||
| 4ee127fe13 | |||
| 1e4aefb7d5 | |||
| 2a825a9f83 | |||
| 6695774037 | |||
| a10bc8c7de | |||
| c1e6e401cc | |||
| 98b6213886 | |||
| b6671c653c | |||
| 55d042bee3 | |||
| 80034ad131 | |||
| 4eb20a1843 | |||
| 98c6378148 | |||
| bb066a7a31 | |||
| b5d3261f03 | |||
| 30c41044da | |||
| e369676808 | |||
| 2fa9e65bee | |||
| cf96bd1ec0 | |||
| 1f5a069877 | |||
| adc5013ea4 | |||
| 515c5e00e9 | |||
| ba9f843368 | |||
| 0653f88c49 | |||
| 4ce9f64808 | |||
| 755bebaecb | |||
| 4fa0229ccb | |||
| 42dd8d6152 | |||
| 0a839b4814 | |||
| 586db317dd | |||
| ae36a24ad1 | |||
| 9a435f8859 | |||
| 81162c5df2 | |||
| c7c3ddfc96 | |||
| 830d3a9022 | |||
| a1c2d19daf | |||
| 004e4be4d3 | |||
| bd87a47551 | |||
| 76103a2a8c | |||
| f63f9dd6db |
@@ -26,7 +26,7 @@ body:
|
|||||||
label: Reproduction steps
|
label: Reproduction steps
|
||||||
description: Please provide us with the steps to reproduce the issue if possible. This step makes a big difference if we are going to be able to fix it so be as precise as possible.
|
description: Please provide us with the steps to reproduce the issue if possible. This step makes a big difference if we are going to be able to fix it so be as precise as possible.
|
||||||
placeholder: |
|
placeholder: |
|
||||||
0. Play a Youtube video
|
0. Play a YouTube video
|
||||||
1. Press on Download button
|
1. Press on Download button
|
||||||
2. Select quality 1440p
|
2. Select quality 1440p
|
||||||
3. Grayjay crashes when attempting to download
|
3. Grayjay crashes when attempting to download
|
||||||
@@ -83,7 +83,7 @@ body:
|
|||||||
- "Spotify"
|
- "Spotify"
|
||||||
- "TedTalks"
|
- "TedTalks"
|
||||||
- "Twitch"
|
- "Twitch"
|
||||||
- "Youtube"
|
- "YouTube"
|
||||||
- "Other"
|
- "Other"
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
@@ -106,3 +106,9 @@
|
|||||||
[submodule "app/src/stable/assets/sources/crunchyroll"]
|
[submodule "app/src/stable/assets/sources/crunchyroll"]
|
||||||
path = app/src/stable/assets/sources/crunchyroll
|
path = app/src/stable/assets/sources/crunchyroll
|
||||||
url = ../plugins/crunchyroll.git
|
url = ../plugins/crunchyroll.git
|
||||||
|
[submodule "app/src/stable/assets/sources/mixcloud"]
|
||||||
|
path = app/src/stable/assets/sources/mixcloud
|
||||||
|
url = ../plugins/mixcloud.git
|
||||||
|
[submodule "app/src/unstable/assets/sources/mixcloud"]
|
||||||
|
path = app/src/unstable/assets/sources/mixcloud
|
||||||
|
url = ../plugins/mixcloud.git
|
||||||
|
|||||||
+11
-4
@@ -39,7 +39,7 @@ protobuf {
|
|||||||
|
|
||||||
android {
|
android {
|
||||||
namespace 'com.futo.platformplayer'
|
namespace 'com.futo.platformplayer'
|
||||||
compileSdk 34
|
compileSdk 36
|
||||||
flavorDimensions "buildType"
|
flavorDimensions "buildType"
|
||||||
productFlavors {
|
productFlavors {
|
||||||
stable {
|
stable {
|
||||||
@@ -97,7 +97,7 @@ android {
|
|||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
minSdk 28
|
minSdk 28
|
||||||
targetSdk 34
|
targetSdk 35
|
||||||
versionCode gitVersionCode
|
versionCode gitVersionCode
|
||||||
versionName gitVersionName
|
versionName gitVersionName
|
||||||
|
|
||||||
@@ -154,9 +154,10 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation 'com.google.dagger:dagger:2.48'
|
//implementation 'com.google.dagger:dagger:2.48'
|
||||||
implementation 'androidx.test:monitor:1.7.2'
|
implementation 'androidx.test:monitor:1.7.2'
|
||||||
annotationProcessor 'com.google.dagger:dagger-compiler:2.48'
|
implementation 'com.google.android.material:material:1.12.0'
|
||||||
|
//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'
|
||||||
@@ -230,4 +231,10 @@ dependencies {
|
|||||||
testImplementation "org.mockito:mockito-core:5.4.0"
|
testImplementation "org.mockito:mockito-core:5.4.0"
|
||||||
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
||||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
||||||
|
|
||||||
|
//Rust casting SDK
|
||||||
|
implementation('org.futo.gitlab.videostreaming.fcast-sdk-jitpack:sender-sdk-minimal:0.3.1') {
|
||||||
|
// Polycentricandroid includes this
|
||||||
|
exclude group: 'net.java.dev.jna'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1022,15 +1022,35 @@
|
|||||||
return x.value
|
return x.value
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
let settingsToUse = __DEV_SETTINGS ?? {};
|
||||||
|
if (true) {
|
||||||
|
for (let setting of this.Plugin?.currentPlugin?.settings) {
|
||||||
|
if (typeof settingsToUse[setting.variable] == "undefined") {
|
||||||
|
switch (setting?.type?.toLowerCase()) {
|
||||||
|
case "boolean":
|
||||||
|
settingsToUse[setting.variable] = setting.default === 'true';
|
||||||
|
break;
|
||||||
|
case "dropdown":
|
||||||
|
let dropDownIndex = parseInt(setting.default);
|
||||||
|
if (dropDownIndex) {
|
||||||
|
settingsToUse[setting.variable] = setting.options[dropDownIndex];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if(name == "enable") {
|
if(name == "enable") {
|
||||||
if(parameterVals.length > 0)
|
if(parameterVals.length > 0)
|
||||||
parameterVals[0] = this.Plugin.currentPlugin;
|
parameterVals[0] = this.Plugin.currentPlugin;
|
||||||
else
|
else
|
||||||
parameterVals.push(this.Plugin.currentPlugin);
|
parameterVals.push(this.Plugin.currentPlugin);
|
||||||
if(parameterVals.length > 1)
|
if(parameterVals.length > 1)
|
||||||
parameterVals[1] = __DEV_SETTINGS;
|
parameterVals[1] = settingsToUse;
|
||||||
else
|
else
|
||||||
parameterVals.push(__DEV_SETTINGS);
|
parameterVals.push(settingsToUse);
|
||||||
}
|
}
|
||||||
|
|
||||||
const func = source[name];
|
const func = source[name];
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ class ScriptException extends Error {
|
|||||||
super(arguments[0]);
|
super(arguments[0]);
|
||||||
this.plugin_type = "ScriptException";
|
this.plugin_type = "ScriptException";
|
||||||
this.message = arguments[0];
|
this.message = arguments[0];
|
||||||
|
this.msg = arguments[0];
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
super(msg);
|
super(msg);
|
||||||
@@ -251,6 +252,9 @@ class PlatformVideo extends PlatformContent {
|
|||||||
this.duration = obj.duration ?? -1; //Long
|
this.duration = obj.duration ?? -1; //Long
|
||||||
this.viewCount = obj.viewCount ?? -1; //Long
|
this.viewCount = obj.viewCount ?? -1; //Long
|
||||||
|
|
||||||
|
this.playbackTime = obj.playbackTime ?? -1;
|
||||||
|
this.playbackDate = obj.playbackDate ?? undefined;
|
||||||
|
|
||||||
this.isLive = obj.isLive ?? false; //Boolean
|
this.isLive = obj.isLive ?? false; //Boolean
|
||||||
this.isShort = !!obj.isShort ?? false;
|
this.isShort = !!obj.isShort ?? false;
|
||||||
}
|
}
|
||||||
@@ -464,14 +468,20 @@ class AudioUrlWidevineSource extends AudioUrlSource {
|
|||||||
this.getLicenseRequestExecutor = () => {
|
this.getLicenseRequestExecutor = () => {
|
||||||
return {
|
return {
|
||||||
executeRequest: (url, _headers, _method, license_request_data) => {
|
executeRequest: (url, _headers, _method, license_request_data) => {
|
||||||
return http.POST(
|
const response = http.POST(
|
||||||
url,
|
url,
|
||||||
license_request_data,
|
license_request_data,
|
||||||
{ Authorization: `Bearer ${obj.bearerToken}` },
|
{ Authorization: `Bearer ${obj.bearerToken}` },
|
||||||
false,
|
false,
|
||||||
true
|
true
|
||||||
).body
|
);
|
||||||
}
|
|
||||||
|
if (!response.body) {
|
||||||
|
throw new ScriptException("Unable to acquire license key");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.body;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -785,6 +795,7 @@ let plugin = {
|
|||||||
//To override by plugin
|
//To override by plugin
|
||||||
const source = {
|
const source = {
|
||||||
getHome() { return new ContentPager([], false, {}); },
|
getHome() { return new ContentPager([], false, {}); },
|
||||||
|
getShorts() { return new VideoPager([], false, {}); },
|
||||||
|
|
||||||
enable(config){ },
|
enable(config){ },
|
||||||
disable() {},
|
disable() {},
|
||||||
|
|||||||
@@ -194,7 +194,6 @@ fun V8ObjectToHashMap(obj: V8ValueObject?): HashMap<String, String> {
|
|||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fun <T: V8Value> V8ValuePromise.toV8ValueBlocking(plugin: V8Plugin): T {
|
fun <T: V8Value> V8ValuePromise.toV8ValueBlocking(plugin: V8Plugin): T {
|
||||||
val latch = CountDownLatch(1);
|
val latch = CountDownLatch(1);
|
||||||
var promiseResult: T? = null;
|
var promiseResult: T? = null;
|
||||||
@@ -204,16 +203,19 @@ fun <T: V8Value> V8ValuePromise.toV8ValueBlocking(plugin: V8Plugin): T {
|
|||||||
override fun onFulfilled(p0: V8Value?) {
|
override fun onFulfilled(p0: V8Value?) {
|
||||||
if(p0 is V8ValueError)
|
if(p0 is V8ValueError)
|
||||||
promiseException = ScriptExecutionException(plugin.config, p0.message);
|
promiseException = ScriptExecutionException(plugin.config, p0.message);
|
||||||
else
|
else {
|
||||||
|
if(p0 is V8ValueObject)
|
||||||
|
p0.setWeak();
|
||||||
promiseResult = p0 as T;
|
promiseResult = p0 as T;
|
||||||
|
}
|
||||||
latch.countDown();
|
latch.countDown();
|
||||||
}
|
}
|
||||||
override fun onRejected(p0: V8Value?) {
|
override fun onRejected(p0: V8Value?) {
|
||||||
promiseException = (NotImplementedError("onRejected promise not implemented.."));
|
promiseException = p0?.toException(plugin.config);
|
||||||
latch.countDown();
|
latch.countDown();
|
||||||
}
|
}
|
||||||
override fun onCatch(p0: V8Value?) {
|
override fun onCatch(p0: V8Value?) {
|
||||||
promiseException = (NotImplementedError("onCatch promise not implemented.."));
|
promiseException = p0?.toException(plugin.config);
|
||||||
latch.countDown();
|
latch.countDown();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -223,8 +225,25 @@ fun <T: V8Value> V8ValuePromise.toV8ValueBlocking(plugin: V8Plugin): T {
|
|||||||
promiseException = CancellationException("Cancelled by system");
|
promiseException = CancellationException("Cancelled by system");
|
||||||
latch.countDown();
|
latch.countDown();
|
||||||
}
|
}
|
||||||
plugin.unbusy {
|
//Logger.i("V8", "V8ValueBlocking started (Busy) [" + blockCount + "]" + Thread.currentThread().stackTrace.drop(3)?.firstOrNull()?.toString() + ", " + Thread.currentThread().stackTrace.drop(4)?.firstOrNull()?.toString()+ ", " + Thread.currentThread().stackTrace.drop(5)?.firstOrNull()?.toString());
|
||||||
latch.await();
|
|
||||||
|
|
||||||
|
if(!promise.isPending) {
|
||||||
|
try {
|
||||||
|
Logger.i("V8", "V8Promise resolved synchronously");
|
||||||
|
if(promise.isFulfilled)
|
||||||
|
promiseResult = promise.getResult<T>();
|
||||||
|
else
|
||||||
|
promiseException = promise.getResult<V8Value>().toException(plugin.config);
|
||||||
|
}
|
||||||
|
catch(ex: Throwable) {
|
||||||
|
promiseException = ex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
plugin.unbusy {
|
||||||
|
latch.await();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if(promiseException != null)
|
if(promiseException != null)
|
||||||
throw promiseException!!;
|
throw promiseException!!;
|
||||||
@@ -250,11 +269,11 @@ fun <T: V8Value> V8ValuePromise.toV8ValueAsync(plugin: V8Plugin): V8Deferred<T>
|
|||||||
}
|
}
|
||||||
override fun onRejected(p0: V8Value?) {
|
override fun onRejected(p0: V8Value?) {
|
||||||
plugin.resolvePromise(promise);
|
plugin.resolvePromise(promise);
|
||||||
underlyingDef.completeExceptionally(NotImplementedError("onRejected promise not implemented.."));
|
underlyingDef.completeExceptionally(p0?.toException(plugin.config) ?: NotImplementedError("onRejected promise not implemented.."));
|
||||||
}
|
}
|
||||||
override fun onCatch(p0: V8Value?) {
|
override fun onCatch(p0: V8Value?) {
|
||||||
plugin.resolvePromise(promise);
|
plugin.resolvePromise(promise);
|
||||||
underlyingDef.completeExceptionally(NotImplementedError("onCatch promise not implemented.."));
|
underlyingDef.completeExceptionally(p0?.toException(plugin.config) ?: NotImplementedError("onCatch promise not implemented.."));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -265,6 +284,20 @@ fun <T: V8Value> V8ValuePromise.toV8ValueAsync(plugin: V8Plugin): V8Deferred<T>
|
|||||||
return def;
|
return def;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun V8Value.toException(config: IV8PluginConfig): Throwable {
|
||||||
|
val p0 = this;
|
||||||
|
if(p0 is V8ValueObject) {
|
||||||
|
val pluginType = p0.getOrDefault(config, "plugin_type", "Promise Exception", "")?.let { if(!it.isNullOrBlank()) it + "" else "" }
|
||||||
|
val msg = p0.getOrDefault<String?>(config, "msg", "Promise Exception", null)
|
||||||
|
?: p0.getOrDefault(config, "message", "Promise Exception", "");
|
||||||
|
return Exception("Promise Failed: " + pluginType + msg);
|
||||||
|
}
|
||||||
|
else if(p0 is V8ValueString)
|
||||||
|
return Exception("Promise Failed:" + p0.value);
|
||||||
|
else
|
||||||
|
return NotImplementedError("onCatch promise not implemented..");
|
||||||
|
}
|
||||||
|
|
||||||
class V8Deferred<T>(val deferred: Deferred<T>, val estDuration: Int = -1): Deferred<T> by deferred {
|
class V8Deferred<T>(val deferred: Deferred<T>, val estDuration: Int = -1): Deferred<T> by deferred {
|
||||||
|
|
||||||
fun <R> convert(conversion: (result: T)->R): V8Deferred<R>{
|
fun <R> convert(conversion: (result: T)->R): V8Deferred<R>{
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ 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.StatePolycentric
|
import com.futo.platformplayer.states.StatePolycentric
|
||||||
|
import com.futo.platformplayer.states.StateSync
|
||||||
import com.futo.platformplayer.states.StateUpdate
|
import com.futo.platformplayer.states.StateUpdate
|
||||||
import com.futo.platformplayer.stores.FragmentedStorage
|
import com.futo.platformplayer.stores.FragmentedStorage
|
||||||
import com.futo.platformplayer.stores.FragmentedStorageFileJson
|
import com.futo.platformplayer.stores.FragmentedStorageFileJson
|
||||||
@@ -34,6 +35,7 @@ import com.futo.platformplayer.views.fields.DropdownFieldOptionsId
|
|||||||
import com.futo.platformplayer.views.fields.FieldForm
|
import com.futo.platformplayer.views.fields.FieldForm
|
||||||
import com.futo.platformplayer.views.fields.FormField
|
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.overlays.slideup.SlideUpMenuItem
|
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -201,6 +203,8 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
8 -> "zh";
|
8 -> "zh";
|
||||||
9 -> "ru";
|
9 -> "ru";
|
||||||
10 -> "ar";
|
10 -> "ar";
|
||||||
|
11 -> "it";
|
||||||
|
12 -> "tr";
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -603,6 +607,16 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
else -> 2.0
|
else -> 2.0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@AdvancedField
|
||||||
|
@FormField(R.string.shorts_pregenerate, FieldForm.TOGGLE, R.string.shorts_pregenerate_description, 28)
|
||||||
|
var shortsPregenerate: Boolean = false;
|
||||||
|
|
||||||
|
@AdvancedField
|
||||||
|
@FormField(R.string.shorts_fit_video, FieldForm.TOGGLE, R.string.shorts_fit_video_description, 29)
|
||||||
|
@FormFieldWarning(R.string.shorts_fit_video_warning)
|
||||||
|
var shortsFitVideo: Boolean = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@FormField(R.string.comments, "group", R.string.comments_description, 6)
|
@FormField(R.string.comments, "group", R.string.comments_description, 6)
|
||||||
@@ -705,6 +719,11 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||||
var allowLinkLocalIpv4: Boolean = false;
|
var allowLinkLocalIpv4: Boolean = false;
|
||||||
|
|
||||||
|
@AdvancedField
|
||||||
|
@FormField(R.string.experimental_cast, FieldForm.TOGGLE, R.string.experimental_cast_description, 6)
|
||||||
|
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||||
|
var experimentalCasting: Boolean = false
|
||||||
|
|
||||||
/*TODO: Should we have a different casting quality?
|
/*TODO: Should we have a different casting quality?
|
||||||
@FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3)
|
@FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3)
|
||||||
@DropdownFieldOptionsId(R.array.preferred_quality_array)
|
@DropdownFieldOptionsId(R.array.preferred_quality_array)
|
||||||
@@ -1087,6 +1106,39 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
|
|
||||||
@FormField(R.string.local_connections, FieldForm.TOGGLE, R.string.local_connections_description, 3)
|
@FormField(R.string.local_connections, FieldForm.TOGGLE, R.string.local_connections_description, 3)
|
||||||
var localConnections: Boolean = true;
|
var localConnections: Boolean = true;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
var syncServerUrl: String? = null;
|
||||||
|
@FormField(R.string.relay_server, FieldForm.READONLYTEXT, -1, 6)
|
||||||
|
val syncServer: String get() = if(syncServerUrl?.isBlank() == true) StateSync.RELAY_SERVER else syncServerUrl ?: StateSync.RELAY_SERVER;
|
||||||
|
|
||||||
|
@AdvancedField
|
||||||
|
@FormField(R.string.configure_sync_server, FieldForm.BUTTON, R.string.configure_sync_server_description, 7)
|
||||||
|
fun configureSyncServer() {
|
||||||
|
SettingsActivity.getActivity()?.let { context ->
|
||||||
|
UIDialogs.showDialog(context, R.drawable.device_sync, false,
|
||||||
|
"Enter the url to your relay server",
|
||||||
|
"Using your own relay server requires a proper setup with portforwarding.\nUse at your own risk.",
|
||||||
|
null,
|
||||||
|
syncServerUrl ?: "",
|
||||||
|
"YourRelayServerDomain.com", 0,
|
||||||
|
UIDialogs.Action("Cancel", {}),
|
||||||
|
UIDialogs.Action("Reset", {
|
||||||
|
syncServerUrl = null;
|
||||||
|
instance.save();
|
||||||
|
context.reloadSettings();
|
||||||
|
UIDialogs.toast("Sync server changes require a restart");
|
||||||
|
}, UIDialogs.ActionStyle.ACCENT),
|
||||||
|
UIDialogs.Action.withInput("Configure", {
|
||||||
|
syncServerUrl = it?.text
|
||||||
|
instance.save();
|
||||||
|
context.reloadSettings();
|
||||||
|
UIDialogs.toast("Sync server changes require a restart");
|
||||||
|
}, UIDialogs.ActionStyle.PRIMARY),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@FormField(R.string.info, FieldForm.GROUP, -1, 21)
|
@FormField(R.string.info, FieldForm.GROUP, -1, 21)
|
||||||
|
|||||||
@@ -113,8 +113,8 @@ class UIDialogs {
|
|||||||
currentDialog.code,
|
currentDialog.code,
|
||||||
currentDialog.defaultCloseAction,
|
currentDialog.defaultCloseAction,
|
||||||
*currentDialog.actions.map {
|
*currentDialog.actions.map {
|
||||||
return@map Action(it.text, {
|
return@map Action.withInput(it.text, { str ->
|
||||||
it.action();
|
it.invokeAction(str);
|
||||||
multiShowDialog(context, dialogDescriptor.drop(1), finally);
|
multiShowDialog(context, dialogDescriptor.drop(1), finally);
|
||||||
}, it.style);
|
}, it.style);
|
||||||
}.toTypedArray());
|
}.toTypedArray());
|
||||||
@@ -203,7 +203,9 @@ class UIDialogs {
|
|||||||
fun showDialog(context: Context, icon: Int, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action): AlertDialog {
|
fun showDialog(context: Context, icon: Int, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action): AlertDialog {
|
||||||
return showDialog(context, icon, false, text, textDetails, code, defaultCloseAction, *actions);
|
return showDialog(context, icon, false, text, textDetails, code, defaultCloseAction, *actions);
|
||||||
}
|
}
|
||||||
fun showDialog(context: Context, icon: Int, animated: Boolean, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action): AlertDialog {
|
fun showDialog(context: Context, icon: Int, animated: Boolean, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action): AlertDialog
|
||||||
|
= showDialog(context, icon, animated, text, textDetails, code, null, null, defaultCloseAction, *actions);
|
||||||
|
fun showDialog(context: Context, icon: Int, animated: Boolean, text: String, textDetails: String? = null, code: String? = null, input: String?, placeholder: String?, defaultCloseAction: Int, vararg actions: Action): AlertDialog {
|
||||||
val builder = AlertDialog.Builder(context);
|
val builder = AlertDialog.Builder(context);
|
||||||
val view = LayoutInflater.from(context).inflate(R.layout.dialog_multi_button, null);
|
val view = LayoutInflater.from(context).inflate(R.layout.dialog_multi_button, null);
|
||||||
builder.setView(view);
|
builder.setView(view);
|
||||||
@@ -226,6 +228,16 @@ class UIDialogs {
|
|||||||
this.text = textDetails;
|
this.text = textDetails;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
var inputView = view.findViewById<TextView>(R.id.dialog_text_input);
|
||||||
|
inputView.apply {
|
||||||
|
if (input == null && placeholder == null) this.visibility = View.GONE;
|
||||||
|
else {
|
||||||
|
this.text = input ?: "";
|
||||||
|
this.hint = placeholder ?: "";
|
||||||
|
this.visibility = View.VISIBLE;
|
||||||
|
this.textAlignment = View.TEXT_ALIGNMENT_VIEW_START
|
||||||
|
}
|
||||||
|
};
|
||||||
view.findViewById<TextView>(R.id.dialog_text_code).apply {
|
view.findViewById<TextView>(R.id.dialog_text_code).apply {
|
||||||
if (code == null) this.visibility = View.GONE;
|
if (code == null) this.visibility = View.GONE;
|
||||||
else {
|
else {
|
||||||
@@ -250,7 +262,7 @@ class UIDialogs {
|
|||||||
buttonView.textSize = 14f;
|
buttonView.textSize = 14f;
|
||||||
buttonView.typeface = resources.getFont(R.font.inter_regular);
|
buttonView.typeface = resources.getFont(R.font.inter_regular);
|
||||||
buttonView.text = act.text;
|
buttonView.text = act.text;
|
||||||
buttonView.setOnClickListener { act.action(); dialog.dismiss(); };
|
buttonView.setOnClickListener { act.invokeAction(DialogResult(inputView?.text?.toString())); dialog.dismiss(); };
|
||||||
when(act.style) {
|
when(act.style) {
|
||||||
ActionStyle.PRIMARY -> buttonView.setBackgroundResource(R.drawable.background_button_primary);
|
ActionStyle.PRIMARY -> buttonView.setBackgroundResource(R.drawable.background_button_primary);
|
||||||
ActionStyle.ACCENT -> buttonView.setBackgroundResource(R.drawable.background_button_accent);
|
ActionStyle.ACCENT -> buttonView.setBackgroundResource(R.drawable.background_button_accent);
|
||||||
@@ -275,7 +287,7 @@ class UIDialogs {
|
|||||||
};
|
};
|
||||||
dialog.setOnCancelListener {
|
dialog.setOnCancelListener {
|
||||||
if(defaultCloseAction >= 0 && defaultCloseAction < actions.size)
|
if(defaultCloseAction >= 0 && defaultCloseAction < actions.size)
|
||||||
actions[defaultCloseAction].action();
|
actions[defaultCloseAction].invokeAction(DialogResult(inputView?.text?.toString()));
|
||||||
}
|
}
|
||||||
dialog.setOnDismissListener {
|
dialog.setOnDismissListener {
|
||||||
registerDialogClosed(dialog);
|
registerDialogClosed(dialog);
|
||||||
@@ -424,7 +436,7 @@ class UIDialogs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fun showCastingDialog(context: Context) {
|
fun showCastingDialog(context: Context, ownerActivity: Activity? = null) {
|
||||||
val d = StateCasting.instance.activeDevice;
|
val d = StateCasting.instance.activeDevice;
|
||||||
if (d != null) {
|
if (d != null) {
|
||||||
val dialog = ConnectedCastingDialog(context);
|
val dialog = ConnectedCastingDialog(context);
|
||||||
@@ -432,6 +444,7 @@ class UIDialogs {
|
|||||||
dialog.setOwnerActivity(context)
|
dialog.setOwnerActivity(context)
|
||||||
}
|
}
|
||||||
registerDialogOpened(dialog);
|
registerDialogOpened(dialog);
|
||||||
|
ownerActivity?.let { dialog.setOwnerActivity(it) }
|
||||||
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
||||||
dialog.show();
|
dialog.show();
|
||||||
} else {
|
} else {
|
||||||
@@ -444,21 +457,24 @@ class UIDialogs {
|
|||||||
if (c is Activity) {
|
if (c is Activity) {
|
||||||
dialog.setOwnerActivity(c);
|
dialog.setOwnerActivity(c);
|
||||||
}
|
}
|
||||||
|
ownerActivity?.let { dialog.setOwnerActivity(it) }
|
||||||
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
||||||
dialog.show();
|
dialog.show();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showCastingTutorialDialog(context: Context) {
|
fun showCastingTutorialDialog(context: Context, ownerActivity: Activity? = null) {
|
||||||
val dialog = CastingHelpDialog(context);
|
val dialog = CastingHelpDialog(context);
|
||||||
registerDialogOpened(dialog);
|
registerDialogOpened(dialog);
|
||||||
|
ownerActivity?.let { dialog.setOwnerActivity(it) }
|
||||||
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
||||||
dialog.show();
|
dialog.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showCastingAddDialog(context: Context) {
|
fun showCastingAddDialog(context: Context, ownerActivity: Activity? = null) {
|
||||||
val dialog = CastingAddDialog(context);
|
val dialog = CastingAddDialog(context);
|
||||||
registerDialogOpened(dialog);
|
registerDialogOpened(dialog);
|
||||||
|
ownerActivity?.let { dialog.setOwnerActivity(it) }
|
||||||
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
||||||
dialog.show();
|
dialog.show();
|
||||||
}
|
}
|
||||||
@@ -531,17 +547,36 @@ class UIDialogs {
|
|||||||
}
|
}
|
||||||
class Action {
|
class Action {
|
||||||
val text: String;
|
val text: String;
|
||||||
val action: ()->Unit;
|
val action: ((DialogResult?)->Unit);
|
||||||
val style: ActionStyle;
|
val style: ActionStyle;
|
||||||
var center: Boolean;
|
var center: Boolean;
|
||||||
|
|
||||||
constructor(text: String, action: ()->Unit, style: ActionStyle = ActionStyle.NONE, center: Boolean = false) {
|
constructor(text: String, action: ()->Unit, style: ActionStyle = ActionStyle.NONE, center: Boolean = false) {
|
||||||
|
this.text = text;
|
||||||
|
this.action = { action() };
|
||||||
|
this.style = style;
|
||||||
|
this.center = center;
|
||||||
|
}
|
||||||
|
protected constructor(text: String, action: (DialogResult?)->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;
|
this.center = center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun invokeAction(input: DialogResult? = null) {
|
||||||
|
this.action(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun withInput(text: String, action: (DialogResult?)->Unit, style: ActionStyle = ActionStyle.NONE, center: Boolean = false): Action {
|
||||||
|
return Action(text, action, style, center);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
class DialogResult(
|
||||||
|
val text: String?
|
||||||
|
);
|
||||||
enum class ActionStyle {
|
enum class ActionStyle {
|
||||||
NONE,
|
NONE,
|
||||||
PRIMARY,
|
PRIMARY,
|
||||||
|
|||||||
@@ -129,115 +129,163 @@ class UISlideOverlays {
|
|||||||
val originalVideo = subscription.doFetchVideos;
|
val originalVideo = subscription.doFetchVideos;
|
||||||
val originalPosts = subscription.doFetchPosts;
|
val originalPosts = subscription.doFetchPosts;
|
||||||
|
|
||||||
val menu = SlideUpMenuOverlay(container.context, container, "Subscription Settings", null, true, listOf());
|
val menu = SlideUpMenuOverlay(
|
||||||
|
container.context,
|
||||||
|
container,
|
||||||
|
"Subscription Settings",
|
||||||
|
null,
|
||||||
|
true,
|
||||||
|
listOf()
|
||||||
|
);
|
||||||
|
|
||||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO){
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||||
val plugin = StatePlatform.instance.getChannelClient(subscription.channel.url);
|
try {
|
||||||
val capabilities = plugin.getChannelCapabilities();
|
val plugin = StatePlatform.instance.getChannelClient(subscription.channel.url);
|
||||||
|
val capabilities = plugin.getChannelCapabilities();
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
items.addAll(listOf(
|
items.addAll(
|
||||||
SlideUpMenuItem(
|
listOf(
|
||||||
container.context,
|
SlideUpMenuItem(
|
||||||
R.drawable.ic_notifications,
|
container.context,
|
||||||
"Notifications",
|
R.drawable.ic_notifications,
|
||||||
"",
|
"Notifications",
|
||||||
tag = "notifications",
|
"",
|
||||||
call = {
|
tag = "notifications",
|
||||||
subscription.doNotifications = menu?.selectOption(null, "notifications", true, true) ?: subscription.doNotifications;
|
call = {
|
||||||
},
|
subscription.doNotifications =
|
||||||
invokeParent = false
|
menu?.selectOption(null, "notifications", true, true)
|
||||||
),
|
?: subscription.doNotifications;
|
||||||
if(StateSubscriptionGroups.instance.getSubscriptionGroups().isNotEmpty())
|
},
|
||||||
SlideUpMenuGroup(container.context, "Subscription Groups",
|
invokeParent = false
|
||||||
"You can select which groups this subscription is part of.",
|
),
|
||||||
-1, listOf()) else null,
|
if (StateSubscriptionGroups.instance.getSubscriptionGroups()
|
||||||
if(StateSubscriptionGroups.instance.getSubscriptionGroups().isNotEmpty())
|
.isNotEmpty()
|
||||||
SlideUpMenuRecycler(container.context, "as") {
|
)
|
||||||
val groups = ArrayList<SubscriptionGroup>(StateSubscriptionGroups.instance.getSubscriptionGroups()
|
SlideUpMenuGroup(
|
||||||
.map { SubscriptionGroup.Selectable(it, it.urls.contains(subscription.channel.url)) }
|
container.context, "Subscription Groups",
|
||||||
.sortedBy { !it.selected });
|
"You can select which groups this subscription is part of.",
|
||||||
var adapter: AnyAdapterView<SubscriptionGroup, SubscriptionGroupBarViewHolder>? = null;
|
-1, listOf()
|
||||||
adapter = it.asAny(groups, RecyclerView.HORIZONTAL) {
|
) else null,
|
||||||
it.onClick.subscribe {
|
if (StateSubscriptionGroups.instance.getSubscriptionGroups()
|
||||||
if(it is SubscriptionGroup.Selectable) {
|
.isNotEmpty()
|
||||||
val actualGroup = StateSubscriptionGroups.instance.getSubscriptionGroup(it.id)
|
)
|
||||||
?: return@subscribe;
|
SlideUpMenuRecycler(container.context, "as") {
|
||||||
groups.clear();
|
val groups =
|
||||||
if(it.selected)
|
ArrayList<SubscriptionGroup>(
|
||||||
actualGroup.urls.remove(subscription.channel.url);
|
StateSubscriptionGroups.instance.getSubscriptionGroups()
|
||||||
else
|
.map {
|
||||||
actualGroup.urls.add(subscription.channel.url);
|
SubscriptionGroup.Selectable(
|
||||||
|
it,
|
||||||
|
it.urls.contains(subscription.channel.url)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.sortedBy { !it.selected });
|
||||||
|
var adapter: AnyAdapterView<SubscriptionGroup, SubscriptionGroupBarViewHolder>? =
|
||||||
|
null;
|
||||||
|
adapter = it.asAny(groups, RecyclerView.HORIZONTAL) {
|
||||||
|
it.onClick.subscribe {
|
||||||
|
if (it is SubscriptionGroup.Selectable) {
|
||||||
|
val actualGroup =
|
||||||
|
StateSubscriptionGroups.instance.getSubscriptionGroup(
|
||||||
|
it.id
|
||||||
|
)
|
||||||
|
?: return@subscribe;
|
||||||
|
groups.clear();
|
||||||
|
if (it.selected)
|
||||||
|
actualGroup.urls.remove(subscription.channel.url);
|
||||||
|
else
|
||||||
|
actualGroup.urls.add(subscription.channel.url);
|
||||||
|
|
||||||
StateSubscriptionGroups.instance.updateSubscriptionGroup(actualGroup);
|
StateSubscriptionGroups.instance.updateSubscriptionGroup(
|
||||||
groups.addAll(StateSubscriptionGroups.instance.getSubscriptionGroups()
|
actualGroup
|
||||||
.map { SubscriptionGroup.Selectable(it, it.urls.contains(subscription.channel.url)) }
|
);
|
||||||
.sortedBy { !it.selected });
|
groups.addAll(
|
||||||
adapter?.notifyContentChanged();
|
StateSubscriptionGroups.instance.getSubscriptionGroups()
|
||||||
}
|
.map {
|
||||||
}
|
SubscriptionGroup.Selectable(
|
||||||
};
|
it,
|
||||||
return@SlideUpMenuRecycler adapter;
|
it.urls.contains(subscription.channel.url)
|
||||||
} else null,
|
)
|
||||||
SlideUpMenuGroup(container.context, "Fetch Settings",
|
}
|
||||||
"Depending on the platform you might not need to enable a type for it to be available.",
|
.sortedBy { !it.selected });
|
||||||
-1, listOf()),
|
adapter?.notifyContentChanged();
|
||||||
if(capabilities.hasType(ResultCapabilities.TYPE_LIVE)) SlideUpMenuItem(
|
}
|
||||||
container.context,
|
}
|
||||||
R.drawable.ic_live_tv,
|
};
|
||||||
"Livestreams",
|
return@SlideUpMenuRecycler adapter;
|
||||||
"Check for livestreams",
|
} else null,
|
||||||
tag = "fetchLive",
|
SlideUpMenuGroup(
|
||||||
call = {
|
container.context, "Fetch Settings",
|
||||||
subscription.doFetchLive = menu?.selectOption(null, "fetchLive", true, true) ?: subscription.doFetchLive;
|
"Depending on the platform you might not need to enable a type for it to be available.",
|
||||||
},
|
-1, listOf()
|
||||||
invokeParent = false
|
),
|
||||||
) else null,
|
if (capabilities.hasType(ResultCapabilities.TYPE_LIVE)) SlideUpMenuItem(
|
||||||
if(capabilities.hasType(ResultCapabilities.TYPE_STREAMS)) SlideUpMenuItem(
|
container.context,
|
||||||
container.context,
|
R.drawable.ic_live_tv,
|
||||||
R.drawable.ic_play,
|
"Livestreams",
|
||||||
"Streams",
|
"Check for livestreams",
|
||||||
"Check for streams",
|
tag = "fetchLive",
|
||||||
tag = "fetchStreams",
|
call = {
|
||||||
call = {
|
subscription.doFetchLive =
|
||||||
subscription.doFetchStreams = menu?.selectOption(null, "fetchStreams", true, true) ?: subscription.doFetchStreams;
|
menu?.selectOption(null, "fetchLive", true, true)
|
||||||
},
|
?: subscription.doFetchLive;
|
||||||
invokeParent = false
|
},
|
||||||
) else null,
|
invokeParent = false
|
||||||
if(capabilities.hasType(ResultCapabilities.TYPE_VIDEOS))
|
) else null,
|
||||||
SlideUpMenuItem(
|
if (capabilities.hasType(ResultCapabilities.TYPE_STREAMS)) SlideUpMenuItem(
|
||||||
container.context,
|
container.context,
|
||||||
R.drawable.ic_play,
|
R.drawable.ic_play,
|
||||||
"Videos",
|
"Streams",
|
||||||
"Check for videos",
|
"Check for streams",
|
||||||
tag = "fetchVideos",
|
tag = "fetchStreams",
|
||||||
call = {
|
call = {
|
||||||
subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos;
|
subscription.doFetchStreams =
|
||||||
},
|
menu?.selectOption(null, "fetchStreams", true, true)
|
||||||
invokeParent = false
|
?: subscription.doFetchStreams;
|
||||||
) else if(capabilities.hasType(ResultCapabilities.TYPE_MIXED) || capabilities.types.isEmpty())
|
},
|
||||||
SlideUpMenuItem(
|
invokeParent = false
|
||||||
container.context,
|
) else null,
|
||||||
R.drawable.ic_play,
|
if (capabilities.hasType(ResultCapabilities.TYPE_VIDEOS))
|
||||||
"Content",
|
SlideUpMenuItem(
|
||||||
"Check for content",
|
container.context,
|
||||||
tag = "fetchVideos",
|
R.drawable.ic_play,
|
||||||
call = {
|
"Videos",
|
||||||
subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos;
|
"Check for videos",
|
||||||
},
|
tag = "fetchVideos",
|
||||||
invokeParent = false
|
call = {
|
||||||
) else null,
|
subscription.doFetchVideos =
|
||||||
if(capabilities.hasType(ResultCapabilities.TYPE_POSTS)) SlideUpMenuItem(
|
menu?.selectOption(null, "fetchVideos", true, true)
|
||||||
container.context,
|
?: subscription.doFetchVideos;
|
||||||
R.drawable.ic_chat,
|
},
|
||||||
"Posts",
|
invokeParent = false
|
||||||
"Check for posts",
|
) else if (capabilities.hasType(ResultCapabilities.TYPE_MIXED) || capabilities.types.isEmpty())
|
||||||
tag = "fetchPosts",
|
SlideUpMenuItem(
|
||||||
call = {
|
container.context,
|
||||||
subscription.doFetchPosts = menu?.selectOption(null, "fetchPosts", true, true) ?: subscription.doFetchPosts;
|
R.drawable.ic_play,
|
||||||
},
|
"Content",
|
||||||
invokeParent = false
|
"Check for content",
|
||||||
) else null/*,,
|
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",
|
||||||
@@ -245,61 +293,82 @@ class UISlideOverlays {
|
|||||||
SlideUpMenuItem(container.context, R.drawable.ic_list, "Add to Group", "", "btnAddToGroup", {
|
SlideUpMenuItem(container.context, R.drawable.ic_list, "Add to Group", "", "btnAddToGroup", {
|
||||||
showCreateSubscriptionGroup(container, subscription.channel);
|
showCreateSubscriptionGroup(container, subscription.channel);
|
||||||
}, false)*/
|
}, false)*/
|
||||||
).filterNotNull());
|
).filterNotNull()
|
||||||
|
);
|
||||||
|
|
||||||
menu.setItems(items);
|
menu.setItems(items);
|
||||||
|
|
||||||
if(subscription.doNotifications)
|
if (subscription.doNotifications)
|
||||||
menu.selectOption(null, "notifications", true, true);
|
menu.selectOption(null, "notifications", true, true);
|
||||||
if(subscription.doFetchLive)
|
if (subscription.doFetchLive)
|
||||||
menu.selectOption(null, "fetchLive", true, true);
|
menu.selectOption(null, "fetchLive", true, true);
|
||||||
if(subscription.doFetchStreams)
|
if (subscription.doFetchStreams)
|
||||||
menu.selectOption(null, "fetchStreams", true, true);
|
menu.selectOption(null, "fetchStreams", true, true);
|
||||||
if(subscription.doFetchVideos)
|
if (subscription.doFetchVideos)
|
||||||
menu.selectOption(null, "fetchVideos", true, true);
|
menu.selectOption(null, "fetchVideos", true, true);
|
||||||
if(subscription.doFetchPosts)
|
if (subscription.doFetchPosts)
|
||||||
menu.selectOption(null, "fetchPosts", true, true);
|
menu.selectOption(null, "fetchPosts", true, true);
|
||||||
|
|
||||||
menu.onOK.subscribe {
|
menu.onOK.subscribe {
|
||||||
subscription.save();
|
subscription.save();
|
||||||
menu.hide(true);
|
menu.hide(true);
|
||||||
|
|
||||||
if(subscription.doNotifications && !originalNotif) {
|
if (subscription.doNotifications && !originalNotif) {
|
||||||
val mainContext = StateApp.instance.contextOrNull;
|
val mainContext = StateApp.instance.contextOrNull;
|
||||||
if(Settings.instance.subscriptions.subscriptionsBackgroundUpdateInterval == 0) {
|
if (Settings.instance.subscriptions.subscriptionsBackgroundUpdateInterval == 0) {
|
||||||
UIDialogs.toast(container.context, "Enable 'Background Update' in settings for notifications to work");
|
UIDialogs.toast(
|
||||||
|
container.context,
|
||||||
|
"Enable 'Background Update' in settings for notifications to work"
|
||||||
|
);
|
||||||
|
|
||||||
if(mainContext is MainActivity) {
|
if (mainContext is MainActivity) {
|
||||||
UIDialogs.showDialog(mainContext, R.drawable.ic_settings, "Background Updating Required",
|
UIDialogs.showDialog(
|
||||||
"You need to set a Background Updating interval for notifications", null, 0,
|
mainContext,
|
||||||
UIDialogs.Action("Cancel", {}),
|
R.drawable.ic_settings,
|
||||||
UIDialogs.Action("Configure", {
|
"Background Updating Required",
|
||||||
val intent = Intent(mainContext, SettingsActivity::class.java);
|
"You need to set a Background Updating interval for notifications",
|
||||||
intent.putExtra("query", mainContext.getString(R.string.background_update));
|
null,
|
||||||
mainContext.startActivity(intent);
|
0,
|
||||||
}, UIDialogs.ActionStyle.PRIMARY));
|
UIDialogs.Action("Cancel", {}),
|
||||||
}
|
UIDialogs.Action("Configure", {
|
||||||
return@subscribe;
|
val intent = Intent(
|
||||||
}
|
mainContext,
|
||||||
else if(!(mainContext?.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).areNotificationsEnabled()) {
|
SettingsActivity::class.java
|
||||||
UIDialogs.toast(container.context, "Android notifications are disabled");
|
);
|
||||||
if(mainContext is MainActivity) {
|
intent.putExtra(
|
||||||
mainContext.requestNotificationPermissions("Notifications are required for subscription updating and notifications to work");
|
"query",
|
||||||
|
mainContext.getString(R.string.background_update)
|
||||||
|
);
|
||||||
|
mainContext.startActivity(intent);
|
||||||
|
}, UIDialogs.ActionStyle.PRIMARY)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return@subscribe;
|
||||||
|
} else if (!(mainContext?.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).areNotificationsEnabled()) {
|
||||||
|
UIDialogs.toast(
|
||||||
|
container.context,
|
||||||
|
"Android notifications are disabled"
|
||||||
|
);
|
||||||
|
if (mainContext is MainActivity) {
|
||||||
|
mainContext.requestNotificationPermissions("Notifications are required for subscription updating and notifications to work");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
};
|
menu.onCancel.subscribe {
|
||||||
menu.onCancel.subscribe {
|
subscription.doNotifications = originalNotif;
|
||||||
subscription.doNotifications = originalNotif;
|
subscription.doFetchLive = originalLive;
|
||||||
subscription.doFetchLive = originalLive;
|
subscription.doFetchStreams = originalStream;
|
||||||
subscription.doFetchStreams = originalStream;
|
subscription.doFetchVideos = originalVideo;
|
||||||
subscription.doFetchVideos = originalVideo;
|
subscription.doFetchPosts = originalPosts;
|
||||||
subscription.doFetchPosts = originalPosts;
|
};
|
||||||
};
|
|
||||||
|
|
||||||
menu.setOk("Save");
|
menu.setOk("Save");
|
||||||
|
|
||||||
menu.show();
|
menu.show();
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to show subscription overlay.", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import com.futo.platformplayer.api.media.platforms.js.SourceAuth
|
|||||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginAuthConfig
|
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.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.matchesDomain
|
||||||
import com.futo.platformplayer.others.LoginWebViewClient
|
import com.futo.platformplayer.others.LoginWebViewClient
|
||||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||||
import com.futo.platformplayer.states.StateApp
|
import com.futo.platformplayer.states.StateApp
|
||||||
@@ -74,9 +75,26 @@ class LoginActivity : AppCompatActivity() {
|
|||||||
finish();
|
finish();
|
||||||
};
|
};
|
||||||
var isFirstLoad = true;
|
var isFirstLoad = true;
|
||||||
|
val loginWarnings = authConfig.loginWarnings?.toMutableList() ?: mutableListOf<SourcePluginAuthConfig.Warning>();
|
||||||
|
val uiMods = authConfig.uiMods?.toMutableList() ?: mutableListOf<SourcePluginAuthConfig.UIMod>();
|
||||||
|
var currentScale = 100;
|
||||||
|
var currentDesktop = false;
|
||||||
webViewClient.onPageLoaded.subscribe { view, url ->
|
webViewClient.onPageLoaded.subscribe { view, url ->
|
||||||
_textUrl.setText(url ?: "");
|
_textUrl.setText(url ?: "");
|
||||||
|
|
||||||
|
if(loginWarnings.size > 0 && url != null) {
|
||||||
|
synchronized(loginWarnings) {
|
||||||
|
val warning = loginWarnings.find { url.matches(it.getRegex()) };
|
||||||
|
if(warning != null) {
|
||||||
|
if(warning.once == true)
|
||||||
|
loginWarnings.remove(warning);
|
||||||
|
UIDialogs.showDialog(this@LoginActivity, R.drawable.ic_warning_yellow, warning.text ?: "", warning.details ?: "", null, 0,
|
||||||
|
UIDialogs.Action("Understood", {
|
||||||
|
}, UIDialogs.ActionStyle.PRIMARY));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if(!isFirstLoad)
|
if(!isFirstLoad)
|
||||||
return@subscribe;
|
return@subscribe;
|
||||||
isFirstLoad = false;
|
isFirstLoad = false;
|
||||||
@@ -86,6 +104,35 @@ class LoginActivity : AppCompatActivity() {
|
|||||||
//TODO: Find most reliable way to wait for page js to finish
|
//TODO: Find most reliable way to wait for page js to finish
|
||||||
view?.evaluateJavascript("setTimeout(()=> document.querySelector(\"${authConfig.loginButton}\")?.click(), 1000)", {});
|
view?.evaluateJavascript("setTimeout(()=> document.querySelector(\"${authConfig.loginButton}\")?.click(), 1000)", {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
var specifiedScale = false;
|
||||||
|
var specifiedDesktop = false;
|
||||||
|
if(uiMods.size > 0 && url != null) {
|
||||||
|
synchronized(uiMods) {
|
||||||
|
val uimod = uiMods.find { url.matches(it.getRegex()) };
|
||||||
|
if(uimod != null) {
|
||||||
|
if(uimod.scale != null) {
|
||||||
|
currentScale =(uimod.scale * 100).toInt();
|
||||||
|
_webView.setInitialScale(currentScale);
|
||||||
|
specifiedScale = true;
|
||||||
|
}
|
||||||
|
if(uimod.desktop != null && uimod.desktop) {
|
||||||
|
_webView.settings.useWideViewPort = true;
|
||||||
|
specifiedDesktop = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(!specifiedScale && currentScale != 100) {
|
||||||
|
currentScale = (100).toInt();
|
||||||
|
_webView.setInitialScale(currentScale);
|
||||||
|
}
|
||||||
|
if(!specifiedDesktop && currentDesktop) {
|
||||||
|
_webView.settings.useWideViewPort = false;
|
||||||
|
currentDesktop = false;
|
||||||
|
}
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
_webView.settings.domStorageEnabled = true;
|
_webView.settings.domStorageEnabled = true;
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import com.futo.platformplayer.R
|
|||||||
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.media.models.video.LocalVideoDetails
|
||||||
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.dp
|
import com.futo.platformplayer.dp
|
||||||
@@ -62,6 +63,7 @@ import com.futo.platformplayer.fragment.mainactivity.main.PlaylistSearchResultsF
|
|||||||
import com.futo.platformplayer.fragment.mainactivity.main.PlaylistsFragment
|
import com.futo.platformplayer.fragment.mainactivity.main.PlaylistsFragment
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.PostDetailFragment
|
import com.futo.platformplayer.fragment.mainactivity.main.PostDetailFragment
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.RemotePlaylistFragment
|
import com.futo.platformplayer.fragment.mainactivity.main.RemotePlaylistFragment
|
||||||
|
import com.futo.platformplayer.fragment.mainactivity.main.ShortsFragment
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.SourceDetailFragment
|
import com.futo.platformplayer.fragment.mainactivity.main.SourceDetailFragment
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.SourcesFragment
|
import com.futo.platformplayer.fragment.mainactivity.main.SourcesFragment
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionGroupFragment
|
import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionGroupFragment
|
||||||
@@ -169,6 +171,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
lateinit var _fragMainRemotePlaylist: RemotePlaylistFragment;
|
lateinit var _fragMainRemotePlaylist: RemotePlaylistFragment;
|
||||||
lateinit var _fragWatchlist: WatchLaterFragment;
|
lateinit var _fragWatchlist: WatchLaterFragment;
|
||||||
lateinit var _fragHistory: HistoryFragment;
|
lateinit var _fragHistory: HistoryFragment;
|
||||||
|
lateinit var _fragShorts: ShortsFragment;
|
||||||
lateinit var _fragSourceDetail: SourceDetailFragment;
|
lateinit var _fragSourceDetail: SourceDetailFragment;
|
||||||
lateinit var _fragDownloads: DownloadsFragment;
|
lateinit var _fragDownloads: DownloadsFragment;
|
||||||
lateinit var _fragImportSubscriptions: ImportSubscriptionsFragment;
|
lateinit var _fragImportSubscriptions: ImportSubscriptionsFragment;
|
||||||
@@ -338,6 +341,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
_fragWebDetail = WebDetailFragment.newInstance();
|
_fragWebDetail = WebDetailFragment.newInstance();
|
||||||
_fragWatchlist = WatchLaterFragment.newInstance();
|
_fragWatchlist = WatchLaterFragment.newInstance();
|
||||||
_fragHistory = HistoryFragment.newInstance();
|
_fragHistory = HistoryFragment.newInstance();
|
||||||
|
_fragShorts = ShortsFragment.newInstance();
|
||||||
_fragSourceDetail = SourceDetailFragment.newInstance();
|
_fragSourceDetail = SourceDetailFragment.newInstance();
|
||||||
_fragDownloads = DownloadsFragment();
|
_fragDownloads = DownloadsFragment();
|
||||||
_fragImportSubscriptions = ImportSubscriptionsFragment.newInstance();
|
_fragImportSubscriptions = ImportSubscriptionsFragment.newInstance();
|
||||||
@@ -765,7 +769,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
if (targetData != null) {
|
if (targetData != null) {
|
||||||
lifecycleScope.launch(Dispatchers.Main) {
|
lifecycleScope.launch(Dispatchers.Main) {
|
||||||
try {
|
try {
|
||||||
handleUrlAll(targetData)
|
handleUrlAll(targetData, intent)
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Logger.e(TAG, "Unhandled exception in handleUrlAll", e)
|
Logger.e(TAG, "Unhandled exception in handleUrlAll", e)
|
||||||
}
|
}
|
||||||
@@ -776,8 +780,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun handleUrlAll(url: String) {
|
suspend fun handleUrlAll(url: String, openIntent: Intent? = null) {
|
||||||
val uri = Uri.parse(url)
|
val uri = Uri.parse(url)
|
||||||
|
val intent = openIntent ?: this.intent;
|
||||||
when (uri.scheme) {
|
when (uri.scheme) {
|
||||||
"grayjay" -> {
|
"grayjay" -> {
|
||||||
if (url.startsWith("grayjay://license/")) {
|
if (url.startsWith("grayjay://license/")) {
|
||||||
@@ -804,11 +809,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
"content" -> {
|
"content" -> {
|
||||||
if (!handleContent(url, intent.type)) {
|
if (!handleContent(url, intent?.type)) {
|
||||||
UIDialogs.showSingleButtonDialog(
|
UIDialogs.showSingleButtonDialog(
|
||||||
this,
|
this,
|
||||||
R.drawable.ic_play,
|
R.drawable.ic_play,
|
||||||
getString(R.string.unknown_content_format) + " [${url}]\n[${intent.type}]",
|
getString(R.string.unknown_content_format) + " [${url}]\n[${intent?.type}]",
|
||||||
"Ok",
|
"Ok",
|
||||||
{ });
|
{ });
|
||||||
}
|
}
|
||||||
@@ -929,6 +934,12 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
} else if (file.lowercase().endsWith(".txt") || mime == "text/plain") {
|
} else if (file.lowercase().endsWith(".txt") || mime == "text/plain") {
|
||||||
return handleUnknownText(String(data));
|
return handleUnknownText(String(data));
|
||||||
}
|
}
|
||||||
|
else if (mime?.let { it.startsWith("video/") || it.startsWith("audio/") } ?: false) {
|
||||||
|
val mediaItem = LocalVideoDetails.fromContent(file, mime);
|
||||||
|
navigateWhenReady(_fragVideoDetail, mediaItem);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1043,7 +1054,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
Logger.i(TAG, "handleFCast");
|
Logger.i(TAG, "handleFCast");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
StateCasting.instance.handleUrl(this, url)
|
StateCasting.instance.handleUrl(url)
|
||||||
return true;
|
return true;
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Log.e(TAG, "Failed to parse FCast URL '${url}'.", e)
|
Log.e(TAG, "Failed to parse FCast URL '${url}'.", e)
|
||||||
@@ -1253,6 +1264,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
WebDetailFragment::class -> _fragWebDetail as T;
|
WebDetailFragment::class -> _fragWebDetail as T;
|
||||||
WatchLaterFragment::class -> _fragWatchlist as T;
|
WatchLaterFragment::class -> _fragWatchlist as T;
|
||||||
HistoryFragment::class -> _fragHistory as T;
|
HistoryFragment::class -> _fragHistory as T;
|
||||||
|
ShortsFragment::class -> _fragShorts as T;
|
||||||
SourceDetailFragment::class -> _fragSourceDetail as T;
|
SourceDetailFragment::class -> _fragSourceDetail as T;
|
||||||
DownloadsFragment::class -> _fragDownloads as T;
|
DownloadsFragment::class -> _fragDownloads as T;
|
||||||
ImportSubscriptionsFragment::class -> _fragImportSubscriptions as T;
|
ImportSubscriptionsFragment::class -> _fragImportSubscriptions as T;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ 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.playlists.IPlatformPlaylist
|
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
|
||||||
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.structures.IPager
|
import com.futo.platformplayer.api.media.structures.IPager
|
||||||
import com.futo.platformplayer.models.ImageVariable
|
import com.futo.platformplayer.models.ImageVariable
|
||||||
|
|
||||||
@@ -36,6 +37,11 @@ interface IPlatformClient {
|
|||||||
*/
|
*/
|
||||||
fun getHome(): IPager<IPlatformContent>
|
fun getHome(): IPager<IPlatformContent>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the shorts feed
|
||||||
|
*/
|
||||||
|
fun getShorts(): IPager<IPlatformVideo>
|
||||||
|
|
||||||
//Search
|
//Search
|
||||||
/**
|
/**
|
||||||
* Gets search suggestion for the provided query string
|
* Gets search suggestion for the provided query string
|
||||||
@@ -176,6 +182,10 @@ interface IPlatformClient {
|
|||||||
* Retrieves the subscriptions of the currently logged in user
|
* Retrieves the subscriptions of the currently logged in user
|
||||||
*/
|
*/
|
||||||
fun getUserSubscriptions(): Array<String>;
|
fun getUserSubscriptions(): Array<String>;
|
||||||
|
/**
|
||||||
|
* Retrieves the history of the currently logged in user
|
||||||
|
*/
|
||||||
|
fun getUserHistory(): IPager<IPlatformContent>;
|
||||||
|
|
||||||
|
|
||||||
fun isClaimTypeSupported(claimType: Int): Boolean;
|
fun isClaimTypeSupported(claimType: Int): Boolean;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
|
|||||||
import com.futo.platformplayer.api.media.models.live.LiveEventComment
|
import com.futo.platformplayer.api.media.models.live.LiveEventComment
|
||||||
import com.futo.platformplayer.api.media.models.live.LiveEventEmojis
|
import com.futo.platformplayer.api.media.models.live.LiveEventEmojis
|
||||||
import com.futo.platformplayer.api.media.platforms.js.models.JSLiveEventPager
|
import com.futo.platformplayer.api.media.platforms.js.models.JSLiveEventPager
|
||||||
|
import com.futo.platformplayer.api.media.platforms.js.models.JSVODEventPager
|
||||||
import com.futo.platformplayer.api.media.structures.IPager
|
import com.futo.platformplayer.api.media.structures.IPager
|
||||||
import com.futo.platformplayer.constructs.BatchedTaskHandler
|
import com.futo.platformplayer.constructs.BatchedTaskHandler
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
@@ -26,12 +27,17 @@ class LiveChatManager {
|
|||||||
private val _emojiCache: EmojiCache = EmojiCache();
|
private val _emojiCache: EmojiCache = EmojiCache();
|
||||||
private val _pager: IPager<IPlatformLiveEvent>?;
|
private val _pager: IPager<IPlatformLiveEvent>?;
|
||||||
|
|
||||||
|
private var _position: Long = 0;
|
||||||
|
private var _eventsPosition: Long = 0;
|
||||||
|
|
||||||
private val _history: ArrayList<IPlatformLiveEvent> = arrayListOf();
|
private val _history: ArrayList<IPlatformLiveEvent> = arrayListOf();
|
||||||
|
|
||||||
private var _startCounter = 0;
|
private var _startCounter = 0;
|
||||||
|
|
||||||
private val _followers: HashMap<Any, (List<IPlatformLiveEvent>) -> Unit> = hashMapOf();
|
private val _followers: HashMap<Any, (List<IPlatformLiveEvent>) -> Unit> = hashMapOf();
|
||||||
|
|
||||||
|
val isVOD get() = _pager is JSVODEventPager;
|
||||||
|
|
||||||
var viewCount: Long = 0
|
var viewCount: Long = 0
|
||||||
private set;
|
private set;
|
||||||
|
|
||||||
@@ -39,8 +45,24 @@ class LiveChatManager {
|
|||||||
_scope = scope;
|
_scope = scope;
|
||||||
_pager = pager;
|
_pager = pager;
|
||||||
viewCount = initialViewCount;
|
viewCount = initialViewCount;
|
||||||
handleEvents(listOf(LiveEventComment("SYSTEM", null, "Live chat is still under construction. While it is mostly functional, the experience still needs to be improved.\n")));
|
if(pager is JSVODEventPager)
|
||||||
handleEvents(pager.getResults());
|
handleEvents(listOf(LiveEventComment("SYSTEM", null, "VOD chat is still under construction. While it is mostly functional, the experience still needs to be improved.\n")));
|
||||||
|
else
|
||||||
|
handleEvents(listOf(LiveEventComment("SYSTEM", null, "Live chat is still under construction. While it is mostly functional, the experience still needs to be improved.\n")));
|
||||||
|
|
||||||
|
if(pager is JSVODEventPager) {
|
||||||
|
var replayResults = pager.getResults().filter { it.time > _eventsPosition || it is LiveEventEmojis };
|
||||||
|
//TODO: Remove this once dripfeed is done properly
|
||||||
|
replayResults = replayResults.filter{ it.time < _eventsPosition + 1500 || it is LiveEventEmojis };
|
||||||
|
if(replayResults.size > 0) {
|
||||||
|
_eventsPosition = replayResults.maxOf { it.time };
|
||||||
|
Logger.i(TAG, "VOD Events last event: " + _eventsPosition);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
_eventsPosition = _eventsPosition + 1500;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
handleEvents(pager.getResults());
|
||||||
}
|
}
|
||||||
|
|
||||||
fun start() {
|
fun start() {
|
||||||
@@ -52,6 +74,10 @@ class LiveChatManager {
|
|||||||
_startCounter++;
|
_startCounter++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setVideoPosition(ms: Long) {
|
||||||
|
_position = ms;
|
||||||
|
}
|
||||||
|
|
||||||
fun getHistory(): List<IPlatformLiveEvent> {
|
fun getHistory(): List<IPlatformLiveEvent> {
|
||||||
synchronized(_history) {
|
synchronized(_history) {
|
||||||
return _history.toList();
|
return _history.toList();
|
||||||
@@ -85,13 +111,34 @@ class LiveChatManager {
|
|||||||
try {
|
try {
|
||||||
while(_startCounter == counter) {
|
while(_startCounter == counter) {
|
||||||
var nextInterval = 1000L;
|
var nextInterval = 1000L;
|
||||||
|
if(_pager is JSVODEventPager && _eventsPosition > _position) {
|
||||||
|
delay(500);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if(_pager == null || !_pager.hasMorePages())
|
if(_pager == null || !_pager.hasMorePages())
|
||||||
return@launch;
|
return@launch;
|
||||||
_pager.nextPage();
|
val newEvents = if(_pager is JSVODEventPager) {
|
||||||
val newEvents = _pager.getResults();
|
val requestPosition = _position;
|
||||||
|
_pager.nextPage(requestPosition.toInt());
|
||||||
|
var replayResults = _pager.getResults().filter { it.time > requestPosition || it is LiveEventEmojis };
|
||||||
|
if(replayResults.size > 0) {
|
||||||
|
_eventsPosition = replayResults.maxOf { it.time };
|
||||||
|
Logger.i(TAG, "VOD Events last event: " + _eventsPosition);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
_eventsPosition = requestPosition + _pager.nextRequest.coerceAtLeast(800).toLong();
|
||||||
|
replayResults;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
_pager.nextPage();
|
||||||
|
_pager.getResults();
|
||||||
|
}
|
||||||
if(_pager is JSLiveEventPager)
|
if(_pager is JSLiveEventPager)
|
||||||
nextInterval = _pager.nextRequest.coerceAtLeast(800).toLong();
|
nextInterval = _pager.nextRequest.coerceAtLeast(800).toLong();
|
||||||
|
else if(_pager is JSVODEventPager)
|
||||||
|
nextInterval = _pager.nextRequest.coerceAtLeast(800).toLong();
|
||||||
|
|
||||||
if(newEvents.size > 0)
|
if(newEvents.size > 0)
|
||||||
Logger.i(TAG, "New Live Events (${newEvents.size}) [${newEvents.map { it.type.name }.joinToString(", ")}]");
|
Logger.i(TAG, "New Live Events (${newEvents.size}) [${newEvents.map { it.type.name }.joinToString(", ")}]");
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ data class PlatformClientCapabilities(
|
|||||||
val hasGetContentChapters: Boolean = false,
|
val hasGetContentChapters: Boolean = false,
|
||||||
val hasPeekChannelContents: Boolean = false,
|
val hasPeekChannelContents: Boolean = false,
|
||||||
val hasGetChannelPlaylists: Boolean = false,
|
val hasGetChannelPlaylists: Boolean = false,
|
||||||
val hasGetContentRecommendations: Boolean = false
|
val hasGetContentRecommendations: Boolean = false,
|
||||||
|
val hasGetUserHistory: Boolean = false
|
||||||
) {
|
) {
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,7 @@ import com.futo.platformplayer.getOrThrow
|
|||||||
|
|
||||||
interface IPlatformLiveEvent {
|
interface IPlatformLiveEvent {
|
||||||
val type : LiveEventType;
|
val type : LiveEventType;
|
||||||
|
var time: Long;
|
||||||
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -18,12 +18,15 @@ class LiveEventComment: IPlatformLiveEvent, ILiveEventChatMessage {
|
|||||||
val colorName: String?;
|
val colorName: String?;
|
||||||
val badges: List<String>;
|
val badges: List<String>;
|
||||||
|
|
||||||
constructor(name: String, thumbnail: String?, message: String, colorName: String? = null, badges: List<String>? = null) {
|
override var time: Long = -1;
|
||||||
|
|
||||||
|
constructor(name: String, thumbnail: String?, message: String, colorName: String? = null, badges: List<String>? = null, time: Long = -1) {
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.message = message;
|
this.message = message;
|
||||||
this.thumbnail = thumbnail;
|
this.thumbnail = thumbnail;
|
||||||
this.colorName = colorName;
|
this.colorName = colorName;
|
||||||
this.badges = badges ?: listOf();
|
this.badges = badges ?: listOf();
|
||||||
|
this.time = time;
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -39,7 +42,8 @@ class LiveEventComment: IPlatformLiveEvent, ILiveEventChatMessage {
|
|||||||
obj.getOrThrow(config, "name", contextName),
|
obj.getOrThrow(config, "name", contextName),
|
||||||
obj.getOrThrow(config, "thumbnail", contextName, true),
|
obj.getOrThrow(config, "thumbnail", contextName, true),
|
||||||
obj.getOrThrow(config, "message", contextName),
|
obj.getOrThrow(config, "message", contextName),
|
||||||
colorName, badges);
|
colorName, badges,
|
||||||
|
obj.getOrDefault(config, "time", contextName, -1) ?: -1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -21,6 +21,8 @@ class LiveEventDonation: IPlatformLiveEvent, ILiveEventChatMessage {
|
|||||||
|
|
||||||
var expire: Int = 6000;
|
var expire: Int = 6000;
|
||||||
|
|
||||||
|
override var time: Long = -1;
|
||||||
|
|
||||||
|
|
||||||
constructor(name: String, thumbnail: String?, message: String, amount: String, expire: Int = 6000, colorDonation: String? = null) {
|
constructor(name: String, thumbnail: String?, message: String, amount: String, expire: Int = 6000, colorDonation: String? = null) {
|
||||||
this.name = name;
|
this.name = name;
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ class LiveEventEmojis: IPlatformLiveEvent {
|
|||||||
|
|
||||||
val emojis: HashMap<String, String>;
|
val emojis: HashMap<String, String>;
|
||||||
|
|
||||||
|
override var time: Long = -1;
|
||||||
|
|
||||||
constructor(emojis: HashMap<String, String>) {
|
constructor(emojis: HashMap<String, String>) {
|
||||||
this.emojis = emojis;
|
this.emojis = emojis;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ class LiveEventRaid: IPlatformLiveEvent {
|
|||||||
val targetUrl: String;
|
val targetUrl: String;
|
||||||
val isOutgoing: Boolean;
|
val isOutgoing: Boolean;
|
||||||
|
|
||||||
|
override var time: Long = -1;
|
||||||
|
|
||||||
constructor(name: String, url: String, thumbnail: String, isOutgoing: Boolean) {
|
constructor(name: String, url: String, thumbnail: String, isOutgoing: Boolean) {
|
||||||
this.targetName = name;
|
this.targetName = name;
|
||||||
this.targetUrl = url;
|
this.targetUrl = url;
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ class LiveEventViewCount: IPlatformLiveEvent {
|
|||||||
|
|
||||||
val viewCount: Int;
|
val viewCount: Int;
|
||||||
|
|
||||||
|
override var time: Long = -1;
|
||||||
|
|
||||||
constructor(viewCount: Int) {
|
constructor(viewCount: Int) {
|
||||||
this.viewCount = viewCount;
|
this.viewCount = viewCount;
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-3
@@ -2,10 +2,24 @@ package com.futo.platformplayer.api.media.models.streams
|
|||||||
|
|
||||||
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.IVideoSource
|
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||||
|
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalAudioContentSource
|
||||||
import com.futo.platformplayer.downloads.VideoLocal
|
import com.futo.platformplayer.downloads.VideoLocal
|
||||||
|
|
||||||
|
|
||||||
class LocalVideoUnMuxedSourceDescriptor(private val video: VideoLocal) : VideoUnMuxedSourceDescriptor() {
|
class LocalVideoUnMuxedSourceDescriptor : VideoUnMuxedSourceDescriptor {
|
||||||
override val videoSources: Array<IVideoSource> get() = video.videoSource.toTypedArray();
|
override val videoSources: Array<IVideoSource>;
|
||||||
override val audioSources: Array<IAudioSource> get() = video.audioSource.toTypedArray();
|
override val audioSources: Array<IAudioSource>;
|
||||||
|
|
||||||
|
constructor(video: VideoLocal) {
|
||||||
|
videoSources = video.videoSource.toTypedArray();
|
||||||
|
audioSources = video.audioSource.toTypedArray();
|
||||||
|
}
|
||||||
|
constructor(audio: LocalAudioContentSource) {
|
||||||
|
videoSources = arrayOf()
|
||||||
|
audioSources = arrayOf(audio);
|
||||||
|
}
|
||||||
|
constructor(videoSources: Array<IVideoSource>, audioSources: Array<IAudioSource>) {
|
||||||
|
this.videoSources = videoSources;
|
||||||
|
this.audioSources = audioSources;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+2
-1
@@ -14,7 +14,8 @@ class AudioUrlSource(
|
|||||||
override val language: String = Language.UNKNOWN,
|
override val language: String = Language.UNKNOWN,
|
||||||
override val duration: Long? = null,
|
override val duration: Long? = null,
|
||||||
override var priority: Boolean = false,
|
override var priority: Boolean = false,
|
||||||
override var original: Boolean = false
|
override var original: Boolean = false,
|
||||||
|
var isLocal: Boolean = false
|
||||||
) : IAudioUrlSource, IStreamMetaDataSource{
|
) : IAudioUrlSource, IStreamMetaDataSource{
|
||||||
override var streamMetaData: StreamMetaData? = null;
|
override var streamMetaData: StreamMetaData? = null;
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -14,7 +14,8 @@ open class VideoUrlSource(
|
|||||||
override val codec : String = "",
|
override val codec : String = "",
|
||||||
override val bitrate : Int? = 0,
|
override val bitrate : Int? = 0,
|
||||||
|
|
||||||
override var priority: Boolean = false
|
override var priority: Boolean = false,
|
||||||
|
var isLocal: Boolean = false
|
||||||
) : IVideoUrlSource, IStreamMetaDataSource {
|
) : IVideoUrlSource, IStreamMetaDataSource {
|
||||||
override var streamMetaData: StreamMetaData? = null;
|
override var streamMetaData: StreamMetaData? = null;
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.models.video
|
|||||||
|
|
||||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A search result representing a video (overview data)
|
* A search result representing a video (overview data)
|
||||||
@@ -12,6 +13,9 @@ interface IPlatformVideo : IPlatformContent {
|
|||||||
val duration: Long;
|
val duration: Long;
|
||||||
val viewCount: Long;
|
val viewCount: Long;
|
||||||
|
|
||||||
|
val playbackTime: Long;
|
||||||
|
val playbackDate: OffsetDateTime?;
|
||||||
|
|
||||||
val isLive : Boolean;
|
val isLive : Boolean;
|
||||||
|
|
||||||
val isShort: Boolean;
|
val isShort: Boolean;
|
||||||
|
|||||||
+122
@@ -0,0 +1,122 @@
|
|||||||
|
package com.futo.platformplayer.api.media.models.video
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import android.provider.OpenableColumns
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import com.futo.platformplayer.api.media.IPlatformClient
|
||||||
|
import com.futo.platformplayer.api.media.PlatformID
|
||||||
|
import com.futo.platformplayer.api.media.Serializer
|
||||||
|
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||||
|
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.playback.IPlaybackTracker
|
||||||
|
import com.futo.platformplayer.api.media.models.ratings.IRating
|
||||||
|
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
|
||||||
|
import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor
|
||||||
|
import com.futo.platformplayer.api.media.models.streams.LocalVideoUnMuxedSourceDescriptor
|
||||||
|
import com.futo.platformplayer.api.media.models.streams.VideoMuxedSourceDescriptor
|
||||||
|
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
|
||||||
|
import com.futo.platformplayer.api.media.models.streams.sources.AudioUrlSource
|
||||||
|
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
|
||||||
|
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.LocalAudioSource
|
||||||
|
import com.futo.platformplayer.api.media.models.streams.sources.LocalVideoSource
|
||||||
|
import com.futo.platformplayer.api.media.models.streams.sources.SubtitleRawSource
|
||||||
|
import com.futo.platformplayer.api.media.models.streams.sources.VideoUrlSource
|
||||||
|
import com.futo.platformplayer.api.media.platforms.local.models.LocalVideoMuxedSourceDescriptor
|
||||||
|
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalAudioContentSource
|
||||||
|
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoContentSource
|
||||||
|
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoFileSource
|
||||||
|
import com.futo.platformplayer.api.media.structures.IPager
|
||||||
|
import com.futo.platformplayer.others.Language
|
||||||
|
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
||||||
|
import com.futo.platformplayer.states.StateApp
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
|
||||||
|
@kotlinx.serialization.Serializable
|
||||||
|
open class LocalVideoDetails(
|
||||||
|
override val id: PlatformID,
|
||||||
|
override val name: String,
|
||||||
|
override val thumbnails: Thumbnails,
|
||||||
|
override val author: PlatformAuthorLink,
|
||||||
|
override val url: String,
|
||||||
|
override val duration: Long,
|
||||||
|
|
||||||
|
val mimeType: String? = null,
|
||||||
|
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
|
||||||
|
override val datetime: OffsetDateTime?
|
||||||
|
) : IPlatformVideo, IPlatformVideoDetails {
|
||||||
|
final override val contentType: ContentType get() = ContentType.MEDIA;
|
||||||
|
|
||||||
|
override var playbackTime: Long = -1;
|
||||||
|
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
|
||||||
|
override var playbackDate: OffsetDateTime? = null;
|
||||||
|
|
||||||
|
override val isLive: Boolean get() = false;
|
||||||
|
|
||||||
|
override val dash: IDashManifestSource? get() = null;
|
||||||
|
override val hls: IHLSManifestSource? get() = null;
|
||||||
|
override val live: IVideoSource? get() = null;
|
||||||
|
|
||||||
|
|
||||||
|
override val shareUrl: String = ""
|
||||||
|
override val viewCount: Long = -1
|
||||||
|
override val rating: IRating = RatingLikes(0)
|
||||||
|
override val description: String = "";
|
||||||
|
override val video: IVideoSourceDescriptor = (if(mimeType?.startsWith("audio/") ?: false)
|
||||||
|
(LocalVideoUnMuxedSourceDescriptor(
|
||||||
|
arrayOf(),
|
||||||
|
arrayOf(LocalAudioContentSource(url, mimeType ?: "", name))
|
||||||
|
))
|
||||||
|
else (LocalVideoMuxedSourceDescriptor(
|
||||||
|
LocalVideoContentSource(url, mimeType ?: "", name)
|
||||||
|
))
|
||||||
|
);
|
||||||
|
override val preview: ISerializedVideoSourceDescriptor? = null;
|
||||||
|
|
||||||
|
override val subtitles: List<SubtitleRawSource> = listOf()
|
||||||
|
override val isShort: Boolean = false
|
||||||
|
|
||||||
|
fun toJson() : String {
|
||||||
|
return Json.encodeToString(this);
|
||||||
|
}
|
||||||
|
fun fromJson(str : String) : SerializedPlatformVideoDetails {
|
||||||
|
return Serializer.json.decodeFromString<SerializedPlatformVideoDetails>(str);
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? = null;
|
||||||
|
override fun getPlaybackTracker(): IPlaybackTracker? = null;
|
||||||
|
override fun getContentRecommendations(client: IPlatformClient): IPager<IPlatformContent>? = null;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromFile(name: String, filePath: String, mimeType: String? = null) : LocalVideoDetails {
|
||||||
|
if(filePath.startsWith("content://"))
|
||||||
|
return fromContent(filePath, mimeType);
|
||||||
|
|
||||||
|
return LocalVideoDetails(PlatformID("FILE", filePath, null, 0, -1),
|
||||||
|
name, Thumbnails(), PlatformAuthorLink.UNKNOWN, filePath, -1, mimeType, null);
|
||||||
|
}
|
||||||
|
fun fromContent(contentUrl: String, mimeType: String? = null) : LocalVideoDetails {
|
||||||
|
var nameToUse = getFileNameFromContentUrl(contentUrl) ?: "File";
|
||||||
|
|
||||||
|
return LocalVideoDetails(PlatformID("FILE", contentUrl, null, 0, -1),
|
||||||
|
nameToUse, Thumbnails(), PlatformAuthorLink.UNKNOWN, contentUrl, -1, mimeType, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("Range")
|
||||||
|
private fun getFileNameFromContentUrl(url: String): String? {
|
||||||
|
val cursor = StateApp.instance.context.contentResolver.query(url.toUri(), null, null, null, null);
|
||||||
|
cursor?.moveToFirst();
|
||||||
|
val fileName = cursor?.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME));
|
||||||
|
cursor?.close();
|
||||||
|
return fileName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-3
@@ -3,11 +3,10 @@ package com.futo.platformplayer.api.media.models.video
|
|||||||
import com.futo.platformplayer.api.media.PlatformID
|
import com.futo.platformplayer.api.media.PlatformID
|
||||||
import com.futo.platformplayer.api.media.Serializer
|
import com.futo.platformplayer.api.media.Serializer
|
||||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||||
|
import com.futo.platformplayer.api.media.models.Thumbnail
|
||||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||||
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
||||||
import com.futo.polycentric.core.combineHashCodes
|
|
||||||
import kotlinx.serialization.decodeFromString
|
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonNames
|
import kotlinx.serialization.json.JsonNames
|
||||||
@@ -18,7 +17,7 @@ open class SerializedPlatformVideo(
|
|||||||
override val contentType: ContentType = ContentType.MEDIA,
|
override val contentType: ContentType = ContentType.MEDIA,
|
||||||
override val id: PlatformID,
|
override val id: PlatformID,
|
||||||
override val name: String,
|
override val name: String,
|
||||||
override val thumbnails: Thumbnails,
|
override val thumbnails: Thumbnails = Thumbnails(),
|
||||||
override val author: PlatformAuthorLink,
|
override val author: PlatformAuthorLink,
|
||||||
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
|
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
|
||||||
@JsonNames("datetime", "dateTime")
|
@JsonNames("datetime", "dateTime")
|
||||||
@@ -33,6 +32,10 @@ open class SerializedPlatformVideo(
|
|||||||
|
|
||||||
override val isLive: Boolean = false;
|
override val isLive: Boolean = false;
|
||||||
|
|
||||||
|
override var playbackTime: Long = -1;
|
||||||
|
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
|
||||||
|
override var playbackDate: OffsetDateTime? = null;
|
||||||
|
|
||||||
override fun toJson() : String {
|
override fun toJson() : String {
|
||||||
return Json.encodeToString(this);
|
return Json.encodeToString(this);
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -13,7 +13,6 @@ import com.futo.platformplayer.api.media.models.ratings.IRating
|
|||||||
import com.futo.platformplayer.api.media.models.streams.sources.*
|
import com.futo.platformplayer.api.media.models.streams.sources.*
|
||||||
import com.futo.platformplayer.api.media.structures.IPager
|
import com.futo.platformplayer.api.media.structures.IPager
|
||||||
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
||||||
import kotlinx.serialization.decodeFromString
|
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
@@ -43,6 +42,10 @@ open class SerializedPlatformVideoDetails(
|
|||||||
) : IPlatformVideo, IPlatformVideoDetails {
|
) : IPlatformVideo, IPlatformVideoDetails {
|
||||||
final override val contentType: ContentType get() = ContentType.MEDIA;
|
final override val contentType: ContentType get() = ContentType.MEDIA;
|
||||||
|
|
||||||
|
override var playbackTime: Long = -1;
|
||||||
|
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
|
||||||
|
override var playbackDate: OffsetDateTime? = null;
|
||||||
|
|
||||||
override val isLive: Boolean get() = false;
|
override val isLive: Boolean get() = false;
|
||||||
|
|
||||||
override val dash: IDashManifestSource? get() = null;
|
override val dash: IDashManifestSource? get() = null;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ 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.playlists.IPlatformPlaylist
|
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
|
||||||
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.platforms.js.internal.JSCallDocs
|
import com.futo.platformplayer.api.media.platforms.js.internal.JSCallDocs
|
||||||
import com.futo.platformplayer.api.media.platforms.js.internal.JSDocs
|
import com.futo.platformplayer.api.media.platforms.js.internal.JSDocs
|
||||||
import com.futo.platformplayer.api.media.platforms.js.internal.JSDocsParameter
|
import com.futo.platformplayer.api.media.platforms.js.internal.JSDocsParameter
|
||||||
@@ -43,6 +44,7 @@ import com.futo.platformplayer.api.media.platforms.js.models.JSLiveEventPager
|
|||||||
import com.futo.platformplayer.api.media.platforms.js.models.JSPlaybackTracker
|
import com.futo.platformplayer.api.media.platforms.js.models.JSPlaybackTracker
|
||||||
import com.futo.platformplayer.api.media.platforms.js.models.JSPlaylistDetails
|
import com.futo.platformplayer.api.media.platforms.js.models.JSPlaylistDetails
|
||||||
import com.futo.platformplayer.api.media.platforms.js.models.JSPlaylistPager
|
import com.futo.platformplayer.api.media.platforms.js.models.JSPlaylistPager
|
||||||
|
import com.futo.platformplayer.api.media.platforms.js.models.JSVideoPager
|
||||||
import com.futo.platformplayer.api.media.structures.EmptyPager
|
import com.futo.platformplayer.api.media.structures.EmptyPager
|
||||||
import com.futo.platformplayer.api.media.structures.IPager
|
import com.futo.platformplayer.api.media.structures.IPager
|
||||||
import com.futo.platformplayer.constructs.Event1
|
import com.futo.platformplayer.constructs.Event1
|
||||||
@@ -124,6 +126,7 @@ open class JSClient : IPlatformClient {
|
|||||||
|
|
||||||
val enableInSearch get() = descriptor.appSettings.tabEnabled.enableSearch ?: true
|
val enableInSearch get() = descriptor.appSettings.tabEnabled.enableSearch ?: true
|
||||||
val enableInHome get() = descriptor.appSettings.tabEnabled.enableHome ?: true
|
val enableInHome get() = descriptor.appSettings.tabEnabled.enableHome ?: true
|
||||||
|
val enableInShorts get() = descriptor.appSettings.tabEnabled.enableShorts ?: true
|
||||||
|
|
||||||
fun getSubscriptionRateLimit(): Int? {
|
fun getSubscriptionRateLimit(): Int? {
|
||||||
val pluginRateLimit = config.subscriptionRateLimit;
|
val pluginRateLimit = config.subscriptionRateLimit;
|
||||||
@@ -269,7 +272,8 @@ open class JSClient : IPlatformClient {
|
|||||||
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
|
hasGetContentRecommendations = plugin.executeBoolean("!!source.getContentRecommendations") ?: false,
|
||||||
|
hasGetUserHistory = plugin.executeBoolean("!!source.getUserHistory") ?: false
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -328,6 +332,13 @@ open class JSClient : IPlatformClient {
|
|||||||
plugin.executeTyped("source.getHome()"));
|
plugin.executeTyped("source.getHome()"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@JSDocs(2, "source.getShorts()", "Gets the Shorts feed of the platform")
|
||||||
|
override fun getShorts(): IPager<IPlatformVideo> = isBusyWith("getShorts") {
|
||||||
|
ensureEnabled()
|
||||||
|
return@isBusyWith JSVideoPager(config, this,
|
||||||
|
plugin.executeTyped("source.getShorts()"))
|
||||||
|
}
|
||||||
|
|
||||||
@JSDocs(3, "source.searchSuggestions(query)", "Gets search suggestions for a given query")
|
@JSDocs(3, "source.searchSuggestions(query)", "Gets search suggestions for a given query")
|
||||||
@JSDocsParameter("query", "Query to complete suggestions for")
|
@JSDocsParameter("query", "Query to complete suggestions for")
|
||||||
override fun searchSuggestions(query: String): Array<String> = isBusyWith("searchSuggestions") {
|
override fun searchSuggestions(query: String): Array<String> = isBusyWith("searchSuggestions") {
|
||||||
@@ -702,6 +713,13 @@ open class JSClient : IPlatformClient {
|
|||||||
.toTypedArray();
|
.toTypedArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@JSOptional
|
||||||
|
@JSDocs(23, "source.getUserHistory()", "Gets the history of the current user")
|
||||||
|
override fun getUserHistory(): IPager<IPlatformContent> {
|
||||||
|
ensureEnabled();
|
||||||
|
return JSContentPager(config, this, plugin.executeTyped("source.getUserHistory()"));
|
||||||
|
}
|
||||||
|
|
||||||
fun validate() {
|
fun validate() {
|
||||||
try {
|
try {
|
||||||
plugin.start();
|
plugin.start();
|
||||||
|
|||||||
+46
-3
@@ -1,6 +1,10 @@
|
|||||||
package com.futo.platformplayer.api.media.platforms.js
|
package com.futo.platformplayer.api.media.platforms.js
|
||||||
|
|
||||||
@kotlinx.serialization.Serializable
|
import kotlinx.serialization.Contextual
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import java.util.Dictionary
|
||||||
|
|
||||||
|
@Serializable
|
||||||
class SourcePluginAuthConfig(
|
class SourcePluginAuthConfig(
|
||||||
val loginUrl: String,
|
val loginUrl: String,
|
||||||
val completionUrl: String? = null,
|
val completionUrl: String? = null,
|
||||||
@@ -11,5 +15,44 @@ class SourcePluginAuthConfig(
|
|||||||
val userAgent: String? = null,
|
val userAgent: String? = null,
|
||||||
val loginButton: String? = null,
|
val loginButton: String? = null,
|
||||||
val domainHeadersToFind: Map<String, List<String>>? = null,
|
val domainHeadersToFind: Map<String, List<String>>? = null,
|
||||||
val loginWarning: String? = null
|
val loginWarning: String? = null,
|
||||||
) { }
|
val loginWarnings: List<Warning>? = null,
|
||||||
|
val uiMods: List<UIMod>? = null
|
||||||
|
) {
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class Warning(
|
||||||
|
val url: String,
|
||||||
|
val text: String?,
|
||||||
|
val details: String? = null,
|
||||||
|
val once: Boolean? = true
|
||||||
|
) {
|
||||||
|
@Contextual
|
||||||
|
private var _regex: Regex? = null;
|
||||||
|
|
||||||
|
fun getRegex(): Regex {
|
||||||
|
return _regex ?: url.let {
|
||||||
|
val reg = Regex(it);
|
||||||
|
_regex = reg;
|
||||||
|
return reg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@Serializable
|
||||||
|
class UIMod(
|
||||||
|
val url: String,
|
||||||
|
val scale: Float?,
|
||||||
|
val desktop: Boolean?
|
||||||
|
) {
|
||||||
|
@Contextual
|
||||||
|
private var _regex: Regex? = null;
|
||||||
|
|
||||||
|
fun getRegex(): Regex {
|
||||||
|
return _regex ?: url.let {
|
||||||
|
val reg = Regex(it);
|
||||||
|
_regex = reg;
|
||||||
|
return reg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,7 @@ class SourcePluginConfig(
|
|||||||
var subscriptionRateLimit: Int? = null,
|
var subscriptionRateLimit: Int? = null,
|
||||||
var enableInSearch: Boolean = true,
|
var enableInSearch: Boolean = true,
|
||||||
var enableInHome: Boolean = true,
|
var enableInHome: Boolean = true,
|
||||||
|
var enableInShorts: Boolean = true,
|
||||||
var supportedClaimTypes: List<Int> = listOf(),
|
var supportedClaimTypes: List<Int> = listOf(),
|
||||||
var primaryClaimFieldType: Int? = null,
|
var primaryClaimFieldType: Int? = null,
|
||||||
var developerSubmitUrl: String? = null,
|
var developerSubmitUrl: String? = null,
|
||||||
|
|||||||
+20
-2
@@ -5,10 +5,16 @@ import com.futo.platformplayer.constructs.Event0
|
|||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
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.StateHistory
|
||||||
|
import com.futo.platformplayer.states.StatePlatform
|
||||||
import com.futo.platformplayer.views.fields.DropdownFieldOptions
|
import com.futo.platformplayer.views.fields.DropdownFieldOptions
|
||||||
import com.futo.platformplayer.views.fields.FieldForm
|
import com.futo.platformplayer.views.fields.FieldForm
|
||||||
import com.futo.platformplayer.views.fields.FormField
|
import com.futo.platformplayer.views.fields.FormField
|
||||||
|
import com.futo.platformplayer.views.fields.FormFieldButton
|
||||||
import com.futo.platformplayer.views.fields.FormFieldWarning
|
import com.futo.platformplayer.views.fields.FormFieldWarning
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -103,12 +109,22 @@ class SourcePluginDescriptor {
|
|||||||
@FormField(R.string.home, FieldForm.TOGGLE, R.string.show_content_in_home_tab, 1)
|
@FormField(R.string.home, FieldForm.TOGGLE, R.string.show_content_in_home_tab, 1)
|
||||||
var enableHome: Boolean? = null;
|
var enableHome: Boolean? = null;
|
||||||
|
|
||||||
|
|
||||||
@FormField(R.string.search, FieldForm.TOGGLE, R.string.show_content_in_search_results, 2)
|
@FormField(R.string.search, FieldForm.TOGGLE, R.string.show_content_in_search_results, 2)
|
||||||
var enableSearch: Boolean? = null;
|
var enableSearch: Boolean? = null;
|
||||||
|
|
||||||
|
@FormField(R.string.shorts, FieldForm.TOGGLE, R.string.show_content_in_shorts_tab, 3)
|
||||||
|
var enableShorts: Boolean? = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@FormField(R.string.ratelimit, "group", R.string.ratelimit_description, 3)
|
@FormField(R.string.sync, "group", R.string.sync_desc, 3,"sync")
|
||||||
|
var sync = Sync();
|
||||||
|
@Serializable
|
||||||
|
class Sync {
|
||||||
|
@FormField(R.string.sync_history, FieldForm.TOGGLE, R.string.sync_history_desc, 1,"syncHistory")
|
||||||
|
var enableHistorySync: Boolean? = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@FormField(R.string.ratelimit, "group", R.string.ratelimit_description, 4)
|
||||||
var rateLimit = RateLimit();
|
var rateLimit = RateLimit();
|
||||||
@Serializable
|
@Serializable
|
||||||
class RateLimit {
|
class RateLimit {
|
||||||
@@ -143,6 +159,8 @@ class SourcePluginDescriptor {
|
|||||||
tabEnabled.enableHome = config.enableInHome
|
tabEnabled.enableHome = config.enableInHome
|
||||||
if(tabEnabled.enableSearch == null)
|
if(tabEnabled.enableSearch == null)
|
||||||
tabEnabled.enableSearch = config.enableInSearch
|
tabEnabled.enableSearch = config.enableInSearch
|
||||||
|
if(tabEnabled.enableShorts == null)
|
||||||
|
tabEnabled.enableShorts = config.enableInShorts
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ abstract class JSPager<T> : IPager<T> {
|
|||||||
protected var pager: V8ValueObject;
|
protected var pager: V8ValueObject;
|
||||||
|
|
||||||
private var _lastResults: List<T>? = null;
|
private var _lastResults: List<T>? = null;
|
||||||
private var _resultChanged: Boolean = true;
|
protected var _resultChanged: Boolean = true;
|
||||||
private var _hasMorePages: Boolean = false;
|
protected var _hasMorePages: Boolean = false;
|
||||||
//private var _morePagesWasFalse: Boolean = false;
|
//private var _morePagesWasFalse: Boolean = false;
|
||||||
|
|
||||||
val isAvailable get() = plugin.getUnderlyingPlugin()._runtime?.let { !it.isClosed && !it.isDead } ?: false;
|
val isAvailable get() = plugin.getUnderlyingPlugin()._runtime?.let { !it.isClosed && !it.isDead } ?: false;
|
||||||
|
|||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
package com.futo.platformplayer.api.media.platforms.js.models
|
||||||
|
|
||||||
|
import com.caoccao.javet.values.V8Value
|
||||||
|
import com.caoccao.javet.values.reference.V8ValueObject
|
||||||
|
import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
|
||||||
|
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.IPlatformLiveEventPager
|
||||||
|
import com.futo.platformplayer.getOrDefault
|
||||||
|
import com.futo.platformplayer.getOrThrow
|
||||||
|
import com.futo.platformplayer.invokeV8
|
||||||
|
import com.futo.platformplayer.warnIfMainThread
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
class JSVODEventPager : JSPager<IPlatformLiveEvent>, IPlatformLiveEventPager {
|
||||||
|
override var nextRequest: Int;
|
||||||
|
|
||||||
|
constructor(config: SourcePluginConfig, plugin: JSClient, pager: V8ValueObject) : super(config, plugin, pager) {
|
||||||
|
nextRequest = pager.getOrThrow(config, "nextRequest", "LiveEventPager");
|
||||||
|
}
|
||||||
|
|
||||||
|
fun nextPage(ms: Int) = plugin.isBusyWith("JSLiveEventPager.nextPage") {
|
||||||
|
warnIfMainThread("VODEventPager.nextPage");
|
||||||
|
|
||||||
|
val pluginV8 = plugin.getUnderlyingPlugin();
|
||||||
|
pluginV8.busy {
|
||||||
|
val newPager: V8Value = pluginV8.catchScriptErrors("[${plugin.config.name}] JSPager", "pager.nextPage(...)") {
|
||||||
|
pager.invokeV8<V8Value>("nextPage", ms);
|
||||||
|
};
|
||||||
|
if(newPager is V8ValueObject)
|
||||||
|
pager = newPager;
|
||||||
|
_hasMorePages = pager.getOrDefault(config, "hasMore", "Pager", false) ?: false;
|
||||||
|
_resultChanged = true;
|
||||||
|
}
|
||||||
|
nextRequest = pager.getOrThrow(config, "nextRequest", "LiveEventPager");
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun nextPage() = nextPage(0);
|
||||||
|
|
||||||
|
override fun convertResult(obj: V8ValueObject): IPlatformLiveEvent {
|
||||||
|
return IPlatformLiveEvent.fromV8(config, obj, "LiveEventPager");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,10 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
|||||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||||
import com.futo.platformplayer.getOrDefault
|
import com.futo.platformplayer.getOrDefault
|
||||||
import com.futo.platformplayer.getOrThrow
|
import com.futo.platformplayer.getOrThrow
|
||||||
|
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
|
||||||
open class JSVideo : JSContent, IPlatformVideo, IPluginSourced {
|
open class JSVideo : JSContent, IPlatformVideo, IPluginSourced {
|
||||||
final override val contentType: ContentType get() = ContentType.MEDIA;
|
final override val contentType: ContentType get() = ContentType.MEDIA;
|
||||||
@@ -17,6 +21,10 @@ open class JSVideo : JSContent, IPlatformVideo, IPluginSourced {
|
|||||||
final override val duration: Long;
|
final override val duration: Long;
|
||||||
final override val viewCount: Long;
|
final override val viewCount: Long;
|
||||||
|
|
||||||
|
override var playbackTime: Long = -1;
|
||||||
|
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
|
||||||
|
override var playbackDate: OffsetDateTime? = null;
|
||||||
|
|
||||||
final override val isLive: Boolean;
|
final override val isLive: Boolean;
|
||||||
final override val isShort: Boolean;
|
final override val isShort: Boolean;
|
||||||
|
|
||||||
@@ -29,5 +37,11 @@ open class JSVideo : JSContent, IPlatformVideo, IPluginSourced {
|
|||||||
viewCount = _content.getOrThrow(config, "viewCount", contextName);
|
viewCount = _content.getOrThrow(config, "viewCount", contextName);
|
||||||
isLive = _content.getOrThrow(config, "isLive", contextName);
|
isLive = _content.getOrThrow(config, "isLive", contextName);
|
||||||
isShort = _content.getOrDefault(config, "isShort", contextName, false) ?: false;
|
isShort = _content.getOrDefault(config, "isShort", contextName, false) ?: false;
|
||||||
|
playbackTime = _content.getOrDefault<Long>(config, "playbackTime", contextName, -1)?.toLong() ?: -1;
|
||||||
|
val playbackDateInt = _content.getOrDefault<Int>(config, "playbackDate", contextName, null)?.toLong();
|
||||||
|
if(playbackDateInt == null || playbackDateInt == 0.toLong())
|
||||||
|
playbackDate = null;
|
||||||
|
else
|
||||||
|
playbackDate = OffsetDateTime.of(LocalDateTime.ofEpochSecond(playbackDateInt, 0, ZoneOffset.UTC), ZoneOffset.UTC);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+16
-1
@@ -7,6 +7,7 @@ import com.caoccao.javet.values.reference.V8ValueObject
|
|||||||
import com.futo.platformplayer.api.media.IPlatformClient
|
import com.futo.platformplayer.api.media.IPlatformClient
|
||||||
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
||||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||||
|
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.IRating
|
import com.futo.platformplayer.api.media.models.ratings.IRating
|
||||||
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
|
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
|
||||||
@@ -26,12 +27,15 @@ import com.futo.platformplayer.getOrThrow
|
|||||||
import com.futo.platformplayer.getOrThrowNullable
|
import com.futo.platformplayer.getOrThrowNullable
|
||||||
import com.futo.platformplayer.invokeV8
|
import com.futo.platformplayer.invokeV8
|
||||||
import com.futo.platformplayer.states.StateDeveloper
|
import com.futo.platformplayer.states.StateDeveloper
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
class JSVideoDetails : JSVideo, IPlatformVideoDetails {
|
class JSVideoDetails : JSVideo, IPlatformVideoDetails {
|
||||||
private val _plugin: JSClient;
|
private val _plugin: JSClient;
|
||||||
private val _hasGetComments: Boolean;
|
private val _hasGetComments: Boolean;
|
||||||
private val _hasGetContentRecommendations: Boolean;
|
private val _hasGetContentRecommendations: Boolean;
|
||||||
private val _hasGetPlaybackTracker: Boolean;
|
private val _hasGetPlaybackTracker: Boolean;
|
||||||
|
private val _hasGetVODEvents: Boolean;
|
||||||
|
|
||||||
//Details
|
//Details
|
||||||
override val description : String;
|
override val description : String;
|
||||||
@@ -47,7 +51,6 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
|
|||||||
|
|
||||||
override val subtitles: List<ISubtitleSource>;
|
override val subtitles: List<ISubtitleSource>;
|
||||||
|
|
||||||
|
|
||||||
constructor(plugin: JSClient, obj: V8ValueObject) : super(plugin.config, obj) {
|
constructor(plugin: JSClient, obj: V8ValueObject) : super(plugin.config, obj) {
|
||||||
val contextName = "VideoDetails";
|
val contextName = "VideoDetails";
|
||||||
_plugin = plugin;
|
_plugin = plugin;
|
||||||
@@ -72,6 +75,7 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
|
|||||||
_hasGetComments = _content.has("getComments");
|
_hasGetComments = _content.has("getComments");
|
||||||
_hasGetPlaybackTracker = _content.has("getPlaybackTracker");
|
_hasGetPlaybackTracker = _content.has("getPlaybackTracker");
|
||||||
_hasGetContentRecommendations = _content.has("getContentRecommendations");
|
_hasGetContentRecommendations = _content.has("getContentRecommendations");
|
||||||
|
_hasGetVODEvents = _content.has("getVODEvents");
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getPlaybackTracker(): IPlaybackTracker? {
|
override fun getPlaybackTracker(): IPlaybackTracker? {
|
||||||
@@ -138,4 +142,15 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
|
|||||||
return@busy JSCommentPager(_pluginConfig, client, commentPager);
|
return@busy JSCommentPager(_pluginConfig, client, commentPager);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun hasVODEvents(): Boolean{
|
||||||
|
return _hasGetVODEvents;
|
||||||
|
}
|
||||||
|
fun getVODEvents(url: String): IPager<IPlatformLiveEvent>? = _plugin.busy {
|
||||||
|
if(!_hasGetVODEvents)
|
||||||
|
return@busy null;
|
||||||
|
|
||||||
|
return@busy JSVODEventPager(_plugin.config, _plugin,
|
||||||
|
_content.invokeV8<V8ValueObject>("getVODEvents", arrayOf<Any>()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+13
@@ -17,6 +17,7 @@ import com.futo.platformplayer.getOrNull
|
|||||||
import com.futo.platformplayer.getOrThrow
|
import com.futo.platformplayer.getOrThrow
|
||||||
import com.futo.platformplayer.invokeV8
|
import com.futo.platformplayer.invokeV8
|
||||||
import com.futo.platformplayer.invokeV8Async
|
import com.futo.platformplayer.invokeV8Async
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.others.Language
|
import com.futo.platformplayer.others.Language
|
||||||
import com.futo.platformplayer.states.StateDeveloper
|
import com.futo.platformplayer.states.StateDeveloper
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
@@ -57,12 +58,24 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
|
|||||||
hasGenerate = _obj.has("generate");
|
hasGenerate = _obj.has("generate");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var _pregenerate: V8Deferred<String?>? = null;
|
||||||
|
fun pregenerateAsync(scope: CoroutineScope): V8Deferred<String?>? {
|
||||||
|
_pregenerate = generateAsync(scope);
|
||||||
|
return _pregenerate;
|
||||||
|
}
|
||||||
|
|
||||||
override fun generateAsync(scope: CoroutineScope): V8Deferred<String?> {
|
override fun generateAsync(scope: CoroutineScope): V8Deferred<String?> {
|
||||||
if(!hasGenerate)
|
if(!hasGenerate)
|
||||||
return V8Deferred(CompletableDeferred(manifest));
|
return V8Deferred(CompletableDeferred(manifest));
|
||||||
if(_obj.isClosed)
|
if(_obj.isClosed)
|
||||||
throw IllegalStateException("Source object already closed");
|
throw IllegalStateException("Source object already closed");
|
||||||
|
|
||||||
|
val pregenerated = _pregenerate;
|
||||||
|
if(pregenerated != null) {
|
||||||
|
Logger.w("JSDashManifestRawAudioSource", "Returning pre-generated audio");
|
||||||
|
return pregenerated;
|
||||||
|
}
|
||||||
|
|
||||||
val plugin = _plugin.getUnderlyingPlugin();
|
val plugin = _plugin.getUnderlyingPlugin();
|
||||||
|
|
||||||
var result: V8Deferred<V8ValueString>? = null;
|
var result: V8Deferred<V8ValueString>? = null;
|
||||||
|
|||||||
+12
@@ -18,6 +18,7 @@ import com.futo.platformplayer.getOrNull
|
|||||||
import com.futo.platformplayer.getOrThrow
|
import com.futo.platformplayer.getOrThrow
|
||||||
import com.futo.platformplayer.invokeV8
|
import com.futo.platformplayer.invokeV8
|
||||||
import com.futo.platformplayer.invokeV8Async
|
import com.futo.platformplayer.invokeV8Async
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.states.StateDeveloper
|
import com.futo.platformplayer.states.StateDeveloper
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -65,11 +66,22 @@ open class JSDashManifestRawSource: JSSource, IVideoSource, IJSDashManifestRawSo
|
|||||||
hasGenerate = _obj.has("generate");
|
hasGenerate = _obj.has("generate");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var _pregenerate: V8Deferred<String?>? = null;
|
||||||
|
fun pregenerateAsync(scope: CoroutineScope): V8Deferred<String?>? {
|
||||||
|
_pregenerate = generateAsync(scope);
|
||||||
|
return _pregenerate;
|
||||||
|
}
|
||||||
|
|
||||||
override fun generateAsync(scope: CoroutineScope): V8Deferred<String?> {
|
override fun generateAsync(scope: CoroutineScope): V8Deferred<String?> {
|
||||||
if(!hasGenerate)
|
if(!hasGenerate)
|
||||||
return V8Deferred(CompletableDeferred(manifest));
|
return V8Deferred(CompletableDeferred(manifest));
|
||||||
if(_obj.isClosed)
|
if(_obj.isClosed)
|
||||||
throw IllegalStateException("Source object already closed");
|
throw IllegalStateException("Source object already closed");
|
||||||
|
val pregenerated = _pregenerate;
|
||||||
|
if(pregenerated != null) {
|
||||||
|
Logger.w("JSDashManifestRawSource", "Returning pre-generated video");
|
||||||
|
return pregenerated;
|
||||||
|
}
|
||||||
|
|
||||||
val plugin = _plugin.getUnderlyingPlugin();
|
val plugin = _plugin.getUnderlyingPlugin();
|
||||||
|
|
||||||
|
|||||||
+5
-2
@@ -11,7 +11,6 @@ import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
|
|||||||
import com.futo.platformplayer.api.media.models.ratings.IRating
|
import com.futo.platformplayer.api.media.models.ratings.IRating
|
||||||
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.IVideoSourceDescriptor
|
import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor
|
||||||
import com.futo.platformplayer.api.media.models.streams.DownloadedVideoMuxedSourceDescriptor
|
|
||||||
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.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
|
||||||
@@ -19,7 +18,7 @@ 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.local.models.sources.LocalVideoFileSource
|
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoFileSource
|
||||||
import com.futo.platformplayer.api.media.structures.IPager
|
import com.futo.platformplayer.api.media.structures.IPager
|
||||||
import com.futo.platformplayer.downloads.VideoLocal
|
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
@@ -53,6 +52,10 @@ class LocalVideoDetails: IPlatformVideoDetails {
|
|||||||
override val isLive: Boolean = false;
|
override val isLive: Boolean = false;
|
||||||
override val isShort: Boolean = false;
|
override val isShort: Boolean = false;
|
||||||
|
|
||||||
|
override var playbackTime: Long = -1;
|
||||||
|
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
|
||||||
|
override var playbackDate: OffsetDateTime? = null;
|
||||||
|
|
||||||
constructor(file: File) {
|
constructor(file: File) {
|
||||||
id = PlatformID("Local", file.path, "LOCAL")
|
id = PlatformID("Local", file.path, "LOCAL")
|
||||||
name = file.name;
|
name = file.name;
|
||||||
|
|||||||
+14
-4
@@ -1,13 +1,23 @@
|
|||||||
package com.futo.platformplayer.api.media.platforms.local.models
|
package com.futo.platformplayer.api.media.platforms.local.models
|
||||||
|
|
||||||
import com.futo.platformplayer.api.media.models.streams.VideoMuxedSourceDescriptor
|
import com.futo.platformplayer.api.media.models.streams.VideoMuxedSourceDescriptor
|
||||||
|
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.models.streams.sources.LocalVideoSource
|
import com.futo.platformplayer.api.media.models.streams.sources.LocalVideoSource
|
||||||
|
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoContentSource
|
||||||
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoFileSource
|
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoFileSource
|
||||||
import com.futo.platformplayer.downloads.VideoLocal
|
import com.futo.platformplayer.downloads.VideoLocal
|
||||||
|
|
||||||
class LocalVideoMuxedSourceDescriptor(
|
class LocalVideoMuxedSourceDescriptor: VideoMuxedSourceDescriptor {
|
||||||
private val video: LocalVideoFileSource
|
override val videoSources: Array<IVideoSource>;
|
||||||
) : VideoMuxedSourceDescriptor() {
|
|
||||||
override val videoSources: Array<IVideoSource> get() = arrayOf(video);
|
constructor(video: LocalVideoFileSource) {
|
||||||
|
videoSources = arrayOf(video);
|
||||||
|
}
|
||||||
|
constructor(video: LocalVideoContentSource) {
|
||||||
|
videoSources = arrayOf(video);
|
||||||
|
}
|
||||||
|
constructor(videoSources: Array<IVideoSource>) {
|
||||||
|
this.videoSources = videoSources;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
package com.futo.platformplayer.api.media.platforms.local.models.sources
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import android.provider.MediaStore.Video
|
||||||
|
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.VideoUrlSource
|
||||||
|
import com.futo.platformplayer.helpers.VideoHelper
|
||||||
|
import com.futo.platformplayer.others.Language
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class LocalAudioContentSource : IAudioSource {
|
||||||
|
|
||||||
|
override val name: String;
|
||||||
|
override val container: String;
|
||||||
|
override val codec: String = ""
|
||||||
|
override val bitrate: Int = 0
|
||||||
|
override val duration: Long;
|
||||||
|
override val priority: Boolean = false;
|
||||||
|
override val language: String = Language.UNKNOWN
|
||||||
|
override val original: Boolean = false;
|
||||||
|
|
||||||
|
var contentUrl: String;
|
||||||
|
|
||||||
|
constructor(contentUrl: String, mime: String, name: String? = null) {
|
||||||
|
this.name = name ?: "File";
|
||||||
|
container = mime;
|
||||||
|
duration = 0;
|
||||||
|
|
||||||
|
this.contentUrl = contentUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
package com.futo.platformplayer.api.media.platforms.local.models.sources
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import android.provider.MediaStore.Video
|
||||||
|
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.VideoUrlSource
|
||||||
|
import com.futo.platformplayer.helpers.VideoHelper
|
||||||
|
import com.futo.platformplayer.others.Language
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class LocalAudioFileSource: IAudioSource {
|
||||||
|
|
||||||
|
|
||||||
|
override val name: String;
|
||||||
|
override val container: String;
|
||||||
|
override val codec: String = ""
|
||||||
|
override val bitrate: Int = 0
|
||||||
|
override val duration: Long;
|
||||||
|
override val priority: Boolean = false;
|
||||||
|
override val language: String = Language.UNKNOWN;
|
||||||
|
override val original: Boolean = false;
|
||||||
|
|
||||||
|
var file: File;
|
||||||
|
|
||||||
|
constructor(file: File) {
|
||||||
|
this.file = file;
|
||||||
|
name = file.name;
|
||||||
|
container = VideoHelper.videoExtensionToMimetype(file.extension) ?: "";
|
||||||
|
duration = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
package com.futo.platformplayer.api.media.platforms.local.models.sources
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import android.provider.MediaStore.Video
|
||||||
|
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||||
|
import com.futo.platformplayer.api.media.models.streams.sources.VideoUrlSource
|
||||||
|
import com.futo.platformplayer.helpers.VideoHelper
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class LocalVideoContentSource: IVideoSource {
|
||||||
|
|
||||||
|
|
||||||
|
override val name: String;
|
||||||
|
override val width: Int;
|
||||||
|
override val height: Int;
|
||||||
|
override val container: String;
|
||||||
|
override val codec: String = ""
|
||||||
|
override val bitrate: Int = 0
|
||||||
|
override val duration: Long;
|
||||||
|
override val priority: Boolean = false;
|
||||||
|
|
||||||
|
var contentUrl: String;
|
||||||
|
|
||||||
|
constructor(contentUrl: String, mime: String, name: String? = null) {
|
||||||
|
this.name = name ?: "File";
|
||||||
|
width = 0;
|
||||||
|
height = 0;
|
||||||
|
container = mime;
|
||||||
|
duration = 0;
|
||||||
|
this.contentUrl = contentUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
@@ -20,7 +20,10 @@ class LocalVideoFileSource: IVideoSource {
|
|||||||
override val duration: Long;
|
override val duration: Long;
|
||||||
override val priority: Boolean = false;
|
override val priority: Boolean = false;
|
||||||
|
|
||||||
|
var file: File;
|
||||||
|
|
||||||
constructor(file: File) {
|
constructor(file: File) {
|
||||||
|
this.file = file;
|
||||||
name = file.name;
|
name = file.name;
|
||||||
width = 0;
|
width = 0;
|
||||||
height = 0;
|
height = 0;
|
||||||
|
|||||||
+5
-7
@@ -7,12 +7,12 @@ import java.util.stream.IntStream
|
|||||||
* A Content MultiPager that returns results based on a specified distribution
|
* A Content MultiPager that returns results based on a specified distribution
|
||||||
* TODO: Merge all basic distribution pagers
|
* TODO: Merge all basic distribution pagers
|
||||||
*/
|
*/
|
||||||
class MultiDistributionContentPager : MultiPager<IPlatformContent> {
|
class MultiDistributionContentPager<T : IPlatformContent> : MultiPager<T> {
|
||||||
|
|
||||||
private val dist : HashMap<IPager<IPlatformContent>, Float>;
|
private val dist : HashMap<IPager<T>, Float>;
|
||||||
private val distConsumed : HashMap<IPager<IPlatformContent>, Float>;
|
private val distConsumed : HashMap<IPager<T>, Float>;
|
||||||
|
|
||||||
constructor(pagers : Map<IPager<IPlatformContent>, Float>) : super(pagers.keys.toMutableList()) {
|
constructor(pagers : Map<IPager<T>, Float>, pageSize: Int = 9) : super(pagers.keys.toMutableList(), false, pageSize) {
|
||||||
val distTotal = pagers.values.sum();
|
val distTotal = pagers.values.sum();
|
||||||
dist = HashMap();
|
dist = HashMap();
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ class MultiDistributionContentPager : MultiPager<IPlatformContent> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
override fun selectItemIndex(options: Array<SelectionOption<IPlatformContent>>): Int {
|
override fun selectItemIndex(options: Array<SelectionOption<T>>): Int {
|
||||||
if(options.size == 0)
|
if(options.size == 0)
|
||||||
return -1;
|
return -1;
|
||||||
var bestIndex = 0;
|
var bestIndex = 0;
|
||||||
@@ -42,6 +42,4 @@ class MultiDistributionContentPager : MultiPager<IPlatformContent> {
|
|||||||
distConsumed[options[bestIndex].pager.getPager()] = bestConsumed;
|
distConsumed[options[bestIndex].pager.getPager()] = bestConsumed;
|
||||||
return bestIndex;
|
return bestIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -15,7 +15,7 @@ import kotlinx.coroutines.launch
|
|||||||
import java.net.InetAddress
|
import java.net.InetAddress
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
class AirPlayCastingDevice : CastingDevice {
|
class AirPlayCastingDevice : CastingDeviceLegacy {
|
||||||
//See for more info: https://nto.github.io/AirPlay
|
//See for more info: https://nto.github.io/AirPlay
|
||||||
|
|
||||||
override val protocol: CastProtocolType get() = CastProtocolType.AIRPLAY;
|
override val protocol: CastProtocolType get() = CastProtocolType.AIRPLAY;
|
||||||
|
|||||||
@@ -2,147 +2,78 @@ package com.futo.platformplayer.casting
|
|||||||
|
|
||||||
import com.futo.platformplayer.constructs.Event1
|
import com.futo.platformplayer.constructs.Event1
|
||||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||||
import kotlinx.serialization.KSerializer
|
import org.fcast.sender_sdk.Metadata
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
|
||||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
|
||||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
|
||||||
import kotlinx.serialization.encoding.Decoder
|
|
||||||
import kotlinx.serialization.encoding.Encoder
|
|
||||||
import java.net.InetAddress
|
import java.net.InetAddress
|
||||||
|
|
||||||
enum class CastConnectionState {
|
|
||||||
DISCONNECTED,
|
|
||||||
CONNECTING,
|
|
||||||
CONNECTED
|
|
||||||
}
|
|
||||||
|
|
||||||
@Serializable(with = CastProtocolType.CastProtocolTypeSerializer::class)
|
|
||||||
enum class CastProtocolType {
|
|
||||||
CHROMECAST,
|
|
||||||
AIRPLAY,
|
|
||||||
FCAST;
|
|
||||||
|
|
||||||
object CastProtocolTypeSerializer : KSerializer<CastProtocolType> {
|
|
||||||
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("CastProtocolType", PrimitiveKind.STRING)
|
|
||||||
|
|
||||||
override fun serialize(encoder: Encoder, value: CastProtocolType) {
|
|
||||||
encoder.encodeString(value.name)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun deserialize(decoder: Decoder): CastProtocolType {
|
|
||||||
val name = decoder.decodeString()
|
|
||||||
return when (name) {
|
|
||||||
"FASTCAST" -> FCAST // Handle the renamed case
|
|
||||||
else -> CastProtocolType.valueOf(name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
abstract class CastingDevice {
|
abstract class CastingDevice {
|
||||||
abstract val protocol: CastProtocolType;
|
abstract val isReady: Boolean
|
||||||
abstract val isReady: Boolean;
|
abstract val usedRemoteAddress: InetAddress?
|
||||||
abstract var usedRemoteAddress: InetAddress?;
|
abstract val localAddress: InetAddress?
|
||||||
abstract var localAddress: InetAddress?;
|
abstract val name: String?
|
||||||
abstract val canSetVolume: Boolean;
|
abstract val onConnectionStateChanged: Event1<CastConnectionState>
|
||||||
abstract val canSetSpeed: Boolean;
|
abstract val onPlayChanged: Event1<Boolean>
|
||||||
|
abstract val onTimeChanged: Event1<Double>
|
||||||
|
abstract val onDurationChanged: Event1<Double>
|
||||||
|
abstract val onVolumeChanged: Event1<Double>
|
||||||
|
abstract val onSpeedChanged: Event1<Double>
|
||||||
|
abstract var connectionState: CastConnectionState
|
||||||
|
abstract val protocolType: CastProtocolType
|
||||||
|
abstract var isPlaying: Boolean
|
||||||
|
abstract val expectedCurrentTime: Double
|
||||||
|
abstract var speed: Double
|
||||||
|
abstract var time: Double
|
||||||
|
abstract var duration: Double
|
||||||
|
abstract var volume: Double
|
||||||
|
abstract fun canSetVolume(): Boolean
|
||||||
|
abstract fun canSetSpeed(): Boolean
|
||||||
|
|
||||||
var name: String? = null;
|
@Throws
|
||||||
var isPlaying: Boolean = false
|
abstract fun resumePlayback()
|
||||||
set(value) {
|
|
||||||
val changed = value != field;
|
|
||||||
field = value;
|
|
||||||
if (changed) {
|
|
||||||
onPlayChanged.emit(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private var lastTimeChangeTime_ms: Long = 0
|
@Throws
|
||||||
var time: Double = 0.0
|
abstract fun pausePlayback()
|
||||||
private set
|
|
||||||
|
|
||||||
protected fun setTime(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
|
@Throws
|
||||||
if (changeTime_ms > lastTimeChangeTime_ms && value != time) {
|
abstract fun stopPlayback()
|
||||||
time = value
|
|
||||||
lastTimeChangeTime_ms = changeTime_ms
|
|
||||||
onTimeChanged.emit(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var lastDurationChangeTime_ms: Long = 0
|
@Throws
|
||||||
var duration: Double = 0.0
|
abstract fun seekTo(timeSeconds: Double)
|
||||||
private set
|
|
||||||
|
|
||||||
protected fun setDuration(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
|
@Throws
|
||||||
if (changeTime_ms > lastDurationChangeTime_ms && value != duration) {
|
abstract fun changeVolume(timeSeconds: Double)
|
||||||
duration = value
|
|
||||||
lastDurationChangeTime_ms = changeTime_ms
|
|
||||||
onDurationChanged.emit(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var lastVolumeChangeTime_ms: Long = 0
|
@Throws
|
||||||
var volume: Double = 1.0
|
abstract fun changeSpeed(speed: Double)
|
||||||
private set
|
|
||||||
|
|
||||||
protected fun setVolume(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
|
@Throws
|
||||||
if (changeTime_ms > lastVolumeChangeTime_ms && value != volume) {
|
abstract fun connect()
|
||||||
volume = value
|
|
||||||
lastVolumeChangeTime_ms = changeTime_ms
|
|
||||||
onVolumeChanged.emit(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var lastSpeedChangeTime_ms: Long = 0
|
@Throws
|
||||||
var speed: Double = 1.0
|
abstract fun disconnect()
|
||||||
private set
|
abstract fun getDeviceInfo(): CastingDeviceInfo
|
||||||
|
abstract fun getAddresses(): List<InetAddress>
|
||||||
|
|
||||||
protected fun setSpeed(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
|
@Throws
|
||||||
if (changeTime_ms > lastSpeedChangeTime_ms && value != speed) {
|
abstract fun loadVideo(
|
||||||
speed = value
|
streamType: String,
|
||||||
lastSpeedChangeTime_ms = changeTime_ms
|
contentType: String,
|
||||||
onSpeedChanged.emit(value)
|
contentId: String,
|
||||||
}
|
resumePosition: Double,
|
||||||
}
|
duration: Double,
|
||||||
|
speed: Double?,
|
||||||
|
metadata: Metadata?
|
||||||
|
)
|
||||||
|
|
||||||
val expectedCurrentTime: Double
|
@Throws
|
||||||
get() {
|
abstract fun loadContent(
|
||||||
val diff = if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0;
|
contentType: String,
|
||||||
return time + diff;
|
content: String,
|
||||||
};
|
resumePosition: Double,
|
||||||
var connectionState: CastConnectionState = CastConnectionState.DISCONNECTED
|
duration: Double,
|
||||||
set(value) {
|
speed: Double?,
|
||||||
val changed = value != field;
|
metadata: Metadata?
|
||||||
field = value;
|
)
|
||||||
|
|
||||||
if (changed) {
|
abstract fun ensureThreadStarted()
|
||||||
onConnectionStateChanged.emit(value);
|
}
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
var onConnectionStateChanged = Event1<CastConnectionState>();
|
|
||||||
var onPlayChanged = Event1<Boolean>();
|
|
||||||
var onTimeChanged = Event1<Double>();
|
|
||||||
var onDurationChanged = Event1<Double>();
|
|
||||||
var onVolumeChanged = Event1<Double>();
|
|
||||||
var onSpeedChanged = Event1<Double>();
|
|
||||||
|
|
||||||
abstract fun stopCasting();
|
|
||||||
|
|
||||||
abstract fun seekVideo(timeSeconds: Double);
|
|
||||||
abstract fun stopVideo();
|
|
||||||
abstract fun pauseVideo();
|
|
||||||
abstract fun resumeVideo();
|
|
||||||
abstract fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double, speed: Double?);
|
|
||||||
abstract fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?);
|
|
||||||
open fun changeVolume(volume: Double) { throw NotImplementedError() }
|
|
||||||
open fun changeSpeed(speed: Double) { throw NotImplementedError() }
|
|
||||||
|
|
||||||
abstract fun start();
|
|
||||||
abstract fun stop();
|
|
||||||
|
|
||||||
abstract fun getDeviceInfo(): CastingDeviceInfo;
|
|
||||||
|
|
||||||
abstract fun getAddresses(): List<InetAddress>;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
package com.futo.platformplayer.casting
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import com.futo.platformplayer.BuildConfig
|
||||||
|
import com.futo.platformplayer.constructs.Event1
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||||
|
import org.fcast.sender_sdk.ApplicationInfo
|
||||||
|
import org.fcast.sender_sdk.GenericKeyEvent
|
||||||
|
import org.fcast.sender_sdk.GenericMediaEvent
|
||||||
|
import org.fcast.sender_sdk.PlaybackState
|
||||||
|
import org.fcast.sender_sdk.Source
|
||||||
|
import java.net.InetAddress
|
||||||
|
import org.fcast.sender_sdk.CastingDevice as RsCastingDevice;
|
||||||
|
import org.fcast.sender_sdk.DeviceEventHandler as RsDeviceEventHandler;
|
||||||
|
import org.fcast.sender_sdk.DeviceConnectionState
|
||||||
|
import org.fcast.sender_sdk.DeviceFeature
|
||||||
|
import org.fcast.sender_sdk.IpAddr
|
||||||
|
import org.fcast.sender_sdk.LoadRequest
|
||||||
|
import org.fcast.sender_sdk.Metadata
|
||||||
|
import org.fcast.sender_sdk.ProtocolType
|
||||||
|
import org.fcast.sender_sdk.urlFormatIpAddr
|
||||||
|
import java.net.Inet4Address
|
||||||
|
import java.net.Inet6Address
|
||||||
|
|
||||||
|
private fun ipAddrToInetAddress(addr: IpAddr): InetAddress = when (addr) {
|
||||||
|
is IpAddr.V4 -> Inet4Address.getByAddress(
|
||||||
|
byteArrayOf(
|
||||||
|
addr.o1.toByte(),
|
||||||
|
addr.o2.toByte(),
|
||||||
|
addr.o3.toByte(),
|
||||||
|
addr.o4.toByte()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
is IpAddr.V6 -> Inet6Address.getByAddress(
|
||||||
|
byteArrayOf(
|
||||||
|
addr.o1.toByte(),
|
||||||
|
addr.o2.toByte(),
|
||||||
|
addr.o3.toByte(),
|
||||||
|
addr.o4.toByte(),
|
||||||
|
addr.o5.toByte(),
|
||||||
|
addr.o6.toByte(),
|
||||||
|
addr.o7.toByte(),
|
||||||
|
addr.o8.toByte(),
|
||||||
|
addr.o9.toByte(),
|
||||||
|
addr.o10.toByte(),
|
||||||
|
addr.o11.toByte(),
|
||||||
|
addr.o12.toByte(),
|
||||||
|
addr.o13.toByte(),
|
||||||
|
addr.o14.toByte(),
|
||||||
|
addr.o15.toByte(),
|
||||||
|
addr.o16.toByte()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
|
||||||
|
class EventHandler : RsDeviceEventHandler {
|
||||||
|
var onConnectionStateChanged = Event1<DeviceConnectionState>();
|
||||||
|
var onPlayChanged = Event1<Boolean>()
|
||||||
|
var onTimeChanged = Event1<Double>()
|
||||||
|
var onDurationChanged = Event1<Double>()
|
||||||
|
var onVolumeChanged = Event1<Double>()
|
||||||
|
var onSpeedChanged = Event1<Double>()
|
||||||
|
|
||||||
|
override fun connectionStateChanged(state: DeviceConnectionState) {
|
||||||
|
onConnectionStateChanged.emit(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun volumeChanged(volume: Double) {
|
||||||
|
onVolumeChanged.emit(volume)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun timeChanged(time: Double) {
|
||||||
|
onTimeChanged.emit(time)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun playbackStateChanged(state: PlaybackState) {
|
||||||
|
onPlayChanged.emit(state == PlaybackState.PLAYING)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun durationChanged(duration: Double) {
|
||||||
|
onDurationChanged.emit(duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun speedChanged(speed: Double) {
|
||||||
|
onSpeedChanged.emit(speed)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun sourceChanged(source: Source) {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun keyEvent(event: GenericKeyEvent) {
|
||||||
|
// Unreachable
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun mediaEvent(event: GenericMediaEvent) {
|
||||||
|
// Unreachable
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun playbackError(message: String) {
|
||||||
|
Logger.e(TAG, "Playback error: $message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val eventHandler = EventHandler()
|
||||||
|
override val isReady: Boolean
|
||||||
|
get() = device.isReady()
|
||||||
|
override val name: String
|
||||||
|
get() = device.name()
|
||||||
|
override var usedRemoteAddress: InetAddress? = null
|
||||||
|
override var localAddress: InetAddress? = null
|
||||||
|
override fun canSetVolume(): Boolean = device.supportsFeature(DeviceFeature.SET_VOLUME)
|
||||||
|
override fun canSetSpeed(): Boolean = device.supportsFeature(DeviceFeature.SET_SPEED)
|
||||||
|
|
||||||
|
override val onConnectionStateChanged =
|
||||||
|
Event1<CastConnectionState>()
|
||||||
|
override val onPlayChanged: Event1<Boolean>
|
||||||
|
get() = eventHandler.onPlayChanged
|
||||||
|
override val onTimeChanged: Event1<Double>
|
||||||
|
get() = eventHandler.onTimeChanged
|
||||||
|
override val onDurationChanged: Event1<Double>
|
||||||
|
get() = eventHandler.onDurationChanged
|
||||||
|
override val onVolumeChanged: Event1<Double>
|
||||||
|
get() = eventHandler.onVolumeChanged
|
||||||
|
override val onSpeedChanged: Event1<Double>
|
||||||
|
get() = eventHandler.onSpeedChanged
|
||||||
|
|
||||||
|
override fun resumePlayback() = device.resumePlayback()
|
||||||
|
override fun pausePlayback() = device.pausePlayback()
|
||||||
|
override fun stopPlayback() = device.stopPlayback()
|
||||||
|
override fun seekTo(timeSeconds: Double) = device.seek(timeSeconds)
|
||||||
|
override fun changeVolume(newVolume: Double) {
|
||||||
|
device.changeVolume(newVolume)
|
||||||
|
volume = newVolume
|
||||||
|
}
|
||||||
|
override fun changeSpeed(speed: Double) = device.changeSpeed(speed)
|
||||||
|
override fun connect() = device.connect(
|
||||||
|
ApplicationInfo(
|
||||||
|
"Grayjay Android",
|
||||||
|
"${BuildConfig.VERSION_NAME}-${BuildConfig.FLAVOR}",
|
||||||
|
"${Build.MANUFACTURER} ${Build.MODEL}"
|
||||||
|
),
|
||||||
|
eventHandler,
|
||||||
|
1000.toULong()
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun disconnect() = device.disconnect()
|
||||||
|
|
||||||
|
override fun getDeviceInfo(): CastingDeviceInfo {
|
||||||
|
val info = device.getDeviceInfo()
|
||||||
|
return CastingDeviceInfo(
|
||||||
|
info.name,
|
||||||
|
when (info.protocol) {
|
||||||
|
ProtocolType.CHROMECAST -> CastProtocolType.CHROMECAST
|
||||||
|
ProtocolType.F_CAST -> CastProtocolType.FCAST
|
||||||
|
},
|
||||||
|
addresses = info.addresses.map { urlFormatIpAddr(it) }.toTypedArray(),
|
||||||
|
port = info.port.toInt(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAddresses(): List<InetAddress> = device.getAddresses().map {
|
||||||
|
ipAddrToInetAddress(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun loadVideo(
|
||||||
|
streamType: String,
|
||||||
|
contentType: String,
|
||||||
|
contentId: String,
|
||||||
|
resumePosition: Double,
|
||||||
|
duration: Double,
|
||||||
|
speed: Double?,
|
||||||
|
metadata: Metadata?
|
||||||
|
) = device.load(
|
||||||
|
LoadRequest.Video(
|
||||||
|
contentType = contentType,
|
||||||
|
url = contentId,
|
||||||
|
resumePosition = resumePosition,
|
||||||
|
speed = speed,
|
||||||
|
volume = volume,
|
||||||
|
metadata = metadata
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun loadContent(
|
||||||
|
contentType: String,
|
||||||
|
content: String,
|
||||||
|
resumePosition: Double,
|
||||||
|
duration: Double,
|
||||||
|
speed: Double?,
|
||||||
|
metadata: Metadata?
|
||||||
|
) = device.load(
|
||||||
|
LoadRequest.Content(
|
||||||
|
contentType = contentType,
|
||||||
|
content = content,
|
||||||
|
resumePosition = resumePosition,
|
||||||
|
speed = speed,
|
||||||
|
volume = volume,
|
||||||
|
metadata = metadata,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
override var connectionState = CastConnectionState.DISCONNECTED
|
||||||
|
override val protocolType: CastProtocolType
|
||||||
|
get() = when (device.castingProtocol()) {
|
||||||
|
ProtocolType.CHROMECAST -> CastProtocolType.CHROMECAST
|
||||||
|
ProtocolType.F_CAST -> CastProtocolType.FCAST
|
||||||
|
}
|
||||||
|
override var volume: Double = 1.0
|
||||||
|
override var duration: Double = 0.0
|
||||||
|
private var lastTimeChangeTime_ms: Long = 0
|
||||||
|
override var time: Double = 0.0
|
||||||
|
override var speed: Double = 0.0
|
||||||
|
override var isPlaying: Boolean = false
|
||||||
|
|
||||||
|
override val expectedCurrentTime: Double
|
||||||
|
get() {
|
||||||
|
val diff =
|
||||||
|
if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0;
|
||||||
|
return time + diff
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
eventHandler.onConnectionStateChanged.subscribe { newState ->
|
||||||
|
when (newState) {
|
||||||
|
is DeviceConnectionState.Connected -> {
|
||||||
|
usedRemoteAddress = ipAddrToInetAddress(newState.usedRemoteAddr)
|
||||||
|
localAddress = ipAddrToInetAddress(newState.localAddr)
|
||||||
|
connectionState = CastConnectionState.CONNECTED
|
||||||
|
onConnectionStateChanged.emit(CastConnectionState.CONNECTED)
|
||||||
|
}
|
||||||
|
|
||||||
|
DeviceConnectionState.Connecting, DeviceConnectionState.Reconnecting -> {
|
||||||
|
connectionState = CastConnectionState.CONNECTING
|
||||||
|
onConnectionStateChanged.emit(CastConnectionState.CONNECTING)
|
||||||
|
}
|
||||||
|
|
||||||
|
DeviceConnectionState.Disconnected -> {
|
||||||
|
connectionState = CastConnectionState.CONNECTING
|
||||||
|
onConnectionStateChanged.emit(CastConnectionState.DISCONNECTED)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newState == DeviceConnectionState.Disconnected) {
|
||||||
|
try {
|
||||||
|
Logger.i(TAG, "Stopping device")
|
||||||
|
device.disconnect()
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to stop device: $e")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
eventHandler.onPlayChanged.subscribe { isPlaying = it }
|
||||||
|
eventHandler.onTimeChanged.subscribe {
|
||||||
|
lastTimeChangeTime_ms = System.currentTimeMillis()
|
||||||
|
time = it
|
||||||
|
}
|
||||||
|
eventHandler.onDurationChanged.subscribe { duration = it }
|
||||||
|
eventHandler.onVolumeChanged.subscribe { volume = it }
|
||||||
|
eventHandler.onSpeedChanged.subscribe { speed = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun ensureThreadStarted() {}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val TAG = "CastingDeviceExp"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
package com.futo.platformplayer.casting
|
||||||
|
|
||||||
|
import com.futo.platformplayer.constructs.Event1
|
||||||
|
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||||
|
import kotlinx.serialization.KSerializer
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||||
|
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||||
|
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||||
|
import kotlinx.serialization.encoding.Decoder
|
||||||
|
import kotlinx.serialization.encoding.Encoder
|
||||||
|
import org.fcast.sender_sdk.Metadata
|
||||||
|
import java.net.InetAddress
|
||||||
|
|
||||||
|
enum class CastConnectionState {
|
||||||
|
DISCONNECTED,
|
||||||
|
CONNECTING,
|
||||||
|
CONNECTED
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable(with = CastProtocolType.CastProtocolTypeSerializer::class)
|
||||||
|
enum class CastProtocolType {
|
||||||
|
CHROMECAST,
|
||||||
|
AIRPLAY,
|
||||||
|
FCAST;
|
||||||
|
|
||||||
|
object CastProtocolTypeSerializer : KSerializer<CastProtocolType> {
|
||||||
|
override val descriptor: SerialDescriptor =
|
||||||
|
PrimitiveSerialDescriptor("CastProtocolType", PrimitiveKind.STRING)
|
||||||
|
|
||||||
|
override fun serialize(encoder: Encoder, value: CastProtocolType) {
|
||||||
|
encoder.encodeString(value.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun deserialize(decoder: Decoder): CastProtocolType {
|
||||||
|
val name = decoder.decodeString()
|
||||||
|
return when (name) {
|
||||||
|
"FASTCAST" -> FCAST // Handle the renamed case
|
||||||
|
else -> CastProtocolType.valueOf(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class CastingDeviceLegacy {
|
||||||
|
abstract val protocol: CastProtocolType;
|
||||||
|
abstract val isReady: Boolean;
|
||||||
|
abstract var usedRemoteAddress: InetAddress?;
|
||||||
|
abstract var localAddress: InetAddress?;
|
||||||
|
abstract val canSetVolume: Boolean;
|
||||||
|
abstract val canSetSpeed: Boolean;
|
||||||
|
|
||||||
|
var name: String? = null;
|
||||||
|
var isPlaying: Boolean = false
|
||||||
|
set(value) {
|
||||||
|
val changed = value != field;
|
||||||
|
field = value;
|
||||||
|
if (changed) {
|
||||||
|
onPlayChanged.emit(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private var lastTimeChangeTime_ms: Long = 0
|
||||||
|
var time: Double = 0.0
|
||||||
|
private set
|
||||||
|
|
||||||
|
protected fun setTime(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
|
||||||
|
if (changeTime_ms > lastTimeChangeTime_ms && value != time) {
|
||||||
|
time = value
|
||||||
|
lastTimeChangeTime_ms = changeTime_ms
|
||||||
|
onTimeChanged.emit(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var lastDurationChangeTime_ms: Long = 0
|
||||||
|
var duration: Double = 0.0
|
||||||
|
private set
|
||||||
|
|
||||||
|
protected fun setDuration(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
|
||||||
|
if (changeTime_ms > lastDurationChangeTime_ms && value != duration) {
|
||||||
|
duration = value
|
||||||
|
lastDurationChangeTime_ms = changeTime_ms
|
||||||
|
onDurationChanged.emit(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var lastVolumeChangeTime_ms: Long = 0
|
||||||
|
var volume: Double = 1.0
|
||||||
|
private set
|
||||||
|
|
||||||
|
protected fun setVolume(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
|
||||||
|
if (changeTime_ms > lastVolumeChangeTime_ms && value != volume) {
|
||||||
|
volume = value
|
||||||
|
lastVolumeChangeTime_ms = changeTime_ms
|
||||||
|
onVolumeChanged.emit(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var lastSpeedChangeTime_ms: Long = 0
|
||||||
|
var speed: Double = 1.0
|
||||||
|
private set
|
||||||
|
|
||||||
|
protected fun setSpeed(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
|
||||||
|
if (changeTime_ms > lastSpeedChangeTime_ms && value != speed) {
|
||||||
|
speed = value
|
||||||
|
lastSpeedChangeTime_ms = changeTime_ms
|
||||||
|
onSpeedChanged.emit(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val expectedCurrentTime: Double
|
||||||
|
get() {
|
||||||
|
val diff =
|
||||||
|
if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0;
|
||||||
|
return time + diff;
|
||||||
|
};
|
||||||
|
var connectionState: CastConnectionState = CastConnectionState.DISCONNECTED
|
||||||
|
set(value) {
|
||||||
|
val changed = value != field;
|
||||||
|
field = value;
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
onConnectionStateChanged.emit(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var onConnectionStateChanged = Event1<CastConnectionState>();
|
||||||
|
var onPlayChanged = Event1<Boolean>();
|
||||||
|
var onTimeChanged = Event1<Double>();
|
||||||
|
var onDurationChanged = Event1<Double>();
|
||||||
|
var onVolumeChanged = Event1<Double>();
|
||||||
|
var onSpeedChanged = Event1<Double>();
|
||||||
|
|
||||||
|
abstract fun stopCasting();
|
||||||
|
|
||||||
|
abstract fun seekVideo(timeSeconds: Double);
|
||||||
|
abstract fun stopVideo();
|
||||||
|
abstract fun pauseVideo();
|
||||||
|
abstract fun resumeVideo();
|
||||||
|
abstract fun loadVideo(
|
||||||
|
streamType: String,
|
||||||
|
contentType: String,
|
||||||
|
contentId: String,
|
||||||
|
resumePosition: Double,
|
||||||
|
duration: Double,
|
||||||
|
speed: Double?
|
||||||
|
);
|
||||||
|
|
||||||
|
abstract fun loadContent(
|
||||||
|
contentType: String,
|
||||||
|
content: String,
|
||||||
|
resumePosition: Double,
|
||||||
|
duration: Double,
|
||||||
|
speed: Double?
|
||||||
|
);
|
||||||
|
|
||||||
|
open fun changeVolume(volume: Double) {
|
||||||
|
throw NotImplementedError()
|
||||||
|
}
|
||||||
|
|
||||||
|
open fun changeSpeed(speed: Double) {
|
||||||
|
throw NotImplementedError()
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract fun start();
|
||||||
|
abstract fun stop();
|
||||||
|
|
||||||
|
abstract fun getDeviceInfo(): CastingDeviceInfo;
|
||||||
|
|
||||||
|
abstract fun getAddresses(): List<InetAddress>;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CastingDeviceLegacyWrapper(val inner: CastingDeviceLegacy) : CastingDevice() {
|
||||||
|
override val isReady: Boolean get() = inner.isReady
|
||||||
|
override val usedRemoteAddress: InetAddress? get() = inner.usedRemoteAddress
|
||||||
|
override val localAddress: InetAddress? get() = inner.localAddress
|
||||||
|
override val name: String? get() = inner.name
|
||||||
|
override val onConnectionStateChanged: Event1<CastConnectionState> get() = inner.onConnectionStateChanged
|
||||||
|
override val onPlayChanged: Event1<Boolean> get() = inner.onPlayChanged
|
||||||
|
override val onTimeChanged: Event1<Double> get() = inner.onTimeChanged
|
||||||
|
override val onDurationChanged: Event1<Double> get() = inner.onDurationChanged
|
||||||
|
override val onVolumeChanged: Event1<Double> get() = inner.onVolumeChanged
|
||||||
|
override val onSpeedChanged: Event1<Double> get() = inner.onSpeedChanged
|
||||||
|
override var connectionState: CastConnectionState
|
||||||
|
get() = inner.connectionState
|
||||||
|
set(_) = Unit
|
||||||
|
override val protocolType: CastProtocolType get() = inner.protocol
|
||||||
|
override var isPlaying: Boolean
|
||||||
|
get() = inner.isPlaying
|
||||||
|
set(_) = Unit
|
||||||
|
override val expectedCurrentTime: Double
|
||||||
|
get() = inner.expectedCurrentTime
|
||||||
|
override var speed: Double
|
||||||
|
get() = inner.speed
|
||||||
|
set(_) = Unit
|
||||||
|
override var time: Double
|
||||||
|
get() = inner.time
|
||||||
|
set(_) = Unit
|
||||||
|
override var duration: Double
|
||||||
|
get() = inner.duration
|
||||||
|
set(_) = Unit
|
||||||
|
override var volume: Double
|
||||||
|
get() = inner.volume
|
||||||
|
set(_) = Unit
|
||||||
|
|
||||||
|
override fun canSetVolume(): Boolean = inner.canSetVolume
|
||||||
|
override fun canSetSpeed(): Boolean = inner.canSetSpeed
|
||||||
|
override fun resumePlayback() = inner.resumeVideo()
|
||||||
|
override fun pausePlayback() = inner.pauseVideo()
|
||||||
|
override fun stopPlayback() = inner.stopVideo()
|
||||||
|
override fun seekTo(timeSeconds: Double) = inner.seekVideo(timeSeconds)
|
||||||
|
override fun changeVolume(timeSeconds: Double) = inner.changeVolume(timeSeconds)
|
||||||
|
override fun changeSpeed(speed: Double) = inner.changeSpeed(speed)
|
||||||
|
override fun connect() = inner.start()
|
||||||
|
override fun disconnect() = inner.stop()
|
||||||
|
override fun getDeviceInfo(): CastingDeviceInfo = inner.getDeviceInfo()
|
||||||
|
override fun getAddresses(): List<InetAddress> = inner.getAddresses()
|
||||||
|
override fun loadVideo(
|
||||||
|
streamType: String,
|
||||||
|
contentType: String,
|
||||||
|
contentId: String,
|
||||||
|
resumePosition: Double,
|
||||||
|
duration: Double,
|
||||||
|
speed: Double?,
|
||||||
|
metadata: Metadata?
|
||||||
|
) = inner.loadVideo(streamType, contentType, contentId, resumePosition, duration, speed)
|
||||||
|
|
||||||
|
override fun loadContent(
|
||||||
|
contentType: String,
|
||||||
|
content: String,
|
||||||
|
resumePosition: Double,
|
||||||
|
duration: Double,
|
||||||
|
speed: Double?,
|
||||||
|
metadata: Metadata?
|
||||||
|
) = inner.loadContent(contentType, content, resumePosition, duration, speed)
|
||||||
|
|
||||||
|
override fun ensureThreadStarted() = when (inner) {
|
||||||
|
is FCastCastingDevice -> inner.ensureThreadStarted()
|
||||||
|
is ChromecastCastingDevice -> inner.ensureThreadsStarted()
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@ import javax.net.ssl.SSLSocket
|
|||||||
import javax.net.ssl.TrustManager
|
import javax.net.ssl.TrustManager
|
||||||
import javax.net.ssl.X509TrustManager
|
import javax.net.ssl.X509TrustManager
|
||||||
|
|
||||||
class ChromecastCastingDevice : CastingDevice {
|
class ChromecastCastingDevice : CastingDeviceLegacy {
|
||||||
//See for more info: https://developers.google.com/cast/docs/media/messages
|
//See for more info: https://developers.google.com/cast/docs/media/messages
|
||||||
|
|
||||||
override val protocol: CastProtocolType get() = CastProtocolType.CHROMECAST;
|
override val protocol: CastProtocolType get() = CastProtocolType.CHROMECAST;
|
||||||
@@ -62,6 +62,7 @@ class ChromecastCastingDevice : CastingDevice {
|
|||||||
private val MAX_LAUNCH_RETRIES = 3
|
private val MAX_LAUNCH_RETRIES = 3
|
||||||
private var _lastLaunchTime_ms = 0L
|
private var _lastLaunchTime_ms = 0L
|
||||||
private var _retryJob: Job? = null
|
private var _retryJob: Job? = null
|
||||||
|
private var _autoLaunchEnabled = true
|
||||||
|
|
||||||
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
|
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
|
||||||
this.name = name;
|
this.name = name;
|
||||||
@@ -305,6 +306,7 @@ class ChromecastCastingDevice : CastingDevice {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_autoLaunchEnabled = true
|
||||||
_started = true;
|
_started = true;
|
||||||
_sessionId = null;
|
_sessionId = null;
|
||||||
_launchRetries = 0
|
_launchRetries = 0
|
||||||
@@ -546,6 +548,7 @@ class ChromecastCastingDevice : CastingDevice {
|
|||||||
|
|
||||||
if (appId == "CC1AD845") {
|
if (appId == "CC1AD845") {
|
||||||
sessionIsRunning = true;
|
sessionIsRunning = true;
|
||||||
|
_autoLaunchEnabled = false
|
||||||
|
|
||||||
if (_sessionId == null) {
|
if (_sessionId == null) {
|
||||||
connectionState = CastConnectionState.CONNECTED;
|
connectionState = CastConnectionState.CONNECTED;
|
||||||
@@ -558,7 +561,6 @@ class ChromecastCastingDevice : CastingDevice {
|
|||||||
_transportId = transportId;
|
_transportId = transportId;
|
||||||
|
|
||||||
requestMediaStatus();
|
requestMediaStatus();
|
||||||
playVideo();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -568,21 +570,22 @@ class ChromecastCastingDevice : CastingDevice {
|
|||||||
if (System.currentTimeMillis() - _lastLaunchTime_ms > 5000) {
|
if (System.currentTimeMillis() - _lastLaunchTime_ms > 5000) {
|
||||||
_sessionId = null
|
_sessionId = null
|
||||||
_mediaSessionId = null
|
_mediaSessionId = null
|
||||||
setTime(0.0)
|
|
||||||
_transportId = null
|
_transportId = null
|
||||||
|
|
||||||
if (_launching && _launchRetries < MAX_LAUNCH_RETRIES) {
|
if (_autoLaunchEnabled) {
|
||||||
Logger.i(TAG, "No player yet; attempting launch #${_launchRetries + 1}")
|
if (_launching && _launchRetries < MAX_LAUNCH_RETRIES) {
|
||||||
_launchRetries++
|
Logger.i(TAG, "No player yet; attempting launch #${_launchRetries + 1}")
|
||||||
launchPlayer()
|
_launchRetries++
|
||||||
} else if (!_launching && _launchRetries < MAX_LAUNCH_RETRIES) {
|
launchPlayer()
|
||||||
// Maybe the first GET_STATUS came back empty; still try launching
|
} else {
|
||||||
Logger.i(TAG, "Player not found; triggering launch #${_launchRetries + 1}")
|
// Maybe the first GET_STATUS came back empty; still try launching
|
||||||
_launching = true
|
Logger.i(TAG, "Player not found; triggering launch #${_launchRetries + 1}")
|
||||||
_launchRetries++
|
_launching = true
|
||||||
launchPlayer()
|
_launchRetries++
|
||||||
|
launchPlayer()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Logger.e(TAG, "Player not found after $_launchRetries attempts; giving up.")
|
Logger.e(TAG, "Player not found ($_launchRetries, _autoLaunchEnabled = $_autoLaunchEnabled); giving up.")
|
||||||
Logger.i(TAG, "Unable to start media receiver on device")
|
Logger.i(TAG, "Unable to start media receiver on device")
|
||||||
stop()
|
stop()
|
||||||
}
|
}
|
||||||
@@ -599,6 +602,7 @@ class ChromecastCastingDevice : CastingDevice {
|
|||||||
} else {
|
} else {
|
||||||
_launching = false
|
_launching = false
|
||||||
_launchRetries = 0
|
_launchRetries = 0
|
||||||
|
_autoLaunchEnabled = false
|
||||||
}
|
}
|
||||||
|
|
||||||
val volume = status.getJSONObject("volume");
|
val volume = status.getJSONObject("volume");
|
||||||
@@ -636,10 +640,16 @@ class ChromecastCastingDevice : CastingDevice {
|
|||||||
stopVideo();
|
stopVideo();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val needsLoad = statuses.length() == 0 || (statuses.getJSONObject(0).getString("playerState") == "IDLE")
|
||||||
|
if (needsLoad && _contentId != null && _mediaSessionId == null) {
|
||||||
|
Logger.i(TAG, "Receiver idle, sending initial LOAD")
|
||||||
|
playVideo()
|
||||||
|
}
|
||||||
} else if (type == "CLOSE") {
|
} else if (type == "CLOSE") {
|
||||||
if (message.sourceId == "receiver-0") {
|
if (message.sourceId == "receiver-0") {
|
||||||
Logger.i(TAG, "Close received.");
|
Logger.i(TAG, "Close received.");
|
||||||
stop();
|
stopCasting();
|
||||||
} else if (_transportId == message.sourceId) {
|
} else if (_transportId == message.sourceId) {
|
||||||
throw Exception("Transport id closed.")
|
throw Exception("Transport id closed.")
|
||||||
}
|
}
|
||||||
@@ -676,6 +686,10 @@ class ChromecastCastingDevice : CastingDevice {
|
|||||||
localAddress = null;
|
localAddress = null;
|
||||||
_started = false;
|
_started = false;
|
||||||
|
|
||||||
|
_contentId = null
|
||||||
|
_contentType = null
|
||||||
|
_streamType = null
|
||||||
|
|
||||||
_retryJob?.cancel()
|
_retryJob?.cancel()
|
||||||
_retryJob = null
|
_retryJob = null
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package com.futo.platformplayer.casting
|
|||||||
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.Settings
|
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.casting.models.FCastDecryptedMessage
|
import com.futo.platformplayer.casting.models.FCastDecryptedMessage
|
||||||
import com.futo.platformplayer.casting.models.FCastEncryptedMessage
|
import com.futo.platformplayer.casting.models.FCastEncryptedMessage
|
||||||
@@ -25,7 +24,6 @@ import com.futo.platformplayer.toInetAddress
|
|||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
@@ -34,7 +32,6 @@ import java.io.IOException
|
|||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
import java.math.BigInteger
|
import java.math.BigInteger
|
||||||
import java.net.Inet4Address
|
|
||||||
import java.net.InetAddress
|
import java.net.InetAddress
|
||||||
import java.net.InetSocketAddress
|
import java.net.InetSocketAddress
|
||||||
import java.net.Socket
|
import java.net.Socket
|
||||||
@@ -72,7 +69,7 @@ enum class Opcode(val value: Byte) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class FCastCastingDevice : CastingDevice {
|
class FCastCastingDevice : CastingDeviceLegacy {
|
||||||
//See for more info: TODO
|
//See for more info: TODO
|
||||||
|
|
||||||
override val protocol: CastProtocolType get() = CastProtocolType.FCAST;
|
override val protocol: CastProtocolType get() = CastProtocolType.FCAST;
|
||||||
@@ -348,7 +345,7 @@ class FCastCastingDevice : CastingDevice {
|
|||||||
headerBytesRead += read
|
headerBytesRead += read
|
||||||
}
|
}
|
||||||
|
|
||||||
val size = ((buffer[3].toLong() shl 24) or (buffer[2].toLong() shl 16) or (buffer[1].toLong() shl 8) or buffer[0].toLong()).toInt();
|
val size = ((buffer[3].toUByte().toLong() shl 24) or (buffer[2].toUByte().toLong() shl 16) or (buffer[1].toUByte().toLong() shl 8) or buffer[0].toUByte().toLong()).toInt();
|
||||||
if (size > buffer.size) {
|
if (size > buffer.size) {
|
||||||
Logger.w(TAG, "Packets larger than $size bytes are not supported.")
|
Logger.w(TAG, "Packets larger than $size bytes are not supported.")
|
||||||
break
|
break
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,174 @@
|
|||||||
|
package com.futo.platformplayer.casting
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import com.futo.platformplayer.BuildConfig
|
||||||
|
import com.futo.platformplayer.constructs.Event1
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import org.fcast.sender_sdk.DeviceInfo as RsDeviceInfo
|
||||||
|
import org.fcast.sender_sdk.ProtocolType
|
||||||
|
import org.fcast.sender_sdk.CastContext
|
||||||
|
import org.fcast.sender_sdk.NsdDeviceDiscoverer
|
||||||
|
|
||||||
|
class StateCastingExp : StateCasting() {
|
||||||
|
private val _context = CastContext()
|
||||||
|
var _deviceDiscoverer: NsdDeviceDiscoverer? = null
|
||||||
|
|
||||||
|
class DiscoveryEventHandler(
|
||||||
|
private val onDeviceAdded: (RsDeviceInfo) -> Unit,
|
||||||
|
private val onDeviceRemoved: (String) -> Unit,
|
||||||
|
private val onDeviceUpdated: (RsDeviceInfo) -> Unit,
|
||||||
|
) : org.fcast.sender_sdk.DeviceDiscovererEventHandler {
|
||||||
|
override fun deviceAvailable(deviceInfo: RsDeviceInfo) {
|
||||||
|
onDeviceAdded(deviceInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun deviceChanged(deviceInfo: RsDeviceInfo) {
|
||||||
|
onDeviceUpdated(deviceInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun deviceRemoved(deviceName: String) {
|
||||||
|
onDeviceRemoved(deviceName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
org.fcast.sender_sdk.initLogger(org.fcast.sender_sdk.LogLevelFilter.DEBUG)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun handleUrl(url: String) {
|
||||||
|
try {
|
||||||
|
val foundDeviceInfo = org.fcast.sender_sdk.deviceInfoFromUrl(url)!!
|
||||||
|
val foundDevice = _context.createDeviceFromInfo(foundDeviceInfo)
|
||||||
|
connectDevice(CastingDeviceExp(foundDevice))
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to handle URL: $e")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStop() {
|
||||||
|
val ad = activeDevice ?: return
|
||||||
|
_resumeCastingDevice = ad.getDeviceInfo()
|
||||||
|
Log.i(TAG, "_resumeCastingDevice set to '${ad.name}'")
|
||||||
|
Logger.i(TAG, "Stopping active device because of onStop.")
|
||||||
|
try {
|
||||||
|
ad.disconnect()
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to disconnect from device: $e")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
override fun start(context: Context) {
|
||||||
|
if (_started)
|
||||||
|
return
|
||||||
|
_started = true
|
||||||
|
|
||||||
|
Log.i(TAG, "_resumeCastingDevice set null start")
|
||||||
|
_resumeCastingDevice = null
|
||||||
|
|
||||||
|
Logger.i(TAG, "CastingService starting...")
|
||||||
|
|
||||||
|
_castServer.start()
|
||||||
|
enableDeveloper(true)
|
||||||
|
|
||||||
|
Logger.i(TAG, "CastingService started.")
|
||||||
|
|
||||||
|
_deviceDiscoverer = NsdDeviceDiscoverer(
|
||||||
|
context,
|
||||||
|
DiscoveryEventHandler(
|
||||||
|
{ deviceInfo -> // Added
|
||||||
|
Logger.i(TAG, "Device added: ${deviceInfo.name}")
|
||||||
|
val device = _context.createDeviceFromInfo(deviceInfo)
|
||||||
|
val deviceHandle = CastingDeviceExp(device)
|
||||||
|
devices[deviceHandle.device.name()] = deviceHandle
|
||||||
|
invokeInMainScopeIfRequired {
|
||||||
|
onDeviceAdded.emit(deviceHandle)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deviceName -> // Removed
|
||||||
|
invokeInMainScopeIfRequired {
|
||||||
|
if (devices.containsKey(deviceName)) {
|
||||||
|
val device = devices.remove(deviceName)
|
||||||
|
if (device != null) {
|
||||||
|
onDeviceRemoved.emit(device)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deviceInfo -> // Updated
|
||||||
|
Logger.i(TAG, "Device updated: $deviceInfo")
|
||||||
|
val handle = devices[deviceInfo.name]
|
||||||
|
if (handle != null && handle is CastingDeviceExp) {
|
||||||
|
handle.device.setPort(deviceInfo.port)
|
||||||
|
handle.device.setAddresses(deviceInfo.addresses)
|
||||||
|
invokeInMainScopeIfRequired {
|
||||||
|
onDeviceChanged.emit(handle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
override fun stop() {
|
||||||
|
if (!_started) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_started = false
|
||||||
|
|
||||||
|
Logger.i(TAG, "CastingService stopping.")
|
||||||
|
|
||||||
|
_scopeIO.cancel()
|
||||||
|
_scopeMain.cancel()
|
||||||
|
|
||||||
|
Logger.i(TAG, "Stopping active device because StateCasting is being stopped.")
|
||||||
|
val d = activeDevice
|
||||||
|
activeDevice = null
|
||||||
|
try {
|
||||||
|
d?.disconnect()
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to disconnect device: $e")
|
||||||
|
}
|
||||||
|
|
||||||
|
_castServer.stop()
|
||||||
|
_castServer.removeAllHandlers()
|
||||||
|
|
||||||
|
Logger.i(TAG, "CastingService stopped.")
|
||||||
|
|
||||||
|
_deviceDiscoverer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun startUpdateTimeJob(
|
||||||
|
onTimeJobTimeChanged_s: Event1<Long>,
|
||||||
|
setTime: (Long) -> Unit
|
||||||
|
): Job? = null
|
||||||
|
|
||||||
|
override fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDeviceExp {
|
||||||
|
val rsAddrs =
|
||||||
|
deviceInfo.addresses.map { org.fcast.sender_sdk.tryIpAddrFromStr(it) } // Throws!
|
||||||
|
val rsDeviceInfo = RsDeviceInfo(
|
||||||
|
name = deviceInfo.name,
|
||||||
|
protocol = when (deviceInfo.type) {
|
||||||
|
com.futo.platformplayer.casting.CastProtocolType.CHROMECAST -> ProtocolType.CHROMECAST
|
||||||
|
com.futo.platformplayer.casting.CastProtocolType.FCAST -> ProtocolType.F_CAST
|
||||||
|
else -> throw IllegalArgumentException()
|
||||||
|
},
|
||||||
|
addresses = rsAddrs,
|
||||||
|
port = deviceInfo.port.toUShort(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return CastingDeviceExp(_context.createDeviceFromInfo(rsDeviceInfo))
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val TAG = "StateCastingExp"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
package com.futo.platformplayer.casting
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.net.nsd.NsdManager
|
||||||
|
import android.net.nsd.NsdServiceInfo
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Base64
|
||||||
|
import android.util.Log
|
||||||
|
import com.futo.platformplayer.constructs.Event1
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import java.net.InetAddress
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
class StateCastingLegacy : StateCasting() {
|
||||||
|
private var _nsdManager: NsdManager? = null
|
||||||
|
|
||||||
|
private val _discoveryListeners = mapOf(
|
||||||
|
"_googlecast._tcp" to createDiscoveryListener(::addOrUpdateChromeCastDevice),
|
||||||
|
"_airplay._tcp" to createDiscoveryListener(::addOrUpdateAirPlayDevice),
|
||||||
|
"_fastcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice),
|
||||||
|
"_fcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice)
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun handleUrl(url: String) {
|
||||||
|
val uri = Uri.parse(url)
|
||||||
|
if (uri.scheme != "fcast") {
|
||||||
|
throw Exception("Expected scheme to be FCast")
|
||||||
|
}
|
||||||
|
|
||||||
|
val type = uri.host
|
||||||
|
if (type != "r") {
|
||||||
|
throw Exception("Expected type r")
|
||||||
|
}
|
||||||
|
|
||||||
|
val connectionInfo = uri.pathSegments[0]
|
||||||
|
val json =
|
||||||
|
Base64.decode(connectionInfo, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
|
||||||
|
.toString(Charsets.UTF_8)
|
||||||
|
val networkConfig = Json.decodeFromString<FCastNetworkConfig>(json)
|
||||||
|
val tcpService = networkConfig.services.first { v -> v.type == 0 }
|
||||||
|
|
||||||
|
val foundInfo = addRememberedDevice(
|
||||||
|
CastingDeviceInfo(
|
||||||
|
name = networkConfig.name,
|
||||||
|
type = CastProtocolType.FCAST,
|
||||||
|
addresses = networkConfig.addresses.toTypedArray(),
|
||||||
|
port = tcpService.port
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
connectDevice(deviceFromInfo(foundInfo))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStop() {
|
||||||
|
val ad = activeDevice ?: return;
|
||||||
|
_resumeCastingDevice = ad.getDeviceInfo()
|
||||||
|
Log.i(TAG, "_resumeCastingDevice set to '${ad.name}'")
|
||||||
|
Logger.i(TAG, "Stopping active device because of onStop.");
|
||||||
|
ad.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
override fun start(context: Context) {
|
||||||
|
if (_started)
|
||||||
|
return;
|
||||||
|
_started = true;
|
||||||
|
|
||||||
|
Log.i(TAG, "_resumeCastingDevice set null start")
|
||||||
|
_resumeCastingDevice = null;
|
||||||
|
|
||||||
|
Logger.i(TAG, "CastingService starting...");
|
||||||
|
|
||||||
|
_castServer.start();
|
||||||
|
enableDeveloper(true);
|
||||||
|
|
||||||
|
Logger.i(TAG, "CastingService started.");
|
||||||
|
|
||||||
|
_nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager
|
||||||
|
startDiscovering()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
private fun startDiscovering() {
|
||||||
|
_nsdManager?.apply {
|
||||||
|
_discoveryListeners.forEach {
|
||||||
|
discoverServices(it.key, NsdManager.PROTOCOL_DNS_SD, it.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
private fun stopDiscovering() {
|
||||||
|
_nsdManager?.apply {
|
||||||
|
_discoveryListeners.forEach {
|
||||||
|
try {
|
||||||
|
stopServiceDiscovery(it.value)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.w(TAG, "Failed to stop service discovery", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
override fun stop() {
|
||||||
|
if (!_started)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_started = false;
|
||||||
|
|
||||||
|
Logger.i(TAG, "CastingService stopping.")
|
||||||
|
|
||||||
|
stopDiscovering()
|
||||||
|
_scopeIO.cancel();
|
||||||
|
_scopeMain.cancel();
|
||||||
|
|
||||||
|
Logger.i(TAG, "Stopping active device because StateCasting is being stopped.")
|
||||||
|
val d = activeDevice;
|
||||||
|
activeDevice = null;
|
||||||
|
d?.disconnect();
|
||||||
|
|
||||||
|
_castServer.stop();
|
||||||
|
_castServer.removeAllHandlers();
|
||||||
|
|
||||||
|
Logger.i(TAG, "CastingService stopped.")
|
||||||
|
|
||||||
|
_nsdManager = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createDiscoveryListener(addOrUpdate: (String, Array<InetAddress>, Int) -> Unit): NsdManager.DiscoveryListener {
|
||||||
|
return object : NsdManager.DiscoveryListener {
|
||||||
|
override fun onDiscoveryStarted(regType: String) {
|
||||||
|
Log.d(TAG, "Service discovery started for $regType")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDiscoveryStopped(serviceType: String) {
|
||||||
|
Log.i(TAG, "Discovery stopped: $serviceType")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceLost(service: NsdServiceInfo) {
|
||||||
|
Log.e(TAG, "service lost: $service")
|
||||||
|
// TODO: Handle service lost, e.g., remove device
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
|
||||||
|
Log.e(TAG, "Discovery failed for $serviceType: Error code:$errorCode")
|
||||||
|
try {
|
||||||
|
_nsdManager?.stopServiceDiscovery(this)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.w(TAG, "Failed to stop service discovery", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {
|
||||||
|
Log.e(TAG, "Stop discovery failed for $serviceType: Error code:$errorCode")
|
||||||
|
try {
|
||||||
|
_nsdManager?.stopServiceDiscovery(this)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.w(TAG, "Failed to stop service discovery", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceFound(service: NsdServiceInfo) {
|
||||||
|
Log.v(TAG, "Service discovery success for ${service.serviceType}: $service")
|
||||||
|
val addresses = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
|
service.hostAddresses.toTypedArray()
|
||||||
|
} else {
|
||||||
|
arrayOf(service.host)
|
||||||
|
}
|
||||||
|
addOrUpdate(service.serviceName, addresses, service.port)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
|
_nsdManager?.registerServiceInfoCallback(
|
||||||
|
service,
|
||||||
|
{ it.run() },
|
||||||
|
object : NsdManager.ServiceInfoCallback {
|
||||||
|
override fun onServiceUpdated(serviceInfo: NsdServiceInfo) {
|
||||||
|
Log.v(TAG, "onServiceUpdated: $serviceInfo")
|
||||||
|
addOrUpdate(
|
||||||
|
serviceInfo.serviceName,
|
||||||
|
serviceInfo.hostAddresses.toTypedArray(),
|
||||||
|
serviceInfo.port
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceLost() {
|
||||||
|
Log.v(TAG, "onServiceLost: $service")
|
||||||
|
// TODO: Handle service lost
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {
|
||||||
|
Log.v(TAG, "onServiceInfoCallbackRegistrationFailed: $errorCode")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceInfoCallbackUnregistered() {
|
||||||
|
Log.v(TAG, "onServiceInfoCallbackUnregistered")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
_nsdManager?.resolveService(service, object : NsdManager.ResolveListener {
|
||||||
|
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
|
||||||
|
Log.v(TAG, "Resolve failed: $errorCode")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
|
||||||
|
Log.v(TAG, "Resolve Succeeded: $serviceInfo")
|
||||||
|
addOrUpdate(
|
||||||
|
serviceInfo.serviceName,
|
||||||
|
arrayOf(serviceInfo.host),
|
||||||
|
serviceInfo.port
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun startUpdateTimeJob(
|
||||||
|
onTimeJobTimeChanged_s: Event1<Long>,
|
||||||
|
setTime: (Long) -> Unit
|
||||||
|
): Job? {
|
||||||
|
val d = activeDevice;
|
||||||
|
if (d is CastingDeviceLegacyWrapper && (d.inner is AirPlayCastingDevice || d.inner is ChromecastCastingDevice)) {
|
||||||
|
return _scopeMain.launch {
|
||||||
|
while (true) {
|
||||||
|
val device = instance.activeDevice
|
||||||
|
if (device == null || !device.isPlaying) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
delay(1000)
|
||||||
|
val time_ms = (device.expectedCurrentTime * 1000.0).toLong()
|
||||||
|
setTime(time_ms)
|
||||||
|
onTimeJobTimeChanged_s.emit(device.expectedCurrentTime.toLong())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDevice {
|
||||||
|
return CastingDeviceLegacyWrapper(
|
||||||
|
when (deviceInfo.type) {
|
||||||
|
CastProtocolType.CHROMECAST -> {
|
||||||
|
ChromecastCastingDevice(deviceInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
CastProtocolType.AIRPLAY -> {
|
||||||
|
AirPlayCastingDevice(deviceInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
CastProtocolType.FCAST -> {
|
||||||
|
FCastCastingDevice(deviceInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addOrUpdateChromeCastDevice(
|
||||||
|
name: String,
|
||||||
|
addresses: Array<InetAddress>,
|
||||||
|
port: Int
|
||||||
|
) {
|
||||||
|
return addOrUpdateCastDevice(
|
||||||
|
name,
|
||||||
|
deviceFactory = {
|
||||||
|
CastingDeviceLegacyWrapper(
|
||||||
|
ChromecastCastingDevice(
|
||||||
|
name,
|
||||||
|
addresses,
|
||||||
|
port
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
deviceUpdater = { d ->
|
||||||
|
if (d.isReady || d !is CastingDeviceLegacyWrapper || d.inner !is ChromecastCastingDevice) {
|
||||||
|
return@addOrUpdateCastDevice false;
|
||||||
|
}
|
||||||
|
|
||||||
|
val changed =
|
||||||
|
addresses.contentEquals(d.inner.addresses) || d.name != name || d.inner.port != port;
|
||||||
|
if (changed) {
|
||||||
|
d.inner.name = name;
|
||||||
|
d.inner.addresses = addresses;
|
||||||
|
d.inner.port = port;
|
||||||
|
}
|
||||||
|
|
||||||
|
return@addOrUpdateCastDevice changed;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addOrUpdateAirPlayDevice(name: String, addresses: Array<InetAddress>, port: Int) {
|
||||||
|
return addOrUpdateCastDevice(
|
||||||
|
name,
|
||||||
|
deviceFactory = {
|
||||||
|
CastingDeviceLegacyWrapper(
|
||||||
|
AirPlayCastingDevice(
|
||||||
|
name,
|
||||||
|
addresses,
|
||||||
|
port
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
deviceUpdater = { d ->
|
||||||
|
if (d.isReady || d !is CastingDeviceLegacyWrapper || d.inner !is AirPlayCastingDevice) {
|
||||||
|
return@addOrUpdateCastDevice false;
|
||||||
|
}
|
||||||
|
|
||||||
|
val changed =
|
||||||
|
addresses.contentEquals(addresses) || d.name != name || d.inner.port != port;
|
||||||
|
if (changed) {
|
||||||
|
d.inner.name = name;
|
||||||
|
d.inner.port = port;
|
||||||
|
d.inner.addresses = addresses;
|
||||||
|
}
|
||||||
|
|
||||||
|
return@addOrUpdateCastDevice changed;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addOrUpdateFastCastDevice(name: String, addresses: Array<InetAddress>, port: Int) {
|
||||||
|
return addOrUpdateCastDevice(
|
||||||
|
name,
|
||||||
|
deviceFactory = { CastingDeviceLegacyWrapper(FCastCastingDevice(name, addresses, port)) },
|
||||||
|
deviceUpdater = { d ->
|
||||||
|
if (d.isReady || d !is CastingDeviceLegacyWrapper || d.inner !is FCastCastingDevice) {
|
||||||
|
return@addOrUpdateCastDevice false;
|
||||||
|
}
|
||||||
|
|
||||||
|
val changed =
|
||||||
|
addresses.contentEquals(addresses) || d.name != name || d.inner.port != port;
|
||||||
|
if (changed) {
|
||||||
|
d.inner.name = name;
|
||||||
|
d.inner.port = port;
|
||||||
|
d.inner.addresses = addresses;
|
||||||
|
}
|
||||||
|
|
||||||
|
return@addOrUpdateCastDevice changed;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun addOrUpdateCastDevice(
|
||||||
|
name: String,
|
||||||
|
deviceFactory: () -> CastingDevice,
|
||||||
|
deviceUpdater: (device: CastingDevice) -> Boolean
|
||||||
|
) {
|
||||||
|
var invokeEvents: (() -> Unit)? = null;
|
||||||
|
|
||||||
|
synchronized(devices) {
|
||||||
|
val device = devices[name];
|
||||||
|
if (device != null) {
|
||||||
|
val changed = deviceUpdater(device);
|
||||||
|
if (changed) {
|
||||||
|
invokeEvents = {
|
||||||
|
onDeviceChanged.emit(device);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val newDevice = deviceFactory();
|
||||||
|
this.devices[name] = newDevice
|
||||||
|
|
||||||
|
invokeEvents = {
|
||||||
|
onDeviceAdded.emit(newDevice);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
invokeEvents?.let { _scopeMain.launch { it(); }; };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class FCastNetworkConfig(
|
||||||
|
val name: String,
|
||||||
|
val addresses: List<String>,
|
||||||
|
val services: List<FCastService>
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class FCastService(
|
||||||
|
val port: Int,
|
||||||
|
val type: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val TAG = "StateCastingLegacy"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,10 +47,10 @@ class DeveloperEndpoints(private val context: Context) {
|
|||||||
private val testPluginOrThrow: V8Plugin get() = _testPlugin ?: throw IllegalStateException("Attempted to use test plugin without plugin");
|
private val testPluginOrThrow: V8Plugin get() = _testPlugin ?: throw IllegalStateException("Attempted to use test plugin without plugin");
|
||||||
private val _testPluginVariables: HashMap<String, V8RemoteObject> = hashMapOf();
|
private val _testPluginVariables: HashMap<String, V8RemoteObject> = hashMapOf();
|
||||||
|
|
||||||
private inline fun <reified T> createRemoteObjectArray(objs: Iterable<T>): List<V8RemoteObject> {
|
private inline fun <reified T> createRemoteObjectArray(objs: Iterable<T>): List<V8RemoteObject?> {
|
||||||
val remotes = mutableListOf<V8RemoteObject>();
|
val remotes = mutableListOf<V8RemoteObject?>();
|
||||||
for(obj in objs)
|
for(obj in objs)
|
||||||
remotes.add(createRemoteObject(obj)!!);
|
remotes.add(createRemoteObject(obj));
|
||||||
return remotes;
|
return remotes;
|
||||||
}
|
}
|
||||||
private inline fun <reified T> createRemoteObject(obj: T): V8RemoteObject? {
|
private inline fun <reified T> createRemoteObject(obj: T): V8RemoteObject? {
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ import android.view.View
|
|||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import android.widget.*
|
import android.widget.*
|
||||||
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.casting.CastProtocolType
|
import com.futo.platformplayer.casting.CastProtocolType
|
||||||
import com.futo.platformplayer.casting.StateCasting
|
import com.futo.platformplayer.casting.StateCasting
|
||||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||||
import com.futo.platformplayer.toInetAddress
|
import com.futo.platformplayer.toInetAddress
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
|
||||||
|
|
||||||
class CastingAddDialog(context: Context?) : AlertDialog(context) {
|
class CastingAddDialog(context: Context?) : AlertDialog(context) {
|
||||||
@@ -38,7 +40,13 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) {
|
|||||||
_buttonConfirm = findViewById(R.id.button_confirm);
|
_buttonConfirm = findViewById(R.id.button_confirm);
|
||||||
_buttonTutorial = findViewById(R.id.button_tutorial)
|
_buttonTutorial = findViewById(R.id.button_tutorial)
|
||||||
|
|
||||||
ArrayAdapter.createFromResource(context, R.array.casting_device_type_array, R.layout.spinner_item_simple).also { adapter ->
|
val deviceTypeArray = if (Settings.instance.casting.experimentalCasting) {
|
||||||
|
R.array.exp_casting_device_type_array
|
||||||
|
} else {
|
||||||
|
R.array.casting_device_type_array
|
||||||
|
}
|
||||||
|
|
||||||
|
ArrayAdapter.createFromResource(context, deviceTypeArray, R.layout.spinner_item_simple).also { adapter ->
|
||||||
adapter.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
|
adapter.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
|
||||||
_spinnerType.adapter = adapter;
|
_spinnerType.adapter = adapter;
|
||||||
};
|
};
|
||||||
@@ -101,12 +109,16 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) {
|
|||||||
|
|
||||||
_textError.visibility = View.GONE;
|
_textError.visibility = View.GONE;
|
||||||
val castingDeviceInfo = CastingDeviceInfo(name, castProtocolType, arrayOf(ip), port.toInt());
|
val castingDeviceInfo = CastingDeviceInfo(name, castProtocolType, arrayOf(ip), port.toInt());
|
||||||
StateCasting.instance.addRememberedDevice(castingDeviceInfo);
|
try {
|
||||||
|
StateCasting.instance.addRememberedDevice(castingDeviceInfo)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to add remembered device: $e")
|
||||||
|
}
|
||||||
performDismiss();
|
performDismiss();
|
||||||
};
|
};
|
||||||
|
|
||||||
_buttonTutorial.setOnClickListener {
|
_buttonTutorial.setOnClickListener {
|
||||||
UIDialogs.showCastingTutorialDialog(context)
|
UIDialogs.showCastingTutorialDialog(context, ownerActivity)
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -130,7 +142,7 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) {
|
|||||||
|
|
||||||
private fun performDismiss(shouldShowCastingDialog: Boolean = true) {
|
private fun performDismiss(shouldShowCastingDialog: Boolean = true) {
|
||||||
if (shouldShowCastingDialog) {
|
if (shouldShowCastingDialog) {
|
||||||
UIDialogs.showCastingDialog(context);
|
UIDialogs.showCastingDialog(context, ownerActivity);
|
||||||
}
|
}
|
||||||
|
|
||||||
dismiss();
|
dismiss();
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ class CastingHelpDialog(context: Context?) : AlertDialog(context) {
|
|||||||
|
|
||||||
findViewById<BigButton>(R.id.button_close).onClick.subscribe {
|
findViewById<BigButton>(R.id.button_close).onClick.subscribe {
|
||||||
dismiss()
|
dismiss()
|
||||||
UIDialogs.showCastingAddDialog(context)
|
UIDialogs.showCastingAddDialog(context, ownerActivity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import android.os.Bundle
|
|||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.Button
|
import android.widget.Button
|
||||||
import android.widget.ImageButton
|
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
@@ -18,7 +17,6 @@ import com.futo.platformplayer.R
|
|||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.activities.MainActivity
|
import com.futo.platformplayer.activities.MainActivity
|
||||||
import com.futo.platformplayer.casting.CastConnectionState
|
import com.futo.platformplayer.casting.CastConnectionState
|
||||||
import com.futo.platformplayer.casting.CastingDevice
|
|
||||||
import com.futo.platformplayer.casting.StateCasting
|
import com.futo.platformplayer.casting.StateCasting
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.states.StateApp
|
import com.futo.platformplayer.states.StateApp
|
||||||
@@ -83,7 +81,7 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
|
|||||||
|
|
||||||
_buttonClose.setOnClickListener { dismiss(); };
|
_buttonClose.setOnClickListener { dismiss(); };
|
||||||
_buttonAdd.setOnClickListener {
|
_buttonAdd.setOnClickListener {
|
||||||
UIDialogs.showCastingAddDialog(context);
|
UIDialogs.showCastingAddDialog(context, ownerActivity);
|
||||||
dismiss();
|
dismiss();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -108,15 +106,16 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
|
|||||||
synchronized(StateCasting.instance.devices) {
|
synchronized(StateCasting.instance.devices) {
|
||||||
_devices.addAll(StateCasting.instance.devices.values.mapNotNull { it.name })
|
_devices.addAll(StateCasting.instance.devices.values.mapNotNull { it.name })
|
||||||
}
|
}
|
||||||
|
|
||||||
_rememberedDevices.addAll(StateCasting.instance.getRememberedCastingDeviceNames())
|
_rememberedDevices.addAll(StateCasting.instance.getRememberedCastingDeviceNames())
|
||||||
|
|
||||||
updateUnifiedList()
|
updateUnifiedList()
|
||||||
|
|
||||||
StateCasting.instance.onDeviceAdded.subscribe(this) { d ->
|
StateCasting.instance.onDeviceAdded.subscribe(this) { d ->
|
||||||
val name = d.name
|
val name = d.name
|
||||||
if (name != null)
|
if (name != null) {
|
||||||
_devices.add(name)
|
_devices.add(name)
|
||||||
updateUnifiedList()
|
updateUnifiedList()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
StateCasting.instance.onDeviceChanged.subscribe(this) { d ->
|
StateCasting.instance.onDeviceChanged.subscribe(this) { d ->
|
||||||
@@ -139,9 +138,6 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_textNoDevicesFound.visibility = if (_devices.isEmpty()) View.VISIBLE else View.GONE;
|
|
||||||
_recyclerDevices.visibility = if (_devices.isNotEmpty()) View.VISIBLE else View.GONE;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun dismiss() {
|
override fun dismiss() {
|
||||||
|
|||||||
@@ -12,12 +12,11 @@ import android.widget.ImageView
|
|||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
|
import com.futo.platformplayer.Settings
|
||||||
import com.futo.platformplayer.activities.MainActivity
|
import com.futo.platformplayer.activities.MainActivity
|
||||||
import com.futo.platformplayer.casting.AirPlayCastingDevice
|
|
||||||
import com.futo.platformplayer.casting.CastConnectionState
|
import com.futo.platformplayer.casting.CastConnectionState
|
||||||
|
import com.futo.platformplayer.casting.CastProtocolType
|
||||||
import com.futo.platformplayer.casting.CastingDevice
|
import com.futo.platformplayer.casting.CastingDevice
|
||||||
import com.futo.platformplayer.casting.ChromecastCastingDevice
|
|
||||||
import com.futo.platformplayer.casting.FCastCastingDevice
|
|
||||||
import com.futo.platformplayer.casting.StateCasting
|
import com.futo.platformplayer.casting.StateCasting
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment
|
import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
@@ -69,18 +68,18 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
|
|||||||
|
|
||||||
_buttonPlay = findViewById(R.id.button_play);
|
_buttonPlay = findViewById(R.id.button_play);
|
||||||
_buttonPlay.setOnClickListener {
|
_buttonPlay.setOnClickListener {
|
||||||
StateCasting.instance.activeDevice?.resumeVideo()
|
StateCasting.instance.resumeVideo()
|
||||||
}
|
}
|
||||||
|
|
||||||
_buttonPause = findViewById(R.id.button_pause);
|
_buttonPause = findViewById(R.id.button_pause);
|
||||||
_buttonPause.setOnClickListener {
|
_buttonPause.setOnClickListener {
|
||||||
StateCasting.instance.activeDevice?.pauseVideo()
|
StateCasting.instance.pauseVideo()
|
||||||
}
|
}
|
||||||
|
|
||||||
_buttonStop = findViewById(R.id.button_stop);
|
_buttonStop = findViewById(R.id.button_stop);
|
||||||
_buttonStop.setOnClickListener {
|
_buttonStop.setOnClickListener {
|
||||||
(ownerActivity as MainActivity?)?.getFragment<VideoDetailFragment>()?.closeVideoDetails()
|
(ownerActivity as MainActivity?)?.getFragment<VideoDetailFragment>()?.closeVideoDetails()
|
||||||
StateCasting.instance.activeDevice?.stopVideo()
|
StateCasting.instance.stopVideo()
|
||||||
}
|
}
|
||||||
|
|
||||||
_buttonNext = findViewById(R.id.button_next);
|
_buttonNext = findViewById(R.id.button_next);
|
||||||
@@ -90,7 +89,11 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
|
|||||||
|
|
||||||
_buttonClose.setOnClickListener { dismiss(); };
|
_buttonClose.setOnClickListener { dismiss(); };
|
||||||
_buttonDisconnect.setOnClickListener {
|
_buttonDisconnect.setOnClickListener {
|
||||||
StateCasting.instance.activeDevice?.stopCasting();
|
try {
|
||||||
|
StateCasting.instance.activeDevice?.disconnect()
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "Active device failed to disconnect: $e")
|
||||||
|
}
|
||||||
dismiss();
|
dismiss();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -99,12 +102,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
|
|||||||
return@OnChangeListener
|
return@OnChangeListener
|
||||||
}
|
}
|
||||||
|
|
||||||
val activeDevice = StateCasting.instance.activeDevice ?: return@OnChangeListener;
|
StateCasting.instance.videoSeekTo(value.toDouble())
|
||||||
try {
|
|
||||||
activeDevice.seekVideo(value.toDouble());
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.e(TAG, "Failed to change volume.", e);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
//TODO: Check if volume slider is properly hidden in all cases
|
//TODO: Check if volume slider is properly hidden in all cases
|
||||||
@@ -113,14 +111,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
|
|||||||
return@OnChangeListener
|
return@OnChangeListener
|
||||||
}
|
}
|
||||||
|
|
||||||
val activeDevice = StateCasting.instance.activeDevice ?: return@OnChangeListener;
|
StateCasting.instance.changeVolume(value.toDouble())
|
||||||
if (activeDevice.canSetVolume) {
|
|
||||||
try {
|
|
||||||
activeDevice.changeVolume(value.toDouble());
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.e(TAG, "Failed to change volume.", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -172,15 +163,25 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
|
|||||||
private fun updateDevice() {
|
private fun updateDevice() {
|
||||||
val d = StateCasting.instance.activeDevice ?: return;
|
val d = StateCasting.instance.activeDevice ?: return;
|
||||||
|
|
||||||
if (d is ChromecastCastingDevice) {
|
when (d.protocolType) {
|
||||||
_imageDevice.setImageResource(R.drawable.ic_chromecast);
|
CastProtocolType.CHROMECAST -> {
|
||||||
_textType.text = "Chromecast";
|
_imageDevice.setImageResource(R.drawable.ic_chromecast);
|
||||||
} else if (d is AirPlayCastingDevice) {
|
_textType.text = "Chromecast";
|
||||||
_imageDevice.setImageResource(R.drawable.ic_airplay);
|
}
|
||||||
_textType.text = "AirPlay";
|
CastProtocolType.AIRPLAY -> {
|
||||||
} else if (d is FCastCastingDevice) {
|
_imageDevice.setImageResource(R.drawable.ic_airplay);
|
||||||
_imageDevice.setImageResource(R.drawable.ic_fc);
|
_textType.text = "AirPlay";
|
||||||
_textType.text = "FastCast";
|
}
|
||||||
|
CastProtocolType.FCAST -> {
|
||||||
|
_imageDevice.setImageResource(
|
||||||
|
if (Settings.instance.casting.experimentalCasting) {
|
||||||
|
R.drawable.ic_exp_fc
|
||||||
|
} else {
|
||||||
|
R.drawable.ic_fc
|
||||||
|
}
|
||||||
|
)
|
||||||
|
_textType.text = "FCast";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_textName.text = d.name;
|
_textName.text = d.name;
|
||||||
@@ -192,7 +193,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
|
|||||||
_sliderPosition.value = d.time.toFloat().coerceAtLeast(0.0f).coerceAtMost(dur)
|
_sliderPosition.value = d.time.toFloat().coerceAtLeast(0.0f).coerceAtMost(dur)
|
||||||
_sliderPosition.valueTo = dur
|
_sliderPosition.valueTo = dur
|
||||||
|
|
||||||
if (d.canSetVolume) {
|
if (d.canSetVolume()) {
|
||||||
_layoutVolumeAdjustable.visibility = View.VISIBLE;
|
_layoutVolumeAdjustable.visibility = View.VISIBLE;
|
||||||
_layoutVolumeFixed.visibility = View.GONE;
|
_layoutVolumeFixed.visibility = View.GONE;
|
||||||
} else {
|
} else {
|
||||||
@@ -214,8 +215,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
|
|||||||
CastConnectionState.CONNECTED -> {
|
CastConnectionState.CONNECTED -> {
|
||||||
enableControls(interactiveControls)
|
enableControls(interactiveControls)
|
||||||
}
|
}
|
||||||
CastConnectionState.CONNECTING,
|
CastConnectionState.CONNECTING, CastConnectionState.DISCONNECTED -> {
|
||||||
CastConnectionState.DISCONNECTED -> {
|
|
||||||
disableControls(interactiveControls)
|
disableControls(interactiveControls)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -303,9 +303,10 @@ class VideoDownload {
|
|||||||
try {
|
try {
|
||||||
val playlistResponse = client.get(source.url)
|
val playlistResponse = client.get(source.url)
|
||||||
if (playlistResponse.isOk) {
|
if (playlistResponse.isOk) {
|
||||||
|
val resolvedPlaylistUrl = playlistResponse.url
|
||||||
val playlistContent = playlistResponse.body?.string()
|
val playlistContent = playlistResponse.body?.string()
|
||||||
if (playlistContent != null) {
|
if (playlistContent != null) {
|
||||||
videoSources.addAll(HLS.parseAndGetVideoSources(source, playlistContent, source.url))
|
videoSources.addAll(HLS.parseAndGetVideoSources(source, playlistContent, resolvedPlaylistUrl))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
@@ -351,9 +352,10 @@ class VideoDownload {
|
|||||||
try {
|
try {
|
||||||
val playlistResponse = client.get(source.url)
|
val playlistResponse = client.get(source.url)
|
||||||
if (playlistResponse.isOk) {
|
if (playlistResponse.isOk) {
|
||||||
|
val resolvedPlaylistUrl = playlistResponse.url
|
||||||
val playlistContent = playlistResponse.body?.string()
|
val playlistContent = playlistResponse.body?.string()
|
||||||
if (playlistContent != null) {
|
if (playlistContent != null) {
|
||||||
audioSources.addAll(HLS.parseAndGetAudioSources(source, playlistContent, source.url))
|
audioSources.addAll(HLS.parseAndGetAudioSources(source, playlistContent, resolvedPlaylistUrl))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
@@ -717,7 +719,7 @@ class VideoDownload {
|
|||||||
|
|
||||||
Logger.i(TAG, "Download $name Dash, CueCount: " + foundCues.count().toString());
|
Logger.i(TAG, "Download $name Dash, CueCount: " + foundCues.count().toString());
|
||||||
|
|
||||||
var written = 0;
|
var written: Long = 0;
|
||||||
var indexCounter = 0;
|
var indexCounter = 0;
|
||||||
onProgress(foundCues.count().toLong(), 0, 0);
|
onProgress(foundCues.count().toLong(), 0, 0);
|
||||||
for(cue in foundCues) {
|
for(cue in foundCues) {
|
||||||
@@ -742,7 +744,7 @@ class VideoDownload {
|
|||||||
|
|
||||||
indexCounter++;
|
indexCounter++;
|
||||||
}
|
}
|
||||||
sourceLength = written.toLong();
|
sourceLength = written;
|
||||||
|
|
||||||
Logger.i(TAG, "$name downloadSource Finished");
|
Logger.i(TAG, "$name downloadSource Finished");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,10 @@ class VideoLocal: IPlatformVideoDetails, IStoreItem {
|
|||||||
|
|
||||||
override val isShort: Boolean get() = videoSerialized.isShort;
|
override val isShort: Boolean get() = videoSerialized.isShort;
|
||||||
|
|
||||||
|
override var playbackTime: Long = -1;
|
||||||
|
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
|
||||||
|
override var playbackDate: OffsetDateTime? = null;
|
||||||
|
|
||||||
//TODO: Offline subtitles
|
//TODO: Offline subtitles
|
||||||
override val subtitles: List<ISubtitleSource> = listOf();
|
override val subtitles: List<ISubtitleSource> = listOf();
|
||||||
|
|
||||||
|
|||||||
@@ -242,10 +242,12 @@ class V8Plugin {
|
|||||||
}
|
}
|
||||||
fun <T> busy(handle: ()->T): T {
|
fun <T> busy(handle: ()->T): T {
|
||||||
_busyLock.lock();
|
_busyLock.lock();
|
||||||
|
//Logger.i(TAG, "Busy Enter [" + _busyLock.holdCount + "]" + Thread.currentThread().stackTrace.drop(3)?.firstOrNull()?.toString() + ", " + Thread.currentThread().stackTrace.drop(4)?.firstOrNull()?.toString() + ", " + Thread.currentThread().stackTrace.drop(5)?.firstOrNull()?.toString())
|
||||||
try {
|
try {
|
||||||
return handle();
|
return handle();
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
|
//Logger.i(TAG, "Busy Leave [" + _busyLock.holdCount + "]" + Thread.currentThread().stackTrace.drop(3)?.firstOrNull()?.toString() + ", " + Thread.currentThread().stackTrace.drop(4)?.firstOrNull()?.toString()+ ", " + Thread.currentThread().stackTrace.drop(5)?.firstOrNull()?.toString())
|
||||||
_busyLock.unlock();
|
_busyLock.unlock();
|
||||||
}
|
}
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ class V8RemoteObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fun List<V8RemoteObject>.serialize() : String {
|
fun List<V8RemoteObject?>.serialize() : String {
|
||||||
return _gson.toJson(this);
|
return _gson.toJson(this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -194,7 +194,11 @@ class PackageBridge : V8Package {
|
|||||||
|
|
||||||
val stackTrace = Thread.currentThread().stackTrace;
|
val stackTrace = Thread.currentThread().stackTrace;
|
||||||
val callerMethod = stackTrace.findLast {
|
val callerMethod = stackTrace.findLast {
|
||||||
it.className == JSClient::class.java.name
|
it.className == JSClient::class.java.name &&
|
||||||
|
it.methodName != "isBusy" &&
|
||||||
|
it.methodName != "busy" &&
|
||||||
|
it.methodName != "getCopy" &&
|
||||||
|
it.methodName != "isBusyWith"
|
||||||
}?.methodName ?: "";
|
}?.methodName ?: "";
|
||||||
val session = StateApp.instance.sessionId;
|
val session = StateApp.instance.sessionId;
|
||||||
val pluginId = _plugin.config.id;
|
val pluginId = _plugin.config.id;
|
||||||
|
|||||||
+7
-3
@@ -25,6 +25,7 @@ import com.futo.platformplayer.api.media.structures.IReplacerPager
|
|||||||
import com.futo.platformplayer.api.media.structures.MultiPager
|
import com.futo.platformplayer.api.media.structures.MultiPager
|
||||||
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.constructs.Event3
|
||||||
import com.futo.platformplayer.constructs.TaskHandler
|
import com.futo.platformplayer.constructs.TaskHandler
|
||||||
import com.futo.platformplayer.dp
|
import com.futo.platformplayer.dp
|
||||||
import com.futo.platformplayer.engine.exceptions.PluginException
|
import com.futo.platformplayer.engine.exceptions.PluginException
|
||||||
@@ -61,7 +62,7 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
|
|||||||
private var _query: String? = null
|
private var _query: String? = null
|
||||||
private var _searchView: SearchView? = null
|
private var _searchView: SearchView? = null
|
||||||
|
|
||||||
val onContentClicked = Event2<IPlatformContent, Long>();
|
val onContentClicked = Event3<IPlatformContent, Long, Pair<IPager<IPlatformContent>, ArrayList<IPlatformContent>>?>();
|
||||||
val onContentUrlClicked = Event2<String, ContentType>();
|
val onContentUrlClicked = Event2<String, ContentType>();
|
||||||
val onUrlClicked = Event1<String>();
|
val onUrlClicked = Event1<String>();
|
||||||
val onChannelClicked = Event1<PlatformAuthorLink>();
|
val onChannelClicked = Event1<PlatformAuthorLink>();
|
||||||
@@ -208,10 +209,13 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
|
|||||||
_searchView = searchView
|
_searchView = searchView
|
||||||
updateSearchViewVisibility()
|
updateSearchViewVisibility()
|
||||||
|
|
||||||
_adapterResults = PreviewContentListAdapter(view.context, FeedStyle.THUMBNAIL, _results, null, Settings.instance.channel.progressBar, viewsToPrepend = arrayListOf(searchView)).apply {
|
_adapterResults = PreviewContentListAdapter(lifecycleScope, view.context, FeedStyle.THUMBNAIL, _results, null, Settings.instance.channel.progressBar, viewsToPrepend = arrayListOf(searchView)).apply {
|
||||||
this.onContentUrlClicked.subscribe(this@ChannelContentsFragment.onContentUrlClicked::emit);
|
this.onContentUrlClicked.subscribe(this@ChannelContentsFragment.onContentUrlClicked::emit);
|
||||||
this.onUrlClicked.subscribe(this@ChannelContentsFragment.onUrlClicked::emit);
|
this.onUrlClicked.subscribe(this@ChannelContentsFragment.onUrlClicked::emit);
|
||||||
this.onContentClicked.subscribe(this@ChannelContentsFragment.onContentClicked::emit);
|
this.onContentClicked.subscribe { content, num ->
|
||||||
|
val results = ArrayList(_results)
|
||||||
|
this@ChannelContentsFragment.onContentClicked.emit(content, num, Pair(_pager!!, results))
|
||||||
|
}
|
||||||
this.onChannelClicked.subscribe(this@ChannelContentsFragment.onChannelClicked::emit);
|
this.onChannelClicked.subscribe(this@ChannelContentsFragment.onChannelClicked::emit);
|
||||||
this.onAddToClicked.subscribe(this@ChannelContentsFragment.onAddToClicked::emit);
|
this.onAddToClicked.subscribe(this@ChannelContentsFragment.onAddToClicked::emit);
|
||||||
this.onAddToQueueClicked.subscribe(this@ChannelContentsFragment.onAddToQueueClicked::emit);
|
this.onAddToQueueClicked.subscribe(this@ChannelContentsFragment.onAddToQueueClicked::emit);
|
||||||
|
|||||||
+1
-1
@@ -148,7 +148,7 @@ class ChannelPlaylistsFragment : Fragment(), IChannelTabFragment {
|
|||||||
_recyclerResults = view.findViewById(R.id.recycler_videos)
|
_recyclerResults = view.findViewById(R.id.recycler_videos)
|
||||||
|
|
||||||
_adapterResults = PreviewContentListAdapter(
|
_adapterResults = PreviewContentListAdapter(
|
||||||
view.context, FeedStyle.THUMBNAIL, _results, null, Settings.instance.channel.progressBar
|
lifecycleScope, view.context, FeedStyle.THUMBNAIL, _results, null, Settings.instance.channel.progressBar
|
||||||
).apply {
|
).apply {
|
||||||
this.onContentUrlClicked.subscribe(this@ChannelPlaylistsFragment.onContentUrlClicked::emit)
|
this.onContentUrlClicked.subscribe(this@ChannelPlaylistsFragment.onContentUrlClicked::emit)
|
||||||
this.onUrlClicked.subscribe(this@ChannelPlaylistsFragment.onUrlClicked::emit)
|
this.onUrlClicked.subscribe(this@ChannelPlaylistsFragment.onUrlClicked::emit)
|
||||||
|
|||||||
+4
-1
@@ -15,6 +15,7 @@ import android.view.ViewGroup
|
|||||||
import android.widget.*
|
import android.widget.*
|
||||||
import androidx.core.animation.doOnEnd
|
import androidx.core.animation.doOnEnd
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
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.UIDialogs
|
||||||
@@ -375,6 +376,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
|||||||
|
|
||||||
fun newInstance() = MenuBottomBarFragment().apply { }
|
fun newInstance() = MenuBottomBarFragment().apply { }
|
||||||
|
|
||||||
|
@UnstableApi
|
||||||
//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 }, {
|
ButtonDefinition(0, R.drawable.ic_home, R.drawable.ic_home_filled, R.string.home, canToggle = true, { it.currentMain is HomeFragment }, {
|
||||||
@@ -390,13 +392,14 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
|||||||
ButtonDefinition(2, R.drawable.ic_creators, R.drawable.ic_creators_filled, R.string.creators, canToggle = false, { it.currentMain is CreatorsFragment }, { it.navigate<CreatorsFragment>(withHistory = false) }),
|
ButtonDefinition(2, R.drawable.ic_creators, R.drawable.ic_creators_filled, R.string.creators, canToggle = false, { it.currentMain is CreatorsFragment }, { it.navigate<CreatorsFragment>(withHistory = false) }),
|
||||||
ButtonDefinition(3, R.drawable.ic_sources, R.drawable.ic_sources_filled, R.string.sources, canToggle = false, { it.currentMain is SourcesFragment }, { it.navigate<SourcesFragment>(withHistory = false) }),
|
ButtonDefinition(3, R.drawable.ic_sources, R.drawable.ic_sources_filled, R.string.sources, canToggle = false, { it.currentMain is SourcesFragment }, { it.navigate<SourcesFragment>(withHistory = false) }),
|
||||||
ButtonDefinition(4, R.drawable.ic_playlist, R.drawable.ic_playlist_filled, R.string.playlists, canToggle = false, { it.currentMain is PlaylistsFragment }, { it.navigate<PlaylistsFragment>(withHistory = false) }),
|
ButtonDefinition(4, R.drawable.ic_playlist, R.drawable.ic_playlist_filled, R.string.playlists, canToggle = false, { it.currentMain is PlaylistsFragment }, { it.navigate<PlaylistsFragment>(withHistory = false) }),
|
||||||
|
ButtonDefinition(11, R.drawable.ic_smart_display, R.drawable.ic_smart_display_filled, R.string.shorts, canToggle = true, { it.currentMain is ShortsFragment && !(it.currentMain as ShortsFragment).isChannelShortsMode }, { it.navigate<ShortsFragment>(withHistory = false) }),
|
||||||
ButtonDefinition(5, R.drawable.ic_history, R.drawable.ic_history, R.string.history, canToggle = false, { it.currentMain is HistoryFragment }, { it.navigate<HistoryFragment>(withHistory = false) }),
|
ButtonDefinition(5, R.drawable.ic_history, R.drawable.ic_history, R.string.history, canToggle = false, { it.currentMain is HistoryFragment }, { it.navigate<HistoryFragment>(withHistory = false) }),
|
||||||
ButtonDefinition(6, R.drawable.ic_download, R.drawable.ic_download, R.string.downloads, canToggle = false, { it.currentMain is DownloadsFragment }, { it.navigate<DownloadsFragment>(withHistory = false) }),
|
ButtonDefinition(6, R.drawable.ic_download, R.drawable.ic_download, R.string.downloads, canToggle = false, { it.currentMain is DownloadsFragment }, { it.navigate<DownloadsFragment>(withHistory = false) }),
|
||||||
ButtonDefinition(8, R.drawable.ic_chat, R.drawable.ic_chat_filled, R.string.comments, canToggle = true, { it.currentMain is CommentsFragment }, { it.navigate<CommentsFragment>(withHistory = false) }),
|
ButtonDefinition(8, R.drawable.ic_chat, R.drawable.ic_chat_filled, R.string.comments, canToggle = true, { it.currentMain is CommentsFragment }, { it.navigate<CommentsFragment>(withHistory = false) }),
|
||||||
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>(withHistory = false) }),
|
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>(withHistory = false) }),
|
||||||
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>(withHistory = false) }),
|
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>(withHistory = false) }),
|
||||||
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()");
|
||||||
it.requireFragment<VideoDetailFragment>().preventPictureInPicture();
|
it.requireFragment<VideoDetailFragment>().preventPictureInPicture();
|
||||||
val intent = Intent(c, SettingsActivity::class.java);
|
val intent = Intent(c, SettingsActivity::class.java);
|
||||||
|
|||||||
+8
@@ -211,6 +211,14 @@ class ChannelFragment : MainFragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
adapter.onShortClicked.subscribe { v, _, pagerPair ->
|
||||||
|
when (v) {
|
||||||
|
is IPlatformVideo -> {
|
||||||
|
StatePlayer.instance.clearQueue()
|
||||||
|
fragment.navigate<ShortsFragment>(Triple(v, pagerPair!!.first, pagerPair.second))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
adapter.onAddToClicked.subscribe { content ->
|
adapter.onAddToClicked.subscribe { content ->
|
||||||
_overlayContainer.let {
|
_overlayContainer.let {
|
||||||
if (content is IPlatformVideo) _slideUpOverlay =
|
if (content is IPlatformVideo) _slideUpOverlay =
|
||||||
|
|||||||
+15
-3
@@ -4,6 +4,7 @@ import android.content.Context
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.recyclerview.widget.GridLayoutManager
|
import androidx.recyclerview.widget.GridLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
@@ -19,6 +20,7 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
|||||||
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.JSWeb
|
import com.futo.platformplayer.api.media.platforms.js.models.JSWeb
|
||||||
import com.futo.platformplayer.api.media.structures.IPager
|
import com.futo.platformplayer.api.media.structures.IPager
|
||||||
|
import com.futo.platformplayer.fragment.mainactivity.main.ShortView.Companion
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.states.StateMeta
|
import com.futo.platformplayer.states.StateMeta
|
||||||
import com.futo.platformplayer.states.StatePlayer
|
import com.futo.platformplayer.states.StatePlayer
|
||||||
@@ -34,6 +36,9 @@ import com.futo.platformplayer.views.adapters.feedtypes.PreviewVideoViewHolder
|
|||||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
|
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
|
||||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
|
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
|
||||||
import com.futo.platformplayer.withTimestamp
|
import com.futo.platformplayer.withTimestamp
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlin.math.floor
|
import kotlin.math.floor
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
|
|
||||||
@@ -59,7 +64,7 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
|
|||||||
player.modifyState("ThumbnailPlayer") { state -> state.muted = true };
|
player.modifyState("ThumbnailPlayer") { state -> state.muted = true };
|
||||||
_exoPlayer = player;
|
_exoPlayer = player;
|
||||||
|
|
||||||
return PreviewContentListAdapter(context, feedStyle, dataset, player, _previewsEnabled, arrayListOf(), arrayListOf(), shouldShowTimeBar).apply {
|
return PreviewContentListAdapter(fragment.lifecycleScope, context, feedStyle, dataset, player, _previewsEnabled, arrayListOf(), arrayListOf(), shouldShowTimeBar).apply {
|
||||||
attachAdapterEvents(this);
|
attachAdapterEvents(this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -246,8 +251,15 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
|
|||||||
}
|
}
|
||||||
|
|
||||||
//TODO: Is this still necessary?
|
//TODO: Is this still necessary?
|
||||||
if(viewHolder.childViewHolder is ContentPreviewViewHolder)
|
if(viewHolder.childViewHolder is ContentPreviewViewHolder) {
|
||||||
(recyclerData.adapter as PreviewContentListAdapter?)?.preview(viewHolder.childViewHolder)
|
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||||
|
try {
|
||||||
|
(recyclerData.adapter as PreviewContentListAdapter?)?.preview(viewHolder.childViewHolder)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "playPreview failed", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun stopVideo() {
|
private fun stopVideo() {
|
||||||
|
|||||||
@@ -279,6 +279,14 @@ class HomeFragment : MainFragment() {
|
|||||||
else {
|
else {
|
||||||
view.setToggle(!active);
|
view.setToggle(!active);
|
||||||
}
|
}
|
||||||
|
}, { view, views, enabled ->
|
||||||
|
val toDisable = views.filter { it != view && it.tag == "plugins" };
|
||||||
|
if(!view.isActive)
|
||||||
|
view.handleClick();
|
||||||
|
for(tag in toDisable) {
|
||||||
|
if(tag.isActive)
|
||||||
|
tag.handleClick();
|
||||||
|
}
|
||||||
}).withTag("plugins")
|
}).withTag("plugins")
|
||||||
})
|
})
|
||||||
else listOf())
|
else listOf())
|
||||||
|
|||||||
@@ -0,0 +1,883 @@
|
|||||||
|
package com.futo.platformplayer.fragment.mainactivity.main
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.graphics.drawable.Animatable
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.animation.AccelerateInterpolator
|
||||||
|
import android.view.animation.OvershootInterpolator
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.media3.common.C
|
||||||
|
import androidx.media3.common.Format
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import com.futo.platformplayer.R
|
||||||
|
import com.futo.platformplayer.Settings
|
||||||
|
import com.futo.platformplayer.UIDialogs
|
||||||
|
import com.futo.platformplayer.api.media.exceptions.ContentNotAvailableYetException
|
||||||
|
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
|
||||||
|
import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
|
||||||
|
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
|
||||||
|
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
||||||
|
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
|
||||||
|
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudioSource
|
||||||
|
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.LocalAudioSource
|
||||||
|
import com.futo.platformplayer.api.media.models.streams.sources.LocalSubtitleSource
|
||||||
|
import com.futo.platformplayer.api.media.models.streams.sources.LocalVideoSource
|
||||||
|
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
|
||||||
|
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||||
|
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
||||||
|
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||||
|
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.constructs.Event0
|
||||||
|
import com.futo.platformplayer.constructs.Event1
|
||||||
|
import com.futo.platformplayer.constructs.Event3
|
||||||
|
import com.futo.platformplayer.constructs.TaskHandler
|
||||||
|
import com.futo.platformplayer.downloads.VideoLocal
|
||||||
|
import com.futo.platformplayer.engine.exceptions.ScriptAgeException
|
||||||
|
import com.futo.platformplayer.engine.exceptions.ScriptException
|
||||||
|
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
|
||||||
|
import com.futo.platformplayer.engine.exceptions.ScriptLoginRequiredException
|
||||||
|
import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException
|
||||||
|
import com.futo.platformplayer.exceptions.UnsupportedCastException
|
||||||
|
import com.futo.platformplayer.fragment.mainactivity.special.CommentsModalBottomSheet
|
||||||
|
import com.futo.platformplayer.helpers.VideoHelper
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.states.StateApp
|
||||||
|
import com.futo.platformplayer.states.StatePlatform
|
||||||
|
import com.futo.platformplayer.states.StatePlugins
|
||||||
|
import com.futo.platformplayer.states.StatePolycentric
|
||||||
|
import com.futo.platformplayer.toHumanBitrate
|
||||||
|
import com.futo.platformplayer.toHumanBytesSize
|
||||||
|
import com.futo.platformplayer.views.buttons.ShortsButton
|
||||||
|
import com.futo.platformplayer.views.others.CreatorThumbnail
|
||||||
|
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuButtonList
|
||||||
|
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuGroup
|
||||||
|
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
|
||||||
|
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
|
||||||
|
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuTitle
|
||||||
|
import com.futo.platformplayer.views.pills.OnLikeDislikeUpdatedArgs
|
||||||
|
import com.futo.platformplayer.views.platform.PlatformIndicator
|
||||||
|
import com.futo.platformplayer.views.video.FutoShortPlayer
|
||||||
|
import com.futo.platformplayer.views.video.FutoVideoPlayerBase
|
||||||
|
import com.futo.platformplayer.views.video.FutoVideoPlayerBase.Companion.PREFERED_AUDIO_CONTAINERS
|
||||||
|
import com.futo.platformplayer.views.video.FutoVideoPlayerBase.Companion.PREFERED_VIDEO_CONTAINERS
|
||||||
|
import com.futo.polycentric.core.ApiMethods
|
||||||
|
import com.futo.polycentric.core.ContentType
|
||||||
|
import com.futo.polycentric.core.Models
|
||||||
|
import com.futo.polycentric.core.Opinion
|
||||||
|
import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions
|
||||||
|
import com.google.android.material.button.MaterialButton
|
||||||
|
//import com.google.android.material.button.MaterialButton
|
||||||
|
import com.google.protobuf.ByteString
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import userpackage.Protocol
|
||||||
|
|
||||||
|
@UnstableApi
|
||||||
|
class ShortView : FrameLayout {
|
||||||
|
private lateinit var fragment: MainFragment
|
||||||
|
private val player: FutoShortPlayer
|
||||||
|
|
||||||
|
private val channelInfo: LinearLayout
|
||||||
|
private val creatorThumbnail: CreatorThumbnail
|
||||||
|
private val channelName: TextView
|
||||||
|
private val videoTitle: TextView
|
||||||
|
private val videoSubtitle: TextView
|
||||||
|
private val platformIndicator: PlatformIndicator
|
||||||
|
|
||||||
|
//TODO: Replace with non-material button
|
||||||
|
private val backButton: MaterialButton
|
||||||
|
private val backButtonContainer: ConstraintLayout
|
||||||
|
|
||||||
|
private val likeButton: ShortsButton
|
||||||
|
//private val likeCount: TextView
|
||||||
|
private val dislikeButton: ShortsButton
|
||||||
|
//private val dislikeCount: TextView
|
||||||
|
|
||||||
|
private val commentsButton: ShortsButton
|
||||||
|
private val shareButton: ShortsButton
|
||||||
|
private val refreshButton: ShortsButton
|
||||||
|
private val qualityButton: ShortsButton
|
||||||
|
|
||||||
|
private val playPauseOverlay: FrameLayout
|
||||||
|
private val playPauseIcon: ImageView
|
||||||
|
|
||||||
|
private val overlayLoading: FrameLayout
|
||||||
|
private val overlayLoadingSpinner: ImageView
|
||||||
|
private lateinit var overlayQualityContainer: FrameLayout
|
||||||
|
|
||||||
|
private var overlayQualitySelector: SlideUpMenuOverlay? = null
|
||||||
|
|
||||||
|
private var video: IPlatformVideo? = null
|
||||||
|
set(value) {
|
||||||
|
field = value
|
||||||
|
onVideoUpdated.emit(value)
|
||||||
|
}
|
||||||
|
private var videoDetails: IPlatformVideoDetails? = null
|
||||||
|
|
||||||
|
private var playWhenReady = false
|
||||||
|
|
||||||
|
private var _lastVideoSource: IVideoSource? = null
|
||||||
|
private var _lastAudioSource: IAudioSource? = null
|
||||||
|
private var _lastSubtitleSource: ISubtitleSource? = null
|
||||||
|
|
||||||
|
private var loadVideoTask: TaskHandler<String, IPlatformVideoDetails>? = null
|
||||||
|
private var loadLikesTask: TaskHandler<IPlatformVideo, Pair<Protocol.Reference, Protocol.QueryReferencesResponse>>? =
|
||||||
|
null
|
||||||
|
|
||||||
|
val onResetTriggered = Event0()
|
||||||
|
private val onPlayingToggled = Event1<Boolean>()
|
||||||
|
private val onLikesLoaded = Event3<RatingLikeDislikes, Boolean, Boolean>()
|
||||||
|
private val onLikeDislikeUpdated = Event1<OnLikeDislikeUpdatedArgs>()
|
||||||
|
private val onVideoUpdated = Event1<IPlatformVideo?>()
|
||||||
|
|
||||||
|
//TODO: Replace with non-material UI? Only true dependency on Material left
|
||||||
|
private val bottomSheet: CommentsModalBottomSheet = CommentsModalBottomSheet()
|
||||||
|
|
||||||
|
var likes: Long = 0
|
||||||
|
set(value) {
|
||||||
|
field = value
|
||||||
|
likeButton.withPrimaryText(value.toString());
|
||||||
|
//likeCount.text = value.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
var dislikes: Long = 0
|
||||||
|
set(value) {
|
||||||
|
field = value
|
||||||
|
dislikeButton.withPrimaryText(value.toString());
|
||||||
|
//dislikeCount.text = value.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(inflater: LayoutInflater, fragment: MainFragment, overlayQualityContainer: FrameLayout) : this(inflater.context) {
|
||||||
|
this.overlayQualityContainer = overlayQualityContainer
|
||||||
|
|
||||||
|
layoutParams = LayoutParams(
|
||||||
|
LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT
|
||||||
|
)
|
||||||
|
|
||||||
|
this.fragment = fragment
|
||||||
|
bottomSheet.mainFragment = fragment
|
||||||
|
}
|
||||||
|
|
||||||
|
// Required constructor for XML inflation
|
||||||
|
constructor(context: Context) : this(context, null, null)
|
||||||
|
|
||||||
|
// Required constructor for XML inflation with attributes
|
||||||
|
constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, null)
|
||||||
|
|
||||||
|
// Required constructor for XML inflation with attributes and style
|
||||||
|
constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int? = null) : super(
|
||||||
|
context, attrs, defStyleAttr ?: 0
|
||||||
|
) {
|
||||||
|
// Inflate the layout once here
|
||||||
|
inflate(context, R.layout.view_short, this)
|
||||||
|
|
||||||
|
// Initialize all val properties using findViewById
|
||||||
|
player = findViewById(R.id.short_player)
|
||||||
|
channelInfo = findViewById(R.id.channel_info)
|
||||||
|
creatorThumbnail = findViewById(R.id.creator_thumbnail)
|
||||||
|
channelName = findViewById(R.id.channel_name)
|
||||||
|
videoTitle = findViewById(R.id.video_title)
|
||||||
|
videoSubtitle = findViewById(R.id.video_subtitle)
|
||||||
|
platformIndicator = findViewById(R.id.short_platform_indicator)
|
||||||
|
backButton = findViewById(R.id.back_button)
|
||||||
|
backButtonContainer = findViewById(R.id.back_button_container)
|
||||||
|
likeButton = findViewById(R.id.like_button)
|
||||||
|
//likeCount = findViewById(R.id.like_count)
|
||||||
|
dislikeButton = findViewById(R.id.dislike_button)
|
||||||
|
//dislikeCount = findViewById(R.id.dislike_count)
|
||||||
|
commentsButton = findViewById(R.id.comments_button)
|
||||||
|
shareButton = findViewById(R.id.share_button)
|
||||||
|
refreshButton = findViewById(R.id.refresh_button)
|
||||||
|
qualityButton = findViewById(R.id.quality_button)
|
||||||
|
playPauseOverlay = findViewById(R.id.play_pause_overlay)
|
||||||
|
playPauseIcon = findViewById(R.id.play_pause_icon)
|
||||||
|
overlayLoading = findViewById(R.id.short_view_loading_overlay)
|
||||||
|
overlayLoadingSpinner = findViewById(R.id.short_view_loader)
|
||||||
|
|
||||||
|
player.setOnClickListener {
|
||||||
|
if (player.activelyPlaying) {
|
||||||
|
player.pause()
|
||||||
|
onPlayingToggled.emit(false)
|
||||||
|
} else {
|
||||||
|
player.play()
|
||||||
|
onPlayingToggled.emit(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onPlayingToggled.subscribe { playing ->
|
||||||
|
if (playing) {
|
||||||
|
playPauseIcon.setImageResource(R.drawable.ic_play)
|
||||||
|
playPauseIcon.contentDescription = context.getString(R.string.play)
|
||||||
|
} else {
|
||||||
|
playPauseIcon.setImageResource(R.drawable.ic_pause)
|
||||||
|
playPauseIcon.contentDescription = context.getString(R.string.pause)
|
||||||
|
}
|
||||||
|
showPlayPauseIcon()
|
||||||
|
}
|
||||||
|
|
||||||
|
onVideoUpdated.subscribe {
|
||||||
|
Logger.i(TAG, "Shorts videoUpdated [${it?.name}] (isDetail: ${it is IPlatformVideoDetails}, thumbnail: ${it?.author?.thumbnail})");
|
||||||
|
videoTitle.text = it?.name
|
||||||
|
videoSubtitle.text = if(it is IPlatformVideoDetails) it?.description; else "";
|
||||||
|
platformIndicator.setPlatformFromClientID(it?.id?.pluginId)
|
||||||
|
creatorThumbnail.setThumbnail(it?.author?.thumbnail, true)
|
||||||
|
channelName.text = it?.author?.name
|
||||||
|
}
|
||||||
|
|
||||||
|
backButton.setOnClickListener {
|
||||||
|
fragment.closeSegment()
|
||||||
|
}
|
||||||
|
|
||||||
|
channelInfo.setOnClickListener {
|
||||||
|
fragment.navigate<ChannelFragment>(video?.author)
|
||||||
|
}
|
||||||
|
|
||||||
|
videoTitle.setOnClickListener {
|
||||||
|
if (!bottomSheet.isAdded) {
|
||||||
|
bottomSheet.show(fragment.childFragmentManager, CommentsModalBottomSheet.TAG)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
commentsButton.onClick.subscribe {
|
||||||
|
if (!bottomSheet.isAdded) {
|
||||||
|
bottomSheet.show(fragment.childFragmentManager, CommentsModalBottomSheet.TAG)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
shareButton.onClick.subscribe {
|
||||||
|
val url = video?.shareUrl ?: video?.url
|
||||||
|
fragment.startActivity(Intent.createChooser(Intent().apply {
|
||||||
|
action = Intent.ACTION_SEND
|
||||||
|
putExtra(Intent.EXTRA_TEXT, url)
|
||||||
|
type = "text/plain"
|
||||||
|
}, null))
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshButton.onClick.subscribe {
|
||||||
|
onResetTriggered.emit()
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshButton.setOnLongClickListener {
|
||||||
|
UIDialogs.toast(context, "Reload all platform shorts pagers")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
qualityButton.onClick.subscribe {
|
||||||
|
showVideoSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
likeButton.onClick.subscribe {
|
||||||
|
val checked = likeButton.iconId == R.drawable.ic_thumb_up_s // !likeButton.isChecked
|
||||||
|
StatePolycentric.instance.requireLogin(context, context.getString(R.string.please_login_to_like)) {
|
||||||
|
if (checked) {
|
||||||
|
likes++
|
||||||
|
} else {
|
||||||
|
likes--
|
||||||
|
}
|
||||||
|
|
||||||
|
if(checked)
|
||||||
|
likeButton.withIcon(R.drawable.ic_thumb_up_s_filled) //.isChecked = checked
|
||||||
|
else
|
||||||
|
likeButton.withIcon(R.drawable.ic_thumb_up_s)
|
||||||
|
|
||||||
|
if (dislikeButton.iconId == R.drawable.ic_thumb_down_s_filled && checked) {
|
||||||
|
//dislikeButton.isChecked = false
|
||||||
|
dislikeButton.withIcon(R.drawable.ic_thumb_down_s)
|
||||||
|
dislikes--
|
||||||
|
}
|
||||||
|
|
||||||
|
onLikeDislikeUpdated.emit(
|
||||||
|
OnLikeDislikeUpdatedArgs(
|
||||||
|
it, likes, checked, dislikes, !checked
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dislikeButton.onClick.subscribe {
|
||||||
|
val checked = dislikeButton.iconId == R.drawable.ic_thumb_down_s //!dislikeButton.isChecked
|
||||||
|
StatePolycentric.instance.requireLogin(context, context.getString(R.string.please_login_to_like)) {
|
||||||
|
if (checked) {
|
||||||
|
dislikes++
|
||||||
|
} else {
|
||||||
|
dislikes--
|
||||||
|
}
|
||||||
|
|
||||||
|
//dislikeButton.isChecked = checked
|
||||||
|
if(checked)
|
||||||
|
dislikeButton.withIcon(R.drawable.ic_thumb_down_s_filled) //.isChecked = checked
|
||||||
|
else
|
||||||
|
dislikeButton.withIcon(R.drawable.ic_thumb_down_s)
|
||||||
|
|
||||||
|
if (likeButton.iconId == R.drawable.ic_thumb_up_s_filled && checked) {
|
||||||
|
//likeButton.isChecked = false
|
||||||
|
likeButton.withIcon(R.drawable.ic_thumb_up_s);
|
||||||
|
likes--
|
||||||
|
}
|
||||||
|
|
||||||
|
onLikeDislikeUpdated.emit(
|
||||||
|
OnLikeDislikeUpdatedArgs(
|
||||||
|
it, likes, !checked, dislikes, checked
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onLikesLoaded.subscribe(tag) { rating, liked, disliked ->
|
||||||
|
likes = rating.likes
|
||||||
|
dislikes = rating.dislikes
|
||||||
|
//likeButton.isChecked = liked
|
||||||
|
//dislikeButton.isChecked = disliked
|
||||||
|
|
||||||
|
dislikeButton.visibility = VISIBLE
|
||||||
|
likeButton.visibility = VISIBLE
|
||||||
|
}
|
||||||
|
|
||||||
|
player.onPlaybackStateChanged.subscribe {
|
||||||
|
val videoSource = _lastVideoSource
|
||||||
|
|
||||||
|
if (videoSource is IDashManifestSource || videoSource is IHLSManifestSource) {
|
||||||
|
val videoTracks =
|
||||||
|
player.exoPlayer?.player?.currentTracks?.groups?.firstOrNull { it.mediaTrackGroup.type == C.TRACK_TYPE_VIDEO }
|
||||||
|
val audioTracks =
|
||||||
|
player.exoPlayer?.player?.currentTracks?.groups?.firstOrNull { it.mediaTrackGroup.type == C.TRACK_TYPE_AUDIO }
|
||||||
|
|
||||||
|
val videoTrackFormats = mutableListOf<Format>()
|
||||||
|
val audioTrackFormats = mutableListOf<Format>()
|
||||||
|
|
||||||
|
if (videoTracks != null) {
|
||||||
|
for (i in 0 until videoTracks.mediaTrackGroup.length) videoTrackFormats.add(videoTracks.mediaTrackGroup.getFormat(i))
|
||||||
|
}
|
||||||
|
if (audioTracks != null) {
|
||||||
|
for (i in 0 until audioTracks.mediaTrackGroup.length) audioTrackFormats.add(audioTracks.mediaTrackGroup.getFormat(i))
|
||||||
|
}
|
||||||
|
|
||||||
|
updateQualitySourcesOverlay(videoDetails, null, videoTrackFormats.distinctBy { it.height }
|
||||||
|
.sortedBy { it.height }, audioTrackFormats.distinctBy { it.bitrate }
|
||||||
|
.sortedBy { it.bitrate })
|
||||||
|
} else {
|
||||||
|
updateQualitySourcesOverlay(videoDetails, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showPlayPauseIcon() {
|
||||||
|
val overlay = playPauseOverlay
|
||||||
|
|
||||||
|
overlay.alpha = 0f
|
||||||
|
overlay.scaleX = 0f
|
||||||
|
overlay.scaleY = 0f
|
||||||
|
overlay.visibility = VISIBLE
|
||||||
|
|
||||||
|
overlay.animate().alpha(1f).scaleX(1f).scaleY(1f).setDuration(400)
|
||||||
|
.setInterpolator(OvershootInterpolator(1.2f)).start()
|
||||||
|
|
||||||
|
overlay.postDelayed({
|
||||||
|
hidePlayPauseIcon()
|
||||||
|
}, 1500)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hidePlayPauseIcon() {
|
||||||
|
val overlay = playPauseOverlay
|
||||||
|
|
||||||
|
overlay.animate().alpha(0f).scaleX(0.8f).scaleY(0.8f).setDuration(300)
|
||||||
|
.setInterpolator(AccelerateInterpolator()).withEndAction {
|
||||||
|
overlay.visibility = GONE
|
||||||
|
}.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO merge this with the updateQualitySourcesOverlay for the normal video player
|
||||||
|
@androidx.annotation.OptIn(UnstableApi::class)
|
||||||
|
private fun updateQualitySourcesOverlay(videoDetails: IPlatformVideoDetails?, videoLocal: VideoLocal? = null, liveStreamVideoFormats: List<Format>? = null, liveStreamAudioFormats: List<Format>? = null) {
|
||||||
|
Logger.i(TAG, "updateQualitySourcesOverlay")
|
||||||
|
|
||||||
|
val video: IPlatformVideoDetails?
|
||||||
|
val localVideoSources: List<LocalVideoSource>?
|
||||||
|
val localAudioSource: List<LocalAudioSource>?
|
||||||
|
val localSubtitleSources: List<LocalSubtitleSource>?
|
||||||
|
|
||||||
|
val videoSources: List<IVideoSource>?
|
||||||
|
val audioSources: List<IAudioSource>?
|
||||||
|
|
||||||
|
if (videoDetails is VideoLocal) {
|
||||||
|
video = videoLocal?.videoSerialized
|
||||||
|
localVideoSources = videoDetails.videoSource.toList()
|
||||||
|
localAudioSource = videoDetails.audioSource.toList()
|
||||||
|
localSubtitleSources = videoDetails.subtitlesSources.toList()
|
||||||
|
videoSources = null
|
||||||
|
audioSources = null
|
||||||
|
} else {
|
||||||
|
video = videoDetails
|
||||||
|
videoSources = video?.video?.videoSources?.toList()
|
||||||
|
audioSources =
|
||||||
|
if (video?.video?.isUnMuxed == true) (video.video as VideoUnMuxedSourceDescriptor).audioSources.toList()
|
||||||
|
else null
|
||||||
|
if (videoLocal != null) {
|
||||||
|
localVideoSources = videoLocal.videoSource.toList()
|
||||||
|
localAudioSource = videoLocal.audioSource.toList()
|
||||||
|
localSubtitleSources = videoLocal.subtitlesSources.toList()
|
||||||
|
} else {
|
||||||
|
localVideoSources = null
|
||||||
|
localAudioSource = null
|
||||||
|
localSubtitleSources = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val doDedup = Settings.instance.playback.simplifySources
|
||||||
|
|
||||||
|
val bestVideoSources = if (doDedup) (videoSources?.map { it.height * it.width }?.distinct()
|
||||||
|
?.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 }))?.distinct()
|
||||||
|
?.filterNotNull()?.toList() ?: listOf() else videoSources?.toList() ?: listOf()
|
||||||
|
val bestAudioContainer =
|
||||||
|
audioSources?.let { VideoHelper.selectBestAudioSource(it, FutoVideoPlayerBase.PREFERED_AUDIO_CONTAINERS)?.container }
|
||||||
|
val bestAudioSources =
|
||||||
|
if (doDedup) audioSources?.filter { it.container == bestAudioContainer }
|
||||||
|
?.plus(audioSources.filter { it is IHLSManifestAudioSource || it is IDashManifestSource })
|
||||||
|
?.distinct()?.toList() ?: listOf() else audioSources?.toList() ?: listOf()
|
||||||
|
|
||||||
|
val canSetSpeed = true
|
||||||
|
val currentPlaybackRate = player.getPlaybackRate()
|
||||||
|
overlayQualitySelector =
|
||||||
|
SlideUpMenuOverlay(
|
||||||
|
this.context, overlayQualityContainer, context.getString(
|
||||||
|
R.string.quality
|
||||||
|
), null, true, if (canSetSpeed) SlideUpMenuTitle(this.context).apply { setTitle(context.getString(R.string.playback_rate)) } else null, if (canSetSpeed) SlideUpMenuButtonList(this.context, null, "playback_rate").apply {
|
||||||
|
setButtons(listOf("0.25", "0.5", "0.75", "1.0", "1.25", "1.5", "1.75", "2.0", "2.25"), currentPlaybackRate.toString())
|
||||||
|
onClick.subscribe { v ->
|
||||||
|
|
||||||
|
player.setPlaybackRate(v.toFloat())
|
||||||
|
setSelected(v)
|
||||||
|
|
||||||
|
}
|
||||||
|
} else null, if (localVideoSources?.isNotEmpty() == true) SlideUpMenuGroup(
|
||||||
|
this.context, context.getString(R.string.offline_video), "video", *localVideoSources.map {
|
||||||
|
SlideUpMenuItem(this.context, R.drawable.ic_movie, it.name, "${it.width}x${it.height}", tag = it, call = { handleSelectVideoTrack(it) })
|
||||||
|
}.toList().toTypedArray()
|
||||||
|
)
|
||||||
|
else null, if (localAudioSource?.isNotEmpty() == true) SlideUpMenuGroup(
|
||||||
|
this.context, context.getString(R.string.offline_audio), "audio", *localAudioSource.map {
|
||||||
|
SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), tag = it, call = { handleSelectAudioTrack(it) })
|
||||||
|
}.toList().toTypedArray()
|
||||||
|
)
|
||||||
|
else null, if (localSubtitleSources?.isNotEmpty() == true) SlideUpMenuGroup(
|
||||||
|
this.context, context.getString(R.string.offline_subtitles), "subtitles", *localSubtitleSources.map {
|
||||||
|
SlideUpMenuItem(this.context, R.drawable.ic_edit, it.name, "", tag = it, call = { handleSelectSubtitleTrack(it) })
|
||||||
|
}.toList().toTypedArray()
|
||||||
|
)
|
||||||
|
else null, if (liveStreamVideoFormats?.isEmpty() == false) SlideUpMenuGroup(
|
||||||
|
this.context, context.getString(R.string.stream_video), "video", (listOf(
|
||||||
|
SlideUpMenuItem(this.context, R.drawable.ic_movie, "Auto", tag = "auto", call = { player.selectVideoTrack(-1) })
|
||||||
|
) + (liveStreamVideoFormats.map {
|
||||||
|
SlideUpMenuItem(
|
||||||
|
this.context, R.drawable.ic_movie, it.label ?: it.containerMimeType
|
||||||
|
?: it.bitrate.toString(), "${it.width}x${it.height}", tag = it, call = { player.selectVideoTrack(it.height) })
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
else null, if (liveStreamAudioFormats?.isEmpty() == false) SlideUpMenuGroup(
|
||||||
|
this.context, context.getString(R.string.stream_audio), "audio", *liveStreamAudioFormats.map {
|
||||||
|
SlideUpMenuItem(this.context, R.drawable.ic_music, "${it.label ?: it.containerMimeType} ${it.bitrate}", "", tag = it, call = { player.selectAudioTrack(it.bitrate) })
|
||||||
|
}.toList().toTypedArray()
|
||||||
|
)
|
||||||
|
else null, if (bestVideoSources.isNotEmpty()) SlideUpMenuGroup(
|
||||||
|
this.context, context.getString(R.string.video), "video", *bestVideoSources.map {
|
||||||
|
val estSize = VideoHelper.estimateSourceSize(it)
|
||||||
|
val prefix = if (estSize > 0) "±" + estSize.toHumanBytesSize() + " " else ""
|
||||||
|
SlideUpMenuItem(this.context, R.drawable.ic_movie, it.name, if (it.width > 0 && it.height > 0) "${it.width}x${it.height}" else "", (prefix + it.codec.trim()).trim(), tag = it, call = { handleSelectVideoTrack(it) })
|
||||||
|
}.toList().toTypedArray()
|
||||||
|
)
|
||||||
|
else null, if (bestAudioSources.isNotEmpty()) SlideUpMenuGroup(
|
||||||
|
this.context, context.getString(R.string.audio), "audio", *bestAudioSources.map {
|
||||||
|
val estSize = VideoHelper.estimateSourceSize(it)
|
||||||
|
val prefix = if (estSize > 0) "±" + estSize.toHumanBytesSize() + " " else ""
|
||||||
|
SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), (prefix + it.codec.trim()).trim(), tag = it, call = { handleSelectAudioTrack(it) })
|
||||||
|
}.toList().toTypedArray()
|
||||||
|
)
|
||||||
|
else null, if (video?.subtitles?.isNotEmpty() == true) SlideUpMenuGroup(
|
||||||
|
this.context, context.getString(R.string.subtitles), "subtitles", *video.subtitles.map {
|
||||||
|
SlideUpMenuItem(this.context, R.drawable.ic_edit, it.name, "", tag = it, call = { handleSelectSubtitleTrack(it) })
|
||||||
|
}.toList().toTypedArray()
|
||||||
|
)
|
||||||
|
else null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleSelectVideoTrack(videoSource: IVideoSource) {
|
||||||
|
Logger.i(TAG, "handleSelectAudioTrack(videoSource=$videoSource)")
|
||||||
|
if (_lastVideoSource == videoSource) return
|
||||||
|
|
||||||
|
_lastVideoSource = videoSource
|
||||||
|
|
||||||
|
playVideo(player.position)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleSelectAudioTrack(audioSource: IAudioSource) {
|
||||||
|
Logger.i(TAG, "handleSelectAudioTrack(audioSource=$audioSource)")
|
||||||
|
if (_lastAudioSource == audioSource) return
|
||||||
|
|
||||||
|
_lastAudioSource = audioSource
|
||||||
|
|
||||||
|
playVideo(player.position)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleSelectSubtitleTrack(subtitleSource: ISubtitleSource) {
|
||||||
|
Logger.i(TAG, "handleSelectSubtitleTrack(subtitleSource=$subtitleSource)")
|
||||||
|
var toSet: ISubtitleSource? = subtitleSource
|
||||||
|
if (_lastSubtitleSource == subtitleSource) toSet = null
|
||||||
|
|
||||||
|
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||||
|
try {
|
||||||
|
player.swapSubtitles(toSet)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "handleSelectSubtitleTrack failed", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastSubtitleSource = toSet
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showVideoSettings() {
|
||||||
|
Logger.i(TAG, "showVideoSettings")
|
||||||
|
|
||||||
|
overlayQualitySelector?.selectOption("video", _lastVideoSource)
|
||||||
|
overlayQualitySelector?.selectOption("audio", _lastAudioSource)
|
||||||
|
overlayQualitySelector?.selectOption("subtitles", _lastSubtitleSource)
|
||||||
|
|
||||||
|
if (_lastVideoSource is IDashManifestSource || _lastVideoSource is IHLSManifestSource) {
|
||||||
|
val videoTracks =
|
||||||
|
player.exoPlayer?.player?.currentTracks?.groups?.firstOrNull { it.mediaTrackGroup.type == C.TRACK_TYPE_VIDEO }
|
||||||
|
|
||||||
|
var selectedQuality: Format? = null
|
||||||
|
|
||||||
|
if (videoTracks != null) {
|
||||||
|
for (i in 0 until videoTracks.mediaTrackGroup.length) {
|
||||||
|
if (videoTracks.mediaTrackGroup.getFormat(i).height == player.targetTrackVideoHeight) {
|
||||||
|
selectedQuality = videoTracks.mediaTrackGroup.getFormat(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var videoMenuGroup: SlideUpMenuGroup? = null
|
||||||
|
for (view in overlayQualitySelector!!.groupItems) {
|
||||||
|
if (view is SlideUpMenuGroup && view.groupTag == "video") {
|
||||||
|
videoMenuGroup = view
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedQuality != null) {
|
||||||
|
videoMenuGroup?.getItem("auto")?.setSubText("")
|
||||||
|
overlayQualitySelector?.selectOption("video", selectedQuality)
|
||||||
|
} else {
|
||||||
|
videoMenuGroup?.getItem("auto")
|
||||||
|
?.setSubText("${player.exoPlayer?.player?.videoFormat?.width}x${player.exoPlayer?.player?.videoFormat?.height}")
|
||||||
|
overlayQualitySelector?.selectOption("video", "auto")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentPlaybackRate = player.getPlaybackRate()
|
||||||
|
overlayQualitySelector?.groupItems?.firstOrNull { it is SlideUpMenuButtonList && it.id == "playback_rate" }
|
||||||
|
?.let {
|
||||||
|
(it as SlideUpMenuButtonList).setSelected(currentPlaybackRate.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
overlayQualitySelector?.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("unused")
|
||||||
|
fun setMainFragment(fragment: MainFragment, overlayQualityContainer: FrameLayout) {
|
||||||
|
this.fragment = fragment
|
||||||
|
this.bottomSheet.mainFragment = fragment
|
||||||
|
this.overlayQualityContainer = overlayQualityContainer
|
||||||
|
}
|
||||||
|
|
||||||
|
fun changeVideo(video: IPlatformVideo, isChannelShortsMode: Boolean) {
|
||||||
|
if (this.video?.url == video.url) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.video = video
|
||||||
|
|
||||||
|
refreshButton.visibility = if (isChannelShortsMode) {
|
||||||
|
GONE
|
||||||
|
} else {
|
||||||
|
GONE //TODO: Revert?
|
||||||
|
}
|
||||||
|
backButtonContainer.visibility = if (isChannelShortsMode) {
|
||||||
|
VISIBLE
|
||||||
|
} else {
|
||||||
|
GONE
|
||||||
|
}
|
||||||
|
|
||||||
|
loadVideo(video.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("unused")
|
||||||
|
fun changeVideo(videoDetails: IPlatformVideoDetails) {
|
||||||
|
if (video?.url == videoDetails.url) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.video = videoDetails
|
||||||
|
this.videoDetails = videoDetails
|
||||||
|
}
|
||||||
|
|
||||||
|
fun play() {
|
||||||
|
loadLikes(this.video!!)
|
||||||
|
player.clear()
|
||||||
|
player.attach()
|
||||||
|
player.clear()
|
||||||
|
playVideo()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pause() {
|
||||||
|
player.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
playWhenReady = false
|
||||||
|
|
||||||
|
player.clear()
|
||||||
|
player.detach()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancel() {
|
||||||
|
loadVideoTask?.cancel()
|
||||||
|
loadLikesTask?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setLoading(isLoading: Boolean) {
|
||||||
|
if (isLoading) {
|
||||||
|
(overlayLoadingSpinner.drawable as Animatable?)?.start()
|
||||||
|
overlayLoading.visibility = VISIBLE
|
||||||
|
} else {
|
||||||
|
overlayLoading.visibility = GONE
|
||||||
|
(overlayLoadingSpinner.drawable as Animatable?)?.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadLikes(video: IPlatformVideo) {
|
||||||
|
likeButton.visibility = GONE
|
||||||
|
dislikeButton.visibility = GONE
|
||||||
|
|
||||||
|
loadLikesTask?.cancel()
|
||||||
|
loadLikesTask =
|
||||||
|
TaskHandler<IPlatformVideo, Pair<Protocol.Reference, Protocol.QueryReferencesResponse>>(
|
||||||
|
StateApp.instance.scopeGetter, {
|
||||||
|
val ref = Models.referenceFromBuffer(video.url.toByteArray())
|
||||||
|
val extraBytesRef =
|
||||||
|
video.id.value?.let { if (it.isNotEmpty()) it.toByteArray() else null }
|
||||||
|
|
||||||
|
val queryReferencesResponse = ApiMethods.getQueryReferences(
|
||||||
|
ApiMethods.SERVER, ref, null, null, arrayListOf(
|
||||||
|
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder()
|
||||||
|
.setFromType(ContentType.OPINION.value).setValue(
|
||||||
|
ByteString.copyFrom(Opinion.like.data)
|
||||||
|
)
|
||||||
|
.build(), Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder()
|
||||||
|
.setFromType(ContentType.OPINION.value).setValue(
|
||||||
|
ByteString.copyFrom(Opinion.dislike.data)
|
||||||
|
).build()
|
||||||
|
), extraByteReferences = listOfNotNull(extraBytesRef)
|
||||||
|
)
|
||||||
|
|
||||||
|
Pair(ref, queryReferencesResponse)
|
||||||
|
}).success { (ref, queryReferencesResponse) ->
|
||||||
|
val likes = queryReferencesResponse.countsList[0]
|
||||||
|
val dislikes = queryReferencesResponse.countsList[1]
|
||||||
|
val hasLiked = StatePolycentric.instance.hasLiked(ref.toByteArray())
|
||||||
|
val hasDisliked = StatePolycentric.instance.hasDisliked(ref.toByteArray())
|
||||||
|
onLikesLoaded.emit(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked)
|
||||||
|
onLikeDislikeUpdated.subscribe(this) { args ->
|
||||||
|
if (args.hasLiked) {
|
||||||
|
args.processHandle.opinion(ref, Opinion.like)
|
||||||
|
} else if (args.hasDisliked) {
|
||||||
|
args.processHandle.opinion(ref, Opinion.dislike)
|
||||||
|
} else {
|
||||||
|
args.processHandle.opinion(ref, Opinion.neutral)
|
||||||
|
}
|
||||||
|
|
||||||
|
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
Logger.i(TAG, "Started backfill")
|
||||||
|
args.processHandle.fullyBackfillServersAnnounceExceptions()
|
||||||
|
Logger.i(TAG, "Finished backfill")
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to backfill servers", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StatePolycentric.instance.updateLikeMap(
|
||||||
|
ref, args.hasLiked, args.hasDisliked
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadLikesTask?.run(video)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadVideo(url: String) {
|
||||||
|
loadVideoTask?.cancel()
|
||||||
|
videoDetails = null
|
||||||
|
_lastVideoSource = null
|
||||||
|
_lastAudioSource = null
|
||||||
|
_lastSubtitleSource = null
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
Logger.i(TAG, "Shorts loadVideo [${url}]");
|
||||||
|
val timeLoadVideoStart = System.currentTimeMillis();
|
||||||
|
loadVideoTask = TaskHandler<String, IPlatformVideoDetails>(
|
||||||
|
StateApp.instance.scopeGetter, {
|
||||||
|
val result = StatePlatform.instance.getContentDetails(it).await()
|
||||||
|
if (result !is IPlatformVideoDetails) throw IllegalStateException("Expected media content, found ${result.contentType}")
|
||||||
|
return@TaskHandler result
|
||||||
|
}).success { result ->
|
||||||
|
val timeLoadVideo = System.currentTimeMillis() - timeLoadVideoStart;
|
||||||
|
Logger.i(TAG, "Shorts loadVideo [${url}] took ${timeLoadVideo}ms");
|
||||||
|
videoDetails = result
|
||||||
|
video = result
|
||||||
|
|
||||||
|
if(Settings.instance.playback.shortsPregenerate)
|
||||||
|
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||||
|
if(result != null) {
|
||||||
|
val prefVid = VideoHelper.selectBestVideoSource(result.video, Settings.instance.playback.getCurrentPreferredQualityPixelCount(), PREFERED_VIDEO_CONTAINERS);
|
||||||
|
val prefAud = VideoHelper.selectBestAudioSource(result.video, PREFERED_AUDIO_CONTAINERS, Settings.instance.playback.getPrimaryLanguage(context));
|
||||||
|
|
||||||
|
if(prefVid != null && prefVid is JSDashManifestRawSource) {
|
||||||
|
Logger.i(TAG, "Shorts pregenerating video (${result.name})");
|
||||||
|
prefVid.pregenerateAsync(fragment.lifecycleScope);
|
||||||
|
}
|
||||||
|
if(prefAud != null && prefAud is JSDashManifestRawAudioSource) {
|
||||||
|
Logger.i(TAG, "Shorts pregenerating audio (${result.name})");
|
||||||
|
prefAud.pregenerateAsync(fragment.lifecycleScope);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bottomSheet.video = result
|
||||||
|
|
||||||
|
setLoading(false)
|
||||||
|
|
||||||
|
if (playWhenReady) playVideo()
|
||||||
|
}.exception<NoPlatformClientException> {
|
||||||
|
Logger.w(TAG, "exception<NoPlatformClientException>", it)
|
||||||
|
UIDialogs.showDialog(
|
||||||
|
context, R.drawable.ic_sources, "No source enabled to support this video\n(${url})", null, null, 0, UIDialogs.Action("Close", { }, UIDialogs.ActionStyle.PRIMARY)
|
||||||
|
)
|
||||||
|
}.exception<ScriptLoginRequiredException> { e ->
|
||||||
|
Logger.w(TAG, "exception<ScriptLoginRequiredException>", e)
|
||||||
|
UIDialogs.showDialog(
|
||||||
|
context, R.drawable.ic_security, "Authentication", e.message, null, 0, UIDialogs.Action("Cancel", {}), UIDialogs.Action("Login", {
|
||||||
|
val id = e.config.let { if (it is SourcePluginConfig) it.id else null }
|
||||||
|
val didLogin =
|
||||||
|
if (id == null) false else StatePlugins.instance.loginPlugin(context, id) {
|
||||||
|
loadVideo(url)
|
||||||
|
}
|
||||||
|
if (!didLogin) UIDialogs.showDialogOk(context, R.drawable.ic_error_pred, "Failed to login")
|
||||||
|
}, UIDialogs.ActionStyle.PRIMARY)
|
||||||
|
)
|
||||||
|
}.exception<ContentNotAvailableYetException> {
|
||||||
|
Logger.w(TAG, "exception<ContentNotAvailableYetException>", it)
|
||||||
|
UIDialogs.showSingleButtonDialog(context, R.drawable.ic_schedule, "Video is available in ${it.availableWhen}.", "Close") { }
|
||||||
|
}.exception<ScriptImplementationException> {
|
||||||
|
Logger.w(TAG, "exception<ScriptImplementationException>", it)
|
||||||
|
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video_scriptimplementationexception), it, { loadVideo(url) }, null, fragment)
|
||||||
|
}.exception<ScriptAgeException> {
|
||||||
|
Logger.w(TAG, "exception<ScriptAgeException>", it)
|
||||||
|
UIDialogs.showDialog(
|
||||||
|
context, R.drawable.ic_lock, "Age restricted video", it.message, null, 0, UIDialogs.Action("Close", { }, UIDialogs.ActionStyle.PRIMARY)
|
||||||
|
)
|
||||||
|
}.exception<ScriptUnavailableException> {
|
||||||
|
Logger.w(TAG, "exception<ScriptUnavailableException>", it)
|
||||||
|
UIDialogs.showDialog(
|
||||||
|
context, R.drawable.ic_lock, context.getString(R.string.unavailable_video), context.getString(R.string.this_video_is_unavailable), null, 0, UIDialogs.Action(context.getString(R.string.close), { }, UIDialogs.ActionStyle.PRIMARY)
|
||||||
|
)
|
||||||
|
}.exception<ScriptException> {
|
||||||
|
Logger.w(TAG, "exception<ScriptException>", it)
|
||||||
|
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video_scriptexception), it, { loadVideo(url) }, null, fragment)
|
||||||
|
}.exception<Throwable> {
|
||||||
|
Logger.w(ChannelFragment.TAG, "Failed to load video.", it)
|
||||||
|
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video), it, { loadVideo(url) }, null, fragment)
|
||||||
|
}
|
||||||
|
|
||||||
|
loadVideoTask?.run(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun playVideo(resumePositionMs: Long = 0) {
|
||||||
|
val videoDetails = this@ShortView.videoDetails
|
||||||
|
|
||||||
|
if (videoDetails === null) {
|
||||||
|
playWhenReady = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updateQualitySourcesOverlay(videoDetails, null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val videoSource = _lastVideoSource
|
||||||
|
?: player.getPreferredVideoSource(videoDetails, Settings.instance.playback.getCurrentPreferredQualityPixelCount())
|
||||||
|
val audioSource = _lastAudioSource
|
||||||
|
?: player.getPreferredAudioSource(videoDetails, Settings.instance.playback.getPrimaryLanguage(context))
|
||||||
|
val subtitleSource = _lastSubtitleSource
|
||||||
|
?: (if (videoDetails is VideoLocal) videoDetails.subtitlesSources.firstOrNull() else null)
|
||||||
|
Logger.i(TAG, "loadCurrentVideo(videoSource=$videoSource, audioSource=$audioSource, subtitleSource=$subtitleSource, resumePositionMs=$resumePositionMs)")
|
||||||
|
|
||||||
|
if (videoSource == null && audioSource == null) {
|
||||||
|
UIDialogs.showDialog(
|
||||||
|
context, R.drawable.ic_lock, context.getString(R.string.unavailable_video), context.getString(R.string.this_video_is_unavailable), null, 0, UIDialogs.Action(context.getString(R.string.close), { }, UIDialogs.ActionStyle.PRIMARY)
|
||||||
|
)
|
||||||
|
StatePlatform.instance.clearContentDetailCache(videoDetails.url)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val thumbnail = videoDetails.thumbnails.getHQThumbnail()
|
||||||
|
/*
|
||||||
|
if (videoSource == null && !thumbnail.isNullOrBlank()) Glide.with(context).asBitmap()
|
||||||
|
.load(thumbnail).into(object : CustomTarget<Bitmap>() {
|
||||||
|
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
||||||
|
player.setArtwork(resource.toDrawable(resources))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLoadCleared(placeholder: Drawable?) {
|
||||||
|
player.setArtwork(null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
else player.setArtwork(null)
|
||||||
|
*/
|
||||||
|
|
||||||
|
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||||
|
try {
|
||||||
|
player.setSource(videoSource, audioSource, play = true, keepSubtitles = false, resume = resumePositionMs > 0)
|
||||||
|
if (subtitleSource != null) player.swapSubtitles(subtitleSource)
|
||||||
|
player.seekTo(resumePositionMs)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "playVideo failed", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastVideoSource = videoSource
|
||||||
|
_lastAudioSource = audioSource
|
||||||
|
_lastSubtitleSource = subtitleSource
|
||||||
|
} catch (ex: UnsupportedCastException) {
|
||||||
|
Logger.e(TAG, "Failed to load cast media", ex)
|
||||||
|
UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.unsupported_cast_format), ex)
|
||||||
|
} catch (ex: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to load media", ex)
|
||||||
|
UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.failed_to_load_media), ex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val TAG = "VideoDetailView"
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
+370
@@ -0,0 +1,370 @@
|
|||||||
|
package com.futo.platformplayer.fragment.mainactivity.main
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.graphics.drawable.Animatable
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.SoundEffectConstants
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import androidx.viewpager2.widget.ViewPager2
|
||||||
|
import com.futo.platformplayer.R
|
||||||
|
import com.futo.platformplayer.UIDialogs
|
||||||
|
import com.futo.platformplayer.activities.MainActivity
|
||||||
|
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||||
|
import com.futo.platformplayer.api.media.structures.IPager
|
||||||
|
import com.futo.platformplayer.constructs.Event0
|
||||||
|
import com.futo.platformplayer.constructs.TaskHandler
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.states.StateApp
|
||||||
|
import com.futo.platformplayer.states.StatePlatform
|
||||||
|
import com.futo.platformplayer.views.buttons.BigButton
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.system.measureTimeMillis
|
||||||
|
|
||||||
|
@UnstableApi
|
||||||
|
class ShortsFragment : MainFragment() {
|
||||||
|
override val isMainView: Boolean = true
|
||||||
|
override val isTab: Boolean = true
|
||||||
|
override val hasBottomBar: Boolean get() = true
|
||||||
|
|
||||||
|
private var loadPagerTask: TaskHandler<ShortsFragment, IPager<IPlatformVideo>>? = null
|
||||||
|
private var nextPageTask: TaskHandler<ShortsFragment, List<IPlatformVideo>>? = null
|
||||||
|
|
||||||
|
//TODO: Reduce number of pagers (1, or at most 2)
|
||||||
|
private var mainShortsPager: IPager<IPlatformVideo>? = null
|
||||||
|
private val mainShorts: MutableList<IPlatformVideo> = mutableListOf()
|
||||||
|
|
||||||
|
// the pager to call next on
|
||||||
|
private var currentShortsPager: IPager<IPlatformVideo>? = null
|
||||||
|
|
||||||
|
// the shorts array bound to the ViewPager2 adapter
|
||||||
|
private val currentShorts: MutableList<IPlatformVideo> = mutableListOf()
|
||||||
|
|
||||||
|
private var channelShortsPager: IPager<IPlatformVideo>? = null
|
||||||
|
private val channelShorts: MutableList<IPlatformVideo> = mutableListOf()
|
||||||
|
val isChannelShortsMode: Boolean
|
||||||
|
get() = channelShortsPager != null
|
||||||
|
|
||||||
|
private var viewPager: ViewPager2? = null
|
||||||
|
private lateinit var zeroState: LinearLayout
|
||||||
|
private lateinit var sourcesButton: BigButton
|
||||||
|
private lateinit var overlayLoading: FrameLayout
|
||||||
|
private lateinit var overlayLoadingSpinner: ImageView
|
||||||
|
private lateinit var overlayQualityContainer: FrameLayout
|
||||||
|
private var customViewAdapter: CustomViewAdapter? = null
|
||||||
|
|
||||||
|
// we just completely reset the data structure so we want to tell the adapter that
|
||||||
|
//TODO: Move most of this logic to ShortsView
|
||||||
|
@SuppressLint("NotifyDataSetChanged")
|
||||||
|
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
|
||||||
|
(activity as MainActivity?)?.getFragment<VideoDetailFragment>()?.closeVideoDetails()
|
||||||
|
super.onShownWithView(parameter, isBack)
|
||||||
|
|
||||||
|
if (parameter is Triple<*, *, *>) {
|
||||||
|
setLoading(false)
|
||||||
|
channelShorts.clear()
|
||||||
|
@Suppress("UNCHECKED_CAST") // TODO replace with a strongly typed parameter
|
||||||
|
channelShorts.addAll(parameter.third as ArrayList<IPlatformVideo>)
|
||||||
|
@Suppress("UNCHECKED_CAST") // TODO replace with a strongly typed parameter
|
||||||
|
channelShortsPager = parameter.second as IPager<IPlatformVideo>
|
||||||
|
|
||||||
|
currentShorts.clear()
|
||||||
|
currentShorts.addAll(channelShorts)
|
||||||
|
currentShortsPager = channelShortsPager
|
||||||
|
|
||||||
|
viewPager?.adapter?.notifyDataSetChanged()
|
||||||
|
|
||||||
|
viewPager?.post {
|
||||||
|
viewPager?.currentItem = channelShorts.indexOfFirst {
|
||||||
|
return@indexOfFirst (parameter.first as IPlatformVideo).id == it.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (isChannelShortsMode) {
|
||||||
|
channelShortsPager = null
|
||||||
|
channelShorts.clear()
|
||||||
|
currentShorts.clear()
|
||||||
|
|
||||||
|
if (loadPagerTask == null) {
|
||||||
|
currentShorts.addAll(mainShorts)
|
||||||
|
currentShortsPager = mainShortsPager
|
||||||
|
} else {
|
||||||
|
setLoading(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
viewPager?.adapter?.notifyDataSetChanged()
|
||||||
|
viewPager?.currentItem = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
updateZeroState()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateMainView(
|
||||||
|
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
|
||||||
|
): View {
|
||||||
|
return inflater.inflate(R.layout.fragment_shorts, container, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
|
||||||
|
viewPager = view.findViewById(R.id.view_pager)
|
||||||
|
zeroState = view.findViewById(R.id.zero_state)
|
||||||
|
sourcesButton = view.findViewById(R.id.sources_button)
|
||||||
|
overlayLoading = view.findViewById(R.id.short_view_loading_overlay)
|
||||||
|
overlayLoadingSpinner = view.findViewById(R.id.short_view_loader)
|
||||||
|
overlayQualityContainer = view.findViewById(R.id.shorts_quality_overview)
|
||||||
|
|
||||||
|
sourcesButton.onClick.subscribe {
|
||||||
|
navigate<SourcesFragment>()
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
Logger.i(TAG, "Creating adapter")
|
||||||
|
val customViewAdapter =
|
||||||
|
CustomViewAdapter(currentShorts, layoutInflater, this@ShortsFragment, overlayQualityContainer, { isChannelShortsMode }) {
|
||||||
|
if (!currentShortsPager!!.hasMorePages()) {
|
||||||
|
return@CustomViewAdapter
|
||||||
|
}
|
||||||
|
nextPage()
|
||||||
|
}
|
||||||
|
customViewAdapter.onResetTriggered.subscribe {
|
||||||
|
setLoading(true)
|
||||||
|
loadPager()
|
||||||
|
|
||||||
|
loadPagerTask!!.success {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val viewPager = viewPager!!
|
||||||
|
viewPager.adapter = customViewAdapter
|
||||||
|
|
||||||
|
this.customViewAdapter = customViewAdapter
|
||||||
|
|
||||||
|
if (loadPagerTask == null) {// && currentShorts.isEmpty()) {
|
||||||
|
loadPager()
|
||||||
|
|
||||||
|
loadPagerTask!!.success {
|
||||||
|
setLoading(false)
|
||||||
|
updateZeroState()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setLoading(false)
|
||||||
|
updateZeroState()
|
||||||
|
}
|
||||||
|
|
||||||
|
viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
|
||||||
|
fun play(adapter: CustomViewAdapter, position: Int) {
|
||||||
|
val recycler = (viewPager.getChildAt(0) as RecyclerView)
|
||||||
|
val viewHolder =
|
||||||
|
recycler.findViewHolderForAdapterPosition(position) as CustomViewHolder?
|
||||||
|
|
||||||
|
if (viewHolder == null) {
|
||||||
|
adapter.needToPlay = position
|
||||||
|
} else {
|
||||||
|
val focusedView = viewHolder.shortView
|
||||||
|
focusedView.play()
|
||||||
|
adapter.previousShownView = focusedView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPageSelected(position: Int) {
|
||||||
|
val adapter = (viewPager.adapter as CustomViewAdapter)
|
||||||
|
if (adapter.previousShownView == null) {
|
||||||
|
// play if this page selection didn't trigger by a swipe from another page
|
||||||
|
play(adapter, position)
|
||||||
|
} else {
|
||||||
|
adapter.previousShownView?.stop()
|
||||||
|
adapter.previousShownView = null
|
||||||
|
adapter.newPosition = position
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wait for the state to idle to prevent UI lag
|
||||||
|
override fun onPageScrollStateChanged(state: Int) {
|
||||||
|
super.onPageScrollStateChanged(state)
|
||||||
|
if (state == ViewPager2.SCROLL_STATE_IDLE) {
|
||||||
|
val adapter = (viewPager.adapter as CustomViewAdapter)
|
||||||
|
val position = adapter.newPosition ?: return
|
||||||
|
adapter.newPosition = null
|
||||||
|
|
||||||
|
play(adapter, position)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateZeroState() {
|
||||||
|
if (mainShorts.isEmpty() && !isChannelShortsMode && loadPagerTask == null) {
|
||||||
|
zeroState.visibility = View.VISIBLE
|
||||||
|
} else {
|
||||||
|
zeroState.visibility = View.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun nextPage() {
|
||||||
|
Logger.i(TAG, "ShortsFragment nextPage");
|
||||||
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val time = measureTimeMillis {
|
||||||
|
currentShortsPager!!.nextPage();
|
||||||
|
}
|
||||||
|
val newVideos = currentShortsPager!!.getResults();
|
||||||
|
val prevCount = customViewAdapter!!.itemCount
|
||||||
|
Logger.i(TAG, "Shorts nextPage took ${time}ms, ${prevCount}-${prevCount + newVideos.size}, hasMore: ${currentShortsPager?.hasMorePages()}");
|
||||||
|
currentShorts.addAll(newVideos)
|
||||||
|
if (isChannelShortsMode) {
|
||||||
|
channelShorts.addAll(newVideos)
|
||||||
|
} else {
|
||||||
|
mainShorts.addAll(newVideos)
|
||||||
|
}
|
||||||
|
lifecycleScope.launch(Dispatchers.Main) {
|
||||||
|
customViewAdapter!!.notifyItemRangeInserted(prevCount, newVideos.size)
|
||||||
|
}
|
||||||
|
nextPageTask = null
|
||||||
|
} catch (ex: Throwable) {
|
||||||
|
Logger.e(TAG, "Shorts Failed to call nextPage", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// we just completely reset the data structure so we want to tell the adapter that
|
||||||
|
@SuppressLint("NotifyDataSetChanged")
|
||||||
|
private fun loadPager() {
|
||||||
|
loadPagerTask?.cancel()
|
||||||
|
|
||||||
|
Logger.i(TAG, "Shorts loadPage");
|
||||||
|
var loadPageStart = System.currentTimeMillis();
|
||||||
|
val loadPagerTask =
|
||||||
|
TaskHandler<ShortsFragment, IPager<IPlatformVideo>>(StateApp.instance.scopeGetter, {
|
||||||
|
val pager = StatePlatform.instance.getShorts();
|
||||||
|
|
||||||
|
return@TaskHandler pager
|
||||||
|
}).success { pager ->
|
||||||
|
val timeLoadPage = System.currentTimeMillis() - loadPageStart;
|
||||||
|
Logger.i(TAG, "Shorts loadPage took ${timeLoadPage}ms");
|
||||||
|
mainShorts.clear()
|
||||||
|
mainShorts.addAll(pager.getResults())
|
||||||
|
mainShortsPager = pager
|
||||||
|
|
||||||
|
if (!isChannelShortsMode) {
|
||||||
|
currentShorts.clear()
|
||||||
|
currentShorts.addAll(mainShorts)
|
||||||
|
currentShortsPager = pager
|
||||||
|
|
||||||
|
// if the view pager exists go back to the beginning
|
||||||
|
viewPager?.adapter?.notifyDataSetChanged()
|
||||||
|
viewPager?.currentItem = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
loadPagerTask = null
|
||||||
|
}.exception<Throwable> { err ->
|
||||||
|
val message = "Unable to load shorts $err"
|
||||||
|
Logger.w(TAG, message, err)
|
||||||
|
if (context != null) {
|
||||||
|
UIDialogs.showDialog(
|
||||||
|
requireContext(), R.drawable.ic_sources, message, null, null, 0, UIDialogs.Action(
|
||||||
|
"Close", { }, UIDialogs.ActionStyle.PRIMARY
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return@exception
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loadPagerTask = loadPagerTask
|
||||||
|
|
||||||
|
loadPagerTask.run(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setLoading(isLoading: Boolean) {
|
||||||
|
if (isLoading) {
|
||||||
|
(overlayLoadingSpinner.drawable as Animatable?)?.start()
|
||||||
|
overlayLoading.visibility = View.VISIBLE
|
||||||
|
} else {
|
||||||
|
overlayLoading.visibility = View.GONE
|
||||||
|
(overlayLoadingSpinner.drawable as Animatable?)?.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
super.onPause()
|
||||||
|
customViewAdapter?.previousShownView?.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
loadPagerTask?.cancel()
|
||||||
|
loadPagerTask = null
|
||||||
|
nextPageTask?.cancel()
|
||||||
|
nextPageTask = null
|
||||||
|
customViewAdapter?.previousShownView?.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "ShortsFragment"
|
||||||
|
|
||||||
|
fun newInstance() = ShortsFragment()
|
||||||
|
}
|
||||||
|
|
||||||
|
class CustomViewAdapter(
|
||||||
|
private val videos: MutableList<IPlatformVideo>,
|
||||||
|
private val inflater: LayoutInflater,
|
||||||
|
private val fragment: MainFragment,
|
||||||
|
private val overlayQualityContainer: FrameLayout,
|
||||||
|
private val isChannelShortsMode: () -> Boolean,
|
||||||
|
private val onNearEnd: () -> Unit,
|
||||||
|
) : RecyclerView.Adapter<CustomViewHolder>() {
|
||||||
|
val onResetTriggered = Event0()
|
||||||
|
var previousShownView: ShortView? = null
|
||||||
|
var newPosition: Int? = null
|
||||||
|
var needToPlay: Int? = null
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CustomViewHolder {
|
||||||
|
val shortView = ShortView(inflater, fragment, overlayQualityContainer)
|
||||||
|
shortView.onResetTriggered.subscribe {
|
||||||
|
onResetTriggered.emit()
|
||||||
|
}
|
||||||
|
return CustomViewHolder(shortView)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
override fun onBindViewHolder(holder: CustomViewHolder, position: Int) {
|
||||||
|
Logger.i(TAG, "Shorts change (position: ${position}): ${videos[position].name} (${videos[position].id.value})")
|
||||||
|
holder.shortView.changeVideo(videos[position], isChannelShortsMode())
|
||||||
|
|
||||||
|
if (position == itemCount - 1) {
|
||||||
|
onNearEnd()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewRecycled(holder: CustomViewHolder) {
|
||||||
|
super.onViewRecycled(holder)
|
||||||
|
holder.shortView.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewAttachedToWindow(holder: CustomViewHolder) {
|
||||||
|
super.onViewAttachedToWindow(holder)
|
||||||
|
|
||||||
|
if (holder.absoluteAdapterPosition == needToPlay) {
|
||||||
|
holder.shortView.play()
|
||||||
|
needToPlay = null
|
||||||
|
previousShownView = holder.shortView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemCount(): Int = videos.size
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
class CustomViewHolder(val shortView: ShortView) : RecyclerView.ViewHolder(shortView)
|
||||||
|
}
|
||||||
+45
@@ -25,6 +25,7 @@ import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
|||||||
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.states.StateDeveloper
|
import com.futo.platformplayer.states.StateDeveloper
|
||||||
|
import com.futo.platformplayer.states.StateHistory
|
||||||
import com.futo.platformplayer.states.StatePlatform
|
import com.futo.platformplayer.states.StatePlatform
|
||||||
import com.futo.platformplayer.states.StatePlugins
|
import com.futo.platformplayer.states.StatePlugins
|
||||||
import com.futo.platformplayer.views.buttons.BigButton
|
import com.futo.platformplayer.views.buttons.BigButton
|
||||||
@@ -152,6 +153,50 @@ class SourceDetailFragment : MainFragment() {
|
|||||||
if(field is View)
|
if(field is View)
|
||||||
field.isVisible = false;
|
field.isVisible = false;
|
||||||
}
|
}
|
||||||
|
if(!source.capabilities.hasGetUserHistory || !source.isLoggedIn) {
|
||||||
|
val field = _settingsAppForm.findField("sync");
|
||||||
|
if(field is View)
|
||||||
|
field.isVisible = false;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
val field = _settingsAppForm.findField("syncHistory");
|
||||||
|
field?.onChanged?.subscribe { field, new, old ->
|
||||||
|
if(old != new && new == true && StatePlatform.instance.isClientEnabled(config.id)) {
|
||||||
|
UIDialogs.showDialog(context, R.drawable.ic_sources, "Would you like to sync now?",
|
||||||
|
"This will attempt to update your history from the platform, when this setting is enabled, it is done during startup.", null, 0,
|
||||||
|
UIDialogs.Action("No", {
|
||||||
|
|
||||||
|
}),
|
||||||
|
UIDialogs.Action("Yes", {
|
||||||
|
UIDialogs.showDialogProgress(context, {
|
||||||
|
it.setText("Importing history..");
|
||||||
|
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val client = StatePlatform.instance.getClient(config.id);
|
||||||
|
if (client != null && client is JSClient) {
|
||||||
|
val count = StateHistory.instance.syncRemoteHistory(client);
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
it.hide();
|
||||||
|
if(count > 0)
|
||||||
|
UIDialogs.showDialogOk(context, R.drawable.ic_pair_success, "Imported ${count} history items");
|
||||||
|
else
|
||||||
|
UIDialogs.showDialogOk(context, R.drawable.ic_help, "Imported no history items");
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch(ex: Throwable) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
UIDialogs.appToast("Sync History failed due to:\n" + ex.message);
|
||||||
|
it.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, UIDialogs.ActionStyle.PRIMARY));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
_settingsAppForm.onChanged.clear();
|
_settingsAppForm.onChanged.clear();
|
||||||
_settingsAppForm.onChanged.subscribe { field, value ->
|
_settingsAppForm.onChanged.subscribe { field, value ->
|
||||||
_settingsAppChanged = true;
|
_settingsAppChanged = true;
|
||||||
|
|||||||
+2
-2
@@ -25,10 +25,10 @@ data class SuggestionsFragmentData(val query: String, val searchType: SearchType
|
|||||||
|
|
||||||
class SuggestionsFragment : MainFragment {
|
class SuggestionsFragment : MainFragment {
|
||||||
override val isMainView : Boolean = true;
|
override val isMainView : Boolean = true;
|
||||||
override val hasBottomBar: Boolean = false;
|
override val hasBottomBar: Boolean = true;
|
||||||
override val isHistory: Boolean = false;
|
override val isHistory: Boolean = false;
|
||||||
|
|
||||||
private var _recyclerSuggestions: RecyclerView? = null;
|
private var _recyclerSuggestions: RecyclerView? = null;
|
||||||
private var _llmSuggestions: LinearLayoutManager? = null;
|
private var _llmSuggestions: LinearLayoutManager? = null;
|
||||||
private var _radioGroupView: RadioGroupView? = null;
|
private var _radioGroupView: RadioGroupView? = null;
|
||||||
private val _suggestions: ArrayList<String> = ArrayList();
|
private val _suggestions: ArrayList<String> = ArrayList();
|
||||||
|
|||||||
+4
@@ -32,6 +32,7 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
|||||||
import com.futo.platformplayer.api.media.structures.EmptyPager
|
import com.futo.platformplayer.api.media.structures.EmptyPager
|
||||||
import com.futo.platformplayer.api.media.structures.IPager
|
import com.futo.platformplayer.api.media.structures.IPager
|
||||||
import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFragment
|
import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFragment
|
||||||
|
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
||||||
import com.futo.platformplayer.views.pills.WidePillButton
|
import com.futo.platformplayer.views.pills.WidePillButton
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
|
|
||||||
@@ -152,6 +153,9 @@ class TutorialFragment : MainFragment() {
|
|||||||
override val viewCount: Long = -1
|
override val viewCount: Long = -1
|
||||||
override val video: IVideoSourceDescriptor = TutorialVideoSourceDescriptor(videoUrl, duration, width, height)
|
override val video: IVideoSourceDescriptor = TutorialVideoSourceDescriptor(videoUrl, duration, width, height)
|
||||||
override val isShort: Boolean = false;
|
override val isShort: Boolean = false;
|
||||||
|
override var playbackTime: Long = -1;
|
||||||
|
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
|
||||||
|
override var playbackDate: OffsetDateTime? = null;
|
||||||
override fun getComments(client: IPlatformClient): IPager<IPlatformComment> {
|
override fun getComments(client: IPlatformClient): IPager<IPlatformComment> {
|
||||||
return EmptyPager()
|
return EmptyPager()
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-12
@@ -337,13 +337,6 @@ class VideoDetailFragment() : MainFragment() {
|
|||||||
closeVideoDetails();
|
closeVideoDetails();
|
||||||
};
|
};
|
||||||
it.onMaximize.subscribe { maximizeVideoDetail(it) };
|
it.onMaximize.subscribe { maximizeVideoDetail(it) };
|
||||||
it.onPlayChanged.subscribe {
|
|
||||||
if(isInPictureInPicture) {
|
|
||||||
val params = _viewDetail?.getPictureInPictureParams();
|
|
||||||
if (params != null)
|
|
||||||
activity?.setPictureInPictureParams(params);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
it.onEnterPictureInPicture.subscribe {
|
it.onEnterPictureInPicture.subscribe {
|
||||||
Logger.i(TAG, "onEnterPictureInPicture")
|
Logger.i(TAG, "onEnterPictureInPicture")
|
||||||
isInPictureInPicture = true;
|
isInPictureInPicture = true;
|
||||||
@@ -444,11 +437,16 @@ class VideoDetailFragment() : MainFragment() {
|
|||||||
|
|
||||||
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?.isAudioOnlyUserAction}");
|
||||||
|
|
||||||
if(viewDetail?.preventPictureInPicture == false && !StateCasting.instance.isCasting && Settings.instance.playback.isBackgroundPictureInPicture() && !viewDetail.allowBackground) {
|
if (viewDetail === null) {
|
||||||
_leavingPiP = false;
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (viewDetail.shouldEnterPictureInPicture) {
|
||||||
|
_leavingPiP = false
|
||||||
|
}
|
||||||
|
if(Build.VERSION.SDK_INT < Build.VERSION_CODES.S && viewDetail.preventPictureInPicture == false && !StateCasting.instance.isCasting && Settings.instance.playback.isBackgroundPictureInPicture() && !viewDetail.isAudioOnlyUserAction) {
|
||||||
val params = _viewDetail?.getPictureInPictureParams();
|
val params = _viewDetail?.getPictureInPictureParams();
|
||||||
if(params != null) {
|
if(params != null) {
|
||||||
Logger.i(TAG, "enterPictureInPictureMode")
|
Logger.i(TAG, "enterPictureInPictureMode")
|
||||||
@@ -457,7 +455,7 @@ class VideoDetailFragment() : MainFragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isFullscreen) {
|
if (isFullscreen) {
|
||||||
viewDetail?.restoreBrightness()
|
viewDetail.restoreBrightness()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -528,7 +526,7 @@ class VideoDetailFragment() : MainFragment() {
|
|||||||
|
|
||||||
private fun stopIfRequired() {
|
private fun stopIfRequired() {
|
||||||
var shouldStop = true;
|
var shouldStop = true;
|
||||||
if (_viewDetail?.allowBackground == true) {
|
if (_viewDetail?.isAudioOnlyUserAction == true) {
|
||||||
shouldStop = false;
|
shouldStop = false;
|
||||||
} else if (Settings.instance.playback.isBackgroundPictureInPicture() && !_leavingPiP) {
|
} else if (Settings.instance.playback.isBackgroundPictureInPicture() && !_leavingPiP) {
|
||||||
shouldStop = false;
|
shouldStop = false;
|
||||||
|
|||||||
+294
-111
@@ -10,12 +10,12 @@ import android.content.Intent
|
|||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.Rect
|
|
||||||
import android.graphics.drawable.Animatable
|
import android.graphics.drawable.Animatable
|
||||||
import android.graphics.drawable.BitmapDrawable
|
import android.graphics.drawable.BitmapDrawable
|
||||||
import android.graphics.drawable.Drawable
|
import android.graphics.drawable.Drawable
|
||||||
import android.graphics.drawable.Icon
|
import android.graphics.drawable.Icon
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
import android.support.v4.media.session.PlaybackStateCompat
|
import android.support.v4.media.session.PlaybackStateCompat
|
||||||
import android.text.Spanned
|
import android.text.Spanned
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
@@ -80,7 +80,6 @@ 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.JSClient
|
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.SourcePluginConfig
|
||||||
import com.futo.platformplayer.api.media.platforms.js.models.JSVideo
|
|
||||||
import com.futo.platformplayer.api.media.platforms.js.models.JSVideoDetails
|
import com.futo.platformplayer.api.media.platforms.js.models.JSVideoDetails
|
||||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
|
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
|
||||||
import com.futo.platformplayer.api.media.structures.IPager
|
import com.futo.platformplayer.api.media.structures.IPager
|
||||||
@@ -247,7 +246,13 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
private val _buttonPins: RoundButtonGroup;
|
private val _buttonPins: RoundButtonGroup;
|
||||||
//private val _buttonMore: RoundButton;
|
//private val _buttonMore: RoundButton;
|
||||||
|
|
||||||
var preventPictureInPicture: Boolean = false;
|
var preventPictureInPicture: Boolean = false
|
||||||
|
set(value) {
|
||||||
|
if (field != value) {
|
||||||
|
field = value
|
||||||
|
onShouldEnterPictureInPictureChanged.emit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val _addCommentView: AddCommentView;
|
private val _addCommentView: AddCommentView;
|
||||||
private var _tabIndex: Int? = null;
|
private var _tabIndex: Int? = null;
|
||||||
@@ -316,11 +321,24 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
val onClose = Event0();
|
val onClose = Event0();
|
||||||
val onFullscreenChanged = Event1<Boolean>();
|
val onFullscreenChanged = Event1<Boolean>();
|
||||||
val onEnterPictureInPicture = Event0();
|
val onEnterPictureInPicture = Event0();
|
||||||
val onPlayChanged = Event1<Boolean>();
|
|
||||||
val onVideoChanged = Event2<Int, Int>()
|
val onVideoChanged = Event2<Int, Int>()
|
||||||
|
|
||||||
var allowBackground : Boolean = false
|
var isAudioOnlyUserAction: Boolean = false
|
||||||
private set;
|
private set(value) {
|
||||||
|
if (field != value) {
|
||||||
|
field = value
|
||||||
|
onShouldEnterPictureInPictureChanged.emit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val shouldEnterPictureInPicture: Boolean
|
||||||
|
get() = !preventPictureInPicture &&
|
||||||
|
!StateCasting.instance.isCasting &&
|
||||||
|
Settings.instance.playback.isBackgroundPictureInPicture() &&
|
||||||
|
!isAudioOnlyUserAction &&
|
||||||
|
isPlaying
|
||||||
|
|
||||||
|
val onShouldEnterPictureInPictureChanged = Event0();
|
||||||
|
|
||||||
val onTouchCancel = Event0();
|
val onTouchCancel = Event0();
|
||||||
private var _lastPositionSaveTime: Long = -1;
|
private var _lastPositionSaveTime: Long = -1;
|
||||||
@@ -433,6 +451,29 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
fragment.navigate<VideoDetailFragment>(it.targetUrl);
|
fragment.navigate<VideoDetailFragment>(it.targetUrl);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
_container_content_liveChat.onUrlClick.subscribe { uri ->
|
||||||
|
val c = context
|
||||||
|
if (c is MainActivity) {
|
||||||
|
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||||
|
try {
|
||||||
|
if (!c.handleUrl(uri.toString())) {
|
||||||
|
Intent(Intent.ACTION_VIEW, uri).apply {
|
||||||
|
addCategory(Intent.CATEGORY_BROWSABLE)
|
||||||
|
context.startActivity(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Log.e(TAG, "Failed to handle live chat URL")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Intent(Intent.ACTION_VIEW, uri).apply {
|
||||||
|
addCategory(Intent.CATEGORY_BROWSABLE)
|
||||||
|
context.startActivity(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_monetization.onSupportTap.subscribe {
|
_monetization.onSupportTap.subscribe {
|
||||||
_container_content_support.setPolycentricProfile(_polycentricProfile);
|
_container_content_support.setPolycentricProfile(_polycentricProfile);
|
||||||
switchContentView(_container_content_support);
|
switchContentView(_container_content_support);
|
||||||
@@ -457,11 +498,6 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
|
|
||||||
_player.attachPlayer();
|
_player.attachPlayer();
|
||||||
|
|
||||||
_container_content_liveChat.onRaidNow.subscribe {
|
|
||||||
StatePlayer.instance.clearQueue();
|
|
||||||
fragment.navigate<VideoDetailFragment>(it.targetUrl);
|
|
||||||
};
|
|
||||||
|
|
||||||
StateApp.instance.preventPictureInPicture.subscribe(this) {
|
StateApp.instance.preventPictureInPicture.subscribe(this) {
|
||||||
Logger.i(TAG, "StateApp.instance.preventPictureInPicture.subscribe preventPictureInPicture = true");
|
Logger.i(TAG, "StateApp.instance.preventPictureInPicture.subscribe preventPictureInPicture = true");
|
||||||
preventPictureInPicture = true;
|
preventPictureInPicture = true;
|
||||||
@@ -540,9 +576,8 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
if(chapter?.type == ChapterType.SKIPPABLE) {
|
if(chapter?.type == ChapterType.SKIPPABLE) {
|
||||||
_layoutSkip.visibility = VISIBLE;
|
_layoutSkip.visibility = VISIBLE;
|
||||||
} else if(chapter?.type == ChapterType.SKIP || chapter?.type == ChapterType.SKIPONCE) {
|
} else if(chapter?.type == ChapterType.SKIP || chapter?.type == ChapterType.SKIPONCE) {
|
||||||
val ad = StateCasting.instance.activeDevice
|
if (StateCasting.instance.activeDevice != null) {
|
||||||
if (ad != null) {
|
StateCasting.instance.videoSeekTo(chapter.timeEnd)
|
||||||
ad.seekVideo(chapter.timeEnd)
|
|
||||||
} else {
|
} else {
|
||||||
_player.seekTo((chapter.timeEnd * 1000).toLong());
|
_player.seekTo((chapter.timeEnd * 1000).toLong());
|
||||||
}
|
}
|
||||||
@@ -622,8 +657,13 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
onShouldEnterPictureInPictureChanged.subscribe {
|
||||||
|
val params = getPictureInPictureParams()
|
||||||
|
fragment.activity?.setPictureInPictureParams(params)
|
||||||
|
}
|
||||||
|
|
||||||
if (!isInEditMode) {
|
if (!isInEditMode) {
|
||||||
StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, connectionState ->
|
StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { device, connectionState ->
|
||||||
if (_onPauseCalled) {
|
if (_onPauseCalled) {
|
||||||
return@subscribe;
|
return@subscribe;
|
||||||
}
|
}
|
||||||
@@ -635,7 +675,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
setCastEnabled(true);
|
setCastEnabled(true);
|
||||||
}
|
}
|
||||||
CastConnectionState.DISCONNECTED -> {
|
CastConnectionState.DISCONNECTED -> {
|
||||||
loadCurrentVideo(lastPositionMilliseconds);
|
loadCurrentVideo(lastPositionMilliseconds, playWhenReady = device.isPlaying);
|
||||||
updatePillButtonVisibilities();
|
updatePillButtonVisibilities();
|
||||||
setCastEnabled(false);
|
setCastEnabled(false);
|
||||||
|
|
||||||
@@ -719,8 +759,8 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
};
|
};
|
||||||
MediaControlReceiver.onBackgroundReceived.subscribe(this) {
|
MediaControlReceiver.onBackgroundReceived.subscribe(this) {
|
||||||
Logger.i(TAG, "MediaControlReceiver.onBackgroundReceived")
|
Logger.i(TAG, "MediaControlReceiver.onBackgroundReceived")
|
||||||
_player.switchToAudioMode();
|
_player.switchToAudioMode(video);
|
||||||
allowBackground = true;
|
isAudioOnlyUserAction = true;
|
||||||
StateApp.instance.contextOrNull?.let {
|
StateApp.instance.contextOrNull?.let {
|
||||||
try {
|
try {
|
||||||
if (it is MainActivity) {
|
if (it is MainActivity) {
|
||||||
@@ -845,7 +885,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
if (ad != null) {
|
if (ad != null) {
|
||||||
val currentChapter = _cast.getCurrentChapter((ad.time * 1000).toLong());
|
val currentChapter = _cast.getCurrentChapter((ad.time * 1000).toLong());
|
||||||
if(currentChapter?.type == ChapterType.SKIPPABLE) {
|
if(currentChapter?.type == ChapterType.SKIPPABLE) {
|
||||||
ad.seekVideo(currentChapter.timeEnd);
|
StateCasting.instance.videoSeekTo(currentChapter.timeEnd);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val currentChapter = _player.getCurrentChapter(_player.position);
|
val currentChapter = _player.getCurrentChapter(_player.position);
|
||||||
@@ -938,6 +978,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
return@let it.config.reduceFunctionsInLimitedVersion && BuildConfig.IS_PLAYSTORE_BUILD
|
return@let it.config.reduceFunctionsInLimitedVersion && BuildConfig.IS_PLAYSTORE_BUILD
|
||||||
else false;
|
else false;
|
||||||
} ?: false;
|
} ?: false;
|
||||||
|
|
||||||
val buttons = listOf(RoundButton(context, R.drawable.ic_add, context.getString(R.string.add), TAG_ADD) {
|
val buttons = listOf(RoundButton(context, R.drawable.ic_add, context.getString(R.string.add), TAG_ADD) {
|
||||||
(video ?: _searchVideo)?.let {
|
(video ?: _searchVideo)?.let {
|
||||||
_slideUpOverlay = UISlideOverlays.showAddToOverlay(it, _overlayContainer) {
|
_slideUpOverlay = UISlideOverlays.showAddToOverlay(it, _overlayContainer) {
|
||||||
@@ -963,15 +1004,26 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
_slideUpOverlay?.hide();
|
_slideUpOverlay?.hide();
|
||||||
|
} else if(video is JSVideoDetails && (video as JSVideoDetails).hasVODEvents())
|
||||||
|
RoundButton(context, R.drawable.ic_chat, context.getString(R.string.vod_chat), TAG_VODCHAT) {
|
||||||
|
video?.let {
|
||||||
|
try {
|
||||||
|
loadVODChat(it);
|
||||||
|
}
|
||||||
|
catch(ex: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to reopen vod chat", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_slideUpOverlay?.hide();
|
||||||
} else null,
|
} else null,
|
||||||
if (!isLimitedVersion) RoundButton(context, R.drawable.ic_screen_share, if (allowBackground) context.getString(R.string.background_revert) else context.getString(R.string.background), TAG_BACKGROUND) {
|
if (!isLimitedVersion) RoundButton(context, R.drawable.ic_screen_share, if (isAudioOnlyUserAction) context.getString(R.string.background_revert) else context.getString(R.string.background), TAG_BACKGROUND) {
|
||||||
if (!allowBackground) {
|
if (!isAudioOnlyUserAction) {
|
||||||
_player.switchToAudioMode();
|
_player.switchToAudioMode(video);
|
||||||
allowBackground = true;
|
isAudioOnlyUserAction = true;
|
||||||
it.text.text = resources.getString(R.string.background_revert);
|
it.text.text = resources.getString(R.string.background_revert);
|
||||||
} else {
|
} else {
|
||||||
_player.switchToVideoMode();
|
_player.switchToVideoMode();
|
||||||
allowBackground = false;
|
isAudioOnlyUserAction = false;
|
||||||
it.text.text = resources.getString(R.string.background);
|
it.text.text = resources.getString(R.string.background);
|
||||||
}
|
}
|
||||||
_slideUpOverlay?.hide();
|
_slideUpOverlay?.hide();
|
||||||
@@ -1087,19 +1139,23 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
|
|
||||||
|
|
||||||
//Lifecycle
|
//Lifecycle
|
||||||
|
var isLoginStop = false; //TODO: This is a bit jank, but easiest solution for now without reworking flow. (Alternatively, fix MainActivity getting stopped/disposing video)
|
||||||
fun onResume() {
|
fun onResume() {
|
||||||
Logger.v(TAG, "onResume");
|
Logger.v(TAG, "onResume");
|
||||||
_onPauseCalled = false;
|
_onPauseCalled = false;
|
||||||
|
|
||||||
|
val wasLoginCall = isLoginStop;
|
||||||
|
isLoginStop = false;
|
||||||
|
|
||||||
Logger.i(TAG, "_video: ${video?.name ?: "no video"}");
|
Logger.i(TAG, "_video: ${video?.name ?: "no video"}");
|
||||||
Logger.i(TAG, "_didStop: $_didStop");
|
Logger.i(TAG, "_didStop: $_didStop");
|
||||||
|
|
||||||
//Recover cancelled loads
|
//Recover cancelled loads
|
||||||
if(video == null) {
|
if(video == null) {
|
||||||
val t = (lastPositionMilliseconds / 1000.0f).roundToLong();
|
val t = (lastPositionMilliseconds / 1000.0f).roundToLong();
|
||||||
if(_searchVideo != null)
|
if(_searchVideo != null && !wasLoginCall)
|
||||||
setVideoOverview(_searchVideo!!, true, t);
|
setVideoOverview(_searchVideo!!, true, t);
|
||||||
else if(_url != null)
|
else if(_url != null && !wasLoginCall)
|
||||||
setVideo(_url!!, t, _playWhenReady);
|
setVideo(_url!!, t, _playWhenReady);
|
||||||
}
|
}
|
||||||
else if(_didStop) {
|
else if(_didStop) {
|
||||||
@@ -1111,10 +1167,14 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
|
|
||||||
if(_player.isAudioMode) {
|
if(_player.isAudioMode) {
|
||||||
//Requested behavior to leave it in audio mode. leaving it commented if it causes issues, revert?
|
//Requested behavior to leave it in audio mode. leaving it commented if it causes issues, revert?
|
||||||
if(!allowBackground) {
|
if(!isAudioOnlyUserAction) {
|
||||||
_player.switchToVideoMode();
|
_player.switchToVideoMode();
|
||||||
|
isAudioOnlyUserAction = false;
|
||||||
_buttonPins.getButtonByTag(TAG_BACKGROUND)?.text?.text = resources.getString(R.string.background);
|
_buttonPins.getButtonByTag(TAG_BACKGROUND)?.text?.text = resources.getString(R.string.background);
|
||||||
}
|
}
|
||||||
|
else {
|
||||||
|
_buttonPins.getButtonByTag(TAG_BACKGROUND)?.text?.text = resources.getString(R.string.video);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if(!_player.isFitMode && !_player.isFullScreen && !fragment.isInPictureInPicture)
|
if(!_player.isFitMode && !_player.isFullScreen && !fragment.isInPictureInPicture)
|
||||||
_player.fitHeight();
|
_player.fitHeight();
|
||||||
@@ -1130,18 +1190,23 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
if(StateCasting.instance.isCasting)
|
if(StateCasting.instance.isCasting)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if(allowBackground)
|
if(isAudioOnlyUserAction)
|
||||||
StatePlayer.instance.startOrUpdateMediaSession(context, video);
|
StatePlayer.instance.startOrUpdateMediaSession(context, video);
|
||||||
else {
|
else {
|
||||||
when (Settings.instance.playback.backgroundPlay) {
|
when (Settings.instance.playback.backgroundPlay) {
|
||||||
0 -> handlePause();
|
0 -> handlePause();
|
||||||
1 -> {
|
1 -> {
|
||||||
if(!(video?.isLive ?: false))
|
if(!(video?.isLive ?: false)) {
|
||||||
_player.switchToAudioMode();
|
_player.switchToAudioMode(video);
|
||||||
|
}
|
||||||
StatePlayer.instance.startOrUpdateMediaSession(context, video);
|
StatePlayer.instance.startOrUpdateMediaSession(context, video);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_player.isFullScreen) {
|
||||||
|
restoreBrightness()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fun onStop() {
|
fun onStop() {
|
||||||
Logger.i(TAG, "onStop");
|
Logger.i(TAG, "onStop");
|
||||||
@@ -1155,6 +1220,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
_taskLoadVideo.cancel();
|
_taskLoadVideo.cancel();
|
||||||
handleStop();
|
handleStop();
|
||||||
_didStop = true;
|
_didStop = true;
|
||||||
|
onShouldEnterPictureInPictureChanged.emit()
|
||||||
Logger.i(TAG, "_didStop set to true");
|
Logger.i(TAG, "_didStop set to true");
|
||||||
|
|
||||||
StatePlayer.instance.rotationLock = false;
|
StatePlayer.instance.rotationLock = false;
|
||||||
@@ -1743,12 +1809,19 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
|
|
||||||
_liveChat?.stop();
|
_liveChat?.stop();
|
||||||
_liveChat = null;
|
_liveChat = null;
|
||||||
|
var gotLive = false;
|
||||||
if (video.isLive && video.live != null) {
|
if (video.isLive && video.live != null) {
|
||||||
loadLiveChat(video);
|
loadLiveChat(video);
|
||||||
|
gotLive = true;
|
||||||
}
|
}
|
||||||
if (video.isLive && video.live == null && !video.video.videoSources.any())
|
if (video.isLive && video.live == null && !video.video.videoSources.any()) {
|
||||||
startLiveTry(video);
|
startLiveTry(video);
|
||||||
|
gotLive = true;
|
||||||
|
}
|
||||||
|
if(!gotLive && video is JSVideoDetails && video.hasVODEvents()) {
|
||||||
|
Logger.i(TAG, "Loading VOD chat");
|
||||||
|
loadVODChat(video);
|
||||||
|
}
|
||||||
|
|
||||||
_player.updateNextPrevious();
|
_player.updateNextPrevious();
|
||||||
updateMoreButtons();
|
updateMoreButtons();
|
||||||
@@ -1772,6 +1845,43 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
_taskLoadRecommendations.run(videoDetail.url)
|
_taskLoadRecommendations.run(videoDetail.url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
fun loadVODChat(video: IPlatformVideoDetails) {
|
||||||
|
_liveChat?.stop();
|
||||||
|
_container_content_liveChat.cancel();
|
||||||
|
_liveChat = null;
|
||||||
|
if(video !is JSVideoDetails)
|
||||||
|
return;
|
||||||
|
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
var livePager: IPager<IPlatformLiveEvent>?;
|
||||||
|
try {
|
||||||
|
//TODO: Create video.getLiveEvents shortcut/optimalization
|
||||||
|
livePager = video.getVODEvents(video.url);
|
||||||
|
} catch (ex: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to obtain VODEvents pager", ex);
|
||||||
|
livePager = null;
|
||||||
|
}
|
||||||
|
val liveChat = livePager?.let {
|
||||||
|
val liveChatManager = LiveChatManager(fragment.lifecycleScope, livePager, video.viewCount);
|
||||||
|
liveChatManager.start();
|
||||||
|
return@let liveChatManager;
|
||||||
|
}
|
||||||
|
_liveChat = liveChat;
|
||||||
|
|
||||||
|
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||||
|
try {
|
||||||
|
_container_content_liveChat.load(fragment.lifecycleScope, liveChat, null, if(liveChat != null) video.viewCount else null);
|
||||||
|
switchContentView(_container_content_liveChat);
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to switch content view to vod chat.", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch(ex: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to load vod chat", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
fun loadLiveChat(video: IPlatformVideoDetails) {
|
fun loadLiveChat(video: IPlatformVideoDetails) {
|
||||||
_liveChat?.stop();
|
_liveChat?.stop();
|
||||||
_container_content_liveChat.cancel();
|
_container_content_liveChat.cancel();
|
||||||
@@ -1846,7 +1956,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
//Source Loads
|
//Source Loads
|
||||||
private fun loadCurrentVideo(resumePositionMs: Long = 0) {
|
private fun loadCurrentVideo(resumePositionMs: Long = 0, playWhenReady: Boolean = true) {
|
||||||
_didStop = false;
|
_didStop = false;
|
||||||
|
|
||||||
val video = (videoLocal ?: video) ?: return;
|
val video = (videoLocal ?: video) ?: return;
|
||||||
@@ -1867,26 +1977,52 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
if (!isCasting) {
|
if (!isCasting) {
|
||||||
setCastEnabled(false);
|
setCastEnabled(false);
|
||||||
|
|
||||||
val thumbnail = video.thumbnails.getHQThumbnail();
|
val isLimitedVersion = StatePlatform.instance.getContentClientOrNull(video.url)?.let {
|
||||||
if (videoSource == null && !thumbnail.isNullOrBlank())
|
if (it is JSClient)
|
||||||
Glide.with(context).asBitmap().load(thumbnail)
|
return@let it.config.reduceFunctionsInLimitedVersion && BuildConfig.IS_PLAYSTORE_BUILD
|
||||||
.into(object: CustomTarget<Bitmap>() {
|
else false;
|
||||||
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
} ?: false;
|
||||||
_player.setArtwork(BitmapDrawable(resources, resource));
|
|
||||||
}
|
if (isLimitedVersion && _player.isAudioMode) {
|
||||||
override fun onLoadCleared(placeholder: Drawable?) {
|
_player.switchToVideoMode()
|
||||||
_player.setArtwork(null);
|
isAudioOnlyUserAction = false;
|
||||||
}
|
} else {
|
||||||
});
|
val thumbnail = video.thumbnails.getHQThumbnail();
|
||||||
else
|
if ((videoSource == null) && !thumbnail.isNullOrBlank()) // || _player.isAudioMode
|
||||||
_player.setArtwork(null);
|
Glide.with(context).asBitmap().load(thumbnail)
|
||||||
_player.setSource(videoSource, audioSource, _playWhenReady, false, resume = resumePositionMs > 0);
|
.into(object: CustomTarget<Bitmap>() {
|
||||||
if(subtitleSource != null)
|
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
||||||
_player.swapSubtitles(fragment.lifecycleScope, subtitleSource);
|
_player.setArtwork(BitmapDrawable(resources, resource));
|
||||||
_player.seekTo(resumePositionMs);
|
}
|
||||||
|
override fun onLoadCleared(placeholder: Drawable?) {
|
||||||
|
_player.setArtwork(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
else
|
||||||
|
_player.setArtwork(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||||
|
try {
|
||||||
|
_player.setSource(videoSource, audioSource, _playWhenReady && playWhenReady, false, resume = resumePositionMs > 0);
|
||||||
|
if(subtitleSource != null)
|
||||||
|
_player.swapSubtitles(subtitleSource);
|
||||||
|
_player.seekTo(resumePositionMs);
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "loadCurrentVideo failed", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else {
|
||||||
loadCurrentVideoCast(video, videoSource, audioSource, subtitleSource, resumePositionMs, Settings.instance.playback.getDefaultPlaybackSpeed().toDouble());
|
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||||
|
try {
|
||||||
|
loadCurrentVideoCast(video, videoSource, audioSource, subtitleSource, resumePositionMs, Settings.instance.playback.getDefaultPlaybackSpeed().toDouble());
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "loadCurrentVideo failed (casting)", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
_lastVideoSource = videoSource;
|
_lastVideoSource = videoSource;
|
||||||
_lastAudioSource = audioSource;
|
_lastAudioSource = audioSource;
|
||||||
@@ -1901,47 +2037,45 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.failed_to_load_media), ex);
|
UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.failed_to_load_media), ex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
private fun loadCurrentVideoCast(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, resumePositionMs: Long, speed: Double?) {
|
private suspend fun loadCurrentVideoCast(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, resumePositionMs: Long, speed: Double?) {
|
||||||
Logger.i(TAG, "loadCurrentVideoCast(video=$video, videoSource=$videoSource, audioSource=$audioSource, resumePositionMs=$resumePositionMs)")
|
Logger.i(TAG, "loadCurrentVideoCast(video=$video, videoSource=$videoSource, audioSource=$audioSource, resumePositionMs=$resumePositionMs)")
|
||||||
castIfAvailable(context.contentResolver, video, videoSource, audioSource, subtitleSource, resumePositionMs, speed)
|
castIfAvailable(context.contentResolver, video, videoSource, audioSource, subtitleSource, resumePositionMs, speed)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun castIfAvailable(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, resumePositionMs: Long, speed: Double?) {
|
private suspend fun castIfAvailable(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, resumePositionMs: Long, speed: Double?) {
|
||||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
try {
|
||||||
|
val plugin = if (videoSource is JSSource) videoSource.getUnderlyingPlugin()
|
||||||
|
else if (audioSource is JSSource) audioSource.getUnderlyingPlugin()
|
||||||
|
else null
|
||||||
|
|
||||||
|
val startId = plugin?.getUnderlyingPlugin()?.runtimeId
|
||||||
try {
|
try {
|
||||||
val plugin = if (videoSource is JSSource) videoSource.getUnderlyingPlugin()
|
val castingSucceeded = StateCasting.instance.castIfAvailable(contentResolver, video, videoSource, audioSource, subtitleSource, resumePositionMs, speed, onLoading = {
|
||||||
else if (audioSource is JSSource) audioSource.getUnderlyingPlugin()
|
_cast.setLoading(it)
|
||||||
else null
|
}, onLoadingEstimate = {
|
||||||
|
_cast.setLoading(it)
|
||||||
|
})
|
||||||
|
|
||||||
val startId = plugin?.getUnderlyingPlugin()?.runtimeId
|
if (castingSucceeded) {
|
||||||
try {
|
withContext(Dispatchers.Main) {
|
||||||
val castingSucceeded = StateCasting.instance.castIfAvailable(contentResolver, video, videoSource, audioSource, subtitleSource, resumePositionMs, speed, onLoading = {
|
_cast.setVideoDetails(video, resumePositionMs / 1000);
|
||||||
_cast.setLoading(it)
|
setCastEnabled(true);
|
||||||
}, onLoadingEstimate = {
|
|
||||||
_cast.setLoading(it)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (castingSucceeded) {
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
_cast.setVideoDetails(video, resumePositionMs / 1000);
|
|
||||||
setCastEnabled(true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e: ScriptReloadRequiredException) {
|
|
||||||
Log.i(TAG, "Reload required exception", e)
|
|
||||||
if (plugin == null)
|
|
||||||
throw e
|
|
||||||
|
|
||||||
if (startId != -1 && plugin.getUnderlyingPlugin().runtimeId != startId)
|
|
||||||
throw e
|
|
||||||
|
|
||||||
StatePlatform.instance.handleReloadRequired(e, {
|
|
||||||
fetchVideo()
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (e: Throwable) {
|
} catch (e: ScriptReloadRequiredException) {
|
||||||
Logger.e(TAG, "loadCurrentVideoCast", e)
|
Log.i(TAG, "Reload required exception", e)
|
||||||
|
if (plugin == null)
|
||||||
|
throw e
|
||||||
|
|
||||||
|
if (startId != -1 && plugin.getUnderlyingPlugin().runtimeId != startId)
|
||||||
|
throw e
|
||||||
|
|
||||||
|
StatePlatform.instance.handleReloadRequired(e, {
|
||||||
|
fetchVideo()
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "loadCurrentVideoCast", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1982,6 +2116,10 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
videoTrackFormats.distinctBy { it.height }.sortedByDescending { it.height },
|
videoTrackFormats.distinctBy { it.height }.sortedByDescending { it.height },
|
||||||
audioTrackFormats.distinctBy { it.bitrate }.sortedByDescending { it.bitrate });
|
audioTrackFormats.distinctBy { it.bitrate }.sortedByDescending { it.bitrate });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_layoutPlayerContainer.post {
|
||||||
|
onShouldEnterPictureInPictureChanged.emit()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var _didTriggerDatasourceErrorCount = 0;
|
private var _didTriggerDatasourceErrorCount = 0;
|
||||||
@@ -2229,11 +2367,11 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
?.distinct()
|
?.distinct()
|
||||||
?.toList() ?: listOf() else audioSources?.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()
|
||||||
val qualityPlaybackSpeedTitle = if (canSetSpeed) SlideUpMenuTitle(this.context).apply { setTitle(context.getString(R.string.playback_rate) + " (${String.format("%.2f", currentPlaybackRate)})"); } else null;
|
val qualityPlaybackSpeedTitle = if (canSetSpeed) SlideUpMenuTitle(this.context).apply { setTitle(context.getString(R.string.playback_rate) + " (${String.format("%.2f", currentPlaybackRate)})"); } else null;
|
||||||
_overlay_quality_selector = SlideUpMenuOverlay(this.context, _overlay_quality_container, context.getString(
|
_overlay_quality_selector = SlideUpMenuOverlay(this.context, _overlay_quality_container, context.getString(
|
||||||
R.string.quality), null, true,
|
R.string.quality), null, true,
|
||||||
qualityPlaybackSpeedTitle,
|
qualityPlaybackSpeedTitle,
|
||||||
if (canSetSpeed) SlideUpMenuButtonList(this.context, null, "playback_rate").apply {
|
if (canSetSpeed) SlideUpMenuButtonList(this.context, null, "playback_rate").apply {
|
||||||
val playbackSpeeds = Settings.instance.playback.getPlaybackSpeeds();
|
val playbackSpeeds = Settings.instance.playback.getPlaybackSpeeds();
|
||||||
@@ -2254,7 +2392,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
val newPlaybackSpeed = playbackSpeedString.toDouble();
|
val newPlaybackSpeed = playbackSpeedString.toDouble();
|
||||||
if (_isCasting) {
|
if (_isCasting) {
|
||||||
val ad = StateCasting.instance.activeDevice ?: return@subscribe
|
val ad = StateCasting.instance.activeDevice ?: return@subscribe
|
||||||
if (!ad.canSetSpeed) {
|
if (!ad.canSetSpeed()) {
|
||||||
return@subscribe
|
return@subscribe
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2378,6 +2516,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
if (!StateCasting.instance.resumeVideo()) {
|
if (!StateCasting.instance.resumeVideo()) {
|
||||||
_player.play();
|
_player.play();
|
||||||
}
|
}
|
||||||
|
onShouldEnterPictureInPictureChanged.emit()
|
||||||
|
|
||||||
//TODO: This was needed because handleLowerVolume was done.
|
//TODO: This was needed because handleLowerVolume was done.
|
||||||
//_player.setVolume(1.0f);
|
//_player.setVolume(1.0f);
|
||||||
@@ -2394,6 +2533,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
if (!StateCasting.instance.pauseVideo()) {
|
if (!StateCasting.instance.pauseVideo()) {
|
||||||
_player.pause();
|
_player.pause();
|
||||||
}
|
}
|
||||||
|
onShouldEnterPictureInPictureChanged.emit()
|
||||||
}
|
}
|
||||||
private fun handleSeek(ms: Long) {
|
private fun handleSeek(ms: Long) {
|
||||||
Logger.i(TAG, "handleSeek(ms=$ms)")
|
Logger.i(TAG, "handleSeek(ms=$ms)")
|
||||||
@@ -2442,7 +2582,6 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isPlaying = playing;
|
isPlaying = playing;
|
||||||
onPlayChanged.emit(playing);
|
|
||||||
updateTracker(lastPositionMilliseconds, playing, true);
|
updateTracker(lastPositionMilliseconds, playing, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2453,11 +2592,17 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
if(_lastVideoSource == videoSource)
|
if(_lastVideoSource == videoSource)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
val d = StateCasting.instance.activeDevice;
|
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||||
if (d != null && d.connectionState == CastConnectionState.CONNECTED)
|
try {
|
||||||
castIfAvailable(context.contentResolver, video, videoSource, _lastAudioSource, _lastSubtitleSource, (d.expectedCurrentTime * 1000.0).toLong(), d.speed);
|
val d = StateCasting.instance.activeDevice;
|
||||||
else if(!_player.swapSources(videoSource, _lastAudioSource, true, true, true))
|
if (d != null && d.connectionState == CastConnectionState.CONNECTED)
|
||||||
_player.hideControls(false); //TODO: Disable player?
|
castIfAvailable(context.contentResolver, video, videoSource, _lastAudioSource, _lastSubtitleSource, (d.expectedCurrentTime * 1000.0).toLong(), d.speed);
|
||||||
|
else if(!_player.swapSources(videoSource, _lastAudioSource, true, true, true))
|
||||||
|
_player.hideControls(false); //TODO: Disable player?
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "handleSelectVideoTrack failed", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_lastVideoSource = videoSource;
|
_lastVideoSource = videoSource;
|
||||||
}
|
}
|
||||||
@@ -2468,11 +2613,17 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
if(_lastAudioSource == audioSource)
|
if(_lastAudioSource == audioSource)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
val d = StateCasting.instance.activeDevice;
|
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||||
if (d != null && d.connectionState == CastConnectionState.CONNECTED)
|
try {
|
||||||
castIfAvailable(context.contentResolver, video, _lastVideoSource, audioSource, _lastSubtitleSource, (d.expectedCurrentTime * 1000.0).toLong(), d.speed)
|
val d = StateCasting.instance.activeDevice;
|
||||||
else(!_player.swapSources(_lastVideoSource, audioSource, true, true, true))
|
if (d != null && d.connectionState == CastConnectionState.CONNECTED)
|
||||||
_player.hideControls(false); //TODO: Disable player?
|
castIfAvailable(context.contentResolver, video, _lastVideoSource, audioSource, _lastSubtitleSource, (d.expectedCurrentTime * 1000.0).toLong(), d.speed)
|
||||||
|
else if (!_player.swapSources(_lastVideoSource, audioSource, true, true, true))
|
||||||
|
_player.hideControls(false); //TODO: Disable player?
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "handleSelectAudioTrack failed", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_lastAudioSource = audioSource;
|
_lastAudioSource = audioSource;
|
||||||
}
|
}
|
||||||
@@ -2484,12 +2635,18 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
if(_lastSubtitleSource == subtitleSource)
|
if(_lastSubtitleSource == subtitleSource)
|
||||||
toSet = null;
|
toSet = null;
|
||||||
|
|
||||||
val d = StateCasting.instance.activeDevice;
|
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||||
if (d != null && d.connectionState == CastConnectionState.CONNECTED)
|
try {
|
||||||
castIfAvailable(context.contentResolver, video, _lastVideoSource, _lastAudioSource, toSet, (d.expectedCurrentTime * 1000.0).toLong(), d.speed);
|
val d = StateCasting.instance.activeDevice;
|
||||||
else
|
if (d != null && d.connectionState == CastConnectionState.CONNECTED)
|
||||||
_player.swapSubtitles(fragment.lifecycleScope, toSet);
|
castIfAvailable(context.contentResolver, video, _lastVideoSource, _lastAudioSource, toSet, (d.expectedCurrentTime * 1000.0).toLong(), d.speed);
|
||||||
|
else {
|
||||||
|
_player.swapSubtitles(toSet);
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "handleSelectSubtitleTrack failed", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
_lastSubtitleSource = toSet;
|
_lastSubtitleSource = toSet;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2576,6 +2733,9 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
setProgressBarOverlayed(false);
|
setProgressBarOverlayed(false);
|
||||||
}
|
}
|
||||||
onFullscreenChanged.emit(fullscreen);
|
onFullscreenChanged.emit(fullscreen);
|
||||||
|
_layoutPlayerContainer.post {
|
||||||
|
onShouldEnterPictureInPictureChanged.emit()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setCastEnabled(isCasting: Boolean) {
|
private fun setCastEnabled(isCasting: Boolean) {
|
||||||
@@ -2603,6 +2763,8 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
if (changed) {
|
if (changed) {
|
||||||
stopAllGestures();
|
stopAllGestures();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onShouldEnterPictureInPictureChanged.emit()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isLandscapeVideo(): Boolean? {
|
fun isLandscapeVideo(): Boolean? {
|
||||||
@@ -2833,6 +2995,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
|
|
||||||
_overlayContainer.removeAllViews();
|
_overlayContainer.removeAllViews();
|
||||||
_overlay_quality_selector?.hide();
|
_overlay_quality_selector?.hide();
|
||||||
|
_container_content.visibility = GONE
|
||||||
|
|
||||||
_player.fillHeight(false)
|
_player.fillHeight(false)
|
||||||
_layoutPlayerContainer.setPadding(0, 0, 0, 0);
|
_layoutPlayerContainer.setPadding(0, 0, 0, 0);
|
||||||
@@ -2841,6 +3004,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
Logger.i(TAG, "handleLeavePictureInPicture")
|
Logger.i(TAG, "handleLeavePictureInPicture")
|
||||||
|
|
||||||
if(!_player.isFullScreen) {
|
if(!_player.isFullScreen) {
|
||||||
|
_container_content.visibility = VISIBLE
|
||||||
_player.fitHeight();
|
_player.fitHeight();
|
||||||
_layoutPlayerContainer.setPadding(0, 0, 0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 6.0f, Resources.getSystem().displayMetrics).toInt());
|
_layoutPlayerContainer.setPadding(0, 0, 0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 6.0f, Resources.getSystem().displayMetrics).toInt());
|
||||||
} else {
|
} else {
|
||||||
@@ -2856,29 +3020,40 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
videoSourceHeight = 9;
|
videoSourceHeight = 9;
|
||||||
}
|
}
|
||||||
val aspectRatio = videoSourceWidth.toDouble() / videoSourceHeight;
|
val aspectRatio = videoSourceWidth.toDouble() / videoSourceHeight;
|
||||||
|
val r = _player.getVideoRect()
|
||||||
if(aspectRatio > 2.38) {
|
if(aspectRatio > 2.38) {
|
||||||
videoSourceWidth = 16;
|
videoSourceWidth = 16;
|
||||||
videoSourceHeight = 9;
|
videoSourceHeight = 9;
|
||||||
|
|
||||||
|
// shrink the left and right equally to get the rect to be 16 by 9 aspect ratio
|
||||||
|
// we don't want a picture in picture mode that's more squashed than 16 by 9
|
||||||
|
val targetWidth = r.height() * 16 / 9
|
||||||
|
val shrinkAmount = (r.width() - targetWidth) / 2
|
||||||
|
r.left += shrinkAmount
|
||||||
|
r.right -= shrinkAmount
|
||||||
}
|
}
|
||||||
else if(aspectRatio < 0.43) {
|
else if(aspectRatio < 0.43) {
|
||||||
videoSourceHeight = 16;
|
videoSourceHeight = 16;
|
||||||
videoSourceWidth = 9;
|
videoSourceWidth = 9;
|
||||||
}
|
}
|
||||||
|
|
||||||
val r = Rect();
|
|
||||||
_player.getGlobalVisibleRect(r);
|
|
||||||
r.right = r.right - _player.paddingEnd;
|
|
||||||
val playpauseAction = if(_player.playing)
|
val playpauseAction = if(_player.playing)
|
||||||
RemoteAction(Icon.createWithResource(context, R.drawable.ic_pause_notif), context.getString(R.string.pause), context.getString(R.string.pauses_the_video), MediaControlReceiver.getPauseIntent(context, 5));
|
RemoteAction(Icon.createWithResource(context, R.drawable.ic_pause_notif), context.getString(R.string.pause), context.getString(R.string.pauses_the_video), MediaControlReceiver.getPauseIntent(context, 5));
|
||||||
else
|
else
|
||||||
RemoteAction(Icon.createWithResource(context, R.drawable.ic_play_notif), context.getString(R.string.play), context.getString(R.string.resumes_the_video), MediaControlReceiver.getPlayIntent(context, 6));
|
RemoteAction(Icon.createWithResource(context, R.drawable.ic_play_notif), context.getString(R.string.play), context.getString(R.string.resumes_the_video), MediaControlReceiver.getPlayIntent(context, 6));
|
||||||
|
|
||||||
val toBackgroundAction = RemoteAction(Icon.createWithResource(context, R.drawable.ic_screen_share), context.getString(R.string.background), context.getString(R.string.background_switch_audio), MediaControlReceiver.getToBackgroundIntent(context, 7));
|
val toBackgroundAction = RemoteAction(Icon.createWithResource(context, R.drawable.ic_screen_share), context.getString(R.string.background), context.getString(R.string.background_switch_audio), MediaControlReceiver.getToBackgroundIntent(context, 7));
|
||||||
return PictureInPictureParams.Builder()
|
|
||||||
|
val params = PictureInPictureParams.Builder()
|
||||||
.setAspectRatio(Rational(videoSourceWidth, videoSourceHeight))
|
.setAspectRatio(Rational(videoSourceWidth, videoSourceHeight))
|
||||||
.setSourceRectHint(r)
|
.setSourceRectHint(r)
|
||||||
.setActions(listOf(toBackgroundAction, playpauseAction))
|
.setActions(listOf(toBackgroundAction, playpauseAction))
|
||||||
.build();
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
params.setAutoEnterEnabled(shouldEnterPictureInPicture)
|
||||||
|
}
|
||||||
|
|
||||||
|
return params.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
//Other
|
//Other
|
||||||
@@ -2896,6 +3071,8 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
private fun setLastPositionMilliseconds(positionMilliseconds: Long, updateHistory: Boolean) {
|
private fun setLastPositionMilliseconds(positionMilliseconds: Long, updateHistory: Boolean) {
|
||||||
lastPositionMilliseconds = positionMilliseconds;
|
lastPositionMilliseconds = positionMilliseconds;
|
||||||
|
|
||||||
|
_liveChat?.setVideoPosition(lastPositionMilliseconds);
|
||||||
|
|
||||||
val v = video ?: return;
|
val v = video ?: return;
|
||||||
val currentTime = System.currentTimeMillis();
|
val currentTime = System.currentTimeMillis();
|
||||||
if (updateHistory && (_lastPositionSaveTime == -1L || currentTime - _lastPositionSaveTime > 5000)) {
|
if (updateHistory && (_lastPositionSaveTime == -1L || currentTime - _lastPositionSaveTime > 5000)) {
|
||||||
@@ -3103,8 +3280,13 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
val id = e.config.let { if(it is SourcePluginConfig) it.id else null };
|
val id = e.config.let { if(it is SourcePluginConfig) it.id else null };
|
||||||
val didLogin = if(id == null)
|
val didLogin = if(id == null)
|
||||||
false
|
false
|
||||||
else StatePlugins.instance.loginPlugin(context, id) {
|
else {
|
||||||
fetchVideo();
|
isLoginStop = true;
|
||||||
|
StatePlugins.instance.loginPlugin(context, id) {
|
||||||
|
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||||
|
fetchVideo();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if(!didLogin)
|
if(!didLogin)
|
||||||
UIDialogs.showDialogOk(context, R.drawable.ic_error_pred, "Failed to login");
|
UIDialogs.showDialogOk(context, R.drawable.ic_error_pred, "Failed to login");
|
||||||
@@ -3282,6 +3464,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
const val TAG_SHARE = "share";
|
const val TAG_SHARE = "share";
|
||||||
const val TAG_OVERLAY = "overlay";
|
const val TAG_OVERLAY = "overlay";
|
||||||
const val TAG_LIVECHAT = "livechat";
|
const val TAG_LIVECHAT = "livechat";
|
||||||
|
const val TAG_VODCHAT = "vodchat";
|
||||||
const val TAG_CHAPTERS = "chapters";
|
const val TAG_CHAPTERS = "chapters";
|
||||||
const val TAG_OPEN = "open";
|
const val TAG_OPEN = "open";
|
||||||
const val TAG_SEND_TO_DEVICE = "send_to_device";
|
const val TAG_SEND_TO_DEVICE = "send_to_device";
|
||||||
|
|||||||
+454
@@ -0,0 +1,454 @@
|
|||||||
|
package com.futo.platformplayer.fragment.mainactivity.special
|
||||||
|
|
||||||
|
import android.app.Dialog
|
||||||
|
import android.content.DialogInterface
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.text.Spanned
|
||||||
|
import android.util.TypedValue
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup.MarginLayoutParams
|
||||||
|
import android.widget.Button
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import android.widget.FrameLayout.GONE
|
||||||
|
import android.widget.FrameLayout.VISIBLE
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import com.futo.platformplayer.R
|
||||||
|
import com.futo.platformplayer.Settings
|
||||||
|
import com.futo.platformplayer.api.media.PlatformID
|
||||||
|
import com.futo.platformplayer.api.media.models.PlatformAuthorMembershipLink
|
||||||
|
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
|
||||||
|
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.video.IPlatformVideoDetails
|
||||||
|
import com.futo.platformplayer.constructs.TaskHandler
|
||||||
|
import com.futo.platformplayer.dp
|
||||||
|
import com.futo.platformplayer.fixHtmlLinks
|
||||||
|
import com.futo.platformplayer.fragment.mainactivity.main.BrowserFragment
|
||||||
|
import com.futo.platformplayer.fragment.mainactivity.main.ChannelFragment
|
||||||
|
import com.futo.platformplayer.fragment.mainactivity.main.MainFragment
|
||||||
|
import com.futo.platformplayer.getNowDiffSeconds
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.selectBestImage
|
||||||
|
import com.futo.platformplayer.states.StateApp
|
||||||
|
import com.futo.platformplayer.states.StateMeta
|
||||||
|
import com.futo.platformplayer.states.StatePlatform
|
||||||
|
import com.futo.platformplayer.states.StatePolycentric
|
||||||
|
import com.futo.platformplayer.toHumanNowDiffString
|
||||||
|
import com.futo.platformplayer.toHumanNumber
|
||||||
|
import com.futo.platformplayer.views.MonetizationView
|
||||||
|
import com.futo.platformplayer.views.comments.AddCommentView
|
||||||
|
import com.futo.platformplayer.views.others.CreatorThumbnail
|
||||||
|
import com.futo.platformplayer.views.overlays.DescriptionOverlay
|
||||||
|
import com.futo.platformplayer.views.overlays.RepliesOverlay
|
||||||
|
import com.futo.platformplayer.views.overlays.SupportOverlay
|
||||||
|
import com.futo.platformplayer.views.platform.PlatformIndicator
|
||||||
|
import com.futo.platformplayer.views.segments.CommentsList
|
||||||
|
import com.futo.polycentric.core.ApiMethods
|
||||||
|
import com.futo.polycentric.core.Models
|
||||||
|
import com.futo.polycentric.core.PolycentricProfile
|
||||||
|
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
|
||||||
|
|
||||||
|
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||||
|
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||||
|
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||||
|
|
||||||
|
|
||||||
|
class CommentsModalBottomSheet : BottomSheetDialogFragment() {
|
||||||
|
var mainFragment: MainFragment? = null
|
||||||
|
|
||||||
|
private lateinit var containerContent: FrameLayout
|
||||||
|
private lateinit var containerContentMain: LinearLayout
|
||||||
|
private lateinit var containerContentReplies: RepliesOverlay
|
||||||
|
private lateinit var containerContentDescription: DescriptionOverlay
|
||||||
|
private lateinit var containerContentSupport: SupportOverlay
|
||||||
|
|
||||||
|
private lateinit var title: TextView
|
||||||
|
private lateinit var subTitle: TextView
|
||||||
|
private lateinit var channelName: TextView
|
||||||
|
private lateinit var channelMeta: TextView
|
||||||
|
private lateinit var creatorThumbnail: CreatorThumbnail
|
||||||
|
private lateinit var channelButton: LinearLayout
|
||||||
|
private lateinit var monetization: MonetizationView
|
||||||
|
private lateinit var platform: PlatformIndicator
|
||||||
|
private lateinit var textLikes: TextView
|
||||||
|
private lateinit var textDislikes: TextView
|
||||||
|
private lateinit var layoutRating: LinearLayout
|
||||||
|
private lateinit var imageDislikeIcon: ImageView
|
||||||
|
private lateinit var imageLikeIcon: ImageView
|
||||||
|
|
||||||
|
private lateinit var description: TextView
|
||||||
|
private lateinit var descriptionContainer: LinearLayout
|
||||||
|
private lateinit var descriptionViewMore: TextView
|
||||||
|
|
||||||
|
private lateinit var commentsList: CommentsList
|
||||||
|
private lateinit var addCommentView: AddCommentView
|
||||||
|
|
||||||
|
private var polycentricProfile: PolycentricProfile? = null
|
||||||
|
|
||||||
|
private lateinit var buttonPolycentric: Button
|
||||||
|
private lateinit var buttonPlatform: Button
|
||||||
|
|
||||||
|
private var tabIndex: Int? = null
|
||||||
|
|
||||||
|
private var contentOverlayView: View? = null
|
||||||
|
|
||||||
|
lateinit var video: IPlatformVideoDetails
|
||||||
|
|
||||||
|
private lateinit var behavior: BottomSheetBehavior<FrameLayout>
|
||||||
|
|
||||||
|
private val _taskLoadPolycentricProfile =
|
||||||
|
TaskHandler<PlatformID, PolycentricProfile?>(StateApp.instance.scopeGetter, { ApiMethods.getPolycentricProfileByClaim(
|
||||||
|
ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, it.claimFieldType.toLong(), it.claimType.toLong(), it.value!!) }).success { setPolycentricProfile(it, animate = true) }
|
||||||
|
.exception<Throwable> {
|
||||||
|
Logger.w(TAG, "Failed to load claims.", it)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateDialog(
|
||||||
|
savedInstanceState: Bundle?,
|
||||||
|
): Dialog {
|
||||||
|
val bottomSheetDialog =
|
||||||
|
BottomSheetDialog(requireContext(), R.style.Custom_BottomSheetDialog_Theme)
|
||||||
|
bottomSheetDialog.setContentView(R.layout.modal_comments)
|
||||||
|
|
||||||
|
behavior = bottomSheetDialog.behavior
|
||||||
|
|
||||||
|
// TODO figure out how to not need all of these non null assertions
|
||||||
|
containerContent = bottomSheetDialog.findViewById(R.id.content_container)!!
|
||||||
|
containerContentMain = bottomSheetDialog.findViewById(R.id.videodetail_container_main)!!
|
||||||
|
containerContentReplies =
|
||||||
|
bottomSheetDialog.findViewById(R.id.videodetail_container_replies)!!
|
||||||
|
containerContentDescription =
|
||||||
|
bottomSheetDialog.findViewById(R.id.videodetail_container_description)!!
|
||||||
|
containerContentSupport =
|
||||||
|
bottomSheetDialog.findViewById(R.id.videodetail_container_support)!!
|
||||||
|
|
||||||
|
title = bottomSheetDialog.findViewById(R.id.videodetail_title)!!
|
||||||
|
subTitle = bottomSheetDialog.findViewById(R.id.videodetail_meta)!!
|
||||||
|
channelName = bottomSheetDialog.findViewById(R.id.videodetail_channel_name)!!
|
||||||
|
channelMeta = bottomSheetDialog.findViewById(R.id.videodetail_channel_meta)!!
|
||||||
|
creatorThumbnail = bottomSheetDialog.findViewById(R.id.creator_thumbnail)!!
|
||||||
|
channelButton = bottomSheetDialog.findViewById(R.id.videodetail_channel_button)!!
|
||||||
|
monetization = bottomSheetDialog.findViewById(R.id.monetization)!!
|
||||||
|
platform = bottomSheetDialog.findViewById(R.id.videodetail_platform)!!
|
||||||
|
layoutRating = bottomSheetDialog.findViewById(R.id.layout_rating)!!
|
||||||
|
textDislikes = bottomSheetDialog.findViewById(R.id.text_dislikes)!!
|
||||||
|
textLikes = bottomSheetDialog.findViewById(R.id.text_likes)!!
|
||||||
|
imageLikeIcon = bottomSheetDialog.findViewById(R.id.image_like_icon)!!
|
||||||
|
imageDislikeIcon = bottomSheetDialog.findViewById(R.id.image_dislike_icon)!!
|
||||||
|
|
||||||
|
description = bottomSheetDialog.findViewById(R.id.videodetail_description)!!
|
||||||
|
descriptionContainer =
|
||||||
|
bottomSheetDialog.findViewById(R.id.videodetail_description_container)!!
|
||||||
|
descriptionViewMore =
|
||||||
|
bottomSheetDialog.findViewById(R.id.videodetail_description_view_more)!!
|
||||||
|
|
||||||
|
addCommentView = bottomSheetDialog.findViewById(R.id.add_comment_view)!!
|
||||||
|
commentsList = bottomSheetDialog.findViewById(R.id.comments_list)!!
|
||||||
|
buttonPolycentric = bottomSheetDialog.findViewById(R.id.button_polycentric)!!
|
||||||
|
buttonPlatform = bottomSheetDialog.findViewById(R.id.button_platform)!!
|
||||||
|
|
||||||
|
commentsList.onAuthorClick.subscribe { c ->
|
||||||
|
if (c !is PolycentricPlatformComment) {
|
||||||
|
return@subscribe
|
||||||
|
}
|
||||||
|
val id = c.author.id.value
|
||||||
|
|
||||||
|
Logger.i(TAG, "onAuthorClick: $id")
|
||||||
|
if (id != null && id.startsWith("polycentric://")) {
|
||||||
|
val navUrl = "https://harbor.social/" + id.substring("polycentric://".length)
|
||||||
|
mainFragment!!.startActivity(Intent(Intent.ACTION_VIEW, navUrl.toUri()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
commentsList.onRepliesClick.subscribe { c ->
|
||||||
|
val replyCount = c.replyCount ?: 0
|
||||||
|
var metadata = ""
|
||||||
|
if (replyCount > 0) {
|
||||||
|
metadata += "$replyCount " + requireContext().getString(R.string.replies)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c is PolycentricPlatformComment) {
|
||||||
|
var parentComment: PolycentricPlatformComment = c
|
||||||
|
containerContentReplies.load(tabIndex!! != 0, metadata, c.contextUrl, c.reference, c, { StatePolycentric.instance.getCommentPager(c.contextUrl, c.reference) }, {
|
||||||
|
val newComment = parentComment.cloneWithUpdatedReplyCount(
|
||||||
|
(parentComment.replyCount ?: 0) + 1
|
||||||
|
)
|
||||||
|
commentsList.replaceComment(parentComment, newComment)
|
||||||
|
parentComment = newComment
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
containerContentReplies.load(tabIndex!! != 0, metadata, null, null, c, { StatePlatform.instance.getSubComments(c) })
|
||||||
|
}
|
||||||
|
animateOpenOverlayView(containerContentReplies)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (StatePolycentric.instance.enabled) {
|
||||||
|
buttonPolycentric.setOnClickListener {
|
||||||
|
setTabIndex(0)
|
||||||
|
StateMeta.instance.setLastCommentSection(0)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
buttonPolycentric.visibility = GONE
|
||||||
|
}
|
||||||
|
|
||||||
|
buttonPlatform.setOnClickListener {
|
||||||
|
setTabIndex(1)
|
||||||
|
StateMeta.instance.setLastCommentSection(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
val ref = Models.referenceFromBuffer(video.url.toByteArray())
|
||||||
|
addCommentView.setContext(video.url, ref)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
containerContentDescription.onClose.subscribe { animateCloseOverlayView() }
|
||||||
|
containerContentReplies.onClose.subscribe { animateCloseOverlayView() }
|
||||||
|
|
||||||
|
descriptionViewMore.setOnClickListener {
|
||||||
|
animateOpenOverlayView(containerContentDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDescriptionUI(video.description.fixHtmlLinks())
|
||||||
|
|
||||||
|
val dp5 =
|
||||||
|
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics)
|
||||||
|
val dp2 =
|
||||||
|
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 2f, resources.displayMetrics)
|
||||||
|
|
||||||
|
//UI
|
||||||
|
title.text = video.name
|
||||||
|
channelName.text = video.author.name
|
||||||
|
if (video.author.subscribers != null) {
|
||||||
|
channelMeta.text = if ((video.author.subscribers
|
||||||
|
?: 0) > 0
|
||||||
|
) video.author.subscribers!!.toHumanNumber() + " " + requireContext().getString(R.string.subscribers) else ""
|
||||||
|
(channelName.layoutParams as MarginLayoutParams).setMargins(
|
||||||
|
0, (dp5 * -1).toInt(), 0, 0
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
channelMeta.text = ""
|
||||||
|
(channelName.layoutParams as MarginLayoutParams).setMargins(0, (dp2).toInt(), 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
video.author.let {
|
||||||
|
if (it is PlatformAuthorMembershipLink && !it.membershipUrl.isNullOrEmpty()) monetization.setPlatformMembership(video.id.pluginId, it.membershipUrl)
|
||||||
|
else monetization.setPlatformMembership(null, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
val subTitleSegments: ArrayList<String> = ArrayList()
|
||||||
|
if (video.viewCount > 0) subTitleSegments.add("${video.viewCount.toHumanNumber()} ${if (video.isLive) requireContext().getString(
|
||||||
|
R.string.watching_now) else requireContext().getString(R.string.views)}")
|
||||||
|
if (video.datetime != null) {
|
||||||
|
val diff = video.datetime?.getNowDiffSeconds() ?: 0
|
||||||
|
val ago = video.datetime?.toHumanNowDiffString(true)
|
||||||
|
if (diff >= 0) subTitleSegments.add("$ago ago")
|
||||||
|
else subTitleSegments.add("available in $ago")
|
||||||
|
}
|
||||||
|
|
||||||
|
platform.setPlatformFromClientID(video.id.pluginId)
|
||||||
|
subTitle.text = subTitleSegments.joinToString(" • ")
|
||||||
|
creatorThumbnail.setThumbnail(video.author.thumbnail, false)
|
||||||
|
|
||||||
|
setPolycentricProfile(null, animate = false)
|
||||||
|
_taskLoadPolycentricProfile.run(video.author.id)
|
||||||
|
|
||||||
|
when (video.rating) {
|
||||||
|
is RatingLikeDislikes -> {
|
||||||
|
val r = video.rating as RatingLikeDislikes
|
||||||
|
layoutRating.visibility = VISIBLE
|
||||||
|
|
||||||
|
textLikes.visibility = VISIBLE
|
||||||
|
imageLikeIcon.visibility = VISIBLE
|
||||||
|
textLikes.text = r.likes.toHumanNumber()
|
||||||
|
|
||||||
|
imageDislikeIcon.visibility = VISIBLE
|
||||||
|
textDislikes.visibility = VISIBLE
|
||||||
|
textDislikes.text = r.dislikes.toHumanNumber()
|
||||||
|
}
|
||||||
|
|
||||||
|
is RatingLikes -> {
|
||||||
|
val r = video.rating as RatingLikes
|
||||||
|
layoutRating.visibility = VISIBLE
|
||||||
|
|
||||||
|
textLikes.visibility = VISIBLE
|
||||||
|
imageLikeIcon.visibility = VISIBLE
|
||||||
|
textLikes.text = r.likes.toHumanNumber()
|
||||||
|
|
||||||
|
imageDislikeIcon.visibility = GONE
|
||||||
|
textDislikes.visibility = GONE
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
layoutRating.visibility = GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
monetization.onSupportTap.subscribe {
|
||||||
|
containerContentSupport.setPolycentricProfile(polycentricProfile)
|
||||||
|
animateOpenOverlayView(containerContentSupport)
|
||||||
|
}
|
||||||
|
|
||||||
|
monetization.onStoreTap.subscribe {
|
||||||
|
polycentricProfile?.systemState?.store?.let {
|
||||||
|
try {
|
||||||
|
val uri = it.toUri()
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW)
|
||||||
|
intent.data = uri
|
||||||
|
requireContext().startActivity(intent)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to open URI: '${it}'.", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
monetization.onUrlTap.subscribe {
|
||||||
|
mainFragment!!.navigate<BrowserFragment>(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
addCommentView.onCommentAdded.subscribe {
|
||||||
|
commentsList.addComment(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
channelButton.setOnClickListener {
|
||||||
|
mainFragment!!.navigate<ChannelFragment>(video.author)
|
||||||
|
}
|
||||||
|
|
||||||
|
return bottomSheetDialog
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDismiss(dialog: DialogInterface) {
|
||||||
|
super.onDismiss(dialog)
|
||||||
|
animateCloseOverlayView()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setPolycentricProfile(profile: PolycentricProfile?, animate: Boolean) {
|
||||||
|
polycentricProfile = profile
|
||||||
|
|
||||||
|
val dp35 = 35.dp(requireContext().resources)
|
||||||
|
val avatar = profile?.systemState?.avatar?.selectBestImage(dp35 * dp35)
|
||||||
|
?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) }
|
||||||
|
|
||||||
|
if (avatar != null) {
|
||||||
|
creatorThumbnail.setThumbnail(avatar, animate)
|
||||||
|
} else {
|
||||||
|
creatorThumbnail.setThumbnail(video.author.thumbnail, animate)
|
||||||
|
creatorThumbnail.setHarborAvailable(profile != null, animate, profile?.system?.toProto())
|
||||||
|
}
|
||||||
|
|
||||||
|
val username = profile?.systemState?.username
|
||||||
|
if (username != null) {
|
||||||
|
channelName.text = username
|
||||||
|
}
|
||||||
|
|
||||||
|
monetization.setPolycentricProfile(profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setTabIndex(index: Int?, forceReload: Boolean = false) {
|
||||||
|
Logger.i(TAG, "setTabIndex (index: ${index}, forceReload: ${forceReload})")
|
||||||
|
val changed = tabIndex != index || forceReload
|
||||||
|
if (!changed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tabIndex = index
|
||||||
|
buttonPlatform.setTextColor(resources.getColor(if (index == 1) R.color.white else R.color.gray_ac, null))
|
||||||
|
buttonPolycentric.setTextColor(resources.getColor(if (index == 0) R.color.white else R.color.gray_ac, null))
|
||||||
|
|
||||||
|
when (index) {
|
||||||
|
null -> {
|
||||||
|
addCommentView.visibility = GONE
|
||||||
|
commentsList.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
0 -> {
|
||||||
|
addCommentView.visibility = VISIBLE
|
||||||
|
fetchPolycentricComments()
|
||||||
|
}
|
||||||
|
|
||||||
|
1 -> {
|
||||||
|
addCommentView.visibility = GONE
|
||||||
|
fetchComments()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetchComments() {
|
||||||
|
Logger.i(TAG, "fetchComments")
|
||||||
|
video.let {
|
||||||
|
commentsList.load(true) { StatePlatform.instance.getComments(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetchPolycentricComments() {
|
||||||
|
Logger.i(TAG, "fetchPolycentricComments")
|
||||||
|
val video = video
|
||||||
|
val idValue = video.id.value
|
||||||
|
if (video.url.isEmpty()) {
|
||||||
|
Logger.w(TAG, "Failed to fetch polycentric comments because url was null")
|
||||||
|
commentsList.clear()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val ref = Models.referenceFromBuffer(video.url.toByteArray())
|
||||||
|
val extraBytesRef = idValue?.let { if (it.isNotEmpty()) it.toByteArray() else null }
|
||||||
|
commentsList.load(false) { StatePolycentric.instance.getCommentPager(video.url, ref, listOfNotNull(extraBytesRef)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateDescriptionUI(text: Spanned) {
|
||||||
|
containerContentDescription.load(text)
|
||||||
|
description.text = text
|
||||||
|
|
||||||
|
if (description.text.isNotEmpty()) descriptionContainer.visibility = VISIBLE
|
||||||
|
else descriptionContainer.visibility = GONE
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun animateOpenOverlayView(view: View) {
|
||||||
|
if (contentOverlayView != null) {
|
||||||
|
Logger.e(TAG, "Content overlay already open")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
behavior.isDraggable = false
|
||||||
|
behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||||
|
|
||||||
|
val animHeight = containerContentMain.height
|
||||||
|
|
||||||
|
view.translationY = animHeight.toFloat()
|
||||||
|
view.visibility = VISIBLE
|
||||||
|
|
||||||
|
view.animate().setDuration(300).translationY(0f).withEndAction {
|
||||||
|
contentOverlayView = view
|
||||||
|
}.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun animateCloseOverlayView() {
|
||||||
|
val curView = contentOverlayView
|
||||||
|
if (curView == null) {
|
||||||
|
Logger.e(TAG, "No content overlay open")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
behavior.isDraggable = true
|
||||||
|
|
||||||
|
val animHeight = contentOverlayView!!.height
|
||||||
|
|
||||||
|
curView.animate().setDuration(300).translationY(animHeight.toFloat()).withEndAction {
|
||||||
|
curView.visibility = GONE
|
||||||
|
contentOverlayView = null
|
||||||
|
}.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val TAG = "ModalBottomSheet"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,16 +3,9 @@ package com.futo.platformplayer.models
|
|||||||
import com.futo.platformplayer.casting.CastProtocolType
|
import com.futo.platformplayer.casting.CastProtocolType
|
||||||
|
|
||||||
@kotlinx.serialization.Serializable
|
@kotlinx.serialization.Serializable
|
||||||
class CastingDeviceInfo {
|
class CastingDeviceInfo(
|
||||||
var name: String;
|
var name: String,
|
||||||
var type: CastProtocolType;
|
var type: CastProtocolType,
|
||||||
var addresses: Array<String>;
|
var addresses: Array<String>,
|
||||||
var port: Int;
|
var port: Int
|
||||||
|
)
|
||||||
constructor(name: String, type: CastProtocolType, addresses: Array<String>, port: Int) {
|
|
||||||
this.name = name;
|
|
||||||
this.type = type;
|
|
||||||
this.addresses = addresses;
|
|
||||||
this.port = port;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -13,6 +13,8 @@ import com.futo.platformplayer.activities.MainActivity
|
|||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.receivers.MediaControlReceiver
|
import com.futo.platformplayer.receivers.MediaControlReceiver
|
||||||
import com.futo.platformplayer.timestampRegex
|
import com.futo.platformplayer.timestampRegex
|
||||||
|
import com.futo.platformplayer.views.behavior.NonScrollingTextView
|
||||||
|
import com.futo.platformplayer.views.behavior.NonScrollingTextView.Companion
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -91,7 +93,11 @@ class PlatformLinkMovementMethod(private val _context: Context) : LinkMovementMe
|
|||||||
}
|
}
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
c.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url)))
|
try {
|
||||||
|
c.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url)))
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.i(TAG, "Failed to start activity.", e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ class DownloadService : Service() {
|
|||||||
private val _scope: CoroutineScope = CoroutineScope(Dispatchers.Default);
|
private val _scope: CoroutineScope = CoroutineScope(Dispatchers.Default);
|
||||||
private var _notificationManager: NotificationManager? = null;
|
private var _notificationManager: NotificationManager? = null;
|
||||||
private var _notificationChannel: NotificationChannel? = null;
|
private var _notificationChannel: NotificationChannel? = null;
|
||||||
|
private var _isForeground = false
|
||||||
|
|
||||||
private val _client = ManagedHttpClient(OkHttpClient.Builder()
|
private val _client = ManagedHttpClient(OkHttpClient.Builder()
|
||||||
//.proxy(Proxy(Proxy.Type.HTTP, InetSocketAddress(InetAddress.getByName("192.168.1.175"), 8081)))
|
//.proxy(Proxy(Proxy.Type.HTTP, InetSocketAddress(InetAddress.getByName("192.168.1.175"), 8081)))
|
||||||
@@ -66,6 +67,7 @@ class DownloadService : Service() {
|
|||||||
|
|
||||||
if(!FragmentedStorage.isInitialized) {
|
if(!FragmentedStorage.isInitialized) {
|
||||||
Logger.i(TAG, "Attempted to start DownloadService without initialized files");
|
Logger.i(TAG, "Attempted to start DownloadService without initialized files");
|
||||||
|
stopSelf()
|
||||||
closeDownloadSession();
|
closeDownloadSession();
|
||||||
return START_NOT_STICKY;
|
return START_NOT_STICKY;
|
||||||
}
|
}
|
||||||
@@ -116,6 +118,22 @@ class DownloadService : Service() {
|
|||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
Logger.i(TAG, "onCreate");
|
Logger.i(TAG, "onCreate");
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
|
||||||
|
setupNotificationRequirements()
|
||||||
|
|
||||||
|
val bootstrapNotif = NotificationCompat.Builder(this, DOWNLOAD_NOTIF_CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_download)
|
||||||
|
.setContentTitle("Preparing downloads...")
|
||||||
|
.setOngoing(true)
|
||||||
|
.setSilent(true)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
|
||||||
|
startForeground(DOWNLOAD_NOTIF_ID, bootstrapNotif, FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||||
|
else
|
||||||
|
startForeground(DOWNLOAD_NOTIF_ID, bootstrapNotif)
|
||||||
|
|
||||||
|
_isForeground = true
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBind(p0: Intent?): IBinder? {
|
override fun onBind(p0: Intent?): IBinder? {
|
||||||
@@ -246,15 +264,14 @@ class DownloadService : Service() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun notifyDownload(download: VideoDownload?) {
|
private fun notifyDownload(download: VideoDownload?) {
|
||||||
val channel = _notificationChannel ?: return;
|
val channelId = DOWNLOAD_NOTIF_CHANNEL_ID
|
||||||
|
|
||||||
val bringUpIntent = Intent(this, MainActivity::class.java);
|
val bringUpIntent = Intent(this, MainActivity::class.java);
|
||||||
bringUpIntent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT);
|
bringUpIntent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT);
|
||||||
bringUpIntent.action = "TAB";
|
bringUpIntent.action = "TAB";
|
||||||
bringUpIntent.putExtra("TAB", "Downloads");
|
bringUpIntent.putExtra("TAB", "Downloads");
|
||||||
|
|
||||||
var builder = if(download != null)
|
val builder = if(download != null)
|
||||||
NotificationCompat.Builder(this, DOWNLOAD_NOTIF_TAG)
|
NotificationCompat.Builder(this, channelId)
|
||||||
.setSmallIcon(R.drawable.ic_download)
|
.setSmallIcon(R.drawable.ic_download)
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.setSilent(true)
|
.setSilent(true)
|
||||||
@@ -262,16 +279,16 @@ class DownloadService : Service() {
|
|||||||
.setContentTitle("${download.state}: ${download.name}")
|
.setContentTitle("${download.state}: ${download.name}")
|
||||||
.setContentText(download.getDownloadInfo())
|
.setContentText(download.getDownloadInfo())
|
||||||
.setProgress(100, (download.progress * 100).toInt(), download.progress == 0.0)
|
.setProgress(100, (download.progress * 100).toInt(), download.progress == 0.0)
|
||||||
.setChannelId(channel.id)
|
.setChannelId(channelId)
|
||||||
else
|
else
|
||||||
NotificationCompat.Builder(this, DOWNLOAD_NOTIF_TAG)
|
NotificationCompat.Builder(this, channelId)
|
||||||
.setSmallIcon(R.drawable.ic_download)
|
.setSmallIcon(R.drawable.ic_download)
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.setSilent(true)
|
.setSilent(true)
|
||||||
.setContentIntent(PendingIntent.getActivity(this, 5, bringUpIntent, PendingIntent.FLAG_IMMUTABLE))
|
.setContentIntent(PendingIntent.getActivity(this, 5, bringUpIntent, PendingIntent.FLAG_IMMUTABLE))
|
||||||
.setContentTitle("Preparing for download...")
|
.setContentTitle("Preparing for download...")
|
||||||
.setContentText("Initializing download process...")
|
.setContentText("Initializing download process...")
|
||||||
.setChannelId(channel.id)
|
.setChannelId(channelId)
|
||||||
|
|
||||||
val notif = builder.build();
|
val notif = builder.build();
|
||||||
notif.flags = notif.flags or NotificationCompat.FLAG_ONGOING_EVENT or NotificationCompat.FLAG_NO_CLEAR;
|
notif.flags = notif.flags or NotificationCompat.FLAG_ONGOING_EVENT or NotificationCompat.FLAG_NO_CLEAR;
|
||||||
|
|||||||
@@ -135,8 +135,12 @@ class StateApp {
|
|||||||
return _scope;
|
return _scope;
|
||||||
}
|
}
|
||||||
val scope: CoroutineScope get() {
|
val scope: CoroutineScope get() {
|
||||||
val thisScope = scopeOrNull
|
val thisScope = scopeOrNull;
|
||||||
?: throw IllegalStateException("Attempted to use a global lifetime scope while MainActivity is no longer available");
|
if(thisScope == null) {
|
||||||
|
//throw IllegalStateException("Attempted to use a global lifetime scope while MainActivity is no longer available");
|
||||||
|
Logger.w(TAG, "Attempted to use a global lifetime scope while MainActivity is no longer available, USING GLOBAL SCOPE");
|
||||||
|
return GlobalScope;
|
||||||
|
}
|
||||||
return thisScope;
|
return thisScope;
|
||||||
}
|
}
|
||||||
val scopeGetter: ()->CoroutineScope get() {
|
val scopeGetter: ()->CoroutineScope get() {
|
||||||
@@ -636,6 +640,20 @@ class StateApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
scopeOrNull?.launch(Dispatchers.IO) {
|
||||||
|
val enabledPlugins = StatePlatform.instance.getEnabledClients();
|
||||||
|
for(plugin in enabledPlugins) {
|
||||||
|
try {
|
||||||
|
if(plugin is JSClient) {
|
||||||
|
if(plugin.descriptor.appSettings.sync.enableHistorySync == true)
|
||||||
|
StateHistory.instance.syncRemoteHistory(plugin);
|
||||||
|
}
|
||||||
|
} catch (ex: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to update remote history for ${plugin.name}", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun mainAppStartedWithExternalFiles(context: Context) {
|
fun mainAppStartedWithExternalFiles(context: Context) {
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
package com.futo.platformplayer.states
|
package com.futo.platformplayer.states
|
||||||
|
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
|
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||||
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.SerializedPlatformVideo
|
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
||||||
|
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||||
import com.futo.platformplayer.api.media.structures.IPager
|
import com.futo.platformplayer.api.media.structures.IPager
|
||||||
import com.futo.platformplayer.constructs.Event2
|
import com.futo.platformplayer.constructs.Event2
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.models.HistoryVideo
|
import com.futo.platformplayer.models.HistoryVideo
|
||||||
import com.futo.platformplayer.models.ImportCache
|
import com.futo.platformplayer.models.ImportCache
|
||||||
import com.futo.platformplayer.states.StatePlaylists.Companion
|
import com.futo.platformplayer.states.StateApp.Companion
|
||||||
import com.futo.platformplayer.stores.FragmentedStorage
|
import com.futo.platformplayer.stores.FragmentedStorage
|
||||||
|
import com.futo.platformplayer.stores.StringDateMapStorage
|
||||||
import com.futo.platformplayer.stores.db.ManagedDBStore
|
import com.futo.platformplayer.stores.db.ManagedDBStore
|
||||||
import com.futo.platformplayer.stores.db.types.DBHistory
|
import com.futo.platformplayer.stores.db.types.DBHistory
|
||||||
import com.futo.platformplayer.stores.v2.ReconstructStore
|
import com.futo.platformplayer.stores.v2.ReconstructStore
|
||||||
@@ -19,7 +22,6 @@ import kotlinx.coroutines.launch
|
|||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import java.util.concurrent.ConcurrentMap
|
import java.util.concurrent.ConcurrentMap
|
||||||
import kotlin.math.min
|
|
||||||
|
|
||||||
class StateHistory {
|
class StateHistory {
|
||||||
//Legacy
|
//Legacy
|
||||||
@@ -31,6 +33,8 @@ class StateHistory {
|
|||||||
})
|
})
|
||||||
.load();
|
.load();
|
||||||
|
|
||||||
|
private val _remoteHistoryDatesStore = FragmentedStorage.get<StringDateMapStorage>("remoteHistoryDates");
|
||||||
|
|
||||||
private val historyIndex: ConcurrentMap<Any, DBHistory.Index> = ConcurrentHashMap();
|
private val historyIndex: ConcurrentMap<Any, DBHistory.Index> = ConcurrentHashMap();
|
||||||
val _historyDBStore = ManagedDBStore.create("history", DBHistory.Descriptor())
|
val _historyDBStore = ManagedDBStore.create("history", DBHistory.Descriptor())
|
||||||
.withIndex({ it.url }, historyIndex, false, true)
|
.withIndex({ it.url }, historyIndex, false, true)
|
||||||
@@ -186,8 +190,98 @@ class StateHistory {
|
|||||||
val toDelete = _historyDBStore.getAllIndexes().filter { minutesToDelete == -1L || (now - it.datetime) < minutesToDelete * 60 };
|
val toDelete = _historyDBStore.getAllIndexes().filter { minutesToDelete == -1L || (now - it.datetime) < minutesToDelete * 60 };
|
||||||
for(item in toDelete)
|
for(item in toDelete)
|
||||||
_historyDBStore.delete(item);
|
_historyDBStore.delete(item);
|
||||||
|
_remoteHistoryDatesStore.map = HashMap<String, Long>();
|
||||||
|
_remoteHistoryDatesStore.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun syncRemoteHistory(plugin: JSClient): Int {
|
||||||
|
if (plugin.capabilities.hasGetUserHistory &&
|
||||||
|
plugin.isLoggedIn) {
|
||||||
|
Logger.i(TAG, "Syncing remote history for plugin [${plugin.name}]");
|
||||||
|
|
||||||
|
val hist = StatePlatform.instance.getUserHistory(plugin.id);
|
||||||
|
|
||||||
|
return syncRemoteHistory(plugin.id, hist, 100, 3);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
fun syncRemoteHistory(pluginId: String, videos: IPager<IPlatformContent>, maxVideos: Int, maxPages: Int): Int {
|
||||||
|
val lastDate = _remoteHistoryDatesStore.get(pluginId) ?: OffsetDateTime.MIN;
|
||||||
|
val maxVideosCount = if(maxVideos <= 0) 500 else maxVideos;
|
||||||
|
val maxPageCount = if(maxPages <= 0) 3 else maxPages;
|
||||||
|
var exceededDate = false;
|
||||||
|
try {
|
||||||
|
val toSync = mutableListOf<IPlatformVideo>();
|
||||||
|
var pageCount = 0;
|
||||||
|
var videoCount = 0;
|
||||||
|
var isFirst = true;
|
||||||
|
var oldestPlayback = OffsetDateTime.MAX;
|
||||||
|
var newestPlayback = OffsetDateTime.MIN;
|
||||||
|
do {
|
||||||
|
if (!isFirst) videos.nextPage();
|
||||||
|
val newVideos = videos.getResults();
|
||||||
|
|
||||||
|
var foundVideos = false;
|
||||||
|
var toSyncAddedCount = 0;
|
||||||
|
for(video in newVideos) {
|
||||||
|
if(video is IPlatformVideo && video.playbackDate != null) {
|
||||||
|
|
||||||
|
if(video.playbackDate!! < lastDate) {
|
||||||
|
exceededDate = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(video.playbackTime > 0) {
|
||||||
|
toSync.add(video);
|
||||||
|
toSyncAddedCount++;
|
||||||
|
foundVideos = true;
|
||||||
|
oldestPlayback = video.playbackDate!!;
|
||||||
|
if(newestPlayback == OffsetDateTime.MIN)
|
||||||
|
newestPlayback = video.playbackDate!!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pageCount++;
|
||||||
|
videoCount += newVideos.size;
|
||||||
|
isFirst = false;
|
||||||
|
|
||||||
|
if(!foundVideos)
|
||||||
|
{
|
||||||
|
Logger.i(TAG, "Found no more videos in remote history");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while(videos.hasMorePages() && videoCount <= maxVideosCount && pageCount <= maxPageCount && !exceededDate);
|
||||||
|
|
||||||
|
var updated = 0;
|
||||||
|
if(oldestPlayback < OffsetDateTime.MAX) {
|
||||||
|
for(video in toSync){
|
||||||
|
val hist = getHistoryByVideo(video, true, video.playbackDate);
|
||||||
|
if(hist != null && hist.position < video.playbackTime) {
|
||||||
|
Logger.i(TAG, "Updated history for video [${video.name}] from remote history");
|
||||||
|
updateHistoryPosition(video, hist, true, video.playbackTime, video.playbackDate, false);
|
||||||
|
updated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(updated > 0) {
|
||||||
|
_remoteHistoryDatesStore.setAndSave(pluginId, newestPlayback);
|
||||||
|
|
||||||
|
try {
|
||||||
|
val client = StatePlatform.instance.getClient(pluginId);
|
||||||
|
UIDialogs.appToast("Updated ${updated} history from ${client.name}")
|
||||||
|
}
|
||||||
|
catch(ex: Throwable){}
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch(ex: Throwable) {
|
||||||
|
val plugin = if(pluginId != StateDeveloper.DEV_ID) StatePlugins.instance.getPlugin(pluginId) else null;
|
||||||
|
Logger.e(TAG, "Sync Remote History failed for [${plugin?.config?.name}] due to: " + ex.message)
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
val TAG = "StateHistory";
|
val TAG = "StateHistory";
|
||||||
|
|||||||
@@ -177,16 +177,11 @@ class StatePlatform {
|
|||||||
}
|
}
|
||||||
|
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
|
var toDisables = mutableListOf<IPlatformClient>();
|
||||||
var enabled: Array<String>;
|
var enabled: Array<String>;
|
||||||
synchronized(_clientsLock) {
|
synchronized(_clientsLock) {
|
||||||
for(e in _enabledClients) {
|
for(e in _enabledClients) {
|
||||||
try {
|
toDisables.add(e);
|
||||||
e.disable();
|
|
||||||
onSourceDisabled.emit(e);
|
|
||||||
}
|
|
||||||
catch(ex: Throwable) {
|
|
||||||
UIDialogs.appToast(ToastView.Toast("If this happens often, please inform the developers on Github", false, null, "Plugin [${e.name}] failed to disable"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_enabledClients.clear();
|
_enabledClients.clear();
|
||||||
@@ -236,6 +231,18 @@ class StatePlatform {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
selectClients(*enabled);
|
selectClients(*enabled);
|
||||||
|
|
||||||
|
for(toDisable in toDisables) {
|
||||||
|
launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
toDisable.disable();
|
||||||
|
onSourceDisabled.emit(toDisable);
|
||||||
|
}
|
||||||
|
catch(ex: Throwable) {
|
||||||
|
Logger.e(TAG, "FAILED TO DISABLE CLIENT [${toDisable?.name}] AFTER UpdateAvailableClients", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,11 +355,11 @@ class StatePlatform {
|
|||||||
StateApp.instance.handleCaptchaException(c, ex);
|
StateApp.instance.handleCaptchaException(c, ex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var toDisable: IPlatformClient? = null;
|
||||||
synchronized(_clientsLock) {
|
synchronized(_clientsLock) {
|
||||||
if (_enabledClients.contains(client)) {
|
if (_enabledClients.contains(client)) {
|
||||||
_enabledClients.remove(client);
|
_enabledClients.remove(client);
|
||||||
client.disable();
|
toDisable = client;
|
||||||
onSourceDisabled.emit(client);
|
|
||||||
newClient.initialize();
|
newClient.initialize();
|
||||||
_enabledClients.add(newClient);
|
_enabledClients.add(newClient);
|
||||||
}
|
}
|
||||||
@@ -360,6 +367,18 @@ class StatePlatform {
|
|||||||
_availableClients.removeIf { it.id == id };
|
_availableClients.removeIf { it.id == id };
|
||||||
_availableClients.add(newClient);
|
_availableClients.add(newClient);
|
||||||
}
|
}
|
||||||
|
if(toDisable != null) {
|
||||||
|
launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
toDisable?.disable();
|
||||||
|
onSourceDisabled.emit(client);
|
||||||
|
}
|
||||||
|
catch (ex: Throwable) {
|
||||||
|
Logger.e(TAG, "FAILED TO DISABLE CLIENT [${toDisable?.name}] AFTER RELOAD", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
afterReload?.invoke();
|
afterReload?.invoke();
|
||||||
return@withContext newClient;
|
return@withContext newClient;
|
||||||
};
|
};
|
||||||
@@ -463,6 +482,47 @@ class StatePlatform {
|
|||||||
pager.initialize();
|
pager.initialize();
|
||||||
return pager;
|
return pager;
|
||||||
}
|
}
|
||||||
|
fun getShorts(): IPager<IPlatformVideo> {
|
||||||
|
Logger.i(TAG, "Platform - getShorts");
|
||||||
|
var clientIdsOngoing = mutableListOf<String>();
|
||||||
|
val clients = getSortedEnabledClient().filter { if (it is JSClient) it.enableInShorts else true };
|
||||||
|
|
||||||
|
StateApp.instance.scopeOrNull?.let {
|
||||||
|
it.launch(Dispatchers.Default) {
|
||||||
|
try {
|
||||||
|
// plugins that take longer than 5 seconds to load are considered "slow"
|
||||||
|
delay(5000);
|
||||||
|
val slowClients = synchronized(clientIdsOngoing) {
|
||||||
|
return@synchronized clients.filter { clientIdsOngoing.contains(it.id) };
|
||||||
|
};
|
||||||
|
for(client in slowClients)
|
||||||
|
UIDialogs.toast("${client.name} is still loading..\nConsider disabling it for Home", false);
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to show toast for slow source.", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val pages = clients.parallelStream()
|
||||||
|
.map {
|
||||||
|
Logger.i(TAG, "getShorts - ${it.name}")
|
||||||
|
synchronized(clientIdsOngoing) {
|
||||||
|
clientIdsOngoing.add(it.id);
|
||||||
|
}
|
||||||
|
val shortsResult = it.fromPool(_pagerClientPool).getShorts();
|
||||||
|
synchronized(clientIdsOngoing) {
|
||||||
|
clientIdsOngoing.remove(it.id);
|
||||||
|
}
|
||||||
|
return@map shortsResult;
|
||||||
|
}
|
||||||
|
.asSequence()
|
||||||
|
.toList()
|
||||||
|
.associateWith { 1f };
|
||||||
|
|
||||||
|
val pager = MultiDistributionContentPager(pages, 2);
|
||||||
|
pager.initialize();
|
||||||
|
return pager;
|
||||||
|
}
|
||||||
suspend fun getHomeRefresh(scope: CoroutineScope): IPager<IPlatformContent> {
|
suspend fun getHomeRefresh(scope: CoroutineScope): IPager<IPlatformContent> {
|
||||||
Logger.i(TAG, "Platform - getHome (Refresh)");
|
Logger.i(TAG, "Platform - getHome (Refresh)");
|
||||||
val clients = getSortedEnabledClient().filter { if (it is JSClient) it.enableInHome else true };
|
val clients = getSortedEnabledClient().filter { if (it is JSClient) it.enableInHome else true };
|
||||||
@@ -995,6 +1055,16 @@ class StatePlatform {
|
|||||||
return client.getLiveChatWindow(url);
|
return client.getLiveChatWindow(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//Account
|
||||||
|
fun getUserHistory(id: String): IPager<IPlatformContent> {
|
||||||
|
val client = getClient(id);
|
||||||
|
if(client is JSClient && client.isLoggedIn) {
|
||||||
|
return client.fromPool(_pagerClientPool).getUserHistory()
|
||||||
|
}
|
||||||
|
return EmptyPager<IPlatformContent>();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
fun injectDevPlugin(source: SourcePluginConfig, script: String): String? {
|
fun injectDevPlugin(source: SourcePluginConfig, script: String): String? {
|
||||||
var devId: String? = null;
|
var devId: String? = null;
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ class StatePlayer {
|
|||||||
//Players
|
//Players
|
||||||
private var _exoplayer : PlayerManager? = null;
|
private var _exoplayer : PlayerManager? = null;
|
||||||
private var _thumbnailExoPlayer : PlayerManager? = null;
|
private var _thumbnailExoPlayer : PlayerManager? = null;
|
||||||
|
private var _shortExoPlayer: PlayerManager? = null
|
||||||
|
|
||||||
//Video Status
|
//Video Status
|
||||||
var rotationLock: Boolean = false
|
var rotationLock: Boolean = false
|
||||||
@@ -633,6 +634,13 @@ class StatePlayer {
|
|||||||
}
|
}
|
||||||
return _thumbnailExoPlayer!!;
|
return _thumbnailExoPlayer!!;
|
||||||
}
|
}
|
||||||
|
fun getShortPlayerOrCreate(context: Context) : PlayerManager {
|
||||||
|
if(_shortExoPlayer == null) {
|
||||||
|
val player = createExoPlayer(context);
|
||||||
|
_shortExoPlayer = PlayerManager(player);
|
||||||
|
}
|
||||||
|
return _shortExoPlayer!!;
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
private fun createExoPlayer(context : Context): ExoPlayer {
|
private fun createExoPlayer(context : Context): ExoPlayer {
|
||||||
@@ -656,10 +664,13 @@ class StatePlayer {
|
|||||||
fun dispose(){
|
fun dispose(){
|
||||||
val player = _exoplayer;
|
val player = _exoplayer;
|
||||||
val thumbPlayer = _thumbnailExoPlayer;
|
val thumbPlayer = _thumbnailExoPlayer;
|
||||||
|
val shortPlayer = _shortExoPlayer
|
||||||
_exoplayer = null;
|
_exoplayer = null;
|
||||||
_thumbnailExoPlayer = null;
|
_thumbnailExoPlayer = null;
|
||||||
|
_shortExoPlayer = null
|
||||||
player?.release();
|
player?.release();
|
||||||
thumbPlayer?.release();
|
thumbPlayer?.release();
|
||||||
|
shortPlayer?.release()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -179,8 +179,9 @@ class StatePlugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
StateApp.instance.scope.launch(Dispatchers.IO) {
|
StateApp.instance.scope.launch(Dispatchers.IO) {
|
||||||
StatePlatform.instance.reloadClient(context, id);
|
StatePlatform.instance.reloadClient(context, id) {
|
||||||
afterLogin.invoke();
|
afterLogin.invoke();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return true;
|
return true;
|
||||||
@@ -401,18 +402,25 @@ class StatePlugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val icon = config.absoluteIconUrl?.let { absIconUrl ->
|
val icon = config.absoluteIconUrl?.let { absIconUrl ->
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
it.setText("Saving plugin...");
|
|
||||||
it.setProgress(0.75);
|
|
||||||
}
|
|
||||||
val iconResp = client.get(absIconUrl);
|
val iconResp = client.get(absIconUrl);
|
||||||
if(iconResp.isOk)
|
if(iconResp.isOk)
|
||||||
return@let iconResp.body?.byteStream()?.use { it.readBytes() };
|
return@let iconResp.body?.byteStream()?.use { it.readBytes() };
|
||||||
return@let null;
|
return@let null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
it.setText("Saving plugin...");
|
||||||
|
it.setProgress(0.75);
|
||||||
|
}
|
||||||
|
|
||||||
val installEx = StatePlugins.instance.createPlugin(config, script, icon, reinstall);
|
val installEx = StatePlugins.instance.createPlugin(config, script, icon, reinstall);
|
||||||
if(installEx != null)
|
if(installEx != null)
|
||||||
throw installEx;
|
throw installEx;
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
it.setText("Reloading available plugins...");
|
||||||
|
it.setProgress(0.9);
|
||||||
|
}
|
||||||
StatePlatform.instance.updateAvailableClients(context);
|
StatePlatform.instance.updateAvailableClients(context);
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
@@ -475,6 +483,7 @@ class StatePlugins {
|
|||||||
delay(500);
|
delay(500);
|
||||||
|
|
||||||
val client = ManagedHttpClient();
|
val client = ManagedHttpClient();
|
||||||
|
client.setTimeout(10000);
|
||||||
try {
|
try {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
onProgress.invoke("Validating script", 0.25);
|
onProgress.invoke("Validating script", 0.25);
|
||||||
@@ -489,14 +498,14 @@ class StatePlugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val icon = config.absoluteIconUrl?.let { absIconUrl ->
|
val icon = config.absoluteIconUrl?.let { absIconUrl ->
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
onProgress.invoke("Saving plugin", 0.75);
|
|
||||||
}
|
|
||||||
val iconResp = client.get(absIconUrl);
|
val iconResp = client.get(absIconUrl);
|
||||||
if (iconResp.isOk)
|
if (iconResp.isOk)
|
||||||
return@let iconResp.body?.byteStream()?.use { it.readBytes() };
|
return@let iconResp.body?.byteStream()?.use { it.readBytes() };
|
||||||
return@let null;
|
return@let null;
|
||||||
}
|
}
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
onProgress.invoke("Saving plugin", 0.75);
|
||||||
|
}
|
||||||
val installEx = StatePlugins.instance.createPlugin(config, script, icon, true);
|
val installEx = StatePlugins.instance.createPlugin(config, script, icon, true);
|
||||||
if (installEx != null)
|
if (installEx != null)
|
||||||
throw installEx;
|
throw installEx;
|
||||||
@@ -520,9 +529,7 @@ class StatePlugins {
|
|||||||
if(id == StateDeveloper.DEV_ID)
|
if(id == StateDeveloper.DEV_ID)
|
||||||
throw IllegalStateException("Attempted to retrieve a persistent developer plugin, this is not allowed");
|
throw IllegalStateException("Attempted to retrieve a persistent developer plugin, this is not allowed");
|
||||||
|
|
||||||
synchronized(_plugins) {
|
return _plugins.findItem { it.config.id == id };
|
||||||
return _plugins.findItem { it.config.id == id };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
fun getPlugins(): List<SourcePluginDescriptor> {
|
fun getPlugins(): List<SourcePluginDescriptor> {
|
||||||
return _plugins.getItems();
|
return _plugins.getItems();
|
||||||
@@ -531,12 +538,10 @@ class StatePlugins {
|
|||||||
|
|
||||||
fun deletePlugin(id: String) {
|
fun deletePlugin(id: String) {
|
||||||
synchronized(_pluginScripts) {
|
synchronized(_pluginScripts) {
|
||||||
synchronized(_plugins) {
|
_pluginScripts.deleteFile(id);
|
||||||
_pluginScripts.deleteFile(id);
|
val plugins = _plugins.findItems { it.config.id == id };
|
||||||
val plugins = _plugins.findItems { it.config.id == id };
|
for(plugin in plugins)
|
||||||
for(plugin in plugins)
|
_plugins.delete(plugin);
|
||||||
_plugins.delete(plugin);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fun createPlugin(config: SourcePluginConfig, script: String, icon: ByteArray? = null, reinstall: Boolean = false, flags: List<String> = listOf()) : Throwable? {
|
fun createPlugin(config: SourcePluginConfig, script: String, icon: ByteArray? = null, reinstall: Boolean = false, flags: List<String> = listOf()) : Throwable? {
|
||||||
|
|||||||
@@ -57,9 +57,12 @@ class StateSync {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var relayServerUrl = Settings.instance.synchronization.syncServer;
|
||||||
|
Logger.i(TAG, "Relay used: ${relayServerUrl}");
|
||||||
|
|
||||||
syncService = SyncService(
|
syncService = SyncService(
|
||||||
SERVICE_NAME,
|
SERVICE_NAME,
|
||||||
RELAY_SERVER,
|
relayServerUrl,
|
||||||
RELAY_PUBLIC_KEY,
|
RELAY_PUBLIC_KEY,
|
||||||
APP_ID,
|
APP_ID,
|
||||||
StoreBasedSyncDatabaseProvider(),
|
StoreBasedSyncDatabaseProvider(),
|
||||||
|
|||||||
@@ -34,15 +34,18 @@ class PlayerManager {
|
|||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun attach(view: PlayerView, stateName: String) {
|
fun attach(view: PlayerView, stateName: String) {
|
||||||
if(view != _currentView) {
|
if (view != _currentView) {
|
||||||
_currentView?.player = null;
|
_currentView?.player = null
|
||||||
switchState(stateName);
|
_currentView = null
|
||||||
view.player = player;
|
switchState(stateName)
|
||||||
_currentView = view;
|
view.player = player
|
||||||
|
_currentView = view
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun detach() {
|
fun detach() {
|
||||||
_currentView?.player = null;
|
_currentView?.player = null
|
||||||
|
_currentView = null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getState(name: String): PlayerState {
|
fun getState(name: String): PlayerState {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.content.Context
|
|||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
|
import androidx.core.view.children
|
||||||
import androidx.lifecycle.findViewTreeLifecycleOwner
|
import androidx.lifecycle.findViewTreeLifecycleOwner
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
@@ -28,6 +29,8 @@ import kotlinx.coroutines.launch
|
|||||||
class ToggleBar : LinearLayout {
|
class ToggleBar : LinearLayout {
|
||||||
private val _tagsContainer: LinearLayout;
|
private val _tagsContainer: LinearLayout;
|
||||||
|
|
||||||
|
private var allowLongPress: Boolean = false;
|
||||||
|
|
||||||
override fun onAttachedToWindow() {
|
override fun onAttachedToWindow() {
|
||||||
super.onAttachedToWindow();
|
super.onAttachedToWindow();
|
||||||
}
|
}
|
||||||
@@ -48,12 +51,31 @@ class ToggleBar : LinearLayout {
|
|||||||
for(button in buttons) {
|
for(button in buttons) {
|
||||||
_tagsContainer.addView(ToggleTagView(context).apply {
|
_tagsContainer.addView(ToggleTagView(context).apply {
|
||||||
if(button.icon > 0)
|
if(button.icon > 0)
|
||||||
this.setInfo(button.icon, button.name, button.isActive, button.isButton);
|
this.setInfo(button.icon, button.name, button.isActive, button.isButton, button.tag);
|
||||||
else if(button.iconVariable != null)
|
else if(button.iconVariable != null)
|
||||||
this.setInfo(button.iconVariable, button.name, button.isActive, button.isButton);
|
this.setInfo(button.iconVariable, button.name, button.isActive, button.isButton, button.tag);
|
||||||
else
|
else
|
||||||
this.setInfo(button.name, button.isActive, button.isButton);
|
this.setInfo(button.name, button.isActive, button.isButton, button.tag);
|
||||||
this.onClick.subscribe({ view, enabled -> button.action(view, enabled); });
|
this.onClick.subscribe({ view, enabled -> button.action(view, enabled); });
|
||||||
|
if(allowLongPress) {
|
||||||
|
this.onLongClick.subscribe({ view, enabled ->
|
||||||
|
for (tagView in _tagsContainer.children.filter { it is ToggleTagView }) {
|
||||||
|
if (tagView != view && tagView is ToggleTagView && !tagView.isButton) {
|
||||||
|
if (enabled && !tagView.isActive) {
|
||||||
|
tagView.handleClick();
|
||||||
|
} else if (!enabled && tagView.isActive) {
|
||||||
|
tagView.handleClick();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
else if(button.actionLong != null) {
|
||||||
|
this.onLongClick.subscribe({ view, enabled ->
|
||||||
|
val tags = _tagsContainer.children.filter { it is ToggleTagView }.map { it as ToggleTagView }.toList();
|
||||||
|
button.actionLong!!(view, tags, enabled);
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,16 +85,18 @@ class ToggleBar : LinearLayout {
|
|||||||
val icon: Int;
|
val icon: Int;
|
||||||
val iconVariable: ImageVariable?;
|
val iconVariable: ImageVariable?;
|
||||||
val action: (ToggleTagView, Boolean)->Unit;
|
val action: (ToggleTagView, Boolean)->Unit;
|
||||||
|
val actionLong: ((ToggleTagView, List<ToggleTagView>, Boolean) -> Unit)?;
|
||||||
val isActive: Boolean;
|
val isActive: Boolean;
|
||||||
var isButton: Boolean = false
|
var isButton: Boolean = false
|
||||||
private set;
|
private set;
|
||||||
var tag: String? = null;
|
var tag: String? = null;
|
||||||
|
|
||||||
constructor(name: String, icon: ImageVariable?, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit) {
|
constructor(name: String, icon: ImageVariable?, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit, actionLong: ((ToggleTagView, List<ToggleTagView>, Boolean)->Unit)? = null) {
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.icon = 0;
|
this.icon = 0;
|
||||||
this.iconVariable = icon;
|
this.iconVariable = icon;
|
||||||
this.action = action;
|
this.action = action;
|
||||||
|
this.actionLong = actionLong;
|
||||||
this.isActive = isActive;
|
this.isActive = isActive;
|
||||||
}
|
}
|
||||||
constructor(name: String, icon: Int, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit) {
|
constructor(name: String, icon: Int, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit) {
|
||||||
@@ -80,6 +104,7 @@ class ToggleBar : LinearLayout {
|
|||||||
this.icon = icon;
|
this.icon = icon;
|
||||||
this.iconVariable = null;
|
this.iconVariable = null;
|
||||||
this.action = action;
|
this.action = action;
|
||||||
|
this.actionLong = null;
|
||||||
this.isActive = isActive;
|
this.isActive = isActive;
|
||||||
}
|
}
|
||||||
constructor(name: String, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit) {
|
constructor(name: String, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit) {
|
||||||
@@ -87,6 +112,7 @@ class ToggleBar : LinearLayout {
|
|||||||
this.icon = 0;
|
this.icon = 0;
|
||||||
this.iconVariable = null;
|
this.iconVariable = null;
|
||||||
this.action = action;
|
this.action = action;
|
||||||
|
this.actionLong = null;
|
||||||
this.isActive = isActive;
|
this.isActive = isActive;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import com.futo.platformplayer.api.media.models.ResultCapabilities
|
|||||||
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
||||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
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.IPlatformContent
|
||||||
|
import com.futo.platformplayer.api.media.structures.IPager
|
||||||
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.constructs.Event3
|
||||||
import com.futo.platformplayer.fragment.channel.tab.ChannelAboutFragment
|
import com.futo.platformplayer.fragment.channel.tab.ChannelAboutFragment
|
||||||
import com.futo.platformplayer.fragment.channel.tab.ChannelContentsFragment
|
import com.futo.platformplayer.fragment.channel.tab.ChannelContentsFragment
|
||||||
import com.futo.platformplayer.fragment.channel.tab.ChannelListFragment
|
import com.futo.platformplayer.fragment.channel.tab.ChannelListFragment
|
||||||
@@ -38,6 +40,7 @@ class ChannelViewPagerAdapter(fragmentManager: FragmentManager, lifecycle: Lifec
|
|||||||
val onContentUrlClicked = Event2<String, ContentType>()
|
val onContentUrlClicked = Event2<String, ContentType>()
|
||||||
val onUrlClicked = Event1<String>()
|
val onUrlClicked = Event1<String>()
|
||||||
val onContentClicked = Event2<IPlatformContent, Long>()
|
val onContentClicked = Event2<IPlatformContent, Long>()
|
||||||
|
val onShortClicked = Event3<IPlatformContent, Long, Pair<IPager<IPlatformContent>, ArrayList<IPlatformContent>>?>()
|
||||||
val onChannelClicked = Event1<PlatformAuthorLink>()
|
val onChannelClicked = Event1<PlatformAuthorLink>()
|
||||||
val onAddToClicked = Event1<IPlatformContent>()
|
val onAddToClicked = Event1<IPlatformContent>()
|
||||||
val onAddToQueueClicked = Event1<IPlatformContent>()
|
val onAddToQueueClicked = Event1<IPlatformContent>()
|
||||||
@@ -81,7 +84,9 @@ class ChannelViewPagerAdapter(fragmentManager: FragmentManager, lifecycle: Lifec
|
|||||||
when (_tabs[position]) {
|
when (_tabs[position]) {
|
||||||
ChannelTab.VIDEOS -> {
|
ChannelTab.VIDEOS -> {
|
||||||
fragment = ChannelContentsFragment.newInstance().apply {
|
fragment = ChannelContentsFragment.newInstance().apply {
|
||||||
onContentClicked.subscribe(this@ChannelViewPagerAdapter.onContentClicked::emit)
|
onContentClicked.subscribe { video, num, _ ->
|
||||||
|
this@ChannelViewPagerAdapter.onContentClicked.emit(video, num)
|
||||||
|
}
|
||||||
onContentUrlClicked.subscribe(this@ChannelViewPagerAdapter.onContentUrlClicked::emit)
|
onContentUrlClicked.subscribe(this@ChannelViewPagerAdapter.onContentUrlClicked::emit)
|
||||||
onUrlClicked.subscribe(this@ChannelViewPagerAdapter.onUrlClicked::emit)
|
onUrlClicked.subscribe(this@ChannelViewPagerAdapter.onUrlClicked::emit)
|
||||||
onChannelClicked.subscribe(this@ChannelViewPagerAdapter.onChannelClicked::emit)
|
onChannelClicked.subscribe(this@ChannelViewPagerAdapter.onChannelClicked::emit)
|
||||||
@@ -94,7 +99,7 @@ class ChannelViewPagerAdapter(fragmentManager: FragmentManager, lifecycle: Lifec
|
|||||||
|
|
||||||
ChannelTab.SHORTS -> {
|
ChannelTab.SHORTS -> {
|
||||||
fragment = ChannelContentsFragment.newInstance(ResultCapabilities.TYPE_SHORTS).apply {
|
fragment = ChannelContentsFragment.newInstance(ResultCapabilities.TYPE_SHORTS).apply {
|
||||||
onContentClicked.subscribe(this@ChannelViewPagerAdapter.onContentClicked::emit)
|
onContentClicked.subscribe(this@ChannelViewPagerAdapter.onShortClicked::emit)
|
||||||
onContentUrlClicked.subscribe(this@ChannelViewPagerAdapter.onContentUrlClicked::emit)
|
onContentUrlClicked.subscribe(this@ChannelViewPagerAdapter.onContentUrlClicked::emit)
|
||||||
onUrlClicked.subscribe(this@ChannelViewPagerAdapter.onUrlClicked::emit)
|
onUrlClicked.subscribe(this@ChannelViewPagerAdapter.onUrlClicked::emit)
|
||||||
onChannelClicked.subscribe(this@ChannelViewPagerAdapter.onChannelClicked::emit)
|
onChannelClicked.subscribe(this@ChannelViewPagerAdapter.onChannelClicked::emit)
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ abstract class ContentPreviewViewHolder(itemView: View) : ViewHolder(itemView) {
|
|||||||
|
|
||||||
abstract fun bind(content: IPlatformContent);
|
abstract fun bind(content: IPlatformContent);
|
||||||
|
|
||||||
abstract fun preview(details: IPlatformContentDetails?, paused: Boolean);
|
abstract suspend fun preview(details: IPlatformContentDetails?, paused: Boolean);
|
||||||
abstract fun stopPreview();
|
abstract fun stopPreview();
|
||||||
abstract fun pausePreview();
|
abstract fun pausePreview();
|
||||||
abstract fun resumePreview();
|
abstract fun resumePreview();
|
||||||
|
|||||||
@@ -4,21 +4,19 @@ import android.graphics.drawable.Animatable
|
|||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import android.widget.LinearLayout
|
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.constraintlayout.widget.ConstraintLayout
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
import androidx.core.view.isVisible
|
||||||
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.casting.AirPlayCastingDevice
|
import com.futo.platformplayer.Settings
|
||||||
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.casting.CastConnectionState
|
import com.futo.platformplayer.casting.CastConnectionState
|
||||||
|
import com.futo.platformplayer.casting.CastProtocolType
|
||||||
import com.futo.platformplayer.casting.CastingDevice
|
import com.futo.platformplayer.casting.CastingDevice
|
||||||
import com.futo.platformplayer.casting.ChromecastCastingDevice
|
|
||||||
import com.futo.platformplayer.casting.FCastCastingDevice
|
|
||||||
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.constructs.Event2
|
import com.futo.platformplayer.logging.Logger
|
||||||
import androidx.core.view.isVisible
|
|
||||||
import com.futo.platformplayer.UIDialogs
|
|
||||||
|
|
||||||
class DeviceViewHolder : ViewHolder {
|
class DeviceViewHolder : ViewHolder {
|
||||||
private val _layoutDevice: FrameLayout;
|
private val _layoutDevice: FrameLayout;
|
||||||
@@ -56,16 +54,18 @@ class DeviceViewHolder : ViewHolder {
|
|||||||
|
|
||||||
val connect = {
|
val connect = {
|
||||||
device?.let { dev ->
|
device?.let { dev ->
|
||||||
if (dev.isReady) {
|
try {
|
||||||
StateCasting.instance.activeDevice?.stopCasting()
|
if (dev.isReady) {
|
||||||
StateCasting.instance.connectDevice(dev)
|
StateCasting.instance.activeDevice?.stopPlayback()
|
||||||
onConnect.emit(dev)
|
StateCasting.instance.connectDevice(dev)
|
||||||
} else {
|
onConnect.emit(dev)
|
||||||
try {
|
} else {
|
||||||
view.context?.let { UIDialogs.toast(it, "Device not ready, may be offline") }
|
view.context?.let {
|
||||||
} catch (e: Throwable) {
|
UIDialogs.toast(it, "Device not ready, may be offline")
|
||||||
//Ignored
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to connect: $e")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,15 +81,25 @@ class DeviceViewHolder : ViewHolder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun bind(d: CastingDevice, isOnlineDevice: Boolean, isPinnedDevice: Boolean) {
|
fun bind(d: CastingDevice, isOnlineDevice: Boolean, isPinnedDevice: Boolean) {
|
||||||
if (d is ChromecastCastingDevice) {
|
when (d.protocolType) {
|
||||||
_imageDevice.setImageResource(R.drawable.ic_chromecast);
|
CastProtocolType.CHROMECAST -> {
|
||||||
_textType.text = "Chromecast";
|
_imageDevice.setImageResource(R.drawable.ic_chromecast);
|
||||||
} else if (d is AirPlayCastingDevice) {
|
_textType.text = "Chromecast";
|
||||||
_imageDevice.setImageResource(R.drawable.ic_airplay);
|
}
|
||||||
_textType.text = "AirPlay";
|
CastProtocolType.AIRPLAY -> {
|
||||||
} else if (d is FCastCastingDevice) {
|
_imageDevice.setImageResource(R.drawable.ic_airplay);
|
||||||
_imageDevice.setImageResource(R.drawable.ic_fc);
|
_textType.text = "AirPlay";
|
||||||
_textType.text = "FCast";
|
}
|
||||||
|
CastProtocolType.FCAST -> {
|
||||||
|
_imageDevice.setImageResource(
|
||||||
|
if (Settings.instance.casting.experimentalCasting) {
|
||||||
|
R.drawable.ic_exp_fc
|
||||||
|
} else {
|
||||||
|
R.drawable.ic_fc
|
||||||
|
}
|
||||||
|
)
|
||||||
|
_textType.text = "FCast";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_textName.text = d.name;
|
_textName.text = d.name;
|
||||||
@@ -136,4 +146,8 @@ class DeviceViewHolder : ViewHolder {
|
|||||||
|
|
||||||
device = d;
|
device = d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val TAG = "DeviceViewHolder"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -11,7 +11,7 @@ class EmptyPreviewViewHolder(viewGroup: ViewGroup) : ContentPreviewViewHolder(Vi
|
|||||||
|
|
||||||
override fun bind(content: IPlatformContent) {}
|
override fun bind(content: IPlatformContent) {}
|
||||||
|
|
||||||
override fun preview(details: IPlatformContentDetails?, paused: Boolean) {}
|
override suspend fun preview(details: IPlatformContentDetails?, paused: Boolean) {}
|
||||||
|
|
||||||
override fun stopPreview() {}
|
override fun stopPreview() {}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -29,7 +29,7 @@ class PreviewChannelViewHolder : ContentPreviewViewHolder {
|
|||||||
|
|
||||||
override fun bind(content: IPlatformContent) = view.bind(content);
|
override fun bind(content: IPlatformContent) = view.bind(content);
|
||||||
|
|
||||||
override fun preview(details: IPlatformContentDetails?, paused: Boolean) = Unit;
|
override suspend fun preview(details: IPlatformContentDetails?, paused: Boolean) = Unit;
|
||||||
override fun stopPreview() = Unit;
|
override fun stopPreview() = Unit;
|
||||||
override fun pausePreview() = Unit;
|
override fun pausePreview() = Unit;
|
||||||
override fun resumePreview() = Unit;
|
override fun resumePreview() = Unit;
|
||||||
|
|||||||
+36
-11
@@ -4,6 +4,7 @@ import android.content.Context
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
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.IPlatformContent
|
||||||
@@ -15,6 +16,8 @@ import com.futo.platformplayer.constructs.Event1
|
|||||||
import com.futo.platformplayer.constructs.Event2
|
import com.futo.platformplayer.constructs.Event2
|
||||||
import com.futo.platformplayer.constructs.TaskHandler
|
import com.futo.platformplayer.constructs.TaskHandler
|
||||||
import com.futo.platformplayer.debug.Stopwatch
|
import com.futo.platformplayer.debug.Stopwatch
|
||||||
|
import com.futo.platformplayer.fragment.mainactivity.main.ShortView
|
||||||
|
import com.futo.platformplayer.fragment.mainactivity.main.ShortView.Companion
|
||||||
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.states.StatePlatform
|
import com.futo.platformplayer.states.StatePlatform
|
||||||
@@ -23,6 +26,9 @@ import com.futo.platformplayer.views.FeedStyle
|
|||||||
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
|
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
|
||||||
import com.futo.platformplayer.views.adapters.EmptyPreviewViewHolder
|
import com.futo.platformplayer.views.adapters.EmptyPreviewViewHolder
|
||||||
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
|
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import okhttp3.internal.platform.Platform
|
import okhttp3.internal.platform.Platform
|
||||||
|
|
||||||
class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewViewHolder> {
|
class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewViewHolder> {
|
||||||
@@ -33,6 +39,7 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewVi
|
|||||||
private val _feedStyle : FeedStyle;
|
private val _feedStyle : FeedStyle;
|
||||||
private var _paused: Boolean = false;
|
private var _paused: Boolean = false;
|
||||||
private val _shouldShowTimeBar: Boolean
|
private val _shouldShowTimeBar: Boolean
|
||||||
|
private val _scope: CoroutineScope
|
||||||
|
|
||||||
val onUrlClicked = Event1<String>();
|
val onUrlClicked = Event1<String>();
|
||||||
val onContentUrlClicked = Event2<String, ContentType>();
|
val onContentUrlClicked = Event2<String, ContentType>();
|
||||||
@@ -43,15 +50,9 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewVi
|
|||||||
val onAddToWatchLaterClicked = Event1<IPlatformContent>();
|
val onAddToWatchLaterClicked = Event1<IPlatformContent>();
|
||||||
val onLongPress = Event1<IPlatformContent>();
|
val onLongPress = Event1<IPlatformContent>();
|
||||||
|
|
||||||
private var _taskLoadContent = TaskHandler<Pair<ContentPreviewViewHolder, IPlatformContent>, Pair<ContentPreviewViewHolder, IPlatformContentDetails>>(
|
private var _taskLoadContent: TaskHandler<Pair<ContentPreviewViewHolder, IPlatformContent>, Pair<ContentPreviewViewHolder, IPlatformContentDetails>>
|
||||||
StateApp.instance.scopeGetter, { (viewHolder, video) ->
|
|
||||||
val stopwatch = Stopwatch()
|
|
||||||
val contentDetails = StatePlatform.instance.getContentDetails(video.url).await();
|
|
||||||
stopwatch.logAndNext(TAG, "Retrieving video detail (IO thread)")
|
|
||||||
return@TaskHandler Pair(viewHolder, contentDetails)
|
|
||||||
}).exception<Throwable> { Logger.e(TAG, "Failed to retrieve preview content.", it) }.success { previewContentDetails(it.first, it.second) }
|
|
||||||
|
|
||||||
constructor(context: Context, feedStyle : FeedStyle, dataSet: ArrayList<IPlatformContent>, exoPlayer: PlayerManager? = null,
|
constructor(scope: CoroutineScope, context: Context, feedStyle : FeedStyle, dataSet: ArrayList<IPlatformContent>, exoPlayer: PlayerManager? = null,
|
||||||
initialPlay: Boolean = false, viewsToPrepend: ArrayList<View> = arrayListOf(),
|
initialPlay: Boolean = false, viewsToPrepend: ArrayList<View> = arrayListOf(),
|
||||||
viewsToAppend: ArrayList<View> = arrayListOf(), shouldShowTimeBar: Boolean = true) : super(context, viewsToPrepend, viewsToAppend) {
|
viewsToAppend: ArrayList<View> = arrayListOf(), shouldShowTimeBar: Boolean = true) : super(context, viewsToPrepend, viewsToAppend) {
|
||||||
|
|
||||||
@@ -60,6 +61,24 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewVi
|
|||||||
this._initialPlay = initialPlay;
|
this._initialPlay = initialPlay;
|
||||||
this._exoPlayer = exoPlayer;
|
this._exoPlayer = exoPlayer;
|
||||||
this._shouldShowTimeBar = shouldShowTimeBar
|
this._shouldShowTimeBar = shouldShowTimeBar
|
||||||
|
this._scope = scope
|
||||||
|
|
||||||
|
_taskLoadContent = TaskHandler<Pair<ContentPreviewViewHolder, IPlatformContent>, Pair<ContentPreviewViewHolder, IPlatformContentDetails>>(
|
||||||
|
{ scope }, { (viewHolder, video) ->
|
||||||
|
val stopwatch = Stopwatch()
|
||||||
|
val contentDetails = StatePlatform.instance.getContentDetails(video.url).await();
|
||||||
|
stopwatch.logAndNext(TAG, "Retrieving video detail (IO thread)")
|
||||||
|
return@TaskHandler Pair(viewHolder, contentDetails)
|
||||||
|
}).exception<Throwable> { Logger.e(TAG, "Failed to retrieve preview content.", it) }.success {
|
||||||
|
|
||||||
|
_scope.launch(Dispatchers.Main) {
|
||||||
|
try {
|
||||||
|
previewContentDetails(it.first, it.second)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "bindChild preview failed", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getChildCount(): Int = _dataSet.size;
|
override fun getChildCount(): Int = _dataSet.size;
|
||||||
@@ -132,12 +151,18 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewVi
|
|||||||
_initialPlay = false;
|
_initialPlay = false;
|
||||||
|
|
||||||
if (_feedStyle != FeedStyle.THUMBNAIL) {
|
if (_feedStyle != FeedStyle.THUMBNAIL) {
|
||||||
preview(holder);
|
_scope.launch(Dispatchers.Main) {
|
||||||
|
try {
|
||||||
|
preview(holder)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "bindChild preview failed", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun preview(viewHolder: ContentPreviewViewHolder) {
|
suspend fun preview(viewHolder: ContentPreviewViewHolder) {
|
||||||
Log.v(TAG, "previewing content");
|
Log.v(TAG, "previewing content");
|
||||||
if (viewHolder == _previewingViewHolder)
|
if (viewHolder == _previewingViewHolder)
|
||||||
return
|
return
|
||||||
@@ -175,7 +200,7 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewVi
|
|||||||
onAddToWatchLaterClicked.clear();
|
onAddToWatchLaterClicked.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun previewContentDetails(viewHolder: ContentPreviewViewHolder, videoDetails: IPlatformContentDetails?) {
|
private suspend fun previewContentDetails(viewHolder: ContentPreviewViewHolder, videoDetails: IPlatformContentDetails?) {
|
||||||
_previewingViewHolder?.stopPreview();
|
_previewingViewHolder?.stopPreview();
|
||||||
viewHolder.preview(videoDetails, _paused);
|
viewHolder.preview(videoDetails, _paused);
|
||||||
_previewingViewHolder = viewHolder;
|
_previewingViewHolder = viewHolder;
|
||||||
|
|||||||
+1
-1
@@ -25,7 +25,7 @@ class PreviewLockedViewHolder : ContentPreviewViewHolder {
|
|||||||
|
|
||||||
override fun bind(content: IPlatformContent) = view.bind(content);
|
override fun bind(content: IPlatformContent) = view.bind(content);
|
||||||
|
|
||||||
override fun preview(details: IPlatformContentDetails?, paused: Boolean) { }
|
override suspend fun preview(details: IPlatformContentDetails?, paused: Boolean) { }
|
||||||
override fun stopPreview() { }
|
override fun stopPreview() { }
|
||||||
override fun pausePreview() { }
|
override fun pausePreview() { }
|
||||||
override fun resumePreview() { }
|
override fun resumePreview() { }
|
||||||
|
|||||||
+1
-1
@@ -185,7 +185,7 @@ class PreviewNestedVideoView : PreviewVideoView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun preview(video: IPlatformContentDetails?, paused: Boolean) {
|
override suspend fun preview(video: IPlatformContentDetails?, paused: Boolean) {
|
||||||
if(video != null) {
|
if(video != null) {
|
||||||
super.preview(video, paused);
|
super.preview(video, paused);
|
||||||
} else if(_content is IPlatformVideoDetails) _contentNested?.let {
|
} else if(_content is IPlatformVideoDetails) _contentNested?.let {
|
||||||
|
|||||||
+1
-1
@@ -40,7 +40,7 @@ class PreviewNestedVideoViewHolder : ContentPreviewViewHolder {
|
|||||||
view.bind(content);
|
view.bind(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun preview(details: IPlatformContentDetails?, paused: Boolean) {
|
override suspend fun preview(details: IPlatformContentDetails?, paused: Boolean) {
|
||||||
view.preview(details, paused);
|
view.preview(details, paused);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -58,7 +58,7 @@ class PreviewPlaceholderViewHolder : ContentPreviewViewHolder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun preview(details: IPlatformContentDetails?, paused: Boolean) { }
|
override suspend fun preview(details: IPlatformContentDetails?, paused: Boolean) { }
|
||||||
override fun stopPreview() { }
|
override fun stopPreview() { }
|
||||||
override fun pausePreview() { }
|
override fun pausePreview() { }
|
||||||
override fun resumePreview() { }
|
override fun resumePreview() { }
|
||||||
|
|||||||
+1
-1
@@ -28,7 +28,7 @@ class PreviewPlaylistViewHolder : ContentPreviewViewHolder {
|
|||||||
|
|
||||||
override fun bind(content: IPlatformContent) = view.bind(content);
|
override fun bind(content: IPlatformContent) = view.bind(content);
|
||||||
|
|
||||||
override fun preview(details: IPlatformContentDetails?, paused: Boolean) = Unit;
|
override suspend fun preview(details: IPlatformContentDetails?, paused: Boolean) = Unit;
|
||||||
override fun stopPreview() = Unit;
|
override fun stopPreview() = Unit;
|
||||||
override fun pausePreview() = Unit;
|
override fun pausePreview() = Unit;
|
||||||
override fun resumePreview() = Unit;
|
override fun resumePreview() = Unit;
|
||||||
|
|||||||
+1
-1
@@ -28,7 +28,7 @@ class PreviewPostViewHolder : ContentPreviewViewHolder {
|
|||||||
|
|
||||||
override fun bind(content: IPlatformContent) = view.bind(content);
|
override fun bind(content: IPlatformContent) = view.bind(content);
|
||||||
|
|
||||||
override fun preview(details: IPlatformContentDetails?, paused: Boolean) {};
|
override suspend fun preview(details: IPlatformContentDetails?, paused: Boolean) {};
|
||||||
override fun stopPreview() {};
|
override fun stopPreview() {};
|
||||||
override fun pausePreview() {};
|
override fun pausePreview() {};
|
||||||
override fun resumePreview() {};
|
override fun resumePreview() {};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user