Compare commits

..

54 Commits

Author SHA1 Message Date
Koen J aac19aef86 Long press moved to layout comment. 2025-11-06 14:26:11 +01:00
Koen J 33efc5c21d Upgraded all dependencies and changed double tap to long press on comment text. 2025-11-06 14:25:27 +01:00
Koen J fc7001c295 Added double click to copy button on comments. 2025-11-06 11:29:24 +01:00
Koen J 9b68394f70 Added setting for persisting subtitles across multiple videos when the same language exists. 2025-11-05 15:02:33 +01:00
Koen J e2ef8c2593 Shorts player keep screen on interaction. 2025-11-05 12:18:47 +01:00
Koen J 551bfe44ac Loader game visible now allows going into pip automatically. 2025-11-05 09:04:01 +01:00
Koen J 6fbfa98ad3 Made the resume more persistent and not visible when loader game is visible. 2025-11-03 17:58:03 +01:00
Koen 7d19c2357c Merge branch 'aw/polycentric-moderation' into 'master'
Polycentric Moderation

See merge request videostreaming/grayjay!151
2025-10-16 11:13:27 +00:00
austin 64030a038c Polycentric Moderation 2025-10-16 11:13:27 +00:00
Koen 9d9ad52535 Merge branch 'marcus/exp-casting-device-pinning-fix' into 'master'
casting(experimental): ignore devices that are unsupported or fails to parse

See merge request videostreaming/grayjay!150
2025-10-14 07:10:45 +00:00
Marcus Hanestad b10cf6a323 casting(experimental): ignore devices that are unsupported or fails to parse 2025-10-09 09:41:32 +02:00
Koen J 4407e82d8a Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-10-08 16:39:14 +02:00
Koen J 3113dc53a6 fix: Session not authorized showing when it shouldn't 2025-10-08 16:38:10 +02:00
Koen bd25276720 Merge branch 'zvonimir-dev' into 'master'
fix: Session not authorized showing when it shouldn't

See merge request videostreaming/grayjay!149
2025-10-08 13:28:38 +00:00
z2rec 29d3a9986e fix: Session not authorized showing when it shouldn't 2025-10-08 15:27:34 +02:00
Koen J bce93b8e0f Changed intent codes for pip to match with intent codes from notification. 2025-10-07 16:21:37 +02:00
Koen 9a950958f9 Merge branch 'possible-ui-fixes' into 'master'
Possible ui fixes

See merge request videostreaming/grayjay!148
2025-10-06 17:40:35 +00:00
Kelvin b6676e7763 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-10-06 19:14:27 +02:00
Kelvin 35fe093e5c Convert promise cancel exceptions to conventional exceptions 2025-10-06 19:13:52 +02:00
Koen J 7cad4fbe07 Fixed crash in TextView drag drop. 2025-10-06 12:58:10 +02:00
Koen J 240772790d Possible fixes for other activities. 2025-10-06 11:51:04 +02:00
Koen J d659ecc518 Possible fixes for DownloadService issues. 2025-10-06 11:00:47 +02:00
Koen J 7d8bb20b71 Possible fixes for DownloadService issues. 2025-10-06 11:00:36 +02:00
Koen J 1cf5f776d5 Trial 1 2025-10-03 19:22:46 +02:00
Koen J 137ba85538 Sync pairing will now always happen in parallel for direct and relayed and reduced amount of occupied threads. 2025-10-03 14:11:48 +02:00
Kelvin 642d218c54 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-09-29 18:45:56 +02:00
Kelvin 26b5470200 Fix crash fix on async promise handling 2025-09-29 18:45:42 +02:00
Koen J 547fe7bc13 Updated target SDK. 2025-09-29 15:46:45 +02:00
Koen J 678305e366 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-09-29 15:28:31 +02:00
Koen J 9f07673d85 Updated compile SDK. 2025-09-29 15:27:51 +02:00
Kelvin 19429263a9 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-09-29 15:01:19 +02:00
Kelvin 986652adab Refs 2025-09-29 15:01:04 +02:00
Koen J 4d93a58d5d Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-09-29 13:15:58 +02:00
Koen J 817c90f3af Translation fix. 2025-09-29 13:15:06 +02:00
Kelvin 77348b3787 Refs 2025-09-29 13:06:42 +02:00
Kelvin 31e26d03c6 Merge 2025-09-29 12:45:00 +02:00
Kelvin 1ef566ab16 Async fixes, local file playback support 2025-09-29 12:31:17 +02:00
Koen J 7597f5136c Fix Android getting stuck. 2025-09-26 13:46:43 +02:00
Koen 9a2a70622f Merge branch 'marcus/fcast-casting-sdk' into 'master'
Experimental casting backend

See merge request videostreaming/grayjay!145
2025-09-10 15:26:16 +00:00
Marcus Hanestad 4fc33411fd Experimental casting backend 2025-09-10 15:26:16 +00:00
Kelvin a9bb900994 Change when plugins are disabled on reload and listing reloads 2025-09-08 19:00:41 +02:00
Koen J 8c1a18d8b4 Build fixes. 2025-08-27 09:58:54 +02:00
Koen J 14ae5f1572 Fixed translations to align. 2025-08-26 21:32:13 +02:00
koen-futo ed40994600 Merge pull request #2357 from 0xrxL/master
Italian localization
2025-08-26 21:14:14 +02:00
koen-futo 90e8c35b19 Merge pull request #2096 from alpqn/master
Added Turkish Translations
2025-08-26 21:13:47 +02:00
Kelvin 4d017ad357 Refs 2025-08-24 21:41:07 +02:00
Kelvin 2ca2a9db23 Workaround for global lifetime scope unavailable 2025-08-24 19:35:15 +02:00
quonverbat 940bed2cee Merge branch 'futo-org:master' into master 2025-07-12 22:12:21 +03:00
0xrxL 4eb20a1843 Typo 2025-06-14 15:29:49 +02:00
0xrxL 98c6378148 Fix spacing 2025-06-14 09:04:40 +02:00
0xrxL bb066a7a31 Added italian localization 2025-06-14 09:02:25 +02:00
0xrxL b5d3261f03 Add files via upload 2025-06-14 09:01:41 +02:00
quonverbat 755bebaecb Merge branch 'futo-org:master' into master 2025-05-30 00:07:27 +03:00
quonverbat 004e4be4d3 Added Turkish Translations 2025-04-02 00:32:10 +03:00
118 changed files with 5785 additions and 1348 deletions
+49 -45
View File
@@ -1,8 +1,8 @@
plugins { plugins {
id 'com.android.application' id 'com.android.application'
id 'org.jetbrains.kotlin.android' id 'org.jetbrains.kotlin.android'
id 'org.jetbrains.kotlin.plugin.serialization' version '1.9.21' id 'org.jetbrains.kotlin.plugin.serialization' version '2.2.21'
id 'org.ajoberstar.grgit' version '5.2.2' id 'org.ajoberstar.grgit' version '5.3.3'
id 'com.google.protobuf' id 'com.google.protobuf'
id 'kotlin-parcelize' id 'kotlin-parcelize'
id 'com.google.devtools.ksp' id 'com.google.devtools.ksp'
@@ -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 36
versionCode gitVersionCode versionCode gitVersionCode
versionName gitVersionName versionName gitVersionName
@@ -155,80 +155,84 @@ 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.8.0'
implementation 'com.google.android.material:material:1.12.0' implementation 'com.google.android.material:material:1.13.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.17.0'
implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.appcompat:appcompat:1.7.1'
implementation 'com.google.android.material:material:1.11.0' implementation 'androidx.constraintlayout:constraintlayout:2.2.1'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'androidx.documentfile:documentfile:1.1.0'
//Images //Images
annotationProcessor 'com.github.bumptech.glide:compiler:4.16.0' annotationProcessor 'com.github.bumptech.glide:compiler:5.0.5'
implementation 'com.github.bumptech.glide:glide:4.16.0' implementation 'com.github.bumptech.glide:glide:5.0.5'
//Async //Async
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2"
//HTTP //HTTP
implementation "com.squareup.okhttp3:okhttp:4.11.0" implementation "com.squareup.okhttp3:okhttp:5.3.0"
//JSON //JSON
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2" //Used for structured json implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0" //Used for structured json
implementation 'com.google.code.gson:gson:2.10.1' //Used for complex/anonymous cases like during development conversions (eg. V8RemoteObject) implementation 'com.google.code.gson:gson:2.13.2' //Used for complex/anonymous cases like during development conversions (eg. V8RemoteObject)
//JS //JS
implementation("com.caoccao.javet:javet-android:3.0.2") implementation 'com.caoccao.javet:javet-v8-android:5.0.1'
//implementation 'com.caoccao.javet:javet-v8-android:4.1.4' //Change after extensive testing the freezing edge cases are solved.
//Exoplayer //Exoplayer
implementation 'androidx.media3:media3-exoplayer:1.2.1' implementation 'androidx.media3:media3-exoplayer:1.8.0'
implementation 'androidx.media3:media3-exoplayer-dash:1.2.1' implementation 'androidx.media3:media3-exoplayer-dash:1.8.0'
implementation 'androidx.media3:media3-ui:1.2.1' implementation 'androidx.media3:media3-ui:1.8.0'
implementation 'androidx.media3:media3-exoplayer-hls:1.2.1' implementation 'androidx.media3:media3-exoplayer-hls:1.8.0'
implementation 'androidx.media3:media3-exoplayer-rtsp:1.2.1' implementation 'androidx.media3:media3-exoplayer-rtsp:1.8.0'
implementation 'androidx.media3:media3-exoplayer-smoothstreaming:1.2.1' implementation 'androidx.media3:media3-exoplayer-smoothstreaming:1.8.0'
implementation 'androidx.media3:media3-transformer:1.2.1' implementation 'androidx.media3:media3-transformer:1.8.0'
implementation 'androidx.navigation:navigation-fragment-ktx:2.7.6' implementation 'androidx.navigation:navigation-fragment-ktx:2.9.6'
implementation 'androidx.navigation:navigation-ui-ktx:2.7.6' implementation 'androidx.navigation:navigation-ui-ktx:2.9.6'
implementation 'androidx.media:media:1.7.0' implementation 'androidx.media:media:1.7.1'
//Other //Other
implementation 'org.jsoup:jsoup:1.15.3' implementation 'org.jsoup:jsoup:1.21.2'
implementation 'com.google.android.flexbox:flexbox:3.0.0' implementation 'com.google.android.flexbox:flexbox:3.0.0'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation fileTree(dir: 'aar', include: ['*.aar']) implementation fileTree(dir: 'aar', include: ['*.aar'])
implementation 'com.arthenica:smart-exception-java:0.2.1' implementation 'com.arthenica:smart-exception-java:0.2.1'
implementation 'org.jetbrains.kotlin:kotlin-reflect:1.9.0' implementation 'org.jetbrains.kotlin:kotlin-reflect:2.2.0'
implementation 'com.github.dhaval2404:imagepicker:2.1' implementation 'com.github.dhaval2404:imagepicker:2.1'
implementation 'com.google.zxing:core:3.4.1' implementation 'com.google.zxing:core:3.5.3'
implementation 'com.journeyapps:zxing-android-embedded:4.3.0' implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
implementation 'com.caverock:androidsvg-aar:1.4' implementation 'com.caverock:androidsvg-aar:1.4'
//Protobuf //Protobuf
implementation 'com.google.protobuf:protobuf-javalite:3.25.1' implementation 'com.google.protobuf:protobuf-javalite:4.33.0'
implementation 'com.polycentric.core:app:1.0' implementation 'com.polycentric.core:app:1.0'
implementation 'com.futo.futopay:app:1.0' implementation 'com.futo.futopay:app:1.0'
implementation 'androidx.work:work-runtime-ktx:2.9.0' implementation 'androidx.work:work-runtime-ktx:2.11.0'
implementation 'androidx.concurrent:concurrent-futures-ktx:1.1.0' implementation 'androidx.concurrent:concurrent-futures-ktx:1.3.0'
//Database //Database
implementation("androidx.room:room-runtime:2.6.1") implementation("androidx.room:room-runtime:2.8.3")
annotationProcessor("androidx.room:room-compiler:2.6.1") ksp("androidx.room:room-compiler:2.8.3")
ksp("androidx.room:room-compiler:2.6.1") implementation("androidx.room:room-ktx:2.8.3")
implementation("androidx.room:room-ktx:2.6.1")
//Payment //Payment
implementation 'com.stripe:stripe-android:20.35.1' implementation 'com.stripe:stripe-android:22.0.0'
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3' testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2'
testImplementation "org.jetbrains.kotlin:kotlin-test:1.8.22" testImplementation "org.jetbrains.kotlin:kotlin-test:2.0.21"
testImplementation "org.xmlunit:xmlunit-core:2.9.1" testImplementation "org.xmlunit:xmlunit-core:2.11.0"
testImplementation "org.mockito:mockito-core:5.4.0" testImplementation "org.mockito:mockito-core:5.20.0"
androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.ext:junit:1.3.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0'
//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'
}
} }
+24 -20
View File
@@ -153,30 +153,30 @@
</activity> </activity>
<activity <activity
android:name=".activities.TestActivity" android:name=".activities.TestActivity"
android:theme="@style/Theme.FutoVideo.NoActionBar" /> android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
<activity <activity
android:name=".activities.SettingsActivity" android:name=".activities.SettingsActivity"
android:theme="@style/Theme.FutoVideo.NoActionBar" /> android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
<activity <activity
android:name=".activities.DeveloperActivity" android:name=".activities.DeveloperActivity"
android:screenOrientation="sensorPortrait" android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" /> android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
<activity <activity
android:name=".activities.ExceptionActivity" android:name=".activities.ExceptionActivity"
android:screenOrientation="sensorPortrait" android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" /> android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
<activity <activity
android:name=".activities.CaptchaActivity" android:name=".activities.CaptchaActivity"
android:screenOrientation="sensorPortrait" android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" /> android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
<activity <activity
android:name=".activities.LoginActivity" android:name=".activities.LoginActivity"
android:screenOrientation="sensorPortrait" android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" /> android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
<activity <activity
android:name=".activities.AddSourceActivity" android:name=".activities.AddSourceActivity"
android:exported="true" android:exported="true"
android:theme="@style/Theme.FutoVideo.NoActionBar"> android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
@@ -189,54 +189,58 @@
<activity <activity
android:name=".activities.AddSourceOptionsActivity" android:name=".activities.AddSourceOptionsActivity"
android:screenOrientation="sensorPortrait" android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" /> android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
<activity <activity
android:name=".activities.PolycentricHomeActivity" android:name=".activities.PolycentricHomeActivity"
android:screenOrientation="sensorPortrait" android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" /> android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
<activity <activity
android:name=".activities.PolycentricBackupActivity" android:name=".activities.PolycentricBackupActivity"
android:screenOrientation="sensorPortrait" android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" /> android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
<activity <activity
android:name=".activities.PolycentricCreateProfileActivity" android:name=".activities.PolycentricCreateProfileActivity"
android:screenOrientation="sensorPortrait" android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" /> android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
<activity <activity
android:name=".activities.PolycentricProfileActivity" android:name=".activities.PolycentricProfileActivity"
android:screenOrientation="sensorPortrait" android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" /> android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
<activity <activity
android:name=".activities.PolycentricWhyActivity" android:name=".activities.PolycentricWhyActivity"
android:screenOrientation="sensorPortrait" android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" /> android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
<activity <activity
android:name=".activities.PolycentricImportProfileActivity" android:name=".activities.PolycentricImportProfileActivity"
android:screenOrientation="sensorPortrait" android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" /> android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
<activity <activity
android:name=".activities.ManageTabsActivity" android:name=".activities.ManageTabsActivity"
android:screenOrientation="sensorPortrait" android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" /> android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
<activity <activity
android:name=".activities.QRCaptureActivity" android:name=".activities.QRCaptureActivity"
android:screenOrientation="sensorPortrait" android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" /> android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
<activity <activity
android:name=".activities.FCastGuideActivity" android:name=".activities.FCastGuideActivity"
android:screenOrientation="sensorPortrait" android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" /> android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
<activity <activity
android:name=".activities.SyncHomeActivity" android:name=".activities.SyncHomeActivity"
android:screenOrientation="sensorPortrait" android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" /> android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
<activity <activity
android:name=".activities.SyncPairActivity" android:name=".activities.SyncPairActivity"
android:screenOrientation="sensorPortrait" android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" /> android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
<activity <activity
android:name=".activities.SyncShowPairingCodeActivity" android:name=".activities.SyncShowPairingCodeActivity"
android:screenOrientation="sensorPortrait" android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" /> android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
<activity
android:name=".activities.PolycentricModerationActivity"
android:exported="false"
android:screenOrientation="portrait" />
</application> </application>
</manifest> </manifest>
+1
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);
@@ -216,10 +216,9 @@ private fun ByteArray.toInetAddress(): InetAddress {
return InetAddress.getByAddress(this); return InetAddress.getByAddress(this);
} }
fun getConnectedSocket(attemptAddresses: List<InetAddress>, port: Int): Socket? { fun getConnectedSocket(attemptAddresses: List<InetAddress>, port: Int, timeoutMs: Int = 10_000): Socket? {
ensureNotMainThread() ensureNotMainThread()
val timeout = 10000
val addresses = if(!Settings.instance.casting.allowIpv6) attemptAddresses.filterIsInstance<Inet4Address>() else attemptAddresses; val addresses = if(!Settings.instance.casting.allowIpv6) attemptAddresses.filterIsInstance<Inet4Address>() else attemptAddresses;
if(addresses.isEmpty()) if(addresses.isEmpty())
throw IllegalStateException("No valid addresses found (ipv6: ${(if(Settings.instance.casting.allowIpv6) "enabled" else "disabled")})"); throw IllegalStateException("No valid addresses found (ipv6: ${(if(Settings.instance.casting.allowIpv6) "enabled" else "disabled")})");
@@ -232,7 +231,7 @@ fun getConnectedSocket(attemptAddresses: List<InetAddress>, port: Int): Socket?
val socket = Socket() val socket = Socket()
try { try {
return socket.apply { this.connect(InetSocketAddress(addresses[0], port), timeout) } return socket.apply { this.connect(InetSocketAddress(addresses[0], port), timeoutMs) }
} catch (e: Throwable) { } catch (e: Throwable) {
Log.i("getConnectedSocket", "Failed to connect to: ${addresses[0]}", e) Log.i("getConnectedSocket", "Failed to connect to: ${addresses[0]}", e)
socket.close() socket.close()
@@ -263,7 +262,7 @@ fun getConnectedSocket(attemptAddresses: List<InetAddress>, port: Int): Socket?
} }
} }
socket.connect(InetSocketAddress(address, port), timeout); socket.connect(InetSocketAddress(address, port), timeoutMs);
synchronized(syncObject) { synchronized(syncObject) {
if (connectedSocket == null) { if (connectedSocket == null) {
@@ -7,11 +7,13 @@ import com.caoccao.javet.values.reference.V8ValueArray
import com.caoccao.javet.values.reference.V8ValueError import com.caoccao.javet.values.reference.V8ValueError
import com.caoccao.javet.values.reference.V8ValueObject import com.caoccao.javet.values.reference.V8ValueObject
import com.caoccao.javet.values.reference.V8ValuePromise import com.caoccao.javet.values.reference.V8ValuePromise
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.engine.IV8PluginConfig import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.engine.V8Plugin import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.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 +23,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 +195,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 +204,19 @@ fun <T: V8Value> V8ValuePromise.toV8ValueBlocking(plugin: V8Plugin): T {
override fun onFulfilled(p0: V8Value?) { override fun onFulfilled(p0: V8Value?) {
if(p0 is V8ValueError) if(p0 is V8ValueError)
promiseException = ScriptExecutionException(plugin.config, p0.message); promiseException = ScriptExecutionException(plugin.config, p0.message);
else else {
if(p0 is V8ValueObject)
p0.setWeak();
promiseResult = p0 as T; promiseResult = p0 as T;
}
latch.countDown(); latch.countDown();
} }
override fun onRejected(p0: V8Value?) { override fun onRejected(p0: V8Value?) {
promiseException = (NotImplementedError("onRejected promise not implemented..")); promiseException = p0?.toException(plugin.config);
latch.countDown(); latch.countDown();
} }
override fun onCatch(p0: V8Value?) { override fun onCatch(p0: V8Value?) {
promiseException = (NotImplementedError("onCatch promise not implemented..")); promiseException = p0?.toException(plugin.config);
latch.countDown(); latch.countDown();
} }
}); });
@@ -223,8 +226,25 @@ fun <T: V8Value> V8ValuePromise.toV8ValueBlocking(plugin: V8Plugin): T {
promiseException = CancellationException("Cancelled by system"); promiseException = CancellationException("Cancelled by system");
latch.countDown(); latch.countDown();
} }
plugin.unbusy { //Logger.i("V8", "V8ValueBlocking started (Busy) [" + blockCount + "]" + Thread.currentThread().stackTrace.drop(3)?.firstOrNull()?.toString() + ", " + Thread.currentThread().stackTrace.drop(4)?.firstOrNull()?.toString()+ ", " + Thread.currentThread().stackTrace.drop(5)?.firstOrNull()?.toString());
latch.await();
if(!promise.isPending) {
try {
Logger.i("V8", "V8Promise resolved synchronously");
if(promise.isFulfilled)
promiseResult = promise.getResult<T>();
else
promiseException = promise.getResult<V8Value>().toException(plugin.config);
}
catch(ex: Throwable) {
promiseException = ex;
}
}
else {
plugin.unbusy {
latch.await();
}
} }
if(promiseException != null) if(promiseException != null)
throw promiseException!!; throw promiseException!!;
@@ -249,12 +269,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?) {
plugin.resolvePromise(promise); try {
underlyingDef.completeExceptionally(NotImplementedError("onRejected promise not implemented..")); plugin.resolvePromise(promise);
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?) {
plugin.resolvePromise(promise); try {
underlyingDef.completeExceptionally(NotImplementedError("onCatch promise not implemented..")); plugin.resolvePromise(promise);
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 +298,23 @@ 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) {
return V8Plugin.getExceptionFromPlugin(config, p0, null, null, null, "P:");
/*
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>{
@@ -325,4 +375,16 @@ fun V8ValueObject.invokeV8VoidAsync(method: String, vararg obj: Any?): V8Deferre
return result; return result;
} }
return V8Deferred(CompletableDeferred(result)); return V8Deferred(CompletableDeferred(result));
}
suspend fun <T> Deferred<T>.awaitCancelConverted(): T {
try {
return this.await();
}
catch(ex: CancellationException) {
if(ex.cause != null) {
throw ex.cause!!;
}
throw ex;
}
} }
@@ -0,0 +1,118 @@
package com.futo.platformplayer
import android.app.Activity
import android.graphics.Color
import android.os.Build
import android.view.ViewGroup
import android.view.Window
import android.view.WindowManager
import androidx.core.graphics.Insets
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsCompat.Type
import androidx.core.view.WindowInsetsControllerCompat
import androidx.core.view.doOnAttach
import androidx.core.view.updatePadding
import kotlin.math.max
class RootInsetsController private constructor(
private val activity: Activity,
private val window: Window,
private val root: ViewGroup
) {
private val controller by lazy { WindowInsetsControllerCompat(window, root) }
private val basePaddingLeft = root.paddingLeft
private val basePaddingTop = root.paddingTop
private val basePaddingRight = root.paddingRight
private val basePaddingBottom = root.paddingBottom
private var currentInsets: WindowInsetsCompat = WindowInsetsCompat.CONSUMED
private var fullscreen = false
init {
WindowCompat.setDecorFitsSystemWindows(window, false)
window.statusBarColor = Color.TRANSPARENT
window.navigationBarColor = Color.TRANSPARENT
controller.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
ViewCompat.setOnApplyWindowInsetsListener(root) { _, insets ->
currentInsets = insets
applyPadding()
insets
}
root.doOnAttach { ViewCompat.requestApplyInsets(root) }
}
private fun effectiveInsets(): Insets {
if (fullscreen) return Insets.NONE
val sys = currentInsets.getInsets(Type.systemBars())
val cut = currentInsets.getInsetsIgnoringVisibility(Type.displayCutout())
val portrait = activity.resources.configuration.orientation == android.content.res.Configuration.ORIENTATION_PORTRAIT
val top = if (portrait) max(sys.top, cut.top) else sys.top
return Insets.of(sys.left, top, sys.right, sys.bottom)
}
private fun applyPadding() {
val e = effectiveInsets()
root.updatePadding(
left = basePaddingLeft + e.left,
top = basePaddingTop + e.top,
right = basePaddingRight + e.right,
bottom = basePaddingBottom + e.bottom
)
}
private fun forceRelayoutAndInsets() {
root.post {
ViewCompat.requestApplyInsets(root)
applyPadding()
root.post {
ViewCompat.requestApplyInsets(root)
applyPadding()
}
}
}
fun enterFullscreen(allowCutoutShortEdges: Boolean = true) {
fullscreen = true
if (allowCutoutShortEdges) {
window.attributes = window.attributes.apply {
layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
}
}
controller.hide(Type.systemBars())
forceRelayoutAndInsets()
}
fun exitFullscreen() {
fullscreen = false
window.attributes = window.attributes.apply {
layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT
}
controller.show(Type.systemBars())
forceRelayoutAndInsets()
}
fun onConfigurationChanged() {
forceRelayoutAndInsets()
}
fun setLightSystemBarAppearance(lightStatus: Boolean, lightNav: Boolean) {
controller.isAppearanceLightStatusBars = lightStatus
controller.isAppearanceLightNavigationBars = lightNav
}
companion object {
fun attach(activity: Activity, root: ViewGroup): RootInsetsController {
return RootInsetsController(activity, activity.window, root)
}
}
}
@@ -203,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
} }
} }
@@ -406,6 +408,10 @@ class Settings : FragmentedStorageFileJson() {
else -> null else -> null
} }
} }
@FormField(R.string.sticky_subtitles, FieldForm.TOGGLE, R.string.sticky_subtitles_description, -1)
var stickySubtitles: Boolean = true;
@FormField(R.string.prefer_original_audio, FieldForm.TOGGLE, R.string.prefer_original_audio_description, -1) @FormField(R.string.prefer_original_audio, FieldForm.TOGGLE, R.string.prefer_original_audio_description, -1)
var preferOriginalAudio: Boolean = true; var preferOriginalAudio: Boolean = true;
@@ -717,6 +723,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)
@@ -1106,6 +1117,7 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.relay_server, FieldForm.READONLYTEXT, -1, 6) @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; 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) @FormField(R.string.configure_sync_server, FieldForm.BUTTON, R.string.configure_sync_server_description, 7)
fun configureSyncServer() { fun configureSyncServer() {
SettingsActivity.getActivity()?.let { context -> SettingsActivity.getActivity()?.let { context ->
@@ -107,10 +107,9 @@ class AddSourceActivity : AppCompatActivity() {
onNewIntent(intent); onNewIntent(intent);
} }
override fun onNewIntent(intent: Intent?) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
var url = intent?.dataString; var url = intent.dataString;
if(url == null) if(url == null)
UIDialogs.showDialog(this, R.drawable.ic_error, getString(R.string.no_valid_url_provided), null, null, UIDialogs.showDialog(this, R.drawable.ic_error, getString(R.string.no_valid_url_provided), null, null,
0, UIDialogs.Action(getString(R.string.ok), { finish() }, UIDialogs.ActionStyle.PRIMARY)); 0, UIDialogs.Action(getString(R.string.ok), { finish() }, UIDialogs.ActionStyle.PRIMARY));
@@ -16,7 +16,6 @@ import android.os.StrictMode.VmPolicy
import android.util.Log import android.util.Log
import android.util.TypedValue import android.util.TypedValue
import android.view.View import android.view.View
import android.view.WindowManager
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.ImageView import android.widget.ImageView
import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResult
@@ -36,9 +35,11 @@ import androidx.lifecycle.withStateAtLeast
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import com.futo.platformplayer.BuildConfig import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.RootInsetsController
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
@@ -198,6 +199,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
private var _privateModeEnabled = false private var _privateModeEnabled = false
private var _pictureInPictureEnabled = false private var _pictureInPictureEnabled = false
private var _isFullscreen = false private var _isFullscreen = false
private lateinit var _rootInsetsController: RootInsetsController
private val _urlQrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> private val _urlQrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data) val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data)
@@ -283,9 +285,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
} }
setContentView(R.layout.activity_main); setContentView(R.layout.activity_main);
setNavigationBarColorAndIcons(); setNavigationBarColorAndIcons();
if (Settings.instance.playback.allowVideoToGoUnderCutout)
window.attributes.layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
runBlocking { runBlocking {
try { try {
@@ -300,6 +299,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
FragmentedStorage.get<Settings>(); FragmentedStorage.get<Settings>();
rootView = findViewById(R.id.rootView); rootView = findViewById(R.id.rootView);
_rootInsetsController = RootInsetsController.attach(this, rootView)
_rootInsetsController.setLightSystemBarAppearance(lightStatus = false, lightNav = false)
_fragContainerTopBar = findViewById(R.id.fragment_top_bar); _fragContainerTopBar = findViewById(R.id.fragment_top_bar);
_fragContainerMain = findViewById(R.id.fragment_main); _fragContainerMain = findViewById(R.id.fragment_main);
_fragContainerBotBar = findViewById(R.id.fragment_bottom_bar); _fragContainerBotBar = findViewById(R.id.fragment_bottom_bar);
@@ -410,6 +412,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
Logger.i(TAG, "onFullscreenChanged ${it}"); Logger.i(TAG, "onFullscreenChanged ${it}");
_isFullscreen = it _isFullscreen = it
updatePrivateModeVisibility() updatePrivateModeVisibility()
if (it) {
_rootInsetsController.enterFullscreen(allowCutoutShortEdges = Settings.instance.playback.allowVideoToGoUnderCutout)
} else {
_rootInsetsController.exitFullscreen()
}
} }
_fragVideoDetail.onMinimize.subscribe { _fragVideoDetail.onMinimize.subscribe {
@@ -638,6 +645,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
private var _qrCodeLoadingDialog: AlertDialog? = null private var _qrCodeLoadingDialog: AlertDialog? = null
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
_rootInsetsController.onConfigurationChanged()
}
fun showUrlQrCodeScanner() { fun showUrlQrCodeScanner() {
try { try {
_qrCodeLoadingDialog = UIDialogs.showDialog(this, R.drawable.ic_loader_animated, true, _qrCodeLoadingDialog = UIDialogs.showDialog(this, R.drawable.ic_loader_animated, true,
@@ -696,17 +708,13 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_wasStopped = true; _wasStopped = true;
} }
override fun onNewIntent(intent: Intent?) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent); super.onNewIntent(intent);
handleIntent(intent); handleIntent(intent);
} }
private fun handleIntent(intent: Intent?) { private fun handleIntent(intent: Intent) {
if (intent == null)
return;
Logger.i(TAG, "handleIntent started by " + intent.action); Logger.i(TAG, "handleIntent started by " + intent.action);
var targetData: String? = null; var targetData: String? = null;
when (intent.action) { when (intent.action) {
@@ -768,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)
} }
@@ -779,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/")) {
@@ -807,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",
{ }); { });
} }
@@ -932,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;
} }
@@ -1046,7 +1061,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)
@@ -0,0 +1,147 @@
package com.futo.platformplayer.activities
import android.content.Context
import android.os.Bundle
import android.widget.ImageButton
import android.widget.SeekBar
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import com.futo.platformplayer.polycentric.ModerationsManager
import com.futo.platformplayer.R
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.setNavigationBarColorAndIcons
class PolycentricModerationActivity : AppCompatActivity() {
private lateinit var _seekbarOffensive: SeekBar
private lateinit var _seekbarExplicit: SeekBar
private lateinit var _seekbarViolence: SeekBar
private lateinit var _textOffensiveDesc: TextView
private lateinit var _textExplicitDesc: TextView
private lateinit var _textViolenceDesc: TextView
private lateinit var _textOffensiveValue: TextView
private lateinit var _textExplicitValue: TextView
private lateinit var _textViolenceValue: TextView
private lateinit var _moderationsManager: ModerationsManager
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_polycentric_moderation)
setNavigationBarColorAndIcons()
_moderationsManager = ModerationsManager.getInstance()
try {
_moderationsManager = ModerationsManager.getInstance()
} catch (e: IllegalStateException) {
finish()
return
}
_seekbarOffensive = findViewById(R.id.seekbar_offensive)
_seekbarExplicit = findViewById(R.id.seekbar_explicit)
_seekbarViolence = findViewById(R.id.seekbar_violence)
_textOffensiveDesc = findViewById(R.id.text_offensive_desc)
_textExplicitDesc = findViewById(R.id.text_explicit_desc)
_textViolenceDesc = findViewById(R.id.text_violence_desc)
_textOffensiveValue = findViewById(R.id.text_offensive_value)
_textExplicitValue = findViewById(R.id.text_explicit_value)
_textViolenceValue = findViewById(R.id.text_violence_value)
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
finish()
}
loadSettings()
setupListeners()
}
private fun loadSettings() {
val levels = _moderationsManager.moderationLevels.value ?: mapOf()
val offensiveLevel = levels["hate"] ?: 2
val explicitLevel = levels["sexual"] ?: 1
val violenceLevel = levels["violence"] ?: 1
_seekbarOffensive.progress = offensiveLevel
_seekbarExplicit.progress = explicitLevel
_seekbarViolence.progress = violenceLevel
updateDescriptionText(_seekbarOffensive, _textOffensiveDesc, _textOffensiveValue, getOffensiveDescriptions())
updateDescriptionText(_seekbarExplicit, _textExplicitDesc, _textExplicitValue, getExplicitDescriptions())
updateDescriptionText(_seekbarViolence, _textViolenceDesc, _textViolenceValue, getViolenceDescriptions())
}
private fun setupListeners() {
_seekbarOffensive.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
updateDescriptionText(seekBar, _textOffensiveDesc, _textOffensiveValue, getOffensiveDescriptions())
if (fromUser) {
_moderationsManager.setModerationLevel("hate", progress)
}
}
override fun onStartTrackingTouch(seekBar: SeekBar?) {}
override fun onStopTrackingTouch(seekBar: SeekBar?) {}
})
_seekbarExplicit.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
updateDescriptionText(seekBar, _textExplicitDesc, _textExplicitValue, getExplicitDescriptions())
if (fromUser) {
_moderationsManager.setModerationLevel("sexual", progress)
}
}
override fun onStartTrackingTouch(seekBar: SeekBar?) {}
override fun onStopTrackingTouch(seekBar: SeekBar?) {}
})
_seekbarViolence.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
updateDescriptionText(seekBar, _textViolenceDesc, _textViolenceValue, getViolenceDescriptions())
if (fromUser) {
_moderationsManager.setModerationLevel("violence", progress)
}
}
override fun onStartTrackingTouch(seekBar: SeekBar?) {}
override fun onStopTrackingTouch(seekBar: SeekBar?) {}
})
}
private fun updateDescriptionText(seekBar: SeekBar?, textDesc: TextView, textValue: TextView, descriptions: Array<String>) {
val progress = seekBar?.progress ?: 0
textDesc.text = descriptions[progress]
textValue.text = progress.toString()
}
private fun getOffensiveDescriptions(): Array<String> {
return arrayOf(
"Neutral, general terms, no bias or hate.",
"Mildly sensitive, factual.",
"Potentially offensive content",
"Offensive content"
)
}
private fun getExplicitDescriptions(): Array<String> {
return arrayOf(
"No explicit content",
"Mildly suggestive, factual or educational",
"Moderate sexual content, non-graphic",
"Explicit sexual content"
)
}
private fun getViolenceDescriptions(): Array<String> {
return arrayOf(
"Non-violent",
"Mild violence, factual or contextual",
"Moderate violence, some graphic content.",
"Graphic violence"
)
}
}
@@ -49,7 +49,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
private lateinit var _buttonHelp: ImageButton; private lateinit var _buttonHelp: ImageButton;
private lateinit var _editName: EditText; private lateinit var _editName: EditText;
private lateinit var _buttonExport: BigButton; private lateinit var _buttonExport: BigButton;
private lateinit var _buttonOpenHarborProfile: BigButton; private lateinit var _buttonModeration: BigButton;
private lateinit var _buttonLogout: BigButton; private lateinit var _buttonLogout: BigButton;
private lateinit var _buttonDelete: BigButton; private lateinit var _buttonDelete: BigButton;
private lateinit var _username: String; private lateinit var _username: String;
@@ -71,7 +71,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
_imagePolycentric = findViewById(R.id.image_polycentric); _imagePolycentric = findViewById(R.id.image_polycentric);
_editName = findViewById(R.id.edit_profile_name); _editName = findViewById(R.id.edit_profile_name);
_buttonExport = findViewById(R.id.button_export); _buttonExport = findViewById(R.id.button_export);
_buttonOpenHarborProfile = findViewById(R.id.button_open_harbor_profile); _buttonModeration = findViewById(R.id.button_moderation);
_buttonLogout = findViewById(R.id.button_logout); _buttonLogout = findViewById(R.id.button_logout);
_buttonDelete = findViewById(R.id.button_delete); _buttonDelete = findViewById(R.id.button_delete);
_loaderOverlay = findViewById(R.id.loader_overlay); _loaderOverlay = findViewById(R.id.loader_overlay);
@@ -99,15 +99,9 @@ class PolycentricProfileActivity : AppCompatActivity() {
startActivity(Intent(this, PolycentricBackupActivity::class.java)); startActivity(Intent(this, PolycentricBackupActivity::class.java));
}; };
_buttonOpenHarborProfile.onClick.subscribe { _buttonModeration.onClick.subscribe {
val processHandle = StatePolycentric.instance.processHandle!!; startActivity(Intent(this, PolycentricModerationActivity::class.java));
processHandle?.let { };
val systemState = SystemState.fromStorageTypeSystemState(Store.instance.getSystemState(it.system));
val url = it.system.systemToURLInfoSystemLinkUrl(systemState.servers.asIterable());
val navUrl = "https://harbor.social/" + url.substring("polycentric://".length)
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(navUrl)))
}
}
_buttonLogout.onClick.subscribe { _buttonLogout.onClick.subscribe {
StatePolycentric.instance.setProcessHandle(null); StatePolycentric.instance.setProcessHandle(null);
@@ -110,7 +110,19 @@ class SyncPairActivity : AppCompatActivity() {
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
try { try {
StateSync.instance.syncService?.connect(deviceInfo) { complete, message -> var wasCompleted = false
StateSync.instance.syncService?.connect(deviceInfo, true) { complete, message ->
if (wasCompleted) {
Logger.i(TAG, "onStatusUpdate(complete = ${complete}, message = '${message} ignored because wasCompleted')")
return@connect
}
if (complete == true) {
wasCompleted = true
}
Logger.i(TAG, "onStatusUpdate(complete = ${complete}, message = '${message}')")
lifecycleScope.launch(Dispatchers.Main) { lifecycleScope.launch(Dispatchers.Main) {
if (complete != null) { if (complete != null) {
if (complete) { if (complete) {
@@ -54,14 +54,16 @@ interface IPlatformChannelContent : IPlatformContent {
val subscribers: Long? val subscribers: Long?
} }
open class JSChannelContent : JSContent, IPlatformChannelContent { open class JSChannelContent(
override val contentType: ContentType get() = ContentType.CHANNEL config: SourcePluginConfig,
override val thumbnail: String? obj: V8ValueObject
override val subscribers: Long? ) : JSContent(config, obj), IPlatformChannelContent {
constructor(config: SourcePluginConfig, obj: V8ValueObject) : super(config, obj) { final override val contentType: ContentType = ContentType.CHANNEL
val contextName = "Channel";
thumbnail = obj.getOrDefault<String>(config, "thumbnail", contextName, null) override val thumbnail: String? =
subscribers = if(obj.has("subscribers")) obj.getOrThrow(config,"subscribers", contextName) else null _content.getOrDefault<String>(_pluginConfig, "thumbnail", "Channel", null)
}
} override val subscribers: Long? =
_content.getOrDefault<Long>(_pluginConfig, "subscribers", "Channel", null)?.toLong()
}
@@ -6,25 +6,15 @@ import com.futo.platformplayer.api.media.models.ratings.IRating
import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.IPager
import java.time.OffsetDateTime import java.time.OffsetDateTime
open class PlatformComment : IPlatformComment { open class PlatformComment(
override val contextUrl: String; override val contextUrl: String,
override val author: PlatformAuthorLink; override val author: PlatformAuthorLink,
override val message: String; override val message: String,
override val rating: IRating; override val rating: IRating,
override val date: OffsetDateTime; override val date: OffsetDateTime,
override val replyCount: Int? = null
) : IPlatformComment {
override val replyCount: Int?; override fun getReplies(client: IPlatformClient): IPager<IPlatformComment> =
NoCommentsPager()
constructor(contextUrl: String, author: PlatformAuthorLink, msg: String, rating: IRating, date: OffsetDateTime, replyCount: Int? = null) { }
this.contextUrl = contextUrl;
this.author = author;
this.message = msg;
this.rating = rating;
this.date = date;
this.replyCount = replyCount;
}
override fun getReplies(client: IPlatformClient): IPager<IPlatformComment> {
return NoCommentsPager();
}
}
@@ -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;
@@ -41,6 +41,7 @@ class HLSVariantSubtitleUrlSource(
override val format: String, override val format: String,
) : ISubtitleSource { ) : ISubtitleSource {
override val hasFetch: Boolean = false override val hasFetch: Boolean = false
override val language: String? = null
override fun getSubtitles(): String? { override fun getSubtitles(): String? {
return null return null
@@ -9,13 +9,15 @@ class LocalSubtitleSource : ISubtitleSource {
override val name: String; override val name: String;
override val url: String?; override val url: String?;
override val format: String?; override val format: String?;
override val language: String?
override val hasFetch: Boolean get() = false; override val hasFetch: Boolean get() = false;
val filePath: String; val filePath: String;
constructor(name: String, format: String?, filePath: String) { constructor(name: String, language: String?, format: String?, filePath: String) {
this.name = name; this.name = name;
this.format = format; this.format = format;
this.language = language
this.filePath = filePath; this.filePath = filePath;
this.url = Uri.fromFile(File(filePath)).toString(); this.url = Uri.fromFile(File(filePath)).toString();
} }
@@ -32,6 +34,7 @@ class LocalSubtitleSource : ISubtitleSource {
fun fromSource(source: SubtitleRawSource, path: String): LocalSubtitleSource { fun fromSource(source: SubtitleRawSource, path: String): LocalSubtitleSource {
return LocalSubtitleSource( return LocalSubtitleSource(
source.name, source.name,
source.language,
source.format, source.format,
path path
); );
@@ -6,6 +6,7 @@ import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
@kotlinx.serialization.Serializable @kotlinx.serialization.Serializable
class SubtitleRawSource( class SubtitleRawSource(
override val name: String, override val name: String,
override val language: String?,
override val format: String?, override val format: String?,
val _subtitles: String, val _subtitles: String,
override val url: String? = null, override val url: String? = 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;
@@ -7,6 +7,7 @@ interface ISubtitleSource {
val url: String?; val url: String?;
val format: String?; val format: String?;
val hasFetch: Boolean; val hasFetch: Boolean;
val language: String?
fun getSubtitles(): String?; fun getSubtitles(): String?;
@@ -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;
}
}
}
@@ -103,7 +103,7 @@ open class JSClient : IPlatformClient {
override val id: String get() = config.id; override val id: String get() = config.id;
override val name: String get() = config.name; override val name: String get() = config.name;
override val icon: ImageVariable; override val icon: ImageVariable get() = StatePlatform.instance.getPlatformIcon(config.id) ?: ImageVariable(config.absoluteIconUrl, null, null)
override var capabilities: PlatformClientCapabilities = PlatformClientCapabilities(); override var capabilities: PlatformClientCapabilities = PlatformClientCapabilities();
private var _busyAction = ""; private var _busyAction = "";
@@ -147,7 +147,6 @@ open class JSClient : IPlatformClient {
constructor(context: Context, descriptor: SourcePluginDescriptor, saveState: String? = null) { constructor(context: Context, descriptor: SourcePluginDescriptor, saveState: String? = null) {
this._context = context; this._context = context;
this.config = descriptor.config; this.config = descriptor.config;
icon = StatePlatform.instance.getPlatformIcon(config.id) ?: ImageVariable(config.absoluteIconUrl, null, null);
this.descriptor = descriptor; this.descriptor = descriptor;
_injectedSaveState = saveState; _injectedSaveState = saveState;
_auth = descriptor.getAuth(); _auth = descriptor.getAuth();
@@ -178,7 +177,6 @@ open class JSClient : IPlatformClient {
constructor(context: Context, descriptor: SourcePluginDescriptor, saveState: String?, script: String, withoutCredentials: Boolean = false) { constructor(context: Context, descriptor: SourcePluginDescriptor, saveState: String?, script: String, withoutCredentials: Boolean = false) {
this._context = context; this._context = context;
this.config = descriptor.config; this.config = descriptor.config;
icon = StatePlatform.instance.getPlatformIcon(config.id) ?: ImageVariable(config.absoluteIconUrl, null, null);
this.descriptor = descriptor; this.descriptor = descriptor;
_injectedSaveState = saveState; _injectedSaveState = saveState;
if(!withoutCredentials) if(!withoutCredentials)
@@ -23,17 +23,22 @@ import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.getOrThrowNullableList import com.futo.platformplayer.getOrThrowNullableList
import com.futo.platformplayer.states.StateDeveloper import com.futo.platformplayer.states.StateDeveloper
open class JSArticle : JSContent, IPlatformArticle, IPluginSourced { open class JSArticle(
final override val contentType: ContentType get() = ContentType.ARTICLE; config: SourcePluginConfig,
obj: V8ValueObject
) : JSContent(config, obj), IPlatformArticle, IPluginSourced {
override val summary: String; final override val contentType: ContentType = ContentType.ARTICLE
override val thumbnails: Thumbnails?;
constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) { override val summary: String =
val contextName = "PlatformArticle"; obj.getOrDefault<String>(config, "summary", "PlatformArticle", "") ?: ""
summary = _content.getOrDefault(config, "summary", contextName, "") ?: ""; override val thumbnails: Thumbnails? =
thumbnails = Thumbnails.fromV8(config, _content.getOrThrow(config, "thumbnails", contextName)); if (obj.has("thumbnails"))
Thumbnails.fromV8(
} config,
} obj.getOrThrow<V8ValueObject>(config, "thumbnails", "PlatformArticle")
)
else
null
}
@@ -24,36 +24,37 @@ import com.futo.platformplayer.getOrThrowNullableList
import com.futo.platformplayer.invokeV8 import com.futo.platformplayer.invokeV8
import com.futo.platformplayer.states.StateDeveloper import com.futo.platformplayer.states.StateDeveloper
open class JSArticleDetails : JSContent, IPlatformArticleDetails, IPluginSourced, IPlatformContentDetails { open class JSArticleDetails(
final override val contentType: ContentType get() = ContentType.ARTICLE; private val client: JSClient,
obj: V8ValueObject
) : JSContent(client.config, obj), IPlatformArticleDetails, IPluginSourced, IPlatformContentDetails {
private val _hasGetComments: Boolean; final override val contentType: ContentType = ContentType.ARTICLE
private val _hasGetContentRecommendations: Boolean;
override val rating: IRating; private val _hasGetComments: Boolean = _content.has("getComments")
private val _hasGetContentRecommendations: Boolean = _content.has("getContentRecommendations")
override val summary: String; override val rating: IRating =
override val thumbnails: Thumbnails?; obj.getOrDefault<V8ValueObject>(client.config, "rating", "PlatformArticle", null)
override val segments: List<IJSArticleSegment>; ?.let { IRating.fromV8(client.config, it, "PlatformArticle") }
?: RatingLikes(0)
constructor(client: JSClient, obj: V8ValueObject): super(client.config, obj) { override val summary: String =
val contextName = "PlatformArticle"; _content.getOrThrow(client.config, "summary", "PlatformArticle")
rating = obj.getOrDefault<V8ValueObject>(client.config, "rating", contextName, null)?.let { IRating.fromV8(client.config, it, contextName) } ?: RatingLikes(0); override val thumbnails: Thumbnails? =
summary = _content.getOrThrow(client.config, "summary", contextName); if (_content.has("thumbnails"))
if(_content.has("thumbnails")) Thumbnails.fromV8(
thumbnails = Thumbnails.fromV8(client.config, _content.getOrThrow(client.config, "thumbnails", contextName)); client.config,
_content.getOrThrow(client.config, "thumbnails", "PlatformArticle")
)
else else
thumbnails = null; null
override val segments: List<IJSArticleSegment> =
segments = (obj.getOrThrowNullableList<V8ValueObject>(client.config, "segments", contextName) obj.getOrThrowNullableList<V8ValueObject>(client.config, "segments", "PlatformArticle")
?.map { fromV8Segment(client, it) } ?.mapNotNull { fromV8Segment(client, it) }
?.filterNotNull() ?: listOf()); ?: emptyList()
_hasGetComments = _content.has("getComments");
_hasGetContentRecommendations = _content.has("getContentRecommendations");
}
override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? { override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? {
if(!_hasGetComments || _content.isClosed) if(!_hasGetComments || _content.isClosed)
@@ -16,51 +16,49 @@ import java.time.LocalDateTime
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.time.ZoneOffset import java.time.ZoneOffset
open class JSContent : IPlatformContent, IPluginSourced { open class JSContent(
protected val _pluginConfig: SourcePluginConfig; protected val _pluginConfig: SourcePluginConfig,
protected val _content : V8ValueObject; protected val _content: V8ValueObject
) : IPlatformContent, IPluginSourced {
protected val _hasGetDetails: Boolean; override val contentType: ContentType = ContentType.UNKNOWN
override val contentType: ContentType get() = ContentType.UNKNOWN; protected val _hasGetDetails: Boolean = _content.has("getDetails")
override val id: PlatformID; override val id: PlatformID =
override val name: String; PlatformID.fromV8(_pluginConfig, _content.getOrThrow(_pluginConfig, "id", CTX))
override val author: PlatformAuthorLink;
override val datetime: OffsetDateTime?;
override val url: String; override val name: String =
override val shareUrl: String; HtmlCompat.fromHtml(
_content.getOrThrow<String>(_pluginConfig, "name", CTX).decodeUnicode(),
HtmlCompat.FROM_HTML_MODE_LEGACY
).toString()
override val sourceConfig: SourcePluginConfig get() = _pluginConfig; override val author: PlatformAuthorLink =
_content.getOrDefault<V8ValueObject>(_pluginConfig, "author", CTX, null)
?.let { PlatformAuthorLink.fromV8(_pluginConfig, it) }
?: PlatformAuthorLink.UNKNOWN
constructor(config: SourcePluginConfig, obj: V8ValueObject) { private val _epoch: Long? =
_pluginConfig = config; _content.getOrDefault<Long>(_pluginConfig, "datetime", CTX, null)?.toLong()
_content = obj;
val contextName = "PlatformContent"; override val datetime: OffsetDateTime? =
_epoch?.takeIf { it != 0L }?.let {
OffsetDateTime.of(LocalDateTime.ofEpochSecond(it, 0, ZoneOffset.UTC), ZoneOffset.UTC)
}
id = PlatformID.fromV8(_pluginConfig, _content.getOrThrow(config, "id", contextName)); override val url: String =
name = HtmlCompat.fromHtml(_content.getOrThrow<String>(config, "name", contextName).decodeUnicode(), HtmlCompat.FROM_HTML_MODE_LEGACY).toString(); _content.getOrThrow<String>(_pluginConfig, "url", CTX)
val authorObj = _content.getOrDefault<V8ValueObject>(config, "author", contextName, null); override val shareUrl: String =
if(authorObj != null) _content.getOrDefault<String>(_pluginConfig, "shareUrl", CTX, null) ?: url
author = PlatformAuthorLink.fromV8(_pluginConfig, authorObj);
else
author = PlatformAuthorLink.UNKNOWN;
val datetimeInt = _content.getOrDefault<Int>(config, "datetime", contextName, null)?.toLong(); override val sourceConfig: SourcePluginConfig
if(datetimeInt == null || datetimeInt == 0.toLong()) get() = _pluginConfig
datetime = null;
else
datetime = OffsetDateTime.of(LocalDateTime.ofEpochSecond(datetimeInt, 0, ZoneOffset.UTC), ZoneOffset.UTC);
url = _content.getOrThrow(config, "url", contextName);
shareUrl = _content.getOrDefault<String>(config, "shareUrl", contextName, null) ?: url;
_hasGetDetails = _content.has("getDetails"); fun getUnderlyingObject(): V8ValueObject? = _content
companion object {
private const val CTX = "PlatformContent"
} }
}
fun getUnderlyingObject(): V8ValueObject? {
return _content;
}
}
@@ -6,14 +6,16 @@ import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.getOrDefault
open class JSPlaylist : JSContent, IPlatformPlaylist { open class JSPlaylist(
override val contentType: ContentType get() = ContentType.PLAYLIST; config: SourcePluginConfig,
override val thumbnail: String?; obj: V8ValueObject
override val videoCount: Int; ) : JSContent(config, obj), IPlatformPlaylist {
constructor(config: SourcePluginConfig, obj: V8ValueObject) : super(config, obj) { override val contentType: ContentType = ContentType.PLAYLIST
val contextName = "Playlist";
thumbnail = obj.getOrDefault(config, "thumbnail", contextName, null); override val thumbnail: String? =
videoCount = obj.getOrDefault(config, "videoCount", contextName, -1)!!; _content.getOrDefault<String>(_pluginConfig, "thumbnail", "Playlist", null)
}
} override val videoCount: Int =
_content.getOrDefault<Int>(_pluginConfig, "videoCount", "Playlist", null)?.toInt() ?: -1
}
@@ -22,6 +22,7 @@ class JSSubtitleSource : ISubtitleSource {
override val name: String; override val name: String;
override val url: String?; override val url: String?;
override val format: String?; override val format: String?;
override val language: String?
override val hasFetch: Boolean; override val hasFetch: Boolean;
constructor(config: SourcePluginConfig, v8Value: V8ValueObject) { constructor(config: SourcePluginConfig, v8Value: V8ValueObject) {
@@ -29,6 +30,7 @@ class JSSubtitleSource : ISubtitleSource {
val context = "JSSubtitles"; val context = "JSSubtitles";
name = v8Value.getOrThrow(config, "name", context, false); name = v8Value.getOrThrow(config, "name", context, false);
language = v8Value.getOrThrow(config, "language", context, false);
url = v8Value.getOrThrow(config, "url", context, true); url = v8Value.getOrThrow(config, "url", context, true);
format = v8Value.getOrThrow(config, "format", context, true); format = v8Value.getOrThrow(config, "format", context, true);
hasFetch = v8Value.has("getSubtitles"); hasFetch = v8Value.has("getSubtitles");
@@ -8,43 +8,44 @@ import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrow
open class JSAudioUrlSource : IAudioUrlSource, JSSource { open class JSAudioUrlSource(
override val name: String; plugin: JSClient,
override val bitrate : Int; obj: V8ValueObject
override val container : String; ) : JSSource(TYPE_AUDIOURL, plugin, obj), IAudioUrlSource {
override val codec: String;
private val url : String;
override val language: String; private val ctx = "AudioUrlSource"
private val cfg = plugin.config
override val duration: Long?; override val bitrate: Int =
_obj.getOrThrow<Int>(cfg, "bitrate", ctx)
override var priority: Boolean = false; override val container: String =
_obj.getOrThrow<String>(cfg, "container", ctx)
override var original: Boolean = false; override val codec: String =
_obj.getOrThrow<String>(cfg, "codec", ctx)
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_AUDIOURL, plugin, obj) { private val url: String =
val contextName = "AudioUrlSource"; _obj.getOrThrow<String>(cfg, "url", ctx)
val config = plugin.config;
bitrate = _obj.getOrThrow(config, "bitrate", contextName); override val language: String =
container = _obj.getOrThrow(config, "container", contextName); _obj.getOrThrow<String>(cfg, "language", ctx)
codec = _obj.getOrThrow(config, "codec", contextName);
url = _obj.getOrThrow(config, "url", contextName);
language = _obj.getOrThrow(config, "language", contextName);
duration = _obj.getOrDefault(config, "duration", contextName, null);
name = _obj.getOrDefault(config, "name", contextName, "${container} ${bitrate}") ?: "${container} ${bitrate}"; override val duration: Long? =
_obj.getOrDefault<Long>(cfg, "duration", ctx, null)?.toLong()
priority = if(_obj.has("priority")) obj.getOrThrow(config, "priority", contextName) else false; override val name: String =
original = if(_obj.has("original")) obj.getOrThrow(config, "original", contextName) else false; _obj.getOrDefault<String>(cfg, "name", ctx, null)
} ?: "$container $bitrate"
override fun getAudioUrl() : String { override var priority: Boolean =
return url; if (_obj.has("priority")) _obj.getOrThrow<Boolean>(cfg, "priority", ctx) else false
}
override fun toString(): String { override var original: Boolean =
return "(name=$name, container=$container, bitrate=$bitrate, codec=$codec, url=$url, language=$language, duration=$duration)"; if (_obj.has("original")) _obj.getOrThrow<Boolean>(cfg, "original", ctx) else false
}
} override fun getAudioUrl(): String = url
override fun toString(): String =
"(name=$name, container=$container, bitrate=$bitrate, codec=$codec, url=$url, language=$language, duration=$duration)"
}
@@ -31,42 +31,52 @@ interface IJSDashManifestRawSource {
fun generateAsync(scope: CoroutineScope): Deferred<String?>; fun generateAsync(scope: CoroutineScope): Deferred<String?>;
fun generate(): String?; fun generate(): String?;
} }
open class JSDashManifestRawSource: JSSource, IVideoSource, IJSDashManifestRawSource, IStreamMetaDataSource { open class JSDashManifestRawSource(
override val container : String; plugin: JSClient,
override val name : String; obj: V8ValueObject
override val width: Int; ) : JSSource(TYPE_DASH_RAW, plugin, obj), IVideoSource, IJSDashManifestRawSource, IStreamMetaDataSource {
override val height: Int;
override val codec: String;
override val bitrate: Int?;
override val duration: Long;
override val priority: Boolean;
val url: String?; private val ctx = "DashRawSource"
override var manifest: String?; private val cfg = plugin.config
override val hasGenerate: Boolean; override val container: String =
val canMerge: Boolean; _obj.getOrDefault<String>(cfg, "container", ctx, null) ?: "application/dash+xml"
override var streamMetaData: StreamMetaData? = null; override val name: String =
_obj.getOrThrow<String>(cfg, "name", ctx)
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH_RAW, plugin, obj) { override val width: Int =
val contextName = "DashRawSource"; _obj.getOrDefault<Int>(cfg, "width", ctx, null)?.toInt() ?: 0
val config = plugin.config;
name = _obj.getOrThrow(config, "name", contextName);
url = _obj.getOrThrow(config, "url", contextName);
container = _obj.getOrDefault<String>(config, "container", contextName, null) ?: "application/dash+xml";
manifest = _obj.getOrDefault<String>(config, "manifest", contextName, null);
width = _obj.getOrDefault(config, "width", contextName, 0) ?: 0;
height = _obj.getOrDefault(config, "height", contextName, 0) ?: 0;
codec = _obj.getOrDefault(config, "codec", contextName, "") ?: "";
bitrate = _obj.getOrDefault(config, "bitrate", contextName, 0) ?: 0;
duration = _obj.getOrDefault(config, "duration", contextName, 0) ?: 0;
priority = _obj.getOrDefault(config, "priority", contextName, false) ?: false;
canMerge = _obj.getOrDefault(config, "canMerge", contextName, false) ?: false;
hasGenerate = _obj.has("generate");
}
private var _pregenerate: V8Deferred<String?>? = null; override val height: Int =
_obj.getOrDefault<Int>(cfg, "height", ctx, null)?.toInt() ?: 0
override val codec: String =
_obj.getOrDefault<String>(cfg, "codec", ctx, "") ?: ""
override val bitrate: Int? =
_obj.getOrDefault<Int>(cfg, "bitrate", ctx, null)?.toInt()
override val duration: Long =
_obj.getOrDefault<Long>(cfg, "duration", ctx, 0)?.toLong() ?: 0L
override val priority: Boolean =
_obj.getOrDefault<Boolean>(cfg, "priority", ctx, false) ?: false
val url: String? =
_obj.getOrDefault<String>(cfg, "url", ctx, null)
override var manifest: String? =
_obj.getOrDefault<String>(cfg, "manifest", ctx, null)
override val hasGenerate: Boolean = _obj.has("generate")
val canMerge: Boolean =
_obj.getOrDefault<Boolean>(cfg, "canMerge", ctx, false) ?: false
override var streamMetaData: StreamMetaData? = null
private var _pregenerate: V8Deferred<String?>? = null
fun pregenerateAsync(scope: CoroutineScope): V8Deferred<String?>? { fun pregenerateAsync(scope: CoroutineScope): V8Deferred<String?>? {
_pregenerate = generateAsync(scope); _pregenerate = generateAsync(scope);
return _pregenerate; return _pregenerate;
@@ -5,42 +5,47 @@ import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.engine.IV8PluginConfig import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.engine.V8Plugin import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrNull import com.futo.platformplayer.getOrNull
import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrow
open class JSVideoUrlSource : IVideoUrlSource, JSSource { open class JSVideoUrlSource(
override val width : Int; plugin: JSClient,
override val height : Int; obj: V8ValueObject
override val container : String; ) : JSSource(TYPE_VIDEOURL, plugin, obj), IVideoUrlSource {
override val codec: String;
override val name : String;
override val bitrate : Int;
override val duration: Long;
private val url : String;
override var priority: Boolean = false; private val ctx = "JSVideoUrlSource"
private val cfg = plugin.config
constructor(plugin: JSClient, obj: V8ValueObject): super(TYPE_VIDEOURL, plugin, obj) { override val width: Int =
val contextName = "JSVideoUrlSource"; _obj.getOrThrow<Int>(cfg, "width", ctx)
val config = plugin.config;
width = _obj.getOrThrow(config, "width", contextName); override val height: Int =
height = _obj.getOrThrow(config, "height", contextName); _obj.getOrThrow<Int>(cfg, "height", ctx)
container = _obj.getOrThrow(config, "container", contextName);
codec = _obj.getOrThrow(config, "codec", contextName);
name = _obj.getOrThrow(config, "name", contextName);
bitrate = _obj.getOrThrow(config, "bitrate", contextName);
duration = _obj.getOrThrow<Int>(config, "duration", contextName).toLong();
url = _obj.getOrThrow(config, "url", contextName);
priority = obj.getOrNull(config, "priority", contextName) ?: false; override val container: String =
} _obj.getOrThrow<String>(cfg, "container", ctx)
override fun getVideoUrl() : String { override val codec: String =
return url; _obj.getOrThrow<String>(cfg, "codec", ctx)
}
override fun toString(): String { override val name: String =
return "(width=$width, height=$height, container=$container, codec=$codec, name=$name, bitrate=$bitrate, duration=$duration, url=$url)" _obj.getOrThrow<String>(cfg, "name", ctx)
}
} override val bitrate: Int =
_obj.getOrThrow<Int>(cfg, "bitrate", ctx)
override val duration: Long =
_obj.getOrThrow<Long>(cfg, "duration", ctx)
private val url: String =
_obj.getOrThrow<String>(cfg, "url", ctx)
override var priority: Boolean =
_obj.getOrDefault<Boolean>(cfg, "priority", ctx, false) ?: false
override fun getVideoUrl(): String = url
override fun toString(): String =
"(width=$width, height=$height, container=$container, codec=$codec, name=$name, bitrate=$bitrate, duration=$duration, url=$url)"
}
@@ -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;
@@ -15,7 +15,7 @@ import kotlinx.coroutines.launch
import java.net.InetAddress import java.net.InetAddress
import java.util.UUID import java.util.UUID
class AirPlayCastingDevice : CastingDevice { class AirPlayCastingDevice : CastingDeviceLegacy {
//See for more info: https://nto.github.io/AirPlay //See for more info: https://nto.github.io/AirPlay
override val protocol: CastProtocolType get() = CastProtocolType.AIRPLAY; override val protocol: CastProtocolType get() = CastProtocolType.AIRPLAY;
@@ -2,147 +2,78 @@ package com.futo.platformplayer.casting
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.models.CastingDeviceInfo import com.futo.platformplayer.models.CastingDeviceInfo
import kotlinx.serialization.KSerializer import org.fcast.sender_sdk.Metadata
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import java.net.InetAddress import java.net.InetAddress
enum class CastConnectionState {
DISCONNECTED,
CONNECTING,
CONNECTED
}
@Serializable(with = CastProtocolType.CastProtocolTypeSerializer::class)
enum class CastProtocolType {
CHROMECAST,
AIRPLAY,
FCAST;
object CastProtocolTypeSerializer : KSerializer<CastProtocolType> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("CastProtocolType", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: CastProtocolType) {
encoder.encodeString(value.name)
}
override fun deserialize(decoder: Decoder): CastProtocolType {
val name = decoder.decodeString()
return when (name) {
"FASTCAST" -> FCAST // Handle the renamed case
else -> CastProtocolType.valueOf(name)
}
}
}
}
abstract class CastingDevice { abstract class CastingDevice {
abstract val protocol: CastProtocolType; abstract val isReady: Boolean
abstract val isReady: Boolean; abstract val usedRemoteAddress: InetAddress?
abstract var usedRemoteAddress: InetAddress?; abstract val localAddress: InetAddress?
abstract var localAddress: InetAddress?; abstract val name: String?
abstract val canSetVolume: Boolean; abstract val onConnectionStateChanged: Event1<CastConnectionState>
abstract val canSetSpeed: Boolean; abstract val onPlayChanged: Event1<Boolean>
abstract val onTimeChanged: Event1<Double>
abstract val onDurationChanged: Event1<Double>
abstract val onVolumeChanged: Event1<Double>
abstract val onSpeedChanged: Event1<Double>
abstract var connectionState: CastConnectionState
abstract val protocolType: CastProtocolType
abstract var isPlaying: Boolean
abstract val expectedCurrentTime: Double
abstract var speed: Double
abstract var time: Double
abstract var duration: Double
abstract var volume: Double
abstract fun canSetVolume(): Boolean
abstract fun canSetSpeed(): Boolean
var name: String? = null; @Throws
var isPlaying: Boolean = false abstract fun resumePlayback()
set(value) {
val changed = value != field;
field = value;
if (changed) {
onPlayChanged.emit(value);
}
};
private var lastTimeChangeTime_ms: Long = 0 @Throws
var time: Double = 0.0 abstract fun pausePlayback()
private set
protected fun setTime(value: Double, changeTime_ms: Long = System.currentTimeMillis()) { @Throws
if (changeTime_ms > lastTimeChangeTime_ms && value != time) { abstract fun stopPlayback()
time = value
lastTimeChangeTime_ms = changeTime_ms
onTimeChanged.emit(value)
}
}
private var lastDurationChangeTime_ms: Long = 0 @Throws
var duration: Double = 0.0 abstract fun seekTo(timeSeconds: Double)
private set
protected fun setDuration(value: Double, changeTime_ms: Long = System.currentTimeMillis()) { @Throws
if (changeTime_ms > lastDurationChangeTime_ms && value != duration) { abstract fun changeVolume(timeSeconds: Double)
duration = value
lastDurationChangeTime_ms = changeTime_ms
onDurationChanged.emit(value)
}
}
private var lastVolumeChangeTime_ms: Long = 0 @Throws
var volume: Double = 1.0 abstract fun changeSpeed(speed: Double)
private set
protected fun setVolume(value: Double, changeTime_ms: Long = System.currentTimeMillis()) { @Throws
if (changeTime_ms > lastVolumeChangeTime_ms && value != volume) { abstract fun connect()
volume = value
lastVolumeChangeTime_ms = changeTime_ms
onVolumeChanged.emit(value)
}
}
private var lastSpeedChangeTime_ms: Long = 0 @Throws
var speed: Double = 1.0 abstract fun disconnect()
private set abstract fun getDeviceInfo(): CastingDeviceInfo
abstract fun getAddresses(): List<InetAddress>
protected fun setSpeed(value: Double, changeTime_ms: Long = System.currentTimeMillis()) { @Throws
if (changeTime_ms > lastSpeedChangeTime_ms && value != speed) { abstract fun loadVideo(
speed = value streamType: String,
lastSpeedChangeTime_ms = changeTime_ms contentType: String,
onSpeedChanged.emit(value) contentId: String,
} resumePosition: Double,
} duration: Double,
speed: Double?,
metadata: Metadata?
)
val expectedCurrentTime: Double @Throws
get() { abstract fun loadContent(
val diff = if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0; contentType: String,
return time + diff; content: String,
}; resumePosition: Double,
var connectionState: CastConnectionState = CastConnectionState.DISCONNECTED duration: Double,
set(value) { speed: Double?,
val changed = value != field; metadata: Metadata?
field = value; )
if (changed) { abstract fun ensureThreadStarted()
onConnectionStateChanged.emit(value); }
}
};
var onConnectionStateChanged = Event1<CastConnectionState>();
var onPlayChanged = Event1<Boolean>();
var onTimeChanged = Event1<Double>();
var onDurationChanged = Event1<Double>();
var onVolumeChanged = Event1<Double>();
var onSpeedChanged = Event1<Double>();
abstract fun stopCasting();
abstract fun seekVideo(timeSeconds: Double);
abstract fun stopVideo();
abstract fun pauseVideo();
abstract fun resumeVideo();
abstract fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double, speed: Double?);
abstract fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?);
open fun changeVolume(volume: Double) { throw NotImplementedError() }
open fun changeSpeed(speed: Double) { throw NotImplementedError() }
abstract fun start();
abstract fun stop();
abstract fun getDeviceInfo(): CastingDeviceInfo;
abstract fun getAddresses(): List<InetAddress>;
}
@@ -0,0 +1,271 @@
package com.futo.platformplayer.casting
import android.os.Build
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo
import org.fcast.sender_sdk.ApplicationInfo
import org.fcast.sender_sdk.GenericKeyEvent
import org.fcast.sender_sdk.GenericMediaEvent
import org.fcast.sender_sdk.PlaybackState
import org.fcast.sender_sdk.Source
import java.net.InetAddress
import org.fcast.sender_sdk.CastingDevice as RsCastingDevice;
import org.fcast.sender_sdk.DeviceEventHandler as RsDeviceEventHandler;
import org.fcast.sender_sdk.DeviceConnectionState
import org.fcast.sender_sdk.DeviceFeature
import org.fcast.sender_sdk.IpAddr
import org.fcast.sender_sdk.LoadRequest
import org.fcast.sender_sdk.Metadata
import org.fcast.sender_sdk.ProtocolType
import org.fcast.sender_sdk.urlFormatIpAddr
import java.net.Inet4Address
import java.net.Inet6Address
private fun ipAddrToInetAddress(addr: IpAddr): InetAddress = when (addr) {
is IpAddr.V4 -> Inet4Address.getByAddress(
byteArrayOf(
addr.o1.toByte(),
addr.o2.toByte(),
addr.o3.toByte(),
addr.o4.toByte()
)
)
is IpAddr.V6 -> Inet6Address.getByAddress(
byteArrayOf(
addr.o1.toByte(),
addr.o2.toByte(),
addr.o3.toByte(),
addr.o4.toByte(),
addr.o5.toByte(),
addr.o6.toByte(),
addr.o7.toByte(),
addr.o8.toByte(),
addr.o9.toByte(),
addr.o10.toByte(),
addr.o11.toByte(),
addr.o12.toByte(),
addr.o13.toByte(),
addr.o14.toByte(),
addr.o15.toByte(),
addr.o16.toByte()
)
)
}
class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
class EventHandler : RsDeviceEventHandler {
var onConnectionStateChanged = Event1<DeviceConnectionState>();
var onPlayChanged = Event1<Boolean>()
var onTimeChanged = Event1<Double>()
var onDurationChanged = Event1<Double>()
var onVolumeChanged = Event1<Double>()
var onSpeedChanged = Event1<Double>()
override fun connectionStateChanged(state: DeviceConnectionState) {
onConnectionStateChanged.emit(state)
}
override fun volumeChanged(volume: Double) {
onVolumeChanged.emit(volume)
}
override fun timeChanged(time: Double) {
onTimeChanged.emit(time)
}
override fun playbackStateChanged(state: PlaybackState) {
onPlayChanged.emit(state == PlaybackState.PLAYING)
}
override fun durationChanged(duration: Double) {
onDurationChanged.emit(duration)
}
override fun speedChanged(speed: Double) {
onSpeedChanged.emit(speed)
}
override fun sourceChanged(source: Source) {
// TODO
}
override fun keyEvent(event: GenericKeyEvent) {
// Unreachable
}
override fun mediaEvent(event: GenericMediaEvent) {
// Unreachable
}
override fun playbackError(message: String) {
Logger.e(TAG, "Playback error: $message")
}
}
val eventHandler = EventHandler()
override val isReady: Boolean
get() = device.isReady()
override val name: String
get() = device.name()
override var usedRemoteAddress: InetAddress? = null
override var localAddress: InetAddress? = null
override fun canSetVolume(): Boolean = device.supportsFeature(DeviceFeature.SET_VOLUME)
override fun canSetSpeed(): Boolean = device.supportsFeature(DeviceFeature.SET_SPEED)
override val onConnectionStateChanged =
Event1<CastConnectionState>()
override val onPlayChanged: Event1<Boolean>
get() = eventHandler.onPlayChanged
override val onTimeChanged: Event1<Double>
get() = eventHandler.onTimeChanged
override val onDurationChanged: Event1<Double>
get() = eventHandler.onDurationChanged
override val onVolumeChanged: Event1<Double>
get() = eventHandler.onVolumeChanged
override val onSpeedChanged: Event1<Double>
get() = eventHandler.onSpeedChanged
override fun resumePlayback() = device.resumePlayback()
override fun pausePlayback() = device.pausePlayback()
override fun stopPlayback() = device.stopPlayback()
override fun seekTo(timeSeconds: Double) = device.seek(timeSeconds)
override fun changeVolume(newVolume: Double) {
device.changeVolume(newVolume)
volume = newVolume
}
override fun changeSpeed(speed: Double) = device.changeSpeed(speed)
override fun connect() = device.connect(
ApplicationInfo(
"Grayjay Android",
"${BuildConfig.VERSION_NAME}-${BuildConfig.FLAVOR}",
"${Build.MANUFACTURER} ${Build.MODEL}"
),
eventHandler,
1000.toULong()
)
override fun disconnect() = device.disconnect()
override fun getDeviceInfo(): CastingDeviceInfo {
val info = device.getDeviceInfo()
return CastingDeviceInfo(
info.name,
when (info.protocol) {
ProtocolType.CHROMECAST -> CastProtocolType.CHROMECAST
ProtocolType.F_CAST -> CastProtocolType.FCAST
},
addresses = info.addresses.map { urlFormatIpAddr(it) }.toTypedArray(),
port = info.port.toInt(),
)
}
override fun getAddresses(): List<InetAddress> = device.getAddresses().map {
ipAddrToInetAddress(it)
}
override fun loadVideo(
streamType: String,
contentType: String,
contentId: String,
resumePosition: Double,
duration: Double,
speed: Double?,
metadata: Metadata?
) = device.load(
LoadRequest.Video(
contentType = contentType,
url = contentId,
resumePosition = resumePosition,
speed = speed,
volume = volume,
metadata = metadata
)
)
override fun loadContent(
contentType: String,
content: String,
resumePosition: Double,
duration: Double,
speed: Double?,
metadata: Metadata?
) = device.load(
LoadRequest.Content(
contentType = contentType,
content = content,
resumePosition = resumePosition,
speed = speed,
volume = volume,
metadata = metadata,
)
)
override var connectionState = CastConnectionState.DISCONNECTED
override val protocolType: CastProtocolType
get() = when (device.castingProtocol()) {
ProtocolType.CHROMECAST -> CastProtocolType.CHROMECAST
ProtocolType.F_CAST -> CastProtocolType.FCAST
}
override var volume: Double = 1.0
override var duration: Double = 0.0
private var lastTimeChangeTime_ms: Long = 0
override var time: Double = 0.0
override var speed: Double = 0.0
override var isPlaying: Boolean = false
override val expectedCurrentTime: Double
get() {
val diff =
if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0;
return time + diff
}
init {
eventHandler.onConnectionStateChanged.subscribe { newState ->
when (newState) {
is DeviceConnectionState.Connected -> {
usedRemoteAddress = ipAddrToInetAddress(newState.usedRemoteAddr)
localAddress = ipAddrToInetAddress(newState.localAddr)
connectionState = CastConnectionState.CONNECTED
onConnectionStateChanged.emit(CastConnectionState.CONNECTED)
}
DeviceConnectionState.Connecting, DeviceConnectionState.Reconnecting -> {
connectionState = CastConnectionState.CONNECTING
onConnectionStateChanged.emit(CastConnectionState.CONNECTING)
}
DeviceConnectionState.Disconnected -> {
connectionState = CastConnectionState.CONNECTING
onConnectionStateChanged.emit(CastConnectionState.DISCONNECTED)
}
}
if (newState == DeviceConnectionState.Disconnected) {
try {
Logger.i(TAG, "Stopping device")
device.disconnect()
} catch (e: Throwable) {
Logger.e(TAG, "Failed to stop device: $e")
}
}
}
eventHandler.onPlayChanged.subscribe { isPlaying = it }
eventHandler.onTimeChanged.subscribe {
lastTimeChangeTime_ms = System.currentTimeMillis()
time = it
}
eventHandler.onDurationChanged.subscribe { duration = it }
eventHandler.onVolumeChanged.subscribe { volume = it }
eventHandler.onSpeedChanged.subscribe { speed = it }
}
override fun ensureThreadStarted() {}
companion object {
private val TAG = "CastingDeviceExp"
}
}
@@ -0,0 +1,242 @@
package com.futo.platformplayer.casting
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.models.CastingDeviceInfo
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import org.fcast.sender_sdk.Metadata
import java.net.InetAddress
enum class CastConnectionState {
DISCONNECTED,
CONNECTING,
CONNECTED
}
@Serializable(with = CastProtocolType.CastProtocolTypeSerializer::class)
enum class CastProtocolType {
CHROMECAST,
AIRPLAY,
FCAST;
object CastProtocolTypeSerializer : KSerializer<CastProtocolType> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("CastProtocolType", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: CastProtocolType) {
encoder.encodeString(value.name)
}
override fun deserialize(decoder: Decoder): CastProtocolType {
val name = decoder.decodeString()
return when (name) {
"FASTCAST" -> FCAST // Handle the renamed case
else -> CastProtocolType.valueOf(name)
}
}
}
}
abstract class CastingDeviceLegacy {
abstract val protocol: CastProtocolType;
abstract val isReady: Boolean;
abstract var usedRemoteAddress: InetAddress?;
abstract var localAddress: InetAddress?;
abstract val canSetVolume: Boolean;
abstract val canSetSpeed: Boolean;
var name: String? = null;
var isPlaying: Boolean = false
set(value) {
val changed = value != field;
field = value;
if (changed) {
onPlayChanged.emit(value);
}
};
private var lastTimeChangeTime_ms: Long = 0
var time: Double = 0.0
private set
protected fun setTime(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
if (changeTime_ms > lastTimeChangeTime_ms && value != time) {
time = value
lastTimeChangeTime_ms = changeTime_ms
onTimeChanged.emit(value)
}
}
private var lastDurationChangeTime_ms: Long = 0
var duration: Double = 0.0
private set
protected fun setDuration(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
if (changeTime_ms > lastDurationChangeTime_ms && value != duration) {
duration = value
lastDurationChangeTime_ms = changeTime_ms
onDurationChanged.emit(value)
}
}
private var lastVolumeChangeTime_ms: Long = 0
var volume: Double = 1.0
private set
protected fun setVolume(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
if (changeTime_ms > lastVolumeChangeTime_ms && value != volume) {
volume = value
lastVolumeChangeTime_ms = changeTime_ms
onVolumeChanged.emit(value)
}
}
private var lastSpeedChangeTime_ms: Long = 0
var speed: Double = 1.0
private set
protected fun setSpeed(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
if (changeTime_ms > lastSpeedChangeTime_ms && value != speed) {
speed = value
lastSpeedChangeTime_ms = changeTime_ms
onSpeedChanged.emit(value)
}
}
val expectedCurrentTime: Double
get() {
val diff =
if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0;
return time + diff;
};
var connectionState: CastConnectionState = CastConnectionState.DISCONNECTED
set(value) {
val changed = value != field;
field = value;
if (changed) {
onConnectionStateChanged.emit(value);
}
};
var onConnectionStateChanged = Event1<CastConnectionState>();
var onPlayChanged = Event1<Boolean>();
var onTimeChanged = Event1<Double>();
var onDurationChanged = Event1<Double>();
var onVolumeChanged = Event1<Double>();
var onSpeedChanged = Event1<Double>();
abstract fun stopCasting();
abstract fun seekVideo(timeSeconds: Double);
abstract fun stopVideo();
abstract fun pauseVideo();
abstract fun resumeVideo();
abstract fun loadVideo(
streamType: String,
contentType: String,
contentId: String,
resumePosition: Double,
duration: Double,
speed: Double?
);
abstract fun loadContent(
contentType: String,
content: String,
resumePosition: Double,
duration: Double,
speed: Double?
);
open fun changeVolume(volume: Double) {
throw NotImplementedError()
}
open fun changeSpeed(speed: Double) {
throw NotImplementedError()
}
abstract fun start();
abstract fun stop();
abstract fun getDeviceInfo(): CastingDeviceInfo;
abstract fun getAddresses(): List<InetAddress>;
}
class CastingDeviceLegacyWrapper(val inner: CastingDeviceLegacy) : CastingDevice() {
override val isReady: Boolean get() = inner.isReady
override val usedRemoteAddress: InetAddress? get() = inner.usedRemoteAddress
override val localAddress: InetAddress? get() = inner.localAddress
override val name: String? get() = inner.name
override val onConnectionStateChanged: Event1<CastConnectionState> get() = inner.onConnectionStateChanged
override val onPlayChanged: Event1<Boolean> get() = inner.onPlayChanged
override val onTimeChanged: Event1<Double> get() = inner.onTimeChanged
override val onDurationChanged: Event1<Double> get() = inner.onDurationChanged
override val onVolumeChanged: Event1<Double> get() = inner.onVolumeChanged
override val onSpeedChanged: Event1<Double> get() = inner.onSpeedChanged
override var connectionState: CastConnectionState
get() = inner.connectionState
set(_) = Unit
override val protocolType: CastProtocolType get() = inner.protocol
override var isPlaying: Boolean
get() = inner.isPlaying
set(_) = Unit
override val expectedCurrentTime: Double
get() = inner.expectedCurrentTime
override var speed: Double
get() = inner.speed
set(_) = Unit
override var time: Double
get() = inner.time
set(_) = Unit
override var duration: Double
get() = inner.duration
set(_) = Unit
override var volume: Double
get() = inner.volume
set(_) = Unit
override fun canSetVolume(): Boolean = inner.canSetVolume
override fun canSetSpeed(): Boolean = inner.canSetSpeed
override fun resumePlayback() = inner.resumeVideo()
override fun pausePlayback() = inner.pauseVideo()
override fun stopPlayback() = inner.stopVideo()
override fun seekTo(timeSeconds: Double) = inner.seekVideo(timeSeconds)
override fun changeVolume(timeSeconds: Double) = inner.changeVolume(timeSeconds)
override fun changeSpeed(speed: Double) = inner.changeSpeed(speed)
override fun connect() = inner.start()
override fun disconnect() = inner.stop()
override fun getDeviceInfo(): CastingDeviceInfo = inner.getDeviceInfo()
override fun getAddresses(): List<InetAddress> = inner.getAddresses()
override fun loadVideo(
streamType: String,
contentType: String,
contentId: String,
resumePosition: Double,
duration: Double,
speed: Double?,
metadata: Metadata?
) = inner.loadVideo(streamType, contentType, contentId, resumePosition, duration, speed)
override fun loadContent(
contentType: String,
content: String,
resumePosition: Double,
duration: Double,
speed: Double?,
metadata: Metadata?
) = inner.loadContent(contentType, content, resumePosition, duration, speed)
override fun ensureThreadStarted() = when (inner) {
is FCastCastingDevice -> inner.ensureThreadStarted()
is ChromecastCastingDevice -> inner.ensureThreadsStarted()
else -> {}
}
}
@@ -27,7 +27,7 @@ import javax.net.ssl.SSLSocket
import javax.net.ssl.TrustManager import javax.net.ssl.TrustManager
import javax.net.ssl.X509TrustManager import javax.net.ssl.X509TrustManager
class ChromecastCastingDevice : CastingDevice { class ChromecastCastingDevice : CastingDeviceLegacy {
//See for more info: https://developers.google.com/cast/docs/media/messages //See for more info: https://developers.google.com/cast/docs/media/messages
override val protocol: CastProtocolType get() = CastProtocolType.CHROMECAST; override val protocol: CastProtocolType get() = CastProtocolType.CHROMECAST;
@@ -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;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,178 @@
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? {
try {
val rsAddrs =
deviceInfo.addresses.map { org.fcast.sender_sdk.tryIpAddrFromStr(it) }
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))
} catch (_: Throwable) {
return null
}
}
companion object {
private val TAG = "StateCastingExp"
}
}
@@ -0,0 +1,399 @@
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
)
)
if (foundInfo != null) {
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,15 +106,16 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
synchronized(StateCasting.instance.devices) { synchronized(StateCasting.instance.devices) {
_devices.addAll(StateCasting.instance.devices.values.mapNotNull { it.name }) _devices.addAll(StateCasting.instance.devices.values.mapNotNull { it.name })
} }
_rememberedDevices.addAll(StateCasting.instance.getRememberedCastingDeviceNames()) _rememberedDevices.addAll(StateCasting.instance.getRememberedCastingDeviceNames())
updateUnifiedList() updateUnifiedList()
StateCasting.instance.onDeviceAdded.subscribe(this) { d -> StateCasting.instance.onDeviceAdded.subscribe(this) { d ->
val name = d.name val name = d.name
if (name != null) if (name != null) {
_devices.add(name) _devices.add(name)
updateUnifiedList() updateUnifiedList()
}
} }
StateCasting.instance.onDeviceChanged.subscribe(this) { d -> StateCasting.instance.onDeviceChanged.subscribe(this) { d ->
@@ -12,12 +12,11 @@ import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.casting.AirPlayCastingDevice
import com.futo.platformplayer.casting.CastConnectionState import com.futo.platformplayer.casting.CastConnectionState
import com.futo.platformplayer.casting.CastProtocolType
import com.futo.platformplayer.casting.CastingDevice import com.futo.platformplayer.casting.CastingDevice
import com.futo.platformplayer.casting.ChromecastCastingDevice
import com.futo.platformplayer.casting.FCastCastingDevice
import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
@@ -69,18 +68,18 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
_buttonPlay = findViewById(R.id.button_play); _buttonPlay = findViewById(R.id.button_play);
_buttonPlay.setOnClickListener { _buttonPlay.setOnClickListener {
StateCasting.instance.activeDevice?.resumeVideo() StateCasting.instance.resumeVideo()
} }
_buttonPause = findViewById(R.id.button_pause); _buttonPause = findViewById(R.id.button_pause);
_buttonPause.setOnClickListener { _buttonPause.setOnClickListener {
StateCasting.instance.activeDevice?.pauseVideo() StateCasting.instance.pauseVideo()
} }
_buttonStop = findViewById(R.id.button_stop); _buttonStop = findViewById(R.id.button_stop);
_buttonStop.setOnClickListener { _buttonStop.setOnClickListener {
(ownerActivity as MainActivity?)?.getFragment<VideoDetailFragment>()?.closeVideoDetails() (ownerActivity as MainActivity?)?.getFragment<VideoDetailFragment>()?.closeVideoDetails()
StateCasting.instance.activeDevice?.stopVideo() StateCasting.instance.stopVideo()
} }
_buttonNext = findViewById(R.id.button_next); _buttonNext = findViewById(R.id.button_next);
@@ -90,7 +89,11 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
_buttonClose.setOnClickListener { dismiss(); }; _buttonClose.setOnClickListener { dismiss(); };
_buttonDisconnect.setOnClickListener { _buttonDisconnect.setOnClickListener {
StateCasting.instance.activeDevice?.stopCasting(); try {
StateCasting.instance.activeDevice?.disconnect()
} catch (e: Throwable) {
Logger.e(TAG, "Active device failed to disconnect: $e")
}
dismiss(); dismiss();
}; };
@@ -99,12 +102,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
return@OnChangeListener return@OnChangeListener
} }
val activeDevice = StateCasting.instance.activeDevice ?: return@OnChangeListener; StateCasting.instance.videoSeekTo(value.toDouble())
try {
activeDevice.seekVideo(value.toDouble());
} catch (e: Throwable) {
Logger.e(TAG, "Failed to change volume.", e);
}
}); });
//TODO: Check if volume slider is properly hidden in all cases //TODO: Check if volume slider is properly hidden in all cases
@@ -113,14 +111,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
return@OnChangeListener return@OnChangeListener
} }
val activeDevice = StateCasting.instance.activeDevice ?: return@OnChangeListener; StateCasting.instance.changeVolume(value.toDouble())
if (activeDevice.canSetVolume) {
try {
activeDevice.changeVolume(value.toDouble());
} catch (e: Throwable) {
Logger.e(TAG, "Failed to change volume.", e);
}
}
}); });
setLoading(false); setLoading(false);
@@ -172,15 +163,25 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
private fun updateDevice() { private fun updateDevice() {
val d = StateCasting.instance.activeDevice ?: return; val d = StateCasting.instance.activeDevice ?: return;
if (d is ChromecastCastingDevice) { when (d.protocolType) {
_imageDevice.setImageResource(R.drawable.ic_chromecast); CastProtocolType.CHROMECAST -> {
_textType.text = "Chromecast"; _imageDevice.setImageResource(R.drawable.ic_chromecast);
} else if (d is AirPlayCastingDevice) { _textType.text = "Chromecast";
_imageDevice.setImageResource(R.drawable.ic_airplay); }
_textType.text = "AirPlay"; CastProtocolType.AIRPLAY -> {
} else if (d is FCastCastingDevice) { _imageDevice.setImageResource(R.drawable.ic_airplay);
_imageDevice.setImageResource(R.drawable.ic_fc); _textType.text = "AirPlay";
_textType.text = "FastCast"; }
CastProtocolType.FCAST -> {
_imageDevice.setImageResource(
if (Settings.instance.casting.experimentalCasting) {
R.drawable.ic_exp_fc
} else {
R.drawable.ic_fc
}
)
_textType.text = "FCast";
}
} }
_textName.text = d.name; _textName.text = d.name;
@@ -192,7 +193,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
_sliderPosition.value = d.time.toFloat().coerceAtLeast(0.0f).coerceAtMost(dur) _sliderPosition.value = d.time.toFloat().coerceAtLeast(0.0f).coerceAtMost(dur)
_sliderPosition.valueTo = dur _sliderPosition.valueTo = dur
if (d.canSetVolume) { if (d.canSetVolume()) {
_layoutVolumeAdjustable.visibility = View.VISIBLE; _layoutVolumeAdjustable.visibility = View.VISIBLE;
_layoutVolumeFixed.visibility = View.GONE; _layoutVolumeFixed.visibility = View.GONE;
} else { } else {
@@ -214,8 +215,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
CastConnectionState.CONNECTED -> { CastConnectionState.CONNECTED -> {
enableControls(interactiveControls) enableControls(interactiveControls)
} }
CastConnectionState.CONNECTING, CastConnectionState.CONNECTING, CastConnectionState.DISCONNECTED -> {
CastConnectionState.DISCONNECTED -> {
disableControls(interactiveControls) disableControls(interactiveControls)
} }
} }
@@ -4,6 +4,8 @@ import android.content.Context
import com.caoccao.javet.exceptions.JavetCompilationException import com.caoccao.javet.exceptions.JavetCompilationException
import com.caoccao.javet.exceptions.JavetException import com.caoccao.javet.exceptions.JavetException
import com.caoccao.javet.exceptions.JavetExecutionException import com.caoccao.javet.exceptions.JavetExecutionException
import com.caoccao.javet.interfaces.IJavetEntityError
import com.caoccao.javet.interfaces.IJavetEntityMap
import com.caoccao.javet.interop.V8Host import com.caoccao.javet.interop.V8Host
import com.caoccao.javet.interop.V8Runtime import com.caoccao.javet.interop.V8Runtime
import com.caoccao.javet.values.V8Value import com.caoccao.javet.values.V8Value
@@ -18,6 +20,7 @@ import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.engine.exceptions.NoInternetException import com.futo.platformplayer.engine.exceptions.NoInternetException
import com.futo.platformplayer.engine.exceptions.PluginEngineStoppedException import com.futo.platformplayer.engine.exceptions.PluginEngineStoppedException
import com.futo.platformplayer.engine.exceptions.PluginException
import com.futo.platformplayer.engine.exceptions.ScriptAgeException import com.futo.platformplayer.engine.exceptions.ScriptAgeException
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.engine.exceptions.ScriptCompilationException import com.futo.platformplayer.engine.exceptions.ScriptCompilationException
@@ -36,6 +39,7 @@ import com.futo.platformplayer.engine.packages.PackageHttp
import com.futo.platformplayer.engine.packages.PackageJSDOM import com.futo.platformplayer.engine.packages.PackageJSDOM
import com.futo.platformplayer.engine.packages.PackageUtilities import com.futo.platformplayer.engine.packages.PackageUtilities
import com.futo.platformplayer.engine.packages.V8Package import com.futo.platformplayer.engine.packages.V8Package
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateAssets import com.futo.platformplayer.states.StateAssets
@@ -242,10 +246,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();
} }
/* /*
@@ -405,6 +411,12 @@ class V8Plugin {
return _runtimeMap.getOrDefault(runtime, null); return _runtimeMap.getOrDefault(runtime, null);
} }
private fun ctxString(ctx: Any?, key: String): String? = when (ctx) {
is Map<*, *> -> ctx[key]?.toString()
is V8ValueObject -> if (ctx.has(key)) ctx.getString(key) else null
else -> null
}
fun <T: Any?> catchScriptErrors(config: IV8PluginConfig, context: String, code: String? = null, handle: ()->T): T { fun <T: Any?> catchScriptErrors(config: IV8PluginConfig, context: String, code: String? = null, handle: ()->T): T {
var codeStripped = code; var codeStripped = code;
if(codeStripped != null) { //TODO: Improve code stripped if(codeStripped != null) { //TODO: Improve code stripped
@@ -438,37 +450,6 @@ class V8Plugin {
throw ScriptCompilationException(config, "Compilation: [${context}]: ${scriptEx.message}\n(${scriptEx.scriptingError.lineNumber})[${scriptEx.scriptingError.startColumn}-${scriptEx.scriptingError.endColumn}]: ${scriptEx.scriptingError.sourceLine}", null, codeStripped); throw ScriptCompilationException(config, "Compilation: [${context}]: ${scriptEx.message}\n(${scriptEx.scriptingError.lineNumber})[${scriptEx.scriptingError.startColumn}-${scriptEx.scriptingError.endColumn}]: ${scriptEx.scriptingError.sourceLine}", null, codeStripped);
} }
catch(executeEx: JavetExecutionException) { catch(executeEx: JavetExecutionException) {
val obj = executeEx.scriptingError?.context
if(obj != null && obj.containsKey("plugin_type") == true) {
val pluginType = obj["plugin_type"].toString();
//Captcha
if (pluginType == "CaptchaRequiredException") {
throw ScriptCaptchaRequiredException(config,
obj["url"]?.toString(),
obj["body"]?.toString(),
executeEx, executeEx.scriptingError?.stack, codeStripped);
}
//Reload Required
if (pluginType == "ReloadRequiredException") {
throw ScriptReloadRequiredException(config,
obj["msg"]?.toString(),
obj["reloadData"]?.toString(),
executeEx, executeEx.scriptingError?.stack, codeStripped);
}
//Others
throwExceptionFromV8(
config,
pluginType,
(extractJSExceptionMessage(executeEx) ?: ""),
executeEx,
executeEx.scriptingError?.stack,
codeStripped
);
}
/* //Required for newer V8 versions
if(executeEx.scriptingError?.context is IJavetEntityError) { if(executeEx.scriptingError?.context is IJavetEntityError) {
val obj = executeEx.scriptingError?.context as IJavetEntityError val obj = executeEx.scriptingError?.context as IJavetEntityError
if(obj.context.containsKey("plugin_type") == true) { if(obj.context.containsKey("plugin_type") == true) {
@@ -502,7 +483,6 @@ class V8Plugin {
} }
} }
*/
throw ScriptExecutionException(config, extractJSExceptionMessage(executeEx) ?: "", null, executeEx.scriptingError?.stack, codeStripped); throw ScriptExecutionException(config, extractJSExceptionMessage(executeEx) ?: "", null, executeEx.scriptingError?.stack, codeStripped);
} }
catch(ex: Exception) { catch(ex: Exception) {
@@ -511,18 +491,29 @@ class V8Plugin {
} }
private fun throwExceptionFromV8(config: IV8PluginConfig, pluginType: String, msg: String, innerEx: Exception? = null, stack: String? = null, code: String? = null) { private fun throwExceptionFromV8(config: IV8PluginConfig, pluginType: String, msg: String, innerEx: Exception? = null, stack: String? = null, code: String? = null) {
throw getExceptionFromPlugin(config, pluginType, msg, innerEx, stack, code);
}
fun getExceptionFromPlugin(config: IV8PluginConfig, obj: V8ValueObject, innerEx: Exception? = null, stack: String? = null, code: String? = null, prefix: String? = null): PluginException {
val pluginType = obj.getOrDefault(config, "plugin_type", "Exception Handling", "")?.let { if(!it.isNullOrBlank()) it + "" else "" } ?: "";
var msg = obj.getOrDefault<String?>(config, "msg", "Exception Handling", null)
?: obj.getOrDefault(config, "message", "Exception Handling", "");
if(!prefix.isNullOrBlank())
msg = prefix + msg;
return getExceptionFromPlugin(config, pluginType, msg ?: "Unknown exception", innerEx, stack, code);
}
fun getExceptionFromPlugin(config: IV8PluginConfig, pluginType: String, msg: String, innerEx: Exception? = null, stack: String? = null, code: String? = null): PluginException {
when(pluginType) { when(pluginType) {
"ScriptException" -> throw ScriptException(config, msg, innerEx, stack, code); "ScriptException" -> return ScriptException(config, msg, innerEx, stack, code);
"CriticalException" -> throw ScriptCriticalException(config, msg, innerEx, stack, code); "CriticalException" -> return ScriptCriticalException(config, msg, innerEx, stack, code);
"AgeException" -> throw ScriptAgeException(config, msg, innerEx, stack, code); "AgeException" -> return ScriptAgeException(config, msg, innerEx, stack, code);
"UnavailableException" -> throw ScriptUnavailableException(config, msg, innerEx, stack, code); "UnavailableException" -> return ScriptUnavailableException(config, msg, innerEx, stack, code);
"ScriptLoginRequiredException" -> throw ScriptLoginRequiredException(config, msg, innerEx, stack, code); "ScriptLoginRequiredException" -> return ScriptLoginRequiredException(config, msg, innerEx, stack, code);
"ScriptExecutionException" -> throw ScriptExecutionException(config, msg, innerEx, stack, code); "ScriptExecutionException" -> return ScriptExecutionException(config, msg, innerEx, stack, code);
"ScriptCompilationException" -> throw ScriptCompilationException(config, msg, innerEx, code); "ScriptCompilationException" -> return ScriptCompilationException(config, msg, innerEx, code);
"ScriptImplementationException" -> throw ScriptImplementationException(config, msg, innerEx, null, code); "ScriptImplementationException" -> return ScriptImplementationException(config, msg, innerEx, null, code);
"ScriptTimeoutException" -> throw ScriptTimeoutException(config, msg, innerEx); "ScriptTimeoutException" -> return ScriptTimeoutException(config, msg, innerEx);
"NoInternetException" -> throw NoInternetException(config, msg, innerEx, stack, code); "NoInternetException" -> return NoInternetException(config, msg, innerEx, stack, code);
else -> throw ScriptExecutionException(config, msg, innerEx, stack, code); else -> return ScriptExecutionException(config, msg, innerEx, stack, code);
} }
} }
@@ -254,7 +254,7 @@ class PackageHttp: V8Package {
//TODO: This object is currently re-wrapped each modification, this is due to an issue passing the same object back and forth, should be fixed in future. //TODO: This object is currently re-wrapped each modification, this is due to an issue passing the same object back and forth, should be fixed in future.
@V8Convert(mode = V8ConversionMode.AllowOnly, proxyMode = V8ProxyMode.Class) @V8Convert(mode = V8ConversionMode.AllowOnly, proxyMode = V8ProxyMode.Class)
class BatchBuilder(private val _package: PackageHttp, existingRequests: MutableList<Pair<PackageHttpClient, RequestDescriptor>> = mutableListOf()): V8BindObject() { class BatchBuilder(@Transient private val _package: PackageHttp, existingRequests: MutableList<Pair<PackageHttpClient, RequestDescriptor>> = mutableListOf()): V8BindObject() {
@Transient @Transient
private val _reqs = existingRequests; private val _reqs = existingRequests;
@@ -5,6 +5,7 @@ import android.content.Intent
import android.graphics.drawable.Animatable import android.graphics.drawable.Animatable
import android.util.AttributeSet import android.util.AttributeSet
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.WindowManager
import android.view.animation.AccelerateInterpolator import android.view.animation.AccelerateInterpolator
import android.view.animation.OvershootInterpolator import android.view.animation.OvershootInterpolator
import android.widget.FrameLayout import android.widget.FrameLayout
@@ -214,6 +215,16 @@ class ShortView : FrameLayout {
} }
} }
player.onPlayChanged.subscribe {
if (it) {
Logger.i(TAG, "Keep screen on set because isPlaying")
fragment.activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
} else {
Logger.i(TAG, "Keep screen on cleared because not isPlaying")
fragment.activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
}
}
onPlayingToggled.subscribe { playing -> onPlayingToggled.subscribe { playing ->
if (playing) { if (playing) {
playPauseIcon.setImageResource(R.drawable.ic_play) playPauseIcon.setImageResource(R.drawable.ic_play)
@@ -7,6 +7,7 @@ import android.view.LayoutInflater
import android.view.SoundEffectConstants import android.view.SoundEffectConstants
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.WindowManager
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.ImageView import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
@@ -309,6 +310,12 @@ class ShortsFragment : MainFragment() {
customViewAdapter?.previousShownView?.stop() customViewAdapter?.previousShownView?.stop()
} }
override fun onDestroyMainView() {
super.onDestroyMainView()
Logger.i(TAG, "Keep screen on cleared because onDestroyMainView fragment")
activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
companion object { companion object {
private const val TAG = "ShortsFragment" private const val TAG = "ShortsFragment"
@@ -244,6 +244,7 @@ class VideoDetailView : ConstraintLayout {
private val _buttonSubscribe: SubscribeButton; private val _buttonSubscribe: SubscribeButton;
private val _buttonPins: RoundButtonGroup; private val _buttonPins: RoundButtonGroup;
private var _loaderGameVisible = false
//private val _buttonMore: RoundButton; //private val _buttonMore: RoundButton;
var preventPictureInPicture: Boolean = false var preventPictureInPicture: Boolean = false
@@ -261,7 +262,6 @@ class VideoDetailView : ConstraintLayout {
private val _textSkip: TextView; private val _textSkip: TextView;
private val _textResume: TextView; private val _textResume: TextView;
private val _layoutResume: LinearLayout; private val _layoutResume: LinearLayout;
private var _jobHideResume: Job? = null;
private val _layoutPlayerContainer: TouchInterceptFrameLayout; private val _layoutPlayerContainer: TouchInterceptFrameLayout;
private val _layoutChangeBottomSection: LinearLayout; private val _layoutChangeBottomSection: LinearLayout;
@@ -336,7 +336,7 @@ class VideoDetailView : ConstraintLayout {
!StateCasting.instance.isCasting && !StateCasting.instance.isCasting &&
Settings.instance.playback.isBackgroundPictureInPicture() && Settings.instance.playback.isBackgroundPictureInPicture() &&
!isAudioOnlyUserAction && !isAudioOnlyUserAction &&
isPlaying (isPlaying || _loaderGameVisible)
val onShouldEnterPictureInPictureChanged = Event0(); val onShouldEnterPictureInPictureChanged = Event0();
@@ -357,6 +357,7 @@ class VideoDetailView : ConstraintLayout {
Pair(-5 * 60, 30), //around 5 minutes, try every 30 seconds Pair(-5 * 60, 30), //around 5 minutes, try every 30 seconds
Pair(0, 10) //around live, try every 10 seconds Pair(0, 10) //around live, try every 10 seconds
); );
private var _subtitleLanguage: String? = null
@androidx.annotation.OptIn(UnstableApi::class) @androidx.annotation.OptIn(UnstableApi::class)
constructor(context: Context, attrs : AttributeSet? = null) : super(context, attrs) { constructor(context: Context, attrs : AttributeSet? = null) : super(context, attrs) {
@@ -548,6 +549,16 @@ class VideoDetailView : ConstraintLayout {
_buttonMore = buttonMore; _buttonMore = buttonMore;
updateMoreButtons(); updateMoreButtons();
val handleLoaderGameVisibilityChanged = { b: Boolean ->
_loaderGameVisible = b
fragment.lifecycleScope.launch(Dispatchers.Main) {
onShouldEnterPictureInPictureChanged.emit()
}
updateResumeVisibilityFor(lastPositionMilliseconds)
}
_player.loaderGameVisibilityChanged.subscribe(handleLoaderGameVisibilityChanged)
_cast.loaderGameVisibilityChanged.subscribe(handleLoaderGameVisibilityChanged)
_channelButton.setOnClickListener { _channelButton.setOnClickListener {
if (video is TutorialFragment.TutorialVideo) { if (video is TutorialFragment.TutorialVideo) {
return@setOnClickListener return@setOnClickListener
@@ -576,9 +587,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());
} }
@@ -873,11 +883,6 @@ class VideoDetailView : ConstraintLayout {
_layoutResume.setOnClickListener { _layoutResume.setOnClickListener {
handleSeek(_historicalPosition * 1000); handleSeek(_historicalPosition * 1000);
val job = _jobHideResume;
_jobHideResume = null;
job?.cancel();
_layoutResume.visibility = View.GONE; _layoutResume.visibility = View.GONE;
}; };
@@ -886,7 +891,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);
@@ -1256,10 +1261,6 @@ class VideoDetailView : ConstraintLayout {
MediaControlReceiver.onCloseReceived.remove(this); MediaControlReceiver.onCloseReceived.remove(this);
MediaControlReceiver.onBackgroundReceived.remove(this); MediaControlReceiver.onBackgroundReceived.remove(this);
MediaControlReceiver.onSeekToReceived.remove(this); MediaControlReceiver.onSeekToReceived.remove(this);
val job = _jobHideResume;
_jobHideResume = null;
job?.cancel();
} }
//Video Setters //Video Setters
@@ -1781,26 +1782,7 @@ class VideoDetailView : ConstraintLayout {
TAG, TAG,
"Historical position: $_historicalPosition, last position: $lastPositionMilliseconds" "Historical position: $_historicalPosition, last position: $lastPositionMilliseconds"
); );
if (_historicalPosition > 60 && video.duration - _historicalPosition > 5 && Math.abs( updateResumeVisibilityFor(lastPositionMilliseconds)
_historicalPosition - lastPositionMilliseconds / 1000
) > 5.0
) {
_layoutResume.visibility = View.VISIBLE;
_textResume.text = "Resume at ${_historicalPosition.toHumanTime(false)}";
_jobHideResume = fragment.lifecycleScope.launch(Dispatchers.Main) {
try {
delay(8000);
_layoutResume.visibility = View.GONE;
_textResume.text = "";
} catch (e: Throwable) {
Logger.e(TAG, "Failed to set resume changes.", e);
}
}
} else {
_layoutResume.visibility = View.GONE;
_textResume.text = "";
}
} }
} }
} }
@@ -1846,6 +1828,35 @@ class VideoDetailView : ConstraintLayout {
_taskLoadRecommendations.run(videoDetail.url) _taskLoadRecommendations.run(videoDetail.url)
} }
} }
private fun shouldShowResume(positionMs: Long): Boolean {
if (_loaderGameVisible) return false
val v = video ?: return false
val resumeS = _historicalPosition
val durS = v.duration
if (_overlay_loading.visibility == View.VISIBLE) return false
if (resumeS <= 60) return false
if (durS - resumeS <= 5) return false
val posMs = positionMs
val resumeMs = resumeS * 1000
val durMs = durS * 1000L
val inFirstFewSeconds = posMs < 8_000
val notYetReachedResume = (resumeMs - posMs) > 5_000
return inFirstFewSeconds && notYetReachedResume && durMs > 0
}
private fun updateResumeVisibilityFor(positionMs: Long) {
val visible = shouldShowResume(positionMs)
if (visible) {
_layoutResume.visibility = View.VISIBLE
_textResume.text = "Resume at ${_historicalPosition.toHumanTime(false)}"
} else {
_layoutResume.visibility = View.GONE
_textResume.text = ""
}
}
fun loadVODChat(video: IPlatformVideoDetails) { fun loadVODChat(video: IPlatformVideoDetails) {
_liveChat?.stop(); _liveChat?.stop();
_container_content_liveChat.cancel(); _container_content_liveChat.cancel();
@@ -1965,7 +1976,7 @@ class VideoDetailView : ConstraintLayout {
try { try {
val videoSource = _lastVideoSource ?: _player.getPreferredVideoSource(video, Settings.instance.playback.getCurrentPreferredQualityPixelCount()); val videoSource = _lastVideoSource ?: _player.getPreferredVideoSource(video, Settings.instance.playback.getCurrentPreferredQualityPixelCount());
val audioSource = _lastAudioSource ?: _player.getPreferredAudioSource(video, Settings.instance.playback.getPrimaryLanguage(context)); val audioSource = _lastAudioSource ?: _player.getPreferredAudioSource(video, Settings.instance.playback.getPrimaryLanguage(context));
val subtitleSource = _lastSubtitleSource ?: (if(video is VideoLocal) video.subtitlesSources.firstOrNull() else null); val subtitleSource = _lastSubtitleSource ?: (if (Settings.instance.playback.stickySubtitles) _player.getPreferredSubtitleSource(video, _subtitleLanguage) else null) ?: (if(video is VideoLocal) video.subtitlesSources.firstOrNull() else null);
Logger.i(TAG, "loadCurrentVideo(videoSource=$videoSource, audioSource=$audioSource, subtitleSource=$subtitleSource, resumePositionMs=$resumePositionMs)") Logger.i(TAG, "loadCurrentVideo(videoSource=$videoSource, audioSource=$audioSource, subtitleSource=$subtitleSource, resumePositionMs=$resumePositionMs)")
if(videoSource == null && audioSource == null) { if(videoSource == null && audioSource == null) {
@@ -2368,11 +2379,11 @@ class VideoDetailView : ConstraintLayout {
?.distinct() ?.distinct()
?.toList() ?: listOf() else audioSources?.toList() ?: listOf(); ?.toList() ?: listOf() else audioSources?.toList() ?: listOf();
val canSetSpeed = !_isCasting || StateCasting.instance.activeDevice?.canSetSpeed == true val canSetSpeed = !_isCasting || StateCasting.instance.activeDevice?.canSetSpeed() == true
val currentPlaybackRate = if (_isCasting) StateCasting.instance.activeDevice?.speed else _player.getPlaybackRate() val currentPlaybackRate = if (_isCasting) StateCasting.instance.activeDevice?.speed else _player.getPlaybackRate()
val qualityPlaybackSpeedTitle = if (canSetSpeed) SlideUpMenuTitle(this.context).apply { setTitle(context.getString(R.string.playback_rate) + " (${String.format("%.2f", currentPlaybackRate)})"); } else null; val qualityPlaybackSpeedTitle = if (canSetSpeed) SlideUpMenuTitle(this.context).apply { setTitle(context.getString(R.string.playback_rate) + " (${String.format("%.2f", currentPlaybackRate)})"); } else null;
_overlay_quality_selector = SlideUpMenuOverlay(this.context, _overlay_quality_container, context.getString( _overlay_quality_selector = SlideUpMenuOverlay(this.context, _overlay_quality_container, context.getString(
R.string.quality), null, true, R.string.quality), null, true,
qualityPlaybackSpeedTitle, qualityPlaybackSpeedTitle,
if (canSetSpeed) SlideUpMenuButtonList(this.context, null, "playback_rate").apply { if (canSetSpeed) SlideUpMenuButtonList(this.context, null, "playback_rate").apply {
val playbackSpeeds = Settings.instance.playback.getPlaybackSpeeds(); val playbackSpeeds = Settings.instance.playback.getPlaybackSpeeds();
@@ -2393,7 +2404,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
} }
@@ -2649,6 +2660,7 @@ class VideoDetailView : ConstraintLayout {
} }
} }
_lastSubtitleSource = toSet; _lastSubtitleSource = toSet;
_subtitleLanguage = toSet?.language
} }
private fun handleUnavailableVideo(msg: String? = null) { private fun handleUnavailableVideo(msg: String? = null) {
@@ -2806,6 +2818,8 @@ class VideoDetailView : ConstraintLayout {
_overlay_loading.visibility = View.GONE; _overlay_loading.visibility = View.GONE;
(_overlay_loading_spinner.drawable as Animatable?)?.stop() (_overlay_loading_spinner.drawable as Animatable?)?.stop()
} }
updateResumeVisibilityFor(lastPositionMilliseconds)
} }
//UI Actions //UI Actions
@@ -3039,9 +3053,9 @@ class VideoDetailView : ConstraintLayout {
} }
val playpauseAction = if(_player.playing) val playpauseAction = if(_player.playing)
RemoteAction(Icon.createWithResource(context, R.drawable.ic_pause_notif), context.getString(R.string.pause), context.getString(R.string.pauses_the_video), MediaControlReceiver.getPauseIntent(context, 5)); RemoteAction(Icon.createWithResource(context, R.drawable.ic_pause_notif), context.getString(R.string.pause), context.getString(R.string.pauses_the_video), MediaControlReceiver.getPauseIntent(context, 2));
else else
RemoteAction(Icon.createWithResource(context, R.drawable.ic_play_notif), context.getString(R.string.play), context.getString(R.string.resumes_the_video), MediaControlReceiver.getPlayIntent(context, 6)); RemoteAction(Icon.createWithResource(context, R.drawable.ic_play_notif), context.getString(R.string.play), context.getString(R.string.resumes_the_video), MediaControlReceiver.getPlayIntent(context, 1));
val toBackgroundAction = RemoteAction(Icon.createWithResource(context, R.drawable.ic_screen_share), context.getString(R.string.background), context.getString(R.string.background_switch_audio), MediaControlReceiver.getToBackgroundIntent(context, 7)); val toBackgroundAction = RemoteAction(Icon.createWithResource(context, R.drawable.ic_screen_share), context.getString(R.string.background), context.getString(R.string.background_switch_audio), MediaControlReceiver.getToBackgroundIntent(context, 7));
@@ -3096,6 +3110,8 @@ class VideoDetailView : ConstraintLayout {
handleSeek(55000); handleSeek(55000);
} }
} }
updateResumeVisibilityFor(positionMilliseconds)
} }
private fun updateTracker(positionMs: Long, isPlaying: Boolean, forceUpdate: Boolean = false) { private fun updateTracker(positionMs: Long, isPlaying: Boolean, forceUpdate: Boolean = false) {
@@ -19,6 +19,8 @@ import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSour
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.IWidevineSource import com.futo.platformplayer.api.media.models.streams.sources.IWidevineSource
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSAudioUrlRangeSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSAudioUrlRangeSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource
@@ -134,6 +136,62 @@ class VideoHelper {
return bestSource; return bestSource;
} }
fun selectBestSubtitleSource(sources: Iterable<ISubtitleSource>, preferredLanguage: String?): ISubtitleSource? {
if (preferredLanguage.isNullOrBlank()) return null
val prefTag = normalizeTag(preferredLanguage)
val prefPrimary = primarySubtag(prefTag) ?: return null
var best: ISubtitleSource? = null
var bestKey: Quad<Int, Int, String, String>? = null
for (src in sources) {
val raw = src.language ?: continue
val tag = normalizeTag(raw)
val primary = primarySubtag(tag) ?: continue
val score = when {
tag.equals(prefTag, ignoreCase = true) -> 0
primary.equals(prefPrimary, ignoreCase = true) && findRegion(tag) == null -> 1
primary.equals(prefPrimary, ignoreCase = true) -> 2
else -> 3
}
if (score >= 3) continue
val key = Quad(score, src.name.length, tag.lowercase(), src.name)
if (bestKey == null || key < bestKey!!) {
bestKey = key
best = src
}
}
return best
}
private fun normalizeTag(tag: String): String = tag.trim().replace('_', '-')
private fun primarySubtag(tag: String): String? = tag.split('-').firstOrNull { it.isNotBlank() }?.lowercase()
private fun findRegion(tag: String): String? {
val parts = tag.split('-').drop(1) // skip primary language
for (p in parts) {
val isAlpha2 = p.length == 2 && p[0].isLetter() && p[1].isLetter()
val isNumeric3 = p.length == 3 && p.all { it.isDigit() }
if (isAlpha2 || isNumeric3) return p.uppercase()
}
return null
}
private data class Quad<A : Comparable<A>, B : Comparable<B>, C : Comparable<C>, D : Comparable<D>>(
val a: A, val b: B, val c: C, val d: D
) : Comparable<Quad<A, B, C, D>> {
override fun compareTo(other: Quad<A, B, C, D>): Int =
when {
a != other.a -> a.compareTo(other.a)
b != other.b -> b.compareTo(other.b)
c != other.c -> c.compareTo(other.c)
else -> d.compareTo(other.d)
}
}
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
fun convertItagSourceToChunkedDashSource(videoSource: JSVideoUrlRangeSource) : Pair<MediaSource, String> { fun convertItagSourceToChunkedDashSource(videoSource: JSVideoUrlRangeSource) : Pair<MediaSource, String> {
val urlToUse = videoSource.getVideoUrl(); val urlToUse = videoSource.getVideoUrl();
@@ -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;
}
}
@@ -0,0 +1,80 @@
package com.futo.platformplayer.polycentric
import android.content.Context
import android.content.SharedPreferences
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import org.json.JSONObject
class ModerationsManager private constructor(context: Context) {
private val prefs: SharedPreferences = context.getSharedPreferences("polycentric_moderation", Context.MODE_PRIVATE)
private val _moderationLevels = MutableLiveData<Map<String, Int>>()
val moderationLevels: LiveData<Map<String, Int>> = _moderationLevels
init {
loadModerationLevels()
}
private fun loadModerationLevels() {
val levels = mutableMapOf<String, Int>()
levels["hate"] = prefs.getInt("offensive_level", 2)
levels["sexual"] = prefs.getInt("explicit_level", 1)
levels["violence"] = prefs.getInt("violence_level", 1)
_moderationLevels.value = levels
}
fun setModerationLevel(category: String, level: Int) {
when (category) {
"hate" -> prefs.edit().putInt("offensive_level", level).apply()
"sexual" -> prefs.edit().putInt("explicit_level", level).apply()
"violence" -> prefs.edit().putInt("violence_level", level).apply()
}
val currentMap = _moderationLevels.value?.toMutableMap() ?: mutableMapOf()
currentMap[category] = level
_moderationLevels.value = currentMap
}
fun getModerationLevelsJson(): String {
val json = JSONObject()
moderationLevels.value?.forEach { (key, value) ->
json.put(key, value)
}
return json.toString()
}
fun shouldFilter(category: String, contentLevel: Int): Boolean {
val userLevel = when (category) {
"hate" -> prefs.getInt("offensive_level", 2)
"sexual" -> prefs.getInt("explicit_level", 1)
"violence" -> prefs.getInt("violence_level", 1)
else -> 3
}
return contentLevel > userLevel
}
fun getCurrentModerationLevels(): Map<String, Int>? {
return moderationLevels.value
}
companion object {
@Volatile
private var instance: ModerationsManager? = null
fun initialize(context: Context) {
if (instance == null) {
synchronized(this) {
if (instance == null) {
instance = ModerationsManager(context.applicationContext)
}
}
}
}
fun getInstance(): ModerationsManager {
return instance ?: throw IllegalStateException("ModerationsManager not initialized")
}
}
}
@@ -66,10 +66,9 @@ class DownloadService : Service() {
return START_NOT_STICKY; return START_NOT_STICKY;
if(!FragmentedStorage.isInitialized) { if(!FragmentedStorage.isInitialized) {
Logger.i(TAG, "Attempted to start DownloadService without initialized files"); Logger.i(TAG, "Attempted to start DownloadService without initialized files")
stopSelf() closeDownloadSession()
closeDownloadSession(); return START_NOT_STICKY
return START_NOT_STICKY;
} }
_started = true; _started = true;
} }
@@ -107,12 +106,19 @@ class DownloadService : Service() {
return START_STICKY; return START_STICKY;
} }
fun setupNotificationRequirements() { fun setupNotificationRequirements() {
_notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager; _notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
_notificationChannel = NotificationChannel(DOWNLOAD_NOTIF_CHANNEL_ID, DOWNLOAD_NOTIF_CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT).apply { if (_notificationChannel == null) {
this.enableVibration(false); _notificationChannel = NotificationChannel(
this.setSound(null, null); DOWNLOAD_NOTIF_CHANNEL_ID,
}; DOWNLOAD_NOTIF_CHANNEL_NAME,
_notificationManager!!.createNotificationChannel(_notificationChannel!!); NotificationManager.IMPORTANCE_LOW
).apply {
enableVibration(false)
setSound(null, null)
setShowBadge(false)
}
}
_notificationManager?.createNotificationChannel(_notificationChannel!!)
} }
override fun onCreate() { override fun onCreate() {
@@ -293,21 +299,28 @@ class DownloadService : Service() {
val notif = builder.build(); val notif = builder.build();
notif.flags = notif.flags or NotificationCompat.FLAG_ONGOING_EVENT or NotificationCompat.FLAG_NO_CLEAR; notif.flags = notif.flags or NotificationCompat.FLAG_ONGOING_EVENT or NotificationCompat.FLAG_NO_CLEAR;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (_isForeground) {
startForeground(DOWNLOAD_NOTIF_ID, notif, FOREGROUND_SERVICE_TYPE_DATA_SYNC); _notificationManager?.notify(DOWNLOAD_NOTIF_ID, notif)
} else { } else {
startForeground(DOWNLOAD_NOTIF_ID, notif); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
startForeground(DOWNLOAD_NOTIF_ID, notif, FOREGROUND_SERVICE_TYPE_DATA_SYNC)
else
startForeground(DOWNLOAD_NOTIF_ID, notif)
_isForeground = true
} }
} }
fun closeDownloadSession() { fun closeDownloadSession() {
Logger.i(TAG, "closeDownloadSession"); Logger.i(TAG, "closeDownloadSession")
stopForeground(STOP_FOREGROUND_REMOVE); if (_isForeground) {
_notificationManager?.cancel(DOWNLOAD_NOTIF_ID); stopForeground(STOP_FOREGROUND_REMOVE)
stopService(); _isForeground = false
_started = false; }
super.stopSelf(); _notificationManager?.cancel(DOWNLOAD_NOTIF_ID)
_started = false
super.stopSelf()
} }
override fun onDestroy() { override fun onDestroy() {
Logger.i(TAG, "onDestroy"); Logger.i(TAG, "onDestroy");
_instance = null; _instance = null;
@@ -49,6 +49,8 @@ import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.v2.ManagedStore import com.futo.platformplayer.stores.v2.ManagedStore
import com.futo.platformplayer.views.ToastView import com.futo.platformplayer.views.ToastView
import com.futo.polycentric.core.ApiMethods import com.futo.polycentric.core.ApiMethods
import com.futo.polycentric.core.toBase64Url
import com.futo.platformplayer.polycentric.ModerationsManager
import kotlinx.coroutines.* import kotlinx.coroutines.*
import java.io.File import java.io.File
import java.util.* import java.util.*
@@ -135,8 +137,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() {
@@ -381,6 +387,29 @@ class StateApp {
_cacheDirectory?.let { ApiMethods.initCache(it) }; _cacheDirectory?.let { ApiMethods.initCache(it) };
} }
Logger.i(TAG, "MainApp Starting: Initializing [ModerationsManager]");
ModerationsManager.initialize(context);
Logger.i(TAG, "MainApp Starting: Setting [ModerationLevelProvider]");
ApiMethods.setModerationLevelProvider {
try {
ModerationsManager.getInstance().getCurrentModerationLevels()
} catch (e: IllegalStateException) {
Logger.e(TAG, "Failed to get moderation levels from manager", e);
null
}
}
Logger.i(TAG, "MainApp Starting: Setting [ModerationExemptSystemProvider]");
ApiMethods.setModerationExemptSystemProvider {
try {
StatePolycentric.instance.processHandle?.system?.toProto()?.toByteArray()?.toBase64Url()
} catch (e: Throwable) {
Logger.e(TAG, "Failed to get moderation exempt system from manager", e);
null
}
}
val logFile = File(context.filesDir, "log.txt"); val logFile = File(context.filesDir, "log.txt");
if (Settings.instance.logging.logLevel > LogLevel.NONE.value) { if (Settings.instance.logging.logLevel > LogLevel.NONE.value) {
val fileLogConsumer = FileLogConsumer(logFile, LogLevel.fromInt(Settings.instance.logging.logLevel), false); val fileLogConsumer = FileLogConsumer(logFile, LogLevel.fromInt(Settings.instance.logging.logLevel), false);
@@ -439,7 +439,7 @@ class StateDownloads {
} else { } else {
throw NotImplementedError("Unsuported scheme"); throw NotImplementedError("Unsuported scheme");
} }
return if (subtitles != null) SubtitleRawSource(subtitle.name, subtitle.format, subtitles!!) else null; return if (subtitles != null) SubtitleRawSource(subtitle.name, subtitle.language, subtitle.format, subtitles!!) else null;
} }
fun cleanupDownloads(): Pair<Int, Long> { fun cleanupDownloads(): Pair<Int, Long> {
@@ -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;
}; };
@@ -402,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) {
@@ -522,9 +529,7 @@ class StatePlugins {
if(id == StateDeveloper.DEV_ID) if(id == StateDeveloper.DEV_ID)
throw IllegalStateException("Attempted to retrieve a persistent developer plugin, this is not allowed"); throw IllegalStateException("Attempted to retrieve a persistent developer plugin, this is not allowed");
synchronized(_plugins) { return _plugins.findItem { it.config.id == id };
return _plugins.findItem { it.config.id == id };
}
} }
fun getPlugins(): List<SourcePluginDescriptor> { fun getPlugins(): List<SourcePluginDescriptor> {
return _plugins.getItems(); return _plugins.getItems();
@@ -533,12 +538,10 @@ class StatePlugins {
fun deletePlugin(id: String) { fun deletePlugin(id: String) {
synchronized(_pluginScripts) { synchronized(_pluginScripts) {
synchronized(_plugins) { _pluginScripts.deleteFile(id);
_pluginScripts.deleteFile(id); val plugins = _plugins.findItems { it.config.id == id };
val plugins = _plugins.findItems { it.config.id == id }; for(plugin in plugins)
for(plugin in plugins) _plugins.delete(plugin);
_plugins.delete(plugin);
}
} }
} }
fun createPlugin(config: SourcePluginConfig, script: String, icon: ByteArray? = null, reinstall: Boolean = false, flags: List<String> = listOf()) : Throwable? { fun createPlugin(config: SourcePluginConfig, script: String, icon: ByteArray? = null, reinstall: Boolean = false, flags: List<String> = listOf()) : Throwable? {
@@ -28,6 +28,7 @@ import com.futo.platformplayer.selectBestImage
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringStorage import com.futo.platformplayer.stores.StringStorage
import com.futo.polycentric.core.ApiMethods import com.futo.polycentric.core.ApiMethods
import com.futo.polycentric.core.ensureServerAndBackfill
import com.futo.polycentric.core.ClaimType import com.futo.polycentric.core.ClaimType
import com.futo.polycentric.core.ContentType import com.futo.polycentric.core.ContentType
import com.futo.polycentric.core.Opinion import com.futo.polycentric.core.Opinion
@@ -46,8 +47,10 @@ import com.google.protobuf.ByteString
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import userpackage.Protocol import userpackage.Protocol
import userpackage.Protocol.Reference import userpackage.Protocol.Reference
@@ -67,6 +70,8 @@ class StatePolycentric {
private val _commentPool = ForkJoinPool(2); private val _commentPool = ForkJoinPool(2);
private val _commentPoolDispatcher = _commentPool.asCoroutineDispatcher(); private val _commentPoolDispatcher = _commentPool.asCoroutineDispatcher();
private val _backgroundJob = SupervisorJob()
private val _backgroundScope = CoroutineScope(_backgroundJob + Dispatchers.IO)
fun load(context: Context) { fun load(context: Context) {
if (!enabled) { if (!enabled) {
@@ -173,6 +178,15 @@ class StatePolycentric {
} }
_likeDislikeMap = newMap _likeDislikeMap = newMap
// Ensure current server is registered & synced
_backgroundScope.launch {
try {
processHandle.ensureServerAndBackfill()
} catch (e: Throwable) {
Logger.w(TAG, "Failed to ensure server and backfill: "+e.message)
}
}
} else { } else {
_activeProcessHandle.setAndSave(""); _activeProcessHandle.setAndSave("");
_likeDislikeMap = hashMapOf() _likeDislikeMap = hashMapOf()
@@ -559,6 +573,11 @@ class StatePolycentric {
}; };
} }
fun cleanup() {
_backgroundJob.cancel()
_commentPool.shutdown()
}
companion object { companion object {
private const val TAG = "StatePolycentric"; private const val TAG = "StatePolycentric";
@@ -6,6 +6,7 @@ import android.net.nsd.NsdServiceInfo
import android.os.Build import android.os.Build
import android.util.Log import android.util.Log
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.ensureNotMainThread
import com.futo.platformplayer.generateReadablePassword import com.futo.platformplayer.generateReadablePassword
import com.futo.platformplayer.getConnectedSocket import com.futo.platformplayer.getConnectedSocket
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
@@ -17,14 +18,23 @@ import com.futo.polycentric.core.base64UrlToByteArray
import com.futo.polycentric.core.toBase64 import com.futo.polycentric.core.toBase64
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.selects.select
import kotlinx.coroutines.withContext
import java.net.InetAddress import java.net.InetAddress
import java.net.InetSocketAddress import java.net.InetSocketAddress
import java.net.ServerSocket import java.net.ServerSocket
import java.net.Socket import java.net.Socket
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.channels.ClosedChannelException
import java.nio.channels.ServerSocketChannel
import java.nio.channels.SocketChannel
import java.util.Base64 import java.util.Base64
import java.util.Locale import java.util.Locale
import kotlin.math.min import kotlin.math.min
@@ -64,11 +74,7 @@ class SyncService(
private val database: ISyncDatabaseProvider, private val database: ISyncDatabaseProvider,
private val settings: SyncServiceSettings = SyncServiceSettings() private val settings: SyncServiceSettings = SyncServiceSettings()
) { ) {
private var _serverSocket: ServerSocket? = null private var _serverSocket: ServerSocketChannel? = null
private var _thread: Thread? = null
private var _connectThread: Thread? = null
private var _mdnsThread: Thread? = null
@Volatile private var _started = false
private val _sessions: MutableMap<String, SyncSession> = mutableMapOf() private val _sessions: MutableMap<String, SyncSession> = mutableMapOf()
private val _lastConnectTimesMdns: MutableMap<String, Long> = mutableMapOf() private val _lastConnectTimesMdns: MutableMap<String, Long> = mutableMapOf()
private val _lastConnectTimesIp: MutableMap<String, Long> = mutableMapOf() private val _lastConnectTimesIp: MutableMap<String, Long> = mutableMapOf()
@@ -82,10 +88,10 @@ class SyncService(
private val _pairingCode: String? = generateReadablePassword(8) private val _pairingCode: String? = generateReadablePassword(8)
val pairingCode: String? get() = _pairingCode val pairingCode: String? get() = _pairingCode
private var _relaySession: SyncSocketSession? = null private var _relaySession: SyncSocketSession? = null
private var _threadRelay: Thread? = null private val _remotePendingStatusUpdateRelayed = mutableMapOf<String, (complete: Boolean?, message: String) -> Unit>()
private val _remotePendingStatusUpdate = mutableMapOf<String, (complete: Boolean?, message: String) -> Unit>() private val _remotePendingStatusUpdateDirect = mutableMapOf<String, (complete: Boolean?, message: String) -> Unit>()
private var _nsdManager: NsdManager? = null private var _nsdManager: NsdManager? = null
private var _scope: CoroutineScope? = null @Volatile private var _scope: CoroutineScope? = null
private val _mdnsCache = mutableMapOf<String, SyncDeviceInfo>() private val _mdnsCache = mutableMapOf<String, SyncDeviceInfo>()
private var _discoveryListener: NsdManager.DiscoveryListener = object : NsdManager.DiscoveryListener { private var _discoveryListener: NsdManager.DiscoveryListener = object : NsdManager.DiscoveryListener {
override fun onDiscoveryStarted(regType: String) { override fun onDiscoveryStarted(regType: String) {
@@ -216,11 +222,12 @@ class SyncService(
var authorizePrompt: ((String, (Boolean) -> Unit) -> Unit)? = null var authorizePrompt: ((String, (Boolean) -> Unit) -> Unit)? = null
fun start(context: Context) { fun start(context: Context) {
if (_started) { if (_scope != null) {
Logger.i(TAG, "Already started.") Log.i(TAG, "Already started.")
return return
} }
_started = true
Log.i(TAG, "Start SyncService.")
_scope = CoroutineScope(Dispatchers.IO) _scope = CoroutineScope(Dispatchers.IO)
try { try {
@@ -294,27 +301,30 @@ class SyncService(
private fun startListener() { private fun startListener() {
serverSocketFailedToStart = false serverSocketFailedToStart = false
serverSocketStarted = false serverSocketStarted = false
_thread = Thread { _scope?.launch(Dispatchers.IO) {
try { try {
val serverSocket = ServerSocket(settings.listenerPort) val serverSocket = ServerSocketChannel.open()
serverSocket.socket().bind(InetSocketAddress("0.0.0.0", settings.listenerPort))
_serverSocket = serverSocket _serverSocket = serverSocket
serverSocketStarted = true
Log.i(TAG, "Running on port ${settings.listenerPort} (TCP)") Log.i(TAG, "Running on port ${settings.listenerPort} (TCP)")
serverSocketStarted = true
while (_started) { while (isActive) {
val socket = serverSocket.accept() val socket = serverSocket.accept()
val session = createSocketSession(socket, true) //TODO: Switch to SocketChannel?
val session = createSocketSession(socket.socket(), true)
session.startAsResponder() session.startAsResponder()
} }
} catch (e: ClosedChannelException) {
serverSocketStarted = false // normal shutdown
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.e(TAG, "Failed to bind server socket to port ${settings.listenerPort}", e) Log.e(TAG, "Failed to bind server socket to port ${settings.listenerPort}", e)
serverSocketFailedToStart = true serverSocketFailedToStart = true
} finally {
serverSocketStarted = false serverSocketStarted = false
} }
}.apply { start() } }
} }
private fun startMdnsRetryLoop() { private fun startMdnsRetryLoop() {
@@ -322,43 +332,44 @@ class SyncService(
discoverServices(serviceName, NsdManager.PROTOCOL_DNS_SD, _discoveryListener) discoverServices(serviceName, NsdManager.PROTOCOL_DNS_SD, _discoveryListener)
} }
_mdnsThread = Thread { _scope?.launch(Dispatchers.IO) {
while (_started) { while (isActive) {
try { try {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
synchronized(_mdnsCache) { val pairs = synchronized (_mdnsCache) { _mdnsCache.toList() }
for ((pkey, info) in _mdnsCache) { for ((pkey, info) in pairs) {
if (!database.isAuthorized(pkey) || getLinkType(pkey) == LinkType.Direct) continue if (!database.isAuthorized(pkey) || getLinkType(pkey) == LinkType.Direct) continue
val last = synchronized(_lastConnectTimesMdns) { val last = synchronized(_lastConnectTimesMdns) {
_lastConnectTimesMdns[pkey] ?: 0L _lastConnectTimesMdns[pkey] ?: 0L
} }
if (now - last > 30_000L) { if (now - last > 30_000L) {
synchronized(_lastConnectTimesMdns) {
_lastConnectTimesMdns[pkey] = now _lastConnectTimesMdns[pkey] = now
try { }
Logger.i(TAG, "MDNS-retry: connecting to $pkey") try {
connect(info) Log.i(TAG, "MDNS-retry: connecting to $pkey")
} catch (ex: Throwable) { connect(info)
Logger.w(TAG, "MDNS retry failed for $pkey", ex) if (!isActive) break
} } catch (ex: Throwable) {
Log.w(TAG, "MDNS retry failed for $pkey", ex)
} }
} }
} }
} catch (ex: Throwable) { } catch (ex: Throwable) {
Logger.e(TAG, "Error in MDNS retry loop", ex) Log.e(TAG, "Error in MDNS retry loop", ex)
} }
Thread.sleep(5000) delay(5000)
} }
}.apply { start() } }
} }
private fun startConnectLastLoop() { private fun startConnectLastLoop() {
_connectThread = Thread { _scope?.launch(Dispatchers.IO) {
Log.i(TAG, "Running auto reconnector") Log.i(TAG, "Running auto reconnector")
while (_started) { while (isActive) {
val authorizedDevices = database.getAllAuthorizedDevices() ?: arrayOf() val authorizedDevices = database.getAllAuthorizedDevices()?.toList() ?: listOf()
val addressesToConnect = authorizedDevices.mapNotNull { val addressesToConnect = authorizedDevices.mapNotNull {
val connectedDirectly = getLinkType(it) == LinkType.Direct val connectedDirectly = getLinkType(it) == LinkType.Direct
if (connectedDirectly) { if (connectedDirectly) {
@@ -382,26 +393,26 @@ class SyncService(
_lastConnectTimesIp[connectPair.first] = now _lastConnectTimesIp[connectPair.first] = now
} }
Logger.i(TAG, "Attempting to connect to authorized device by last known IP '${connectPair.first}' with pkey=${connectPair.first}") Log.i(TAG, "Attempting to connect to authorized device by last known IP '${connectPair.first}' with pkey=${connectPair.first}")
connect(arrayOf(connectPair.second), settings.listenerPort, connectPair.first, null) connect(arrayOf(connectPair.second), settings.listenerPort, connectPair.first, null)
} }
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.i(TAG, "Failed to connect to " + connectPair.first, e) Log.i(TAG, "Failed to connect to " + connectPair.first, e)
} }
} }
Thread.sleep(5000) delay(5000)
} }
}.apply { start() } }
} }
private fun startRelayLoop() { private fun startRelayLoop() {
relayConnected = false relayConnected = false
_threadRelay = Thread { _scope?.launch(Dispatchers.IO) {
try { try {
var backoffs: Array<Long> = arrayOf(1000, 5000, 10000, 20000) var backoffs: Array<Long> = arrayOf(1000, 5000, 10000, 20000)
var backoffIndex = 0; var backoffIndex = 0;
while (_started) { while (isActive) {
try { try {
Log.i(TAG, "Starting relay session...") Log.i(TAG, "Starting relay session...")
relayConnected = false relayConnected = false
@@ -465,7 +476,7 @@ class SyncService(
Thread { Thread {
try { try {
while (_started && !socketClosed) { while (isActive && !socketClosed) {
val unconnectedAuthorizedDevices = val unconnectedAuthorizedDevices =
database.getAllAuthorizedDevices() database.getAllAuthorizedDevices()
?.filter { ?.filter {
@@ -503,27 +514,14 @@ class SyncService(
connectionInfo.ipv4Addresses connectionInfo.ipv4Addresses
.filter { it != connectionInfo.remoteIp } .filter { it != connectionInfo.remoteIp }
if (getLinkType(targetKey) != LinkType.Direct && connectionInfo.allowLocalDirect && Settings.instance.synchronization.connectLocalDirectThroughRelay) { if (getLinkType(targetKey) != LinkType.Direct && connectionInfo.allowLocalDirect && Settings.instance.synchronization.connectLocalDirectThroughRelay) {
Thread { launch(Dispatchers.IO) {
try { try {
Log.v( Log.v(TAG, "Attempting to connect directly, locally to '$targetKey'.")
TAG, connect(potentialLocalAddresses.map { it }.toTypedArray(), settings.listenerPort, targetKey, null)
"Attempting to connect directly, locally to '$targetKey'."
)
connect(
potentialLocalAddresses.map { it }
.toTypedArray(),
settings.listenerPort,
targetKey,
null
)
} catch (e: Throwable) { } catch (e: Throwable) {
Log.e( Log.e(TAG, "Failed to start direct connection using connection info with $targetKey.", e)
TAG,
"Failed to start direct connection using connection info with $targetKey.",
e
)
} }
}.start() }
} }
if (connectionInfo.allowRemoteDirect) { if (connectionInfo.allowRemoteDirect) {
@@ -587,7 +585,7 @@ class SyncService(
} catch (ex: Throwable) { } catch (ex: Throwable) {
Log.i(TAG, "Unhandled exception in relay loop.", ex) Log.i(TAG, "Unhandled exception in relay loop.", ex)
} }
}.apply { start() } }
} }
private fun createSocketSession(socket: Socket, isResponder: Boolean): SyncSocketSession { private fun createSocketSession(socket: Socket, isResponder: Boolean): SyncSocketSession {
@@ -699,14 +697,21 @@ class SyncService(
return _pairingCode == pairingCode return _pairingCode == pairingCode
} }
private fun sendRemotePendingStatusUpdate(remotePublicKey: String, complete: Boolean, message: String) {
synchronized(_remotePendingStatusUpdateDirect) {
_remotePendingStatusUpdateDirect.remove(remotePublicKey)?.invoke(complete, message)
}
synchronized(_remotePendingStatusUpdateRelayed) {
_remotePendingStatusUpdateRelayed.remove(remotePublicKey)?.invoke(complete, message)
}
}
private fun createNewSyncSession(rpk: String, remoteDeviceName: String?): SyncSession { private fun createNewSyncSession(rpk: String, remoteDeviceName: String?): SyncSession {
val remotePublicKey = rpk.base64ToByteArray().toBase64() val remotePublicKey = rpk.base64ToByteArray().toBase64()
return SyncSession( return SyncSession(
remotePublicKey, remotePublicKey,
onAuthorized = { it, isNewlyAuthorized, isNewSession -> onAuthorized = { it, isNewlyAuthorized, isNewSession ->
synchronized(_remotePendingStatusUpdate) { sendRemotePendingStatusUpdate(remotePublicKey, true, "Authorized")
_remotePendingStatusUpdate.remove(remotePublicKey)?.invoke(true, "Authorized")
}
if (isNewSession) { if (isNewSession) {
it.remoteDeviceName?.let { remoteDeviceName -> it.remoteDeviceName?.let { remoteDeviceName ->
@@ -719,10 +724,7 @@ class SyncService(
onAuthorized?.invoke(it, isNewlyAuthorized, isNewSession) onAuthorized?.invoke(it, isNewlyAuthorized, isNewSession)
}, },
onUnauthorized = { onUnauthorized = {
synchronized(_remotePendingStatusUpdate) { sendRemotePendingStatusUpdate(remotePublicKey, false, "Unauthorized")
_remotePendingStatusUpdate.remove(remotePublicKey)?.invoke(false, "Unauthorized")
}
onUnauthorized?.invoke(it) onUnauthorized?.invoke(it)
}, },
onConnectedChanged = { it, connected -> onConnectedChanged = { it, connected ->
@@ -733,9 +735,7 @@ class SyncService(
Logger.i(TAG, "$remotePublicKey closed") Logger.i(TAG, "$remotePublicKey closed")
removeSession(it.remotePublicKey) removeSession(it.remotePublicKey)
synchronized(_remotePendingStatusUpdate) { sendRemotePendingStatusUpdate(remotePublicKey, false, "Connection closed")
_remotePendingStatusUpdate.remove(remotePublicKey)?.invoke(false, "Connection closed")
}
onClose?.invoke(it) onClose?.invoke(it)
}, },
@@ -757,42 +757,67 @@ class SyncService(
fun getAllAuthorizedDevices(): Array<String>? = database.getAllAuthorizedDevices() fun getAllAuthorizedDevices(): Array<String>? = database.getAllAuthorizedDevices()
fun removeAuthorizedDevice(publicKey: String) = database.removeAuthorizedDevice(publicKey) fun removeAuthorizedDevice(publicKey: String) = database.removeAuthorizedDevice(publicKey)
fun connect(deviceInfo: SyncDeviceInfo, onStatusUpdate: ((complete: Boolean?, message: String) -> Unit)? = null) {
try {
connect(deviceInfo.addresses, deviceInfo.port, deviceInfo.publicKey, deviceInfo.pairingCode, onStatusUpdate)
} catch (e: Throwable) {
Logger.e(TAG, "Failed to connect directly", e)
val relaySession = _relaySession
if (relaySession != null && Settings.instance.synchronization.pairThroughRelay) {
onStatusUpdate?.invoke(null, "Connecting via relay...")
runBlocking { suspend fun connect(deviceInfo: SyncDeviceInfo, alsoTryRelayed: Boolean = false, timeout_ms: Int = 10_000, onStatusUpdate: ((complete: Boolean?, message: String) -> Unit)? = null) {
if (onStatusUpdate != null) { val rs = _relaySession
synchronized(_remotePendingStatusUpdate) { val startTime = System.currentTimeMillis()
_remotePendingStatusUpdate[deviceInfo.publicKey.base64ToByteArray().toBase64()] = onStatusUpdate if (alsoTryRelayed && rs != null && settings.relayPairAllowed) {
} onStatusUpdate?.invoke(null, "Connecting via relay...")
}
relaySession.startRelayedChannel(deviceInfo.publicKey.base64ToByteArray().toBase64(), appId, deviceInfo.pairingCode) if (onStatusUpdate != null) {
synchronized(_remotePendingStatusUpdateRelayed) {
_remotePendingStatusUpdateRelayed[deviceInfo.publicKey.base64ToByteArray().toBase64()] = onStatusUpdate
} }
} else { }
throw e //TODO: Do not try relayed channel here only for pairing mode?
rs.startRelayedChannel(deviceInfo.publicKey.base64ToByteArray().toBase64(), appId, deviceInfo.pairingCode)
}
try {
connect(deviceInfo.addresses, deviceInfo.port, deviceInfo.publicKey, deviceInfo.pairingCode, onStatusUpdate, timeout_ms)
} catch (e: Throwable) {
Log.e(TAG, "Failed to connect directly", e)
val waitTime_ms = timeout_ms - (System.currentTimeMillis() - startTime)
if (waitTime_ms > 0)
delay(waitTime_ms)
onStatusUpdate?.invoke(false, "Failed to connect.")
synchronized(_remotePendingStatusUpdateRelayed) {
_remotePendingStatusUpdateRelayed.remove(deviceInfo.publicKey.base64ToByteArray().toBase64())
} }
} }
} }
fun connect(addresses: Array<String>, port: Int, publicKey: String, pairingCode: String?, onStatusUpdate: ((complete: Boolean?, message: String) -> Unit)? = null): SyncSocketSession { suspend fun connect(addresses: Array<String>, port: Int, publicKey: String, pairingCode: String?, onStatusUpdate: ((complete: Boolean?, message: String) -> Unit)? = null, timeout_ms: Int = 10_000): SyncSocketSession {
val startTime_ms = System.currentTimeMillis()
Log.i(TAG, "Connecting directly (timeout_ms = ${timeout_ms})...")
onStatusUpdate?.invoke(null, "Connecting directly...") onStatusUpdate?.invoke(null, "Connecting directly...")
val socket = getConnectedSocket(addresses.map { InetAddress.getByName(it) }, port) ?: throw Exception("Failed to connect") val socket = getConnectedSocket(addresses.map { InetAddress.getByName(it) }, port, timeout_ms) ?: throw Exception("Failed to connect")
onStatusUpdate?.invoke(null, "Handshaking...") onStatusUpdate?.invoke(null, "Handshaking...")
val session = createSocketSession(socket, false) val session = createSocketSession(socket, false)
if (onStatusUpdate != null) { if (onStatusUpdate != null) {
synchronized(_remotePendingStatusUpdate) { synchronized(_remotePendingStatusUpdateDirect) {
_remotePendingStatusUpdate[publicKey.base64ToByteArray().toBase64()] = onStatusUpdate _remotePendingStatusUpdateDirect[publicKey.base64ToByteArray().toBase64()] = onStatusUpdate
} }
} }
session.startAsInitiator(publicKey, appId, pairingCode) session.startAsInitiator(publicKey, appId, pairingCode)
while ((System.currentTimeMillis() - startTime_ms) < timeout_ms && !session.isAuthorized) {
delay(100)
}
if (!session.isAuthorized) {
Log.i(TAG, "Session is not authorized after timeout, cancelling connection.")
session.stop()
onStatusUpdate?.invoke(false, "Session not authorized.")
synchronized(_remotePendingStatusUpdateDirect) {
_remotePendingStatusUpdateDirect.remove(publicKey.base64ToByteArray().toBase64())
}
}
return session return session
} }
@@ -811,6 +836,8 @@ class SyncService(
synchronized(_sessions) { synchronized(_sessions) {
_sessions.clear() _sessions.clear()
} }
_remotePendingStatusUpdateDirect.clear()
_remotePendingStatusUpdateRelayed.clear()
} }
private fun getDeviceName(): String { private fun getDeviceName(): String {
@@ -56,6 +56,7 @@ class SyncSocketSession {
private var _remotePublicKey: String? = null private var _remotePublicKey: String? = null
val remotePublicKey: String? get() = _remotePublicKey val remotePublicKey: String? get() = _remotePublicKey
private var _started: Boolean = false private var _started: Boolean = false
val started get() = _started
private val _localKeyPair: DHState private val _localKeyPair: DHState
private var _thread: Thread? = null private var _thread: Thread? = null
private var _localPublicKey: String private var _localPublicKey: String
@@ -1,6 +1,11 @@
package com.futo.platformplayer.views.adapters package com.futo.platformplayer.views.adapters
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.view.GestureDetector
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.FrameLayout import android.widget.FrameLayout
@@ -11,6 +16,7 @@ import androidx.constraintlayout.widget.ConstraintLayout
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.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.media.models.comments.IPlatformComment import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.comments.LazyComment import com.futo.platformplayer.api.media.models.comments.LazyComment
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
@@ -97,6 +103,15 @@ class CommentViewHolder : ViewHolder {
StatePolycentric.instance.updateLikeMap(c.reference, args.hasLiked, args.hasDisliked) StatePolycentric.instance.updateLikeMap(c.reference, args.hasLiked, args.hasDisliked)
}; };
_layoutComment.setOnLongClickListener {
val clipboard = viewGroup.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val text = comment?.message.orEmpty()
val clip = ClipData.newPlainText("Comment", text)
clipboard.setPrimaryClip(clip)
UIDialogs.toast(viewGroup.context, "Copied", false)
true
}
_creatorThumbnail.onClick.subscribe { _creatorThumbnail.onClick.subscribe {
val c = comment ?: return@subscribe; val c = comment ?: return@subscribe;
onAuthorClick.emit(c); onAuthorClick.emit(c);
@@ -120,7 +135,7 @@ class CommentViewHolder : ViewHolder {
onDelete.emit(c); onDelete.emit(c);
} }
_textBody.setPlatformPlayerLinkMovementMethod(viewGroup.context); _textBody.setPlatformPlayerLinkMovementMethod(viewGroup.context)
} }
fun bind(comment: IPlatformComment, readonly: Boolean) { fun bind(comment: IPlatformComment, readonly: Boolean) {
@@ -4,21 +4,19 @@ import android.graphics.drawable.Animatable
import android.view.View import android.view.View
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.ImageView import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView.ViewHolder import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.casting.AirPlayCastingDevice import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.casting.CastConnectionState import com.futo.platformplayer.casting.CastConnectionState
import com.futo.platformplayer.casting.CastProtocolType
import com.futo.platformplayer.casting.CastingDevice import com.futo.platformplayer.casting.CastingDevice
import com.futo.platformplayer.casting.ChromecastCastingDevice
import com.futo.platformplayer.casting.FCastCastingDevice
import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.logging.Logger
import androidx.core.view.isVisible
import com.futo.platformplayer.UIDialogs
class DeviceViewHolder : ViewHolder { class DeviceViewHolder : ViewHolder {
private val _layoutDevice: FrameLayout; private val _layoutDevice: FrameLayout;
@@ -56,16 +54,18 @@ class DeviceViewHolder : ViewHolder {
val connect = { val connect = {
device?.let { dev -> device?.let { dev ->
if (dev.isReady) { try {
StateCasting.instance.activeDevice?.stopCasting() if (dev.isReady) {
StateCasting.instance.connectDevice(dev) StateCasting.instance.activeDevice?.stopPlayback()
onConnect.emit(dev) StateCasting.instance.connectDevice(dev)
} else { onConnect.emit(dev)
try { } else {
view.context?.let { UIDialogs.toast(it, "Device not ready, may be offline") } view.context?.let {
} catch (e: Throwable) { UIDialogs.toast(it, "Device not ready, may be offline")
//Ignored }
} }
} catch (e: Throwable) {
Logger.e(TAG, "Failed to connect: $e")
} }
} }
} }
@@ -81,15 +81,25 @@ class DeviceViewHolder : ViewHolder {
} }
fun bind(d: CastingDevice, isOnlineDevice: Boolean, isPinnedDevice: Boolean) { fun bind(d: CastingDevice, isOnlineDevice: Boolean, isPinnedDevice: Boolean) {
if (d is ChromecastCastingDevice) { when (d.protocolType) {
_imageDevice.setImageResource(R.drawable.ic_chromecast); CastProtocolType.CHROMECAST -> {
_textType.text = "Chromecast"; _imageDevice.setImageResource(R.drawable.ic_chromecast);
} else if (d is AirPlayCastingDevice) { _textType.text = "Chromecast";
_imageDevice.setImageResource(R.drawable.ic_airplay); }
_textType.text = "AirPlay"; CastProtocolType.AIRPLAY -> {
} else if (d is FCastCastingDevice) { _imageDevice.setImageResource(R.drawable.ic_airplay);
_imageDevice.setImageResource(R.drawable.ic_fc); _textType.text = "AirPlay";
_textType.text = "FCast"; }
CastProtocolType.FCAST -> {
_imageDevice.setImageResource(
if (Settings.instance.casting.experimentalCasting) {
R.drawable.ic_exp_fc
} else {
R.drawable.ic_fc
}
)
_textType.text = "FCast";
}
} }
_textName.text = d.name; _textName.text = d.name;
@@ -136,4 +146,8 @@ class DeviceViewHolder : ViewHolder {
device = d; device = d;
} }
companion object {
private val TAG = "DeviceViewHolder"
}
} }
@@ -0,0 +1,25 @@
package com.futo.platformplayer.views.behavior
import android.content.Context
import android.util.AttributeSet
import androidx.appcompat.widget.AppCompatTextView
import com.futo.platformplayer.logging.Logger
class SafeTextView : AppCompatTextView {
constructor(context: Context) : super(context) {}
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {}
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {}
override fun performLongClick(): Boolean {
try {
return super.performLongClick()
} catch (e: IllegalStateException) {
Logger.w(TAG, "Swallowed exception", e)
return false
}
}
companion object {
private const val TAG = "SafeTextView"
}
}
@@ -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 {
@@ -70,6 +68,7 @@ class CastView : ConstraintLayout {
val onPrevious = Event0(); val onPrevious = Event0();
val onNext = Event0(); val onNext = Event0();
val onTimeJobTimeChanged_s = Event1<Long>() val onTimeJobTimeChanged_s = Event1<Long>()
val loaderGameVisibilityChanged = Event1<Boolean>();
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
@@ -92,6 +91,7 @@ class CastView : ConstraintLayout {
_gestureControlView = findViewById(R.id.gesture_control); _gestureControlView = findViewById(R.id.gesture_control);
_loaderGame = findViewById(R.id.loader_overlay) _loaderGame = findViewById(R.id.loader_overlay)
_loaderGame.visibility = View.GONE _loaderGame.visibility = View.GONE
loaderGameVisibilityChanged.emit(false)
_gestureControlView.fullScreenGestureEnabled = false _gestureControlView.fullScreenGestureEnabled = false
_gestureControlView.setupTouchArea(); _gestureControlView.setupTouchArea();
@@ -99,19 +99,30 @@ 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 {
d.changeSpeed(Settings.instance.playback.getHoldPlaybackSpeed()) if (d.canSetSpeed()) {
d.resumeVideo() d.changeSpeed(Settings.instance.playback.getHoldPlaybackSpeed())
}
d.resumePlayback()
} catch (e: Throwable) {
Logger.e(TAG, "Failed to change playback speed to hold playback speed: $e")
}
} }
_gestureControlView.onSpeedHoldEnd.subscribe { _gestureControlView.onSpeedHoldEnd.subscribe {
val d = StateCasting.instance.activeDevice ?: return@subscribe; try {
if (!_speedHoldWasPlaying) d.pauseVideo() val d = StateCasting.instance.activeDevice ?: return@subscribe;
d.changeSpeed(_speedHoldPrevRate) if (!_speedHoldWasPlaying) {
d.pausePlayback()
}
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 {
val d = StateCasting.instance.activeDevice ?: return@subscribe; val d = StateCasting.instance.activeDevice ?: return@subscribe;
StateCasting.instance.videoSeekTo(d.expectedCurrentTime + it / 1000); StateCasting.instance.videoSeekTo( d.expectedCurrentTime + it / 1000);
}; };
_buttonLoop.setOnClickListener { _buttonLoop.setOnClickListener {
@@ -220,22 +231,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;
@@ -323,14 +321,21 @@ class CastView : ConstraintLayout {
if (isLoading) { if (isLoading) {
_loaderGame.visibility = View.VISIBLE _loaderGame.visibility = View.VISIBLE
_loaderGame.startLoader() _loaderGame.startLoader()
loaderGameVisibilityChanged.emit(true)
} else { } else {
_loaderGame.visibility = View.GONE _loaderGame.visibility = View.GONE
_loaderGame.stopAndResetLoader() _loaderGame.stopAndResetLoader()
loaderGameVisibilityChanged.emit(false)
} }
} }
fun setLoading(expectedDurationMs: Int) { fun setLoading(expectedDurationMs: Int) {
_loaderGame.visibility = View.VISIBLE _loaderGame.visibility = View.VISIBLE
_loaderGame.startLoader(expectedDurationMs.toLong()) _loaderGame.startLoader(expectedDurationMs.toLong())
loaderGameVisibilityChanged.emit(true)
}
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();
@@ -60,6 +60,10 @@ class FutoShortPlayer(context: Context, attrs: AttributeSet? = null) :
if (events.containsAny(Player.EVENT_PLAYBACK_STATE_CHANGED)) { if (events.containsAny(Player.EVENT_PLAYBACK_STATE_CHANGED)) {
onPlaybackStateChanged.emit(player.playbackState) onPlaybackStateChanged.emit(player.playbackState)
} }
if (events.containsAny(Player.EVENT_IS_PLAYING_CHANGED)) {
onPlayChanged.emit(player.isPlaying)
}
} }
} }
@@ -164,6 +164,8 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
private val _loaderGame: TargetTapLoaderView private val _loaderGame: TargetTapLoaderView
val loaderGameVisibilityChanged = Event1<Boolean>();
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
constructor(context: Context, attrs: AttributeSet? = null) : super(PLAYER_STATE_NAME, context, attrs) { constructor(context: Context, attrs: AttributeSet? = null) : super(PLAYER_STATE_NAME, context, attrs) {
LayoutInflater.from(context).inflate(R.layout.video_view, this, true); LayoutInflater.from(context).inflate(R.layout.video_view, this, true);
@@ -206,6 +208,7 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
_loaderGame = findViewById(R.id.loader_overlay) _loaderGame = findViewById(R.id.loader_overlay)
_loaderGame.visibility = View.GONE _loaderGame.visibility = View.GONE
loaderGameVisibilityChanged.emit(false)
_control_chapter.setOnClickListener { _control_chapter.setOnClickListener {
_currentChapter?.let { _currentChapter?.let {
@@ -894,15 +897,18 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
if (isLoading) { if (isLoading) {
_loaderGame.visibility = View.VISIBLE _loaderGame.visibility = View.VISIBLE
_loaderGame.startLoader() _loaderGame.startLoader()
loaderGameVisibilityChanged.emit(true)
} else { } else {
_loaderGame.visibility = View.GONE _loaderGame.visibility = View.GONE
_loaderGame.stopAndResetLoader() _loaderGame.stopAndResetLoader()
loaderGameVisibilityChanged.emit(false)
} }
} }
override fun setLoading(expectedDurationMs: Int) { override fun setLoading(expectedDurationMs: Int) {
_loaderGame.visibility = View.VISIBLE _loaderGame.visibility = View.VISIBLE
_loaderGame.startLoader(expectedDurationMs.toLong()) _loaderGame.startLoader(expectedDurationMs.toLong())
loaderGameVisibilityChanged.emit(true)
} }
override fun switchToVideoMode() { override fun switchToVideoMode() {
@@ -64,7 +64,12 @@ 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.awaitCancelConverted
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.TaskHandler import com.futo.platformplayer.constructs.TaskHandler
@@ -480,6 +485,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 +503,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 +523,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) {
@@ -618,7 +644,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
setLoading(true) setLoading(true)
} }
} }
val generated = generatedDef.await(); val generated = generatedDef.awaitCancelConverted();
if (_swapIdVideo.get() != swapId) { if (_swapIdVideo.get() != swapId) {
return@launch return@launch
} }
@@ -707,6 +733,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) {
@@ -765,7 +808,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
setLoading(true) setLoading(true)
} }
} }
val generated = generatedDef.await(); val generated = generatedDef.awaitCancelConverted();
if (_swapIdAudio.get() != swapId) { if (_swapIdAudio.get() != swapId) {
return@launch return@launch
} }
@@ -871,6 +914,10 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
return VideoHelper.selectBestAudioSource(video.video, PREFERED_AUDIO_CONTAINERS, preferredLanguage); return VideoHelper.selectBestAudioSource(video.video, PREFERED_AUDIO_CONTAINERS, preferredLanguage);
} }
fun getPreferredSubtitleSource(video: IPlatformVideoDetails, preferredLanguage: String?): ISubtitleSource? {
return VideoHelper.selectBestSubtitleSource(video.subtitles, preferredLanguage);
}
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
private fun loadSelectedSources(play: Boolean, resume: Boolean): Boolean { private fun loadSelectedSources(play: Boolean, resume: Boolean): Boolean {
val sourceVideo = if(!isAudioMode || _lastAudioMediaSource == null) _lastVideoMediaSource else null; val sourceVideo = if(!isAudioMode || _lastAudioMediaSource == null) _lastVideoMediaSource else null;
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/colorPrimary" />
<corners android:radius="18dp" />
</shape>
+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>
@@ -15,7 +15,6 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal" android:orientation="horizontal"
android:gravity="center_vertical" android:gravity="center_vertical"
android:paddingTop="20dp"
android:paddingBottom="15dp"> android:paddingBottom="15dp">
<ImageButton <ImageButton
@@ -19,7 +19,6 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal" android:orientation="horizontal"
android:gravity="center_vertical" android:gravity="center_vertical"
android:paddingTop="20dp"
android:paddingBottom="15dp"> android:paddingBottom="15dp">
<ImageButton <ImageButton
-1
View File
@@ -15,7 +15,6 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal" android:orientation="horizontal"
android:gravity="center_vertical" android:gravity="center_vertical"
android:paddingTop="20dp"
android:paddingBottom="15dp"> android:paddingBottom="15dp">
<ImageButton <ImageButton
@@ -18,7 +18,6 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal" android:orientation="horizontal"
android:gravity="center_vertical" android:gravity="center_vertical"
android:paddingTop="20dp"
android:paddingStart="20dp" android:paddingStart="20dp"
android:paddingEnd="20dp" android:paddingEnd="20dp"
android:paddingBottom="15dp"> android:paddingBottom="15dp">
@@ -14,7 +14,6 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal" android:orientation="horizontal"
android:gravity="center_vertical" android:gravity="center_vertical"
android:paddingTop="20dp"
android:paddingBottom="15dp"> android:paddingBottom="15dp">
<ImageButton <ImageButton
@@ -64,7 +64,6 @@
android:id="@+id/layout_buttons" android:id="@+id/layout_buttons"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:paddingTop="20dp"
android:paddingBottom="20dp" android:paddingBottom="20dp"
android:orientation="vertical"> android:orientation="vertical">
@@ -0,0 +1,228 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/black"
android:padding="16dp">
<LinearLayout
android:id="@+id/header"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintTop_toTopOf="parent">
<ImageButton
android:id="@+id/button_back"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/back"
android:padding="12dp"
android:scaleType="fitCenter"
app:srcCompat="@drawable/ic_back_thin_white_16dp" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_weight="1"
android:text="Moderation Settings"
android:textColor="?attr/colorOnBackground"
android:textSize="18sp"
android:textStyle="bold" />
</LinearLayout>
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="16dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@id/header">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/text_moderation_blurb"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:text="A lower slider value hides more content. Posts or comments with a tag level ABOVE your selected value will be hidden. (Level 0 = most strict, Level 3 = allow everything)"
android:textColor="?attr/colorOnSurface"
android:textSize="14sp" />
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardBackgroundColor="?attr/colorSurface"
app:cardCornerRadius="16dp"
app:cardElevation="2dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Moderation Levels"
android:textColor="?attr/colorOnSurface"
android:textSize="18sp"
android:textStyle="bold" />
<!-- Offensive Content -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="Offensive Content"
android:textColor="?attr/colorOnSurface"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/text_offensive_desc"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="Description"
android:textColor="?attr/colorOnSurface"
android:textSize="14sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<SeekBar
android:id="@+id/seekbar_offensive"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:max="3"
android:progress="2" />
<TextView
android:id="@+id/text_offensive_value"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:background="@drawable/background_slider_value"
android:gravity="center"
android:minWidth="36dp"
android:padding="8dp"
android:text="2"
android:textColor="@android:color/white"
android:textStyle="bold" />
</LinearLayout>
<!-- Explicit Content -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="Explicit Content"
android:textColor="?attr/colorOnSurface"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/text_explicit_desc"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="Description"
android:textColor="?attr/colorOnSurface"
android:textSize="14sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<SeekBar
android:id="@+id/seekbar_explicit"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:max="3"
android:progress="1" />
<TextView
android:id="@+id/text_explicit_value"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:background="@drawable/background_slider_value"
android:gravity="center"
android:minWidth="36dp"
android:padding="8dp"
android:text="1"
android:textColor="@android:color/white"
android:textStyle="bold" />
</LinearLayout>
<!-- Violence -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="Violence"
android:textColor="?attr/colorOnSurface"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/text_violence_desc"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="Description"
android:textColor="?attr/colorOnSurface"
android:textSize="14sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<SeekBar
android:id="@+id/seekbar_violence"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:max="3"
android:progress="1" />
<TextView
android:id="@+id/text_violence_value"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:background="@drawable/background_slider_value"
android:gravity="center"
android:minWidth="36dp"
android:padding="8dp"
android:text="1"
android:textColor="@android:color/white"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
</androidx.cardview.widget.CardView>
</LinearLayout>
</ScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -73,7 +73,7 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:fontFamily="@font/inter_regular" android:fontFamily="@font/inter_regular"
android:text="Further customize your profile, make platform claims, and other creator-specific features in the Harbor app." android:text="Further customize your profile, make platform claims, and other creator-specific features in the Polycentric app."
android:textSize="12dp" android:textSize="12dp"
android:linksClickable="true" android:linksClickable="true"
android:paddingLeft="20dp" android:paddingLeft="20dp"
@@ -90,7 +90,7 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:fontFamily="@font/inter_regular" android:fontFamily="@font/inter_regular"
android:text="https://harbor.social" android:text="https://polycentric.io"
android:textSize="12dp" android:textSize="12dp"
android:linksClickable="true" android:linksClickable="true"
android:paddingLeft="20dp" android:paddingLeft="20dp"
@@ -107,7 +107,7 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:fontFamily="@font/inter_regular" android:fontFamily="@font/inter_regular"
android:text="After you've installed Harbor you can export this profile to Harbor using the Export button." android:text="After you've installed Polycentric you can export this profile to Polycentric using the Export button."
android:textSize="12dp" android:textSize="12dp"
android:linksClickable="true" android:linksClickable="true"
android:paddingLeft="20dp" android:paddingLeft="20dp"
@@ -133,13 +133,13 @@
app:layout_constraintBottom_toBottomOf="parent"> app:layout_constraintBottom_toBottomOf="parent">
<com.futo.platformplayer.views.buttons.BigButton <com.futo.platformplayer.views.buttons.BigButton
android:id="@+id/button_open_harbor_profile" android:id="@+id/button_moderation"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
app:buttonText="Harbor Profile" app:buttonSubText="Set moderation settings for polycentric comments"
app:buttonSubText="See your Harbor profile in a browser" android:layout_marginTop="8dp"
app:buttonIcon="@drawable/ic_export" app:buttonIcon="@drawable/ic_settings"
android:layout_marginTop="8dp" /> app:buttonText="Moderation Settings" />
<com.futo.platformplayer.views.buttons.BigButton <com.futo.platformplayer.views.buttons.BigButton
android:id="@+id/button_export" android:id="@+id/button_export"
@@ -17,7 +17,6 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal" android:orientation="horizontal"
android:gravity="center_vertical" android:gravity="center_vertical"
android:paddingTop="20dp"
android:paddingBottom="15dp"> android:paddingBottom="15dp">
<ImageButton <ImageButton
@@ -5,7 +5,6 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical" android:orientation="vertical"
android:fitsSystemWindows="false"
android:background="@drawable/bottom_menu_border" android:background="@drawable/bottom_menu_border"
android:id="@+id/root" android:id="@+id/root"
android:clickable="true"> android:clickable="true">
@@ -5,7 +5,6 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical" android:orientation="vertical"
android:fitsSystemWindows="false"
android:background="@drawable/bottom_menu_border" android:background="@drawable/bottom_menu_border"
android:id="@+id/root" android:id="@+id/root"
android:clickable="true"> android:clickable="true">
@@ -5,7 +5,6 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical" android:orientation="vertical"
android:fitsSystemWindows="false"
android:background="@drawable/bottom_menu_border" android:background="@drawable/bottom_menu_border"
android:id="@+id/videodetail_root" android:id="@+id/videodetail_root"
android:clickable="true"> android:clickable="true">
@@ -5,7 +5,6 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical" android:orientation="vertical"
android:fitsSystemWindows="false"
android:background="@drawable/bottom_menu_border" android:background="@drawable/bottom_menu_border"
android:id="@+id/root" android:id="@+id/root"
android:clickable="true"> android:clickable="true">
@@ -26,7 +26,7 @@
app:layout_constraintTop_toBottomOf="@id/topbar" app:layout_constraintTop_toBottomOf="@id/topbar"
app:layout_constraintBottom_toBottomOf="parent"> app:layout_constraintBottom_toBottomOf="parent">
<TextView <com.futo.platformplayer.views.behavior.SafeTextView
android:id="@+id/text_description" android:id="@+id/text_description"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
+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>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
@@ -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>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
@@ -712,4 +712,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
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.FutoVideo.NoActionBarFitsSystem" parent="Theme.FutoVideo.NoActionBar">
<item name="android:fitsSystemWindows">true</item>
<item name="android:enforceStatusBarContrast">false</item>
<item name="android:enforceNavigationBarContrast">false</item>
</style>
</resources>

Some files were not shown because too many files have changed in this diff Show More