merge master

This commit is contained in:
Trevor
2025-10-01 13:34:16 -05:00
97 changed files with 5762 additions and 1737 deletions
+2 -2
View File
@@ -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
+6
View File
@@ -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 -5
View File
@@ -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
@@ -119,7 +119,7 @@ android {
buildTypes { buildTypes {
release { release {
signingConfig signingConfigs.debug signingConfig signingConfigs.release
minifyEnabled false minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
} }
@@ -154,10 +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'
implementation 'com.google.android.material:material:1.12.0' implementation 'com.google.android.material:material:1.12.0'
annotationProcessor 'com.google.dagger:dagger-compiler:2.48' //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'
@@ -231,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'
}
} }
+22 -2
View File
@@ -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];
+9 -2
View File
@@ -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);
@@ -467,13 +468,19 @@ 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;
} }
} }
} }
@@ -12,6 +12,7 @@ import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.engine.exceptions.ScriptExecutionException import com.futo.platformplayer.engine.exceptions.ScriptExecutionException
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred import kotlinx.coroutines.Deferred
@@ -21,7 +22,6 @@ import kotlinx.coroutines.async
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.selects.SelectClause0 import kotlinx.coroutines.selects.SelectClause0
import kotlinx.coroutines.selects.SelectClause1 import kotlinx.coroutines.selects.SelectClause1
import java.util.concurrent.CancellationException
import java.util.concurrent.CountDownLatch import java.util.concurrent.CountDownLatch
import kotlin.coroutines.AbstractCoroutineContextElement import kotlin.coroutines.AbstractCoroutineContextElement
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
@@ -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,9 +225,26 @@ fun <T: V8Value> V8ValuePromise.toV8ValueBlocking(plugin: V8Plugin): T {
promiseException = CancellationException("Cancelled by system"); promiseException = CancellationException("Cancelled by system");
latch.countDown(); latch.countDown();
} }
//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());
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 { plugin.unbusy {
latch.await(); latch.await();
} }
}
if(promiseException != null) if(promiseException != null)
throw promiseException!!; throw promiseException!!;
return promiseResult!!; return promiseResult!!;
@@ -249,12 +268,25 @@ fun <T: V8Value> V8ValuePromise.toV8ValueAsync(plugin: V8Plugin): V8Deferred<T>
underlyingDef.complete(p0 as T); underlyingDef.complete(p0 as T);
} }
override fun onRejected(p0: V8Value?) { override fun onRejected(p0: V8Value?) {
try {
plugin.resolvePromise(promise); plugin.resolvePromise(promise);
underlyingDef.completeExceptionally(NotImplementedError("onRejected promise not implemented..")); val exceptionFound = p0?.toException(plugin.config) ?: NotImplementedError("onRejected promise not implemented..");
Logger.i("V8", "Promise rejected, setting exception");
underlyingDef.completeExceptionally(CancellationException(exceptionFound.message, exceptionFound));
}
catch(ex: Throwable) {
Logger.e("V8", "Rejection handling failed?" , ex);
}
} }
override fun onCatch(p0: V8Value?) { override fun onCatch(p0: V8Value?) {
try {
plugin.resolvePromise(promise); plugin.resolvePromise(promise);
underlyingDef.completeExceptionally(NotImplementedError("onCatch promise not implemented..")); val exceptionFound = p0?.toException(plugin.config) ?: NotImplementedError("onCatch promise not implemented..");
underlyingDef.completeExceptionally(CancellationException(exceptionFound.message, exceptionFound));
}
catch(ex: Throwable) {
Logger.e("V8", "Catching handling failed?" , ex);
}
} }
}); });
} }
@@ -265,6 +297,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 Throwable("Promise Failed: " + pluginType + msg);
}
else if(p0 is V8ValueString)
return Throwable("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);
@@ -535,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,
@@ -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
@@ -775,7 +776,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)
} }
@@ -786,8 +787,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/")) {
@@ -814,11 +816,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",
{ }); { });
} }
@@ -939,6 +941,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;
} }
@@ -1061,7 +1069,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)
@@ -123,8 +123,6 @@ class LiveChatManager {
val requestPosition = _position; val requestPosition = _position;
_pager.nextPage(requestPosition.toInt()); _pager.nextPage(requestPosition.toInt());
var replayResults = _pager.getResults().filter { it.time > requestPosition || it is LiveEventEmojis }; var replayResults = _pager.getResults().filter { it.time > requestPosition || it is LiveEventEmojis };
//TODO: Remove this once dripfeed is done properly
replayResults = replayResults.filter{ it.time < requestPosition + 1500 || it is LiveEventEmojis };
if(replayResults.size > 0) { if(replayResults.size > 0) {
_eventsPosition = replayResults.maxOf { it.time }; _eventsPosition = replayResults.maxOf { it.time };
Logger.i(TAG, "VOD Events last event: " + _eventsPosition); Logger.i(TAG, "VOD Events last event: " + _eventsPosition);
@@ -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;
}
} }
@@ -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;
@@ -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;
@@ -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;
}
}
}
@@ -3,6 +3,7 @@ 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
@@ -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;
}
}
}
}
@@ -116,25 +116,12 @@ class SourcePluginDescriptor {
var enableShorts: Boolean? = null; var enableShorts: Boolean? = null;
} }
@FormField(R.string.sync, "group", R.string.sync_desc, 3) @FormField(R.string.sync, "group", R.string.sync_desc, 3,"sync")
var sync = Sync(); var sync = Sync();
@Serializable @Serializable
class Sync { class Sync {
@FormField(R.string.sync_history, FieldForm.TOGGLE, R.string.sync_history_desc, 1) @FormField(R.string.sync_history, FieldForm.TOGGLE, R.string.sync_history_desc, 1,"syncHistory")
var enableHistorySync: Boolean? = null; var enableHistorySync: Boolean? = null;
@FormField(R.string.sync_history, FieldForm.BUTTON, R.string.sync_history_desc, 2)
@FormFieldButton()
fun syncHistoryNow() {
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
val clients = StatePlatform.instance.getEnabledClients();
for (client in clients) {
if (client is JSClient) {//) && client.descriptor.appSettings.sync.enableHistorySync == true) {
StateHistory.instance.syncRemoteHistory(client);
}
}
};
}
} }
@FormField(R.string.ratelimit, "group", R.string.ratelimit_description, 4) @FormField(R.string.ratelimit, "group", R.string.ratelimit_description, 4)
@@ -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;
@@ -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();
@@ -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;
}
} }
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
@@ -12,7 +12,7 @@ class MultiDistributionContentPager<T : IPlatformContent> : MultiPager<T> {
private val dist : HashMap<IPager<T>, Float>; private val dist : HashMap<IPager<T>, Float>;
private val distConsumed : HashMap<IPager<T>, Float>; private val distConsumed : HashMap<IPager<T>, Float>;
constructor(pagers : Map<IPager<T>, 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();
@@ -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 @Throws
onTimeChanged.emit(value) abstract fun seekTo(timeSeconds: Double)
}
@Throws
abstract fun changeVolume(timeSeconds: Double)
@Throws
abstract fun changeSpeed(speed: Double)
@Throws
abstract fun connect()
@Throws
abstract fun disconnect()
abstract fun getDeviceInfo(): CastingDeviceInfo
abstract fun getAddresses(): List<InetAddress>
@Throws
abstract fun loadVideo(
streamType: String,
contentType: String,
contentId: String,
resumePosition: Double,
duration: Double,
speed: Double?,
metadata: Metadata?
)
@Throws
abstract fun loadContent(
contentType: String,
content: String,
resumePosition: Double,
duration: Double,
speed: Double?,
metadata: Metadata?
)
abstract fun ensureThreadStarted()
} }
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>;
}
@@ -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;
@@ -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;
@@ -3,15 +3,8 @@ package com.futo.platformplayer.casting
import android.app.AlertDialog import android.app.AlertDialog
import android.content.ContentResolver import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.net.Uri
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.os.Build
import android.os.Looper import android.os.Looper
import android.util.Base64
import android.util.Log import android.util.Log
import java.net.NetworkInterface
import java.net.Inet4Address
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import com.futo.platformplayer.R import com.futo.platformplayer.R
@@ -41,39 +34,38 @@ import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManif
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
import com.futo.platformplayer.builders.DashBuilder import com.futo.platformplayer.builders.DashBuilder
import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.exceptions.UnsupportedCastException import com.futo.platformplayer.exceptions.UnsupportedCastException
import com.futo.platformplayer.findPreferredAddress import com.futo.platformplayer.findPreferredAddress
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.platformplayer.parsers.HLS import com.futo.platformplayer.parsers.HLS
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.stores.CastingDeviceInfoStorage import com.futo.platformplayer.stores.CastingDeviceInfoStorage
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.toUrlAddress import com.futo.platformplayer.toUrlAddress
import com.futo.platformplayer.views.casting.CastView
import com.futo.platformplayer.views.casting.CastView.Companion
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable import org.fcast.sender_sdk.Metadata
import kotlinx.serialization.json.Json
import java.net.Inet6Address import java.net.Inet6Address
import java.net.InetAddress
import java.net.URLDecoder import java.net.URLDecoder
import java.net.URLEncoder import java.net.URLEncoder
import java.util.Collections
import java.util.UUID import java.util.UUID
import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicInteger
class StateCasting { abstract class StateCasting {
private val _scopeIO = CoroutineScope(Dispatchers.IO); val _scopeIO = CoroutineScope(Dispatchers.IO);
private val _scopeMain = CoroutineScope(Dispatchers.Main); val _scopeMain = CoroutineScope(Dispatchers.Main);
private val _storage: CastingDeviceInfoStorage = FragmentedStorage.get(); private val _storage: CastingDeviceInfoStorage = FragmentedStorage.get();
private val _castServer = ManagedHttpServer(); val _castServer = ManagedHttpServer();
private var _started = false; var _started = false;
var devices: HashMap<String, CastingDevice> = hashMapOf(); var devices: HashMap<String, CastingDevice> = hashMapOf();
val onDeviceAdded = Event1<CastingDevice>(); val onDeviceAdded = Event1<CastingDevice>();
@@ -89,212 +81,46 @@ class StateCasting {
private var _audioExecutor: JSRequestExecutor? = null private var _audioExecutor: JSRequestExecutor? = null
private val _client = ManagedHttpClient(); private val _client = ManagedHttpClient();
var _resumeCastingDevice: CastingDeviceInfo? = null; var _resumeCastingDevice: CastingDeviceInfo? = null;
private var _nsdManager: NsdManager? = null
val isCasting: Boolean get() = activeDevice != null; val isCasting: Boolean get() = activeDevice != null;
private val _castId = AtomicInteger(0) private val _castId = AtomicInteger(0)
private val _discoveryListeners = mapOf( abstract fun handleUrl(url: String)
"_googlecast._tcp" to createDiscoveryListener(::addOrUpdateChromeCastDevice), abstract fun onStop()
"_airplay._tcp" to createDiscoveryListener(::addOrUpdateAirPlayDevice), abstract fun start(context: Context)
"_fastcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice), abstract fun stop()
"_fcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice)
)
fun handleUrl(context: Context, url: String) { @Throws
val uri = Uri.parse(url) abstract fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDevice
if (uri.scheme != "fcast") { abstract fun startUpdateTimeJob(
throw Exception("Expected scheme to be FCast") onTimeJobTimeChanged_s: Event1<Long>, setTime: (Long) -> Unit
} ): Job?
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(deviceFromCastingDeviceInfo(foundInfo))
}
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.stop();
}
fun onResume() { fun onResume() {
val ad = activeDevice val ad = activeDevice
if (ad != null) { if (ad != null) {
if (ad is FCastCastingDevice) {
ad.ensureThreadStarted() ad.ensureThreadStarted()
} else if (ad is ChromecastCastingDevice) {
ad.ensureThreadsStarted()
}
} else { } else {
val resumeCastingDevice = _resumeCastingDevice val resumeCastingDevice = _resumeCastingDevice
if (resumeCastingDevice != null) { if (resumeCastingDevice != null) {
connectDevice(deviceFromCastingDeviceInfo(resumeCastingDevice)) val dev = deviceFromInfo(resumeCastingDevice) ?: return
connectDevice(dev)
_resumeCastingDevice = null _resumeCastingDevice = null
Log.i(TAG, "_resumeCastingDevice set to null onResume") Log.i(TAG, "_resumeCastingDevice set to null onResume")
} }
} }
} }
@Synchronized fun cancel() {
fun start(context: Context) { _castId.incrementAndGet()
if (_started) }
fun invokeInMainScopeIfRequired(action: () -> Unit) {
if (Looper.getMainLooper().thread != Thread.currentThread()) {
_scopeMain.launch { action() }
return; 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 action();
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
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?.stop();
_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)
}
})
}
}
}
} }
private val _castingDialogLock = Any(); private val _castingDialogLock = Any();
@@ -302,8 +128,9 @@ class StateCasting {
@Synchronized @Synchronized
fun connectDevice(device: CastingDevice) { fun connectDevice(device: CastingDevice) {
if (activeDevice == device) if (activeDevice == device) {
return; return
}
val ad = activeDevice; val ad = activeDevice;
if (ad != null) { if (ad != null) {
@@ -313,11 +140,11 @@ class StateCasting {
device.onTimeChanged.clear(); device.onTimeChanged.clear();
device.onVolumeChanged.clear(); device.onVolumeChanged.clear();
device.onDurationChanged.clear(); device.onDurationChanged.clear();
ad.stop(); ad.disconnect()
} }
device.onConnectionStateChanged.subscribe { castConnectionState -> device.onConnectionStateChanged.subscribe { castConnectionState ->
Logger.i(TAG, "Active device connection state changed: $castConnectionState"); Logger.i(TAG, "Active device connection state changed: $castConnectionState")
if (castConnectionState == CastConnectionState.DISCONNECTED) { if (castConnectionState == CastConnectionState.DISCONNECTED) {
Logger.i(TAG, "Clearing events: $castConnectionState"); Logger.i(TAG, "Clearing events: $castConnectionState");
@@ -354,7 +181,11 @@ class StateCasting {
"Connecting to [${device.name}]", "Connecting to [${device.name}]",
"Make sure you are on the same network\n\nVPNs and guest networks can cause issues", null, -2, "Make sure you are on the same network\n\nVPNs and guest networks can cause issues", null, -2,
UIDialogs.Action("Disconnect", { UIDialogs.Action("Disconnect", {
device.stop(); try {
device.disconnect()
} catch (e: Throwable) {
Logger.e(TAG, "Failed to disconnect from device: $e")
}
})); }));
} }
} }
@@ -376,7 +207,7 @@ class StateCasting {
}; };
device.onPlayChanged.subscribe { device.onPlayChanged.subscribe {
invokeInMainScopeIfRequired { onActiveDevicePlayChanged.emit(it) }; invokeInMainScopeIfRequired { onActiveDevicePlayChanged.emit(it) };
} };
device.onDurationChanged.subscribe { device.onDurationChanged.subscribe {
invokeInMainScopeIfRequired { onActiveDeviceDurationChanged.emit(it) }; invokeInMainScopeIfRequired { onActiveDeviceDurationChanged.emit(it) };
}; };
@@ -388,7 +219,7 @@ class StateCasting {
}; };
try { try {
device.start(); device.connect();
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.w(TAG, "Failed to connect to device."); Logger.w(TAG, "Failed to connect to device.");
device.onConnectionStateChanged.clear(); device.onConnectionStateChanged.clear();
@@ -399,52 +230,24 @@ class StateCasting {
return; return;
} }
activeDevice = device; activeDevice = device
Logger.i(TAG, "Connect to device ${device.name}"); Logger.i(TAG, "Connect to device ${device.name}")
} }
fun addRememberedDevice(deviceInfo: CastingDeviceInfo): CastingDeviceInfo { fun metadataFromVideo(video: IPlatformVideoDetails): Metadata {
val device = deviceFromCastingDeviceInfo(deviceInfo); return Metadata(
return addRememberedDevice(device); title = video.name, thumbnailUrl = video.thumbnails.getHQThumbnail()
} )
fun getRememberedCastingDevices(): List<CastingDevice> {
return _storage.getDevices().map { deviceFromCastingDeviceInfo(it) }
}
fun getRememberedCastingDeviceNames(): List<String> {
return _storage.getDeviceNames()
}
fun addRememberedDevice(device: CastingDevice): CastingDeviceInfo {
val deviceInfo = device.getDeviceInfo()
return _storage.addDevice(deviceInfo)
}
fun removeRememberedDevice(device: CastingDevice) {
val name = device.name ?: return
_storage.removeDevice(name)
}
private fun invokeInMainScopeIfRequired(action: () -> Unit){
if(Looper.getMainLooper().thread != Thread.currentThread()) {
_scopeMain.launch { action(); }
return;
}
action();
}
fun cancel() {
_castId.incrementAndGet()
} }
@Throws
suspend fun castIfAvailable(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, ms: Long = -1, speed: Double?, onLoadingEstimate: ((Int) -> Unit)? = null, onLoading: ((Boolean) -> Unit)? = null): Boolean { suspend fun castIfAvailable(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, ms: Long = -1, speed: Double?, onLoadingEstimate: ((Int) -> Unit)? = null, onLoading: ((Boolean) -> Unit)? = null): Boolean {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
val ad = activeDevice ?: return@withContext false; val ad = activeDevice ?: return@withContext false;
if (ad.connectionState != CastConnectionState.CONNECTED) { if (ad.connectionState != CastConnectionState.CONNECTED) {
return@withContext false; return@withContext false;
} }
val deviceProto = ad.protocolType
val resumePosition = if (ms > 0L) (ms.toDouble() / 1000.0) else 0.0; val resumePosition = if (ms > 0L) (ms.toDouble() / 1000.0) else 0.0;
val castId = _castId.incrementAndGet() val castId = _castId.incrementAndGet()
@@ -460,7 +263,7 @@ class StateCasting {
if (sourceCount > 1) { if (sourceCount > 1) {
if (videoSource is LocalVideoSource || audioSource is LocalAudioSource || subtitleSource is LocalSubtitleSource) { if (videoSource is LocalVideoSource || audioSource is LocalAudioSource || subtitleSource is LocalSubtitleSource) {
if (ad is AirPlayCastingDevice) { if (deviceProto == CastProtocolType.AIRPLAY) {
Logger.i(TAG, "Casting as local HLS"); Logger.i(TAG, "Casting as local HLS");
castLocalHls(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition, speed); castLocalHls(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition, speed);
} else { } else {
@@ -468,16 +271,17 @@ class StateCasting {
castLocalDash(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition, speed); castLocalDash(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition, speed);
} }
} else { } else {
val isRawDash = videoSource is JSDashManifestRawSource || audioSource is JSDashManifestRawAudioSource val isRawDash =
videoSource is JSDashManifestRawSource || audioSource is JSDashManifestRawAudioSource
if (isRawDash) { if (isRawDash) {
Logger.i(TAG, "Casting as raw DASH"); Logger.i(TAG, "Casting as raw DASH");
castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, audioSource as JSDashManifestRawAudioSource?, subtitleSource, resumePosition, speed, castId, onLoadingEstimate, onLoading); castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, audioSource as JSDashManifestRawAudioSource?, subtitleSource, resumePosition, speed, castId, onLoadingEstimate, onLoading);
} else { } else {
if (ad is FCastCastingDevice) { if (deviceProto == CastProtocolType.FCAST) {
Logger.i(TAG, "Casting as DASH direct"); Logger.i(TAG, "Casting as DASH direct");
castDashDirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed); castDashDirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed);
} else if (ad is AirPlayCastingDevice) { } else if (deviceProto == CastProtocolType.AIRPLAY) {
Logger.i(TAG, "Casting as HLS indirect"); Logger.i(TAG, "Casting as HLS indirect");
castHlsIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed); castHlsIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed);
} else { } else {
@@ -495,27 +299,27 @@ class StateCasting {
val videoPath = "/video-${id}" val videoPath = "/video-${id}"
val videoUrl = if(proxyStreams) url + videoPath else videoSource.getVideoUrl(); val videoUrl = if(proxyStreams) url + videoPath else videoSource.getVideoUrl();
Logger.i(TAG, "Casting as singular video"); Logger.i(TAG, "Casting as singular video");
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), speed); ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video));
} else if (audioSource is IAudioUrlSource) { } else if (audioSource is IAudioUrlSource) {
val audioPath = "/audio-${id}" val audioPath = "/audio-${id}"
val audioUrl = if(proxyStreams) url + audioPath else audioSource.getAudioUrl(); val audioUrl = if(proxyStreams) url + audioPath else audioSource.getAudioUrl();
Logger.i(TAG, "Casting as singular audio"); Logger.i(TAG, "Casting as singular audio");
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), speed); ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video));
} else if (videoSource is IHLSManifestSource) { } else if (videoSource is IHLSManifestSource) {
if (proxyStreams || ad is ChromecastCastingDevice) { if (proxyStreams || deviceProto == CastProtocolType.CHROMECAST) {
Logger.i(TAG, "Casting as proxied HLS"); Logger.i(TAG, "Casting as proxied HLS");
castProxiedHls(video, videoSource.url, videoSource.codec, resumePosition, speed); castProxiedHls(video, videoSource.url, videoSource.codec, resumePosition, speed);
} else { } else {
Logger.i(TAG, "Casting as non-proxied HLS"); Logger.i(TAG, "Casting as non-proxied HLS");
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble(), speed); ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video));
} }
} else if (audioSource is IHLSManifestAudioSource) { } else if (audioSource is IHLSManifestAudioSource) {
if (proxyStreams || ad is ChromecastCastingDevice) { if (proxyStreams || deviceProto == CastProtocolType.CHROMECAST) {
Logger.i(TAG, "Casting as proxied audio HLS"); Logger.i(TAG, "Casting as proxied audio HLS");
castProxiedHls(video, audioSource.url, audioSource.codec, resumePosition, speed); castProxiedHls(video, audioSource.url, audioSource.codec, resumePosition, speed);
} else { } else {
Logger.i(TAG, "Casting as non-proxied audio HLS"); Logger.i(TAG, "Casting as non-proxied audio HLS");
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble(), speed); ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video));
} }
} else if (videoSource is LocalVideoSource) { } else if (videoSource is LocalVideoSource) {
Logger.i(TAG, "Casting as local video"); Logger.i(TAG, "Casting as local video");
@@ -545,28 +349,69 @@ class StateCasting {
fun resumeVideo(): Boolean { fun resumeVideo(): Boolean {
val ad = activeDevice ?: return false; val ad = activeDevice ?: return false;
ad.resumeVideo(); try {
ad.resumePlayback();
} catch (e: Throwable) {
Logger.e(TAG, "Failed to resume playback: $e")
return false
}
return true; return true;
} }
fun pauseVideo(): Boolean { fun pauseVideo(): Boolean {
val ad = activeDevice ?: return false; val ad = activeDevice ?: return false;
ad.pauseVideo(); try {
ad.pausePlayback();
} catch (e: Throwable) {
Logger.e(TAG, "Failed to pause playback: $e")
return false
}
return true; return true;
} }
fun stopVideo(): Boolean { fun stopVideo(): Boolean {
val ad = activeDevice ?: return false; val ad = activeDevice ?: return false;
ad.stopVideo(); try {
ad.stopPlayback();
} catch (e: Throwable) {
Logger.e(TAG, "Failed to stop playback: $e")
return false
}
return true; return true;
} }
fun videoSeekTo(timeSeconds: Double): Boolean { fun videoSeekTo(timeSeconds: Double): Boolean {
val ad = activeDevice ?: return false; val ad = activeDevice ?: return false;
ad.seekVideo(timeSeconds); try {
ad.seekTo(timeSeconds);
} catch (e: Throwable) {
Logger.e(TAG, "Failed to seek: $e")
return false
}
return true; return true;
} }
fun changeVolume(volume: Double): Boolean {
val ad = activeDevice ?: return false;
try {
ad.changeVolume(volume);
} catch (e: Throwable) {
Logger.e(TAG, "Failed to change volume: $e")
return false
}
return true;
}
fun changeSpeed(speed: Double): Boolean {
val ad = activeDevice ?: return false;
try {
ad.changeSpeed(speed);
} catch (e: Throwable) {
Logger.e(TAG, "Failed to change speed: $e")
return false
}
return true;
}
private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double, speed: Double?) : List<String> { private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double, speed: Double?) : List<String> {
val ad = activeDevice ?: return listOf(); val ad = activeDevice ?: return listOf();
@@ -581,7 +426,7 @@ class StateCasting {
).withTag("cast"); ).withTag("cast");
Logger.i(TAG, "Casting local video (videoUrl: $videoUrl)."); Logger.i(TAG, "Casting local video (videoUrl: $videoUrl).");
ad.loadVideo("BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), speed); ad.loadVideo("BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video));
return listOf(videoUrl); return listOf(videoUrl);
} }
@@ -600,7 +445,7 @@ class StateCasting {
).withTag("cast"); ).withTag("cast");
Logger.i(TAG, "Casting local audio (audioUrl: $audioUrl)."); Logger.i(TAG, "Casting local audio (audioUrl: $audioUrl).");
ad.loadVideo("BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), speed); ad.loadVideo("BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video));
return listOf(audioUrl); return listOf(audioUrl);
} }
@@ -696,7 +541,7 @@ class StateCasting {
).withTag("castLocalHls") ).withTag("castLocalHls")
Logger.i(TAG, "added new castLocalHls handlers (hlsPath: $hlsPath, videoPath: $videoPath, audioPath: $audioPath, subtitlePath: $subtitlePath).") Logger.i(TAG, "added new castLocalHls handlers (hlsPath: $hlsPath, videoPath: $videoPath, audioPath: $audioPath, subtitlePath: $subtitlePath).")
ad.loadVideo("BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble(), speed) ad.loadVideo("BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video))
return listOf(hlsUrl, videoUrl, audioUrl, subtitleUrl) return listOf(hlsUrl, videoUrl, audioUrl, subtitleUrl)
} }
@@ -745,7 +590,7 @@ class StateCasting {
} }
Logger.i(TAG, "added new castLocalDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath, subtitlePath: $subtitlePath)."); Logger.i(TAG, "added new castLocalDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath, subtitlePath: $subtitlePath).");
ad.loadVideo("BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble(), speed); ad.loadVideo("BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video));
return listOf(dashUrl, videoUrl, audioUrl, subtitleUrl); return listOf(dashUrl, videoUrl, audioUrl, subtitleUrl);
} }
@@ -810,12 +655,18 @@ class StateCasting {
Logger.i(TAG, "Direct dash cast to casting device (videoUrl: $videoUrl, audioUrl: $audioUrl)."); Logger.i(TAG, "Direct dash cast to casting device (videoUrl: $videoUrl, audioUrl: $audioUrl).");
Logger.v(TAG) { "Dash manifest: $content" }; Logger.v(TAG) { "Dash manifest: $content" };
ad.loadContent("application/dash+xml", content, resumePosition, video.duration.toDouble(), speed); ad.loadContent("application/dash+xml", content, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video));
return listOf(videoUrl ?: "", audioUrl ?: "", subtitlesUrl ?: "", videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString()); return listOf(videoUrl ?: "", audioUrl ?: "", subtitlesUrl ?: "", videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString());
} }
private fun castProxiedHls(video: IPlatformVideoDetails, sourceUrl: String, codec: String?, resumePosition: Double, speed: Double?): List<String> { private fun castProxiedHls(
video: IPlatformVideoDetails,
sourceUrl: String,
codec: String?,
resumePosition: Double,
speed: Double?
): List<String> {
_castServer.removeAllHandlers("castProxiedHlsMaster") _castServer.removeAllHandlers("castProxiedHlsMaster")
val ad = activeDevice ?: return listOf(); val ad = activeDevice ?: return listOf();
@@ -826,7 +677,10 @@ class StateCasting {
val hlsUrl = url + hlsPath val hlsUrl = url + hlsPath
Logger.i(TAG, "HLS url: $hlsUrl"); Logger.i(TAG, "HLS url: $hlsUrl");
_castServer.addHandlerWithAllowAllOptions(HttpFunctionHandler("GET", hlsPath) { masterContext -> _castServer.addHandlerWithAllowAllOptions(
HttpFunctionHandler(
"GET", hlsPath
) { masterContext ->
_castServer.removeAllHandlers("castProxiedHlsVariant") _castServer.removeAllHandlers("castProxiedHlsVariant")
val headers = masterContext.headers.clone() val headers = masterContext.headers.clone()
@@ -849,8 +703,10 @@ class StateCasting {
val vpHeaders = masterContext.headers.clone() val vpHeaders = masterContext.headers.clone()
vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl"; vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl";
val variantPlaylist = HLS.parseVariantPlaylist(masterPlaylistContent, sourceUrl) val variantPlaylist =
val proxiedVariantPlaylist = proxyVariantPlaylist(url, id, variantPlaylist, video.isLive) HLS.parseVariantPlaylist(masterPlaylistContent, sourceUrl)
val proxiedVariantPlaylist =
proxyVariantPlaylist(url, id, variantPlaylist, video.isLive)
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8() val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
masterContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8); masterContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
return@HttpFunctionHandler return@HttpFunctionHandler
@@ -863,14 +719,22 @@ class StateCasting {
val newVariantPlaylistRefs = arrayListOf<HLS.VariantPlaylistReference>() val newVariantPlaylistRefs = arrayListOf<HLS.VariantPlaylistReference>()
val newMediaRenditions = arrayListOf<HLS.MediaRendition>() val newMediaRenditions = arrayListOf<HLS.MediaRendition>()
val newMasterPlaylist = HLS.MasterPlaylist(newVariantPlaylistRefs, newMediaRenditions, masterPlaylist.sessionDataList, masterPlaylist.independentSegments) val newMasterPlaylist = HLS.MasterPlaylist(
newVariantPlaylistRefs,
newMediaRenditions,
masterPlaylist.sessionDataList,
masterPlaylist.independentSegments
)
for (variantPlaylistRef in masterPlaylist.variantPlaylistsRefs) { for (variantPlaylistRef in masterPlaylist.variantPlaylistsRefs) {
val playlistId = UUID.randomUUID(); val playlistId = UUID.randomUUID();
val newPlaylistPath = "/hls-playlist-${playlistId}" val newPlaylistPath = "/hls-playlist-${playlistId}"
val newPlaylistUrl = url + newPlaylistPath; val newPlaylistUrl = url + newPlaylistPath;
_castServer.addHandlerWithAllowAllOptions(HttpFunctionHandler("GET", newPlaylistPath) { vpContext -> _castServer.addHandlerWithAllowAllOptions(
HttpFunctionHandler(
"GET", newPlaylistPath
) { vpContext ->
val vpHeaders = vpContext.headers.clone() val vpHeaders = vpContext.headers.clone()
vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl"; vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl";
@@ -880,16 +744,20 @@ class StateCasting {
val vpContent = response.body?.string() val vpContent = response.body?.string()
?: throw Exception("Variant playlist content is empty") ?: throw Exception("Variant playlist content is empty")
val variantPlaylist = HLS.parseVariantPlaylist(vpContent, variantPlaylistRef.url) val variantPlaylist =
val proxiedVariantPlaylist = proxyVariantPlaylist(url, playlistId, variantPlaylist, video.isLive) HLS.parseVariantPlaylist(vpContent, variantPlaylistRef.url)
val proxiedVariantPlaylist =
proxyVariantPlaylist(url, playlistId, variantPlaylist, video.isLive)
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8() val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8); vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
}.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castProxiedHlsVariant") }.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castProxiedHlsVariant")
newVariantPlaylistRefs.add(HLS.VariantPlaylistReference( newVariantPlaylistRefs.add(
newPlaylistUrl, HLS.VariantPlaylistReference(
variantPlaylistRef.streamInfo newPlaylistUrl, variantPlaylistRef.streamInfo
)) )
)
} }
for (mediaRendition in masterPlaylist.mediaRenditions) { for (mediaRendition in masterPlaylist.mediaRenditions) {
@@ -900,7 +768,10 @@ class StateCasting {
val newPlaylistPath = "/hls-playlist-${playlistId}" val newPlaylistPath = "/hls-playlist-${playlistId}"
newPlaylistUrl = url + newPlaylistPath newPlaylistUrl = url + newPlaylistPath
_castServer.addHandlerWithAllowAllOptions(HttpFunctionHandler("GET", newPlaylistPath) { vpContext -> _castServer.addHandlerWithAllowAllOptions(
HttpFunctionHandler(
"GET", newPlaylistPath
) { vpContext ->
val vpHeaders = vpContext.headers.clone() val vpHeaders = vpContext.headers.clone()
vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl"; vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl";
@@ -910,11 +781,15 @@ class StateCasting {
val vpContent = response.body?.string() val vpContent = response.body?.string()
?: throw Exception("Variant playlist content is empty") ?: throw Exception("Variant playlist content is empty")
val variantPlaylist = HLS.parseVariantPlaylist(vpContent, mediaRendition.uri) val variantPlaylist =
val proxiedVariantPlaylist = proxyVariantPlaylist(url, playlistId, variantPlaylist, video.isLive) HLS.parseVariantPlaylist(vpContent, mediaRendition.uri)
val proxiedVariantPlaylist = proxyVariantPlaylist(
url, playlistId, variantPlaylist, video.isLive
)
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8() val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8); vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
}.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castProxiedHlsVariant") }.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castProxiedHlsVariant")
} }
newMediaRenditions.add(HLS.MediaRendition( newMediaRenditions.add(HLS.MediaRendition(
@@ -930,13 +805,23 @@ class StateCasting {
} }
masterContext.respondCode(200, headers, newMasterPlaylist.buildM3U8()); masterContext.respondCode(200, headers, newMasterPlaylist.buildM3U8());
}.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castProxiedHlsMaster") }.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castProxiedHlsMaster")
Logger.i(TAG, "added new castHlsIndirect handlers (hlsPath: $hlsPath)."); Logger.i(TAG, "added new castHlsIndirect handlers (hlsPath: $hlsPath).");
//ChromeCast is sometimes funky with resume position 0 //ChromeCast is sometimes funky with resume position 0
val hackfixResumePosition = if (ad is ChromecastCastingDevice && !video.isLive && resumePosition == 0.0) 0.1 else resumePosition; val hackfixResumePosition =
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, hackfixResumePosition, video.duration.toDouble(), speed); if (ad.protocolType == CastProtocolType.CHROMECAST && !video.isLive && resumePosition == 0.0) 0.1 else resumePosition;
ad.loadVideo(
if (video.isLive) "LIVE" else "BUFFERED",
"application/vnd.apple.mpegurl",
hlsUrl,
hackfixResumePosition,
video.duration.toDouble(),
speed,
metadataFromVideo(video)
);
return listOf(hlsUrl); return listOf(hlsUrl);
} }
@@ -1110,14 +995,14 @@ class StateCasting {
).withTag("castHlsIndirectMaster") ).withTag("castHlsIndirectMaster")
Logger.i(TAG, "added new castHls handlers (hlsPath: $hlsPath)."); Logger.i(TAG, "added new castHls handlers (hlsPath: $hlsPath).");
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble(), speed); ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video));
return listOf(hlsUrl, videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString()); return listOf(hlsUrl, videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString());
} }
private fun shouldProxyStreams(castingDevice: CastingDevice, videoSource: IVideoSource?, audioSource: IAudioSource?): Boolean { private fun shouldProxyStreams(castingDevice: CastingDevice, videoSource: IVideoSource?, audioSource: IAudioSource?): Boolean {
val hasRequestModifier = (videoSource as? JSSource)?.hasRequestModifier == true || (audioSource as? JSSource)?.hasRequestModifier == true val hasRequestModifier = (videoSource as? JSSource)?.hasRequestModifier == true || (audioSource as? JSSource)?.hasRequestModifier == true
return Settings.instance.casting.alwaysProxyRequests || castingDevice !is FCastCastingDevice || hasRequestModifier return Settings.instance.casting.alwaysProxyRequests || castingDevice.protocolType != CastProtocolType.FCAST || hasRequestModifier
} }
private suspend fun castDashIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List<String> { private suspend fun castDashIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List<String> {
@@ -1193,12 +1078,12 @@ class StateCasting {
} }
Logger.i(TAG, "added new castDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath)."); Logger.i(TAG, "added new castDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath).");
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble(), speed); ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video));
return listOf(dashUrl, videoUrl ?: "", audioUrl ?: "", subtitlesUrl ?: "", videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString()); return listOf(dashUrl, videoUrl ?: "", audioUrl ?: "", subtitlesUrl ?: "", videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString());
} }
private fun cleanExecutors() { fun cleanExecutors() {
if (_videoExecutor != null) { if (_videoExecutor != null) {
_videoExecutor?.cleanup() _videoExecutor?.cleanup()
_videoExecutor = null _videoExecutor = null
@@ -1397,114 +1282,32 @@ class StateCasting {
} }
Logger.i(TAG, "added new castDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath)."); Logger.i(TAG, "added new castDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath).");
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble(), speed); ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video));
return listOf() return listOf()
} }
private fun deviceFromCastingDeviceInfo(deviceInfo: CastingDeviceInfo): CastingDevice { fun addRememberedDevice(deviceInfo: CastingDeviceInfo): CastingDeviceInfo {
return when (deviceInfo.type) { val device = deviceFromInfo(deviceInfo);
CastProtocolType.CHROMECAST -> { return addRememberedDevice(device);
ChromecastCastingDevice(deviceInfo);
}
CastProtocolType.AIRPLAY -> {
AirPlayCastingDevice(deviceInfo);
}
CastProtocolType.FCAST -> {
FCastCastingDevice(deviceInfo);
}
}
} }
private fun addOrUpdateChromeCastDevice(name: String, addresses: Array<InetAddress>, port: Int) { fun addRememberedDevice(device: CastingDevice): CastingDeviceInfo {
return addOrUpdateCastDevice<ChromecastCastingDevice>(name, val deviceInfo = device.getDeviceInfo()
deviceFactory = { ChromecastCastingDevice(name, addresses, port) }, return _storage.addDevice(deviceInfo)
deviceUpdater = { d ->
if (d.isReady) {
return@addOrUpdateCastDevice false;
} }
val changed = addresses.contentEquals(d.addresses) || d.name != name || d.port != port; fun getRememberedCastingDevices(): List<CastingDevice> {
if (changed) { return _storage.getDevices().map { deviceFromInfo(it) }
d.name = name;
d.addresses = addresses;
d.port = port;
} }
return@addOrUpdateCastDevice changed; fun getRememberedCastingDeviceNames(): List<String> {
} return _storage.getDeviceNames()
);
} }
private fun addOrUpdateAirPlayDevice(name: String, addresses: Array<InetAddress>, port: Int) { fun removeRememberedDevice(device: CastingDevice) {
return addOrUpdateCastDevice<AirPlayCastingDevice>(name, val name = device.name ?: return
deviceFactory = { AirPlayCastingDevice(name, addresses, port) }, _storage.removeDevice(name)
deviceUpdater = { d ->
if (d.isReady) {
return@addOrUpdateCastDevice false;
}
val changed = addresses.contentEquals(addresses) || d.name != name || d.port != port;
if (changed) {
d.name = name;
d.port = port;
d.addresses = addresses;
}
return@addOrUpdateCastDevice changed;
}
);
}
private fun addOrUpdateFastCastDevice(name: String, addresses: Array<InetAddress>, port: Int) {
return addOrUpdateCastDevice<FCastCastingDevice>(name,
deviceFactory = { FCastCastingDevice(name, addresses, port) },
deviceUpdater = { d ->
if (d.isReady) {
return@addOrUpdateCastDevice false;
}
val changed = addresses.contentEquals(addresses) || d.name != name || d.port != port;
if (changed) {
d.name = name;
d.port = port;
d.addresses = addresses;
}
return@addOrUpdateCastDevice changed;
}
);
}
private inline fun <reified TCastDevice> addOrUpdateCastDevice(name: String, deviceFactory: () -> TCastDevice, deviceUpdater: (device: TCastDevice) -> Boolean) where TCastDevice : CastingDevice {
var invokeEvents: (() -> Unit)? = null;
synchronized(devices) {
val device = devices[name];
if (device != null) {
if (device !is TCastDevice) {
Logger.w(TAG, "Device name conflict between device types. Ignoring device.");
} else {
val changed = deviceUpdater(device as TCastDevice);
if (changed) {
invokeEvents = {
onDeviceChanged.emit(device);
}
} else {
}
}
} else {
val newDevice = deviceFactory();
this.devices[name] = newDevice;
invokeEvents = {
onDeviceAdded.emit(newDevice);
};
}
}
invokeEvents?.let { _scopeMain.launch { it(); }; };
} }
fun enableDeveloper(enableDev: Boolean) { fun enableDeveloper(enableDev: Boolean) {
@@ -1513,47 +1316,27 @@ class StateCasting {
_castServer.addHandler(HttpFunctionHandler("GET", "/dashPlayer") { context -> _castServer.addHandler(HttpFunctionHandler("GET", "/dashPlayer") { context ->
if (context.query.containsKey("dashUrl")) { if (context.query.containsKey("dashUrl")) {
val dashUrl = context.query["dashUrl"]; val dashUrl = context.query["dashUrl"];
val html = "<div>\n" + val html =
" <video id=\"test\" width=\"1280\" height=\"720\" controls>\n" + "<div>\n" + " <video id=\"test\" width=\"1280\" height=\"720\" controls>\n" + " </video>\n" + " \n" + " \n" + " <script src=\"https://cdn.dashjs.org/latest/dash.all.min.js\"></script>\n" + " <script>\n" + " <!--setup the video element and attach it to the Dash player-->\n" + " (function(){\n" + " var url = \"${dashUrl}\";\n" + " var player = dashjs.MediaPlayer().create();\n" + " player.initialize(document.querySelector(\"#test\"), url, true);\n" + " })();\n" + " </script>\n" + "</div>";
" </video>\n" +
" \n" +
" \n" +
" <script src=\"https://cdn.dashjs.org/latest/dash.all.min.js\"></script>\n" +
" <script>\n" +
" <!--setup the video element and attach it to the Dash player-->\n" +
" (function(){\n" +
" var url = \"${dashUrl}\";\n" +
" var player = dashjs.MediaPlayer().create();\n" +
" player.initialize(document.querySelector(\"#test\"), url, true);\n" +
" })();\n" +
" </script>\n" +
"</div>";
context.respondCode(200, html, "text/html"); context.respondCode(200, html, "text/html");
} }
}).withTag("dev"); }).withTag("dev");
} }
} }
@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 { companion object {
val instance: StateCasting = StateCasting(); var instance: StateCasting = if (Settings.instance.casting.experimentalCasting) {
StateCastingExp()
private val representationRegex = Regex("<Representation .*?mimeType=\"(.*?)\".*?>(.*?)<\\/Representation>", RegexOption.DOT_MATCHES_ALL) } else {
private val mediaInitializationRegex = Regex("(media|initiali[sz]ation)=\"([^\"]+)\"", RegexOption.DOT_MATCHES_ALL); StateCastingLegacy()
}
private val representationRegex = Regex(
"<Representation .*?mimeType=\"(.*?)\".*?>(.*?)<\\/Representation>",
RegexOption.DOT_MATCHES_ALL
)
private val mediaInitializationRegex =
Regex("(media|initiali[sz]ation)=\"([^\"]+)\"", RegexOption.DOT_MATCHES_ALL);
private val TAG = "StateCasting"; private val TAG = "StateCasting";
} }
} }
@@ -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"
}
}
@@ -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,7 +109,11 @@ 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();
}; };
@@ -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
@@ -108,16 +106,17 @@ 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 ->
val index = _unifiedDevices.indexOfFirst { it.castingDevice.name == d.name } val index = _unifiedDevices.indexOfFirst { it.castingDevice.name == d.name }
@@ -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) {
CastProtocolType.CHROMECAST -> {
_imageDevice.setImageResource(R.drawable.ic_chromecast); _imageDevice.setImageResource(R.drawable.ic_chromecast);
_textType.text = "Chromecast"; _textType.text = "Chromecast";
} else if (d is AirPlayCastingDevice) { }
CastProtocolType.AIRPLAY -> {
_imageDevice.setImageResource(R.drawable.ic_airplay); _imageDevice.setImageResource(R.drawable.ic_airplay);
_textType.text = "AirPlay"; _textType.text = "AirPlay";
} else if (d is FCastCastingDevice) { }
_imageDevice.setImageResource(R.drawable.ic_fc); CastProtocolType.FCAST -> {
_textType.text = "FastCast"; _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)
} }
} }
@@ -719,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) {
@@ -744,7 +744,7 @@ class VideoDownload {
indexCounter++; indexCounter++;
} }
sourceLength = written.toLong(); sourceLength = written;
Logger.i(TAG, "$name downloadSource Finished"); Logger.i(TAG, "$name downloadSource Finished");
} }
@@ -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();
} }
/* /*
@@ -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;
@@ -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())
@@ -1,46 +1,27 @@
package com.futo.platformplayer.fragment.mainactivity.main package com.futo.platformplayer.fragment.mainactivity.main
import android.app.Dialog
import android.content.Context import android.content.Context
import android.content.DialogInterface
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap
import android.graphics.drawable.Animatable import android.graphics.drawable.Animatable
import android.graphics.drawable.Drawable
import android.os.Bundle
import android.text.Spanned
import android.util.AttributeSet import android.util.AttributeSet
import android.util.TypedValue
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.SoundEffectConstants
import android.view.View
import android.view.animation.AccelerateInterpolator import android.view.animation.AccelerateInterpolator
import android.view.animation.OvershootInterpolator import android.view.animation.OvershootInterpolator
import android.widget.Button
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.ImageView import android.widget.ImageView
import android.widget.LinearLayout 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.graphics.drawable.toDrawable
import androidx.core.net.toUri
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.Format import androidx.media3.common.Format
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import com.bumptech.glide.Glide
import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition
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
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.exceptions.ContentNotAvailableYetException import com.futo.platformplayer.api.media.exceptions.ContentNotAvailableYetException
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
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.RatingLikeDislikes
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
@@ -54,40 +35,30 @@ import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.casting.CastConnectionState import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource
import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource
import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event3 import com.futo.platformplayer.constructs.Event3
import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.downloads.VideoLocal import com.futo.platformplayer.downloads.VideoLocal
import com.futo.platformplayer.dp
import com.futo.platformplayer.engine.exceptions.ScriptAgeException import com.futo.platformplayer.engine.exceptions.ScriptAgeException
import com.futo.platformplayer.engine.exceptions.ScriptException import com.futo.platformplayer.engine.exceptions.ScriptException
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
import com.futo.platformplayer.engine.exceptions.ScriptLoginRequiredException import com.futo.platformplayer.engine.exceptions.ScriptLoginRequiredException
import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException
import com.futo.platformplayer.exceptions.UnsupportedCastException import com.futo.platformplayer.exceptions.UnsupportedCastException
import com.futo.platformplayer.fixHtmlLinks import com.futo.platformplayer.fragment.mainactivity.special.CommentsModalBottomSheet
import com.futo.platformplayer.getNowDiffSeconds
import com.futo.platformplayer.helpers.VideoHelper import com.futo.platformplayer.helpers.VideoHelper
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.selectBestImage
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateMeta
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.states.StatePolycentric import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.toHumanBitrate import com.futo.platformplayer.toHumanBitrate
import com.futo.platformplayer.toHumanBytesSize import com.futo.platformplayer.toHumanBytesSize
import com.futo.platformplayer.toHumanNowDiffString import com.futo.platformplayer.views.buttons.ShortsButton
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.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.overlays.slideup.SlideUpMenuButtonList import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuButtonList
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuGroup import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuGroup
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
@@ -95,20 +66,17 @@ import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuTitle import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuTitle
import com.futo.platformplayer.views.pills.OnLikeDislikeUpdatedArgs import com.futo.platformplayer.views.pills.OnLikeDislikeUpdatedArgs
import com.futo.platformplayer.views.platform.PlatformIndicator import com.futo.platformplayer.views.platform.PlatformIndicator
import com.futo.platformplayer.views.segments.CommentsList
import com.futo.platformplayer.views.video.FutoShortPlayer import com.futo.platformplayer.views.video.FutoShortPlayer
import com.futo.platformplayer.views.video.FutoVideoPlayerBase 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.ApiMethods
import com.futo.polycentric.core.ContentType import com.futo.polycentric.core.ContentType
import com.futo.polycentric.core.Models import com.futo.polycentric.core.Models
import com.futo.polycentric.core.Opinion import com.futo.polycentric.core.Opinion
import com.futo.polycentric.core.PolycentricProfile
import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions
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
import com.google.android.material.button.MaterialButton import com.google.android.material.button.MaterialButton
//import com.google.android.material.button.MaterialButton
import com.google.protobuf.ByteString import com.google.protobuf.ByteString
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -116,30 +84,29 @@ import userpackage.Protocol
@UnstableApi @UnstableApi
class ShortView : FrameLayout { class ShortView : FrameLayout {
private lateinit var mainFragment: MainFragment private lateinit var fragment: MainFragment
private val player: FutoShortPlayer private val player: FutoShortPlayer
private val channelInfo: LinearLayout private val channelInfo: LinearLayout
private val creatorThumbnail: CreatorThumbnail private val creatorThumbnail: CreatorThumbnail
private val channelName: TextView private val channelName: TextView
private val videoTitle: TextView private val videoTitle: TextView
private val videoSubtitle: TextView
private val platformIndicator: PlatformIndicator private val platformIndicator: PlatformIndicator
//TODO: Replace with non-material button
private val backButton: MaterialButton private val backButton: MaterialButton
private val backButtonContainer: ConstraintLayout private val backButtonContainer: ConstraintLayout
private val likeContainer: FrameLayout private val likeButton: ShortsButton
private val dislikeContainer: FrameLayout //private val likeCount: TextView
private val likeButton: MaterialButton private val dislikeButton: ShortsButton
private val likeCount: TextView //private val dislikeCount: TextView
private val dislikeButton: MaterialButton
private val dislikeCount: TextView
private val commentsButton: MaterialButton private val commentsButton: ShortsButton
private val shareButton: MaterialButton private val shareButton: ShortsButton
private val refreshButton: MaterialButton private val refreshButton: ShortsButton
private val refreshButtonContainer: View private val qualityButton: ShortsButton
private val qualityButton: MaterialButton
private val playPauseOverlay: FrameLayout private val playPauseOverlay: FrameLayout
private val playPauseIcon: ImageView private val playPauseIcon: ImageView
@@ -173,18 +140,21 @@ class ShortView : FrameLayout {
private val onLikeDislikeUpdated = Event1<OnLikeDislikeUpdatedArgs>() private val onLikeDislikeUpdated = Event1<OnLikeDislikeUpdatedArgs>()
private val onVideoUpdated = Event1<IPlatformVideo?>() private val onVideoUpdated = Event1<IPlatformVideo?>()
//TODO: Replace with non-material UI? Only true dependency on Material left
private val bottomSheet: CommentsModalBottomSheet = CommentsModalBottomSheet() private val bottomSheet: CommentsModalBottomSheet = CommentsModalBottomSheet()
var likes: Long = 0 var likes: Long = 0
set(value) { set(value) {
field = value field = value
likeCount.text = value.toString() likeButton.withPrimaryText(value.toString());
//likeCount.text = value.toString()
} }
var dislikes: Long = 0 var dislikes: Long = 0
set(value) { set(value) {
field = value field = value
dislikeCount.text = value.toString() dislikeButton.withPrimaryText(value.toString());
//dislikeCount.text = value.toString()
} }
constructor(inflater: LayoutInflater, fragment: MainFragment, overlayQualityContainer: FrameLayout) : this(inflater.context) { constructor(inflater: LayoutInflater, fragment: MainFragment, overlayQualityContainer: FrameLayout) : this(inflater.context) {
@@ -194,7 +164,7 @@ class ShortView : FrameLayout {
LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT
) )
this.mainFragment = fragment this.fragment = fragment
bottomSheet.mainFragment = fragment bottomSheet.mainFragment = fragment
} }
@@ -217,19 +187,17 @@ class ShortView : FrameLayout {
creatorThumbnail = findViewById(R.id.creator_thumbnail) creatorThumbnail = findViewById(R.id.creator_thumbnail)
channelName = findViewById(R.id.channel_name) channelName = findViewById(R.id.channel_name)
videoTitle = findViewById(R.id.video_title) videoTitle = findViewById(R.id.video_title)
videoSubtitle = findViewById(R.id.video_subtitle)
platformIndicator = findViewById(R.id.short_platform_indicator) platformIndicator = findViewById(R.id.short_platform_indicator)
backButton = findViewById(R.id.back_button) backButton = findViewById(R.id.back_button)
backButtonContainer = findViewById(R.id.back_button_container) backButtonContainer = findViewById(R.id.back_button_container)
likeContainer = findViewById(R.id.like_container)
dislikeContainer = findViewById(R.id.dislike_container)
likeButton = findViewById(R.id.like_button) likeButton = findViewById(R.id.like_button)
likeCount = findViewById(R.id.like_count) //likeCount = findViewById(R.id.like_count)
dislikeButton = findViewById(R.id.dislike_button) dislikeButton = findViewById(R.id.dislike_button)
dislikeCount = findViewById(R.id.dislike_count) //dislikeCount = findViewById(R.id.dislike_count)
commentsButton = findViewById(R.id.comments_button) commentsButton = findViewById(R.id.comments_button)
shareButton = findViewById(R.id.share_button) shareButton = findViewById(R.id.share_button)
refreshButton = findViewById(R.id.refresh_button) refreshButton = findViewById(R.id.refresh_button)
refreshButtonContainer = findViewById(R.id.refresh_button_container)
qualityButton = findViewById(R.id.quality_button) qualityButton = findViewById(R.id.quality_button)
playPauseOverlay = findViewById(R.id.play_pause_overlay) playPauseOverlay = findViewById(R.id.play_pause_overlay)
playPauseIcon = findViewById(R.id.play_pause_icon) playPauseIcon = findViewById(R.id.play_pause_icon)
@@ -258,48 +226,44 @@ class ShortView : FrameLayout {
} }
onVideoUpdated.subscribe { onVideoUpdated.subscribe {
Logger.i(TAG, "Shorts videoUpdated [${it?.name}] (isDetail: ${it is IPlatformVideoDetails}, thumbnail: ${it?.author?.thumbnail})");
videoTitle.text = it?.name videoTitle.text = it?.name
videoSubtitle.text = if(it is IPlatformVideoDetails) it?.description; else "";
platformIndicator.setPlatformFromClientID(it?.id?.pluginId) platformIndicator.setPlatformFromClientID(it?.id?.pluginId)
creatorThumbnail.setThumbnail(it?.author?.thumbnail, true) creatorThumbnail.setThumbnail(it?.author?.thumbnail, true)
channelName.text = it?.author?.name channelName.text = it?.author?.name
} }
backButton.setOnClickListener { backButton.setOnClickListener {
playSoundEffect(SoundEffectConstants.CLICK) fragment.closeSegment()
mainFragment.closeSegment()
} }
channelInfo.setOnClickListener { channelInfo.setOnClickListener {
playSoundEffect(SoundEffectConstants.CLICK) fragment.navigate<ChannelFragment>(video?.author)
mainFragment.navigate<ChannelFragment>(video?.author)
} }
videoTitle.setOnClickListener { videoTitle.setOnClickListener {
playSoundEffect(SoundEffectConstants.CLICK)
if (!bottomSheet.isAdded) { if (!bottomSheet.isAdded) {
bottomSheet.show(mainFragment.childFragmentManager, CommentsModalBottomSheet.TAG) bottomSheet.show(fragment.childFragmentManager, CommentsModalBottomSheet.TAG)
} }
} }
commentsButton.setOnClickListener { commentsButton.onClick.subscribe {
playSoundEffect(SoundEffectConstants.CLICK)
if (!bottomSheet.isAdded) { if (!bottomSheet.isAdded) {
bottomSheet.show(mainFragment.childFragmentManager, CommentsModalBottomSheet.TAG) bottomSheet.show(fragment.childFragmentManager, CommentsModalBottomSheet.TAG)
} }
} }
shareButton.setOnClickListener { shareButton.onClick.subscribe {
playSoundEffect(SoundEffectConstants.CLICK)
val url = video?.shareUrl ?: video?.url val url = video?.shareUrl ?: video?.url
mainFragment.startActivity(Intent.createChooser(Intent().apply { fragment.startActivity(Intent.createChooser(Intent().apply {
action = Intent.ACTION_SEND action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, url) putExtra(Intent.EXTRA_TEXT, url)
type = "text/plain" type = "text/plain"
}, null)) }, null))
} }
refreshButton.setOnClickListener { refreshButton.onClick.subscribe {
playSoundEffect(SoundEffectConstants.CLICK)
onResetTriggered.emit() onResetTriggered.emit()
} }
@@ -308,14 +272,12 @@ class ShortView : FrameLayout {
false false
} }
qualityButton.setOnClickListener { qualityButton.onClick.subscribe {
playSoundEffect(SoundEffectConstants.CLICK)
showVideoSettings() showVideoSettings()
} }
likeButton.setOnClickListener { likeButton.onClick.subscribe {
playSoundEffect(SoundEffectConstants.CLICK) val checked = likeButton.iconId == R.drawable.ic_thumb_up_s // !likeButton.isChecked
val checked = !likeButton.isChecked
StatePolycentric.instance.requireLogin(context, context.getString(R.string.please_login_to_like)) { StatePolycentric.instance.requireLogin(context, context.getString(R.string.please_login_to_like)) {
if (checked) { if (checked) {
likes++ likes++
@@ -323,24 +285,27 @@ class ShortView : FrameLayout {
likes-- likes--
} }
likeButton.isChecked = checked if(checked)
likeButton.withIcon(R.drawable.ic_thumb_up_s_filled) //.isChecked = checked
else
likeButton.withIcon(R.drawable.ic_thumb_up_s)
if (dislikeButton.isChecked && checked) { if (dislikeButton.iconId == R.drawable.ic_thumb_down_s_filled && checked) {
dislikeButton.isChecked = false //dislikeButton.isChecked = false
dislikeButton.withIcon(R.drawable.ic_thumb_down_s)
dislikes-- dislikes--
} }
onLikeDislikeUpdated.emit( onLikeDislikeUpdated.emit(
OnLikeDislikeUpdatedArgs( OnLikeDislikeUpdatedArgs(
it, likes, likeButton.isChecked, dislikes, dislikeButton.isChecked it, likes, checked, dislikes, !checked
) )
) )
} }
} }
dislikeButton.setOnClickListener { dislikeButton.onClick.subscribe {
playSoundEffect(SoundEffectConstants.CLICK) val checked = dislikeButton.iconId == R.drawable.ic_thumb_down_s //!dislikeButton.isChecked
val checked = !dislikeButton.isChecked
StatePolycentric.instance.requireLogin(context, context.getString(R.string.please_login_to_like)) { StatePolycentric.instance.requireLogin(context, context.getString(R.string.please_login_to_like)) {
if (checked) { if (checked) {
dislikes++ dislikes++
@@ -348,16 +313,21 @@ class ShortView : FrameLayout {
dislikes-- dislikes--
} }
dislikeButton.isChecked = checked //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.isChecked && checked) { if (likeButton.iconId == R.drawable.ic_thumb_up_s_filled && checked) {
likeButton.isChecked = false //likeButton.isChecked = false
likeButton.withIcon(R.drawable.ic_thumb_up_s);
likes-- likes--
} }
onLikeDislikeUpdated.emit( onLikeDislikeUpdated.emit(
OnLikeDislikeUpdatedArgs( OnLikeDislikeUpdatedArgs(
it, likes, likeButton.isChecked, dislikes, dislikeButton.isChecked it, likes, !checked, dislikes, checked
) )
) )
} }
@@ -366,11 +336,11 @@ class ShortView : FrameLayout {
onLikesLoaded.subscribe(tag) { rating, liked, disliked -> onLikesLoaded.subscribe(tag) { rating, liked, disliked ->
likes = rating.likes likes = rating.likes
dislikes = rating.dislikes dislikes = rating.dislikes
likeButton.isChecked = liked //likeButton.isChecked = liked
dislikeButton.isChecked = disliked //dislikeButton.isChecked = disliked
dislikeContainer.visibility = VISIBLE dislikeButton.visibility = VISIBLE
likeContainer.visibility = VISIBLE likeButton.visibility = VISIBLE
} }
player.onPlaybackStateChanged.subscribe { player.onPlaybackStateChanged.subscribe {
@@ -565,7 +535,7 @@ class ShortView : FrameLayout {
var toSet: ISubtitleSource? = subtitleSource var toSet: ISubtitleSource? = subtitleSource
if (_lastSubtitleSource == subtitleSource) toSet = null if (_lastSubtitleSource == subtitleSource) toSet = null
mainFragment.lifecycleScope.launch(Dispatchers.Main) { fragment.lifecycleScope.launch(Dispatchers.Main) {
try { try {
player.swapSubtitles(toSet) player.swapSubtitles(toSet)
} catch (e: Throwable) { } catch (e: Throwable) {
@@ -625,7 +595,7 @@ class ShortView : FrameLayout {
@Suppress("unused") @Suppress("unused")
fun setMainFragment(fragment: MainFragment, overlayQualityContainer: FrameLayout) { fun setMainFragment(fragment: MainFragment, overlayQualityContainer: FrameLayout) {
this.mainFragment = fragment this.fragment = fragment
this.bottomSheet.mainFragment = fragment this.bottomSheet.mainFragment = fragment
this.overlayQualityContainer = overlayQualityContainer this.overlayQualityContainer = overlayQualityContainer
} }
@@ -636,10 +606,10 @@ class ShortView : FrameLayout {
} }
this.video = video this.video = video
refreshButtonContainer.visibility = if (isChannelShortsMode) { refreshButton.visibility = if (isChannelShortsMode) {
GONE GONE
} else { } else {
VISIBLE GONE //TODO: Revert?
} }
backButtonContainer.visibility = if (isChannelShortsMode) { backButtonContainer.visibility = if (isChannelShortsMode) {
VISIBLE VISIBLE
@@ -695,8 +665,8 @@ class ShortView : FrameLayout {
} }
private fun loadLikes(video: IPlatformVideo) { private fun loadLikes(video: IPlatformVideo) {
likeContainer.visibility = GONE likeButton.visibility = GONE
dislikeContainer.visibility = GONE dislikeButton.visibility = GONE
loadLikesTask?.cancel() loadLikesTask?.cancel()
loadLikesTask = loadLikesTask =
@@ -735,13 +705,13 @@ class ShortView : FrameLayout {
args.processHandle.opinion(ref, Opinion.neutral) args.processHandle.opinion(ref, Opinion.neutral)
} }
mainFragment.lifecycleScope.launch(Dispatchers.IO) { fragment.lifecycleScope.launch(Dispatchers.IO) {
try { try {
Logger.i(CommentsModalBottomSheet.TAG, "Started backfill") Logger.i(TAG, "Started backfill")
args.processHandle.fullyBackfillServersAnnounceExceptions() args.processHandle.fullyBackfillServersAnnounceExceptions()
Logger.i(CommentsModalBottomSheet.TAG, "Finished backfill") Logger.i(TAG, "Finished backfill")
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.e(CommentsModalBottomSheet.TAG, "Failed to backfill servers", e) Logger.e(TAG, "Failed to backfill servers", e)
} }
} }
@@ -763,15 +733,36 @@ class ShortView : FrameLayout {
setLoading(true) setLoading(true)
Logger.i(TAG, "Shorts loadVideo [${url}]");
val timeLoadVideoStart = System.currentTimeMillis();
loadVideoTask = TaskHandler<String, IPlatformVideoDetails>( loadVideoTask = TaskHandler<String, IPlatformVideoDetails>(
StateApp.instance.scopeGetter, { StateApp.instance.scopeGetter, {
val result = StatePlatform.instance.getContentDetails(it).await() val result = StatePlatform.instance.getContentDetails(it).await()
if (result !is IPlatformVideoDetails) throw IllegalStateException("Expected media content, found ${result.contentType}") if (result !is IPlatformVideoDetails) throw IllegalStateException("Expected media content, found ${result.contentType}")
return@TaskHandler result return@TaskHandler result
}).success { result -> }).success { result ->
val timeLoadVideo = System.currentTimeMillis() - timeLoadVideoStart;
Logger.i(TAG, "Shorts loadVideo [${url}] took ${timeLoadVideo}ms");
videoDetails = result videoDetails = result
video = 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 bottomSheet.video = result
setLoading(false) setLoading(false)
@@ -799,7 +790,7 @@ class ShortView : FrameLayout {
UIDialogs.showSingleButtonDialog(context, R.drawable.ic_schedule, "Video is available in ${it.availableWhen}.", "Close") { } UIDialogs.showSingleButtonDialog(context, R.drawable.ic_schedule, "Video is available in ${it.availableWhen}.", "Close") { }
}.exception<ScriptImplementationException> { }.exception<ScriptImplementationException> {
Logger.w(TAG, "exception<ScriptImplementationException>", it) Logger.w(TAG, "exception<ScriptImplementationException>", it)
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video_scriptimplementationexception), it, { loadVideo(url) }, null, mainFragment) UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video_scriptimplementationexception), it, { loadVideo(url) }, null, fragment)
}.exception<ScriptAgeException> { }.exception<ScriptAgeException> {
Logger.w(TAG, "exception<ScriptAgeException>", it) Logger.w(TAG, "exception<ScriptAgeException>", it)
UIDialogs.showDialog( UIDialogs.showDialog(
@@ -812,10 +803,10 @@ class ShortView : FrameLayout {
) )
}.exception<ScriptException> { }.exception<ScriptException> {
Logger.w(TAG, "exception<ScriptException>", it) Logger.w(TAG, "exception<ScriptException>", it)
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video_scriptexception), it, { loadVideo(url) }, null, mainFragment) UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video_scriptexception), it, { loadVideo(url) }, null, fragment)
}.exception<Throwable> { }.exception<Throwable> {
Logger.w(ChannelFragment.TAG, "Failed to load video.", it) Logger.w(ChannelFragment.TAG, "Failed to load video.", it)
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video), it, { loadVideo(url) }, null, mainFragment) UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video), it, { loadVideo(url) }, null, fragment)
} }
loadVideoTask?.run(url) loadVideoTask?.run(url)
@@ -849,6 +840,7 @@ class ShortView : FrameLayout {
} }
val thumbnail = videoDetails.thumbnails.getHQThumbnail() val thumbnail = videoDetails.thumbnails.getHQThumbnail()
/*
if (videoSource == null && !thumbnail.isNullOrBlank()) Glide.with(context).asBitmap() if (videoSource == null && !thumbnail.isNullOrBlank()) Glide.with(context).asBitmap()
.load(thumbnail).into(object : CustomTarget<Bitmap>() { .load(thumbnail).into(object : CustomTarget<Bitmap>() {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) { override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
@@ -860,8 +852,9 @@ class ShortView : FrameLayout {
} }
}) })
else player.setArtwork(null) else player.setArtwork(null)
*/
mainFragment.lifecycleScope.launch(Dispatchers.Main) { fragment.lifecycleScope.launch(Dispatchers.Main) {
try { try {
player.setSource(videoSource, audioSource, play = true, keepSubtitles = false, resume = resumePositionMs > 0) player.setSource(videoSource, audioSource, play = true, keepSubtitles = false, resume = resumePositionMs > 0)
if (subtitleSource != null) player.swapSubtitles(subtitleSource) if (subtitleSource != null) player.swapSubtitles(subtitleSource)
@@ -887,397 +880,4 @@ class ShortView : FrameLayout {
const val TAG = "VideoDetailView" const val TAG = "VideoDetailView"
} }
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"
}
}
} }
@@ -11,6 +11,7 @@ import android.widget.FrameLayout
import android.widget.ImageView import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.lifecycle.lifecycleScope
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2 import androidx.viewpager2.widget.ViewPager2
@@ -25,6 +26,9 @@ 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
import com.futo.platformplayer.views.buttons.BigButton import com.futo.platformplayer.views.buttons.BigButton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlin.system.measureTimeMillis
@UnstableApi @UnstableApi
class ShortsFragment : MainFragment() { class ShortsFragment : MainFragment() {
@@ -35,6 +39,7 @@ class ShortsFragment : MainFragment() {
private var loadPagerTask: TaskHandler<ShortsFragment, IPager<IPlatformVideo>>? = null private var loadPagerTask: TaskHandler<ShortsFragment, IPager<IPlatformVideo>>? = null
private var nextPageTask: TaskHandler<ShortsFragment, List<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 var mainShortsPager: IPager<IPlatformVideo>? = null
private val mainShorts: MutableList<IPlatformVideo> = mutableListOf() private val mainShorts: MutableList<IPlatformVideo> = mutableListOf()
@@ -58,6 +63,7 @@ class ShortsFragment : MainFragment() {
private var customViewAdapter: CustomViewAdapter? = null private var customViewAdapter: CustomViewAdapter? = null
// we just completely reset the data structure so we want to tell the adapter that // 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") @SuppressLint("NotifyDataSetChanged")
override fun onShownWithView(parameter: Any?, isBack: Boolean) { override fun onShownWithView(parameter: Any?, isBack: Boolean) {
(activity as MainActivity?)?.getFragment<VideoDetailFragment>()?.closeVideoDetails() (activity as MainActivity?)?.getFragment<VideoDetailFragment>()?.closeVideoDetails()
@@ -118,7 +124,6 @@ class ShortsFragment : MainFragment() {
overlayQualityContainer = view.findViewById(R.id.shorts_quality_overview) overlayQualityContainer = view.findViewById(R.id.shorts_quality_overview)
sourcesButton.onClick.subscribe { sourcesButton.onClick.subscribe {
sourcesButton.playSoundEffect(SoundEffectConstants.CLICK)
navigate<SourcesFragment>() navigate<SourcesFragment>()
} }
@@ -145,7 +150,7 @@ class ShortsFragment : MainFragment() {
this.customViewAdapter = customViewAdapter this.customViewAdapter = customViewAdapter
if (loadPagerTask == null && currentShorts.isEmpty()) { if (loadPagerTask == null) {// && currentShorts.isEmpty()) {
loadPager() loadPager()
loadPagerTask!!.success { loadPagerTask!!.success {
@@ -207,28 +212,29 @@ class ShortsFragment : MainFragment() {
} }
private fun nextPage() { private fun nextPage() {
nextPageTask?.cancel() Logger.i(TAG, "ShortsFragment nextPage");
lifecycleScope.launch(Dispatchers.IO) {
val nextPageTask = try {
TaskHandler<ShortsFragment, List<IPlatformVideo>>(StateApp.instance.scopeGetter, { val time = measureTimeMillis {
currentShortsPager!!.nextPage() currentShortsPager!!.nextPage();
}
return@TaskHandler currentShortsPager!!.getResults() val newVideos = currentShortsPager!!.getResults();
}).success { newVideos ->
val prevCount = customViewAdapter!!.itemCount val prevCount = customViewAdapter!!.itemCount
Logger.i(TAG, "Shorts nextPage took ${time}ms, ${prevCount}-${prevCount + newVideos.size}, hasMore: ${currentShortsPager?.hasMorePages()}");
currentShorts.addAll(newVideos) currentShorts.addAll(newVideos)
if (isChannelShortsMode) { if (isChannelShortsMode) {
channelShorts.addAll(newVideos) channelShorts.addAll(newVideos)
} else { } else {
mainShorts.addAll(newVideos) mainShorts.addAll(newVideos)
} }
lifecycleScope.launch(Dispatchers.Main) {
customViewAdapter!!.notifyItemRangeInserted(prevCount, newVideos.size) customViewAdapter!!.notifyItemRangeInserted(prevCount, newVideos.size)
nextPageTask = null
} }
nextPageTask = null
nextPageTask.run(this) } catch (ex: Throwable) {
Logger.e(TAG, "Shorts Failed to call nextPage", ex);
this.nextPageTask = nextPageTask }
}
} }
// we just completely reset the data structure so we want to tell the adapter that // we just completely reset the data structure so we want to tell the adapter that
@@ -236,12 +242,16 @@ class ShortsFragment : MainFragment() {
private fun loadPager() { private fun loadPager() {
loadPagerTask?.cancel() loadPagerTask?.cancel()
Logger.i(TAG, "Shorts loadPage");
var loadPageStart = System.currentTimeMillis();
val loadPagerTask = val loadPagerTask =
TaskHandler<ShortsFragment, IPager<IPlatformVideo>>(StateApp.instance.scopeGetter, { TaskHandler<ShortsFragment, IPager<IPlatformVideo>>(StateApp.instance.scopeGetter, {
val pager = StatePlatform.instance.getShorts() val pager = StatePlatform.instance.getShorts();
return@TaskHandler pager return@TaskHandler pager
}).success { pager -> }).success { pager ->
val timeLoadPage = System.currentTimeMillis() - loadPageStart;
Logger.i(TAG, "Shorts loadPage took ${timeLoadPage}ms");
mainShorts.clear() mainShorts.clear()
mainShorts.addAll(pager.getResults()) mainShorts.addAll(pager.getResults())
mainShortsPager = pager mainShortsPager = pager
@@ -259,7 +269,7 @@ class ShortsFragment : MainFragment() {
loadPagerTask = null loadPagerTask = null
}.exception<Throwable> { err -> }.exception<Throwable> { err ->
val message = "Unable to load shorts $err" val message = "Unable to load shorts $err"
Logger.i(TAG, message) Logger.w(TAG, message, err)
if (context != null) { if (context != null) {
UIDialogs.showDialog( UIDialogs.showDialog(
requireContext(), R.drawable.ic_sources, message, null, null, 0, UIDialogs.Action( requireContext(), R.drawable.ic_sources, message, null, null, 0, UIDialogs.Action(
@@ -329,6 +339,7 @@ class ShortsFragment : MainFragment() {
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
override fun onBindViewHolder(holder: CustomViewHolder, position: Int) { 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()) holder.shortView.changeVideo(videos[position], isChannelShortsMode())
if (position == itemCount - 1) { if (position == itemCount - 1) {
@@ -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;
@@ -437,7 +437,7 @@ 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 === null) { if (viewDetail === null) {
return return
@@ -446,7 +446,7 @@ class VideoDetailFragment() : MainFragment() {
if (viewDetail.shouldEnterPictureInPicture) { if (viewDetail.shouldEnterPictureInPicture) {
_leavingPiP = false _leavingPiP = false
} }
if(Build.VERSION.SDK_INT < Build.VERSION_CODES.S && viewDetail.preventPictureInPicture == false && !StateCasting.instance.isCasting && Settings.instance.playback.isBackgroundPictureInPicture() && !viewDetail.allowBackground) { 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")
@@ -526,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;
@@ -10,7 +10,6 @@ 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
@@ -51,7 +50,6 @@ import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UISlideOverlays import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.SyncShowPairingCodeActivity.Companion.activity
import com.futo.platformplayer.api.media.IPluginSourced import com.futo.platformplayer.api.media.IPluginSourced
import com.futo.platformplayer.api.media.LiveChatManager import com.futo.platformplayer.api.media.LiveChatManager
import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.PlatformID
@@ -82,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
@@ -326,7 +323,7 @@ class VideoDetailView : ConstraintLayout {
val onEnterPictureInPicture = Event0(); val onEnterPictureInPicture = Event0();
val onVideoChanged = Event2<Int, Int>() val onVideoChanged = Event2<Int, Int>()
var allowBackground: Boolean = false var isAudioOnlyUserAction: Boolean = false
private set(value) { private set(value) {
if (field != value) { if (field != value) {
field = value field = value
@@ -338,7 +335,7 @@ class VideoDetailView : ConstraintLayout {
get() = !preventPictureInPicture && get() = !preventPictureInPicture &&
!StateCasting.instance.isCasting && !StateCasting.instance.isCasting &&
Settings.instance.playback.isBackgroundPictureInPicture() && Settings.instance.playback.isBackgroundPictureInPicture() &&
!allowBackground && !isAudioOnlyUserAction &&
isPlaying isPlaying
val onShouldEnterPictureInPictureChanged = Event0(); val onShouldEnterPictureInPictureChanged = Event0();
@@ -579,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());
} }
@@ -764,7 +760,7 @@ class VideoDetailView : ConstraintLayout {
MediaControlReceiver.onBackgroundReceived.subscribe(this) { MediaControlReceiver.onBackgroundReceived.subscribe(this) {
Logger.i(TAG, "MediaControlReceiver.onBackgroundReceived") Logger.i(TAG, "MediaControlReceiver.onBackgroundReceived")
_player.switchToAudioMode(video); _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) {
@@ -889,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);
@@ -1008,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(video); _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();
@@ -1132,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) {
@@ -1156,11 +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();
allowBackground = false; 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();
@@ -1176,7 +1190,7 @@ 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) {
@@ -1184,7 +1198,6 @@ class VideoDetailView : ConstraintLayout {
1 -> { 1 -> {
if(!(video?.isLive ?: false)) { if(!(video?.isLive ?: false)) {
_player.switchToAudioMode(video); _player.switchToAudioMode(video);
allowBackground = true;
} }
StatePlayer.instance.startOrUpdateMediaSession(context, video); StatePlayer.instance.startOrUpdateMediaSession(context, video);
} }
@@ -1207,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;
@@ -1971,10 +1985,10 @@ class VideoDetailView : ConstraintLayout {
if (isLimitedVersion && _player.isAudioMode) { if (isLimitedVersion && _player.isAudioMode) {
_player.switchToVideoMode() _player.switchToVideoMode()
allowBackground = false; isAudioOnlyUserAction = false;
} else { } else {
val thumbnail = video.thumbnails.getHQThumbnail(); val thumbnail = video.thumbnails.getHQThumbnail();
if ((videoSource == null || _player.isAudioMode) && !thumbnail.isNullOrBlank()) if ((videoSource == null) && !thumbnail.isNullOrBlank()) // || _player.isAudioMode
Glide.with(context).asBitmap().load(thumbnail) Glide.with(context).asBitmap().load(thumbnail)
.into(object: CustomTarget<Bitmap>() { .into(object: CustomTarget<Bitmap>() {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) { override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
@@ -2353,7 +2367,7 @@ 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(
@@ -2378,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
} }
@@ -2502,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);
@@ -2518,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)")
@@ -3264,9 +3280,14 @@ 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 {
isLoginStop = true;
StatePlugins.instance.loginPlugin(context, id) {
fragment.lifecycleScope.launch(Dispatchers.Main) {
fetchVideo(); 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");
}, UIDialogs.ActionStyle.PRIMARY)); }, UIDialogs.ActionStyle.PRIMARY));
@@ -3443,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";
@@ -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;
}
}
@@ -136,8 +136,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() {
@@ -194,17 +194,18 @@ class StateHistory {
_remoteHistoryDatesStore.save(); _remoteHistoryDatesStore.save();
} }
fun syncRemoteHistory(plugin: JSClient) { fun syncRemoteHistory(plugin: JSClient): Int {
if (plugin.capabilities.hasGetUserHistory && if (plugin.capabilities.hasGetUserHistory &&
plugin.isLoggedIn) { plugin.isLoggedIn) {
Logger.i(TAG, "Syncing remote history for plugin [${plugin.name}]"); Logger.i(TAG, "Syncing remote history for plugin [${plugin.name}]");
val hist = StatePlatform.instance.getUserHistory(plugin.id); val hist = StatePlatform.instance.getUserHistory(plugin.id);
syncRemoteHistory(plugin.id, hist, 100, 3); return syncRemoteHistory(plugin.id, hist, 100, 3);
} }
return 0;
} }
fun syncRemoteHistory(pluginId: String, videos: IPager<IPlatformContent>, maxVideos: Int, maxPages: Int) { fun syncRemoteHistory(pluginId: String, videos: IPager<IPlatformContent>, maxVideos: Int, maxPages: Int): Int {
val lastDate = _remoteHistoryDatesStore.get(pluginId) ?: OffsetDateTime.MIN; val lastDate = _remoteHistoryDatesStore.get(pluginId) ?: OffsetDateTime.MIN;
val maxVideosCount = if(maxVideos <= 0) 500 else maxVideos; val maxVideosCount = if(maxVideos <= 0) 500 else maxVideos;
val maxPageCount = if(maxPages <= 0) 3 else maxPages; val maxPageCount = if(maxPages <= 0) 3 else maxPages;
@@ -272,12 +273,14 @@ class StateHistory {
} }
catch(ex: Throwable){} catch(ex: Throwable){}
} }
return updated;
} }
} }
catch(ex: Throwable) { catch(ex: Throwable) {
val plugin = if(pluginId != StateDeveloper.DEV_ID) StatePlugins.instance.getPlugin(pluginId) else null; 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) Logger.e(TAG, "Sync Remote History failed for [${plugin?.config?.name}] due to: " + ex.message)
} }
return 0;
} }
companion object { companion object {
@@ -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;
}; };
@@ -500,7 +519,7 @@ class StatePlatform {
.toList() .toList()
.associateWith { 1f }; .associateWith { 1f };
val pager = MultiDistributionContentPager(pages); val pager = MultiDistributionContentPager(pages, 2);
pager.initialize(); pager.initialize();
return pager; return pager;
} }
@@ -179,9 +179,10 @@ 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,10 +529,8 @@ 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,14 +538,12 @@ 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? {
try { try {
if(config.id == StateDeveloper.DEV_ID) if(config.id == StateDeveloper.DEV_ID)
@@ -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(),
@@ -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;
} }
@@ -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,17 +54,19 @@ class DeviceViewHolder : ViewHolder {
val connect = { val connect = {
device?.let { dev -> device?.let { dev ->
try {
if (dev.isReady) { if (dev.isReady) {
StateCasting.instance.activeDevice?.stopCasting() StateCasting.instance.activeDevice?.stopPlayback()
StateCasting.instance.connectDevice(dev) StateCasting.instance.connectDevice(dev)
onConnect.emit(dev) onConnect.emit(dev)
} else { } else {
try { view.context?.let {
view.context?.let { UIDialogs.toast(it, "Device not ready, may be offline") } UIDialogs.toast(it, "Device not ready, may be offline")
} catch (e: Throwable) {
//Ignored
} }
} }
} catch (e: Throwable) {
Logger.e(TAG, "Failed to connect: $e")
}
} }
} }
@@ -81,16 +81,26 @@ 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) {
CastProtocolType.CHROMECAST -> {
_imageDevice.setImageResource(R.drawable.ic_chromecast); _imageDevice.setImageResource(R.drawable.ic_chromecast);
_textType.text = "Chromecast"; _textType.text = "Chromecast";
} else if (d is AirPlayCastingDevice) { }
CastProtocolType.AIRPLAY -> {
_imageDevice.setImageResource(R.drawable.ic_airplay); _imageDevice.setImageResource(R.drawable.ic_airplay);
_textType.text = "AirPlay"; _textType.text = "AirPlay";
} else if (d is FCastCastingDevice) { }
_imageDevice.setImageResource(R.drawable.ic_fc); CastProtocolType.FCAST -> {
_imageDevice.setImageResource(
if (Settings.instance.casting.experimentalCasting) {
R.drawable.ic_exp_fc
} else {
R.drawable.ic_fc
}
)
_textType.text = "FCast"; _textType.text = "FCast";
} }
}
_textName.text = d.name; _textName.text = d.name;
_imageOnline.visibility = if (isOnlineDevice && d.isReady) View.VISIBLE else View.GONE _imageOnline.visibility = if (isOnlineDevice && d.isReady) View.VISIBLE else View.GONE
@@ -136,4 +146,8 @@ class DeviceViewHolder : ViewHolder {
device = d; device = d;
} }
companion object {
private val TAG = "DeviceViewHolder"
}
} }
@@ -0,0 +1,117 @@
package com.futo.platformplayer.views.buttons
import android.content.Context
import android.graphics.Bitmap
import android.util.AttributeSet
import android.util.TypedValue
import android.view.View
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.constructs.Event0
import com.google.android.material.imageview.ShapeableImageView
import com.google.android.material.shape.ShapeAppearanceModel
class ShortsButton : LinearLayout {
private val _root: LinearLayout;
private val _icon: ImageView;
private val _textPrimary: TextView;
val onClick = Event0();
var iconId: Int? = null;
constructor(context : Context, text: String, icon: Int, action: ()->Unit) : super(context) {
inflate(context, R.layout.view_shorts_button, this);
_icon = findViewById(R.id.button_icon);
_textPrimary = findViewById(R.id.button_text);
_root = findViewById(R.id.root);
withPrimaryText(text);
withIcon(icon);
_root.apply {
isClickable = true;
setOnClickListener {
if(!isEnabled)
return@setOnClickListener;
action();
onClick.emit();
UIDialogs.toast("Clicked button: " + _textPrimary.text);
};
}
}
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
inflate(context, R.layout.view_shorts_button, this);
_icon = findViewById(R.id.image_icon);
_textPrimary = findViewById(R.id.text_title);
_root = findViewById(R.id.root);
_root.apply {
isClickable = true;
setOnClickListener {
if(!isEnabled)
return@setOnClickListener;
onClick.emit();
};
}
val attrArr = context.obtainStyledAttributes(attrs, R.styleable.ShortsButton, 0, 0);
val attrIconRef = attrArr.getResourceId(R.styleable.ShortsButton_buttonIcon_s, -1);
val attrText = attrArr.getText(R.styleable.ShortsButton_buttonText_s) ?: "";
attrArr.recycle()
withIcon(attrIconRef);
withPrimaryText(attrText.toString());
}
fun withMargin(bottom: Int, side: Int = 0): ShortsButton {
setPadding(side, 0, side, bottom)
return this;
}
fun withPrimaryText(text: String): ShortsButton {
_textPrimary.text = text;
if(text.isNullOrBlank())
_textPrimary.visibility = View.GONE;
else
_textPrimary.visibility = View.VISIBLE;
return this;
}
fun withIcon(resourceId: Int): ShortsButton {
if (resourceId != -1) {
_icon.visibility = View.VISIBLE;
_icon.setImageResource(resourceId);
} else
_icon.visibility = View.GONE;
_icon.scaleType = ImageView.ScaleType.CENTER_CROP;
iconId = resourceId;
return this;
}
fun withIcon(bitmap: Bitmap): ShortsButton {
_icon.visibility = View.VISIBLE;
_icon.setImageBitmap(bitmap);
iconId = -1;
_icon.scaleType = ImageView.ScaleType.CENTER_CROP;
return this;
}
fun setButtonEnabled(enabled: Boolean) {
if(enabled) {
alpha = 1f;
isEnabled = true;
isClickable = true;
}
else {
alpha = 0.5f;
isEnabled = false;
isClickable = false;
}
}
}
@@ -2,12 +2,7 @@ package com.futo.platformplayer.views.casting
import android.content.Context import android.content.Context
import android.util.AttributeSet import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View import android.view.View
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
@@ -21,14 +21,13 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.api.media.models.chapters.IChapter import com.futo.platformplayer.api.media.models.chapters.IChapter
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.casting.AirPlayCastingDevice import com.futo.platformplayer.casting.CastConnectionState
import com.futo.platformplayer.casting.ChromecastCastingDevice
import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.formatDuration import com.futo.platformplayer.formatDuration
import com.futo.platformplayer.states.StateHistory import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.views.TargetTapLoaderView import com.futo.platformplayer.views.TargetTapLoaderView
import com.futo.platformplayer.views.behavior.GestureControlView import com.futo.platformplayer.views.behavior.GestureControlView
@@ -36,7 +35,6 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class CastView : ConstraintLayout { class CastView : ConstraintLayout {
@@ -99,14 +97,25 @@ class CastView : ConstraintLayout {
val d = StateCasting.instance.activeDevice ?: return@subscribe; val d = StateCasting.instance.activeDevice ?: return@subscribe;
_speedHoldWasPlaying = d.isPlaying _speedHoldWasPlaying = d.isPlaying
_speedHoldPrevRate = d.speed _speedHoldPrevRate = d.speed
if (d.canSetSpeed) try {
if (d.canSetSpeed()) {
d.changeSpeed(Settings.instance.playback.getHoldPlaybackSpeed()) d.changeSpeed(Settings.instance.playback.getHoldPlaybackSpeed())
d.resumeVideo() }
d.resumePlayback()
} catch (e: Throwable) {
Logger.e(TAG, "Failed to change playback speed to hold playback speed: $e")
}
} }
_gestureControlView.onSpeedHoldEnd.subscribe { _gestureControlView.onSpeedHoldEnd.subscribe {
try {
val d = StateCasting.instance.activeDevice ?: return@subscribe; val d = StateCasting.instance.activeDevice ?: return@subscribe;
if (!_speedHoldWasPlaying) d.pauseVideo() if (!_speedHoldWasPlaying) {
d.pausePlayback()
}
d.changeSpeed(_speedHoldPrevRate) d.changeSpeed(_speedHoldPrevRate)
} catch (e: Throwable) {
Logger.e(TAG, "Failed to change playback speed to previous hold playback speed: $e")
}
} }
_gestureControlView.onSeek.subscribe { _gestureControlView.onSeek.subscribe {
@@ -220,22 +229,9 @@ class CastView : ConstraintLayout {
stopTimeJob() stopTimeJob()
if(isPlaying) { if(isPlaying) {
val d = StateCasting.instance.activeDevice; StateCasting.instance.startUpdateTimeJob(
if (d is AirPlayCastingDevice || d is ChromecastCastingDevice) { onTimeJobTimeChanged_s
_updateTimeJob = _scope.launch { ) { setTime(it) }
while (true) {
val device = StateCasting.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())
}
}
}
if (!_inPictureInPicture) { if (!_inPictureInPicture) {
_buttonPause.visibility = View.VISIBLE; _buttonPause.visibility = View.VISIBLE;
@@ -333,4 +329,8 @@ class CastView : ConstraintLayout {
_loaderGame.visibility = View.VISIBLE _loaderGame.visibility = View.VISIBLE
_loaderGame.startLoader(expectedDurationMs.toLong()) _loaderGame.startLoader(expectedDurationMs.toLong())
} }
companion object {
private val TAG = "CastView";
}
} }
@@ -4,7 +4,7 @@ import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.constructs.Event3 import com.futo.platformplayer.constructs.Event3
import java.lang.reflect.Field import java.lang.reflect.Field
@Target(AnnotationTarget.FIELD, AnnotationTarget.PROPERTY) @Target(AnnotationTarget.FIELD, AnnotationTarget.PROPERTY, AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME) @Retention(AnnotationRetention.RUNTIME)
annotation class AdvancedField(); annotation class AdvancedField();
@@ -14,6 +14,7 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.getDataLinkFromUrl import com.futo.platformplayer.getDataLinkFromUrl
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.views.IdenticonView import com.futo.platformplayer.views.IdenticonView
import userpackage.Protocol import userpackage.Protocol
@@ -82,14 +83,14 @@ class CreatorThumbnail : ConstraintLayout {
Glide.with(_imageChannelThumbnail) Glide.with(_imageChannelThumbnail)
.load(url) .load(url)
.placeholder(R.drawable.placeholder_channel_thumbnail) .placeholder(R.drawable.placeholder_channel_thumbnail)
.diskCacheStrategy(DiskCacheStrategy.DATA) .diskCacheStrategy(DiskCacheStrategy.AUTOMATIC)
.crossfade() .crossfade()
.into(_imageChannelThumbnail); .into(_imageChannelThumbnail)
} else { } else {
Glide.with(_imageChannelThumbnail) Glide.with(_imageChannelThumbnail)
.load(url) .load(url)
.placeholder(R.drawable.placeholder_channel_thumbnail) .placeholder(R.drawable.placeholder_channel_thumbnail)
.diskCacheStrategy(DiskCacheStrategy.DATA) .diskCacheStrategy(DiskCacheStrategy.AUTOMATIC)
.into(_imageChannelThumbnail); .into(_imageChannelThumbnail);
} }
} }
@@ -50,6 +50,29 @@ class RadioGroupView : FlexboxLayout {
radioView.layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) radioView.layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
radioView.setInfo(option.first, initiallySelectedOptions.contains(option.second)); radioView.setInfo(option.first, initiallySelectedOptions.contains(option.second));
radioView.setPadding(_padding_px, _padding_px, _padding_px, _padding_px); radioView.setPadding(_padding_px, _padding_px, _padding_px, _padding_px);
if(multiSelect)
radioView.onLongClick.subscribe {
val selected = !radioView.selected;
if (selected) {
selectedOptions.clear();
for(v in radioViews)
v.setIsSelected(true);
selectedOptions.addAll(options.map { it.second });
} else {
if(atLeastOne) {
for(v in radioViews)
v.setIsSelected(false);
selectedOptions.clear();
selectedOptions.add(option.second);
}
else {
for(v in radioViews)
v.setIsSelected(false);
selectedOptions.clear();
}
}
onSelectedChange.emit(selectedOptions);
}
radioView.onClick.subscribe { radioView.onClick.subscribe {
val selected = !radioView.selected; val selected = !radioView.selected;
if (selected) { if (selected) {
@@ -20,6 +20,7 @@ class RadioView : LinearLayout {
val selected get() = _selected; val selected get() = _selected;
var onClick = Event0(); var onClick = Event0();
var onLongClick = Event0();
var onSelectedChange = Event1<Boolean>(); var onSelectedChange = Event1<Boolean>();
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
@@ -32,6 +33,13 @@ class RadioView : LinearLayout {
setIsSelected(!_selected) setIsSelected(!_selected)
} }
}; };
_root.setOnLongClickListener {
onLongClick.emit();
if (_handleClick) {
setIsSelected(!_selected)
}
return@setOnLongClickListener true;
}
_root.setBackgroundResource(R.drawable.background_radio_unselected); _root.setBackgroundResource(R.drawable.background_radio_unselected);
_textTag.setTextColor(ContextCompat.getColor(context, R.color.gray_67)); _textTag.setTextColor(ContextCompat.getColor(context, R.color.gray_67));
@@ -23,12 +23,16 @@ class ToggleTagView : LinearLayout {
private var _text: String = ""; private var _text: String = "";
private var _image: ImageView; private var _image: ImageView;
var tag: String? = null
private set;
var isActive: Boolean = false var isActive: Boolean = false
private set; private set;
var isButton: Boolean = false var isButton: Boolean = false
private set; private set;
var onClick = Event2<ToggleTagView, Boolean>(); var onClick = Event2<ToggleTagView, Boolean>();
var onLongClick = Event2<ToggleTagView, Boolean>();
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
LayoutInflater.from(context).inflate(R.layout.view_toggle_tag, this, true); LayoutInflater.from(context).inflate(R.layout.view_toggle_tag, this, true);
@@ -36,11 +40,26 @@ class ToggleTagView : LinearLayout {
_textTag = findViewById(R.id.text_tag); _textTag = findViewById(R.id.text_tag);
_image = findViewById(R.id.image_tag); _image = findViewById(R.id.image_tag);
_root.setOnClickListener { _root.setOnClickListener {
handleClick();
}
_root.setOnLongClickListener {
if(onLongClick.hasListeners())
onLongClick.emit(this, isActive);
else {
if(!isButton) {
setToggle(!isActive);
}
onClick.emit(this, isActive);
}
return@setOnLongClickListener true;
}
}
fun handleClick() {
if(!isButton) if(!isButton)
setToggle(!isActive); setToggle(!isActive);
onClick.emit(this, isActive); onClick.emit(this, isActive);
} }
}
fun setToggle(isActive: Boolean) { fun setToggle(isActive: Boolean) {
this.isActive = isActive; this.isActive = isActive;
@@ -70,9 +89,10 @@ class ToggleTagView : LinearLayout {
_image.visibility = View.VISIBLE; _image.visibility = View.VISIBLE;
_textTag.visibility = if(!toggle.name.isNullOrEmpty()) View.VISIBLE else View.GONE; _textTag.visibility = if(!toggle.name.isNullOrEmpty()) View.VISIBLE else View.GONE;
this.isButton = isButton; this.isButton = isButton;
tag = toggle.tag;
} }
fun setInfo(imageResource: Int, text: String, isActive: Boolean, isButton: Boolean = false) { fun setInfo(imageResource: Int, text: String, isActive: Boolean, isButton: Boolean = false, tag: String? = null) {
_text = text; _text = text;
_textTag.text = text; _textTag.text = text;
setToggle(isActive); setToggle(isActive);
@@ -80,8 +100,9 @@ class ToggleTagView : LinearLayout {
_image.visibility = View.VISIBLE; _image.visibility = View.VISIBLE;
_textTag.visibility = if(!text.isNullOrEmpty()) View.VISIBLE else View.GONE; _textTag.visibility = if(!text.isNullOrEmpty()) View.VISIBLE else View.GONE;
this.isButton = isButton; this.isButton = isButton;
this.tag = tag;
} }
fun setInfo(image: ImageVariable, text: String, isActive: Boolean, isButton: Boolean = false) { fun setInfo(image: ImageVariable, text: String, isActive: Boolean, isButton: Boolean = false, tag: String? = null) {
_text = text; _text = text;
_textTag.text = text; _textTag.text = text;
setToggle(isActive); setToggle(isActive);
@@ -89,13 +110,15 @@ class ToggleTagView : LinearLayout {
_image.visibility = View.VISIBLE; _image.visibility = View.VISIBLE;
_textTag.visibility = if(!text.isNullOrEmpty()) View.VISIBLE else View.GONE; _textTag.visibility = if(!text.isNullOrEmpty()) View.VISIBLE else View.GONE;
this.isButton = isButton; this.isButton = isButton;
this.tag = tag;
} }
fun setInfo(text: String, isActive: Boolean, isButton: Boolean = false) { fun setInfo(text: String, isActive: Boolean, isButton: Boolean = false, tag: String? = null) {
_image.visibility = View.GONE; _image.visibility = View.GONE;
_text = text; _text = text;
_textTag.text = text; _textTag.text = text;
_textTag.visibility = if(!text.isNullOrEmpty()) View.VISIBLE else View.GONE; _textTag.visibility = if(!text.isNullOrEmpty()) View.VISIBLE else View.GONE;
setToggle(isActive); setToggle(isActive);
this.isButton = isButton; this.isButton = isButton;
this.tag = tag;
} }
} }
@@ -6,14 +6,17 @@ import android.graphics.drawable.Drawable
import android.util.AttributeSet import android.util.AttributeSet
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.animation.LinearInterpolator import android.view.animation.LinearInterpolator
import androidx.annotation.Dimension
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.media3.common.PlaybackParameters import androidx.media3.common.PlaybackParameters
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.DefaultTimeBar import androidx.media3.ui.DefaultTimeBar
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import androidx.media3.ui.TimeBar import androidx.media3.ui.TimeBar
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlayer
@@ -65,6 +68,13 @@ class FutoShortPlayer(context: Context, attrs: AttributeSet? = null) :
videoView = findViewById(R.id.short_player_view) videoView = findViewById(R.id.short_player_view)
progressBar = findViewById(R.id.short_player_progress_bar) progressBar = findViewById(R.id.short_player_progress_bar)
if(Settings.instance.playback.shortsFitVideo)
videoView.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT;
else
videoView.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM;
videoView.subtitleView?.setFixedTextSize(Dimension.SP, 18F);
if (!isInEditMode) { if (!isInEditMode) {
player = StatePlayer.instance.getShortPlayerOrCreate(context) player = StatePlayer.instance.getShortPlayerOrCreate(context)
player.player.repeatMode = Player.REPEAT_MODE_ONE player.player.repeatMode = Player.REPEAT_MODE_ONE
@@ -907,11 +907,14 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
override fun switchToVideoMode() { override fun switchToVideoMode() {
super.switchToVideoMode() super.switchToVideoMode()
setArtwork(null) //setArtwork(null)
} }
override fun switchToAudioMode(video: IPlatformVideoDetails?) { override fun switchToAudioMode(video: IPlatformVideoDetails?) {
super.switchToAudioMode(video) super.switchToAudioMode(video)
//This causes issues, and is in general confusing, needs improvements
/*
val thumbnail = video?.thumbnails?.getHQThumbnail() val thumbnail = video?.thumbnails?.getHQThumbnail()
if (!thumbnail.isNullOrBlank()) { if (!thumbnail.isNullOrBlank()) {
Glide.with(context).asBitmap().load(thumbnail) Glide.with(context).asBitmap().load(thumbnail)
@@ -928,5 +931,6 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
} }
}) })
} }
*/
} }
} }
@@ -64,6 +64,10 @@ import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManif
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSHLSManifestAudioSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSHLSManifestAudioSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSVideoUrlRangeSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSVideoUrlRangeSource
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalAudioContentSource
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalAudioFileSource
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.api.media.structures.IPager
import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
@@ -480,6 +484,8 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
is IHLSManifestSource -> { swapVideoSourceHLS(videoSource); true; } is IHLSManifestSource -> { swapVideoSourceHLS(videoSource); true; }
is IVideoUrlWidevineSource -> { swapVideoSourceUrlWidevine(videoSource); true; } is IVideoUrlWidevineSource -> { swapVideoSourceUrlWidevine(videoSource); true; }
is IVideoUrlSource -> { swapVideoSourceUrl(videoSource); true; } is IVideoUrlSource -> { swapVideoSourceUrl(videoSource); true; }
is LocalVideoFileSource -> { swapVideoSourceLocalFile(videoSource); true; }
is LocalVideoContentSource -> { swapVideoSourceLocalContent(videoSource); true; }
null -> { _lastVideoMediaSource = null; true;} null -> { _lastVideoMediaSource = null; true;}
else -> throw IllegalArgumentException("Unsupported video source [${videoSource.javaClass.simpleName}]"); else -> throw IllegalArgumentException("Unsupported video source [${videoSource.javaClass.simpleName}]");
} }
@@ -496,6 +502,8 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
is JSDashManifestRawAudioSource -> swapAudioSourceDashRaw(audioSource, play, resume, swapId); is JSDashManifestRawAudioSource -> swapAudioSourceDashRaw(audioSource, play, resume, swapId);
is IAudioUrlWidevineSource -> { swapAudioSourceUrlWidevine(audioSource); true; } is IAudioUrlWidevineSource -> { swapAudioSourceUrlWidevine(audioSource); true; }
is IAudioUrlSource -> { swapAudioSourceUrl(audioSource); true; } is IAudioUrlSource -> { swapAudioSourceUrl(audioSource); true; }
is LocalAudioFileSource -> { swapAudioSourceLocalFile(audioSource); true; }
is LocalAudioContentSource -> { swapAudioSourceLocalContent(audioSource); true; }
null -> { _lastAudioMediaSource = null; true; } null -> { _lastAudioMediaSource = null; true; }
else -> throw IllegalArgumentException("Unsupported video source [${audioSource.javaClass.simpleName}]"); else -> throw IllegalArgumentException("Unsupported video source [${audioSource.javaClass.simpleName}]");
} }
@@ -514,6 +522,23 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
.createMediaSource(MediaItem.fromUri(Uri.fromFile(file))); .createMediaSource(MediaItem.fromUri(Uri.fromFile(file)));
} }
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
private fun swapVideoSourceLocalFile(videoSource: LocalVideoFileSource) {
Logger.i(TAG, "Loading VideoSource [Local]");
val file = videoSource.file;
if(!file.exists())
throw IllegalArgumentException("File for this video does not exist");
_lastVideoMediaSource = ProgressiveMediaSource.Factory(DefaultDataSource.Factory(context))
.createMediaSource(MediaItem.fromUri(Uri.fromFile(file)));
}
@OptIn(UnstableApi::class)
private fun swapVideoSourceLocalContent(videoSource: LocalVideoContentSource) {
Logger.i(TAG, "Loading VideoSource [Local]");
if(!videoSource.contentUrl.startsWith("content://"))
throw IllegalArgumentException("Not a content uri");
_lastVideoMediaSource = ProgressiveMediaSource.Factory(DefaultDataSource.Factory(context))
.createMediaSource(MediaItem.fromUri(videoSource.contentUrl));
}
@OptIn(UnstableApi::class)
private fun swapVideoSourceUrlRange(videoSource: JSVideoUrlRangeSource) { private fun swapVideoSourceUrlRange(videoSource: JSVideoUrlRangeSource) {
Logger.i(TAG, "Loading JSVideoUrlRangeSource"); Logger.i(TAG, "Loading JSVideoUrlRangeSource");
if(videoSource.hasItag) { if(videoSource.hasItag) {
@@ -707,6 +732,23 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
.createMediaSource(MediaItem.fromUri(Uri.fromFile(file))); .createMediaSource(MediaItem.fromUri(Uri.fromFile(file)));
} }
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
private fun swapAudioSourceLocalFile(audioSource: LocalAudioFileSource) {
Logger.i(TAG, "Loading VideoSource [Local]");
val file = audioSource.file;
if(!file.exists())
throw IllegalArgumentException("File for this video does not exist");
_lastAudioMediaSource = ProgressiveMediaSource.Factory(DefaultDataSource.Factory(context))
.createMediaSource(MediaItem.fromUri(Uri.fromFile(file)));
}
@OptIn(UnstableApi::class)
private fun swapAudioSourceLocalContent(audioSource: LocalAudioContentSource) {
Logger.i(TAG, "Loading VideoSource [Local]");
if(!audioSource.contentUrl.startsWith("content://"))
throw IllegalArgumentException("Not a content uri");
_lastAudioMediaSource = ProgressiveMediaSource.Factory(DefaultDataSource.Factory(context))
.createMediaSource(MediaItem.fromUri(audioSource.contentUrl));
}
@OptIn(UnstableApi::class)
private fun swapAudioSourceUrlRange(audioSource: JSAudioUrlRangeSource) { private fun swapAudioSourceUrlRange(audioSource: JSAudioUrlRangeSource) {
Logger.i(TAG, "Loading JSAudioUrlRangeSource"); Logger.i(TAG, "Loading JSAudioUrlRangeSource");
if(audioSource.hasItag) { if(audioSource.hasItag) {
@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:autoMirrored="true">
<path
android:fillColor="@android:color/white"
android:strokeColor="#222"
android:strokeWidth="20"
android:pathData="M240,560L720,560L720,480L240,480L240,560ZM240,440L720,440L720,360L240,360L240,440ZM240,320L720,320L720,240L240,240L240,320ZM880,880L720,720L160,720Q127,720 103.5,696.5Q80,673 80,640L80,160Q80,127 103.5,103.5Q127,80 160,80L800,80Q833,80 856.5,103.5Q880,127 880,160L880,880ZM160,640L754,640L800,685L800,160Q800,160 800,160Q800,160 800,160L160,160Q160,160 160,160Q160,160 160,160L160,640Q160,640 160,640Q160,640 160,640ZM160,640Q160,640 160,640Q160,640 160,640L160,160Q160,160 160,160Q160,160 160,160L160,160Q160,160 160,160Q160,160 160,160L160,640Z"/>
</vector>
+14
View File
@@ -0,0 +1,14 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="111.96dp"
android:height="114.46dp"
android:viewportWidth="111.96"
android:viewportHeight="114.46">
<path
android:pathData="m84.76,5.58c2.06,-2.06 0.6,-5.58 -2.31,-5.58H3.27C1.46,-0 -0,1.46 -0,3.27V82.45c0,2.91 3.52,4.37 5.58,2.31L20.37,69.98c0.61,-0.61 0.96,-1.45 0.96,-2.31V24.6c0,-1.81 1.46,-3.27 3.27,-3.27h43.07c0.87,0 1.7,-0.34 2.31,-0.96z"
android:fillColor="#ffffff"
android:fillType="evenOdd"/>
<path
android:pathData="m45.68,73.5v17.18c0,0.68 -0.55,1.23 -1.23,1.23L27.56,91.9c-0.68,0 -1.23,-0.55 -1.23,-1.23v-17.18c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM45.68,51.16v17.18c0,0.68 -0.55,1.23 -1.23,1.23L27.56,69.57c-0.68,0 -1.23,-0.55 -1.23,-1.23L26.33,51.16c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM67.73,51.16v17.18c0,0.68 -0.55,1.23 -1.23,1.23L49.61,69.57c-0.68,0 -1.23,-0.55 -1.23,-1.23L48.38,51.16c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM67.73,28.83v17.18c0,0.68 -0.55,1.23 -1.23,1.23L49.61,47.24c-0.68,0 -1.23,-0.55 -1.23,-1.23L48.38,28.83c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM89.77,51.16v17.18c0,0.68 -0.55,1.23 -1.23,1.23h-16.89c-0.68,0 -1.23,-0.55 -1.23,-1.23L70.43,51.16c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM67.73,73.5v17.18c0,0.68 -0.55,1.23 -1.23,1.23L49.61,91.9c-0.68,0 -1.23,-0.55 -1.23,-1.23v-17.18c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM89.77,28.83v17.18c0,0.68 -0.55,1.23 -1.23,1.23h-16.89c-0.68,0 -1.23,-0.55 -1.23,-1.23L70.43,28.83c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM45.68,28.83v17.18c0,0.68 -0.55,1.23 -1.23,1.23L27.56,47.24c-0.68,0 -1.23,-0.55 -1.23,-1.23L26.33,28.83c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM45.68,95.83v17.18c0,0.68 -0.55,1.23 -1.23,1.23L27.56,114.24c-0.68,0 -1.23,-0.55 -1.23,-1.23L26.33,95.83c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM111.77,28.83v17.18c0,0.68 -0.55,1.23 -1.23,1.23L93.65,47.24c-0.68,0 -1.23,-0.55 -1.23,-1.23L92.43,28.83c0,-0.68 0.55,-1.23 1.23,-1.23L110.55,27.6c0.68,0 1.23,0.55 1.23,1.23z"
android:strokeWidth="0"
android:fillColor="#ffffff"/>
</vector>
@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:strokeColor="#222"
android:strokeWidth="20"
android:pathData="M370,880L354,752Q341,747 329.5,740Q318,733 307,725L188,775L78,585L181,507Q180,500 180,493.5Q180,487 180,480Q180,473 180,466.5Q180,460 181,453L78,375L188,185L307,235Q318,227 330,220Q342,213 354,208L370,80L590,80L606,208Q619,213 630.5,220Q642,227 653,235L772,185L882,375L779,453Q780,460 780,466.5Q780,473 780,480Q780,487 780,493.5Q780,500 778,507L881,585L771,775L653,725Q642,733 630,740Q618,747 606,752L590,880L370,880ZM440,800L519,800L533,694Q564,686 590.5,670.5Q617,655 639,633L738,674L777,606L691,541Q696,527 698,511.5Q700,496 700,480Q700,464 698,448.5Q696,433 691,419L777,354L738,286L639,328Q617,305 590.5,289.5Q564,274 533,266L520,160L441,160L427,266Q396,274 369.5,289.5Q343,305 321,327L222,286L183,354L269,418Q264,433 262,448Q260,463 260,480Q260,496 262,511Q264,526 269,541L183,606L222,674L321,632Q343,655 369.5,670.5Q396,686 427,694L440,800ZM482,620Q540,620 581,579Q622,538 622,480Q622,422 581,381Q540,340 482,340Q423,340 382.5,381Q342,422 342,480Q342,538 382.5,579Q423,620 482,620ZM480,480L480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480L480,480Z"/>
</vector>
+11
View File
@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:strokeColor="#222"
android:strokeWidth="20"
android:pathData="M680,880Q630,880 595,845Q560,810 560,760Q560,754 563,732L282,568Q266,583 245,591.5Q224,600 200,600Q150,600 115,565Q80,530 80,480Q80,430 115,395Q150,360 200,360Q224,360 245,368.5Q266,377 282,392L563,228Q561,221 560.5,214.5Q560,208 560,200Q560,150 595,115Q630,80 680,80Q730,80 765,115Q800,150 800,200Q800,250 765,285Q730,320 680,320Q656,320 635,311.5Q614,303 598,288L317,452Q319,459 319.5,465.5Q320,472 320,480Q320,488 319.5,494.5Q319,501 317,508L598,672Q614,657 635,648.5Q656,640 680,640Q730,640 765,675Q800,710 800,760Q800,810 765,845Q730,880 680,880ZM680,800Q697,800 708.5,788.5Q720,777 720,760Q720,743 708.5,731.5Q697,720 680,720Q663,720 651.5,731.5Q640,743 640,760Q640,777 651.5,788.5Q663,800 680,800ZM200,520Q217,520 228.5,508.5Q240,497 240,480Q240,463 228.5,451.5Q217,440 200,440Q183,440 171.5,451.5Q160,463 160,480Q160,497 171.5,508.5Q183,520 200,520ZM680,240Q697,240 708.5,228.5Q720,217 720,200Q720,183 708.5,171.5Q697,160 680,160Q663,160 651.5,171.5Q640,183 640,200Q640,217 651.5,228.5Q663,240 680,240ZM680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760ZM200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480ZM680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Z"/>
</vector>
@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:strokeColor="#222"
android:strokeWidth="20"
android:pathData="M240,120L680,120L680,640L400,920L350,870Q343,863 338.5,851Q334,839 334,828L334,814L378,640L120,640Q88,640 64,616Q40,592 40,560L40,480Q40,473 42,465Q44,457 46,450L166,168Q175,148 196,134Q217,120 240,120ZM600,200L240,200Q240,200 240,200Q240,200 240,200L120,480L120,560Q120,560 120,560Q120,560 120,560L480,560L426,780L600,606L600,200ZM600,606L600,606L600,560L600,560Q600,560 600,560Q600,560 600,560L600,480L600,200Q600,200 600,200Q600,200 600,200L600,200L600,606ZM680,640L680,560L800,560L800,200L680,200L680,120L880,120L880,640L680,640Z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@color/colorPrimary"
android:pathData="M240,120L640,120L640,640L360,920L310,870Q303,863 298.5,851Q294,839 294,828L294,814L338,640L120,640Q88,640 64,616Q40,592 40,560L40,480Q40,473 41.5,465Q43,457 46,450L166,168Q175,148 196,134Q217,120 240,120ZM720,640L720,120L880,120L880,640L720,640Z"/>
</vector>
@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:strokeColor="#222"
android:strokeWidth="20"
android:pathData="M720,840L280,840L280,320L560,40L610,90Q617,97 621.5,109Q626,121 626,132L626,146L582,320L840,320Q872,320 896,344Q920,368 920,400L920,480Q920,487 918,495Q916,503 914,510L794,792Q785,812 764,826Q743,840 720,840ZM360,760L720,760Q720,760 720,760Q720,760 720,760L840,480L840,400Q840,400 840,400Q840,400 840,400L480,400L534,180L360,354L360,760ZM360,354L360,354L360,400L360,400Q360,400 360,400Q360,400 360,400L360,480L360,760Q360,760 360,760Q360,760 360,760L360,760L360,354ZM280,320L280,400L160,400L160,760L280,760L280,840L80,840L80,320L280,320Z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@color/colorPrimary"
android:pathData="M720,840L320,840L320,320L600,40L650,90Q657,97 661.5,109Q666,121 666,132L666,146L622,320L840,320Q872,320 896,344Q920,368 920,400L920,480Q920,487 918.5,495Q917,503 914,510L794,792Q785,812 764,826Q743,840 720,840ZM240,320L240,840L80,840L80,320L240,320Z"/>
</vector>
@@ -63,6 +63,16 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
tools:ignore="HardcodedText" /> tools:ignore="HardcodedText" />
<EditText
android:id="@+id/dialog_text_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="10dp"
android:layout_marginRight="10dp"
android:layout_marginTop="10dp"
android:visibility="gone"
/>
<LinearLayout <LinearLayout
android:id="@+id/dialog_buttons" android:id="@+id/dialog_buttons"
android:layout_width="match_parent" android:layout_width="match_parent"
+32 -272
View File
@@ -129,6 +129,19 @@
android:text="" android:text=""
android:textColor="@android:color/white" android:textColor="@android:color/white"
android:textSize="14sp" /> android:textSize="14sp" />
<TextView
android:id="@+id/video_subtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:ellipsize="end"
android:maxLines="1"
android:paddingHorizontal="4dp"
android:shadowColor="@android:color/black"
android:shadowRadius="8"
android:text=""
android:textColor="#CCC"
android:textSize="14sp" />
</LinearLayout> </LinearLayout>
<!-- Buttons section --> <!-- Buttons section -->
@@ -143,41 +156,15 @@
app:layout_constraintEnd_toEndOf="parent"> app:layout_constraintEnd_toEndOf="parent">
<!-- Like button --> <!-- Like button -->
<FrameLayout <com.futo.platformplayer.views.buttons.ShortsButton
android:id="@+id/like_container"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginBottom="12dp"
android:visibility="gone">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal">
<ImageView
android:layout_width="0dp"
android:layout_height="0dp"
android:importantForAccessibility="no"
android:src="@drawable/button_shadow"
app:layout_constraintBottom_toBottomOf="@id/like_button"
app:layout_constraintEnd_toEndOf="@id/like_button"
app:layout_constraintStart_toStartOf="@id/like_button"
app:layout_constraintTop_toTopOf="@id/like_button"
app:tint="@color/black"
tools:ignore="ImageContrastCheck" />
<com.google.android.material.button.MaterialButton
android:id="@+id/like_button" android:id="@+id/like_button"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="8dp" android:layout_marginBottom="10dp"
android:checkable="true" android:checkable="true"
android:contentDescription="@string/cd_image_like_icon" android:contentDescription="@string/cd_image_like_icon"
app:backgroundTint="@color/transparent" app:backgroundTint="@color/transparent"
app:icon="@drawable/thumb_up_selector" app:buttonIcon_s="@drawable/ic_thumb_up_s"
app:iconSize="24dp" app:iconSize="24dp"
app:iconTint="@android:color/white" app:iconTint="@android:color/white"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
@@ -186,298 +173,71 @@
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:rippleColor="@color/ripple" app:rippleColor="@color/ripple"
app:toggleCheckedStateOnClick="false" /> app:toggleCheckedStateOnClick="false" />
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView
android:id="@+id/like_count"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|center_horizontal"
android:paddingHorizontal="4dp"
android:shadowColor="@android:color/black"
android:shadowRadius="8"
android:textColor="@android:color/white"
android:textSize="12sp" />
</FrameLayout>
<!-- Dislike button --> <!-- Dislike button -->
<FrameLayout <com.futo.platformplayer.views.buttons.ShortsButton
android:id="@+id/dislike_container"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginBottom="12dp"
android:visibility="gone">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal">
<ImageView
android:layout_width="0dp"
android:layout_height="0dp"
android:importantForAccessibility="no"
android:src="@drawable/button_shadow"
app:layout_constraintBottom_toBottomOf="@id/dislike_button"
app:layout_constraintEnd_toEndOf="@id/dislike_button"
app:layout_constraintStart_toStartOf="@id/dislike_button"
app:layout_constraintTop_toTopOf="@id/dislike_button"
app:tint="@color/black"
tools:ignore="ImageContrastCheck" />
<com.google.android.material.button.MaterialButton
android:id="@+id/dislike_button" android:id="@+id/dislike_button"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="8dp" android:layout_marginBottom="20dp"
android:checkable="true" android:checkable="true"
android:contentDescription="@string/cd_image_dislike_icon" android:contentDescription="@string/cd_image_dislike_icon"
app:backgroundTint="@color/transparent" app:backgroundTint="@color/transparent"
app:icon="@drawable/thumb_down_selector" app:buttonIcon_s="@drawable/ic_thumb_down_s"
app:iconSize="24dp" app:iconSize="24dp"
app:iconTint="@android:color/white" app:iconTint="@android:color/white"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:rippleColor="@color/ripple" app:rippleColor="@color/ripple"
app:toggleCheckedStateOnClick="false" /> app:toggleCheckedStateOnClick="false" />
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView
android:id="@+id/dislike_count"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|center_horizontal"
android:paddingHorizontal="4dp"
android:shadowColor="@android:color/black"
android:shadowRadius="8"
android:textColor="@android:color/white"
android:textSize="12sp" />
</FrameLayout>
<!-- Comments button --> <!-- Comments button -->
<FrameLayout <com.futo.platformplayer.views.buttons.ShortsButton
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginBottom="12dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal">
<ImageView
android:layout_width="0dp"
android:layout_height="0dp"
android:importantForAccessibility="no"
android:src="@drawable/button_shadow"
app:layout_constraintBottom_toBottomOf="@id/comments_button"
app:layout_constraintEnd_toEndOf="@id/comments_button"
app:layout_constraintStart_toStartOf="@id/comments_button"
app:layout_constraintTop_toTopOf="@id/comments_button"
app:tint="@color/black"
tools:ignore="ImageContrastCheck" />
<com.google.android.material.button.MaterialButton
android:id="@+id/comments_button" android:id="@+id/comments_button"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="8dp" android:layout_marginBottom="20dp"
android:contentDescription="@string/comments" android:contentDescription="@string/comments"
app:icon="@drawable/desktop_comments" app:buttonIcon_s="@drawable/ic_comment_s"
app:buttonText_s=""
app:iconSize="24dp" app:iconSize="24dp"
app:iconTint="@android:color/white" app:iconTint="@android:color/white"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:rippleColor="@color/ripple" /> app:rippleColor="@color/ripple" />
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|center_horizontal"
android:importantForAccessibility="no"
android:paddingHorizontal="4dp"
android:shadowColor="@android:color/black"
android:shadowRadius="8"
android:text="@string/comments"
android:textColor="@android:color/white"
android:textSize="12sp"
tools:ignore="TextContrastCheck" />
</FrameLayout>
<!-- Share button --> <!-- Share button -->
<FrameLayout <com.futo.platformplayer.views.buttons.ShortsButton
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginBottom="12dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal">
<ImageView
android:layout_width="0dp"
android:layout_height="0dp"
android:importantForAccessibility="no"
android:src="@drawable/button_shadow"
app:layout_constraintBottom_toBottomOf="@id/share_button"
app:layout_constraintEnd_toEndOf="@id/share_button"
app:layout_constraintStart_toStartOf="@id/share_button"
app:layout_constraintTop_toTopOf="@id/share_button"
app:tint="@color/black"
tools:ignore="ImageContrastCheck" />
<com.google.android.material.button.MaterialButton
android:id="@+id/share_button" android:id="@+id/share_button"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="8dp" android:layout_marginBottom="20dp"
android:contentDescription="@string/share" android:contentDescription="@string/share"
app:icon="@drawable/desktop_share" app:buttonIcon_s="@drawable/ic_share_s"
app:iconSize="24dp" app:iconSize="24dp"
app:iconTint="@android:color/white" app:iconTint="@android:color/white"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:rippleColor="@color/ripple" /> app:rippleColor="@color/ripple" />
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|center_horizontal"
android:importantForAccessibility="no"
android:paddingHorizontal="4dp"
android:shadowColor="@android:color/black"
android:shadowRadius="8"
android:text="@string/share"
android:textColor="@android:color/white"
android:textSize="12sp"
tools:ignore="TextContrastCheck" />
</FrameLayout>
<!-- Refresh button --> <!-- Refresh button -->
<FrameLayout <com.futo.platformplayer.views.buttons.ShortsButton
android:id="@+id/refresh_button_container"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginBottom="12dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal">
<ImageView
android:layout_width="0dp"
android:layout_height="0dp"
android:importantForAccessibility="no"
android:src="@drawable/button_shadow"
app:layout_constraintBottom_toBottomOf="@id/refresh_button"
app:layout_constraintEnd_toEndOf="@id/refresh_button"
app:layout_constraintStart_toStartOf="@id/refresh_button"
app:layout_constraintTop_toTopOf="@id/refresh_button"
app:tint="@color/black"
tools:ignore="ImageContrastCheck" />
<com.google.android.material.button.MaterialButton
android:id="@+id/refresh_button" android:id="@+id/refresh_button"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center_horizontal" android:layout_gravity="center_horizontal"
android:layout_marginBottom="8dp" android:layout_marginBottom="20dp"
android:contentDescription="@string/refresh" android:contentDescription="@string/refresh"
app:icon="@drawable/desktop_refresh" app:buttonIcon_s="@drawable/ic_refresh"
app:iconSize="24dp" app:iconSize="24dp"
app:iconTint="@android:color/white" app:iconTint="@android:color/white"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:rippleColor="@color/ripple" /> app:rippleColor="@color/ripple" />
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|center_horizontal"
android:importantForAccessibility="no"
android:paddingHorizontal="4dp"
android:shadowColor="@android:color/black"
android:shadowRadius="8"
android:text="@string/refresh"
android:textColor="@android:color/white"
android:textSize="12sp"
tools:ignore="TextContrastCheck" />
</FrameLayout>
<!-- Quality/More button --> <!-- Quality/More button -->
<FrameLayout <com.futo.platformplayer.views.buttons.ShortsButton
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal">
<ImageView
android:layout_width="0dp"
android:layout_height="0dp"
android:importantForAccessibility="no"
android:src="@drawable/button_shadow"
app:layout_constraintBottom_toBottomOf="@id/quality_button"
app:layout_constraintEnd_toEndOf="@id/quality_button"
app:layout_constraintStart_toStartOf="@id/quality_button"
app:layout_constraintTop_toTopOf="@id/quality_button"
app:tint="@color/black"
tools:ignore="ImageContrastCheck" />
<com.google.android.material.button.MaterialButton
android:id="@+id/quality_button" android:id="@+id/quality_button"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="8dp" android:layout_marginBottom="10dp"
android:contentDescription="@string/quality" android:contentDescription="@string/quality"
app:icon="@drawable/desktop_gear" app:buttonIcon_s="@drawable/ic_settings_s"
app:iconSize="24dp" app:iconSize="24dp"
app:iconTint="@android:color/white" app:iconTint="@android:color/white"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:rippleColor="@color/ripple" /> app:rippleColor="@color/ripple" />
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|center_horizontal"
android:importantForAccessibility="no"
android:paddingHorizontal="4dp"
android:shadowColor="@android:color/black"
android:shadowRadius="8"
android:text="@string/quality"
android:textColor="@android:color/white"
android:textSize="12sp"
tools:ignore="TextContrastCheck" />
</FrameLayout>
</LinearLayout> </LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>
@@ -9,7 +9,7 @@
android:layout_above="@+id/short_player_progress_bar" android:layout_above="@+id/short_player_progress_bar"
android:background="@color/black" android:background="@color/black"
app:default_artwork="@drawable/placeholder_video_thumbnail" app:default_artwork="@drawable/placeholder_video_thumbnail"
app:resize_mode="fit" app:resize_mode="zoom"
app:show_buffering="when_playing" app:show_buffering="when_playing"
app:use_artwork="true" app:use_artwork="true"
app:use_controller="false" /> app:use_controller="false" />
@@ -17,9 +17,9 @@
<androidx.media3.ui.DefaultTimeBar <androidx.media3.ui.DefaultTimeBar
android:id="@+id/short_player_progress_bar" android:id="@+id/short_player_progress_bar"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="6dp" android:layout_height="3dp"
android:layout_alignParentBottom="true" android:layout_alignParentBottom="true"
app:bar_height="6dp" app:bar_height="3dp"
app:buffered_color="#DDEEEEEE" app:buffered_color="#DDEEEEEE"
app:played_color="@color/colorPrimary" app:played_color="@color/colorPrimary"
app:scrubber_disabled_size="0dp" app:scrubber_disabled_size="0dp"
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingBottom="5dp"
android:orientation="vertical"
android:background="@color/transparent"
android:id="@+id/root"
android:paddingTop="3dp"
android:paddingLeft="10dp"
android:paddingRight="10dp">
<ImageView
android:id="@+id/image_icon"
android:layout_width="32dp"
android:layout_height="32dp"
android:src="@drawable/ic_qr" />
<TextView
android:id="@+id/text_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="14dp"
android:autoSizeTextType="uniform"
android:fontFamily="@font/inter_light"
android:textColor="@color/white"
android:textAlignment="center"
android:maxLines="1"
android:text="" />
</LinearLayout>
+15
View File
@@ -704,4 +704,19 @@
<item>Newest</item> <item>Newest</item>
<item>Oldest</item> <item>Oldest</item>
</string-array> </string-array>
<string-array name="app_languages">
<item>النظام</item>
<item>الإنجليزية (EN)</item>
<item>الألمانية (DE)</item>
<item>الإسبانية (ES)</item>
<item>البرتغالية (PT)</item>
<item>الفرنسية (FR)</item>
<item>اليابانية (JA)</item>
<item>الكورية (KO)</item>
<item>الصينية (ZH)</item>
<item>الروسية (RU)</item>
<item>العربية (AR)</item>
<item>الإيطالية (IT)</item>
<item>التركية (TR)</item>
</string-array>
</resources> </resources>
+15
View File
@@ -706,4 +706,19 @@
<item>Newest</item> <item>Newest</item>
<item>Oldest</item> <item>Oldest</item>
</string-array> </string-array>
<string-array name="app_languages">
<item>System</item>
<item>Englisch (EN)</item>
<item>Deutsch (DE)</item>
<item>Spanisch (ES)</item>
<item>Portugiesisch (PT)</item>
<item>Französisch (FR)</item>
<item>Japanisch (JA)</item>
<item>Koreanisch (KO)</item>
<item>Chinesisch (ZH)</item>
<item>Russisch (RU)</item>
<item>Arabisch (AR)</item>
<item>Italienisch (IT)</item>
<item>Türkisch (TR)</item>
</string-array>
</resources> </resources>
+15
View File
@@ -716,4 +716,19 @@
<item>Newest</item> <item>Newest</item>
<item>Oldest</item> <item>Oldest</item>
</string-array> </string-array>
<string-array name="app_languages">
<item>Sistema</item>
<item>Inglés (EN)</item>
<item>Alemán (DE)</item>
<item>Español (ES)</item>
<item>Portugués (PT)</item>
<item>Francés (FR)</item>
<item>Japonés (JA)</item>
<item>Coreano (KO)</item>
<item>Chino (ZH)</item>
<item>Ruso (RU)</item>
<item>Árabe (AR)</item>
<item>Italiano (IT)</item>
<item>Turco (TR)</item>
</string-array>
</resources> </resources>
+15
View File
@@ -714,4 +714,19 @@
<item>Newest</item> <item>Newest</item>
<item>Oldest</item> <item>Oldest</item>
</string-array> </string-array>
<string-array name="app_languages">
<item>Système</item>
<item>Anglais (EN)</item>
<item>Allemand (DE)</item>
<item>Espagnol (ES)</item>
<item>Portugais (PT)</item>
<item>Français (FR)</item>
<item>Japonais (JA)</item>
<item>Coréen (KO)</item>
<item>Chinois (ZH)</item>
<item>Russe (RU)</item>
<item>Arabe (AR)</item>
<item>Italien (IT)</item>
<item>Turc (TR)</item>
</string-array>
</resources> </resources>
File diff suppressed because it is too large Load Diff
+15
View File
@@ -704,4 +704,19 @@
<item>Newest</item> <item>Newest</item>
<item>Oldest</item> <item>Oldest</item>
</string-array> </string-array>
<string-array name="app_languages">
<item>システム</item>
<item>英語 (EN)</item>
<item>ドイツ語 (DE)</item>
<item>スペイン語 (ES)</item>
<item>ポルトガル語 (PT)</item>
<item>フランス語 (FR)</item>
<item>日本語 (JA)</item>
<item>韓国語 (KO)</item>
<item>中国語 (ZH)</item>
<item>ロシア語 (RU)</item>
<item>アラビア語 (AR)</item>
<item>イタリア語 (IT)</item>
<item>トルコ語 (TR)</item>
</string-array>
</resources> </resources>
+15
View File
@@ -704,4 +704,19 @@
<item>Newest</item> <item>Newest</item>
<item>Oldest</item> <item>Oldest</item>
</string-array> </string-array>
<string-array name="app_languages">
<item>시스템</item>
<item>영어 (EN)</item>
<item>독일어 (DE)</item>
<item>스페인어 (ES)</item>
<item>포르투갈어 (PT)</item>
<item>프랑스어 (FR)</item>
<item>일본어 (JA)</item>
<item>한국어 (KO)</item>
<item>중국어 (ZH)</item>
<item>러시아어 (RU)</item>
<item>아랍어 (AR)</item>
<item>이탈리아어 (IT)</item>
<item>터키어 (TR)</item>
</string-array>
</resources> </resources>
+15
View File
@@ -704,4 +704,19 @@
<item>Newest</item> <item>Newest</item>
<item>Oldest</item> <item>Oldest</item>
</string-array> </string-array>
<string-array name="app_languages">
<item>Sistema</item>
<item>Inglês (EN)</item>
<item>Alemão (DE)</item>
<item>Espanhol (ES)</item>
<item>Português (PT)</item>
<item>Francês (FR)</item>
<item>Japonês (JA)</item>
<item>Coreano (KO)</item>
<item>Chinês (ZH)</item>
<item>Russo (RU)</item>
<item>Árabe (AR)</item>
<item>Italiano (IT)</item>
<item>Turco (TR)</item>
</string-array>
</resources> </resources>
+15
View File
@@ -704,4 +704,19 @@
<item>Newest</item> <item>Newest</item>
<item>Oldest</item> <item>Oldest</item>
</string-array> </string-array>
<string-array name="app_languages">
<item>Система</item>
<item>Английский (EN)</item>
<item>Немецкий (DE)</item>
<item>Испанский (ES)</item>
<item>Португальский (PT)</item>
<item>Французский (FR)</item>
<item>Японский (JA)</item>
<item>Корейский (KO)</item>
<item>Китайский (ZH)</item>
<item>Русский (RU)</item>
<item>Арабский (AR)</item>
<item>Итальянский (IT)</item>
<item>Турецкий (TR)</item>
</string-array>
</resources> </resources>
File diff suppressed because it is too large Load Diff
+15
View File
@@ -704,4 +704,19 @@
<item>Newest</item> <item>Newest</item>
<item>Oldest</item> <item>Oldest</item>
</string-array> </string-array>
<string-array name="app_languages">
<item>系统</item>
<item>英语 (EN)</item>
<item>德语 (DE)</item>
<item>西班牙语 (ES)</item>
<item>葡萄牙语 (PT)</item>
<item>法语 (FR)</item>
<item>日语 (JA)</item>
<item>韩语 (KO)</item>
<item>中文 (ZH)</item>
<item>俄语 (RU)</item>
<item>阿拉伯语 (AR)</item>
<item>意大利语 (IT)</item>
<item>土耳其语 (TR)</item>
</string-array>
</resources> </resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="ShortsButton">
<attr name="buttonIcon_s" format="reference" />
<attr name="buttonText_s" format="string" />
</declare-styleable>
</resources>
+17
View File
@@ -82,6 +82,8 @@
<string name="allow_ipv6_description">If casting over IPV6 is allowed, can cause issues on some networks</string> <string name="allow_ipv6_description">If casting over IPV6 is allowed, can cause issues on some networks</string>
<string name="allow_ipv4">Allow Link Local IPV4</string> <string name="allow_ipv4">Allow Link Local IPV4</string>
<string name="allow_ipv4_description">If casting over IPV4 link local is allowed, can cause issues on some networks</string> <string name="allow_ipv4_description">If casting over IPV4 link local is allowed, can cause issues on some networks</string>
<string name="experimental_cast">Experimental</string>
<string name="experimental_cast_description">Use experimental casting backend (requires restart)</string>
<string name="discover">Discover</string> <string name="discover">Discover</string>
<string name="find_new_video_sources_to_add">Find new video sources to add</string> <string name="find_new_video_sources_to_add">Find new video sources to add</string>
<string name="these_sources_have_been_disabled">These sources have been disabled</string> <string name="these_sources_have_been_disabled">These sources have been disabled</string>
@@ -247,6 +249,7 @@
<string name="membership">Membership</string> <string name="membership">Membership</string>
<string name="store">Store</string> <string name="store">Store</string>
<string name="live_chat">Live Chat</string> <string name="live_chat">Live Chat</string>
<string name="vod_chat">VOD Chat</string>
<string name="remove">Remove</string> <string name="remove">Remove</string>
<string name="space_videos">Videos</string> <string name="space_videos">Videos</string>
<string name="playlist">Playlist</string> <string name="playlist">Playlist</string>
@@ -337,6 +340,8 @@
<string name="test_background_worker">Test Background Worker</string> <string name="test_background_worker">Test Background Worker</string>
<string name="test_background_worker_description"></string> <string name="test_background_worker_description"></string>
<string name="clear_payment">Clear Payment</string> <string name="clear_payment">Clear Payment</string>
<string name="configure_sync_server">Configure Sync Server</string>
<string name="configure_sync_server_description">Allows you to change the Sync Server to a self-hosted one.</string>
<string name="clears_cookies_when_you_log_out">Clears cookies when you log out</string> <string name="clears_cookies_when_you_log_out">Clears cookies when you log out</string>
<string name="clears_in_app_browser_cookies">Clears in-app browser cookies</string> <string name="clears_in_app_browser_cookies">Clears in-app browser cookies</string>
<string name="configure_browsing_behavior">Configure browsing behavior</string> <string name="configure_browsing_behavior">Configure browsing behavior</string>
@@ -435,6 +440,11 @@
<string name="allow_full_screen_portrait">Allow full-screen portrait when watching horizontal videos</string> <string name="allow_full_screen_portrait">Allow full-screen portrait when watching horizontal videos</string>
<string name="delete_watchlist_on_finish">Delete from WatchLater when watched</string> <string name="delete_watchlist_on_finish">Delete from WatchLater when watched</string>
<string name="delete_watchlist_on_finish_description">After you leave a video that you mostly watched, it will be removed from watch later.</string> <string name="delete_watchlist_on_finish_description">After you leave a video that you mostly watched, it will be removed from watch later.</string>
<string name="shorts_pregenerate">Pre-generate shorts sources</string>
<string name="shorts_pregenerate_description">Generates short sources (when applicable) one video ahead</string>
<string name="shorts_fit_video">Fit Shorts Video</string>
<string name="shorts_fit_video_description">Will scale the video to fit the view, instead of filling the view properly.</string>
<string name="shorts_fit_video_warning">This setting will require you to reboot Grayjay.</string>
<string name="seek_offset">Seek duration</string> <string name="seek_offset">Seek duration</string>
<string name="min_playback_speed">Minimum Playback Speed</string> <string name="min_playback_speed">Minimum Playback Speed</string>
<string name="min_playback_speed_description">Minimum Available Speed</string> <string name="min_playback_speed_description">Minimum Available Speed</string>
@@ -470,6 +480,7 @@
<string name="number_of_concurrent_threads_to_multiply_download_speeds_from_throttled_sources">Number of concurrent threads to multiply download speeds from throttled sources</string> <string name="number_of_concurrent_threads_to_multiply_download_speeds_from_throttled_sources">Number of concurrent threads to multiply download speeds from throttled sources</string>
<string name="payment">Payment</string> <string name="payment">Payment</string>
<string name="payment_status">Payment Status</string> <string name="payment_status">Payment Status</string>
<string name="relay_server">Sync Relay Server</string>
<string name="bypass_rotation_prevention">Bypass Rotation Prevention</string> <string name="bypass_rotation_prevention">Bypass Rotation Prevention</string>
<string name="playlist_delete_confirmation">Playlist Delete Confirmation</string> <string name="playlist_delete_confirmation">Playlist Delete Confirmation</string>
<string name="playlist_delete_confirmation_description">Show confirmation dialog when deleting media from a playlist</string> <string name="playlist_delete_confirmation_description">Show confirmation dialog when deleting media from a playlist</string>
@@ -1061,6 +1072,8 @@
<item>Chinese (ZH)</item> <item>Chinese (ZH)</item>
<item>Russian (RU)</item> <item>Russian (RU)</item>
<item>Arabic (AR)</item> <item>Arabic (AR)</item>
<item>Italian (IT)</item>
<item>Turkish (TR)</item>
</string-array> </string-array>
<string-array name="player_background_behavior"> <string-array name="player_background_behavior">
<item>None</item> <item>None</item>
@@ -1105,6 +1118,10 @@
<item>ChromeCast</item> <item>ChromeCast</item>
<item>AirPlay</item> <item>AirPlay</item>
</string-array> </string-array>
<string-array name="exp_casting_device_type_array" translatable="false">
<item>FCast</item>
<item>ChromeCast</item>
</string-array>
<string-array name="log_levels"> <string-array name="log_levels">
<item>None</item> <item>None</item>
<item>Error</item> <item>Error</item>
+2 -1
View File
@@ -16,7 +16,8 @@
"89ae4889-0420-4d16-ad6c-19c776b28f99": "sources/apple-podcasts/ApplePodcastsConfig.json", "89ae4889-0420-4d16-ad6c-19c776b28f99": "sources/apple-podcasts/ApplePodcastsConfig.json",
"8d029a7f-5507-4e36-8bd8-c19a3b77d383": "sources/tedtalks/TedTalksConfig.json", "8d029a7f-5507-4e36-8bd8-c19a3b77d383": "sources/tedtalks/TedTalksConfig.json",
"273b6523-5438-44e2-9f5d-78e0325a8fd9": "sources/curiositystream/CuriosityStreamConfig.json", "273b6523-5438-44e2-9f5d-78e0325a8fd9": "sources/curiositystream/CuriosityStreamConfig.json",
"9bb33039-8580-48d4-9849-21319ae845a4": "sources/crunchyroll/CrunchyrollConfig.json" "9bb33039-8580-48d4-9849-21319ae845a4": "sources/crunchyroll/CrunchyrollConfig.json",
"84331338-b045-419c-88e4-c86036f4cbf5": "sources/mixcloud/MixcloudConfig.json"
}, },
"SOURCES_EMBEDDED_DEFAULT": [ "SOURCES_EMBEDDED_DEFAULT": [
"35ae969a-a7db-11ed-afa1-0242ac120002" "35ae969a-a7db-11ed-afa1-0242ac120002"
+10
View File
@@ -8,6 +8,16 @@
<receiver android:name=".receivers.InstallReceiver" /> <receiver android:name=".receivers.InstallReceiver" />
<activity android:name=".activities.MainActivity" android:launchMode="singleInstance"> <activity android:name=".activities.MainActivity" android:launchMode="singleInstance">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<category android:name="android.intent.category.OPENABLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="video/*" />
<data android:mimeType="audio/*" />
</intent-filter>
<intent-filter android:autoVerify="true"> <intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH" /> <action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH" />
+2 -1
View File
@@ -16,7 +16,8 @@
"89ae4889-0420-4d16-ad6c-19c776b28f99": "sources/apple-podcasts/ApplePodcastsConfig.json", "89ae4889-0420-4d16-ad6c-19c776b28f99": "sources/apple-podcasts/ApplePodcastsConfig.json",
"8d029a7f-5507-4e36-8bd8-c19a3b77d383": "sources/tedtalks/TedTalksConfig.json", "8d029a7f-5507-4e36-8bd8-c19a3b77d383": "sources/tedtalks/TedTalksConfig.json",
"273b6523-5438-44e2-9f5d-78e0325a8fd9": "sources/curiositystream/CuriosityStreamConfig.json", "273b6523-5438-44e2-9f5d-78e0325a8fd9": "sources/curiositystream/CuriosityStreamConfig.json",
"9bb33039-8580-48d4-9849-21319ae845a4": "sources/crunchyroll/CrunchyrollConfig.json" "9bb33039-8580-48d4-9849-21319ae845a4": "sources/crunchyroll/CrunchyrollConfig.json",
"84331338-b045-419c-88e4-c86036f4cbf5": "sources/mixcloud/MixcloudConfig.json"
}, },
"SOURCES_EMBEDDED_DEFAULT": [ "SOURCES_EMBEDDED_DEFAULT": [
"35ae969a-a7db-11ed-afa1-0242ac120002" "35ae969a-a7db-11ed-afa1-0242ac120002"