mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-16 04:52:39 +02:00
Compare commits
111 Commits
zvonimir-dev
...
348
| Author | SHA1 | Date | |
|---|---|---|---|
| efe074d272 | |||
| 8a9efd3a0f | |||
| 251302b9c3 | |||
| 5cdac1405e | |||
| 565ea7cb8b | |||
| 9fa3e22d2e | |||
| 5548783337 | |||
| 0dca8798cb | |||
| d902306fe4 | |||
| baa2a4fcf3 | |||
| 8be7ad9f68 | |||
| 992cbcb3a0 | |||
| e0857aea9b | |||
| 50cd0723c9 | |||
| 4c4b322682 | |||
| 7cff8568c0 | |||
| 801c646a09 | |||
| df4ec87613 | |||
| b08a79b7cb | |||
| 396e9f9f43 | |||
| 0e5a87a911 | |||
| 64d72f6d10 | |||
| 5b40da109b | |||
| 949294952f | |||
| 40a058e369 | |||
| a070d78dd9 | |||
| 105ac538bb | |||
| ce2029774e | |||
| 50c63d7e8d | |||
| d3534080d7 | |||
| b5025193a5 | |||
| 3f85b7ed78 | |||
| 98d008ef6c | |||
| 20eb53fc38 | |||
| 1ea7b307fa | |||
| f18571e0b2 | |||
| 70872d429a | |||
| cbf3db6e30 | |||
| 0be0dcfadc | |||
| abd226c33d | |||
| 89dbdc99a0 | |||
| f89ed18a49 | |||
| 77ac2b537c | |||
| 8ab03b6b66 | |||
| dad70e57c6 | |||
| eb9c6c8330 | |||
| 68da797f4d | |||
| 25948dd296 | |||
| 10d39d6ed1 | |||
| 85e8e674dd | |||
| 0d70392bf0 | |||
| f89b074d28 | |||
| ee2af411aa | |||
| 9ffbe6dd03 | |||
| 0ae6ac2fac | |||
| fd835cc54e | |||
| 07f3140038 | |||
| 3e753d70de | |||
| d578c47975 | |||
| b7a61425ca | |||
| 727f977672 | |||
| fc9d5eeb27 | |||
| f17e147b4e | |||
| 1c569b465b | |||
| 6289c85bd5 | |||
| 098599853b | |||
| 68d11f6d58 | |||
| 74f6b9aa62 | |||
| fc2aba0120 | |||
| e4f51bb130 | |||
| 9e9d26c752 | |||
| 5c5dd3af44 | |||
| 4433364cd8 | |||
| 2c957d7188 | |||
| f229f4ed1f | |||
| e8d1f73e29 | |||
| dd2cf18cb2 | |||
| 5355602577 | |||
| 8cc82e4d16 | |||
| d6468ba283 | |||
| 4b5ed38175 | |||
| 75eb7359de | |||
| fd519d48cf | |||
| 6f1866ac27 | |||
| 0dc0f07785 | |||
| bae8cb7bc4 | |||
| d5a696289b | |||
| 75ef7085eb | |||
| 347ef855b3 | |||
| aac19aef86 | |||
| 33efc5c21d | |||
| fc7001c295 | |||
| 9b68394f70 | |||
| e2ef8c2593 | |||
| 551bfe44ac | |||
| 6fbfa98ad3 | |||
| 9b97e05e3b | |||
| 62a2f42d68 | |||
| da44e86163 | |||
| 682b86330e | |||
| c9ba8a09e2 | |||
| 7d19c2357c | |||
| 64030a038c | |||
| 87d93c2ed8 | |||
| 9d9ad52535 | |||
| b10cf6a323 | |||
| 4407e82d8a | |||
| 3113dc53a6 | |||
| bd25276720 | |||
| 29d3a9986e | |||
| 7c70e58129 |
@@ -1,2 +1,6 @@
|
||||
aar/* filter=lfs diff=lfs merge=lfs -text
|
||||
app/aar/* filter=lfs diff=lfs merge=lfs -text
|
||||
app/src/main/jniLibs/arm64-v8a filter=lfs diff=lfs merge=lfs -text
|
||||
app/src/main/jniLibs/armeabi-v7a filter=lfs diff=lfs merge=lfs -text
|
||||
app/src/main/jniLibs/x86 filter=lfs diff=lfs merge=lfs -text
|
||||
app/src/main/jniLibs/x86_64 filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
@@ -64,12 +64,6 @@
|
||||
[submodule "app/src/stable/assets/sources/bilibili"]
|
||||
path = app/src/stable/assets/sources/bilibili
|
||||
url = ../plugins/bilibili.git
|
||||
[submodule "app/src/stable/assets/sources/spotify"]
|
||||
path = app/src/stable/assets/sources/spotify
|
||||
url = ../plugins/spotify.git
|
||||
[submodule "app/src/unstable/assets/sources/spotify"]
|
||||
path = app/src/unstable/assets/sources/spotify
|
||||
url = ../plugins/spotify.git
|
||||
[submodule "app/src/stable/assets/sources/bitchute"]
|
||||
path = app/src/stable/assets/sources/bitchute
|
||||
url = ../plugins/bitchute.git
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ea10d3c5562c9f449a4e89e9c3dfcf881ed79a952f3409bc005bcc62c2cf4b81
|
||||
size 65512557
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:22c06ca0d1a5808b2fc0a12227d5915b3126bc0b9b1305cf6bab855f2ec6fcbb
|
||||
size 36133152
|
||||
+43
-44
@@ -1,8 +1,8 @@
|
||||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
id 'org.jetbrains.kotlin.plugin.serialization' version '1.9.21'
|
||||
id 'org.ajoberstar.grgit' version '5.2.2'
|
||||
id 'org.jetbrains.kotlin.plugin.serialization' version '2.2.21'
|
||||
id 'org.ajoberstar.grgit' version '5.3.3'
|
||||
id 'com.google.protobuf'
|
||||
id 'kotlin-parcelize'
|
||||
id 'com.google.devtools.ksp'
|
||||
@@ -97,7 +97,7 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
minSdk 28
|
||||
targetSdk 35
|
||||
targetSdk 36
|
||||
versionCode gitVersionCode
|
||||
versionName gitVersionName
|
||||
|
||||
@@ -146,6 +146,7 @@ android {
|
||||
}
|
||||
sourceSets {
|
||||
main {
|
||||
jniLibs.srcDirs = ['src/main/jniLibs']
|
||||
assets {
|
||||
srcDirs 'src/main/assets', 'src/tests/assets', 'src/test/assets'
|
||||
}
|
||||
@@ -155,82 +156,80 @@ android {
|
||||
|
||||
dependencies {
|
||||
//implementation 'com.google.dagger:dagger:2.48'
|
||||
implementation 'androidx.test:monitor:1.7.2'
|
||||
implementation 'com.google.android.material:material:1.12.0'
|
||||
implementation 'androidx.test:monitor:1.8.0'
|
||||
implementation 'com.google.android.material:material:1.13.0'
|
||||
//annotationProcessor 'com.google.dagger:dagger-compiler:2.48'
|
||||
|
||||
//Core
|
||||
implementation 'androidx.core:core-ktx:1.12.0'
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
implementation 'com.google.android.material:material:1.11.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||
implementation 'androidx.core:core-ktx:1.17.0'
|
||||
implementation 'androidx.appcompat:appcompat:1.7.1'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.2.1'
|
||||
implementation 'androidx.documentfile:documentfile:1.1.0'
|
||||
|
||||
//Images
|
||||
annotationProcessor 'com.github.bumptech.glide:compiler:4.16.0'
|
||||
implementation 'com.github.bumptech.glide:glide:4.16.0'
|
||||
annotationProcessor 'com.github.bumptech.glide:compiler:5.0.5'
|
||||
implementation 'com.github.bumptech.glide:glide:5.0.5'
|
||||
|
||||
//Async
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2"
|
||||
|
||||
//HTTP
|
||||
implementation "com.squareup.okhttp3:okhttp:4.11.0"
|
||||
implementation "com.squareup.okhttp3:okhttp:5.3.0"
|
||||
|
||||
//JSON
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2" //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 "org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0" //Used for structured json
|
||||
implementation 'com.google.code.gson:gson:2.13.2' //Used for complex/anonymous cases like during development conversions (eg. V8RemoteObject)
|
||||
|
||||
//JS
|
||||
implementation("com.caoccao.javet:javet-android:3.0.2")
|
||||
//implementation 'com.caoccao.javet:javet-v8-android:4.1.4' //Change after extensive testing the freezing edge cases are solved.
|
||||
implementation 'com.caoccao.javet:javet-v8-android:4.1.5'
|
||||
|
||||
//Exoplayer
|
||||
implementation 'androidx.media3:media3-exoplayer:1.2.1'
|
||||
implementation 'androidx.media3:media3-exoplayer-dash:1.2.1'
|
||||
implementation 'androidx.media3:media3-ui:1.2.1'
|
||||
implementation 'androidx.media3:media3-exoplayer-hls:1.2.1'
|
||||
implementation 'androidx.media3:media3-exoplayer-rtsp:1.2.1'
|
||||
implementation 'androidx.media3:media3-exoplayer-smoothstreaming:1.2.1'
|
||||
implementation 'androidx.media3:media3-transformer:1.2.1'
|
||||
implementation 'androidx.navigation:navigation-fragment-ktx:2.7.6'
|
||||
implementation 'androidx.navigation:navigation-ui-ktx:2.7.6'
|
||||
implementation 'androidx.media:media:1.7.0'
|
||||
implementation 'androidx.media3:media3-exoplayer:1.8.0'
|
||||
implementation 'androidx.media3:media3-exoplayer-dash:1.8.0'
|
||||
implementation 'androidx.media3:media3-ui:1.8.0'
|
||||
implementation 'androidx.media3:media3-exoplayer-hls:1.8.0'
|
||||
implementation 'androidx.media3:media3-exoplayer-rtsp:1.8.0'
|
||||
implementation 'androidx.media3:media3-exoplayer-smoothstreaming:1.8.0'
|
||||
implementation 'androidx.media3:media3-transformer:1.8.0'
|
||||
implementation 'androidx.navigation:navigation-fragment-ktx:2.9.6'
|
||||
implementation 'androidx.navigation:navigation-ui-ktx:2.9.6'
|
||||
implementation 'androidx.media:media:1.7.1'
|
||||
|
||||
//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 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||
implementation fileTree(dir: 'aar', include: ['*.aar'])
|
||||
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.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.caverock:androidsvg-aar:1.4'
|
||||
|
||||
//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.futo.futopay:app:1.0'
|
||||
implementation 'androidx.work:work-runtime-ktx:2.9.0'
|
||||
implementation 'androidx.concurrent:concurrent-futures-ktx:1.1.0'
|
||||
implementation 'androidx.work:work-runtime-ktx:2.11.0'
|
||||
implementation 'androidx.concurrent:concurrent-futures-ktx:1.3.0'
|
||||
|
||||
//Database
|
||||
implementation("androidx.room:room-runtime:2.6.1")
|
||||
annotationProcessor("androidx.room:room-compiler:2.6.1")
|
||||
ksp("androidx.room:room-compiler:2.6.1")
|
||||
implementation("androidx.room:room-ktx:2.6.1")
|
||||
implementation("androidx.room:room-runtime:2.8.3")
|
||||
ksp("androidx.room:room-compiler:2.8.3")
|
||||
implementation("androidx.room:room-ktx:2.8.3")
|
||||
|
||||
//Payment
|
||||
implementation 'com.stripe:stripe-android:20.35.1'
|
||||
implementation 'com.stripe:stripe-android:22.0.0'
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
|
||||
testImplementation "org.jetbrains.kotlin:kotlin-test:1.8.22"
|
||||
testImplementation "org.xmlunit:xmlunit-core:2.9.1"
|
||||
testImplementation "org.mockito:mockito-core:5.4.0"
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2'
|
||||
testImplementation "org.jetbrains.kotlin:kotlin-test:2.0.21"
|
||||
testImplementation "org.xmlunit:xmlunit-core:2.11.0"
|
||||
testImplementation "org.mockito:mockito-core:5.20.0"
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.3.0'
|
||||
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') {
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
|
||||
<uses-permission android:name="android.permission.WRITE_SETTINGS" tools:ignore="ProtectedPermissions"/>
|
||||
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
@@ -26,6 +29,8 @@
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.FutoVideo"
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:replace="android:enableOnBackInvokedCallback"
|
||||
android:enableOnBackInvokedCallback="false"
|
||||
tools:targetApi="31"
|
||||
android:largeHeap="true">
|
||||
<provider
|
||||
@@ -58,6 +63,7 @@
|
||||
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|uiMode"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar"
|
||||
android:windowSoftInputMode="adjustPan"
|
||||
android:launchMode="singleInstance"
|
||||
android:resizeableActivity="true"
|
||||
android:supportsPictureInPicture="true">
|
||||
@@ -238,5 +244,13 @@
|
||||
android:name=".activities.SyncShowPairingCodeActivity"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||
<activity
|
||||
android:name=".activities.PolycentricModerationActivity"
|
||||
android:exported="false"
|
||||
android:screenOrientation="portrait" />
|
||||
<activity
|
||||
android:name=".activities.QRCodeFullscreenActivity"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -1025,18 +1025,21 @@
|
||||
|
||||
let settingsToUse = __DEV_SETTINGS ?? {};
|
||||
if (true) {
|
||||
for (let setting of this.Plugin?.currentPlugin?.settings) {
|
||||
if (typeof settingsToUse[setting.variable] == "undefined") {
|
||||
switch (setting?.type?.toLowerCase()) {
|
||||
case "boolean":
|
||||
settingsToUse[setting.variable] = setting.default === 'true';
|
||||
break;
|
||||
case "dropdown":
|
||||
let dropDownIndex = parseInt(setting.default);
|
||||
if (dropDownIndex) {
|
||||
settingsToUse[setting.variable] = setting.options[dropDownIndex];
|
||||
}
|
||||
break;
|
||||
const settings = this.Plugin?.currentPlugin?.settings;
|
||||
if (settings) {
|
||||
for (let setting of settings) {
|
||||
if (typeof settingsToUse[setting.variable] == "undefined") {
|
||||
switch (setting?.type?.toLowerCase()) {
|
||||
case "boolean":
|
||||
settingsToUse[setting.variable] = setting.default === 'true';
|
||||
break;
|
||||
case "dropdown":
|
||||
let dropDownIndex = parseInt(setting.default);
|
||||
if (dropDownIndex) {
|
||||
settingsToUse[setting.variable] = setting.options[dropDownIndex];
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.futo.platformplayer
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
object AppCaUpdater {
|
||||
private const val CA_URL = "https://curl.se/ca/cacert.pem"
|
||||
private const val CACHE_FILENAME = "curl-ca-bundle.pem"
|
||||
private const val MAX_AGE_DAYS = 30
|
||||
|
||||
suspend fun ensureCaBundle(context: Context): File = withContext(Dispatchers.IO) {
|
||||
val file = File(context.noBackupFilesDir, CACHE_FILENAME)
|
||||
val needsUpdate = !file.exists() || isOlderThanDays(file, MAX_AGE_DAYS)
|
||||
if (needsUpdate) {
|
||||
downloadToFile(CA_URL, file)
|
||||
}
|
||||
return@withContext file
|
||||
}
|
||||
|
||||
private fun isOlderThanDays(file: File, days: Int): Boolean {
|
||||
val ageMs = System.currentTimeMillis() - file.lastModified()
|
||||
return ageMs > days * 24L * 60L * 60L * 1000L
|
||||
}
|
||||
|
||||
private fun downloadToFile(urlStr: String, dest: File) {
|
||||
val conn = (URL(urlStr).openConnection() as HttpURLConnection).apply {
|
||||
connectTimeout = 15000
|
||||
readTimeout = 15000
|
||||
instanceFollowRedirects = true
|
||||
}
|
||||
conn.inputStream.use { input ->
|
||||
dest.parentFile?.mkdirs()
|
||||
dest.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
conn.disconnect()
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import com.caoccao.javet.values.reference.V8ValueError
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.caoccao.javet.values.reference.V8ValuePromise
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.engine.V8Plugin
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptExecutionException
|
||||
@@ -387,4 +388,15 @@ suspend fun <T> Deferred<T>.awaitCancelConverted(): T {
|
||||
}
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> IPager<T>.toList(): List<T> {
|
||||
val list = this.getResults().toMutableList();
|
||||
|
||||
while(this.hasMorePages()) {
|
||||
this.nextPage();
|
||||
list.addAll(this.getResults());
|
||||
}
|
||||
|
||||
return list.toList();
|
||||
}
|
||||
@@ -10,11 +10,11 @@ import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.activities.ManageTabsActivity
|
||||
import com.futo.platformplayer.activities.PolycentricHomeActivity
|
||||
import com.futo.platformplayer.activities.PolycentricProfileActivity
|
||||
import com.futo.platformplayer.activities.SettingsActivity
|
||||
import com.futo.platformplayer.activities.SyncHomeActivity
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SettingsFragment
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.serializers.FlexibleBooleanSerializer
|
||||
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
|
||||
@@ -42,7 +42,6 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import java.time.OffsetDateTime
|
||||
@@ -64,7 +63,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@FormField(R.string.sync_grayjay, FieldForm.BUTTON, R.string.sync_grayjay_description, -8)
|
||||
@FormFieldButton(R.drawable.ic_update)
|
||||
fun syncGrayjay() {
|
||||
SettingsActivity.getActivity()?.let {
|
||||
StateApp?.instance?.activity?.let {
|
||||
it.startActivity(Intent(it, SyncHomeActivity::class.java))
|
||||
}
|
||||
}
|
||||
@@ -73,7 +72,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@FormField(R.string.manage_polycentric_identity, FieldForm.BUTTON, R.string.manage_your_polycentric_identity, -7)
|
||||
@FormFieldButton(R.drawable.ic_person)
|
||||
fun managePolycentricIdentity() {
|
||||
SettingsActivity.getActivity()?.let {
|
||||
StateApp?.instance?.activity?.let {
|
||||
if (StatePolycentric.instance.enabled) {
|
||||
if (StatePolycentric.instance.processHandle != null) {
|
||||
it.startActivity(Intent(it, PolycentricProfileActivity::class.java));
|
||||
@@ -91,7 +90,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
fun openFAQ() {
|
||||
try {
|
||||
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(Settings.URL_FAQ))
|
||||
SettingsActivity.getActivity()?.startActivity(browserIntent);
|
||||
StateApp?.instance?.activity?.startActivity(browserIntent);
|
||||
} catch (e: Throwable) {
|
||||
//Ignored
|
||||
}
|
||||
@@ -101,7 +100,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
fun openIssues() {
|
||||
try {
|
||||
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/futo-org/grayjay-android/issues"))
|
||||
SettingsActivity.getActivity()?.startActivity(browserIntent);
|
||||
StateApp?.instance?.activity?.startActivity(browserIntent);
|
||||
} catch (e: Throwable) {
|
||||
//Ignored
|
||||
}
|
||||
@@ -132,7 +131,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@FormFieldButton(R.drawable.ic_tabs)
|
||||
fun manageTabs() {
|
||||
try {
|
||||
SettingsActivity.getActivity()?.let {
|
||||
StateApp?.instance?.activity?.let {
|
||||
it.startActivity(Intent(it, ManageTabsActivity::class.java));
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
@@ -145,7 +144,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@FormField(R.string.import_data, FieldForm.BUTTON, R.string.import_data_description, -3)
|
||||
@FormFieldButton(R.drawable.ic_move_up)
|
||||
fun import() {
|
||||
val act = SettingsActivity.getActivity() ?: return;
|
||||
val act = StateApp.instance.activity ?: return;
|
||||
val intent = MainActivity.getImportOptionsIntent(act);
|
||||
act.startActivity(intent);
|
||||
}
|
||||
@@ -154,7 +153,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@FormFieldButton(R.drawable.ic_link)
|
||||
fun manageLinks() {
|
||||
try {
|
||||
SettingsActivity.getActivity()?.let { UIDialogs.showUrlHandlingPrompt(it) }
|
||||
StateApp.instance.activity?.let { UIDialogs.showUrlHandlingPrompt(it) }
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to show url handling prompt", e)
|
||||
}
|
||||
@@ -163,7 +162,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
/*@FormField(R.string.disable_battery_optimization, FieldForm.BUTTON, R.string.click_to_go_to_battery_optimization_settings_disabling_battery_optimization_will_prevent_the_os_from_killing_media_sessions, -1)
|
||||
@FormFieldButton(R.drawable.battery_full_24px)
|
||||
fun ignoreBatteryOptimization() {
|
||||
SettingsActivity.getActivity()?.let {
|
||||
StateApp.instance.activity?.let {
|
||||
val intent = Intent()
|
||||
val packageName = it.packageName
|
||||
val pm = it.getSystemService(POWER_SERVICE) as PowerManager;
|
||||
@@ -244,7 +243,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
fun clearHidden() {
|
||||
StateMeta.instance.removeAllHiddenCreators();
|
||||
StateMeta.instance.removeAllHiddenVideos();
|
||||
SettingsActivity.getActivity()?.let {
|
||||
StateApp.instance.activity?.let {
|
||||
UIDialogs.toast(it, "Creators and videos should show up again");
|
||||
}
|
||||
}
|
||||
@@ -374,9 +373,9 @@ class Settings : FragmentedStorageFileJson() {
|
||||
|
||||
@FormField(R.string.clear_channel_cache, FieldForm.BUTTON, R.string.clear_channel_cache_description, 16)
|
||||
fun clearChannelCache() {
|
||||
UIDialogs.toast(SettingsActivity.getActivity()!!, "Started clearing..");
|
||||
UIDialogs.toast(StateApp.instance.activity!!, "Started clearing..");
|
||||
StateCache.instance.clear();
|
||||
UIDialogs.toast(SettingsActivity.getActivity()!!, "Finished clearing");
|
||||
UIDialogs.toast(StateApp.instance.activity!!, "Finished clearing");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -408,6 +407,10 @@ class Settings : FragmentedStorageFileJson() {
|
||||
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)
|
||||
var preferOriginalAudio: Boolean = true;
|
||||
|
||||
@@ -426,6 +429,9 @@ class Settings : FragmentedStorageFileJson() {
|
||||
6 -> 1.75f;
|
||||
7 -> 2.0f;
|
||||
8 -> 2.25f;
|
||||
9 -> 2.5f;
|
||||
10 -> 2.75f;
|
||||
11 -> 3.0f;
|
||||
else -> 1.0f;
|
||||
};
|
||||
|
||||
@@ -722,7 +728,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@AdvancedField
|
||||
@FormField(R.string.experimental_cast, FieldForm.TOGGLE, R.string.experimental_cast_description, 6)
|
||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||
var experimentalCasting: Boolean = false
|
||||
var experimentalCasting: Boolean = true
|
||||
|
||||
/*TODO: Should we have a different casting quality?
|
||||
@FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3)
|
||||
@@ -756,7 +762,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
try {
|
||||
if (!Logger.submitLogs()) {
|
||||
withContext(Dispatchers.Main) {
|
||||
SettingsActivity.getActivity()?.let { UIDialogs.toast(it, it.getString(R.string.please_enable_logging_to_submit_logs)) }
|
||||
StateApp.instance.activity?.let { UIDialogs.toast(it, it.getString(R.string.please_enable_logging_to_submit_logs)) }
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
@@ -773,7 +779,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@FormField(R.string.reset_announcements, FieldForm.BUTTON, R.string.reset_hidden_announcements, 1)
|
||||
fun resetAnnouncements() {
|
||||
StateAnnouncement.instance.resetAnnouncements();
|
||||
SettingsActivity.getActivity()?.let { UIDialogs.toast(it, it.getString(R.string.announcements_reset)); };
|
||||
StateApp.instance.activity?.let { UIDialogs.toast(it, it.getString(R.string.announcements_reset)); };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -841,13 +847,13 @@ class Settings : FragmentedStorageFileJson() {
|
||||
|
||||
@FormField(R.string.change_external_general_directory, FieldForm.BUTTON, R.string.change_the_external_directory_for_general_files, 3)
|
||||
fun changeStorageGeneral() {
|
||||
SettingsActivity.getActivity()?.let {
|
||||
StateApp.instance.activity?.let {
|
||||
StateApp.instance.changeExternalGeneralDirectory(it);
|
||||
}
|
||||
}
|
||||
@FormField(R.string.change_external_downloads_directory, FieldForm.BUTTON, R.string.change_the_external_storage_for_download_files, 4)
|
||||
fun changeStorageDownload() {
|
||||
SettingsActivity.getActivity()?.let {
|
||||
StateApp.instance.activity?.let {
|
||||
StateApp.instance.changeExternalDownloadDirectory(it);
|
||||
}
|
||||
}
|
||||
@@ -856,7 +862,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
fun clearStorageDownload() {
|
||||
Settings.instance.storage.storage_download = null;
|
||||
Settings.instance.save();
|
||||
SettingsActivity.getActivity()?.let { UIDialogs.toast(it, "Cleared download storage directory") };
|
||||
StateApp.instance.activity?.let { UIDialogs.toast(it, "Cleared download storage directory") };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -893,13 +899,13 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@FormField(R.string.manual_check, FieldForm.BUTTON, R.string.manually_check_for_updates, 3)
|
||||
fun manualCheck() {
|
||||
if (!BuildConfig.IS_PLAYSTORE_BUILD) {
|
||||
SettingsActivity.getActivity()?.let {
|
||||
StateApp.instance.activity?.let {
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
StateUpdate.instance.checkForUpdates(it, true)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
SettingsActivity.getActivity()?.let {
|
||||
StateApp.instance.activity?.let {
|
||||
try {
|
||||
it.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=${it.packageName}")))
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
@@ -911,7 +917,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
|
||||
@FormField(R.string.view_changelog, FieldForm.BUTTON, R.string.review_the_current_and_past_changelogs, 4)
|
||||
fun viewChangelog() {
|
||||
SettingsActivity.getActivity()?.let {
|
||||
StateApp.instance.activity?.let {
|
||||
UIDialogs.toast(it.getString(R.string.retrieving_changelog));
|
||||
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
@@ -951,7 +957,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
class Backup {
|
||||
@Serializable(with = OffsetDateTimeSerializer::class)
|
||||
var lastAutoBackupTime: OffsetDateTime = OffsetDateTime.MIN;
|
||||
var didAskAutoBackup: Boolean = false;
|
||||
var didAskAutoBackup: Boolean = true;
|
||||
var autoBackupPassword: String? = null;
|
||||
fun shouldAutomaticBackup() = autoBackupPassword != null;
|
||||
|
||||
@@ -960,13 +966,13 @@ class Settings : FragmentedStorageFileJson() {
|
||||
|
||||
@FormField(R.string.set_automatic_backup, FieldForm.BUTTON, R.string.configure_daily_backup_in_case_of_catastrophic_failure, 1)
|
||||
fun configureAutomaticBackup() {
|
||||
UIDialogs.showAutomaticBackupDialog(SettingsActivity.getActivity()!!, autoBackupPassword != null) {
|
||||
SettingsActivity.getActivity()?.reloadSettings();
|
||||
UIDialogs.showAutomaticBackupDialog(StateApp.instance.activity!!, autoBackupPassword != null) {
|
||||
SettingsFragment.currentView?.reloadSettings();
|
||||
};
|
||||
}
|
||||
@FormField(R.string.restore_automatic_backup, FieldForm.BUTTON, R.string.restore_a_previous_automatic_backup, 2)
|
||||
fun restoreAutomaticBackup() {
|
||||
val activity = SettingsActivity.getActivity()!!
|
||||
val activity = StateApp.instance.activity!!
|
||||
|
||||
if(!StateBackup.hasAutomaticBackup())
|
||||
UIDialogs.toast(activity, activity.getString(R.string.you_don_t_have_any_automatic_backups), false);
|
||||
@@ -977,8 +983,9 @@ class Settings : FragmentedStorageFileJson() {
|
||||
|
||||
@FormField(R.string.export_data, FieldForm.BUTTON, R.string.creates_a_zip_file_with_your_data_which_can_be_imported_by_opening_it_with_grayjay, 3)
|
||||
fun export() {
|
||||
val activity = SettingsActivity.getActivity() ?: return;
|
||||
UISlideOverlays.showOverlay(activity.overlay, "Select export type", null, {},
|
||||
val activity = StateApp.instance.activity ?: return;
|
||||
val fragView = SettingsFragment.currentView ?: return;
|
||||
UISlideOverlays.showOverlay(fragView.overlay, "Select export type", null, {},
|
||||
SlideUpMenuItem(activity, R.drawable.ic_share, "Share", "", tag = null, call = {
|
||||
StateBackup.shareExternalBackup();
|
||||
}),
|
||||
@@ -994,11 +1001,11 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@Serializable
|
||||
class Payment {
|
||||
@FormField(R.string.payment_status, FieldForm.READONLYTEXT, -1, 1)
|
||||
val paymentStatus: String get() = SettingsActivity.getActivity()?.let { if (StatePayment.instance.hasPaid) it.getString(R.string.paid) else it.getString(R.string.not_paid); } ?: "Unknown";
|
||||
val paymentStatus: String get() = StateApp.instance.activity?.let { if (StatePayment.instance.hasPaid) it.getString(R.string.paid) else it.getString(R.string.not_paid); } ?: "Unknown";
|
||||
|
||||
@FormField(R.string.license_status, FieldForm.BUTTON, R.string.view_license_status, 2)
|
||||
fun viewLicenseStatus() {
|
||||
SettingsActivity.getActivity()?.let {
|
||||
StateApp.instance.activity?.let {
|
||||
try {
|
||||
if (StatePayment.instance.hasPaid) {
|
||||
val paymentKey = StatePayment.instance.getPaymentKey()
|
||||
@@ -1014,12 +1021,12 @@ class Settings : FragmentedStorageFileJson() {
|
||||
|
||||
@FormField(R.string.clear_payment, FieldForm.BUTTON, R.string.deletes_license_keys_from_app, 3)
|
||||
fun clearPayment() {
|
||||
SettingsActivity.getActivity()?.let { context ->
|
||||
StateApp.instance.activity?.let { context ->
|
||||
UIDialogs.showConfirmationDialog(context, "Are you sure you want to delete your license?", {
|
||||
StatePayment.instance.clearLicenses();
|
||||
SettingsActivity.getActivity()?.let {
|
||||
StateApp.instance.activity?.let {
|
||||
UIDialogs.toast(it, it.getString(R.string.licenses_cleared_might_require_app_restart));
|
||||
it.reloadSettings();
|
||||
SettingsFragment.currentView?.reloadSettings();
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1116,7 +1123,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@AdvancedField
|
||||
@FormField(R.string.configure_sync_server, FieldForm.BUTTON, R.string.configure_sync_server_description, 7)
|
||||
fun configureSyncServer() {
|
||||
SettingsActivity.getActivity()?.let { context ->
|
||||
StateApp.instance.activity?.let { context ->
|
||||
UIDialogs.showDialog(context, R.drawable.device_sync, false,
|
||||
"Enter the url to your relay server",
|
||||
"Using your own relay server requires a proper setup with portforwarding.\nUse at your own risk.",
|
||||
@@ -1127,13 +1134,13 @@ class Settings : FragmentedStorageFileJson() {
|
||||
UIDialogs.Action("Reset", {
|
||||
syncServerUrl = null;
|
||||
instance.save();
|
||||
context.reloadSettings();
|
||||
SettingsFragment.currentView?.reloadSettings();
|
||||
UIDialogs.toast("Sync server changes require a restart");
|
||||
}, UIDialogs.ActionStyle.ACCENT),
|
||||
UIDialogs.Action.withInput("Configure", {
|
||||
syncServerUrl = it?.text
|
||||
instance.save();
|
||||
context.reloadSettings();
|
||||
SettingsFragment.currentView?.reloadSettings();
|
||||
UIDialogs.toast("Sync server changes require a restart");
|
||||
}, UIDialogs.ActionStyle.PRIMARY),
|
||||
)
|
||||
|
||||
@@ -8,9 +8,7 @@ import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import com.caoccao.javet.values.primitive.V8ValueInteger
|
||||
import com.caoccao.javet.values.primitive.V8ValueString
|
||||
import com.futo.platformplayer.activities.DeveloperActivity
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.activities.SettingsActivity
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
@@ -20,6 +18,8 @@ import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.background.BackgroundWorker
|
||||
import com.futo.platformplayer.engine.V8Plugin
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.DeveloperFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SettingsFragment
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.serializers.FlexibleBooleanSerializer
|
||||
import com.futo.platformplayer.states.StateAnnouncement
|
||||
@@ -97,10 +97,10 @@ class SettingsDev : FragmentedStorageFileJson() {
|
||||
fun subscriptionsCache5000() {
|
||||
Logger.i("SettingsDev", "Started caching 5000 sub items");
|
||||
UIDialogs.toast(
|
||||
SettingsActivity.getActivity()!!,
|
||||
StateApp.instance.activity!!,
|
||||
"Started caching 5000 sub items"
|
||||
);
|
||||
val button = DeveloperActivity.getActivity()?.getField("subscription_cache_button");
|
||||
val button = DeveloperFragment.currentView?.getField("subscription_cache_button");
|
||||
if(button is ButtonField)
|
||||
button.setButtonEnabled(false);
|
||||
StateApp.instance.scope.launch(Dispatchers.IO) {
|
||||
@@ -121,7 +121,7 @@ class SettingsDev : FragmentedStorageFileJson() {
|
||||
val diff = System.currentTimeMillis() - lastToast;
|
||||
lastToast = System.currentTimeMillis();
|
||||
UIDialogs.toast(
|
||||
SettingsActivity.getActivity()!!,
|
||||
StateApp.instance.activity!!,
|
||||
"Page: ${page}, Total: ${total}, Speed: ${diff}ms"
|
||||
);
|
||||
}
|
||||
@@ -130,7 +130,7 @@ class SettingsDev : FragmentedStorageFileJson() {
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.toast(
|
||||
SettingsActivity.getActivity()!!,
|
||||
StateApp.instance.activity!!,
|
||||
"FINISHED Page: ${page}, Total: ${total}"
|
||||
);
|
||||
}
|
||||
@@ -152,10 +152,10 @@ class SettingsDev : FragmentedStorageFileJson() {
|
||||
fun historyCache100() {
|
||||
Logger.i("SettingsDev", "Started caching 100 history items (from home)");
|
||||
UIDialogs.toast(
|
||||
SettingsActivity.getActivity()!!,
|
||||
StateApp.instance.activity!!,
|
||||
"Started caching 100 history items (from home)"
|
||||
);
|
||||
val button = DeveloperActivity.getActivity()?.getField("history_cache_button");
|
||||
val button = DeveloperFragment.currentView?.getField("history_cache_button");
|
||||
if(button is ButtonField)
|
||||
button.setButtonEnabled(false);
|
||||
StateApp.instance.scope.launch(Dispatchers.IO) {
|
||||
@@ -186,7 +186,7 @@ class SettingsDev : FragmentedStorageFileJson() {
|
||||
val diff = System.currentTimeMillis() - lastToast;
|
||||
lastToast = System.currentTimeMillis();
|
||||
UIDialogs.toast(
|
||||
SettingsActivity.getActivity()!!,
|
||||
StateApp.instance.activity!!,
|
||||
"Page: ${page}, Total: ${total}, Speed: ${diff}ms"
|
||||
);
|
||||
}
|
||||
@@ -195,7 +195,7 @@ class SettingsDev : FragmentedStorageFileJson() {
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.toast(
|
||||
SettingsActivity.getActivity()!!,
|
||||
StateApp.instance.activity!!,
|
||||
"FINISHED Page: ${page}, Total: ${total}"
|
||||
);
|
||||
}
|
||||
@@ -235,9 +235,9 @@ class SettingsDev : FragmentedStorageFileJson() {
|
||||
@FormField(R.string.test_background_worker, FieldForm.BUTTON,
|
||||
R.string.test_background_worker_description, 4)
|
||||
fun triggerBackgroundUpdate() {
|
||||
val act = SettingsActivity.getActivity()!!;
|
||||
val act = StateApp.instance.activity!!;
|
||||
try {
|
||||
UIDialogs.toast(SettingsActivity.getActivity()!!, "Starting test background worker");
|
||||
UIDialogs.toast(StateApp.instance.activity!!, "Starting test background worker");
|
||||
|
||||
val wm = WorkManager.getInstance(act);
|
||||
val req = OneTimeWorkRequestBuilder<BackgroundWorker>()
|
||||
@@ -251,9 +251,9 @@ class SettingsDev : FragmentedStorageFileJson() {
|
||||
@FormField(R.string.clear_channel_cache, FieldForm.BUTTON,
|
||||
R.string.test_background_worker_description, 4)
|
||||
fun clearChannelContentCache() {
|
||||
UIDialogs.toast(SettingsActivity.getActivity()!!, "Clearing cache");
|
||||
UIDialogs.toast(StateApp.instance.activity!!, "Clearing cache");
|
||||
StateCache.instance.clearToday();
|
||||
UIDialogs.toast(SettingsActivity.getActivity()!!, "Cleared");
|
||||
UIDialogs.toast(StateApp.instance.activity!!, "Cleared");
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist
|
||||
import androidx.media3.exoplayer.hls.playlist.HlsMultivariantPlaylist
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.activities.SettingsActivity
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.media.models.ResultCapabilities
|
||||
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
||||
@@ -74,6 +73,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.ByteArrayInputStream
|
||||
import androidx.core.net.toUri
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SettingsFragment
|
||||
|
||||
class UISlideOverlays {
|
||||
companion object {
|
||||
@@ -331,15 +331,9 @@ class UISlideOverlays {
|
||||
0,
|
||||
UIDialogs.Action("Cancel", {}),
|
||||
UIDialogs.Action("Configure", {
|
||||
val intent = Intent(
|
||||
mainContext,
|
||||
SettingsActivity::class.java
|
||||
);
|
||||
intent.putExtra(
|
||||
"query",
|
||||
mainContext.getString(R.string.background_update)
|
||||
);
|
||||
mainContext.startActivity(intent);
|
||||
StateApp.instance.activity?.let {
|
||||
it.navigate(it.getFragment<SettingsFragment>(), mainContext.getString(R.string.background_update))
|
||||
}
|
||||
}, UIDialogs.ActionStyle.PRIMARY)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ fun String.isHexColor(): Boolean {
|
||||
|
||||
fun IPlatformClient.fromPool(pool: PlatformMultiClientPool) = pool.getClientPooled(this);
|
||||
|
||||
fun IPlatformVideo.withTimestamp(sec: Long) = PlatformVideoWithTime(this, sec);
|
||||
fun IPlatformVideo.withTimestamp(sec: Long) = PlatformVideoWithTime(this, sec);
|
||||
|
||||
fun DocumentFile.getInputStream(context: Context) = context.contentResolver.openInputStream(this.uri);
|
||||
fun DocumentFile.getOutputStream(context: Context) = context.contentResolver.openOutputStream(this.uri);
|
||||
|
||||
@@ -107,10 +107,9 @@ class AddSourceActivity : AppCompatActivity() {
|
||||
onNewIntent(intent);
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent?) {
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
var url = intent?.dataString;
|
||||
|
||||
var url = intent.dataString;
|
||||
if(url == 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));
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
package com.futo.platformplayer.activities
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Bundle
|
||||
import android.widget.ImageButton
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.views.fields.FieldForm
|
||||
import com.futo.platformplayer.views.fields.IField
|
||||
|
||||
class DeveloperActivity : AppCompatActivity() {
|
||||
private lateinit var _form: FieldForm;
|
||||
private lateinit var _buttonBack: ImageButton;
|
||||
|
||||
fun getField(id: String): IField? {
|
||||
return _form.findField(id);
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
DeveloperActivity._lastActivity = this;
|
||||
setContentView(R.layout.activity_dev);
|
||||
setNavigationBarColorAndIcons();
|
||||
|
||||
_buttonBack = findViewById(R.id.button_back);
|
||||
_form = findViewById(R.id.settings_form);
|
||||
|
||||
_form.fromObject(SettingsDev.instance);
|
||||
_form.onChanged.subscribe { _, _ ->
|
||||
_form.setObjectValues();
|
||||
SettingsDev.instance.save();
|
||||
};
|
||||
|
||||
_buttonBack.setOnClickListener {
|
||||
finish();
|
||||
}
|
||||
}
|
||||
|
||||
override fun finish() {
|
||||
super.finish()
|
||||
overridePendingTransition(R.anim.slide_lighten, R.anim.slide_out_up)
|
||||
}
|
||||
|
||||
|
||||
|
||||
companion object {
|
||||
//TODO: Temporary for solving Settings issues
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
private var _lastActivity: DeveloperActivity? = null;
|
||||
|
||||
fun getActivity(): DeveloperActivity? {
|
||||
val act = _lastActivity;
|
||||
if(act != null)
|
||||
return act;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import android.content.Intent
|
||||
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Color
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
@@ -52,17 +53,28 @@ import com.futo.platformplayer.fragment.mainactivity.main.CommentsFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.ContentSearchResultsFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.CreatorSearchResultsFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.CreatorsFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.DeveloperFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.DownloadsFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.HistoryFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.HomeFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.ImportPlaylistsFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.ImportSubscriptionsFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.LibraryAlbumFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.LibraryAlbumsFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.LibraryArtistFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.LibraryArtistsFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.LibraryFilesFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.LibraryFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.LibrarySearchFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.LibraryVideosFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.LoginFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.MainFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.PlaylistFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.PlaylistSearchResultsFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.PlaylistsFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.PostDetailFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.RemotePlaylistFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SettingsFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.ShortsFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SourceDetailFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SourcesFragment
|
||||
@@ -76,6 +88,7 @@ import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment.St
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.WatchLaterFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.WebDetailFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.topbar.AddTopBarFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.topbar.FilesTopBarFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.topbar.GeneralTopBarFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.topbar.ImportTopBarFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFragment
|
||||
@@ -147,6 +160,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
lateinit var _fragTopBarNavigation: NavigationTopBarFragment;
|
||||
lateinit var _fragTopBarImport: ImportTopBarFragment;
|
||||
lateinit var _fragTopBarAdd: AddTopBarFragment;
|
||||
lateinit var _fragTopBarFiles: FilesTopBarFragment;
|
||||
|
||||
//Frags BotBar
|
||||
lateinit var _fragBotBarMenu: MenuBottomBarFragment;
|
||||
@@ -179,6 +193,17 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
lateinit var _fragBuy: BuyFragment;
|
||||
lateinit var _fragSubGroup: SubscriptionGroupFragment;
|
||||
lateinit var _fragSubGroupList: SubscriptionGroupListFragment;
|
||||
lateinit var _fragLibrary: LibraryFragment;
|
||||
lateinit var _fragLibraryAlbums: LibraryAlbumsFragment;
|
||||
lateinit var _fragLibraryAlbum: LibraryAlbumFragment;
|
||||
lateinit var _fragLibraryArtists: LibraryArtistsFragment;
|
||||
lateinit var _fragLibraryArtist: LibraryArtistFragment;
|
||||
lateinit var _fragLibraryVideos: LibraryVideosFragment;
|
||||
lateinit var _fragLibrarySearch: LibrarySearchFragment;
|
||||
lateinit var _fragLibraryFiles: LibraryFilesFragment;
|
||||
lateinit var _fragSettings: SettingsFragment;
|
||||
lateinit var _fragDeveloper: DeveloperFragment;
|
||||
lateinit var _fragLogin: LoginFragment;
|
||||
|
||||
lateinit var _fragBrowser: BrowserFragment;
|
||||
|
||||
@@ -187,7 +212,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
|
||||
//State
|
||||
private val _queue: LinkedList<Pair<MainFragment, Any?>> = LinkedList();
|
||||
lateinit var fragCurrent: MainFragment private set;
|
||||
var fragCurrent: MainFragment? = null; private set;
|
||||
private var _parameterCurrent: Any? = null;
|
||||
|
||||
var fragBeforeOverlay: MainFragment? = null; private set;
|
||||
@@ -220,6 +245,19 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
}
|
||||
}
|
||||
}
|
||||
private val _notifPermission = "android.permission.POST_NOTIFICATIONS";
|
||||
private val _notificationPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
|
||||
if (isGranted)
|
||||
UIDialogs.toast(this, "Notification permission granted");
|
||||
else
|
||||
UIDialogs.toast(this, "Notification permission denied");
|
||||
};
|
||||
|
||||
|
||||
|
||||
fun requestNotificationPermissions() {
|
||||
_notificationPermissionLauncher?.launch(_notifPermission);
|
||||
}
|
||||
|
||||
val mainId = UUID.randomUUID().toString().substring(0, 5)
|
||||
|
||||
@@ -275,6 +313,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
@UnstableApi
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
Logger.w(TAG, "MainActivity Starting [$mainId]");
|
||||
|
||||
StateApp.instance.setGlobalContext(this, lifecycleScope, mainId);
|
||||
StateApp.instance.mainAppStarting(this);
|
||||
|
||||
@@ -294,6 +333,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
}
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
window.isNavigationBarContrastEnforced = false
|
||||
}
|
||||
|
||||
//Preload common files to memory
|
||||
FragmentedStorage.get<SubscriptionStorage>();
|
||||
FragmentedStorage.get<Settings>();
|
||||
@@ -318,6 +361,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
_fragTopBarNavigation = NavigationTopBarFragment.newInstance();
|
||||
_fragTopBarImport = ImportTopBarFragment.newInstance();
|
||||
_fragTopBarAdd = AddTopBarFragment.newInstance();
|
||||
_fragTopBarFiles = FilesTopBarFragment.newInstance();
|
||||
|
||||
//BotBars
|
||||
_fragBotBarMenu = MenuBottomBarFragment.newInstance();
|
||||
@@ -350,6 +394,17 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
_fragBuy = BuyFragment.newInstance();
|
||||
_fragSubGroup = SubscriptionGroupFragment.newInstance();
|
||||
_fragSubGroupList = SubscriptionGroupListFragment.newInstance();
|
||||
_fragLibrary = LibraryFragment.newInstance();
|
||||
_fragLibraryAlbums = LibraryAlbumsFragment.newInstance();
|
||||
_fragLibraryAlbum = LibraryAlbumFragment.newInstance();
|
||||
_fragLibraryArtists = LibraryArtistsFragment.newInstance();
|
||||
_fragLibraryArtist = LibraryArtistFragment.newInstance();
|
||||
_fragLibraryVideos = LibraryVideosFragment.newInstance();
|
||||
_fragLibraryFiles = LibraryFilesFragment.newInstance();
|
||||
_fragLibrarySearch = LibrarySearchFragment.newInstance();
|
||||
_fragSettings = SettingsFragment.newInstance();
|
||||
_fragDeveloper = DeveloperFragment.newInstance();
|
||||
_fragLogin = LoginFragment.newInstance();
|
||||
|
||||
_fragBrowser = BrowserFragment.newInstance();
|
||||
|
||||
@@ -481,6 +536,16 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
_fragImportSubscriptions.topBar = _fragTopBarImport;
|
||||
_fragImportPlaylists.topBar = _fragTopBarImport;
|
||||
_fragSubGroupList.topBar = _fragTopBarAdd;
|
||||
_fragLibrary.topBar = _fragTopBarGeneral;
|
||||
_fragLibraryAlbums.topBar = _fragTopBarNavigation;
|
||||
_fragLibraryAlbum.topBar = _fragTopBarNavigation;
|
||||
_fragLibraryArtists.topBar = _fragTopBarNavigation;
|
||||
_fragLibraryArtist.topBar = _fragTopBarNavigation;
|
||||
_fragLibraryVideos.topBar = _fragTopBarNavigation;
|
||||
_fragLibraryFiles.topBar = _fragTopBarFiles;
|
||||
_fragLibrarySearch.topBar = _fragTopBarSearch;
|
||||
_fragSettings.topBar = _fragTopBarNavigation;
|
||||
_fragDeveloper.topBar = _fragTopBarNavigation;
|
||||
|
||||
_fragBrowser.topBar = _fragTopBarNavigation;
|
||||
|
||||
@@ -506,7 +571,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
defaultTab.action(_fragBotBarMenu);
|
||||
StateSubscriptions.instance;
|
||||
|
||||
fragCurrent.onShown(null, false);
|
||||
fragCurrent?.onShown(null, false);
|
||||
|
||||
//Other stuff
|
||||
rootView.progress = 0f;
|
||||
@@ -708,17 +773,13 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
_wasStopped = true;
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent?) {
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent);
|
||||
handleIntent(intent);
|
||||
}
|
||||
|
||||
private fun handleIntent(intent: Intent?) {
|
||||
if (intent == null)
|
||||
return;
|
||||
private fun handleIntent(intent: Intent) {
|
||||
Logger.i(TAG, "handleIntent started by " + intent.action);
|
||||
|
||||
|
||||
var targetData: String? = null;
|
||||
|
||||
when (intent.action) {
|
||||
@@ -1097,7 +1158,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
if (_fragVideoDetail.state == VideoDetailFragment.State.MAXIMIZED && _fragVideoDetail.onBackPressed())
|
||||
return;
|
||||
|
||||
if (!fragCurrent.onBackPressed())
|
||||
if (!(fragCurrent?.onBackPressed() ?: true))
|
||||
closeSegment();
|
||||
}
|
||||
|
||||
@@ -1148,6 +1209,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <reified T : Fragment> navigate(parameter: Any? = null, withHistory: Boolean = true, isBack: Boolean = false) {
|
||||
val segment = getFragment<T>();
|
||||
navigate(segment as MainFragment, parameter, withHistory, isBack);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate takes a MainFragment, and makes them the current main visible view
|
||||
* A parameter can be provided which becomes available in the onShow of said fragment
|
||||
@@ -1170,27 +1236,27 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
return;
|
||||
}
|
||||
|
||||
fragCurrent.onHide();
|
||||
fragCurrent?.onHide();
|
||||
|
||||
if (segment.isMainView) {
|
||||
var transaction = supportFragmentManager.beginTransaction();
|
||||
if (segment.topBar != null) {
|
||||
if (segment.topBar != fragCurrent.topBar) {
|
||||
if (segment.topBar != fragCurrent?.topBar) {
|
||||
transaction = transaction
|
||||
.show(segment.topBar as Fragment)
|
||||
.replace(R.id.fragment_top_bar, segment.topBar as Fragment);
|
||||
fragCurrent.topBar?.onHide();
|
||||
fragCurrent?.topBar?.onHide();
|
||||
}
|
||||
} else if (fragCurrent.topBar != null)
|
||||
transaction.hide(fragCurrent.topBar as Fragment);
|
||||
} else if (fragCurrent?.topBar != null)
|
||||
transaction.hide(fragCurrent?.topBar as Fragment);
|
||||
|
||||
transaction = transaction.replace(R.id.fragment_main, segment);
|
||||
|
||||
if (segment.hasBottomBar) {
|
||||
if (!fragCurrent.hasBottomBar)
|
||||
if (!(fragCurrent?.hasBottomBar ?: false))
|
||||
transaction = transaction.show(_fragBotBarMenu);
|
||||
} else {
|
||||
if (fragCurrent.hasBottomBar)
|
||||
if (fragCurrent?.hasBottomBar ?: false)
|
||||
transaction = transaction.hide(_fragBotBarMenu);
|
||||
}
|
||||
transaction.commitNow();
|
||||
@@ -1203,10 +1269,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
}
|
||||
}
|
||||
|
||||
if (fragCurrent.isHistory && withHistory && _queue.lastOrNull() != fragCurrent)
|
||||
_queue.add(Pair(fragCurrent, _parameterCurrent));
|
||||
if (fragCurrent?.isHistory ?: false && withHistory && _queue.lastOrNull() != fragCurrent)
|
||||
_queue.add(Pair(fragCurrent!!, _parameterCurrent));
|
||||
|
||||
if (segment.isOverlay && !fragCurrent.isOverlay && withHistory)// && fragCurrent.isHistory)
|
||||
if (segment.isOverlay && !(fragCurrent?.isOverlay ?: false) && withHistory)// && fragCurrent.isHistory)
|
||||
fragBeforeOverlay = fragCurrent;
|
||||
|
||||
fragCurrent = segment;
|
||||
@@ -1260,6 +1326,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
VideoDetailFragment::class -> _fragVideoDetail as T;
|
||||
MenuBottomBarFragment::class -> _fragBotBarMenu as T;
|
||||
GeneralTopBarFragment::class -> _fragTopBarGeneral as T;
|
||||
FilesTopBarFragment::class -> _fragTopBarFiles as T;
|
||||
SearchTopBarFragment::class -> _fragTopBarSearch as T;
|
||||
CreatorsFragment::class -> _fragMainSubscriptions as T;
|
||||
CommentsFragment::class -> _fragMainComments as T;
|
||||
@@ -1284,6 +1351,17 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
BuyFragment::class -> _fragBuy as T;
|
||||
SubscriptionGroupFragment::class -> _fragSubGroup as T;
|
||||
SubscriptionGroupListFragment::class -> _fragSubGroupList as T;
|
||||
LibraryFragment::class -> _fragLibrary as T;
|
||||
LibraryAlbumsFragment::class -> _fragLibraryAlbums as T;
|
||||
LibraryAlbumFragment::class -> _fragLibraryAlbum as T;
|
||||
LibraryArtistsFragment::class -> _fragLibraryArtists as T;
|
||||
LibraryArtistFragment::class -> _fragLibraryArtist as T;
|
||||
LibraryVideosFragment::class -> _fragLibraryVideos as T;
|
||||
LibraryFilesFragment::class -> _fragLibraryFiles as T;
|
||||
LibrarySearchFragment::class -> _fragLibrarySearch as T;
|
||||
SettingsFragment:: class -> _fragSettings as T;
|
||||
DeveloperFragment::class -> _fragDeveloper as T;
|
||||
LoginFragment::class -> _fragLogin as T;
|
||||
else -> throw IllegalArgumentException("Fragment type ${T::class.java.name} is not available in MainActivity");
|
||||
}
|
||||
}
|
||||
@@ -1291,7 +1369,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
|
||||
private fun updateSegmentPaddings() {
|
||||
var paddingBottom = 0f;
|
||||
if (fragCurrent.hasBottomBar)
|
||||
if (fragCurrent?.hasBottomBar ?: false)
|
||||
paddingBottom += HEIGHT_MENU_DP;
|
||||
|
||||
_fragContainerOverlay.setPadding(
|
||||
@@ -1308,6 +1386,23 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
);
|
||||
}
|
||||
|
||||
var _callbackPermissionAudio: ((Boolean)->Unit)? = null;
|
||||
var _callbackPermissionVideo: ((Boolean)->Unit)? = null;
|
||||
val permissionReqAudio = registerForActivityResult(ActivityResultContracts.RequestPermission(), { isGranted ->
|
||||
_callbackPermissionAudio?.invoke(isGranted);
|
||||
});
|
||||
val permissionReqVideo = registerForActivityResult(ActivityResultContracts.RequestPermission(), { isGranted ->
|
||||
_callbackPermissionVideo?.invoke(isGranted);
|
||||
});
|
||||
fun requestPermissionAudio(cb: ((Boolean)->Unit)? = null) {
|
||||
_callbackPermissionAudio = cb;
|
||||
permissionReqAudio.launch(android.Manifest.permission.READ_MEDIA_AUDIO);
|
||||
}
|
||||
fun requestPermissionVideo(cb: ((Boolean)->Unit)? = null) {
|
||||
_callbackPermissionVideo = cb;
|
||||
permissionReqVideo.launch(android.Manifest.permission.READ_MEDIA_VIDEO);
|
||||
}
|
||||
|
||||
|
||||
val notifPermission = "android.permission.POST_NOTIFICATIONS";
|
||||
val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
|
||||
|
||||
@@ -13,15 +13,18 @@ import android.view.View
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateApp.Companion.withContext
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
import com.futo.platformplayer.views.buttons.BigButton
|
||||
import com.futo.platformplayer.activities.QRCodeFullscreenActivity
|
||||
import com.futo.polycentric.core.ContentType
|
||||
import com.futo.polycentric.core.SignedEvent
|
||||
import com.futo.polycentric.core.StorageTypeCRDTItem
|
||||
@@ -29,8 +32,10 @@ import com.futo.polycentric.core.StorageTypeCRDTSetItem
|
||||
import com.futo.polycentric.core.Store
|
||||
import com.futo.polycentric.core.toBase64Url
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.EncodeHintType
|
||||
import com.google.zxing.MultiFormatWriter
|
||||
import com.google.zxing.common.BitMatrix
|
||||
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -41,11 +46,27 @@ import userpackage.Protocol.URLInfo
|
||||
class PolycentricBackupActivity : AppCompatActivity() {
|
||||
private lateinit var _buttonShare: BigButton;
|
||||
private lateinit var _buttonCopy: BigButton;
|
||||
private lateinit var _buttonExportFile: BigButton;
|
||||
private lateinit var _imageQR: ImageView;
|
||||
private lateinit var _exportBundle: String;
|
||||
private lateinit var _textQR: TextView;
|
||||
private lateinit var _textQRHint: TextView;
|
||||
private lateinit var _loader: View
|
||||
|
||||
private val _createDocumentLauncher = registerForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri ->
|
||||
uri?.let { fileUri ->
|
||||
try {
|
||||
contentResolver.openOutputStream(fileUri)?.use { outputStream ->
|
||||
outputStream.write(_exportBundle.toByteArray())
|
||||
}
|
||||
UIDialogs.toast(this, getString(R.string.profile_saved_successfully))
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Failed to write to document", e)
|
||||
UIDialogs.toast(this, "Failed to save profile: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
@@ -57,8 +78,10 @@ class PolycentricBackupActivity : AppCompatActivity() {
|
||||
|
||||
_buttonShare = findViewById(R.id.button_share)
|
||||
_buttonCopy = findViewById(R.id.button_copy)
|
||||
_buttonExportFile = findViewById(R.id.button_export_file)
|
||||
_imageQR = findViewById(R.id.image_qr)
|
||||
_textQR = findViewById(R.id.text_qr)
|
||||
_textQRHint = findViewById(R.id.text_qr_hint)
|
||||
_loader = findViewById(R.id.progress_loader)
|
||||
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
|
||||
finish();
|
||||
@@ -66,14 +89,23 @@ class PolycentricBackupActivity : AppCompatActivity() {
|
||||
|
||||
_imageQR.visibility = View.INVISIBLE
|
||||
_textQR.visibility = View.INVISIBLE
|
||||
_textQRHint.visibility = View.INVISIBLE
|
||||
_loader.visibility = View.VISIBLE
|
||||
_buttonShare.visibility = View.INVISIBLE
|
||||
_buttonCopy.visibility = View.INVISIBLE
|
||||
_buttonExportFile.visibility = View.INVISIBLE
|
||||
|
||||
lifecycleScope.launch {
|
||||
val bundle = withContext(Dispatchers.IO) { createExportBundle() }
|
||||
_exportBundle = bundle
|
||||
Logger.i(TAG, "Export bundle created, length: ${bundle.length}")
|
||||
|
||||
try {
|
||||
val pair = withContext(Dispatchers.IO) {
|
||||
val bundle = createExportBundle()
|
||||
if (!isContentSuitableForQRCode(bundle)) {
|
||||
throw Exception("Data too big for QR code generation")
|
||||
}
|
||||
|
||||
val dimension = TypedValue.applyDimension(
|
||||
TypedValue.COMPLEX_UNIT_DIP, 200f, resources.displayMetrics
|
||||
).toInt()
|
||||
@@ -81,18 +113,35 @@ class PolycentricBackupActivity : AppCompatActivity() {
|
||||
Pair(bundle, qr)
|
||||
}
|
||||
|
||||
_exportBundle = pair.first
|
||||
_imageQR.setImageBitmap(pair.second)
|
||||
_imageQR.visibility = View.VISIBLE
|
||||
_textQR.visibility = View.VISIBLE
|
||||
_textQRHint.visibility = View.VISIBLE
|
||||
_buttonShare.visibility = View.VISIBLE
|
||||
_buttonCopy.visibility = View.VISIBLE
|
||||
|
||||
_imageQR.setOnClickListener {
|
||||
val intent = QRCodeFullscreenActivity.createIntent(this@PolycentricBackupActivity, _exportBundle)
|
||||
startActivity(intent)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, getString(R.string.failed_to_generate_qr_code), e)
|
||||
val byteSize = bundle.toByteArray(Charsets.UTF_8).size
|
||||
Logger.e(TAG, "QR code generation failed. Bundle length: ${bundle.length} chars, ${byteSize} bytes, Error: ${e.message}", e)
|
||||
|
||||
if (e.message?.contains("Data too big") == true) {
|
||||
_textQR.text = getString(R.string.qr_code_too_large_use_file_export)
|
||||
_buttonExportFile.visibility = View.VISIBLE
|
||||
} else {
|
||||
_textQR.text = getString(R.string.failed_to_generate_qr_code)
|
||||
}
|
||||
|
||||
_textQR.visibility = View.VISIBLE
|
||||
_textQRHint.visibility = View.INVISIBLE
|
||||
_buttonShare.visibility = View.VISIBLE
|
||||
_buttonCopy.visibility = View.VISIBLE
|
||||
|
||||
// Hide QR image since generation failed
|
||||
_imageQR.visibility = View.INVISIBLE
|
||||
_textQR.visibility = View.INVISIBLE
|
||||
_buttonShare.visibility = View.INVISIBLE
|
||||
_buttonCopy.visibility = View.INVISIBLE
|
||||
} finally {
|
||||
_loader.visibility = View.GONE
|
||||
}
|
||||
@@ -108,11 +157,29 @@ class PolycentricBackupActivity : AppCompatActivity() {
|
||||
val clip = ClipData.newPlainText(getString(R.string.copied_text), _exportBundle);
|
||||
clipboard.setPrimaryClip(clip);
|
||||
};
|
||||
|
||||
_buttonExportFile.onClick.subscribe {
|
||||
val fileName = "polycentric_profile_${System.currentTimeMillis()}.txt"
|
||||
_createDocumentLauncher.launch(fileName)
|
||||
};
|
||||
}
|
||||
|
||||
private fun isContentSuitableForQRCode(content: String): Boolean {
|
||||
val bytes = content.toByteArray(Charsets.UTF_8)
|
||||
return bytes.size <= 2300 // QR Code Version 40 with Error Correction Level M can hold ~2331 bytes
|
||||
}
|
||||
|
||||
private fun generateQRCode(content: String, width: Int, height: Int): Bitmap {
|
||||
val bitMatrix = MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, width, height);
|
||||
return bitMatrixToBitmap(bitMatrix);
|
||||
if (!isContentSuitableForQRCode(content)) {
|
||||
throw Exception("Data too big for QR code generation")
|
||||
}
|
||||
|
||||
val hints = java.util.EnumMap<EncodeHintType, Any>(EncodeHintType::class.java)
|
||||
hints[EncodeHintType.ERROR_CORRECTION] = ErrorCorrectionLevel.M
|
||||
hints[EncodeHintType.MARGIN] = 1
|
||||
|
||||
val bitMatrix = MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, width, height, hints)
|
||||
return bitMatrixToBitmap(bitMatrix)
|
||||
}
|
||||
|
||||
private fun bitMatrixToBitmap(matrix: BitMatrix): Bitmap {
|
||||
@@ -203,7 +270,8 @@ class PolycentricBackupActivity : AppCompatActivity() {
|
||||
.setBody(exportBundle.toByteString())
|
||||
.build();
|
||||
|
||||
return "polycentric://" + urlInfo.toByteArray().toBase64Url()
|
||||
val data = urlInfo.toByteArray()
|
||||
return "polycentric://" + data.toBase64Url()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
+133
-61
@@ -32,100 +32,166 @@ import userpackage.Protocol
|
||||
import userpackage.Protocol.ExportBundle
|
||||
|
||||
class PolycentricImportProfileActivity : AppCompatActivity() {
|
||||
private lateinit var _buttonHelp: ImageButton;
|
||||
private lateinit var _buttonScanProfile: LinearLayout;
|
||||
private lateinit var _buttonImportProfile: LinearLayout;
|
||||
private lateinit var _editProfile: EditText;
|
||||
private lateinit var _loaderOverlay: LoaderOverlay;
|
||||
private lateinit var _buttonHelp: ImageButton
|
||||
private lateinit var _buttonScanProfile: LinearLayout
|
||||
private lateinit var _buttonImportFile: LinearLayout
|
||||
private lateinit var _buttonImportProfile: LinearLayout
|
||||
private lateinit var _editProfile: EditText
|
||||
private lateinit var _loaderOverlay: LoaderOverlay
|
||||
|
||||
private val _qrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data)
|
||||
scanResult?.let {
|
||||
if (it.contents != null) {
|
||||
val scannedUrl = it.contents
|
||||
import(scannedUrl)
|
||||
private val _qrCodeResultLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
val scanResult =
|
||||
IntentIntegrator.parseActivityResult(result.resultCode, result.data)
|
||||
scanResult?.let {
|
||||
if (it.contents != null) {
|
||||
val scannedUrl = it.contents
|
||||
import(scannedUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val _filePickerLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
uri?.let { fileUri ->
|
||||
try {
|
||||
// Check file size before reading
|
||||
val fileSize =
|
||||
contentResolver.openFileDescriptor(fileUri, "r")?.statSize ?: 0
|
||||
val maxFileSize = 10 * 1024 * 1024 // 10MB limit
|
||||
|
||||
if (fileSize > maxFileSize) {
|
||||
UIDialogs.toast(this, "File too large. Maximum size is 10MB.")
|
||||
return@let
|
||||
}
|
||||
|
||||
if (fileSize == 0L) {
|
||||
UIDialogs.toast(this, "Selected file is empty.")
|
||||
return@let
|
||||
}
|
||||
|
||||
val content =
|
||||
contentResolver
|
||||
.openInputStream(fileUri)
|
||||
?.bufferedReader()
|
||||
?.readText()
|
||||
content?.let { fileContent ->
|
||||
val trimmedContent = fileContent.trim()
|
||||
|
||||
// Check if content is empty after trimming
|
||||
if (trimmedContent.isEmpty()) {
|
||||
UIDialogs.toast(this, "Selected file contains no data.")
|
||||
return@let
|
||||
}
|
||||
|
||||
// Check if content looks like a valid polycentric URL
|
||||
if (!trimmedContent.startsWith("polycentric://")) {
|
||||
UIDialogs.toast(
|
||||
this,
|
||||
"Selected file does not contain a valid polycentric profile URL."
|
||||
)
|
||||
return@let
|
||||
}
|
||||
|
||||
import(trimmedContent)
|
||||
}
|
||||
?: run { UIDialogs.toast(this, "Could not read file content.") }
|
||||
} catch (e: SecurityException) {
|
||||
Logger.e(TAG, "Security exception reading file", e)
|
||||
UIDialogs.toast(this, "Permission denied to read file.")
|
||||
} catch (e: OutOfMemoryError) {
|
||||
Logger.e(TAG, "Out of memory reading file", e)
|
||||
UIDialogs.toast(this, "File too large to process.")
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Failed to read file", e)
|
||||
UIDialogs.toast(this, "Failed to read file: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_polycentric_import_profile);
|
||||
setNavigationBarColorAndIcons();
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_polycentric_import_profile)
|
||||
setNavigationBarColorAndIcons()
|
||||
|
||||
_buttonHelp = findViewById(R.id.button_help);
|
||||
_buttonScanProfile = findViewById(R.id.button_scan_profile);
|
||||
_buttonImportProfile = findViewById(R.id.button_import_profile);
|
||||
_loaderOverlay = findViewById(R.id.loader_overlay);
|
||||
_editProfile = findViewById(R.id.edit_profile);
|
||||
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
|
||||
finish();
|
||||
};
|
||||
_buttonHelp = findViewById(R.id.button_help)
|
||||
_buttonScanProfile = findViewById(R.id.button_scan_profile)
|
||||
_buttonImportFile = findViewById(R.id.button_import_file)
|
||||
_buttonImportProfile = findViewById(R.id.button_import_profile)
|
||||
_loaderOverlay = findViewById(R.id.loader_overlay)
|
||||
_editProfile = findViewById(R.id.edit_profile)
|
||||
findViewById<ImageButton>(R.id.button_back).setOnClickListener { finish() }
|
||||
|
||||
_buttonHelp.setOnClickListener {
|
||||
startActivity(Intent(this, PolycentricWhyActivity::class.java));
|
||||
};
|
||||
startActivity(Intent(this, PolycentricWhyActivity::class.java))
|
||||
}
|
||||
|
||||
_buttonScanProfile.setOnClickListener {
|
||||
val integrator = IntentIntegrator(this)
|
||||
integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE)
|
||||
integrator.setPrompt(getString(R.string.scan_a_qr_code))
|
||||
integrator.setOrientationLocked(true);
|
||||
integrator.setOrientationLocked(true)
|
||||
integrator.setCameraId(0)
|
||||
integrator.setBeepEnabled(false)
|
||||
integrator.setBarcodeImageEnabled(true)
|
||||
integrator.setCaptureActivity(QRCaptureActivity::class.java);
|
||||
integrator.setCaptureActivity(QRCaptureActivity::class.java)
|
||||
_qrCodeResultLauncher.launch(integrator.createScanIntent())
|
||||
};
|
||||
}
|
||||
|
||||
_buttonImportFile.setOnClickListener { _filePickerLauncher.launch("text/plain") }
|
||||
|
||||
_buttonImportProfile.setOnClickListener {
|
||||
if (_editProfile.text.isEmpty()) {
|
||||
UIDialogs.toast(this, getString(R.string.text_field_does_not_contain_any_data));
|
||||
return@setOnClickListener;
|
||||
UIDialogs.toast(this, getString(R.string.text_field_does_not_contain_any_data))
|
||||
return@setOnClickListener
|
||||
}
|
||||
|
||||
import(_editProfile.text.toString());
|
||||
};
|
||||
import(_editProfile.text.toString())
|
||||
}
|
||||
|
||||
val url = intent.getStringExtra("url");
|
||||
val url = intent.getStringExtra("url")
|
||||
if (url != null) {
|
||||
import(url);
|
||||
import(url)
|
||||
}
|
||||
}
|
||||
|
||||
private fun import(url: String) {
|
||||
if (!url.startsWith("polycentric://")) {
|
||||
UIDialogs.toast(this, getString(R.string.not_a_valid_url));
|
||||
return;
|
||||
UIDialogs.toast(this, getString(R.string.not_a_valid_url))
|
||||
return
|
||||
}
|
||||
|
||||
_loaderOverlay.show()
|
||||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val data = url.substring("polycentric://".length).base64UrlToByteArray();
|
||||
val urlInfo = Protocol.URLInfo.parseFrom(data);
|
||||
val data = url.substring("polycentric://".length).base64UrlToByteArray()
|
||||
val urlInfo = Protocol.URLInfo.parseFrom(data)
|
||||
|
||||
if (urlInfo.urlType != 3L) {
|
||||
throw Exception("Expected urlInfo struct of type ExportBundle")
|
||||
}
|
||||
|
||||
val exportBundle = ExportBundle.parseFrom(urlInfo.body);
|
||||
val keyPair = KeyPair.fromProto(exportBundle.keyPair);
|
||||
val exportBundle = ExportBundle.parseFrom(urlInfo.body)
|
||||
val keyPair = KeyPair.fromProto(exportBundle.keyPair)
|
||||
|
||||
val existingProcessSecret = Store.instance.getProcessSecret(keyPair.publicKey);
|
||||
val existingProcessSecret = Store.instance.getProcessSecret(keyPair.publicKey)
|
||||
if (existingProcessSecret != null) {
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.toast(this@PolycentricImportProfileActivity, getString(R.string.this_profile_is_already_imported));
|
||||
UIDialogs.toast(
|
||||
this@PolycentricImportProfileActivity,
|
||||
getString(R.string.this_profile_is_already_imported)
|
||||
)
|
||||
}
|
||||
return@launch;
|
||||
return@launch
|
||||
}
|
||||
|
||||
val processSecret = ProcessSecret(keyPair, Process.random());
|
||||
Store.instance.addProcessSecret(processSecret);
|
||||
val processSecret = ProcessSecret(keyPair, Process.random())
|
||||
Store.instance.addProcessSecret(processSecret)
|
||||
|
||||
try {
|
||||
PolycentricStorage.instance.addProcessSecret(processSecret)
|
||||
@@ -133,37 +199,43 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
|
||||
Logger.e(TAG, "Failed to save process secret to secret storage.", e)
|
||||
}
|
||||
|
||||
val processHandle = processSecret.toProcessHandle();
|
||||
val processHandle = processSecret.toProcessHandle()
|
||||
|
||||
for (e in exportBundle.events.eventsList) {
|
||||
try {
|
||||
val se = SignedEvent.fromProto(e);
|
||||
Store.instance.putSignedEvent(se);
|
||||
val se = SignedEvent.fromProto(e)
|
||||
Store.instance.putSignedEvent(se)
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Ignored invalid event", e);
|
||||
Logger.w(TAG, "Ignored invalid event", e)
|
||||
}
|
||||
}
|
||||
|
||||
StatePolycentric.instance.setProcessHandle(processHandle);
|
||||
processHandle.fullyBackfillClient(ApiMethods.SERVER);
|
||||
StatePolycentric.instance.setProcessHandle(processHandle)
|
||||
processHandle.fullyBackfillClient(ApiMethods.SERVER)
|
||||
withContext(Dispatchers.Main) {
|
||||
startActivity(Intent(this@PolycentricImportProfileActivity, PolycentricProfileActivity::class.java));
|
||||
finish();
|
||||
startActivity(
|
||||
Intent(
|
||||
this@PolycentricImportProfileActivity,
|
||||
PolycentricProfileActivity::class.java
|
||||
)
|
||||
)
|
||||
finish()
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to import profile", e);
|
||||
Logger.w(TAG, "Failed to import profile", e)
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.toast(this@PolycentricImportProfileActivity, getString(R.string.failed_to_import_profile) + " '${e.message}'");
|
||||
UIDialogs.toast(
|
||||
this@PolycentricImportProfileActivity,
|
||||
getString(R.string.failed_to_import_profile) + " '${e.message}'"
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
withContext(Dispatchers.Main) {
|
||||
_loaderOverlay.hide();
|
||||
}
|
||||
withContext(Dispatchers.Main) { _loaderOverlay.hide() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PolycentricImportProfileActivity";
|
||||
private const val TAG = "PolycentricImportProfileActivity"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+147
@@ -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 _editName: EditText;
|
||||
private lateinit var _buttonExport: BigButton;
|
||||
private lateinit var _buttonOpenHarborProfile: BigButton;
|
||||
private lateinit var _buttonModeration: BigButton;
|
||||
private lateinit var _buttonLogout: BigButton;
|
||||
private lateinit var _buttonDelete: BigButton;
|
||||
private lateinit var _username: String;
|
||||
@@ -71,7 +71,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
|
||||
_imagePolycentric = findViewById(R.id.image_polycentric);
|
||||
_editName = findViewById(R.id.edit_profile_name);
|
||||
_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);
|
||||
_buttonDelete = findViewById(R.id.button_delete);
|
||||
_loaderOverlay = findViewById(R.id.loader_overlay);
|
||||
@@ -99,15 +99,9 @@ class PolycentricProfileActivity : AppCompatActivity() {
|
||||
startActivity(Intent(this, PolycentricBackupActivity::class.java));
|
||||
};
|
||||
|
||||
_buttonOpenHarborProfile.onClick.subscribe {
|
||||
val processHandle = StatePolycentric.instance.processHandle!!;
|
||||
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)))
|
||||
}
|
||||
}
|
||||
_buttonModeration.onClick.subscribe {
|
||||
startActivity(Intent(this, PolycentricModerationActivity::class.java));
|
||||
};
|
||||
|
||||
_buttonLogout.onClick.subscribe {
|
||||
StatePolycentric.instance.setProcessHandle(null);
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.futo.platformplayer.activities
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.util.TypedValue
|
||||
import android.view.View
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.EncodeHintType
|
||||
import com.google.zxing.MultiFormatWriter
|
||||
import com.google.zxing.common.BitMatrix
|
||||
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
|
||||
|
||||
class QRCodeFullscreenActivity : AppCompatActivity() {
|
||||
companion object {
|
||||
private const val EXTRA_QR_TEXT = "qr_text"
|
||||
|
||||
fun createIntent(context: Context, qrText: String): android.content.Intent {
|
||||
return android.content.Intent(context, QRCodeFullscreenActivity::class.java).apply {
|
||||
putExtra(EXTRA_QR_TEXT, qrText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_qr_code_fullscreen)
|
||||
setNavigationBarColorAndIcons()
|
||||
|
||||
val qrText = intent.getStringExtra(EXTRA_QR_TEXT)
|
||||
|
||||
val imageQR = findViewById<ImageView>(R.id.image_qr_fullscreen)
|
||||
val buttonBack = findViewById<ImageButton>(R.id.button_back_fullscreen)
|
||||
val buttonClose = findViewById<ImageButton>(R.id.button_close_fullscreen)
|
||||
|
||||
// Generate QR code bitmap from text
|
||||
qrText?.let { text ->
|
||||
try {
|
||||
if (!isContentSuitableForQRCode(text)) {
|
||||
throw Exception("Data too big for QR code generation")
|
||||
}
|
||||
|
||||
val dimension = TypedValue.applyDimension(
|
||||
TypedValue.COMPLEX_UNIT_DIP, 300f, resources.displayMetrics
|
||||
).toInt()
|
||||
val qrBitmap = generateQRCode(text, dimension, dimension)
|
||||
imageQR.setImageBitmap(qrBitmap)
|
||||
} catch (e: Exception) {
|
||||
// If QR generation fails, show error or fallback
|
||||
imageQR.setImageResource(R.drawable.ic_qr)
|
||||
}
|
||||
}
|
||||
|
||||
buttonBack.setOnClickListener {
|
||||
finish()
|
||||
}
|
||||
|
||||
buttonClose.setOnClickListener {
|
||||
finish()
|
||||
}
|
||||
|
||||
imageQR.setOnClickListener {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
private fun isContentSuitableForQRCode(content: String): Boolean {
|
||||
val bytes = content.toByteArray(Charsets.UTF_8)
|
||||
return bytes.size <= 2300 // QR Code Version 40 with Error Correction Level M can hold ~2331 bytes
|
||||
}
|
||||
|
||||
private fun generateQRCode(content: String, width: Int, height: Int): Bitmap {
|
||||
if (!isContentSuitableForQRCode(content)) {
|
||||
throw Exception("Data too big for QR code generation")
|
||||
}
|
||||
|
||||
val hints = java.util.EnumMap<EncodeHintType, Any>(EncodeHintType::class.java)
|
||||
hints[EncodeHintType.ERROR_CORRECTION] = ErrorCorrectionLevel.M
|
||||
hints[EncodeHintType.MARGIN] = 1
|
||||
|
||||
val bitMatrix = MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, width, height, hints)
|
||||
return bitMatrixToBitmap(bitMatrix)
|
||||
}
|
||||
|
||||
private fun bitMatrixToBitmap(matrix: BitMatrix): Bitmap {
|
||||
val width = matrix.width
|
||||
val height = matrix.height
|
||||
val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565)
|
||||
|
||||
for (x in 0 until width) {
|
||||
for (y in 0 until height) {
|
||||
bmp.setPixel(x, y, if (matrix[x, y]) Color.BLACK else Color.WHITE)
|
||||
}
|
||||
}
|
||||
return bmp
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
package com.futo.platformplayer.activities
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageButton
|
||||
import android.widget.LinearLayout
|
||||
import androidx.activity.result.ActivityResult
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.views.LoaderView
|
||||
import com.futo.platformplayer.views.fields.FieldForm
|
||||
import com.futo.platformplayer.views.fields.ReadOnlyTextField
|
||||
import com.google.android.material.button.MaterialButton
|
||||
|
||||
class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
|
||||
private lateinit var _form: FieldForm;
|
||||
private lateinit var _buttonBack: ImageButton;
|
||||
private lateinit var _loaderView: LoaderView;
|
||||
|
||||
private lateinit var _devSets: LinearLayout;
|
||||
private lateinit var _buttonDev: MaterialButton;
|
||||
|
||||
private var _isFinished = false;
|
||||
|
||||
lateinit var overlay: FrameLayout;
|
||||
|
||||
val notifPermission = "android.permission.POST_NOTIFICATIONS";
|
||||
val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
|
||||
if (isGranted)
|
||||
UIDialogs.toast(this, "Notification permission granted");
|
||||
else
|
||||
UIDialogs.toast(this, "Notification permission denied");
|
||||
}
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
Logger.i("SettingsActivity", "SettingsActivity.attachBaseContext")
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_settings);
|
||||
setNavigationBarColorAndIcons();
|
||||
|
||||
_form = findViewById(R.id.settings_form);
|
||||
_buttonBack = findViewById(R.id.button_back);
|
||||
_buttonDev = findViewById(R.id.button_dev);
|
||||
_devSets = findViewById(R.id.dev_settings);
|
||||
_loaderView = findViewById(R.id.loader);
|
||||
overlay = findViewById(R.id.overlay_container);
|
||||
|
||||
_form.onChanged.subscribe { field, _ ->
|
||||
Logger.i("SettingsActivity", "Setting [${field.field?.name}] changed, saving");
|
||||
_form.setObjectValues();
|
||||
Settings.instance.save();
|
||||
|
||||
if(field.descriptor?.id == "app_language") {
|
||||
Logger.i("SettingsActivity", "App language change detected, propogating to shared preferences");
|
||||
StateApp.instance.setLocaleSetting(this, Settings.instance.language.getAppLanguageLocaleString());
|
||||
}
|
||||
|
||||
if(field.descriptor?.id == "background_update") {
|
||||
Logger.i("SettingsActivity", "Detected change in background work ${field.value}");
|
||||
if(Settings.instance.subscriptions.subscriptionsBackgroundUpdateInterval > 0) {
|
||||
val notifManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager;
|
||||
if(!notifManager.areNotificationsEnabled()) {
|
||||
UIDialogs.toast(this, "Notifications aren't enabled");
|
||||
|
||||
when {
|
||||
ContextCompat.checkSelfPermission(this, notifPermission) == PackageManager.PERMISSION_GRANTED -> {
|
||||
|
||||
}
|
||||
ActivityCompat.shouldShowRequestPermissionRationale(this, notifPermission) -> {
|
||||
UIDialogs.showDialog(this, R.drawable.ic_notifications, "Notifications Required",
|
||||
"Notifications need to be enabled for background updating to function", null, 0,
|
||||
UIDialogs.Action("Cancel", {}),
|
||||
UIDialogs.Action("Enable", {
|
||||
requestPermissionLauncher.launch(notifPermission);
|
||||
}, UIDialogs.ActionStyle.PRIMARY));
|
||||
}
|
||||
else -> {
|
||||
requestPermissionLauncher.launch(notifPermission);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
_buttonBack.setOnClickListener {
|
||||
finish();
|
||||
}
|
||||
|
||||
_buttonDev.setOnClickListener {
|
||||
startActivity(Intent(this, DeveloperActivity::class.java));
|
||||
}
|
||||
|
||||
_lastActivity = this;
|
||||
|
||||
reloadSettings();
|
||||
}
|
||||
|
||||
var isFirstLoad = true;
|
||||
fun reloadSettings() {
|
||||
val firstLoad = isFirstLoad;
|
||||
isFirstLoad = false;
|
||||
_form.setSearchVisible(false);
|
||||
_loaderView.start();
|
||||
_form.fromObject(lifecycleScope, Settings.instance) {
|
||||
_loaderView.stop();
|
||||
_form.setSearchVisible(true);
|
||||
|
||||
var devCounter = 0;
|
||||
_form.findField("code")?.assume<ReadOnlyTextField>()?.setOnClickListener {
|
||||
devCounter++;
|
||||
if(devCounter > 5) {
|
||||
devCounter = 0;
|
||||
SettingsDev.instance.developerMode = true;
|
||||
SettingsDev.instance.save();
|
||||
updateDevMode();
|
||||
UIDialogs.toast(this, getString(R.string.you_are_now_in_developer_mode));
|
||||
}
|
||||
};
|
||||
|
||||
if(firstLoad) {
|
||||
val query = intent.getStringExtra("query");
|
||||
if(!query.isNullOrEmpty()) {
|
||||
_form.setSearchQuery(query);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
updateDevMode();
|
||||
}
|
||||
|
||||
fun updateDevMode() {
|
||||
if(SettingsDev.instance.developerMode)
|
||||
_devSets.visibility = View.VISIBLE;
|
||||
else
|
||||
_devSets.visibility = View.GONE;
|
||||
}
|
||||
|
||||
override fun finish() {
|
||||
super.finish()
|
||||
_isFinished = true;
|
||||
if(_lastActivity == this)
|
||||
_lastActivity = null;
|
||||
overridePendingTransition(R.anim.slide_lighten, R.anim.slide_out_up)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
private var resultLauncherMap = mutableMapOf<Int, (ActivityResult)->Unit>();
|
||||
private var requestCode: Int? = -1;
|
||||
private val resultLauncher: ActivityResultLauncher<Intent> = registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()) {
|
||||
result: ActivityResult ->
|
||||
val handler = synchronized(resultLauncherMap) {
|
||||
resultLauncherMap.remove(requestCode);
|
||||
}
|
||||
if(handler != null)
|
||||
handler(result);
|
||||
};
|
||||
override fun launchForResult(intent: Intent, code: Int, handler: (ActivityResult)->Unit) {
|
||||
synchronized(resultLauncherMap) {
|
||||
resultLauncherMap[code] = handler;
|
||||
}
|
||||
requestCode = code;
|
||||
resultLauncher.launch(intent);
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
settingsActivityClosed.emit()
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
//TODO: Temporary for solving Settings issues
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
private var _lastActivity: SettingsActivity? = null;
|
||||
|
||||
val settingsActivityClosed = Event0()
|
||||
|
||||
fun getActivity(): SettingsActivity? {
|
||||
val act = _lastActivity;
|
||||
if(act != null && !act._isFinished)
|
||||
return act;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,7 +110,19 @@ class SyncPairActivity : AppCompatActivity() {
|
||||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
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) {
|
||||
if (complete != null) {
|
||||
if (complete) {
|
||||
|
||||
+39
-9
@@ -5,6 +5,7 @@ import android.util.Log
|
||||
import com.futo.platformplayer.api.http.server.HttpContext
|
||||
import com.futo.platformplayer.api.http.server.HttpHeaders
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.media.models.modifier.IRequest
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.parsers.HttpResponseParser
|
||||
import com.futo.platformplayer.readLine
|
||||
@@ -27,6 +28,7 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String, priv
|
||||
private var _injectReferer = false;
|
||||
|
||||
private val _client = ManagedHttpClient();
|
||||
private var _requestModifier: ((String, Map<String, String>) -> IRequest)? = null;
|
||||
|
||||
override fun handle(context: HttpContext) {
|
||||
if (useTcp) {
|
||||
@@ -43,21 +45,33 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String, priv
|
||||
for (injectHeader in _injectRequestHeader)
|
||||
proxyHeaders[injectHeader.first] = injectHeader.second;
|
||||
|
||||
val parsed = Uri.parse(targetUrl);
|
||||
val req = _requestModifier?.invoke(targetUrl, proxyHeaders)
|
||||
var url = targetUrl
|
||||
if (req != null) {
|
||||
req.url?.let {
|
||||
url = it
|
||||
}
|
||||
req.headers.let {
|
||||
proxyHeaders.clear()
|
||||
proxyHeaders.putAll(it)
|
||||
}
|
||||
}
|
||||
|
||||
val parsed = Uri.parse(url);
|
||||
if(_injectHost)
|
||||
proxyHeaders.put("Host", parsed.host!!);
|
||||
if(_injectReferer)
|
||||
proxyHeaders.put("Referer", targetUrl);
|
||||
proxyHeaders.put("Referer", url);
|
||||
|
||||
val useMethod = if (method == "inherit") context.method else method;
|
||||
Logger.i(TAG, "handleWithOkHttp Proxied Request ${useMethod}: ${targetUrl}");
|
||||
Logger.i(TAG, "handleWithOkHttp Proxied Request ${useMethod}: ${url}");
|
||||
Logger.i(TAG, "handleWithOkHttp Headers:" + proxyHeaders.map { "${it.key}: ${it.value}" }.joinToString("\n"));
|
||||
|
||||
val resp = when (useMethod) {
|
||||
"GET" -> _client.get(targetUrl, proxyHeaders);
|
||||
"POST" -> _client.post(targetUrl, content ?: "", proxyHeaders);
|
||||
"HEAD" -> _client.head(targetUrl, proxyHeaders)
|
||||
else -> _client.requestMethod(useMethod, targetUrl, proxyHeaders);
|
||||
"GET" -> _client.get(url, proxyHeaders);
|
||||
"POST" -> _client.post(url, content ?: "", proxyHeaders);
|
||||
"HEAD" -> _client.head(url, proxyHeaders)
|
||||
else -> _client.requestMethod(useMethod, url, proxyHeaders);
|
||||
};
|
||||
|
||||
Logger.i(TAG, "Proxied Response [${resp.code}]");
|
||||
@@ -91,11 +105,23 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String, priv
|
||||
for (injectHeader in _injectRequestHeader)
|
||||
proxyHeaders[injectHeader.first] = injectHeader.second;
|
||||
|
||||
val parsed = Uri.parse(targetUrl);
|
||||
val req = _requestModifier?.invoke(targetUrl, proxyHeaders)
|
||||
var url = targetUrl
|
||||
if (req != null) {
|
||||
req.url?.let {
|
||||
url = it
|
||||
}
|
||||
req.headers.let {
|
||||
proxyHeaders.clear()
|
||||
proxyHeaders.putAll(it)
|
||||
}
|
||||
}
|
||||
|
||||
val parsed = Uri.parse(url);
|
||||
if(_injectHost)
|
||||
proxyHeaders.put("Host", parsed.host!!);
|
||||
if(_injectReferer)
|
||||
proxyHeaders.put("Referer", targetUrl);
|
||||
proxyHeaders.put("Referer", url);
|
||||
|
||||
val useMethod = if (method == "inherit") context.method else method;
|
||||
Logger.i(TAG, "handleWithTcp Proxied Request ${useMethod}: ${parsed}");
|
||||
@@ -242,6 +268,10 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String, priv
|
||||
_ignoreRequestHeaders.add("referer");
|
||||
return this;
|
||||
}
|
||||
fun withRequestModifier(modifier: (String, Map<String, String>) -> IRequest) : HttpProxyHandler {
|
||||
_requestModifier = modifier;
|
||||
return this;
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "HttpProxyHandler"
|
||||
|
||||
@@ -54,14 +54,16 @@ interface IPlatformChannelContent : IPlatformContent {
|
||||
val subscribers: Long?
|
||||
}
|
||||
|
||||
open class JSChannelContent : JSContent, IPlatformChannelContent {
|
||||
override val contentType: ContentType get() = ContentType.CHANNEL
|
||||
override val thumbnail: String?
|
||||
override val subscribers: Long?
|
||||
open class JSChannelContent(
|
||||
config: SourcePluginConfig,
|
||||
obj: V8ValueObject
|
||||
) : JSContent(config, obj), IPlatformChannelContent {
|
||||
|
||||
constructor(config: SourcePluginConfig, obj: V8ValueObject) : super(config, obj) {
|
||||
val contextName = "Channel";
|
||||
thumbnail = obj.getOrDefault<String>(config, "thumbnail", contextName, null)
|
||||
subscribers = if(obj.has("subscribers")) obj.getOrThrow(config,"subscribers", contextName) else null
|
||||
}
|
||||
}
|
||||
final override val contentType: ContentType = ContentType.CHANNEL
|
||||
|
||||
override val thumbnail: String? =
|
||||
_content.getOrDefault<String>(_pluginConfig, "thumbnail", "Channel", null)
|
||||
|
||||
override val subscribers: Long? =
|
||||
_content.getOrDefault<Long>(_pluginConfig, "subscribers", "Channel", null)?.toLong()
|
||||
}
|
||||
|
||||
+11
-21
@@ -6,25 +6,15 @@ import com.futo.platformplayer.api.media.models.ratings.IRating
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
open class PlatformComment : IPlatformComment {
|
||||
override val contextUrl: String;
|
||||
override val author: PlatformAuthorLink;
|
||||
override val message: String;
|
||||
override val rating: IRating;
|
||||
override val date: OffsetDateTime;
|
||||
open class PlatformComment(
|
||||
override val contextUrl: String,
|
||||
override val author: PlatformAuthorLink,
|
||||
override val message: String,
|
||||
override val rating: IRating,
|
||||
override val date: OffsetDateTime,
|
||||
override val replyCount: Int? = null
|
||||
) : IPlatformComment {
|
||||
|
||||
override val replyCount: Int?;
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
override fun getReplies(client: IPlatformClient): IPager<IPlatformComment> =
|
||||
NoCommentsPager()
|
||||
}
|
||||
|
||||
+1
@@ -41,6 +41,7 @@ class HLSVariantSubtitleUrlSource(
|
||||
override val format: String,
|
||||
) : ISubtitleSource {
|
||||
override val hasFetch: Boolean = false
|
||||
override val language: String? = null
|
||||
|
||||
override fun getSubtitles(): String? {
|
||||
return null
|
||||
|
||||
+4
-1
@@ -9,13 +9,15 @@ class LocalSubtitleSource : ISubtitleSource {
|
||||
override val name: String;
|
||||
override val url: String?;
|
||||
override val format: String?;
|
||||
override val language: String?
|
||||
override val hasFetch: Boolean get() = false;
|
||||
|
||||
val filePath: String;
|
||||
|
||||
constructor(name: String, format: String?, filePath: String) {
|
||||
constructor(name: String, language: String?, format: String?, filePath: String) {
|
||||
this.name = name;
|
||||
this.format = format;
|
||||
this.language = language
|
||||
this.filePath = filePath;
|
||||
this.url = Uri.fromFile(File(filePath)).toString();
|
||||
}
|
||||
@@ -32,6 +34,7 @@ class LocalSubtitleSource : ISubtitleSource {
|
||||
fun fromSource(source: SubtitleRawSource, path: String): LocalSubtitleSource {
|
||||
return LocalSubtitleSource(
|
||||
source.name,
|
||||
source.language,
|
||||
source.format,
|
||||
path
|
||||
);
|
||||
|
||||
+1
@@ -6,6 +6,7 @@ import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
|
||||
@kotlinx.serialization.Serializable
|
||||
class SubtitleRawSource(
|
||||
override val name: String,
|
||||
override val language: String?,
|
||||
override val format: String?,
|
||||
val _subtitles: String,
|
||||
override val url: String? = null,
|
||||
|
||||
+1
@@ -7,6 +7,7 @@ interface ISubtitleSource {
|
||||
val url: String?;
|
||||
val format: String?;
|
||||
val hasFetch: Boolean;
|
||||
val language: String?
|
||||
|
||||
fun getSubtitles(): String?;
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ open class JSClient : IPlatformClient {
|
||||
|
||||
override val id: String get() = config.id;
|
||||
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();
|
||||
|
||||
private var _busyAction = "";
|
||||
@@ -147,15 +147,14 @@ open class JSClient : IPlatformClient {
|
||||
constructor(context: Context, descriptor: SourcePluginDescriptor, saveState: String? = null) {
|
||||
this._context = context;
|
||||
this.config = descriptor.config;
|
||||
icon = StatePlatform.instance.getPlatformIcon(config.id) ?: ImageVariable(config.absoluteIconUrl, null, null);
|
||||
this.descriptor = descriptor;
|
||||
_injectedSaveState = saveState;
|
||||
_auth = descriptor.getAuth();
|
||||
_captcha = descriptor.getCaptchaData();
|
||||
flags = descriptor.flags.toTypedArray();
|
||||
|
||||
_httpClient = JSHttpClient(this, null, _captcha);
|
||||
_httpClientAuth = JSHttpClient(this, _auth, _captcha);
|
||||
_httpClient = JSHttpClient(this, null, _captcha, config);
|
||||
_httpClientAuth = JSHttpClient(this, _auth, _captcha, config);
|
||||
_plugin = V8Plugin(context, descriptor.config, null, _httpClient, _httpClientAuth);
|
||||
_plugin.withDependency(context, "scripts/polyfil.js");
|
||||
_plugin.withDependency(context, "scripts/source.js");
|
||||
@@ -178,7 +177,6 @@ open class JSClient : IPlatformClient {
|
||||
constructor(context: Context, descriptor: SourcePluginDescriptor, saveState: String?, script: String, withoutCredentials: Boolean = false) {
|
||||
this._context = context;
|
||||
this.config = descriptor.config;
|
||||
icon = StatePlatform.instance.getPlatformIcon(config.id) ?: ImageVariable(config.absoluteIconUrl, null, null);
|
||||
this.descriptor = descriptor;
|
||||
_injectedSaveState = saveState;
|
||||
if(!withoutCredentials)
|
||||
@@ -188,8 +186,8 @@ open class JSClient : IPlatformClient {
|
||||
_captcha = descriptor.getCaptchaData();
|
||||
flags = descriptor.flags.toTypedArray();
|
||||
|
||||
_httpClient = JSHttpClient(this, null, _captcha);
|
||||
_httpClientAuth = JSHttpClient(this, _auth, _captcha);
|
||||
_httpClient = JSHttpClient(this, null, _captcha, config);
|
||||
_httpClientAuth = JSHttpClient(this, _auth, _captcha, config);
|
||||
_plugin = V8Plugin(context, descriptor.config, script, _httpClient, _httpClientAuth);
|
||||
_plugin.withDependency(context, "scripts/polyfil.js");
|
||||
_plugin.withDependency(context, "scripts/source.js");
|
||||
|
||||
+2
-1
@@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.platforms.js
|
||||
|
||||
import kotlinx.serialization.Contextual
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
import java.util.Dictionary
|
||||
|
||||
@Serializable
|
||||
@@ -27,7 +28,7 @@ class SourcePluginAuthConfig(
|
||||
val details: String? = null,
|
||||
val once: Boolean? = true
|
||||
) {
|
||||
@Contextual
|
||||
@Transient
|
||||
private var _regex: Regex? = null;
|
||||
|
||||
fun getRegex(): Regex {
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ class SourcePluginConfig(
|
||||
//Script
|
||||
val repositoryUrl: String? = null,
|
||||
val scriptUrl: String = "",
|
||||
val version: Int = -1,
|
||||
var version: Int = -1,
|
||||
|
||||
val iconUrl: String? = null,
|
||||
var id: String = UUID.randomUUID().toString(),
|
||||
|
||||
+71
@@ -23,6 +23,7 @@ import java.util.UUID
|
||||
class JSHttpClient : ManagedHttpClient {
|
||||
private val _jsClient: JSClient?;
|
||||
private val _jsConfig: SourcePluginConfig?;
|
||||
val config get() = _jsConfig
|
||||
private val _auth: SourceAuth?;
|
||||
private val _captcha: SourceCaptchaData?;
|
||||
|
||||
@@ -254,6 +255,76 @@ class JSHttpClient : ManagedHttpClient {
|
||||
|
||||
return resp;
|
||||
}
|
||||
fun processRequest(method: String, responseCode: Int, url: Uri, headers: Map<String, List<String>>) {
|
||||
if(doUpdateCookies) {
|
||||
val domain = url.host?.lowercase() ?: return;
|
||||
val domainParts = domain.split(".");
|
||||
val defaultCookieDomain =
|
||||
"." + domainParts.drop(domainParts.size - 2).joinToString(".");
|
||||
for (header in headers) {
|
||||
if(header.key.lowercase() == "set-cookie") {
|
||||
var domainToUse = domain;
|
||||
val cookie = cookieStringToPair(header.value.first());
|
||||
var cookieValue = cookie.second;
|
||||
|
||||
if (cookie.first.isNotEmpty() && cookie.second.isNotEmpty()) {
|
||||
val cookieParts = cookie.second.split(";");
|
||||
if (cookieParts.size == 0)
|
||||
continue;
|
||||
cookieValue = cookieParts[0].trim();
|
||||
|
||||
val cookieVariables = cookieParts.drop(1).map {
|
||||
val splitIndex = it.indexOf("=");
|
||||
if (splitIndex < 0)
|
||||
return@map Pair(it.trim().lowercase(), "");
|
||||
return@map Pair<String, String>(
|
||||
it.substring(0, splitIndex).lowercase().trim(),
|
||||
it.substring(splitIndex + 1).trim()
|
||||
);
|
||||
}.toMap();
|
||||
domainToUse = if (cookieVariables.containsKey("domain"))
|
||||
cookieVariables["domain"]!!.lowercase();
|
||||
else defaultCookieDomain;
|
||||
//TODO: Make sure this has no negative effect besides apply cookies to root domain
|
||||
if(!domainToUse.startsWith("."))
|
||||
domainToUse = ".${domainToUse}";
|
||||
}
|
||||
|
||||
if ((_auth != null || _currentCookieMap.isNotEmpty())) {
|
||||
val cookieMap = if (_currentCookieMap.containsKey(domainToUse))
|
||||
_currentCookieMap[domainToUse]!!;
|
||||
else {
|
||||
val newMap = hashMapOf<String, String>();
|
||||
_currentCookieMap[domainToUse] = newMap
|
||||
newMap;
|
||||
}
|
||||
if (cookieMap.containsKey(cookie.first) || doAllowNewCookies)
|
||||
cookieMap[cookie.first] = cookieValue;
|
||||
}
|
||||
else {
|
||||
val cookieMap = if (_otherCookieMap.containsKey(domainToUse))
|
||||
_otherCookieMap[domainToUse]!!;
|
||||
else {
|
||||
val newMap = hashMapOf<String, String>();
|
||||
_otherCookieMap[domainToUse] = newMap
|
||||
newMap;
|
||||
}
|
||||
if (cookieMap.containsKey(cookie.first) || doAllowNewCookies)
|
||||
cookieMap[cookie.first] = cookieValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(_jsClient is DevJSClient) {
|
||||
//val peekBody = resp.peekBody(1000 * 1000).string();
|
||||
StateDeveloper.instance.addDevHttpExchange(
|
||||
StateDeveloper.DevHttpExchange(
|
||||
StateDeveloper.DevHttpRequest(method, url.toString(), mapOf(*headers.map { Pair(it.key, it.value.joinToString(";")) }.toTypedArray()), ""),
|
||||
StateDeveloper.DevHttpRequest("RESP", url.toString(), mapOf(*headers.map { Pair(it.key, it.value.joinToString(";")) }.toTypedArray()), "", responseCode)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
private fun cookieStringToPair(cookie: String): Pair<String, String> {
|
||||
val cookieKey = cookie.substring(0, cookie.indexOf("="));
|
||||
|
||||
+16
-11
@@ -23,17 +23,22 @@ import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.getOrThrowNullableList
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
|
||||
open class JSArticle : JSContent, IPlatformArticle, IPluginSourced {
|
||||
final override val contentType: ContentType get() = ContentType.ARTICLE;
|
||||
open class JSArticle(
|
||||
config: SourcePluginConfig,
|
||||
obj: V8ValueObject
|
||||
) : JSContent(config, obj), IPlatformArticle, IPluginSourced {
|
||||
|
||||
override val summary: String;
|
||||
override val thumbnails: Thumbnails?;
|
||||
final override val contentType: ContentType = ContentType.ARTICLE
|
||||
|
||||
constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) {
|
||||
val contextName = "PlatformArticle";
|
||||
override val summary: String =
|
||||
obj.getOrDefault<String>(config, "summary", "PlatformArticle", "") ?: ""
|
||||
|
||||
summary = _content.getOrDefault(config, "summary", contextName, "") ?: "";
|
||||
thumbnails = Thumbnails.fromV8(config, _content.getOrThrow(config, "thumbnails", contextName));
|
||||
|
||||
}
|
||||
}
|
||||
override val thumbnails: Thumbnails? =
|
||||
if (obj.has("thumbnails"))
|
||||
Thumbnails.fromV8(
|
||||
config,
|
||||
obj.getOrThrow<V8ValueObject>(config, "thumbnails", "PlatformArticle")
|
||||
)
|
||||
else
|
||||
null
|
||||
}
|
||||
|
||||
+24
-23
@@ -24,36 +24,37 @@ import com.futo.platformplayer.getOrThrowNullableList
|
||||
import com.futo.platformplayer.invokeV8
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
|
||||
open class JSArticleDetails : JSContent, IPlatformArticleDetails, IPluginSourced, IPlatformContentDetails {
|
||||
final override val contentType: ContentType get() = ContentType.ARTICLE;
|
||||
open class JSArticleDetails(
|
||||
private val client: JSClient,
|
||||
obj: V8ValueObject
|
||||
) : JSContent(client.config, obj), IPlatformArticleDetails, IPluginSourced, IPlatformContentDetails {
|
||||
|
||||
private val _hasGetComments: Boolean;
|
||||
private val _hasGetContentRecommendations: Boolean;
|
||||
final override val contentType: ContentType = ContentType.ARTICLE
|
||||
|
||||
override val rating: IRating;
|
||||
private val _hasGetComments: Boolean = _content.has("getComments")
|
||||
private val _hasGetContentRecommendations: Boolean = _content.has("getContentRecommendations")
|
||||
|
||||
override val summary: String;
|
||||
override val thumbnails: Thumbnails?;
|
||||
override val segments: List<IJSArticleSegment>;
|
||||
override val rating: IRating =
|
||||
obj.getOrDefault<V8ValueObject>(client.config, "rating", "PlatformArticle", null)
|
||||
?.let { IRating.fromV8(client.config, it, "PlatformArticle") }
|
||||
?: RatingLikes(0)
|
||||
|
||||
constructor(client: JSClient, obj: V8ValueObject): super(client.config, obj) {
|
||||
val contextName = "PlatformArticle";
|
||||
override val summary: String =
|
||||
_content.getOrThrow(client.config, "summary", "PlatformArticle")
|
||||
|
||||
rating = obj.getOrDefault<V8ValueObject>(client.config, "rating", contextName, null)?.let { IRating.fromV8(client.config, it, contextName) } ?: RatingLikes(0);
|
||||
summary = _content.getOrThrow(client.config, "summary", contextName);
|
||||
if(_content.has("thumbnails"))
|
||||
thumbnails = Thumbnails.fromV8(client.config, _content.getOrThrow(client.config, "thumbnails", contextName));
|
||||
override val thumbnails: Thumbnails? =
|
||||
if (_content.has("thumbnails"))
|
||||
Thumbnails.fromV8(
|
||||
client.config,
|
||||
_content.getOrThrow(client.config, "thumbnails", "PlatformArticle")
|
||||
)
|
||||
else
|
||||
thumbnails = null;
|
||||
null
|
||||
|
||||
|
||||
segments = (obj.getOrThrowNullableList<V8ValueObject>(client.config, "segments", contextName)
|
||||
?.map { fromV8Segment(client, it) }
|
||||
?.filterNotNull() ?: listOf());
|
||||
|
||||
_hasGetComments = _content.has("getComments");
|
||||
_hasGetContentRecommendations = _content.has("getContentRecommendations");
|
||||
}
|
||||
override val segments: List<IJSArticleSegment> =
|
||||
obj.getOrThrowNullableList<V8ValueObject>(client.config, "segments", "PlatformArticle")
|
||||
?.mapNotNull { fromV8Segment(client, it) }
|
||||
?: emptyList()
|
||||
|
||||
override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? {
|
||||
if(!_hasGetComments || _content.isClosed)
|
||||
|
||||
+34
-36
@@ -16,51 +16,49 @@ import java.time.LocalDateTime
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneOffset
|
||||
|
||||
open class JSContent : IPlatformContent, IPluginSourced {
|
||||
protected val _pluginConfig: SourcePluginConfig;
|
||||
protected val _content : V8ValueObject;
|
||||
open class JSContent(
|
||||
protected val _pluginConfig: SourcePluginConfig,
|
||||
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 name: String;
|
||||
override val author: PlatformAuthorLink;
|
||||
override val datetime: OffsetDateTime?;
|
||||
override val id: PlatformID =
|
||||
PlatformID.fromV8(_pluginConfig, _content.getOrThrow(_pluginConfig, "id", CTX))
|
||||
|
||||
override val url: String;
|
||||
override val shareUrl: String;
|
||||
override val name: 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) {
|
||||
_pluginConfig = config;
|
||||
_content = obj;
|
||||
private val _epoch: Long? =
|
||||
_content.getOrDefault<Long>(_pluginConfig, "datetime", CTX, null)?.toLong()
|
||||
|
||||
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));
|
||||
name = HtmlCompat.fromHtml(_content.getOrThrow<String>(config, "name", contextName).decodeUnicode(), HtmlCompat.FROM_HTML_MODE_LEGACY).toString();
|
||||
override val url: String =
|
||||
_content.getOrThrow<String>(_pluginConfig, "url", CTX)
|
||||
|
||||
val authorObj = _content.getOrDefault<V8ValueObject>(config, "author", contextName, null);
|
||||
if(authorObj != null)
|
||||
author = PlatformAuthorLink.fromV8(_pluginConfig, authorObj);
|
||||
else
|
||||
author = PlatformAuthorLink.UNKNOWN;
|
||||
override val shareUrl: String =
|
||||
_content.getOrDefault<String>(_pluginConfig, "shareUrl", CTX, null) ?: url
|
||||
|
||||
val datetimeInt = _content.getOrDefault<Int>(config, "datetime", contextName, null)?.toLong();
|
||||
if(datetimeInt == null || datetimeInt == 0.toLong())
|
||||
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;
|
||||
override val sourceConfig: SourcePluginConfig
|
||||
get() = _pluginConfig
|
||||
|
||||
_hasGetDetails = _content.has("getDetails");
|
||||
fun getUnderlyingObject(): V8ValueObject? = _content
|
||||
|
||||
companion object {
|
||||
private const val CTX = "PlatformContent"
|
||||
}
|
||||
|
||||
fun getUnderlyingObject(): V8ValueObject? {
|
||||
return _content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
-10
@@ -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.getOrDefault
|
||||
|
||||
open class JSPlaylist : JSContent, IPlatformPlaylist {
|
||||
override val contentType: ContentType get() = ContentType.PLAYLIST;
|
||||
override val thumbnail: String?;
|
||||
override val videoCount: Int;
|
||||
open class JSPlaylist(
|
||||
config: SourcePluginConfig,
|
||||
obj: V8ValueObject
|
||||
) : JSContent(config, obj), IPlatformPlaylist {
|
||||
|
||||
constructor(config: SourcePluginConfig, obj: V8ValueObject) : super(config, obj) {
|
||||
val contextName = "Playlist";
|
||||
thumbnail = obj.getOrDefault(config, "thumbnail", contextName, null);
|
||||
videoCount = obj.getOrDefault(config, "videoCount", contextName, -1)!!;
|
||||
}
|
||||
}
|
||||
override val contentType: ContentType = ContentType.PLAYLIST
|
||||
|
||||
override val thumbnail: String? =
|
||||
_content.getOrDefault<String>(_pluginConfig, "thumbnail", "Playlist", null)
|
||||
|
||||
override val videoCount: Int =
|
||||
_content.getOrDefault<Int>(_pluginConfig, "videoCount", "Playlist", null)?.toInt() ?: -1
|
||||
}
|
||||
|
||||
+3
@@ -5,6 +5,7 @@ import com.caoccao.javet.values.primitive.V8ValueString
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.getSourcePlugin
|
||||
import com.futo.platformplayer.invokeV8
|
||||
@@ -22,6 +23,7 @@ class JSSubtitleSource : ISubtitleSource {
|
||||
override val name: String;
|
||||
override val url: String?;
|
||||
override val format: String?;
|
||||
override val language: String?
|
||||
override val hasFetch: Boolean;
|
||||
|
||||
constructor(config: SourcePluginConfig, v8Value: V8ValueObject) {
|
||||
@@ -29,6 +31,7 @@ class JSSubtitleSource : ISubtitleSource {
|
||||
|
||||
val context = "JSSubtitles";
|
||||
name = v8Value.getOrThrow(config, "name", context, false);
|
||||
language = v8Value.getOrDefault(config, "language", context, null);
|
||||
url = v8Value.getOrThrow(config, "url", context, true);
|
||||
format = v8Value.getOrThrow(config, "format", context, true);
|
||||
hasFetch = v8Value.has("getSubtitles");
|
||||
|
||||
+31
-30
@@ -8,43 +8,44 @@ import com.futo.platformplayer.engine.V8Plugin
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
open class JSAudioUrlSource : IAudioUrlSource, JSSource {
|
||||
override val name: String;
|
||||
override val bitrate : Int;
|
||||
override val container : String;
|
||||
override val codec: String;
|
||||
private val url : String;
|
||||
open class JSAudioUrlSource(
|
||||
plugin: JSClient,
|
||||
obj: V8ValueObject
|
||||
) : JSSource(TYPE_AUDIOURL, plugin, obj), IAudioUrlSource {
|
||||
|
||||
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) {
|
||||
val contextName = "AudioUrlSource";
|
||||
val config = plugin.config;
|
||||
private val url: String =
|
||||
_obj.getOrThrow<String>(cfg, "url", ctx)
|
||||
|
||||
bitrate = _obj.getOrThrow(config, "bitrate", contextName);
|
||||
container = _obj.getOrThrow(config, "container", contextName);
|
||||
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);
|
||||
override val language: String =
|
||||
_obj.getOrThrow<String>(cfg, "language", ctx)
|
||||
|
||||
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;
|
||||
original = if(_obj.has("original")) obj.getOrThrow(config, "original", contextName) else false;
|
||||
}
|
||||
override val name: String =
|
||||
_obj.getOrDefault<String>(cfg, "name", ctx, null)
|
||||
?: "$container $bitrate"
|
||||
|
||||
override fun getAudioUrl() : String {
|
||||
return url;
|
||||
}
|
||||
override var priority: Boolean =
|
||||
if (_obj.has("priority")) _obj.getOrThrow<Boolean>(cfg, "priority", ctx) else false
|
||||
|
||||
override fun toString(): String {
|
||||
return "(name=$name, container=$container, bitrate=$bitrate, codec=$codec, url=$url, language=$language, duration=$duration)";
|
||||
}
|
||||
}
|
||||
override var original: Boolean =
|
||||
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)"
|
||||
}
|
||||
|
||||
+41
-31
@@ -31,42 +31,52 @@ interface IJSDashManifestRawSource {
|
||||
fun generateAsync(scope: CoroutineScope): Deferred<String?>;
|
||||
fun generate(): String?;
|
||||
}
|
||||
open class JSDashManifestRawSource: JSSource, IVideoSource, IJSDashManifestRawSource, IStreamMetaDataSource {
|
||||
override val container : String;
|
||||
override val name : String;
|
||||
override val width: Int;
|
||||
override val height: Int;
|
||||
override val codec: String;
|
||||
override val bitrate: Int?;
|
||||
override val duration: Long;
|
||||
override val priority: Boolean;
|
||||
open class JSDashManifestRawSource(
|
||||
plugin: JSClient,
|
||||
obj: V8ValueObject
|
||||
) : JSSource(TYPE_DASH_RAW, plugin, obj), IVideoSource, IJSDashManifestRawSource, IStreamMetaDataSource {
|
||||
|
||||
val url: String?;
|
||||
override var manifest: String?;
|
||||
private val ctx = "DashRawSource"
|
||||
private val cfg = plugin.config
|
||||
|
||||
override val hasGenerate: Boolean;
|
||||
val canMerge: Boolean;
|
||||
override val container: String =
|
||||
_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) {
|
||||
val contextName = "DashRawSource";
|
||||
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");
|
||||
}
|
||||
override val width: Int =
|
||||
_obj.getOrDefault<Int>(cfg, "width", ctx, null)?.toInt() ?: 0
|
||||
|
||||
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?>? {
|
||||
_pregenerate = generateAsync(scope);
|
||||
return _pregenerate;
|
||||
|
||||
+35
-30
@@ -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.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.engine.V8Plugin
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrNull
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
open class JSVideoUrlSource : IVideoUrlSource, JSSource {
|
||||
override val width : Int;
|
||||
override val height : Int;
|
||||
override val container : String;
|
||||
override val codec: String;
|
||||
override val name : String;
|
||||
override val bitrate : Int;
|
||||
override val duration: Long;
|
||||
private val url : String;
|
||||
open class JSVideoUrlSource(
|
||||
plugin: JSClient,
|
||||
obj: V8ValueObject
|
||||
) : JSSource(TYPE_VIDEOURL, plugin, obj), IVideoUrlSource {
|
||||
|
||||
override var priority: Boolean = false;
|
||||
private val ctx = "JSVideoUrlSource"
|
||||
private val cfg = plugin.config
|
||||
|
||||
constructor(plugin: JSClient, obj: V8ValueObject): super(TYPE_VIDEOURL, plugin, obj) {
|
||||
val contextName = "JSVideoUrlSource";
|
||||
val config = plugin.config;
|
||||
override val width: Int =
|
||||
_obj.getOrThrow<Int>(cfg, "width", ctx)
|
||||
|
||||
width = _obj.getOrThrow(config, "width", contextName);
|
||||
height = _obj.getOrThrow(config, "height", contextName);
|
||||
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);
|
||||
override val height: Int =
|
||||
_obj.getOrThrow<Int>(cfg, "height", ctx)
|
||||
|
||||
priority = obj.getOrNull(config, "priority", contextName) ?: false;
|
||||
}
|
||||
override val container: String =
|
||||
_obj.getOrThrow<String>(cfg, "container", ctx)
|
||||
|
||||
override fun getVideoUrl() : String {
|
||||
return url;
|
||||
}
|
||||
override val codec: String =
|
||||
_obj.getOrThrow<String>(cfg, "codec", ctx)
|
||||
|
||||
override fun toString(): String {
|
||||
return "(width=$width, height=$height, container=$container, codec=$codec, name=$name, bitrate=$bitrate, duration=$duration, url=$url)"
|
||||
}
|
||||
}
|
||||
override val name: String =
|
||||
_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)"
|
||||
}
|
||||
|
||||
+157
-2
@@ -1,5 +1,160 @@
|
||||
package com.futo.platformplayer.api.media.platforms.local
|
||||
|
||||
class LocalClient {
|
||||
//TODO
|
||||
import android.content.ContentResolver
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.api.media.IPlatformClient
|
||||
import com.futo.platformplayer.api.media.PlatformClientCapabilities
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||
import com.futo.platformplayer.api.media.models.ResultCapabilities
|
||||
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
||||
import com.futo.platformplayer.api.media.models.chapters.IChapter
|
||||
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
|
||||
import com.futo.platformplayer.api.media.models.live.ILiveChatWindowDescriptor
|
||||
import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
|
||||
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
|
||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
|
||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.api.media.structures.EmptyPager
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException
|
||||
import com.futo.platformplayer.models.ImageVariable
|
||||
import com.futo.platformplayer.states.StateLibrary
|
||||
import java.net.MalformedURLException
|
||||
|
||||
class LocalClient: IPlatformClient {
|
||||
override val id: String = "LOCAL"
|
||||
override val name: String = "Local"
|
||||
override val icon: ImageVariable? = ImageVariable.fromResource(R.drawable.ic_library)
|
||||
override val capabilities: PlatformClientCapabilities = PlatformClientCapabilities()
|
||||
|
||||
override fun initialize() {}
|
||||
|
||||
override fun disable() {
|
||||
|
||||
}
|
||||
|
||||
override fun getHome(): IPager<IPlatformContent>
|
||||
= EmptyPager();
|
||||
|
||||
override fun isContentDetailsUrl(url: String): Boolean {
|
||||
try {
|
||||
val uri = Uri.parse(url);
|
||||
return ContentResolver.SCHEME_CONTENT == uri.scheme
|
||||
&& (
|
||||
MediaStore.AUTHORITY == uri.authority ||
|
||||
uri.authority == "com.android.externalstorage.documents"
|
||||
)
|
||||
}
|
||||
catch(ex: MalformedURLException) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
val audioExtensions = listOf(".mp3", ".wav", ".flac", ".mp4a", ".m4a");
|
||||
override fun getContentDetails(url: String): IPlatformContentDetails {
|
||||
val uri = Uri.parse(url);
|
||||
|
||||
if("audio" in uri.pathSegments) {
|
||||
return StateLibrary.getAudioTrack(url) ?: throw Exception("Failed to find ${url}");
|
||||
}
|
||||
else if("video" in uri.pathSegments) {
|
||||
return StateLibrary.getVideoTrack(url) ?: throw Exception("Failed to find ${url}");
|
||||
}
|
||||
else if(uri.toString().contains("com.android.externalstorage.documents")) {
|
||||
if(audioExtensions.any { uri.lastPathSegment?.lowercase()?.endsWith(it) ?: false })
|
||||
return StateLibrary.getAudioTrack(url) ?: throw Exception("Failed to find ${url}");
|
||||
else
|
||||
return StateLibrary.getVideoTrack(url) ?: throw Exception("Failed to find ${url}");
|
||||
}
|
||||
else
|
||||
throw Exception("Unknown content url [${url}]");
|
||||
}
|
||||
|
||||
override fun getSearchCapabilities(): ResultCapabilities
|
||||
= ResultCapabilities();
|
||||
override fun search(query: String, type: String?, order: String?, filters: Map<String, List<String>>?): IPager<IPlatformContent> {
|
||||
return EmptyPager(); //TODO
|
||||
}
|
||||
|
||||
override fun getSearchChannelContentsCapabilities(): ResultCapabilities
|
||||
= ResultCapabilities();
|
||||
override fun searchChannelContents(channelUrl: String, query: String, type: String?, order: String?, filters: Map<String, List<String>>?): IPager<IPlatformContent> {
|
||||
return EmptyPager(); //TODO
|
||||
}
|
||||
|
||||
override fun searchChannels(query: String): IPager<PlatformAuthorLink> {
|
||||
return EmptyPager(); //TODO
|
||||
}
|
||||
|
||||
override fun searchChannelsAsContent(query: String): IPager<IPlatformContent> {
|
||||
return EmptyPager(); //TODO
|
||||
}
|
||||
|
||||
override fun isChannelUrl(url: String): Boolean {
|
||||
return false //TODO
|
||||
}
|
||||
|
||||
override fun getChannel(channelUrl: String): IPlatformChannel {
|
||||
throw NotImplementedError();
|
||||
}
|
||||
|
||||
override fun getChannelCapabilities(): ResultCapabilities
|
||||
= ResultCapabilities();
|
||||
override fun getChannelContents(channelUrl: String, type: String?, order: String?, filters: Map<String, List<String>>?): IPager<IPlatformContent> {
|
||||
return EmptyPager();
|
||||
}
|
||||
|
||||
override fun getChannelPlaylists(channelUrl: String): IPager<IPlatformPlaylist> {
|
||||
return EmptyPager();
|
||||
}
|
||||
|
||||
override fun getPeekChannelTypes(): List<String> = listOf();
|
||||
|
||||
override fun peekChannelContents(channelUrl: String, type: String?): List<IPlatformContent>
|
||||
= listOf();
|
||||
|
||||
override fun getShorts(): IPager<IPlatformVideo> = EmptyPager();
|
||||
|
||||
override fun searchSuggestions(query: String): Array<String> = arrayOf();
|
||||
|
||||
override fun getChannelUrlByClaim(claimType: Int, claimValues: Map<Int, String>): String?
|
||||
= null;
|
||||
|
||||
override fun getContentChapters(url: String): List<IChapter>
|
||||
= listOf();
|
||||
|
||||
override fun getPlaybackTracker(url: String): IPlaybackTracker?
|
||||
= null;
|
||||
|
||||
override fun getContentRecommendations(url: String): IPager<IPlatformContent>?
|
||||
= null;
|
||||
|
||||
override fun getComments(url: String): IPager<IPlatformComment>
|
||||
= EmptyPager();
|
||||
|
||||
override fun getSubComments(comment: IPlatformComment): IPager<IPlatformComment>
|
||||
= EmptyPager();
|
||||
|
||||
override fun getLiveChatWindow(url: String): ILiveChatWindowDescriptor?
|
||||
= null;
|
||||
|
||||
override fun getLiveEvents(url: String): IPager<IPlatformLiveEvent>?
|
||||
= null;
|
||||
|
||||
override fun searchPlaylists(query: String, type: String?, order: String?, filters: Map<String, List<String>>?): IPager<IPlatformContent>
|
||||
= throw NotImplementedError();
|
||||
|
||||
override fun isPlaylistUrl(url: String): Boolean = false;
|
||||
|
||||
override fun getPlaylist(url: String): IPlatformPlaylistDetails
|
||||
= throw NotImplementedError();
|
||||
override fun getUserPlaylists(): Array<String> = throw NotImplementedError();
|
||||
override fun getUserSubscriptions(): Array<String> = throw NotImplementedError();
|
||||
override fun getUserHistory(): IPager<IPlatformContent> = throw NotImplementedError();
|
||||
override fun isClaimTypeSupported(claimType: Int): Boolean = false;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import com.futo.platformplayer.api.http.server.handlers.HttpConstantHandler
|
||||
import com.futo.platformplayer.api.http.server.handlers.HttpFileHandler
|
||||
import com.futo.platformplayer.api.http.server.handlers.HttpFunctionHandler
|
||||
import com.futo.platformplayer.api.http.server.handlers.HttpProxyHandler
|
||||
import com.futo.platformplayer.api.media.models.modifier.IRequestModifier
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudioSource
|
||||
@@ -90,8 +91,7 @@ abstract class StateCasting {
|
||||
abstract fun start(context: Context)
|
||||
abstract fun stop()
|
||||
|
||||
@Throws
|
||||
abstract fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDevice
|
||||
abstract fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDevice?
|
||||
abstract fun startUpdateTimeJob(
|
||||
onTimeJobTimeChanged_s: Event1<Long>, setTime: (Long) -> Unit
|
||||
): Job?
|
||||
@@ -296,20 +296,63 @@ abstract class StateCasting {
|
||||
val url = getLocalUrl(ad);
|
||||
val id = UUID.randomUUID();
|
||||
|
||||
|
||||
if (videoSource is IVideoUrlSource) {
|
||||
val videoPath = "/video-${id}"
|
||||
val videoUrl = if(proxyStreams) url + videoPath else videoSource.getVideoUrl();
|
||||
Logger.i(TAG, "Casting as singular video");
|
||||
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video));
|
||||
val videoPath = "/video-$id"
|
||||
val upstreamUrl = videoSource.getVideoUrl()
|
||||
val videoUrl = if (proxyStreams) url + videoPath else upstreamUrl
|
||||
val jsReqMod = (videoSource as? JSSource)?.getRequestModifier()
|
||||
|
||||
if (proxyStreams) {
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpProxyHandler("GET", videoPath, upstreamUrl, true)
|
||||
.withIRequestModifier(jsReqMod)
|
||||
.withInjectedHost()
|
||||
.withHeader("Access-Control-Allow-Origin", "*"),
|
||||
true
|
||||
).withTag("castSingular")
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Casting as singular video (proxy=$proxyStreams, url=$videoUrl)")
|
||||
ad.loadVideo(
|
||||
if (video.isLive) "LIVE" else "BUFFERED",
|
||||
videoSource.container,
|
||||
videoUrl,
|
||||
resumePosition,
|
||||
video.duration.toDouble(),
|
||||
speed,
|
||||
metadataFromVideo(video)
|
||||
)
|
||||
} else if (audioSource is IAudioUrlSource) {
|
||||
val audioPath = "/audio-${id}"
|
||||
val audioUrl = if(proxyStreams) url + audioPath else audioSource.getAudioUrl();
|
||||
Logger.i(TAG, "Casting as singular audio");
|
||||
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video));
|
||||
val audioPath = "/audio-$id"
|
||||
val upstreamUrl = audioSource.getAudioUrl()
|
||||
val audioUrl = if (proxyStreams) url + audioPath else upstreamUrl
|
||||
val jsReqMod = (audioSource as? JSSource)?.getRequestModifier()
|
||||
|
||||
if (proxyStreams) {
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpProxyHandler("GET", audioPath, upstreamUrl, true)
|
||||
.withIRequestModifier(jsReqMod)
|
||||
.withInjectedHost()
|
||||
.withHeader("Access-Control-Allow-Origin", "*"),
|
||||
true
|
||||
).withTag("castSingular")
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Casting as singular audio (proxy=$proxyStreams, url=$audioUrl)")
|
||||
ad.loadVideo(
|
||||
if (video.isLive) "LIVE" else "BUFFERED",
|
||||
audioSource.container,
|
||||
audioUrl,
|
||||
resumePosition,
|
||||
video.duration.toDouble(),
|
||||
speed,
|
||||
metadataFromVideo(video)
|
||||
)
|
||||
} else if (videoSource is IHLSManifestSource) {
|
||||
if (proxyStreams || deviceProto == CastProtocolType.CHROMECAST) {
|
||||
Logger.i(TAG, "Casting as proxied HLS");
|
||||
castProxiedHls(video, videoSource.url, videoSource.codec, resumePosition, speed);
|
||||
castProxiedHls(video, videoSource.url, videoSource.codec, resumePosition, speed, (videoSource as JSSource?)?.getRequestModifier());
|
||||
} else {
|
||||
Logger.i(TAG, "Casting as non-proxied HLS");
|
||||
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video));
|
||||
@@ -317,7 +360,7 @@ abstract class StateCasting {
|
||||
} else if (audioSource is IHLSManifestAudioSource) {
|
||||
if (proxyStreams || deviceProto == CastProtocolType.CHROMECAST) {
|
||||
Logger.i(TAG, "Casting as proxied audio HLS");
|
||||
castProxiedHls(video, audioSource.url, audioSource.codec, resumePosition, speed);
|
||||
castProxiedHls(video, audioSource.url, audioSource.codec, resumePosition, speed, (audioSource as JSSource?)?.getRequestModifier());
|
||||
} else {
|
||||
Logger.i(TAG, "Casting as non-proxied audio HLS");
|
||||
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video));
|
||||
@@ -348,6 +391,11 @@ abstract class StateCasting {
|
||||
}
|
||||
}
|
||||
|
||||
private fun HttpProxyHandler.withIRequestModifier(requestModifier: IRequestModifier?): HttpProxyHandler {
|
||||
if (requestModifier == null) return this
|
||||
return withRequestModifier { url, headers -> requestModifier.modifyRequest(url, headers) }
|
||||
}
|
||||
|
||||
fun resumeVideo(): Boolean {
|
||||
val ad = activeDevice ?: return false;
|
||||
try {
|
||||
@@ -666,7 +714,8 @@ abstract class StateCasting {
|
||||
sourceUrl: String,
|
||||
codec: String?,
|
||||
resumePosition: Double,
|
||||
speed: Double?
|
||||
speed: Double?,
|
||||
requestModifier: IRequestModifier?
|
||||
): List<String> {
|
||||
_castServer.removeAllHandlers("castProxiedHlsMaster")
|
||||
|
||||
@@ -687,7 +736,9 @@ abstract class StateCasting {
|
||||
val headers = masterContext.headers.clone()
|
||||
headers["Content-Type"] = "application/vnd.apple.mpegurl";
|
||||
|
||||
val masterPlaylistResponse = _client.get(sourceUrl)
|
||||
val req = requestModifier?.modifyRequest(sourceUrl, mapOf())
|
||||
val masterPlaylistResponse = _client.get(req?.url ?: sourceUrl, (req?.headers ?: mapOf()).toMutableMap())
|
||||
|
||||
check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" }
|
||||
|
||||
val masterPlaylistContent = masterPlaylistResponse.body?.string()
|
||||
@@ -707,7 +758,7 @@ abstract class StateCasting {
|
||||
val variantPlaylist =
|
||||
HLS.parseVariantPlaylist(masterPlaylistContent, sourceUrl)
|
||||
val proxiedVariantPlaylist =
|
||||
proxyVariantPlaylist(url, id, variantPlaylist, video.isLive)
|
||||
proxyVariantPlaylist(url, id, variantPlaylist, video.isLive, requestModifier)
|
||||
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
|
||||
masterContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
|
||||
return@HttpFunctionHandler
|
||||
@@ -748,7 +799,7 @@ abstract class StateCasting {
|
||||
val variantPlaylist =
|
||||
HLS.parseVariantPlaylist(vpContent, variantPlaylistRef.url)
|
||||
val proxiedVariantPlaylist =
|
||||
proxyVariantPlaylist(url, playlistId, variantPlaylist, video.isLive)
|
||||
proxyVariantPlaylist(url, playlistId, variantPlaylist, video.isLive, requestModifier)
|
||||
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
|
||||
vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
|
||||
}.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
@@ -785,7 +836,7 @@ abstract class StateCasting {
|
||||
val variantPlaylist =
|
||||
HLS.parseVariantPlaylist(vpContent, mediaRendition.uri)
|
||||
val proxiedVariantPlaylist = proxyVariantPlaylist(
|
||||
url, playlistId, variantPlaylist, video.isLive
|
||||
url, playlistId, variantPlaylist, video.isLive, requestModifier
|
||||
)
|
||||
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
|
||||
vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
|
||||
@@ -827,13 +878,13 @@ abstract class StateCasting {
|
||||
return listOf(hlsUrl);
|
||||
}
|
||||
|
||||
private fun proxyVariantPlaylist(url: String, playlistId: UUID, variantPlaylist: HLS.VariantPlaylist, isLive: Boolean, proxySegments: Boolean = true): HLS.VariantPlaylist {
|
||||
private fun proxyVariantPlaylist(url: String, playlistId: UUID, variantPlaylist: HLS.VariantPlaylist, isLive: Boolean, requestModifier: IRequestModifier?, proxySegments: Boolean = true): HLS.VariantPlaylist {
|
||||
val newSegments = arrayListOf<HLS.Segment>()
|
||||
|
||||
if (proxySegments) {
|
||||
variantPlaylist.segments.forEachIndexed { index, segment ->
|
||||
val sequenceNumber = (variantPlaylist.mediaSequence ?: 0) + index.toLong()
|
||||
newSegments.add(proxySegment(url, playlistId, segment, sequenceNumber))
|
||||
newSegments.add(proxySegment(url, playlistId, segment, sequenceNumber, requestModifier))
|
||||
}
|
||||
} else {
|
||||
newSegments.addAll(variantPlaylist.segments)
|
||||
@@ -851,7 +902,7 @@ abstract class StateCasting {
|
||||
)
|
||||
}
|
||||
|
||||
private fun proxySegment(url: String, playlistId: UUID, segment: HLS.Segment, index: Long): HLS.Segment {
|
||||
private fun proxySegment(url: String, playlistId: UUID, segment: HLS.Segment, index: Long, requestModifier: IRequestModifier?): HLS.Segment {
|
||||
if (segment is HLS.MediaSegment) {
|
||||
val newSegmentPath = "/hls-playlist-${playlistId}-segment-${index}"
|
||||
val newSegmentUrl = url + newSegmentPath;
|
||||
@@ -859,6 +910,7 @@ abstract class StateCasting {
|
||||
if (_castServer.getHandler("GET", newSegmentPath) == null) {
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpProxyHandler("GET", newSegmentPath, segment.uri, true)
|
||||
.withIRequestModifier(requestModifier)
|
||||
.withInjectedHost()
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("castProxiedHlsVariant")
|
||||
@@ -1288,9 +1340,11 @@ abstract class StateCasting {
|
||||
return listOf()
|
||||
}
|
||||
|
||||
fun addRememberedDevice(deviceInfo: CastingDeviceInfo): CastingDeviceInfo {
|
||||
val device = deviceFromInfo(deviceInfo);
|
||||
return addRememberedDevice(device);
|
||||
fun addRememberedDevice(deviceInfo: CastingDeviceInfo): CastingDeviceInfo? {
|
||||
return when (val device = deviceFromInfo(deviceInfo)) {
|
||||
null -> null
|
||||
else -> addRememberedDevice(device)
|
||||
}
|
||||
}
|
||||
|
||||
fun addRememberedDevice(device: CastingDevice): CastingDeviceInfo {
|
||||
@@ -1299,7 +1353,7 @@ abstract class StateCasting {
|
||||
}
|
||||
|
||||
fun getRememberedCastingDevices(): List<CastingDevice> {
|
||||
return _storage.getDevices().map { deviceFromInfo(it) }
|
||||
return _storage.getDevices().map { deviceFromInfo(it) }.filterNotNull()
|
||||
}
|
||||
|
||||
fun getRememberedCastingDeviceNames(): List<String> {
|
||||
|
||||
@@ -151,21 +151,25 @@ class StateCastingExp : StateCasting() {
|
||||
setTime: (Long) -> Unit
|
||||
): Job? = null
|
||||
|
||||
override fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDeviceExp {
|
||||
val rsAddrs =
|
||||
deviceInfo.addresses.map { org.fcast.sender_sdk.tryIpAddrFromStr(it) } // Throws!
|
||||
val rsDeviceInfo = RsDeviceInfo(
|
||||
name = deviceInfo.name,
|
||||
protocol = when (deviceInfo.type) {
|
||||
com.futo.platformplayer.casting.CastProtocolType.CHROMECAST -> ProtocolType.CHROMECAST
|
||||
com.futo.platformplayer.casting.CastProtocolType.FCAST -> ProtocolType.F_CAST
|
||||
else -> throw IllegalArgumentException()
|
||||
},
|
||||
addresses = rsAddrs,
|
||||
port = deviceInfo.port.toUShort(),
|
||||
)
|
||||
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))
|
||||
return CastingDeviceExp(_context.createDeviceFromInfo(rsDeviceInfo))
|
||||
} catch (_: Throwable) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -55,7 +55,9 @@ class StateCastingLegacy : StateCasting() {
|
||||
)
|
||||
)
|
||||
|
||||
connectDevice(deviceFromInfo(foundInfo))
|
||||
if (foundInfo != null) {
|
||||
connectDevice(deviceFromInfo(foundInfo))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
|
||||
@@ -18,6 +18,7 @@ import com.futo.platformplayer.engine.dev.V8RemoteObject
|
||||
import com.futo.platformplayer.engine.dev.V8RemoteObject.Companion.gsonStandard
|
||||
import com.futo.platformplayer.engine.dev.V8RemoteObject.Companion.serialize
|
||||
import com.futo.platformplayer.engine.packages.PackageHttp
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.LoginFragment
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateAssets
|
||||
@@ -268,11 +269,15 @@ class DeveloperEndpoints(private val context: Context) {
|
||||
context.respondCode(403, "This plugin doesn't support auth");
|
||||
return;
|
||||
}
|
||||
LoginFragment.showLogin(config){
|
||||
_testPluginVariables.clear();
|
||||
_testPlugin = V8Plugin(StateApp.instance.context, config, null, JSHttpClient(null, null, null, config), JSHttpClient(null, it, null, config));
|
||||
};
|
||||
/*
|
||||
LoginActivity.showLogin(StateApp.instance.context, config) {
|
||||
_testPluginVariables.clear();
|
||||
_testPlugin = V8Plugin(StateApp.instance.context, config, null, JSHttpClient(null, null, null, config), JSHttpClient(null, it, null, config));
|
||||
|
||||
};
|
||||
}; */
|
||||
context.respondCode(200, "Login started");
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
|
||||
@@ -90,6 +90,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
|
||||
_buttonClose.setOnClickListener { dismiss(); };
|
||||
_buttonDisconnect.setOnClickListener {
|
||||
try {
|
||||
StateCasting.instance.stopVideo()
|
||||
StateCasting.instance.activeDevice?.disconnect()
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Active device failed to disconnect: $e")
|
||||
|
||||
@@ -48,6 +48,7 @@ class PluginUpdateDialog : AlertDialog {
|
||||
|
||||
private lateinit var _buttonCancel1: Button;
|
||||
private lateinit var _buttonCancel2: Button;
|
||||
private lateinit var _buttonAlways: LinearLayout;
|
||||
private lateinit var _buttonUpdate: LinearLayout;
|
||||
|
||||
private lateinit var _buttonOk: LinearLayout;
|
||||
@@ -58,6 +59,7 @@ class PluginUpdateDialog : AlertDialog {
|
||||
private lateinit var _textProgres: TextView;
|
||||
private lateinit var _textError: TextView;
|
||||
private lateinit var _textResult: TextView;
|
||||
private lateinit var _textChangelogResult: TextView;
|
||||
|
||||
private lateinit var _uiChoiceTop: FrameLayout;
|
||||
private lateinit var _uiProgressTop: FrameLayout;
|
||||
@@ -89,6 +91,7 @@ class PluginUpdateDialog : AlertDialog {
|
||||
|
||||
_buttonCancel1 = findViewById(R.id.button_cancel_1);
|
||||
_buttonCancel2 = findViewById(R.id.button_cancel_2);
|
||||
_buttonAlways = findViewById(R.id.button_always);
|
||||
_buttonUpdate = findViewById(R.id.button_update);
|
||||
|
||||
_buttonOk = findViewById(R.id.button_ok);
|
||||
@@ -99,6 +102,7 @@ class PluginUpdateDialog : AlertDialog {
|
||||
_textProgres = findViewById(R.id.text_progress);
|
||||
_textError = findViewById(R.id.text_error);
|
||||
_textResult = findViewById(R.id.text_result);
|
||||
_textChangelogResult = findViewById(R.id.text_changelog_result);
|
||||
|
||||
_uiChoiceTop = findViewById(R.id.dialog_ui_choice_top);
|
||||
_uiProgressTop = findViewById(R.id.dialog_ui_progress_top);
|
||||
@@ -119,17 +123,24 @@ class PluginUpdateDialog : AlertDialog {
|
||||
val changelog = _newConfig.changelog!![changelogVersion]!!;
|
||||
if(changelog.size > 1) {
|
||||
_textChangelog.text = "Changelog (${_newConfig.version})\n" + changelog.map { " - " + it.trim() }.joinToString("\n");
|
||||
_textChangelogResult.text = _textChangelog.text;
|
||||
}
|
||||
else if(changelog.size == 1) {
|
||||
_textChangelog.text = "Changelog (${_newConfig.version})\n" + changelog[0].trim();
|
||||
_textChangelogResult.text = _textChangelog.text;
|
||||
}
|
||||
else
|
||||
else {
|
||||
_textChangelog.visibility = View.GONE;
|
||||
} else
|
||||
_textChangelog.visibility = View.GONE;
|
||||
_textChangelogResult.visibility = View.GONE;
|
||||
}
|
||||
} else {
|
||||
_textChangelog.visibility = View.GONE;
|
||||
_textChangelogResult.visibility = View.GONE;
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
_textChangelog.visibility = View.GONE;
|
||||
_textChangelogResult.visibility = View.GONE;
|
||||
Logger.e(TAG, "Invalid changelog? ", ex);
|
||||
}
|
||||
|
||||
@@ -145,6 +156,18 @@ class PluginUpdateDialog : AlertDialog {
|
||||
_isUpdating = true;
|
||||
update();
|
||||
};
|
||||
_buttonAlways.setOnClickListener {
|
||||
if (_isUpdating)
|
||||
return@setOnClickListener;
|
||||
val plugin = StatePlugins.instance.getPlugin(_oldConfig.id);
|
||||
if(plugin != null) {
|
||||
plugin.appSettings.automaticUpdate = true;
|
||||
StatePlugins.instance.savePlugin(_oldConfig.id);
|
||||
UIDialogs.appToast("Automatic update enabled, can be disabled in plugin settings");
|
||||
}
|
||||
_isUpdating = true;
|
||||
update();
|
||||
};
|
||||
|
||||
Glide.with(_iconPlugin)
|
||||
.load(_oldConfig.absoluteIconUrl)
|
||||
@@ -158,7 +181,8 @@ class PluginUpdateDialog : AlertDialog {
|
||||
if (_isUpdating)
|
||||
return;
|
||||
_isUpdating = true;
|
||||
update();
|
||||
|
||||
update(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -167,7 +191,7 @@ class PluginUpdateDialog : AlertDialog {
|
||||
super.dismiss();
|
||||
}
|
||||
|
||||
private fun update() {
|
||||
private fun update(automatic: Boolean = false) {
|
||||
_uiChoiceTop.visibility = View.GONE;
|
||||
_uiRiskTop.visibility = View.GONE;
|
||||
_uiChoiceBot.visibility = View.GONE;
|
||||
@@ -187,9 +211,16 @@ class PluginUpdateDialog : AlertDialog {
|
||||
val scope = StateApp.instance.scopeOrNull;
|
||||
scope?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
withContext(Dispatchers.Main) {
|
||||
_textProgres.setText("Loading current script file...");
|
||||
}
|
||||
val client = ManagedHttpClient();
|
||||
client.setTimeout(10000);
|
||||
val script = StatePlugins.instance.getScript(_oldConfig.id) ?: "";
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
_textProgres.setText("Requesting new script file...");
|
||||
}
|
||||
val newScript = client.get(_newConfig.absoluteScriptUrl)?.body?.string();
|
||||
if(newScript.isNullOrEmpty())
|
||||
throw IllegalStateException("No script found");
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package com.futo.platformplayer.downloads
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaCodec
|
||||
import android.media.MediaExtractor
|
||||
import android.media.MediaMuxer
|
||||
import android.util.Log
|
||||
import com.arthenica.ffmpegkit.FFmpegKit
|
||||
import com.arthenica.ffmpegkit.ReturnCode
|
||||
@@ -8,6 +11,7 @@ import com.arthenica.ffmpegkit.StatisticsCallback
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.media.PlatformID
|
||||
import com.futo.platformplayer.api.media.models.modifier.IRequestModifier
|
||||
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.IAudioSource
|
||||
@@ -136,6 +140,8 @@ class VideoDownload {
|
||||
|
||||
var hasVideoRequestExecutor: Boolean = false;
|
||||
var hasAudioRequestExecutor: Boolean = false;
|
||||
var hasVideoRequestModifier: Boolean = false;
|
||||
var hasAudioRequestModifier: Boolean = false;
|
||||
|
||||
var progress: Double = 0.0;
|
||||
var isCancelled = false;
|
||||
@@ -203,8 +209,10 @@ class VideoDownload {
|
||||
this.prepareTime = OffsetDateTime.now();
|
||||
this.hasVideoRequestExecutor = videoSource is JSSource && videoSource.hasRequestExecutor;
|
||||
this.hasAudioRequestExecutor = audioSource is JSSource && audioSource.hasRequestExecutor;
|
||||
this.requiresLiveVideoSource = this.hasVideoRequestExecutor || (videoSource is JSDashManifestRawSource && videoSource.hasGenerate);
|
||||
this.requiresLiveAudioSource = this.hasAudioRequestExecutor || (audioSource is JSDashManifestRawAudioSource && audioSource.hasGenerate);
|
||||
this.hasVideoRequestModifier = videoSource is JSSource && videoSource.hasRequestModifier;
|
||||
this.hasAudioRequestModifier = audioSource is JSSource && audioSource.hasRequestModifier;
|
||||
this.requiresLiveVideoSource = this.hasVideoRequestModifier || this.hasVideoRequestExecutor || (videoSource is JSDashManifestRawSource && videoSource.hasGenerate);
|
||||
this.requiresLiveAudioSource = this.hasAudioRequestModifier || this.hasAudioRequestExecutor || (audioSource is JSDashManifestRawAudioSource && audioSource.hasGenerate);
|
||||
this.targetVideoName = videoSource?.name;
|
||||
this.targetAudioName = audioSource?.name;
|
||||
this.targetPixelCount = if(videoSource != null) (videoSource.width * videoSource.height).toLong() else null;
|
||||
@@ -478,8 +486,8 @@ class VideoDownload {
|
||||
|
||||
if(actualVideoSource is IVideoUrlSource)
|
||||
videoFileSize = when (videoSource!!.container) {
|
||||
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Video", client, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
|
||||
else -> downloadFileSource("Video", client, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
|
||||
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Video", client, if (actualVideoSource is JSSource) actualVideoSource else null, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
|
||||
else -> downloadFileSource("Video", client, if (actualVideoSource is JSSource) actualVideoSource else null, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
|
||||
}
|
||||
else if(actualVideoSource is JSDashManifestRawSource) {
|
||||
videoFileSize = downloadDashFileSource("Video", client, actualVideoSource, File(downloadDir, videoFileName!!), progressCallback);
|
||||
@@ -518,8 +526,8 @@ class VideoDownload {
|
||||
|
||||
if(actualAudioSource is IAudioUrlSource)
|
||||
audioFileSize = when (audioSource!!.container) {
|
||||
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Audio", client, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
|
||||
else -> downloadFileSource("Audio", client, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
|
||||
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Audio", client, if (actualAudioSource is JSSource) actualAudioSource else null, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
|
||||
else -> downloadFileSource("Audio", client, if (actualAudioSource is JSSource) actualAudioSource else null, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
|
||||
}
|
||||
else if(actualAudioSource is JSDashManifestRawAudioSource) {
|
||||
audioFileSize = downloadDashFileSource("Audio", client, actualAudioSource, File(downloadDir, audioFileName!!), progressCallback);
|
||||
@@ -580,83 +588,12 @@ class VideoDownload {
|
||||
return cipher.doFinal(encryptedSegment)
|
||||
}
|
||||
|
||||
private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||
if(targetFile.exists())
|
||||
targetFile.delete();
|
||||
|
||||
var downloadedTotalLength = 0L
|
||||
|
||||
val segmentFiles = arrayListOf<File>()
|
||||
try {
|
||||
val response = client.get(hlsUrl)
|
||||
check(response.isOk) { "Failed to get variant playlist: ${response.code}" }
|
||||
|
||||
val vpContent = response.body?.string()
|
||||
?: throw Exception("Variant playlist content is empty")
|
||||
|
||||
val variantPlaylist = HLS.parseVariantPlaylist(vpContent, hlsUrl)
|
||||
val decryptionInfo: DecryptionInfo? = if (variantPlaylist.decryptionInfo != null) {
|
||||
val keyResponse = client.get(variantPlaylist.decryptionInfo.keyUrl)
|
||||
check(keyResponse.isOk) { "HLS request failed for decryption key: ${keyResponse.code}" }
|
||||
DecryptionInfo(keyResponse.body!!.bytes(), variantPlaylist.decryptionInfo.iv?.hexStringToByteArray())
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
variantPlaylist.segments.forEachIndexed { index, segment ->
|
||||
if (segment !is HLS.MediaSegment) {
|
||||
return@forEachIndexed
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Download '$name' segment $index Sequential");
|
||||
val segmentFile = File(context.cacheDir, "segment-${UUID.randomUUID()}")
|
||||
val outputStream = segmentFile.outputStream()
|
||||
try {
|
||||
segmentFiles.add(segmentFile)
|
||||
|
||||
val segmentLength = downloadSource_Sequential(client, outputStream, segment.uri, if (index == 0) null else decryptionInfo, index) { segmentLength, totalRead, lastSpeed ->
|
||||
val averageSegmentLength = if (index == 0) segmentLength else downloadedTotalLength / index
|
||||
val expectedTotalLength = averageSegmentLength * (variantPlaylist.segments.size - 1) + segmentLength
|
||||
onProgress(expectedTotalLength, downloadedTotalLength + totalRead, lastSpeed)
|
||||
}
|
||||
|
||||
downloadedTotalLength += segmentLength
|
||||
} finally {
|
||||
outputStream.close()
|
||||
}
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Combining segments into $targetFile");
|
||||
combineSegments(context, segmentFiles, targetFile)
|
||||
|
||||
Logger.i(TAG, "${name} downloadSource Finished");
|
||||
}
|
||||
catch(ioex: IOException) {
|
||||
if(targetFile.exists())
|
||||
targetFile.delete();
|
||||
if(ioex.message?.contains("ENOSPC") ?: false)
|
||||
throw Exception("Not enough space on device", ioex);
|
||||
else
|
||||
throw ioex;
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
if(targetFile.exists())
|
||||
targetFile.delete();
|
||||
throw ex;
|
||||
}
|
||||
finally {
|
||||
for (segmentFile in segmentFiles) {
|
||||
segmentFile.delete()
|
||||
}
|
||||
}
|
||||
return downloadedTotalLength;
|
||||
}
|
||||
|
||||
private suspend fun combineSegments(context: Context, segmentFiles: List<File>, targetFile: File) = withContext(Dispatchers.IO) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
val cmd =
|
||||
"-i \"concat:${segmentFiles.joinToString("|")}\" -c copy \"${targetFile.absolutePath}\""
|
||||
val fileList = File(context.cacheDir, "fileList-${UUID.randomUUID()}.txt")
|
||||
fileList.writeText(segmentFiles.joinToString("\n") { "file '${it.absolutePath}'" })
|
||||
|
||||
val cmd = "-f concat -safe 0 -i \"${fileList.absolutePath}\" -c copy \"${targetFile.absolutePath}\""
|
||||
val statisticsCallback = StatisticsCallback { _ ->
|
||||
//TODO: Show progress?
|
||||
}
|
||||
@@ -665,6 +602,7 @@ class VideoDownload {
|
||||
val session = FFmpegKit.executeAsync(cmd,
|
||||
{ session ->
|
||||
if (ReturnCode.isSuccess(session.returnCode)) {
|
||||
fileList.delete()
|
||||
continuation.resumeWith(Result.success(Unit))
|
||||
} else {
|
||||
val errorMessage = if (ReturnCode.isCancel(session.returnCode)) {
|
||||
@@ -672,6 +610,7 @@ class VideoDownload {
|
||||
} else {
|
||||
"Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}"
|
||||
}
|
||||
fileList.delete()
|
||||
continuation.resumeWithException(RuntimeException(errorMessage))
|
||||
}
|
||||
},
|
||||
@@ -686,6 +625,237 @@ class VideoDownload {
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, source: JSSource?, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||
if (targetFile.exists())
|
||||
targetFile.delete()
|
||||
|
||||
var downloadedTotalLength = 0L
|
||||
val modifier = if (source is JSSource && source.hasRequestModifier)
|
||||
source.getRequestModifier()
|
||||
else
|
||||
null
|
||||
|
||||
fun downloadBytes(url: String, rangeStart: Long? = null, rangeLength: Long? = null): ByteArray {
|
||||
val headers = mutableMapOf<String, String>()
|
||||
|
||||
if (rangeStart != null) {
|
||||
if (rangeLength != null && rangeLength > 0) {
|
||||
val end = rangeStart + rangeLength - 1
|
||||
headers["Range"] = "bytes=$rangeStart-$end"
|
||||
} else {
|
||||
headers["Range"] = "bytes=$rangeStart-"
|
||||
}
|
||||
}
|
||||
|
||||
val modified = modifier?.modifyRequest(url, headers)
|
||||
val finalUrl = modified?.url ?: url
|
||||
val finalHeaders = modified?.headers?.toMutableMap() ?: headers
|
||||
|
||||
val resp = client.get(finalUrl, finalHeaders)
|
||||
if (!resp.isOk) {
|
||||
resp.body?.close()
|
||||
throw IllegalStateException("Failed to download HLS resource ($finalUrl): HTTP ${resp.code}")
|
||||
}
|
||||
|
||||
val body = resp.body ?: throw IllegalStateException("Failed to download HLS resource ($finalUrl): Empty body")
|
||||
val bytes = body.bytes()
|
||||
body.close()
|
||||
return bytes
|
||||
}
|
||||
|
||||
fun buildSequenceIv(sequenceNumber: Long): ByteArray {
|
||||
return ByteBuffer.allocate(16)
|
||||
.putLong(0L)
|
||||
.putLong(sequenceNumber)
|
||||
.array()
|
||||
}
|
||||
|
||||
val segmentFiles = arrayListOf<File>()
|
||||
try {
|
||||
val playlistHeaders = mutableMapOf<String, String>()
|
||||
val modifiedPlaylistReq = modifier?.modifyRequest(hlsUrl, playlistHeaders)
|
||||
val playlistResp = client.get(
|
||||
modifiedPlaylistReq?.url ?: hlsUrl,
|
||||
modifiedPlaylistReq?.headers?.toMutableMap() ?: playlistHeaders
|
||||
)
|
||||
|
||||
check(playlistResp.isOk) { "Failed to get variant playlist: ${playlistResp.code}" }
|
||||
|
||||
val vpContent = playlistResp.body?.string()
|
||||
?: throw IllegalStateException("Variant playlist content is empty")
|
||||
|
||||
val variantPlaylist = HLS.parseVariantPlaylist(vpContent, hlsUrl)
|
||||
val hlsDec = variantPlaylist.decryptionInfo
|
||||
val useDecryption = hlsDec != null && !hlsDec.method.equals("NONE", ignoreCase = true)
|
||||
var keyBytes: ByteArray? = null
|
||||
var staticIvBytes: ByteArray? = null
|
||||
|
||||
if (useDecryption) {
|
||||
if (!hlsDec.method.equals("AES-128", ignoreCase = true)) {
|
||||
throw UnsupportedOperationException("HLS decryption method '${hlsDec.method}' is not supported.")
|
||||
}
|
||||
|
||||
val keyUrl = hlsDec.keyUrl ?: throw IllegalStateException("Encrypted HLS playlist without key URI is not supported.")
|
||||
|
||||
keyBytes = downloadBytes(keyUrl)
|
||||
if (!hlsDec.iv.isNullOrEmpty()) {
|
||||
staticIvBytes = hlsDec.iv.hexStringToByteArray()
|
||||
}
|
||||
}
|
||||
|
||||
val mediaSequence = variantPlaylist.mediaSequence ?: 0L
|
||||
val rangeOffsets = mutableMapOf<String, Long>()
|
||||
|
||||
if (!variantPlaylist.mapUrl.isNullOrEmpty()) {
|
||||
if (isCancelled) throw CancellationException("Cancelled")
|
||||
|
||||
Logger.i(TAG, "Downloading HLS initialization map")
|
||||
|
||||
var mapRangeStart: Long? = null
|
||||
var mapRangeLength: Long? = null
|
||||
|
||||
if (variantPlaylist.mapBytesLength > 0) {
|
||||
mapRangeLength = variantPlaylist.mapBytesLength
|
||||
|
||||
val mapUrl = variantPlaylist.mapUrl
|
||||
if (variantPlaylist.mapBytesStart >= 0) {
|
||||
mapRangeStart = variantPlaylist.mapBytesStart
|
||||
rangeOffsets[mapUrl] =
|
||||
variantPlaylist.mapBytesStart + variantPlaylist.mapBytesLength
|
||||
} else {
|
||||
val offset = rangeOffsets[mapUrl] ?: 0L
|
||||
mapRangeStart = offset
|
||||
rangeOffsets[mapUrl] = offset + variantPlaylist.mapBytesLength
|
||||
}
|
||||
}
|
||||
|
||||
var mapBytes = downloadBytes(variantPlaylist.mapUrl!!, mapRangeStart, mapRangeLength)
|
||||
|
||||
if (useDecryption) {
|
||||
val kb = keyBytes ?: throw IllegalStateException("Decryption key bytes are missing.")
|
||||
val iv = staticIvBytes
|
||||
?: throw UnsupportedOperationException("Encrypted EXT-X-MAP without explicit IV is not supported.")
|
||||
mapBytes = decryptSegment(mapBytes, kb, iv)
|
||||
}
|
||||
|
||||
if (mapBytes.size.toLong() > Int.MAX_VALUE) {
|
||||
throw IllegalStateException("HLS MAP segment too large to handle.")
|
||||
}
|
||||
|
||||
val segmentFile = File(context.cacheDir, "segment-${UUID.randomUUID()}")
|
||||
val outStr = segmentFile.outputStream()
|
||||
try {
|
||||
segmentFiles.add(segmentFile)
|
||||
outStr.write(mapBytes)
|
||||
outStr.flush()
|
||||
} finally {
|
||||
outStr.close()
|
||||
}
|
||||
downloadedTotalLength += mapBytes.size
|
||||
}
|
||||
|
||||
val totalSegments = variantPlaylist.segments.size
|
||||
var mediaSegmentIndex = 0
|
||||
|
||||
var bytesSinceLastSpeedUpdate = 0L
|
||||
var lastSpeedUpdateTime = System.currentTimeMillis()
|
||||
var lastSpeed = 0L
|
||||
|
||||
variantPlaylist.segments.forEachIndexed { index, segment ->
|
||||
if (segment !is HLS.MediaSegment) return@forEachIndexed
|
||||
if (isCancelled) throw CancellationException("Cancelled")
|
||||
|
||||
Logger.i(TAG, "Download '$name' segment $index sequential")
|
||||
|
||||
var rangeStart: Long? = null
|
||||
var rangeLength: Long? = null
|
||||
|
||||
if (segment.bytesLength > 0) {
|
||||
rangeLength = segment.bytesLength
|
||||
|
||||
val urlKey = segment.uri
|
||||
if (segment.bytesStart >= 0) {
|
||||
rangeStart = segment.bytesStart
|
||||
rangeOffsets[urlKey] = segment.bytesStart + segment.bytesLength
|
||||
} else {
|
||||
val offset = rangeOffsets[urlKey] ?: 0L
|
||||
rangeStart = offset
|
||||
rangeOffsets[urlKey] = offset + segment.bytesLength
|
||||
}
|
||||
}
|
||||
|
||||
var segmentBytes = downloadBytes(segment.uri, rangeStart, rangeLength)
|
||||
|
||||
if (useDecryption) {
|
||||
val kb = keyBytes ?: throw IllegalStateException("Decryption key bytes are missing.")
|
||||
val ivBytes = if (staticIvBytes != null) {
|
||||
staticIvBytes
|
||||
} else {
|
||||
val sequenceNumber = mediaSequence + mediaSegmentIndex
|
||||
buildSequenceIv(sequenceNumber)
|
||||
}
|
||||
|
||||
segmentBytes = decryptSegment(segmentBytes, kb, ivBytes)
|
||||
}
|
||||
|
||||
val segmentLength = segmentBytes.size.toLong()
|
||||
if (segmentLength > Int.MAX_VALUE) {
|
||||
throw IllegalStateException("HLS media segment too large to handle.")
|
||||
}
|
||||
|
||||
val avgLen = if (index == 0) {
|
||||
segmentLength
|
||||
} else {
|
||||
if (index > 0) downloadedTotalLength / index else segmentLength
|
||||
}
|
||||
val expectedTotal = avgLen * (totalSegments - 1) + segmentLength
|
||||
|
||||
val segmentFile = File(context.cacheDir, "segment-${UUID.randomUUID()}")
|
||||
val outStr = segmentFile.outputStream()
|
||||
try {
|
||||
segmentFiles.add(segmentFile)
|
||||
outStr.write(segmentBytes)
|
||||
} finally {
|
||||
outStr.close()
|
||||
}
|
||||
downloadedTotalLength += segmentLength
|
||||
|
||||
bytesSinceLastSpeedUpdate += segmentLength
|
||||
val now = System.currentTimeMillis()
|
||||
val elapsed = now - lastSpeedUpdateTime
|
||||
if (elapsed >= 500 && bytesSinceLastSpeedUpdate > 0) {
|
||||
lastSpeed = (bytesSinceLastSpeedUpdate * 1000L / elapsed)
|
||||
bytesSinceLastSpeedUpdate = 0
|
||||
lastSpeedUpdateTime = now
|
||||
}
|
||||
|
||||
onProgress(expectedTotal, downloadedTotalLength, lastSpeed)
|
||||
mediaSegmentIndex++
|
||||
}
|
||||
|
||||
combineSegments(context, segmentFiles, targetFile)
|
||||
Logger.i(TAG, "Finished HLS Source for $name")
|
||||
} catch (ioex: IOException) {
|
||||
if (targetFile.exists())
|
||||
targetFile.delete()
|
||||
if (ioex.message?.contains("ENOSPC") == true)
|
||||
throw Exception("Not enough space on device", ioex)
|
||||
else
|
||||
throw ioex
|
||||
} catch (ex: Throwable) {
|
||||
if (targetFile.exists())
|
||||
targetFile.delete()
|
||||
throw ex
|
||||
}
|
||||
finally {
|
||||
for (segmentFile in segmentFiles) {
|
||||
segmentFile.delete()
|
||||
}
|
||||
}
|
||||
|
||||
return downloadedTotalLength
|
||||
}
|
||||
|
||||
private fun downloadDashFileSource(name: String, client: ManagedHttpClient, source: IJSDashManifestRawSource, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||
if(targetFile.exists())
|
||||
targetFile.delete();
|
||||
@@ -715,6 +885,11 @@ class VideoDownload {
|
||||
source.getRequestExecutor();
|
||||
else
|
||||
null;
|
||||
|
||||
val modifier = if (source is JSSource && source.hasRequestModifier)
|
||||
source.getRequestModifier();
|
||||
else
|
||||
null;
|
||||
val speedTracker = SpeedTracker(1000);
|
||||
|
||||
Logger.i(TAG, "Download $name Dash, CueCount: " + foundCues.count().toString());
|
||||
@@ -726,12 +901,14 @@ class VideoDownload {
|
||||
val t = cue.groupValues[1];
|
||||
val d = cue.groupValues[2];
|
||||
|
||||
|
||||
val url = foundTemplateUrl.replace("\$Number\$", (indexCounter).toString());
|
||||
val modified = modifier?.modifyRequest(url, mapOf());
|
||||
|
||||
val data = if(executor != null)
|
||||
executor.executeRequest("GET", url, null, mapOf());
|
||||
executor.executeRequest("GET", modified?.url ?: url, null, modified?.headers ?: mapOf());
|
||||
else {
|
||||
val resp = client.get(url, mutableMapOf());
|
||||
val resp = client.get(modified?.url ?: url, modified?.headers?.toMutableMap() ?: mutableMapOf());
|
||||
if(!resp.isOk)
|
||||
throw IllegalStateException("Dash request failed for index " + indexCounter.toString() + ", with code: " + resp.code.toString());
|
||||
resp.body!!.bytes()
|
||||
@@ -766,7 +943,7 @@ class VideoDownload {
|
||||
}
|
||||
return sourceLength!!;
|
||||
}
|
||||
private fun downloadFileSource(name: String, client: ManagedHttpClient, videoUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||
private fun downloadFileSource(name: String, client: ManagedHttpClient, source: JSSource?, videoUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||
if(targetFile.exists())
|
||||
targetFile.delete();
|
||||
|
||||
@@ -775,7 +952,12 @@ class VideoDownload {
|
||||
val sourceLength: Long?;
|
||||
val fileStream = FileOutputStream(targetFile);
|
||||
|
||||
try{
|
||||
val modifier = if (source is JSSource && source.hasRequestModifier)
|
||||
source.getRequestModifier();
|
||||
else
|
||||
null;
|
||||
|
||||
try {
|
||||
val head = client.tryHead(videoUrl);
|
||||
val relatedPlugin = (video?.url ?: videoDetails?.url)?.let { StatePlatform.instance.getContentClient(it) }?.let { if(it is JSClient) it else null };
|
||||
if(Settings.instance.downloads.byteRangeDownload && head?.containsKey("accept-ranges") == true && head.containsKey("content-length"))
|
||||
@@ -786,12 +968,12 @@ class VideoDownload {
|
||||
Logger.i(TAG, "Download $name ByteRange Parallel (${concurrency}): " + videoUrl);
|
||||
sourceLength = head["content-length"]!!.toLong();
|
||||
onProgress(sourceLength, 0, 0);
|
||||
downloadSource_Ranges(name, client, fileStream, videoUrl, sourceLength, 1024*512, concurrency, onProgress);
|
||||
downloadSource_Ranges(name, client, modifier, fileStream, videoUrl, sourceLength, 1024*512, concurrency, onProgress);
|
||||
}
|
||||
else {
|
||||
Logger.i(TAG, "Download $name Sequential");
|
||||
try {
|
||||
sourceLength = downloadSource_Sequential(client, fileStream, videoUrl, null, 0, onProgress);
|
||||
sourceLength = downloadSource_Sequential(client, modifier, fileStream, videoUrl, null, 0, onProgress);
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to download sequentially (url = $videoUrl)")
|
||||
throw e
|
||||
@@ -842,7 +1024,7 @@ class VideoDownload {
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadSource_Sequential(client: ManagedHttpClient, fileStream: FileOutputStream, url: String, decryptionInfo: DecryptionInfo?, index: Int, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||
private fun downloadSource_Sequential(client: ManagedHttpClient, modifier: IRequestModifier? = null, fileStream: FileOutputStream, url: String, decryptionInfo: DecryptionInfo?, index: Int, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||
val progressRate: Int = 4096 * 5;
|
||||
var lastProgressCount: Int = 0;
|
||||
val speedRate: Int = 4096 * 5;
|
||||
@@ -851,7 +1033,12 @@ class VideoDownload {
|
||||
|
||||
var lastSpeed: Long = 0;
|
||||
|
||||
val result = client.get(url);
|
||||
val result = if (modifier != null) {
|
||||
val modified = modifier.modifyRequest(url, mapOf())
|
||||
client.get(modified.url!!, modified.headers.toMutableMap())
|
||||
} else {
|
||||
client.get(url)
|
||||
}
|
||||
if (!result.isOk) {
|
||||
result.body?.close()
|
||||
throw IllegalStateException("Failed to download source. Web[${result.code}] Error");
|
||||
@@ -988,7 +1175,7 @@ class VideoDownload {
|
||||
onProgress(sourceLength, totalRead, 0)
|
||||
return sourceLength
|
||||
}*/
|
||||
private fun downloadSource_Ranges(name: String, client: ManagedHttpClient, fileStream: FileOutputStream, url: String, sourceLength: Long, rangeSize: Int, concurrency: Int = 1, onProgress: (Long, Long, Long) -> Unit) {
|
||||
private fun downloadSource_Ranges(name: String, client: ManagedHttpClient, modifier: IRequestModifier?, fileStream: FileOutputStream, url: String, sourceLength: Long, rangeSize: Int, concurrency: Int = 1, onProgress: (Long, Long, Long) -> Unit) {
|
||||
val progressRate: Int = 4096 * 5;
|
||||
var lastProgressCount: Int = 0;
|
||||
val speedRate: Int = 4096 * 5;
|
||||
@@ -1007,7 +1194,7 @@ class VideoDownload {
|
||||
|
||||
Logger.i(TAG, "Download ${name} Batch #${reqCount} [${concurrency}] (${lastSpeed.toHumanBytesSpeed()})");
|
||||
|
||||
val byteRangeResults = requestByteRangeParallel(client, pool, url, sourceLength, concurrency, totalRead,
|
||||
val byteRangeResults = requestByteRangeParallel(client, pool, modifier, url, sourceLength, concurrency, totalRead,
|
||||
rangeSize, 1024 * 64);
|
||||
|
||||
for(byteRange in byteRangeResults) {
|
||||
@@ -1038,7 +1225,7 @@ class VideoDownload {
|
||||
onProgress(sourceLength, totalRead, 0);
|
||||
}
|
||||
|
||||
private fun requestByteRangeParallel(client: ManagedHttpClient, pool: ForkJoinPool, url: String, totalLength: Long, concurrency: Int, rangePosition: Long, rangeSize: Int, rangeVariance: Int = -1): List<Triple<ByteArray, Long, Long>> {
|
||||
private fun requestByteRangeParallel(client: ManagedHttpClient, pool: ForkJoinPool, modifier: IRequestModifier?, url: String, totalLength: Long, concurrency: Int, rangePosition: Long, rangeSize: Int, rangeVariance: Int = -1): List<Triple<ByteArray, Long, Long>> {
|
||||
val tasks = mutableListOf<ForkJoinTask<Triple<ByteArray, Long, Long>>>();
|
||||
var readPosition = rangePosition;
|
||||
for(i in 0 until concurrency) {
|
||||
@@ -1052,21 +1239,25 @@ class VideoDownload {
|
||||
else readPosition + toRead;
|
||||
|
||||
tasks.add(pool.submit<Triple<ByteArray, Long, Long>> {
|
||||
return@submit requestByteRange(client, url, rangeStart, rangeEnd);
|
||||
return@submit requestByteRange(client, modifier, url, rangeStart, rangeEnd);
|
||||
});
|
||||
readPosition = rangeEnd + 1;
|
||||
}
|
||||
|
||||
return tasks.map { it.get() };
|
||||
}
|
||||
private fun requestByteRange(client: ManagedHttpClient, url: String, rangeStart: Long, rangeEnd: Long): Triple<ByteArray, Long, Long> {
|
||||
private fun requestByteRange(client: ManagedHttpClient, modifier: IRequestModifier?, url: String, rangeStart: Long, rangeEnd: Long): Triple<ByteArray, Long, Long> {
|
||||
var retryCount = 0
|
||||
var lastException: Throwable? = null
|
||||
var lastException: Throwable? = null;
|
||||
|
||||
val headers = mutableMapOf(Pair("Range", "bytes=${rangeStart}-${rangeEnd}"));
|
||||
val modified = modifier?.modifyRequest(url, headers);
|
||||
|
||||
while (retryCount <= 3) {
|
||||
try {
|
||||
val toRead = rangeEnd - rangeStart;
|
||||
val req = client.get(url, mutableMapOf(Pair("Range", "bytes=${rangeStart}-${rangeEnd}")));
|
||||
|
||||
val req = client.get(modified?.url ?: url, modified?.headers?.toMutableMap() ?: headers);
|
||||
if (!req.isOk) {
|
||||
val bodyString = req.body?.string()
|
||||
req.body?.close()
|
||||
|
||||
@@ -4,6 +4,8 @@ import android.content.Context
|
||||
import com.caoccao.javet.exceptions.JavetCompilationException
|
||||
import com.caoccao.javet.exceptions.JavetException
|
||||
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.V8Runtime
|
||||
import com.caoccao.javet.values.V8Value
|
||||
@@ -34,6 +36,7 @@ import com.futo.platformplayer.engine.internal.V8Converter
|
||||
import com.futo.platformplayer.engine.packages.PackageBridge
|
||||
import com.futo.platformplayer.engine.packages.PackageDOMParser
|
||||
import com.futo.platformplayer.engine.packages.PackageHttp
|
||||
import com.futo.platformplayer.engine.packages.PackageHttpImp
|
||||
import com.futo.platformplayer.engine.packages.PackageJSDOM
|
||||
import com.futo.platformplayer.engine.packages.PackageUtilities
|
||||
import com.futo.platformplayer.engine.packages.V8Package
|
||||
@@ -381,6 +384,7 @@ class V8Plugin {
|
||||
return when(packageName) {
|
||||
"DOMParser" -> PackageDOMParser(this)
|
||||
"Http" -> PackageHttp(this, config)
|
||||
"HttpImp" -> PackageHttpImp(this, config)
|
||||
"Utilities" -> PackageUtilities(this, config)
|
||||
"JSDOM" -> PackageJSDOM(this, config)
|
||||
else -> if(allowNull) null else throw ScriptCompilationException(config, "Unknown package [${packageName}] required for plugin ${config.name}");
|
||||
@@ -409,6 +413,12 @@ class V8Plugin {
|
||||
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 {
|
||||
var codeStripped = code;
|
||||
if(codeStripped != null) { //TODO: Improve code stripped
|
||||
@@ -442,37 +452,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);
|
||||
}
|
||||
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) {
|
||||
val obj = executeEx.scriptingError?.context as IJavetEntityError
|
||||
if(obj.context.containsKey("plugin_type") == true) {
|
||||
@@ -506,7 +485,6 @@ class V8Plugin {
|
||||
}
|
||||
|
||||
}
|
||||
*/
|
||||
throw ScriptExecutionException(config, extractJSExceptionMessage(executeEx) ?: "", null, executeEx.scriptingError?.stack, codeStripped);
|
||||
}
|
||||
catch(ex: Exception) {
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
package com.curlbind
|
||||
|
||||
import androidx.annotation.Keep
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.nio.charset.Charset
|
||||
import kotlin.collections.iterator
|
||||
import kotlin.math.min
|
||||
|
||||
@Keep
|
||||
object Libcurl {
|
||||
init {
|
||||
System.loadLibrary("curl-impersonate")
|
||||
System.loadLibrary("curl-impersonate-jni")
|
||||
// CURL_GLOBAL_ALL = 3
|
||||
require(ce_global_init(3) == CURLcode.CURLE_OK) { "curl_global_init failed" }
|
||||
}
|
||||
|
||||
@Keep
|
||||
data class Request(
|
||||
var url: String,
|
||||
var method: String = "GET",
|
||||
var headers: Map<String, String> = emptyMap(),
|
||||
var body: ByteArray? = null,
|
||||
var impersonateTarget: String = "chrome136",
|
||||
var useBuiltInHeaders: Boolean = true,
|
||||
var timeoutMs: Int = 30_000
|
||||
)
|
||||
|
||||
@Keep
|
||||
data class Response(
|
||||
val status: Int,
|
||||
val effectiveUrl: String,
|
||||
val bodyBytes: ByteArray,
|
||||
val headers: Map<String, List<String>>
|
||||
)
|
||||
|
||||
object CURLcode {
|
||||
const val CURLE_OK = 0
|
||||
const val CURLE_UNKNOWN_OPTION = 48
|
||||
}
|
||||
|
||||
object CurlInfoConsts {
|
||||
const val CURLINFO_STRING = 0x100000
|
||||
const val CURLINFO_LONG = 0x200000
|
||||
const val CURLINFO_DOUBLE = 0x300000
|
||||
const val CURLINFO_SLIST = 0x400000
|
||||
const val CURLINFO_PTR = 0x400000
|
||||
const val CURLINFO_SOCKET = 0x500000
|
||||
const val CURLINFO_OFF_T = 0x600000
|
||||
const val CURLINFO_MASK = 0x0fffff
|
||||
const val CURLINFO_TYPEMASK = 0xf00000
|
||||
}
|
||||
|
||||
object CURLINFO {
|
||||
const val NONE = 0
|
||||
const val EFFECTIVE_URL = CurlInfoConsts.CURLINFO_STRING + 1
|
||||
const val RESPONSE_CODE = CurlInfoConsts.CURLINFO_LONG + 2
|
||||
}
|
||||
|
||||
object CURLOPT {
|
||||
const val URL = 10002
|
||||
const val FOLLOWLOCATION = 52
|
||||
const val MAXREDIRS = 68
|
||||
const val CONNECTTIMEOUT_MS = 156
|
||||
const val TIMEOUT_MS = 155
|
||||
const val HTTP_VERSION = 84
|
||||
const val ACCEPT_ENCODING = 10102
|
||||
const val HTTPHEADER = 10023
|
||||
const val COOKIEFILE = 10031
|
||||
const val COOKIEJAR = 10082
|
||||
const val CUSTOMREQUEST = 10036
|
||||
const val IPRESOLVE = 113
|
||||
const val POSTFIELDS = 10015
|
||||
const val POSTFIELDSIZE = 60
|
||||
const val WRITEFUNCTION = 20011
|
||||
const val HEADERFUNCTION = 20079
|
||||
const val WRITEDATA = 10001
|
||||
const val HEADERDATA = 10029
|
||||
const val COPYPOSTFIELDS = 10165
|
||||
const val CURLOPT_DNS_SERVERS = 10211
|
||||
const val CAPATH = 10097
|
||||
const val CAINFO = 10065
|
||||
}
|
||||
|
||||
object CURL_HTTP_VERSION { const val TWO_TLS = 4 }
|
||||
object CURL_IPRESOLVE { const val WHATEVER = 0; const val V4 = 1; const val V6 = 2 }
|
||||
|
||||
@Keep interface WriteCallback { fun onWrite(chunk: ByteArray): Int }
|
||||
@Keep interface HeaderCallback { fun onHeader(line: ByteArray): Int }
|
||||
|
||||
@Volatile private var defaultCAPath: String? = null
|
||||
@Keep fun setDefaultCAPath(path: String) { defaultCAPath = path }
|
||||
|
||||
fun perform(req: Request): Response {
|
||||
val easy = ce_easy_init()
|
||||
require(easy != 0L) { "curl_easy_init failed" }
|
||||
|
||||
var slist: Long = 0L
|
||||
val bodySink = ByteArrayOutputStream(64 * 1024)
|
||||
val rawHeaderLines = ArrayList<String>(64)
|
||||
|
||||
try {
|
||||
val imp = ce_easy_impersonate(easy, req.impersonateTarget, req.useBuiltInHeaders)
|
||||
if (imp != CURLcode.CURLE_OK && imp != CURLcode.CURLE_UNKNOWN_OPTION) {
|
||||
error("curl_easy_impersonate failed: ${ce_easy_strerror(imp)}")
|
||||
}
|
||||
|
||||
checkOK(ce_setopt_str(easy, CURLOPT.URL, req.url))
|
||||
checkOK(ce_setopt_long(easy, CURLOPT.FOLLOWLOCATION, 1))
|
||||
checkOK(ce_setopt_long(easy, CURLOPT.MAXREDIRS, 10))
|
||||
checkOK(ce_setopt_long(easy, CURLOPT.CONNECTTIMEOUT_MS, req.timeoutMs.toLong()))
|
||||
checkOK(ce_setopt_long(easy, CURLOPT.TIMEOUT_MS, req.timeoutMs.toLong()))
|
||||
checkOK(ce_setopt_long(easy, CURLOPT.HTTP_VERSION, CURL_HTTP_VERSION.TWO_TLS.toLong()))
|
||||
checkOK(ce_setopt_str(easy, CURLOPT.ACCEPT_ENCODING, "")) // enable auto-decompress
|
||||
|
||||
if (req.headers.isNotEmpty()) {
|
||||
for ((k, v) in req.headers) slist = ce_slist_append(slist, "$k: $v")
|
||||
if (slist != 0L) checkOK(ce_setopt_ptr(easy, CURLOPT.HTTPHEADER, slist))
|
||||
}
|
||||
|
||||
val method = req.method
|
||||
if (!method.equals("GET", ignoreCase = true)) {
|
||||
checkOK(ce_setopt_str(easy, CURLOPT.CUSTOMREQUEST, method))
|
||||
val body = req.body
|
||||
if (body != null && body.isNotEmpty()) {
|
||||
checkOK(ce_set_postfields(easy, body))
|
||||
}
|
||||
}
|
||||
|
||||
checkOK(ce_set_write_callback(easy, object : WriteCallback {
|
||||
override fun onWrite(chunk: ByteArray): Int {
|
||||
bodySink.write(chunk)
|
||||
return chunk.size
|
||||
}
|
||||
}))
|
||||
checkOK(ce_set_header_callback(easy, object : HeaderCallback {
|
||||
override fun onHeader(line: ByteArray): Int {
|
||||
// Keep raw but trim CRLF for convenience
|
||||
val s = line.toString(Charset.forName("ISO-8859-1")).trimEnd('\r', '\n')
|
||||
if (s.isNotBlank()) rawHeaderLines.add(s)
|
||||
return line.size
|
||||
}
|
||||
}))
|
||||
|
||||
checkOK(ce_setopt_str(easy, CURLOPT.CURLOPT_DNS_SERVERS, "1.1.1.1,8.8.8.8"));
|
||||
defaultCAPath?.let { checkOK(ce_setopt_str(easy, CURLOPT.CAINFO, it)) }
|
||||
|
||||
val rc = ce_easy_perform(easy)
|
||||
if (rc != CURLcode.CURLE_OK) error("curl_easy_perform failed: ${ce_easy_strerror(rc)}")
|
||||
|
||||
val codeArr = longArrayOf(0)
|
||||
checkOK(ce_easy_getinfo_long(easy, CURLINFO.RESPONSE_CODE, codeArr))
|
||||
val effective = ce_easy_getinfo_string(easy, CURLINFO.EFFECTIVE_URL) ?: req.url
|
||||
|
||||
return Response(
|
||||
status = codeArr[0].toInt(),
|
||||
effectiveUrl = effective,
|
||||
bodyBytes = bodySink.toByteArray(),
|
||||
headers = parseHeaders(rawHeaderLines)
|
||||
)
|
||||
} finally {
|
||||
if (slist != 0L) ce_slist_free_all(slist)
|
||||
ce_easy_cleanup(easy)
|
||||
}
|
||||
}
|
||||
|
||||
private fun defaultCookieJarPath(): String {
|
||||
val tmp = System.getProperty("java.io.tmpdir") ?: "/data/local/tmp"
|
||||
return if (tmp.endsWith("/")) "${tmp}imphttp.cookies.txt" else "$tmp/imphttp.cookies.txt"
|
||||
}
|
||||
|
||||
private fun checkOK(code: Int) {
|
||||
if (code != CURLcode.CURLE_OK) throw IllegalStateException("libcurl error: ${ce_easy_strerror(code)}")
|
||||
}
|
||||
|
||||
private fun parseHeaders(lines: List<String>): Map<String, List<String>> {
|
||||
val map = linkedMapOf<String, MutableList<String>>()
|
||||
for (line in lines) {
|
||||
val idx = line.indexOf(':')
|
||||
if (idx <= 0) continue
|
||||
val name = line.substring(0, idx).trim()
|
||||
val value = line.substring(min(idx + 1, line.length)).trim()
|
||||
map.getOrPut(name) { mutableListOf() }.add(value)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
@JvmStatic external fun ce_set_write_callback(easy: Long, cb: WriteCallback?): Int
|
||||
@JvmStatic external fun ce_set_header_callback(easy: Long, cb: HeaderCallback?): Int
|
||||
|
||||
@JvmStatic external fun ce_global_init(flags: Long): Int
|
||||
@JvmStatic external fun ce_global_cleanup()
|
||||
@JvmStatic external fun ce_easy_init(): Long
|
||||
@JvmStatic external fun ce_easy_cleanup(easy: Long)
|
||||
@JvmStatic external fun ce_easy_perform(easy: Long): Int
|
||||
|
||||
@JvmStatic external fun ce_easy_impersonate(easy: Long, target: String, defaultHeaders: Boolean): Int
|
||||
@JvmStatic external fun ce_setopt_long(easy: Long, opt: Int, value: Long): Int
|
||||
@JvmStatic external fun ce_setopt_str(easy: Long, opt: Int, value: String): Int
|
||||
@JvmStatic external fun ce_setopt_ptr(easy: Long, opt: Int, ptr: Long): Int
|
||||
@JvmStatic external fun ce_slist_append(list: Long, header: String): Long
|
||||
@JvmStatic external fun ce_slist_free_all(list: Long)
|
||||
@JvmStatic external fun ce_easy_getinfo_long(easy: Long, info: Int, outVal: LongArray): Int
|
||||
@JvmStatic external fun ce_easy_getinfo_string(easy: Long, info: Int): String?
|
||||
|
||||
@JvmStatic external fun ce_set_postfields(easy: Long, body: ByteArray): Int
|
||||
@JvmStatic external fun ce_easy_strerror(code: Int): String
|
||||
}
|
||||
@@ -55,7 +55,7 @@ class PackageDOMParser : V8Package {
|
||||
}
|
||||
@V8Property
|
||||
fun lastChild(): DOMNode? {
|
||||
val result = _element.firstElementChild()?.let { DOMNode(_package, it) };
|
||||
val result = _element.lastElementChild()?.let { DOMNode(_package, it) };
|
||||
if(result != null)
|
||||
_children.add(result);
|
||||
return result;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -6,7 +6,7 @@ import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.MainFragment
|
||||
|
||||
open class MainActivityFragment : Fragment() {
|
||||
protected val currentMain : MainFragment
|
||||
protected val currentMain : MainFragment?
|
||||
get() {
|
||||
isValidMainActivity();
|
||||
return (activity as MainActivity).fragCurrent;
|
||||
|
||||
+227
-21
@@ -8,19 +8,25 @@ import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.*
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.animation.doOnEnd
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.activities.SettingsActivity
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.dp
|
||||
import com.futo.platformplayer.fragment.mainactivity.MainActivityFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.*
|
||||
@@ -28,6 +34,10 @@ import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StatePayment
|
||||
import com.futo.platformplayer.states.StateSubscriptions
|
||||
import com.futo.platformplayer.views.AnyAdapterView
|
||||
import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny
|
||||
import com.futo.platformplayer.views.adapters.AnyAdapter
|
||||
import com.futo.platformplayer.views.pills.RoundButton
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.floor
|
||||
@@ -70,9 +80,15 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
private val _inflater: LayoutInflater;
|
||||
private val _subscribedActivity: MainActivity?;
|
||||
|
||||
private val _containerMoreHeader: ConstraintLayout;
|
||||
private val _toggleAirplaneMode: LinearLayout;
|
||||
private val _togglePrivacy: LinearLayout;
|
||||
|
||||
private var _overlayMore: FrameLayout;
|
||||
private var _overlayMoreBackground: FrameLayout;
|
||||
private var _layoutMoreButtons: LinearLayout;
|
||||
private var _layoutMoreButtons: RecyclerView;
|
||||
private val _layoutMoreButtonItems = arrayListOf<MenuButtonItem>();
|
||||
private var _layoutMoreButtonsAdapter: AnyAdapterView<MenuButtonItem, MenuButtonItemViewHolder>;
|
||||
private var _layoutBottomBarButtons: LinearLayout;
|
||||
|
||||
private var _moreVisible = false;
|
||||
@@ -86,15 +102,79 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
|
||||
private var currentButtonDefinitions: List<ButtonDefinition>? = null;
|
||||
|
||||
private var moreColumns = 3;
|
||||
|
||||
constructor(fragment: MenuBottomBarFragment, inflater: LayoutInflater) : super(inflater.context) {
|
||||
_fragment = fragment;
|
||||
_inflater = inflater;
|
||||
inflater.inflate(R.layout.fragment_overview_bottom_bar, this);
|
||||
|
||||
_containerMoreHeader = findViewById(R.id.container_more_options);
|
||||
_toggleAirplaneMode = findViewById(R.id.container_toggle_airplane);
|
||||
_togglePrivacy = findViewById(R.id.container_toggle_privacy);
|
||||
|
||||
_toggleAirplaneMode.isVisible = false //TODO: Remove when airplane mode implemented
|
||||
|
||||
StateApp.instance.airplaneModeChanged.subscribe {
|
||||
if(!StateApp.instance.airplaneMode)
|
||||
_toggleAirplaneMode.setBackgroundResource(R.drawable.background_menu_toggle)
|
||||
else
|
||||
_toggleAirplaneMode.setBackgroundResource(R.drawable.background_menu_toggle_active)
|
||||
}
|
||||
if(!StateApp.instance.airplaneMode)
|
||||
_toggleAirplaneMode.setBackgroundResource(R.drawable.background_menu_toggle)
|
||||
else
|
||||
_toggleAirplaneMode.setBackgroundResource(R.drawable.background_menu_toggle_active)
|
||||
_toggleAirplaneMode.setOnClickListener {
|
||||
if(StateApp.instance.airplaneMode) {
|
||||
StateApp.instance.setAirMode(false);
|
||||
UIDialogs.appToast("Airplane mode disabled");
|
||||
}
|
||||
else {
|
||||
StateApp.instance.setAirMode(true);
|
||||
UIDialogs.appToast("Airplane mode enabled");
|
||||
}
|
||||
}
|
||||
|
||||
StateApp.instance.privateModeChanged.subscribe {
|
||||
if(!StateApp.instance.privateMode)
|
||||
_togglePrivacy.setBackgroundResource(R.drawable.background_menu_toggle)
|
||||
else
|
||||
_togglePrivacy.setBackgroundResource(R.drawable.background_menu_toggle_active)
|
||||
}
|
||||
if(!StateApp.instance.privateMode)
|
||||
_togglePrivacy.setBackgroundResource(R.drawable.background_menu_toggle)
|
||||
else
|
||||
_togglePrivacy.setBackgroundResource(R.drawable.background_menu_toggle_active)
|
||||
_togglePrivacy.setOnClickListener {
|
||||
if(StateApp.instance.privateMode) {
|
||||
StateApp.instance.setPrivacyMode(false);
|
||||
UIDialogs.appToast("Privacy mode disabled");
|
||||
}
|
||||
else {
|
||||
StateApp.instance.setPrivacyMode(true);
|
||||
UIDialogs.appToast("Privacy mode enabled");
|
||||
}
|
||||
}
|
||||
|
||||
_overlayMore = findViewById(R.id.more_overlay);
|
||||
_overlayMoreBackground = findViewById(R.id.more_overlay_background);
|
||||
_layoutMoreButtons = findViewById(R.id.more_menu_buttons);
|
||||
_layoutBottomBarButtons = findViewById(R.id.bottom_bar_buttons)
|
||||
_layoutBottomBarButtons = findViewById(R.id.bottom_bar_buttons);
|
||||
|
||||
val totalWidthDp = resources.displayMetrics.widthPixels / resources.displayMetrics.density;
|
||||
val columns = MenuButtonItemViewHolder.getAutoSizeColumns(totalWidthDp);
|
||||
_layoutMoreButtonsAdapter = _layoutMoreButtons.asAny<MenuButtonItem, MenuButtonItemViewHolder>(_layoutMoreButtonItems,
|
||||
RecyclerView.VERTICAL, false, { button ->
|
||||
button.setAutoSize(totalWidthDp);
|
||||
button.parentFragment = this@MenuBottomBarView._fragment;
|
||||
button.onClick.subscribe {
|
||||
setMoreVisible(false);
|
||||
}
|
||||
})
|
||||
moreColumns = columns;
|
||||
val layoutManager = GridLayoutManager(context, columns, GridLayoutManager.VERTICAL, true);
|
||||
_layoutMoreButtons.layoutManager = layoutManager;
|
||||
|
||||
_overlayMoreBackground.setOnClickListener { setMoreVisible(false); };
|
||||
|
||||
@@ -121,6 +201,8 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
}
|
||||
|
||||
private fun setMoreVisible(visible: Boolean) {
|
||||
|
||||
//TODO: issues with these bools
|
||||
if (_moreVisibleAnimating) {
|
||||
return
|
||||
}
|
||||
@@ -129,9 +211,12 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
val height = _moreButtons.firstOrNull()?.let {
|
||||
it.height.toFloat() + (it.layoutParams as MarginLayoutParams).bottomMargin
|
||||
} ?: return
|
||||
*/
|
||||
|
||||
_moreVisibleAnimating = true
|
||||
val moreOverlayBackground = _overlayMoreBackground
|
||||
@@ -143,10 +228,17 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
moreOverlay.visibility = VISIBLE
|
||||
val animations = arrayListOf<Animator>()
|
||||
animations.add(ObjectAnimator.ofFloat(moreOverlayBackground, "alpha", 0.0f, 1.0f).setDuration(duration))
|
||||
animations.add(ObjectAnimator.ofFloat(_layoutMoreButtons, "alpha", 0.0f, 1.0f).setDuration(duration))
|
||||
animations.add(ObjectAnimator.ofFloat(_containerMoreHeader, "alpha", 0.0f, 1.0f).setDuration(duration))
|
||||
_bottomButtons.find { it.definition.id == 99 }?.let {
|
||||
animations.add(ObjectAnimator.ofFloat(it, "alpha", 0.5f, 1.0f)
|
||||
.setDuration(duration));
|
||||
}
|
||||
|
||||
animations.add(ObjectAnimator.ofFloat(_layoutMoreButtons, "translationY", resources.displayMetrics.heightPixels.toFloat(), 0.0f).setDuration(duration))
|
||||
for ((index, button) in _moreButtons.withIndex()) {
|
||||
val i = _moreButtons.size - index
|
||||
animations.add(ObjectAnimator.ofFloat(button, "translationY", height * staggerFactor * (i + 1), 0.0f).setDuration(duration))
|
||||
//animations.add(ObjectAnimator.ofFloat(button, "translationY", height * staggerFactor * (i + 1), 0.0f).setDuration(duration))
|
||||
}
|
||||
|
||||
val animatorSet = AnimatorSet()
|
||||
@@ -158,11 +250,24 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
animatorSet.start()
|
||||
} else {
|
||||
val animations = arrayListOf<Animator>()
|
||||
animations.add(ObjectAnimator.ofFloat(moreOverlayBackground, "alpha", 1.0f, 0.0f).setDuration(duration))
|
||||
animations
|
||||
.add(ObjectAnimator.ofFloat(moreOverlayBackground, "alpha", 1.0f, 0.0f)
|
||||
.setDuration(duration))
|
||||
animations
|
||||
.add(ObjectAnimator.ofFloat(_layoutMoreButtons, "alpha", 1.0f, 0.0f)
|
||||
.setDuration(duration))
|
||||
animations
|
||||
.add(ObjectAnimator.ofFloat(_containerMoreHeader, "alpha", 1.0f, 0.0f)
|
||||
.setDuration(duration))
|
||||
_bottomButtons.find { it.definition.id == 99 }?.let {
|
||||
animations.add(ObjectAnimator.ofFloat(it, "alpha", 1.0f, 0.5f)
|
||||
.setDuration(duration));
|
||||
}
|
||||
|
||||
animations.add(ObjectAnimator.ofFloat(_layoutMoreButtons, "translationY", 0.0f, resources.displayMetrics.heightPixels.toFloat()).setDuration(duration))
|
||||
for ((index, button) in _moreButtons.withIndex()) {
|
||||
val i = _moreButtons.size - index
|
||||
animations.add(ObjectAnimator.ofFloat(button, "translationY", 0.0f, height * staggerFactor * (i + 1)).setDuration(duration))
|
||||
//animations.add(ObjectAnimator.ofFloat(button, "translationY", 0.0f, height * staggerFactor * (i + 1)).setDuration(duration))
|
||||
}
|
||||
|
||||
val animatorSet = AnimatorSet()
|
||||
@@ -174,11 +279,12 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
animatorSet.playTogether(animations)
|
||||
animatorSet.start()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun updateBottomMenuButtons(buttons: MutableList<ButtonDefinition>, hasMore: Boolean) {
|
||||
if (hasMore) {
|
||||
buttons.add(ButtonDefinition(99, R.drawable.ic_more, R.drawable.ic_more, R.string.more, canToggle = false, { false }, { setMoreVisible(true) }))
|
||||
buttons.add(ButtonDefinition(99, R.drawable.ic_more, R.drawable.ic_more, R.string.more, canToggle = false, { false }, { setMoreVisible(!_moreVisible) }))
|
||||
}
|
||||
|
||||
_bottomButtons.clear();
|
||||
@@ -218,32 +324,42 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
_layoutMoreButtons.removeAllViews();
|
||||
|
||||
var insertedButtons = 0;
|
||||
//Force settings to be first
|
||||
val settingsIndex = buttons.indexOfFirst { b -> b.id == 7 };
|
||||
if (settingsIndex != -1) {
|
||||
val button = buttons[settingsIndex]
|
||||
buttons.removeAt(settingsIndex)
|
||||
buttons.add(0, button)
|
||||
//insertedButtons++;
|
||||
}
|
||||
//Force buy to be on top for more buttons
|
||||
val buyIndex = buttons.indexOfFirst { b -> b.id == 98 };
|
||||
if (buyIndex != -1) {
|
||||
val button = buttons[buyIndex]
|
||||
buttons.removeAt(buyIndex)
|
||||
buttons.add(0, button)
|
||||
insertedButtons++;
|
||||
buttons.add(button)
|
||||
//insertedButtons++;
|
||||
}
|
||||
//Force faq to be second
|
||||
val faqIndex = buttons.indexOfFirst { b -> b.id == 97 };
|
||||
if (faqIndex != -1) {
|
||||
val button = buttons[faqIndex]
|
||||
buttons.removeAt(faqIndex)
|
||||
buttons.add(if (insertedButtons == 1) 1 else 0, button)
|
||||
insertedButtons++;
|
||||
buttons.add(button)
|
||||
//insertedButtons++;
|
||||
}
|
||||
//Force privacy to be third
|
||||
val privacyIndex = buttons.indexOfFirst { b -> b.id == 96 };
|
||||
if (privacyIndex != -1) {
|
||||
val button = buttons[privacyIndex]
|
||||
buttons.removeAt(privacyIndex)
|
||||
buttons.add(if (insertedButtons == 2) 2 else (if(insertedButtons == 1) 1 else 0), button)
|
||||
insertedButtons++;
|
||||
buttons.add(button)
|
||||
//insertedButtons++;
|
||||
}
|
||||
|
||||
val newButtons = mutableListOf<MenuButtonItem>();
|
||||
for (data in buttons) {
|
||||
/*
|
||||
val button = MenuButton(context, data, _fragment, true);
|
||||
button.setOnClickListener {
|
||||
updateMenuIcons()
|
||||
@@ -253,14 +369,19 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
|
||||
_moreButtons.add(button);
|
||||
_layoutMoreButtons.addView(button);
|
||||
*/
|
||||
val buttonItem = MenuButtonItem(data);
|
||||
newButtons.add(buttonItem);
|
||||
}
|
||||
_layoutMoreButtonsAdapter.setData(newButtons);
|
||||
_layoutMoreButtonsAdapter.notifyContentChanged();
|
||||
}
|
||||
|
||||
private fun updateMenuIcons() {
|
||||
for(button in _bottomButtons.toList())
|
||||
button.updateActive(_fragment);
|
||||
for(button in _moreButtons.toList())
|
||||
button.updateActive(_fragment);
|
||||
button.updateActive(_fragment, true);
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration?) {
|
||||
@@ -341,6 +462,71 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
}
|
||||
|
||||
|
||||
class MenuButtonItem(val def: ButtonDefinition);
|
||||
class MenuButtonItemViewHolder(private val _viewGroup: ViewGroup): AnyAdapter.AnyViewHolder<MenuButtonItem>(
|
||||
LayoutInflater.from(_viewGroup.context).inflate(R.layout.list_menu_tile,
|
||||
_viewGroup, false)) {
|
||||
|
||||
val onClick = Event1<MenuButtonItem>();
|
||||
|
||||
val root: ConstraintLayout;
|
||||
val imageIcon: ImageView;
|
||||
val textName: TextView;
|
||||
|
||||
|
||||
var button: MenuButtonItem? = null;
|
||||
|
||||
var parentFragment: MenuBottomBarFragment? = null;
|
||||
|
||||
init {
|
||||
root = _view.findViewById(R.id.root);
|
||||
imageIcon = _view.findViewById(R.id.image_icon);
|
||||
textName = _view.findViewById(R.id.text_name);
|
||||
|
||||
root.setOnClickListener {
|
||||
button?.let {
|
||||
it.def.action(parentFragment ?: return@let);
|
||||
onClick.emit(it);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun bind(value: MenuButtonItem) {
|
||||
button = value;
|
||||
textName.text = _view.context.getString(value.def.string);
|
||||
imageIcon.setImageResource(value.def.iconActive);
|
||||
}
|
||||
|
||||
|
||||
fun setWidth(dp: Int) {
|
||||
root.updateLayoutParams {
|
||||
this.width = (dp - 6).dp(_viewGroup.context.resources);
|
||||
this.height = (dp - 6).dp(_viewGroup.context.resources);
|
||||
}
|
||||
imageIcon.updateLayoutParams {
|
||||
this.width = (dp - 54).dp(_viewGroup.context.resources);
|
||||
this.height = (dp - 54).dp(_viewGroup.context.resources);
|
||||
}
|
||||
}
|
||||
|
||||
fun setAutoSize(totalWidth: Float) {
|
||||
val dpWidth = totalWidth;
|
||||
val columns = Math.max(((dpWidth) / viewWidthDp).toInt(), 1);
|
||||
val remainder = dpWidth - columns * viewWidthDp;
|
||||
val targetSize = viewWidthDp + (remainder / columns).toInt();
|
||||
setWidth(targetSize);
|
||||
}
|
||||
|
||||
companion object {
|
||||
val viewWidthDp = 90;
|
||||
fun getAutoSizeColumns(totalWidth: Float): Int {
|
||||
val dpWidth = totalWidth;
|
||||
val columns = Math.max(((dpWidth) / viewWidthDp).toInt(), 1);
|
||||
return columns;
|
||||
}
|
||||
}
|
||||
}
|
||||
class MenuButton: LinearLayout {
|
||||
val definition: ButtonDefinition;
|
||||
|
||||
@@ -354,7 +540,14 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
this.definition = def;
|
||||
|
||||
_buttonImage = findViewById(R.id.image_button);
|
||||
_buttonImage.setImageResource(if (def.isActive(fragment)) def.iconActive else def.icon);
|
||||
//_buttonImage.setImageResource(if (def.isActive(fragment)) def.iconActive else def.icon);
|
||||
_buttonImage.setImageResource(definition.iconActive);
|
||||
if(definition.isActive(fragment) || isMore) {
|
||||
this.alpha = 1f;
|
||||
}
|
||||
else {
|
||||
this.alpha = 0.5f;
|
||||
}
|
||||
|
||||
_textButton = findViewById(R.id.text_button);
|
||||
_textButton.text = resources.getString(def.string);
|
||||
@@ -365,8 +558,16 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
}
|
||||
}
|
||||
|
||||
fun updateActive(fragment: MenuBottomBarFragment) {
|
||||
_buttonImage.setImageResource(if (definition.isActive(fragment)) definition.iconActive else definition.icon);
|
||||
fun updateActive(fragment: MenuBottomBarFragment, isMore: Boolean = false, overrideValue: Boolean? = null) {
|
||||
//_buttonImage.setImageResource(if (definition.isActive(fragment)) definition.iconActive else definition.icon);
|
||||
_buttonImage.setImageResource(definition.iconActive);
|
||||
val isActive = overrideValue ?: definition.isActive(fragment) || isMore
|
||||
if(isActive) {
|
||||
this.alpha = 1f;
|
||||
}
|
||||
else {
|
||||
this.alpha = 0.5f;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -389,6 +590,9 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
}
|
||||
}),
|
||||
ButtonDefinition(1, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscriptions, canToggle = true, { it.currentMain is SubscriptionsFeedFragment }, { it.navigate<SubscriptionsFeedFragment>(withHistory = false) }),
|
||||
//if(Build.VERSION.SDK_INT > Build.VERSION_CODES.P)
|
||||
ButtonDefinition(12, R.drawable.ic_library, R.drawable.ic_library, R.string.library, canToggle = false, { it.currentMain is LibraryFragment }, { it.navigate<LibraryFragment>(withHistory = false) })
|
||||
,//else null,
|
||||
ButtonDefinition(2, R.drawable.ic_creators, R.drawable.ic_creators_filled, R.string.creators, canToggle = false, { it.currentMain is CreatorsFragment }, { it.navigate<CreatorsFragment>(withHistory = false) }),
|
||||
ButtonDefinition(3, R.drawable.ic_sources, R.drawable.ic_sources_filled, R.string.sources, canToggle = false, { it.currentMain is SourcesFragment }, { it.navigate<SourcesFragment>(withHistory = false) }),
|
||||
ButtonDefinition(4, R.drawable.ic_playlist, R.drawable.ic_playlist_filled, R.string.playlists, canToggle = false, { it.currentMain is PlaylistsFragment }, { it.navigate<PlaylistsFragment>(withHistory = false) }),
|
||||
@@ -399,6 +603,8 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
ButtonDefinition(9, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscription_group_menu, canToggle = true, { it.currentMain is SubscriptionGroupListFragment }, { it.navigate<SubscriptionGroupListFragment>(withHistory = false) }),
|
||||
ButtonDefinition(10, R.drawable.ic_help_square, R.drawable.ic_help_square_fill, R.string.tutorials, canToggle = true, { it.currentMain is TutorialFragment }, { it.navigate<TutorialFragment>(withHistory = false) }),
|
||||
ButtonDefinition(7, R.drawable.ic_settings, R.drawable.ic_settings_filled, R.string.settings, canToggle = false, { false }, {
|
||||
it.navigate<SettingsFragment>();
|
||||
/*
|
||||
val c = it.context ?: return@ButtonDefinition;
|
||||
Logger.i(TAG, "settings preventPictureInPicture()");
|
||||
it.requireFragment<VideoDetailFragment>().preventPictureInPicture();
|
||||
@@ -406,8 +612,8 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
c.startActivity(intent);
|
||||
if (c is Activity) {
|
||||
c.overridePendingTransition(R.anim.slide_in_up, R.anim.slide_darken);
|
||||
}
|
||||
}),
|
||||
}*/
|
||||
}),/*
|
||||
ButtonDefinition(96, R.drawable.ic_disabled_visible, R.drawable.ic_disabled_visible, R.string.privacy_mode, canToggle = true, { false }, {
|
||||
UIDialogs.showDialog(it.context ?: return@ButtonDefinition, R.drawable.ic_disabled_visible_purple, "Privacy Mode",
|
||||
"All requests will be processed anonymously (any logins will be disabled except for the personalized home page), local playback and history tracking will also be disabled.\n\nTap the icon to disable.", null, 0,
|
||||
@@ -417,14 +623,14 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
UIDialogs.Action("Enable", {
|
||||
StateApp.instance.setPrivacyMode(true);
|
||||
}, UIDialogs.ActionStyle.PRIMARY));
|
||||
}),
|
||||
}),*/
|
||||
ButtonDefinition(97, R.drawable.ic_quiz, R.drawable.ic_quiz_fill, R.string.faq, canToggle = true, { false }, {
|
||||
it.navigate<BrowserFragment>(Settings.URL_FAQ, withHistory = false);
|
||||
})
|
||||
//96 is reserved for privacy button
|
||||
//98 is reserved for buy button
|
||||
//99 is reserved for more button
|
||||
);
|
||||
).filterNotNull();
|
||||
}
|
||||
|
||||
data class ButtonDefinition(
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.futo.platformplayer.fragment.mainactivity.main
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import android.provider.MediaStore
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.dp
|
||||
import com.futo.platformplayer.states.Album
|
||||
import com.futo.platformplayer.states.Artist
|
||||
import com.futo.platformplayer.states.ArtistOrdering
|
||||
import com.futo.platformplayer.states.FileEntry
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateLibrary
|
||||
import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny
|
||||
import com.futo.platformplayer.views.AnyInsertedAdapterView
|
||||
import com.futo.platformplayer.views.AnyInsertedAdapterView.Companion.asAnyWithTop
|
||||
import com.futo.platformplayer.views.AnyInsertedAdapterView.Companion.asAnyWithViews
|
||||
import com.futo.platformplayer.views.LibrarySection
|
||||
import com.futo.platformplayer.views.adapters.AnyAdapter
|
||||
import com.futo.platformplayer.views.adapters.InsertedViewAdapter
|
||||
import com.futo.platformplayer.views.adapters.viewholders.AlbumTileViewHolder
|
||||
import com.futo.platformplayer.views.adapters.viewholders.ArtistTileViewHolder
|
||||
import com.futo.platformplayer.views.adapters.viewholders.FileViewHolder
|
||||
import com.futo.platformplayer.views.adapters.viewholders.LocalVideoTileViewHolder
|
||||
import com.futo.platformplayer.views.buttons.BigButton
|
||||
|
||||
|
||||
class BaseFragment : MainFragment() {
|
||||
override val isMainView : Boolean = true;
|
||||
override val isTab: Boolean = true;
|
||||
override val hasBottomBar: Boolean get() = true;
|
||||
|
||||
private var view: FragView? = null;
|
||||
|
||||
|
||||
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
val newView = FragView(this);
|
||||
view = newView;
|
||||
return newView;
|
||||
}
|
||||
|
||||
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
|
||||
super.onShownWithView(parameter, isBack);
|
||||
view?.onShown();
|
||||
}
|
||||
|
||||
override fun onDestroyMainView() {
|
||||
view = null;
|
||||
super.onDestroyMainView();
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun newInstance() = BaseFragment().apply {}
|
||||
}
|
||||
|
||||
|
||||
class FragView: ConstraintLayout {
|
||||
val fragment: BaseFragment;
|
||||
|
||||
constructor(fragment: BaseFragment) : super(fragment.requireContext()) {
|
||||
inflate(context, R.layout.fragview_library, this);
|
||||
this.fragment = fragment;
|
||||
}
|
||||
|
||||
|
||||
fun onShown() {
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -64,7 +64,7 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
|
||||
player.modifyState("ThumbnailPlayer") { state -> state.muted = true };
|
||||
_exoPlayer = player;
|
||||
|
||||
return PreviewContentListAdapter(fragment.lifecycleScope, context, feedStyle, dataset, player, _previewsEnabled, arrayListOf(), arrayListOf(), shouldShowTimeBar).apply {
|
||||
return PreviewContentListAdapter(fragment.lifecycleScope, context, feedStyle ?: FeedStyle.THUMBNAIL, dataset, player, _previewsEnabled, arrayListOf(), arrayListOf(), shouldShowTimeBar).apply {
|
||||
attachAdapterEvents(this);
|
||||
}
|
||||
}
|
||||
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package com.futo.platformplayer.fragment.mainactivity.main
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageButton
|
||||
import android.widget.LinearLayout
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.view.isVisible
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.SettingsDev
|
||||
import com.futo.platformplayer.views.fields.FieldForm
|
||||
import com.futo.platformplayer.views.fields.IField
|
||||
|
||||
|
||||
class DeveloperFragment : MainFragment() {
|
||||
override val isMainView : Boolean = true;
|
||||
override val isTab: Boolean = true;
|
||||
override val hasBottomBar: Boolean get() = true;
|
||||
|
||||
private var view: FragView? = null;
|
||||
|
||||
|
||||
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
val newView = FragView(this);
|
||||
view = newView;
|
||||
_currentView = view;
|
||||
return newView;
|
||||
}
|
||||
|
||||
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
|
||||
super.onShownWithView(parameter, isBack);
|
||||
view?.onShown();
|
||||
}
|
||||
|
||||
override fun onDestroyMainView() {
|
||||
view = null;
|
||||
_currentView = null;
|
||||
super.onDestroyMainView();
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun newInstance() = DeveloperFragment().apply {}
|
||||
|
||||
private var _currentView: FragView? = null;
|
||||
val currentView: FragView?
|
||||
get() = _currentView;
|
||||
}
|
||||
|
||||
|
||||
class FragView: ConstraintLayout {
|
||||
val fragment: DeveloperFragment;
|
||||
|
||||
private lateinit var _form: FieldForm;
|
||||
private lateinit var _buttonBack: ImageButton;
|
||||
|
||||
private var _isFinished = false;
|
||||
|
||||
lateinit var overlay: FrameLayout;
|
||||
|
||||
val notifPermission = "android.permission.POST_NOTIFICATIONS";
|
||||
|
||||
constructor(fragment: DeveloperFragment) : super(fragment.requireContext()) {
|
||||
inflate(context, R.layout.activity_dev, this);
|
||||
this.fragment = fragment;
|
||||
|
||||
val activity = fragment.activity;
|
||||
findViewById<LinearLayout>(R.id.container_topbar).isVisible = false;
|
||||
|
||||
_buttonBack = findViewById(R.id.button_back);
|
||||
_form = findViewById(R.id.settings_form);
|
||||
|
||||
_form.fromObject(SettingsDev.instance);
|
||||
_form.onChanged.subscribe { _, _ ->
|
||||
_form.setObjectValues();
|
||||
SettingsDev.instance.save();
|
||||
};
|
||||
}
|
||||
|
||||
fun getField(id: String): IField? {
|
||||
return _form.findField(id);
|
||||
}
|
||||
|
||||
fun onShown() {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.*
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
@@ -39,6 +40,7 @@ import java.time.OffsetDateTime
|
||||
import kotlin.math.max
|
||||
|
||||
abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : LinearLayout where TPager : IPager<TResult>, TViewHolder : RecyclerView.ViewHolder, TFragment : MainFragment {
|
||||
protected val _feedRoot: ConstraintLayout;
|
||||
protected val _recyclerResults: RecyclerView;
|
||||
protected val _overlayContainer: FrameLayout;
|
||||
protected val _swipeRefresh: SwipeRefreshLayout;
|
||||
@@ -51,6 +53,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
||||
private val _emptyPagerContainer: FrameLayout;
|
||||
|
||||
protected val _toolbarContentView: LinearLayout;
|
||||
protected val _bottomContentView: LinearLayout;
|
||||
|
||||
private var _loading: Boolean = true;
|
||||
|
||||
@@ -67,7 +70,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
||||
private var _sortByOptions: List<String>? = null;
|
||||
private var _activeTags: List<String>? = null;
|
||||
|
||||
private var _nextPageHandler: TaskHandler<TPager, List<TResult>>;
|
||||
private var _nextPageHandler: TaskHandler<TPager, Pair<TPager, List<TResult>>>;
|
||||
val recyclerData: RecyclerData<InsertedViewAdapterWithLoader<TViewHolder>, GridLayoutManager, TPager, TResult, TConverted, InsertedViewHolder<TViewHolder>>;
|
||||
|
||||
val fragment: TFragment;
|
||||
@@ -80,6 +83,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
||||
this.fragment = fragment;
|
||||
inflater.inflate(R.layout.fragment_feed, this);
|
||||
|
||||
_feedRoot = findViewById(R.id.feed_root);
|
||||
_textCentered = findViewById(R.id.text_centered);
|
||||
_emptyPagerContainer = findViewById(R.id.empty_pager_container);
|
||||
_progressBar = findViewById(R.id.progress_bar);
|
||||
@@ -134,24 +138,29 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
||||
setActiveTags(null);
|
||||
|
||||
_toolbarContentView = findViewById(R.id.container_toolbar_content);
|
||||
_bottomContentView = findViewById(R.id.container_bottom);
|
||||
|
||||
_nextPageHandler = TaskHandler<TPager, List<TResult>>({fragment.lifecycleScope}, {
|
||||
_nextPageHandler = TaskHandler<TPager, Pair<TPager, List<TResult>>>({fragment.lifecycleScope}, {
|
||||
if (it is IAsyncPager<*>)
|
||||
it.nextPageAsync();
|
||||
else
|
||||
it.nextPage();
|
||||
|
||||
processPagerExceptions(it);
|
||||
return@TaskHandler it.getResults();
|
||||
return@TaskHandler Pair(it, it.getResults());
|
||||
}).success {
|
||||
val pager = it.first;
|
||||
val results = it.second
|
||||
|
||||
setLoading(false);
|
||||
|
||||
val posBefore = recyclerData.results.size;
|
||||
val filteredResults = filterResults(it);
|
||||
val filteredResults = filterResults(results);
|
||||
recyclerData.results.addAll(filteredResults);
|
||||
recyclerData.resultsUnfiltered.addAll(it);
|
||||
recyclerData.resultsUnfiltered.addAll(results);
|
||||
recyclerData.adapter.notifyItemRangeInserted(recyclerData.adapter.childToParentPosition(posBefore), filteredResults.size);
|
||||
ensureEnoughContentVisible(filteredResults)
|
||||
if(pager.hasMorePages())
|
||||
ensureEnoughContentVisible(filteredResults)
|
||||
}.exception<Throwable> {
|
||||
Logger.w(TAG, "Failed to load next page.", it);
|
||||
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_next_page), it, {
|
||||
@@ -390,6 +399,9 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
||||
protected fun finishRefreshLayoutLoader() {
|
||||
_swipeRefresh.isRefreshing = false;
|
||||
}
|
||||
protected fun disableRefreshLayout() {
|
||||
_swipeRefresh.isEnabled = false;
|
||||
}
|
||||
|
||||
fun clearResults(){
|
||||
setPager(EmptyPager<TResult>() as TPager);
|
||||
@@ -472,7 +484,8 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
||||
recyclerData.resultsUnfiltered.addAll(toAdd);
|
||||
recyclerData.adapter.notifyDataSetChanged();
|
||||
recyclerData.loadedFeedStyle = feedStyle;
|
||||
ensureEnoughContentVisible(filteredResults)
|
||||
if(pager.hasMorePages())
|
||||
ensureEnoughContentVisible(filteredResults)
|
||||
}
|
||||
|
||||
private fun detachPagerEvents() {
|
||||
|
||||
+15
-3
@@ -26,6 +26,7 @@ import com.futo.platformplayer.models.HistoryVideo
|
||||
import com.futo.platformplayer.states.StateHistory
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StatePlayer
|
||||
import com.futo.platformplayer.states.StatePlaylists
|
||||
import com.futo.platformplayer.states.StatePlugins
|
||||
import com.futo.platformplayer.views.ToggleBar
|
||||
import com.futo.platformplayer.views.adapters.HistoryListViewHolder
|
||||
@@ -243,12 +244,23 @@ class HistoryFragment : MainFragment() {
|
||||
return;
|
||||
}
|
||||
|
||||
val inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager;
|
||||
val diff = v.video.duration - v.position;
|
||||
val vid: Any = if (diff > 5) { v.video.withTimestamp(v.position) } else { v.video };
|
||||
StatePlayer.instance.clearQueue();
|
||||
_fragment.navigate<VideoDetailFragment>(vid).maximizeVideoDetail();
|
||||
|
||||
val playlistId = v.playlistId
|
||||
val playlist = playlistId?.let { StatePlaylists.instance.getPlaylist(it) }
|
||||
val playlistIndex = playlist?.videos?.indexOfFirst { it.url == v.video.url }
|
||||
if (playlist != null && playlistIndex != null && playlistIndex >= 0) {
|
||||
_fragment.navigate<VideoDetailFragment>(vid).maximizeVideoDetail();
|
||||
StatePlayer.instance.setPlaylist(playlist, playlistIndex)
|
||||
|
||||
} else {
|
||||
StatePlayer.instance.clearQueue();
|
||||
_fragment.navigate<VideoDetailFragment>(vid).maximizeVideoDetail();
|
||||
}
|
||||
|
||||
_editSearch.clearFocus();
|
||||
val inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager;
|
||||
inputMethodManager.hideSoftInputFromWindow(_editSearch.windowToken, 0);
|
||||
|
||||
_fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
|
||||
+3
-1
@@ -365,8 +365,10 @@ class HomeFragment : MainFragment() {
|
||||
finishRefreshLayoutLoader();
|
||||
setLoading(false);
|
||||
setPager(pager);
|
||||
if(pager.getResults().isEmpty() && !pager.hasMorePages())
|
||||
if(pager.getResults().isEmpty() && !pager.hasMorePages()) {
|
||||
setLoading(false);
|
||||
setEmptyPager(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
package com.futo.platformplayer.fragment.mainactivity.main
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.util.TypedValue
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.LinearLayout
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.futo.platformplayer.UISlideOverlays
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.api.media.structures.AdhocPager
|
||||
import com.futo.platformplayer.api.media.structures.EmptyPager
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.dp
|
||||
import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFragment
|
||||
import com.futo.platformplayer.states.Album
|
||||
import com.futo.platformplayer.states.StateLibrary
|
||||
import com.futo.platformplayer.states.StatePlayer
|
||||
import com.futo.platformplayer.toHumanDuration
|
||||
import com.futo.platformplayer.views.AlbumHeaderView
|
||||
import com.futo.platformplayer.views.FeedStyle
|
||||
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
|
||||
import com.futo.platformplayer.views.adapters.viewholders.TrackViewHolder
|
||||
|
||||
|
||||
class LibraryAlbumFragment : MainFragment() {
|
||||
override val isMainView : Boolean = true;
|
||||
override val isTab: Boolean = true;
|
||||
override val hasBottomBar: Boolean get() = true;
|
||||
|
||||
private var view: FragView? = null;
|
||||
|
||||
|
||||
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): android.view.View {
|
||||
val newView = FragView(this, inflater);
|
||||
view = newView;
|
||||
return newView;
|
||||
}
|
||||
|
||||
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
|
||||
super.onShownWithView(parameter, isBack);
|
||||
view?.onShown(parameter);
|
||||
}
|
||||
|
||||
override fun onDestroyMainView() {
|
||||
view = null;
|
||||
super.onDestroyMainView();
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun newInstance() = LibraryAlbumFragment().apply {}
|
||||
}
|
||||
|
||||
|
||||
class FragView : FeedView<LibraryAlbumFragment, IPlatformVideo, IPlatformVideo, IPager<IPlatformVideo>, TrackViewHolder> {
|
||||
override val feedStyle: FeedStyle = FeedStyle.THUMBNAIL; //R.layout.list_creator;
|
||||
|
||||
private val _header: AlbumHeaderView;
|
||||
|
||||
private var _album: Album? = null;
|
||||
private var _tracks: List<IPlatformVideo>? = null;
|
||||
private var _url: String? = null;
|
||||
|
||||
constructor(fragment: LibraryAlbumFragment, inflater: LayoutInflater) : super(fragment, inflater) {
|
||||
_header = AlbumHeaderView(context);
|
||||
_toolbarContentView.addView(_header);
|
||||
|
||||
_header.onPlayAll.subscribe {
|
||||
val playlist = _album?.toPlaylist(_tracks);
|
||||
if (playlist != null) {
|
||||
StatePlayer.instance.setPlaylist(playlist, focus = true);
|
||||
}
|
||||
}
|
||||
_header.onShuffle.subscribe {
|
||||
val playlist = _album?.toPlaylist(_tracks);
|
||||
if (playlist != null) {
|
||||
StatePlayer.instance.setPlaylist(playlist, focus = true, shuffle = true);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
_feedRoot.updateLayoutParams<LinearLayout.LayoutParams> {
|
||||
this.setMargins(0,-50.dp(resources),0,0)
|
||||
} */
|
||||
}
|
||||
|
||||
fun onShown(parameter: Any?) {
|
||||
val album = if(parameter is String)
|
||||
StateLibrary.instance.getAlbum(parameter);
|
||||
else if(parameter is Long)
|
||||
StateLibrary.instance.getAlbum(parameter);
|
||||
else if(parameter is Album)
|
||||
parameter;
|
||||
else null;
|
||||
if(album == null) {
|
||||
_album = null;
|
||||
_tracks = null;
|
||||
setPager(EmptyPager());
|
||||
return;
|
||||
}
|
||||
_header.setName(album.name);
|
||||
_header.setThumbnail(album.thumbnail);
|
||||
val tracks = album.getTracks();
|
||||
_album = album;
|
||||
_tracks = tracks;
|
||||
_header.setMetadata("${tracks.size} tracks" + if(tracks.size > 0) (" • " + tracks.sumOf { it.duration }.toHumanDuration(false)) else "");
|
||||
setPager(AdhocPager({listOf()}, tracks));
|
||||
}
|
||||
|
||||
override fun createAdapter(recyclerResults: RecyclerView, context: Context, dataset: ArrayList<IPlatformVideo>): InsertedViewAdapterWithLoader<TrackViewHolder> {
|
||||
return InsertedViewAdapterWithLoader(context, arrayListOf(), arrayListOf(),
|
||||
childCountGetter = { dataset.size },
|
||||
childViewHolderBinder = { viewHolder, position -> viewHolder.bind(dataset[position]); },
|
||||
childViewHolderFactory = { viewGroup, _ ->
|
||||
val holder = TrackViewHolder(viewGroup);
|
||||
holder.onClick.subscribe { c ->
|
||||
|
||||
val playlist = _album?.toPlaylist(_tracks);
|
||||
val index = playlist?.videos?.indexOfFirst { it.name == c.name } ?: -1;
|
||||
if (playlist != null) {
|
||||
if (index == -1)
|
||||
return@subscribe;
|
||||
|
||||
StatePlayer.instance.setPlaylist(playlist, index, true);
|
||||
}
|
||||
};
|
||||
holder.onOptions.subscribe {
|
||||
if(it is IPlatformVideo)
|
||||
UISlideOverlays.showVideoOptionsOverlay(it, _overlayContainer);
|
||||
}
|
||||
return@InsertedViewAdapterWithLoader holder;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
override fun updateSpanCount(){ }
|
||||
|
||||
override fun createLayoutManager(recyclerResults: RecyclerView, context: Context): GridLayoutManager {
|
||||
val glmResults = GridLayoutManager(context, 1)
|
||||
|
||||
_swipeRefresh.layoutParams = (_swipeRefresh.layoutParams as MarginLayoutParams?)?.apply {
|
||||
rightMargin = TypedValue.applyDimension(
|
||||
TypedValue.COMPLEX_UNIT_DIP,
|
||||
8.0f,
|
||||
context.resources.displayMetrics
|
||||
).toInt()
|
||||
}
|
||||
return glmResults
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "LibraryArtistsFragmentsView";
|
||||
}
|
||||
}
|
||||
}
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
package com.futo.platformplayer.fragment.mainactivity.main
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.util.TypedValue
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.AdapterView
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.EditText
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout.GONE
|
||||
import android.widget.LinearLayout.VISIBLE
|
||||
import android.widget.Spinner
|
||||
import android.widget.TextView
|
||||
import androidx.core.widget.addTextChangedListener
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.bumptech.glide.Glide
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UISlideOverlays
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
|
||||
import com.futo.platformplayer.api.media.structures.AdhocPager
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.Album
|
||||
import com.futo.platformplayer.states.StateLibrary
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
import com.futo.platformplayer.stores.StringStorage
|
||||
import com.futo.platformplayer.views.FeedStyle
|
||||
import com.futo.platformplayer.views.LibraryTypeHeaderView
|
||||
import com.futo.platformplayer.views.adapters.AnyAdapter
|
||||
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
|
||||
import com.futo.platformplayer.views.adapters.SubscriptionAdapter
|
||||
import com.futo.platformplayer.views.adapters.viewholders.AlbumTileViewHolder
|
||||
import com.futo.platformplayer.views.adapters.viewholders.SelectablePlaylist
|
||||
import com.futo.platformplayer.views.others.CreatorThumbnail
|
||||
import com.futo.platformplayer.views.platform.PlatformIndicator
|
||||
|
||||
class LibraryAlbumsFragment : MainFragment() {
|
||||
override val isMainView : Boolean = true;
|
||||
override val isTab: Boolean = true;
|
||||
override val hasBottomBar: Boolean get() = true;
|
||||
|
||||
|
||||
var view: FragView? = null;
|
||||
|
||||
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
val view = FragView(this, inflater);
|
||||
this.view = view;
|
||||
return view;
|
||||
}
|
||||
|
||||
override fun onShown(parameter: Any?, isBack: Boolean) {
|
||||
super.onShown(parameter, isBack)
|
||||
}
|
||||
|
||||
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
|
||||
super.onShownWithView(parameter, isBack);
|
||||
view?.onShown();
|
||||
}
|
||||
|
||||
override fun onDestroyMainView() {
|
||||
view = null;
|
||||
super.onDestroyMainView();
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun newInstance() = LibraryAlbumsFragment().apply {}
|
||||
}
|
||||
|
||||
class FragView : FeedView<LibraryAlbumsFragment, Album, Album, IPager<Album>, AlbumTileViewHolder> {
|
||||
override val feedStyle: FeedStyle = FeedStyle.THUMBNAIL; //R.layout.list_creator;
|
||||
|
||||
val libraryTypeHeader: LibraryTypeHeaderView;
|
||||
|
||||
constructor(fragment: LibraryAlbumsFragment, inflater: LayoutInflater) : super(fragment, inflater) {
|
||||
libraryTypeHeader = LibraryTypeHeaderView(context);
|
||||
libraryTypeHeader.setSelectedType(LibraryTypeHeaderView.SelectedType.Albums);
|
||||
libraryTypeHeader.setMetadata("");
|
||||
|
||||
libraryTypeHeader.onSelectedChanged.subscribe {
|
||||
when(it) {
|
||||
LibraryTypeHeaderView.SelectedType.Artists -> fragment.navigate<LibraryArtistsFragment>();
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
_toolbarContentView.addView(libraryTypeHeader);
|
||||
disableRefreshLayout();
|
||||
}
|
||||
|
||||
fun onShown() {
|
||||
val initialAlbums = StateLibrary.instance.getAlbums();
|
||||
Logger.i(TAG, "Initial album count: " + initialAlbums.size);
|
||||
|
||||
libraryTypeHeader.setMetadata("${initialAlbums.size} albums");
|
||||
setPager(AdhocPager<Album>({ listOf(); }, initialAlbums));
|
||||
}
|
||||
|
||||
override fun reload() {
|
||||
super.reload();
|
||||
finishRefreshLayoutLoader();
|
||||
}
|
||||
|
||||
override fun createAdapter(recyclerResults: RecyclerView, context: Context, dataset: ArrayList<Album>): InsertedViewAdapterWithLoader<AlbumTileViewHolder> {
|
||||
return InsertedViewAdapterWithLoader(context, arrayListOf(), arrayListOf(),
|
||||
childCountGetter = { dataset.size },
|
||||
childViewHolderBinder = { viewHolder, position -> viewHolder.bind(dataset[position]); },
|
||||
childViewHolderFactory = { viewGroup, _ ->
|
||||
val holder = AlbumTileViewHolder(viewGroup);
|
||||
holder.setAutoSize(resources.displayMetrics.widthPixels / resources.displayMetrics.density)
|
||||
holder.onClick.subscribe { c -> fragment.navigate<LibraryAlbumFragment>(c) };
|
||||
return@InsertedViewAdapterWithLoader holder;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
override fun updateSpanCount(){ }
|
||||
|
||||
override fun createLayoutManager(recyclerResults: RecyclerView, context: Context): GridLayoutManager {
|
||||
val glmResults = GridLayoutManager(context, AlbumTileViewHolder.getAutoSizeColumns(resources.displayMetrics.widthPixels / resources.displayMetrics.density))
|
||||
|
||||
_swipeRefresh.layoutParams = (_swipeRefresh.layoutParams as MarginLayoutParams?)?.apply {
|
||||
leftMargin = TypedValue.applyDimension(
|
||||
TypedValue.COMPLEX_UNIT_DIP,
|
||||
3f,
|
||||
context.resources.displayMetrics
|
||||
).toInt()
|
||||
}
|
||||
|
||||
return glmResults
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "LibraryAlbumsFragmentsView";
|
||||
}
|
||||
}
|
||||
|
||||
class AlbumViewHolder(private val _viewGroup: ViewGroup) : AnyAdapter.AnyViewHolder<Album>(
|
||||
LayoutInflater.from(_viewGroup.context).inflate(R.layout.list_album,
|
||||
_viewGroup, false)) {
|
||||
|
||||
val onClick = Event1<Album?>();
|
||||
|
||||
protected var _album: Album? = null;
|
||||
protected val _imageThumbnail: ImageView
|
||||
protected val _textName: TextView
|
||||
protected val _textMetadata: TextView
|
||||
|
||||
init {
|
||||
_imageThumbnail = _view.findViewById(R.id.image_thumbnail);
|
||||
_textName = _view.findViewById(R.id.text_name);
|
||||
_textMetadata = _view.findViewById(R.id.text_metadata);
|
||||
|
||||
_view.setOnClickListener { onClick.emit(_album) };
|
||||
}
|
||||
|
||||
|
||||
override fun bind(album: Album) {
|
||||
_album = album;
|
||||
_imageThumbnail?.let {
|
||||
if (album.thumbnail != null)
|
||||
Glide.with(it)
|
||||
.load(album.thumbnail)
|
||||
.placeholder(R.drawable.placeholder_channel_thumbnail)
|
||||
.into(it)
|
||||
else
|
||||
Glide.with(it).load(R.drawable.placeholder_channel_thumbnail).into(it);
|
||||
};
|
||||
|
||||
_textName.text = album.name;
|
||||
_textMetadata.text = album.artist ?: "";
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
+634
@@ -0,0 +1,634 @@
|
||||
package com.futo.platformplayer.fragment.mainactivity.main
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Animatable
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.util.TypedValue
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.widget.AppCompatImageView
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import com.bumptech.glide.Glide
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.UISlideOverlays
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
|
||||
import com.futo.platformplayer.api.media.models.post.IPlatformPost
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
||||
import com.futo.platformplayer.api.media.structures.AdhocPager
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.assume
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.constructs.Event2
|
||||
import com.futo.platformplayer.constructs.Event3
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.LibraryAlbumsFragment.AlbumViewHolder
|
||||
import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFragment
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.SearchType
|
||||
import com.futo.platformplayer.states.Album
|
||||
import com.futo.platformplayer.states.Artist
|
||||
import com.futo.platformplayer.states.StateLibrary
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StatePlayer
|
||||
import com.futo.platformplayer.states.StatePlaylists
|
||||
import com.futo.platformplayer.states.StateSubscriptions
|
||||
import com.futo.platformplayer.views.FeedStyle
|
||||
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
|
||||
import com.futo.platformplayer.views.adapters.viewholders.AlbumTileViewHolder
|
||||
import com.futo.platformplayer.views.adapters.viewholders.TrackViewHolder
|
||||
import com.futo.platformplayer.views.others.CreatorThumbnail
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
|
||||
import com.futo.platformplayer.views.subscriptions.SubscribeButton
|
||||
import com.google.android.material.tabs.TabLayout
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class LibraryArtistFragment : MainFragment() {
|
||||
override val isMainView : Boolean = true;
|
||||
override val isTab: Boolean = true;
|
||||
override val hasBottomBar: Boolean get() = true;
|
||||
|
||||
private var _textMeta: TextView? = null;
|
||||
|
||||
var view: FragView? = null;
|
||||
|
||||
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
val view = FragView(this, inflater);
|
||||
this.view = view;
|
||||
return view;
|
||||
}
|
||||
|
||||
override fun onShown(parameter: Any?, isBack: Boolean) {
|
||||
super.onShown(parameter, isBack)
|
||||
}
|
||||
|
||||
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
|
||||
super.onShownWithView(parameter, isBack);
|
||||
view?.onShown(parameter, isBack);
|
||||
}
|
||||
|
||||
override fun onDestroyMainView() {
|
||||
view = null;
|
||||
super.onDestroyMainView();
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun newInstance() = LibraryArtistFragment().apply {}
|
||||
}
|
||||
|
||||
class FragView(fragment: LibraryArtistFragment, inflater: LayoutInflater) : LinearLayout(inflater.context) {
|
||||
private val _fragment: LibraryArtistFragment = fragment
|
||||
|
||||
private var _textChannel: TextView
|
||||
private var _textChannelSub: TextView
|
||||
private var _creatorThumbnail: CreatorThumbnail
|
||||
private var _imageBanner: AppCompatImageView
|
||||
|
||||
private var _tabs: TabLayout
|
||||
private var _viewPager: ViewPager2
|
||||
|
||||
// private var _adapter: ChannelViewPagerAdapter;
|
||||
private var _tabLayoutMediator: TabLayoutMediator
|
||||
private var _buttonSubscribe: SubscribeButton
|
||||
private var _buttonSubscriptionSettings: ImageButton
|
||||
|
||||
private var _overlayContainer: FrameLayout
|
||||
private var _overlayLoading: LinearLayout
|
||||
private var _overlayLoadingSpinner: ImageView
|
||||
|
||||
private var _slideUpOverlay: SlideUpMenuOverlay? = null
|
||||
|
||||
private var _isLoading: Boolean = false
|
||||
private var _selectedTabIndex: Int = -1
|
||||
var channel: Artist? = null
|
||||
private set
|
||||
private var _url: String? = null
|
||||
|
||||
private val _onPageChangeCallback = object : ViewPager2.OnPageChangeCallback() {}
|
||||
|
||||
init {
|
||||
inflater.inflate(R.layout.fragment_artist, this)
|
||||
|
||||
val tabs: TabLayout = findViewById(R.id.tabs)
|
||||
val viewPager: ViewPager2 = findViewById(R.id.view_pager)
|
||||
_textChannel = findViewById(R.id.text_channel_name)
|
||||
_textChannelSub = findViewById(R.id.text_metadata)
|
||||
_creatorThumbnail = findViewById(R.id.creator_thumbnail)
|
||||
_imageBanner = findViewById(R.id.image_channel_banner)
|
||||
_buttonSubscribe = findViewById(R.id.button_subscribe)
|
||||
_buttonSubscriptionSettings = findViewById(R.id.button_sub_settings)
|
||||
_overlayLoading = findViewById(R.id.channel_loading_overlay)
|
||||
_overlayLoadingSpinner = findViewById(R.id.channel_loader_frag)
|
||||
_overlayContainer = findViewById(R.id.overlay_container)
|
||||
_buttonSubscribe.onSubscribed.subscribe {
|
||||
UISlideOverlays.showSubscriptionOptionsOverlay(it, _overlayContainer)
|
||||
_buttonSubscriptionSettings.visibility =
|
||||
if (_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE
|
||||
}
|
||||
_buttonSubscribe.onUnSubscribed.subscribe {
|
||||
_buttonSubscriptionSettings.visibility =
|
||||
if (_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE
|
||||
}
|
||||
_buttonSubscriptionSettings.setOnClickListener {
|
||||
val url = channel?.contentUrl ?: _url ?: return@setOnClickListener
|
||||
val sub =
|
||||
StateSubscriptions.instance.getSubscription(url) ?: return@setOnClickListener
|
||||
UISlideOverlays.showSubscriptionOptionsOverlay(sub, _overlayContainer)
|
||||
}
|
||||
|
||||
//TODO: Determine if this is really the only solution (isSaveEnabled=false)
|
||||
viewPager.isSaveEnabled = false
|
||||
viewPager.registerOnPageChangeCallback(_onPageChangeCallback)
|
||||
val adapter = ArtistViewPagerAdapter(fragment, fragment.childFragmentManager, fragment.lifecycle)
|
||||
adapter.onChannelClicked.subscribe { c -> fragment.navigate<ChannelFragment>(c) }
|
||||
adapter.onContentClicked.subscribe { v, _ ->
|
||||
when (v) {
|
||||
is IPlatformVideo -> {
|
||||
StatePlayer.instance.clearQueue()
|
||||
fragment.navigate<VideoDetailFragment>(v).maximizeVideoDetail()
|
||||
}
|
||||
|
||||
is IPlatformPlaylist -> {
|
||||
fragment.navigate<RemotePlaylistFragment>(v)
|
||||
}
|
||||
|
||||
is IPlatformPost -> {
|
||||
fragment.navigate<PostDetailFragment>(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
adapter.onShortClicked.subscribe { v, _, pagerPair ->
|
||||
when (v) {
|
||||
is IPlatformVideo -> {
|
||||
StatePlayer.instance.clearQueue()
|
||||
fragment.navigate<ShortsFragment>(Triple(v, pagerPair!!.first, pagerPair.second))
|
||||
}
|
||||
}
|
||||
}
|
||||
adapter.onAddToClicked.subscribe { content ->
|
||||
_overlayContainer.let {
|
||||
if (content is IPlatformVideo) _slideUpOverlay =
|
||||
UISlideOverlays.showVideoOptionsOverlay(content, it)
|
||||
}
|
||||
}
|
||||
adapter.onAddToQueueClicked.subscribe { content ->
|
||||
if (content is IPlatformVideo) {
|
||||
StatePlayer.instance.addToQueue(content)
|
||||
}
|
||||
}
|
||||
adapter.onAddToWatchLaterClicked.subscribe { content ->
|
||||
if (content is IPlatformVideo) {
|
||||
if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(content), true))
|
||||
UIDialogs.toast("Added to watch later\n[${content.name}]")
|
||||
else
|
||||
UIDialogs.toast(context.getString(R.string.already_in_watch_later))
|
||||
}
|
||||
}
|
||||
adapter.onUrlClicked.subscribe { url ->
|
||||
fragment.navigate<BrowserFragment>(url)
|
||||
}
|
||||
adapter.onContentUrlClicked.subscribe { url, contentType ->
|
||||
when (contentType) {
|
||||
ContentType.MEDIA -> {
|
||||
StatePlayer.instance.clearQueue()
|
||||
fragment.navigate<VideoDetailFragment>(url).maximizeVideoDetail()
|
||||
}
|
||||
|
||||
ContentType.URL -> fragment.navigate<BrowserFragment>(url)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
adapter.onLongPress.subscribe { content ->
|
||||
_overlayContainer.let {
|
||||
if (content is IPlatformVideo) _slideUpOverlay =
|
||||
UISlideOverlays.showVideoOptionsOverlay(content, it)
|
||||
}
|
||||
}
|
||||
viewPager.adapter = adapter
|
||||
val tabLayoutMediator = TabLayoutMediator(
|
||||
tabs, viewPager, (viewPager.adapter as ArtistViewPagerAdapter)::getTabNames
|
||||
)
|
||||
tabLayoutMediator.attach()
|
||||
|
||||
_tabLayoutMediator = tabLayoutMediator
|
||||
_tabs = tabs
|
||||
_viewPager = viewPager
|
||||
if (_selectedTabIndex != -1) {
|
||||
selectTab(_selectedTabIndex)
|
||||
}
|
||||
setLoading(true)
|
||||
}
|
||||
|
||||
fun selectTab(tab: ArtistTab) {
|
||||
(_viewPager.adapter as ArtistViewPagerAdapter).getTabPosition(tab)
|
||||
}
|
||||
|
||||
fun cleanup() {
|
||||
_tabLayoutMediator.detach()
|
||||
_viewPager.unregisterOnPageChangeCallback(_onPageChangeCallback)
|
||||
hideSlideUpOverlay()
|
||||
(_overlayLoadingSpinner.drawable as Animatable?)?.stop()
|
||||
}
|
||||
|
||||
fun onShown(parameter: Any?, isBack: Boolean) {
|
||||
hideSlideUpOverlay()
|
||||
_selectedTabIndex = -1
|
||||
|
||||
if (!isBack || _url == null) {
|
||||
_imageBanner.setImageDrawable(null)
|
||||
|
||||
when (parameter) {
|
||||
is String -> {
|
||||
_buttonSubscribe.setSubscribeChannel(parameter)
|
||||
_buttonSubscriptionSettings.visibility =
|
||||
if (_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE
|
||||
|
||||
_url = parameter
|
||||
|
||||
val parsed = Uri.parse(parameter);
|
||||
val idLong = parsed.lastPathSegment?.toLongOrNull();
|
||||
if(idLong != null) {
|
||||
val artist = StateLibrary.instance.getArtist(idLong) ?: return;
|
||||
showArtist(artist);
|
||||
}
|
||||
}
|
||||
|
||||
is Artist -> {
|
||||
showArtist(parameter)
|
||||
_url = parameter.contentUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun selectTab(selectedTabIndex: Int) {
|
||||
_selectedTabIndex = selectedTabIndex
|
||||
_tabs.selectTab(_tabs.getTabAt(selectedTabIndex))
|
||||
}
|
||||
|
||||
private fun setLoading(isLoading: Boolean) {
|
||||
if (_isLoading == isLoading) {
|
||||
return
|
||||
}
|
||||
|
||||
_isLoading = isLoading
|
||||
if (isLoading) {
|
||||
_overlayLoading.visibility = View.VISIBLE
|
||||
(_overlayLoadingSpinner.drawable as Animatable?)?.start()
|
||||
} else {
|
||||
(_overlayLoadingSpinner.drawable as Animatable?)?.stop()
|
||||
_overlayLoading.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
fun onBackPressed(): Boolean {
|
||||
if (_slideUpOverlay != null) {
|
||||
hideSlideUpOverlay()
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private fun hideSlideUpOverlay() {
|
||||
_slideUpOverlay?.hide(false)
|
||||
_slideUpOverlay = null
|
||||
}
|
||||
|
||||
private fun showArtist(channel: Artist) {
|
||||
setLoading(false)
|
||||
|
||||
_fragment.topBar?.onShown(channel)
|
||||
|
||||
val buttons = arrayListOf<Pair<Int, ()->Unit>>();
|
||||
|
||||
_fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
val plugin = StatePlatform.instance.getChannelClientOrNull(channel.contentUrl ?: return@launch)
|
||||
withContext(Dispatchers.Main) {
|
||||
buttons.add(Pair(R.drawable.ic_search) {
|
||||
_fragment.navigate<SuggestionsFragment>(
|
||||
SuggestionsFragmentData(
|
||||
"", SearchType.VIDEO
|
||||
)
|
||||
)
|
||||
})
|
||||
_fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(buttons)
|
||||
}
|
||||
}
|
||||
|
||||
_buttonSubscribe.visibility = GONE;
|
||||
_buttonSubscriptionSettings.visibility = View.GONE
|
||||
_textChannel.text = channel.name
|
||||
_textChannelSub.text = "${channel.countTracks} songs, ${channel.countAlbums} albums";
|
||||
|
||||
var supportsPlaylists = false;
|
||||
val playlistPosition = 1
|
||||
// keep the current tab selected
|
||||
if (_viewPager.currentItem >= playlistPosition) {
|
||||
_viewPager.setCurrentItem(_viewPager.currentItem + 1, false)
|
||||
}
|
||||
(_viewPager.adapter as ArtistViewPagerAdapter).insert(
|
||||
playlistPosition,
|
||||
ArtistTab.ALBUMS
|
||||
)
|
||||
|
||||
// sets the channel for each tab
|
||||
for (fragment in _fragment.childFragmentManager.fragments) {
|
||||
(fragment as IArtistTabFragment).setArtist(channel)
|
||||
}
|
||||
|
||||
(_viewPager.adapter as ArtistViewPagerAdapter).artist = channel
|
||||
|
||||
|
||||
_viewPager.adapter!!.notifyDataSetChanged();
|
||||
|
||||
val artistThumbnail = channel.getThumbnailOrAlbum();
|
||||
if(artistThumbnail != null) {
|
||||
_creatorThumbnail.isVisible = true;
|
||||
_creatorThumbnail.setThumbnail(channel.getThumbnailOrAlbum(), true, true);
|
||||
Glide.with(_imageBanner)
|
||||
.load(artistThumbnail)
|
||||
.into(_imageBanner);
|
||||
}
|
||||
else {
|
||||
_creatorThumbnail.isVisible = false;
|
||||
Glide.with(_imageBanner).clear(_imageBanner);
|
||||
}
|
||||
|
||||
|
||||
this.channel = channel
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
private const val TAG = "LibraryArtistFragmentsView";
|
||||
}
|
||||
}
|
||||
enum class ArtistTab {
|
||||
SONGS, ALBUMS
|
||||
}
|
||||
|
||||
class ArtistViewPagerAdapter(private val fragment: LibraryArtistFragment, fragmentManager: FragmentManager, lifecycle: Lifecycle) :
|
||||
FragmentStateAdapter(fragmentManager, lifecycle) {
|
||||
private val _supportedFragments = mutableMapOf(
|
||||
ArtistTab.SONGS.ordinal to ArtistTab.SONGS
|
||||
)
|
||||
private val _tabs = arrayListOf(ArtistTab.SONGS, ArtistTab.ALBUMS)
|
||||
|
||||
var artist: Artist? = null
|
||||
|
||||
val onContentUrlClicked = Event2<String, ContentType>()
|
||||
val onUrlClicked = Event1<String>()
|
||||
val onContentClicked = Event2<IPlatformContent, Long>()
|
||||
val onShortClicked = Event3<IPlatformContent, Long, Pair<IPager<IPlatformContent>, ArrayList<IPlatformContent>>?>()
|
||||
val onChannelClicked = Event1<PlatformAuthorLink>()
|
||||
val onAddToClicked = Event1<IPlatformContent>()
|
||||
val onAddToQueueClicked = Event1<IPlatformContent>()
|
||||
val onAddToWatchLaterClicked = Event1<IPlatformContent>()
|
||||
val onLongPress = Event1<IPlatformContent>()
|
||||
|
||||
override fun getItemId(position: Int): Long {
|
||||
return _tabs[position].ordinal.toLong()
|
||||
}
|
||||
|
||||
override fun containsItem(itemId: Long): Boolean {
|
||||
return _supportedFragments.containsKey(itemId.toInt())
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return _supportedFragments.size
|
||||
}
|
||||
|
||||
fun getTabPosition(tab: ArtistTab): Int {
|
||||
return _tabs.indexOf(tab)
|
||||
}
|
||||
|
||||
fun getTabNames(tab: TabLayout.Tab, position: Int) {
|
||||
tab.text = _tabs[position].name
|
||||
}
|
||||
|
||||
fun insert(position: Int, tab: ArtistTab) {
|
||||
_supportedFragments[tab.ordinal] = tab
|
||||
_tabs.add(position, tab)
|
||||
notifyItemInserted(position)
|
||||
}
|
||||
|
||||
fun remove(position: Int) {
|
||||
_supportedFragments.remove(_tabs[position].ordinal)
|
||||
_tabs.removeAt(position)
|
||||
notifyItemRemoved(position)
|
||||
}
|
||||
|
||||
override fun createFragment(position: Int): Fragment {
|
||||
val fragment: Fragment
|
||||
when (_tabs[position]) {
|
||||
ArtistTab.SONGS -> {
|
||||
fragment = ChannelContentsFragment(this.fragment).apply {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
ArtistTab.ALBUMS -> {
|
||||
fragment = ArtistAlbumsFragment(this.fragment).apply {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
artist?.let { (fragment as IArtistTabFragment).setArtist(it) }
|
||||
|
||||
return fragment
|
||||
}
|
||||
}
|
||||
|
||||
interface IArtistTabFragment {
|
||||
fun setArtist(artist: Artist);
|
||||
}
|
||||
|
||||
class ChannelContentsFragment(private val frag: LibraryArtistFragment) : Fragment(), IArtistTabFragment {
|
||||
|
||||
var view: ArtistContentView? = null;
|
||||
|
||||
private var _lastArtist: Artist? = null;
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
view = ArtistContentView(frag, inflater);
|
||||
_lastArtist?.let {
|
||||
view?.setArtist(it);
|
||||
}
|
||||
return view;
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
view = null;
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
override fun setArtist(artist: Artist) {
|
||||
view?.setArtist(artist);
|
||||
_lastArtist = artist;
|
||||
}
|
||||
}
|
||||
class ArtistContentView : FeedView<LibraryArtistFragment, IPlatformContent, IPlatformVideo, IPager<IPlatformContent>, TrackViewHolder> {
|
||||
override val feedStyle: FeedStyle = FeedStyle.THUMBNAIL; //R.layout.list_creator;
|
||||
|
||||
protected var _artist: Artist? = null;
|
||||
|
||||
constructor(fragment: LibraryArtistFragment, inflater: LayoutInflater) : super(fragment, inflater) {
|
||||
|
||||
}
|
||||
|
||||
fun setArtist(artist: Artist) {
|
||||
this._artist = artist;
|
||||
val tracks = artist.getAudioTracks();
|
||||
if(tracks.getResults().isEmpty())
|
||||
UIDialogs.appToast("No tracks found");
|
||||
setPager(tracks);
|
||||
}
|
||||
|
||||
override fun filterResults(results: List<IPlatformContent>): List<IPlatformVideo> {
|
||||
return results.filter { it is IPlatformVideo }.map { it as IPlatformVideo };
|
||||
}
|
||||
|
||||
override fun createAdapter(recyclerResults: RecyclerView, context: Context, dataset: ArrayList<IPlatformVideo>): InsertedViewAdapterWithLoader<TrackViewHolder> {
|
||||
return InsertedViewAdapterWithLoader(context, arrayListOf(), arrayListOf(),
|
||||
childCountGetter = { dataset.size },
|
||||
childViewHolderBinder = { viewHolder, position -> viewHolder.bind(dataset[position]); },
|
||||
childViewHolderFactory = { viewGroup, _ ->
|
||||
val holder = TrackViewHolder(viewGroup);
|
||||
holder.onClick.subscribe { c ->
|
||||
|
||||
val playlist = _artist?.toPlaylist();
|
||||
if (playlist != null) {
|
||||
val sameVideo = playlist.videos.find { it.name == c.name };
|
||||
val index = sameVideo?.let {
|
||||
playlist.videos.indexOf(sameVideo)
|
||||
} ?: -1;
|
||||
if (index == -1)
|
||||
return@subscribe;
|
||||
|
||||
StatePlayer.instance.setPlaylist(playlist, index, true);
|
||||
}
|
||||
};
|
||||
holder.onOptions.subscribe {
|
||||
if(it is IPlatformVideo)
|
||||
UISlideOverlays.showVideoOptionsOverlay(it, _overlayContainer);
|
||||
}
|
||||
return@InsertedViewAdapterWithLoader holder;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
override fun createLayoutManager(recyclerResults: RecyclerView, context: Context): GridLayoutManager {
|
||||
val glmResults = GridLayoutManager(context, 1)
|
||||
|
||||
_swipeRefresh.layoutParams = (_swipeRefresh.layoutParams as MarginLayoutParams?)?.apply {
|
||||
rightMargin = TypedValue.applyDimension(
|
||||
TypedValue.COMPLEX_UNIT_DIP,
|
||||
8.0f,
|
||||
context.resources.displayMetrics
|
||||
).toInt()
|
||||
}
|
||||
return glmResults
|
||||
}
|
||||
|
||||
override fun updateSpanCount(){ }
|
||||
|
||||
|
||||
companion object {
|
||||
private const val TAG = "LibraryAlbumsFragmentsView";
|
||||
}
|
||||
}
|
||||
class ArtistAlbumsFragment(private val frag: LibraryArtistFragment) : Fragment(), IArtistTabFragment {
|
||||
|
||||
var view: ArtistAlbumsView? = null;
|
||||
|
||||
private var _lastArtist: Artist? = null;
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
view = ArtistAlbumsView(frag, inflater);
|
||||
_lastArtist?.let {
|
||||
view?.setArtist(it);
|
||||
}
|
||||
return view;
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
view = null;
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
override fun setArtist(artist: Artist) {
|
||||
view?.setArtist(artist);
|
||||
_lastArtist = artist;
|
||||
}
|
||||
}
|
||||
class ArtistAlbumsView : FeedView<LibraryArtistFragment, Album, Album, IPager<Album>, AlbumTileViewHolder> {
|
||||
override val feedStyle: FeedStyle = FeedStyle.THUMBNAIL; //R.layout.list_creator;
|
||||
|
||||
constructor(fragment: LibraryArtistFragment, inflater: LayoutInflater) : super(fragment, inflater)
|
||||
|
||||
fun onShown() {
|
||||
}
|
||||
|
||||
fun setArtist(artist: Artist) {
|
||||
val initialAlbums = artist.getAlbums();
|
||||
Logger.i(TAG, "Initial album count: " + initialAlbums.size);
|
||||
|
||||
setPager(AdhocPager({ listOf() }, initialAlbums));
|
||||
}
|
||||
|
||||
override fun createAdapter(recyclerResults: RecyclerView, context: Context, dataset: ArrayList<Album>): InsertedViewAdapterWithLoader<AlbumTileViewHolder> {
|
||||
return InsertedViewAdapterWithLoader(context, arrayListOf(), arrayListOf(),
|
||||
childCountGetter = { dataset.size },
|
||||
childViewHolderBinder = { viewHolder, position -> viewHolder.bind(dataset[position]); },
|
||||
childViewHolderFactory = { viewGroup, _ ->
|
||||
val holder = AlbumTileViewHolder(viewGroup);
|
||||
holder.setAutoSize(resources.displayMetrics.widthPixels / resources.displayMetrics.density)
|
||||
holder.onClick.subscribe { c -> fragment.navigate<LibraryAlbumFragment>(c) };
|
||||
return@InsertedViewAdapterWithLoader holder;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
override fun updateSpanCount(){ }
|
||||
|
||||
override fun createLayoutManager(recyclerResults: RecyclerView, context: Context): GridLayoutManager {
|
||||
val glmResults = GridLayoutManager(context, AlbumTileViewHolder.getAutoSizeColumns(resources.displayMetrics.widthPixels / resources.displayMetrics.density))
|
||||
|
||||
_swipeRefresh.layoutParams = (_swipeRefresh.layoutParams as MarginLayoutParams?)?.apply {
|
||||
rightMargin = TypedValue.applyDimension(
|
||||
TypedValue.COMPLEX_UNIT_DIP,
|
||||
8.0f,
|
||||
context.resources.displayMetrics
|
||||
).toInt()
|
||||
}
|
||||
|
||||
return glmResults
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "LibraryAlbumsFragmentsView";
|
||||
}
|
||||
}
|
||||
}
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
package com.futo.platformplayer.fragment.mainactivity.main
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.util.TypedValue
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.AdapterView
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.EditText
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.LinearLayout.GONE
|
||||
import android.widget.LinearLayout.VISIBLE
|
||||
import android.widget.Spinner
|
||||
import android.widget.TextView
|
||||
import androidx.core.widget.addTextChangedListener
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.bumptech.glide.Glide
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UISlideOverlays
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
|
||||
import com.futo.platformplayer.api.media.structures.AdhocPager
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.Album
|
||||
import com.futo.platformplayer.states.Artist
|
||||
import com.futo.platformplayer.states.ArtistOrdering
|
||||
import com.futo.platformplayer.states.StateLibrary
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
import com.futo.platformplayer.stores.StringStorage
|
||||
import com.futo.platformplayer.views.FeedStyle
|
||||
import com.futo.platformplayer.views.LibraryTypeHeaderView
|
||||
import com.futo.platformplayer.views.adapters.AnyAdapter
|
||||
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
|
||||
import com.futo.platformplayer.views.adapters.SubscriptionAdapter
|
||||
import com.futo.platformplayer.views.adapters.viewholders.SelectablePlaylist
|
||||
import com.futo.platformplayer.views.others.CreatorThumbnail
|
||||
import com.futo.platformplayer.views.platform.PlatformIndicator
|
||||
|
||||
class LibraryArtistsFragment : MainFragment() {
|
||||
override val isMainView : Boolean = true;
|
||||
override val isTab: Boolean = true;
|
||||
override val hasBottomBar: Boolean get() = true;
|
||||
|
||||
private var _textMeta: TextView? = null;
|
||||
|
||||
var view: FragView? = null;
|
||||
|
||||
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
val view = FragView(this, inflater);
|
||||
this.view = view;
|
||||
return view;
|
||||
}
|
||||
|
||||
override fun onShown(parameter: Any?, isBack: Boolean) {
|
||||
super.onShown(parameter, isBack)
|
||||
}
|
||||
|
||||
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
|
||||
super.onShownWithView(parameter, isBack);
|
||||
view?.onShown();
|
||||
}
|
||||
|
||||
override fun onDestroyMainView() {
|
||||
view = null;
|
||||
super.onDestroyMainView();
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun newInstance() = LibraryArtistsFragment().apply {}
|
||||
}
|
||||
|
||||
class FragView : FeedView<LibraryArtistsFragment, Artist, Artist, IPager<Artist>, ArtistViewHolder> {
|
||||
override val feedStyle: FeedStyle = FeedStyle.THUMBNAIL; //R.layout.list_creator;
|
||||
|
||||
val libraryTypeHeader: LibraryTypeHeaderView;
|
||||
|
||||
constructor(fragment: LibraryArtistsFragment, inflater: LayoutInflater) : super(fragment, inflater) {
|
||||
libraryTypeHeader = LibraryTypeHeaderView(context);
|
||||
libraryTypeHeader.setSelectedType(LibraryTypeHeaderView.SelectedType.Artists);
|
||||
libraryTypeHeader.setMetadata("");
|
||||
|
||||
libraryTypeHeader.onSelectedChanged.subscribe {
|
||||
when(it) {
|
||||
LibraryTypeHeaderView.SelectedType.Albums -> fragment.navigate<LibraryAlbumsFragment>();
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
_toolbarContentView.addView(libraryTypeHeader);
|
||||
disableRefreshLayout();
|
||||
}
|
||||
|
||||
fun onShown() {
|
||||
reload();
|
||||
}
|
||||
|
||||
|
||||
override fun reload() {
|
||||
try {
|
||||
setLoading(true);
|
||||
val intialArtists = StateLibrary.instance.getArtists(ArtistOrdering.Alphabethic);
|
||||
Logger.i(TAG, "Initial album count: " + intialArtists.size);
|
||||
|
||||
libraryTypeHeader.setMetadata("${intialArtists.size} artists");
|
||||
setPager(AdhocPager<Artist>({ listOf(); }, intialArtists));
|
||||
}
|
||||
finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
override fun createAdapter(recyclerResults: RecyclerView, context: Context, dataset: ArrayList<Artist>): InsertedViewAdapterWithLoader<ArtistViewHolder> {
|
||||
return InsertedViewAdapterWithLoader(context, arrayListOf(), arrayListOf(),
|
||||
childCountGetter = { dataset.size },
|
||||
childViewHolderBinder = { viewHolder, position -> viewHolder.bind(dataset[position]); },
|
||||
childViewHolderFactory = { viewGroup, _ ->
|
||||
val holder = ArtistViewHolder(viewGroup);
|
||||
holder.onClick.subscribe { c ->
|
||||
fragment.navigate<LibraryArtistFragment>(c)
|
||||
};
|
||||
return@InsertedViewAdapterWithLoader holder;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
override fun updateSpanCount(){ }
|
||||
|
||||
override fun createLayoutManager(recyclerResults: RecyclerView, context: Context): GridLayoutManager {
|
||||
val glmResults = GridLayoutManager(context, 1)
|
||||
|
||||
_swipeRefresh.layoutParams = (_swipeRefresh.layoutParams as MarginLayoutParams?)?.apply {
|
||||
rightMargin = TypedValue.applyDimension(
|
||||
TypedValue.COMPLEX_UNIT_DIP,
|
||||
8.0f,
|
||||
context.resources.displayMetrics
|
||||
).toInt()
|
||||
}
|
||||
return glmResults
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "LibraryArtistsFragmentsView";
|
||||
}
|
||||
}
|
||||
|
||||
class ArtistViewHolder(private val _viewGroup: ViewGroup) : AnyAdapter.AnyViewHolder<Artist>(
|
||||
LayoutInflater.from(_viewGroup.context).inflate(R.layout.list_artist,
|
||||
_viewGroup, false)) {
|
||||
|
||||
val onClick = Event1<Artist>();
|
||||
|
||||
protected var _artist: Artist? = null;
|
||||
//protected val _imageThumbnail: ImageView
|
||||
protected val _textName: TextView
|
||||
protected val _textMetadata: TextView
|
||||
|
||||
init {
|
||||
//_imageThumbnail = _view.findViewById(R.id.image_thumbnail);
|
||||
_textName = _view.findViewById(R.id.text_name);
|
||||
_textMetadata = _view.findViewById(R.id.text_metadata);
|
||||
|
||||
_view.setOnClickListener { _artist?.let { onClick.emit(it) } };
|
||||
}
|
||||
|
||||
override fun bind(artist: Artist) {
|
||||
_artist = artist;
|
||||
/*
|
||||
_imageThumbnail?.let {
|
||||
if (artist.thumbnail != null)
|
||||
Glide.with(it)
|
||||
.load(artist.thumbnail)
|
||||
.placeholder(R.drawable.placeholder_channel_thumbnail)
|
||||
.into(it)
|
||||
else
|
||||
Glide.with(it).load(R.drawable.placeholder_channel_thumbnail).into(it);
|
||||
};
|
||||
*/
|
||||
|
||||
_textName.text = artist.name;
|
||||
|
||||
val metaComps = listOf(
|
||||
artist.countTracks?.let { "${it} tracks" },
|
||||
artist.countAlbums?.let { "${it} albums" }
|
||||
).filterNotNull();
|
||||
|
||||
_textMetadata.text = metaComps.joinToString(", ");
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
package com.futo.platformplayer.fragment.mainactivity.main
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.util.TypedValue
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.bumptech.glide.Glide
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.api.media.models.video.LocalVideoDetails
|
||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
||||
import com.futo.platformplayer.api.media.structures.AdhocPager
|
||||
import com.futo.platformplayer.api.media.structures.EmptyPager
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.fragment.mainactivity.topbar.FilesTopBarFragment
|
||||
import com.futo.platformplayer.models.Playlist
|
||||
import com.futo.platformplayer.states.FileEntry
|
||||
import com.futo.platformplayer.states.StateLibrary
|
||||
import com.futo.platformplayer.states.StatePlayer
|
||||
import com.futo.platformplayer.views.FeedStyle
|
||||
import com.futo.platformplayer.views.NoResultsView
|
||||
import com.futo.platformplayer.views.adapters.AnyAdapter
|
||||
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
|
||||
import com.futo.platformplayer.views.adapters.viewholders.FileViewHolder
|
||||
import com.futo.platformplayer.views.buttons.BigButton
|
||||
import com.futo.platformplayer.views.buttons.ButtonsContainer
|
||||
|
||||
class LibraryFilesFragment : MainFragment() {
|
||||
override val isMainView : Boolean = true;
|
||||
override val isTab: Boolean = true;
|
||||
override val hasBottomBar: Boolean get() = true;
|
||||
|
||||
|
||||
var view: FragView? = null;
|
||||
|
||||
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
val view = FragView(this, inflater);
|
||||
this.view = view;
|
||||
return view;
|
||||
}
|
||||
|
||||
override fun onShown(parameter: Any?, isBack: Boolean) {
|
||||
super.onShown(parameter, isBack)
|
||||
}
|
||||
|
||||
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
|
||||
super.onShownWithView(parameter, isBack);
|
||||
view?.onShown(parameter);
|
||||
}
|
||||
|
||||
override fun onDestroyMainView() {
|
||||
view = null;
|
||||
super.onDestroyMainView();
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun newInstance() = LibraryFilesFragment().apply {}
|
||||
}
|
||||
|
||||
class FragView : FeedView<LibraryFilesFragment, FileEntry, FileEntry, IPager<FileEntry>, FileViewHolder> {
|
||||
override val feedStyle: FeedStyle = FeedStyle.THUMBNAIL; //R.layout.list_creator;
|
||||
|
||||
val navStack = mutableListOf<FileStack>()
|
||||
var buttonUp: BigButton? = null;
|
||||
var buttonAdd: BigButton? = null;
|
||||
|
||||
private var root: FileEntry? = null;
|
||||
|
||||
constructor(fragment: LibraryFilesFragment, inflater: LayoutInflater) : super(fragment, inflater) {
|
||||
disableRefreshLayout();
|
||||
}
|
||||
|
||||
fun onShown(parameter: Any? = null) {
|
||||
this.root = if(parameter is FileEntry) parameter else null;
|
||||
loadTop();
|
||||
}
|
||||
fun loadTop() {
|
||||
var initialDirectories = listOf<FileEntry>();
|
||||
var path = "";
|
||||
if(root == null) {
|
||||
initialDirectories = StateLibrary.instance.getFileDirectories();
|
||||
if (initialDirectories.size == 0) {
|
||||
setEmptyPager(true);
|
||||
setPager(EmptyPager());
|
||||
buttonAdd?.let {
|
||||
it.isVisible = false;
|
||||
}
|
||||
buttonUp?.let {
|
||||
it.isVisible = false;
|
||||
}
|
||||
return;
|
||||
} else
|
||||
setEmptyPager(false);
|
||||
}
|
||||
else {
|
||||
buttonAdd?.let {
|
||||
it.isVisible = false;
|
||||
}
|
||||
buttonUp?.let {
|
||||
it.isVisible = false;
|
||||
}
|
||||
initialDirectories = root?.getSubFiles() ?: listOf();
|
||||
path = root?.path ?: "";
|
||||
}
|
||||
navStack.clear();
|
||||
val entry = FileStack(path, initialDirectories);
|
||||
navStack.add(entry);
|
||||
openDirectory(navStack.last());
|
||||
fragment.topBar?.let {
|
||||
if(it is FilesTopBarFragment) {
|
||||
it.setUpNavigate(null);
|
||||
it.setTitle(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
fun leaveDirectory() {
|
||||
if (navStack.size > 1) {
|
||||
navStack.removeAt(navStack.size - 1)
|
||||
openDirectory(navStack.last())
|
||||
}
|
||||
}
|
||||
fun openDirectory(stack: FileStack, addToStack: Boolean = false) {
|
||||
if(addToStack)
|
||||
navStack.add(stack);
|
||||
|
||||
fragment.topBar?.let {
|
||||
if(it is FilesTopBarFragment) {
|
||||
it.setTitle(stack);
|
||||
}
|
||||
}
|
||||
|
||||
buttonAdd?.let {
|
||||
it.isVisible = navStack.size < 2
|
||||
}
|
||||
buttonUp?.let {
|
||||
it.isVisible = navStack.size > 1;
|
||||
}
|
||||
setPager(AdhocPager<FileEntry>({ listOf(); }, stack.files));
|
||||
setLoading(false);
|
||||
|
||||
val allSongs = stack.files.filter { !it.isDirectory };
|
||||
if(allSongs.any()) {
|
||||
_bottomContentView.addView(ButtonsContainer(context,
|
||||
listOf(
|
||||
ButtonsContainer.Button("Play All", R.drawable.background_button_primary) {
|
||||
StatePlayer.instance.setPlaylist(Playlist(stack.path.toUri().lastPathSegment ?: "", allSongs.map {
|
||||
SerializedPlatformVideo.fromVideo(LocalVideoDetails.fromContent(it.path))
|
||||
}), focus = true, shuffle = false)
|
||||
},
|
||||
ButtonsContainer.Button("Shuffle", R.drawable.background_button_accent) {
|
||||
StatePlayer.instance.setPlaylist(Playlist(stack.path.toUri().lastPathSegment ?: "", allSongs.map {
|
||||
SerializedPlatformVideo.fromVideo(LocalVideoDetails.fromContent(it.path))
|
||||
}), focus = true, shuffle = true)
|
||||
}
|
||||
)).apply {
|
||||
this.layoutParams = LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
|
||||
})
|
||||
}
|
||||
else
|
||||
_bottomContentView.removeAllViews();
|
||||
|
||||
fragment.topBar?.let {
|
||||
if(it is FilesTopBarFragment) {
|
||||
if(navStack.size > 1)
|
||||
it.setUpNavigate{
|
||||
leaveDirectory();
|
||||
};
|
||||
else it.setUpNavigate(null);
|
||||
it.setTitle(stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setBack() {
|
||||
fragment.topBar?.view
|
||||
}
|
||||
|
||||
override fun getEmptyPagerView(): View? {
|
||||
return NoResultsView(context, "No Directories Added",
|
||||
"To see files in Grayjay you have to add directories to view",
|
||||
R.drawable.ic_library, listOf(
|
||||
BigButton(context, "Add Directory", "Select a directory to add", R.drawable.ic_add, {
|
||||
StateLibrary.instance.addFileDirectory({
|
||||
loadTop();
|
||||
}, true);
|
||||
})
|
||||
))
|
||||
}
|
||||
|
||||
override fun createAdapter(recyclerResults: RecyclerView, context: Context, dataset: ArrayList<FileEntry>): InsertedViewAdapterWithLoader<FileViewHolder> {
|
||||
/*
|
||||
val buttonUp = BigButton(fragment.requireContext(), "Go up", "Go up a directory", R.drawable.ic_move_up) {
|
||||
if(navStack.size > 1)
|
||||
leaveDirectory();
|
||||
}
|
||||
val buttonAdd = BigButton(fragment.requireContext(), "Add Directory", "Select a directory to add", R.drawable.ic_add) {
|
||||
StateLibrary.instance.addFileDirectory {
|
||||
loadTop();
|
||||
};
|
||||
}
|
||||
*/
|
||||
//this.buttonUp = buttonUp;
|
||||
//this.buttonAdd = buttonAdd;
|
||||
return InsertedViewAdapterWithLoader(context, arrayListOf(), arrayListOf(),
|
||||
childCountGetter = { dataset.size },
|
||||
childViewHolderBinder = { viewHolder, position -> viewHolder.bind(dataset[position]); },
|
||||
childViewHolderFactory = { viewGroup, _ ->
|
||||
val holder = FileViewHolder(viewGroup);
|
||||
holder.onClick.subscribe { c ->
|
||||
if (c != null) {
|
||||
if(c.isDirectory) {
|
||||
openDirectory(FileStack(c.path, c.getSubFiles()), true);
|
||||
} else {
|
||||
fragment.navigate<VideoDetailFragment>(c.path)
|
||||
}
|
||||
}
|
||||
};
|
||||
holder.onDelete.subscribe { c ->
|
||||
if(c != null) {
|
||||
StateLibrary.instance.deleteFileDirectory(c.path);
|
||||
loadTop();
|
||||
}
|
||||
}
|
||||
return@InsertedViewAdapterWithLoader holder;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
override fun updateSpanCount(){ }
|
||||
|
||||
override fun createLayoutManager(recyclerResults: RecyclerView, context: Context): GridLayoutManager {
|
||||
val glmResults = GridLayoutManager(context, 1)
|
||||
|
||||
_swipeRefresh.layoutParams = (_swipeRefresh.layoutParams as MarginLayoutParams?)?.apply {
|
||||
rightMargin = TypedValue.applyDimension(
|
||||
TypedValue.COMPLEX_UNIT_DIP,
|
||||
8.0f,
|
||||
context.resources.displayMetrics
|
||||
).toInt()
|
||||
}
|
||||
|
||||
return glmResults
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "LibraryAlbumsFragmentsView";
|
||||
}
|
||||
}
|
||||
class FileStack(
|
||||
val path: String,
|
||||
val files: List<FileEntry>
|
||||
)
|
||||
|
||||
}
|
||||
+393
@@ -0,0 +1,393 @@
|
||||
package com.futo.platformplayer.fragment.mainactivity.main
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.MediaStore
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.collection.emptyLongSet
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.dp
|
||||
import com.futo.platformplayer.states.Album
|
||||
import com.futo.platformplayer.states.Artist
|
||||
import com.futo.platformplayer.states.ArtistOrdering
|
||||
import com.futo.platformplayer.states.FileEntry
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateLibrary
|
||||
import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny
|
||||
import com.futo.platformplayer.views.AnyInsertedAdapterView
|
||||
import com.futo.platformplayer.views.AnyInsertedAdapterView.Companion.asAnyWithTop
|
||||
import com.futo.platformplayer.views.AnyInsertedAdapterView.Companion.asAnyWithViews
|
||||
import com.futo.platformplayer.views.LibrarySection
|
||||
import com.futo.platformplayer.views.NoResultsView
|
||||
import com.futo.platformplayer.views.adapters.AnyAdapter
|
||||
import com.futo.platformplayer.views.adapters.InsertedViewAdapter
|
||||
import com.futo.platformplayer.views.adapters.viewholders.AlbumTileViewHolder
|
||||
import com.futo.platformplayer.views.adapters.viewholders.ArtistTileViewHolder
|
||||
import com.futo.platformplayer.views.adapters.viewholders.FileViewHolder
|
||||
import com.futo.platformplayer.views.adapters.viewholders.LocalVideoTileViewHolder
|
||||
import com.futo.platformplayer.views.buttons.BigButton
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.Dispatcher
|
||||
|
||||
|
||||
class LibraryFragment : MainFragment() {
|
||||
override val isMainView : Boolean = true;
|
||||
override val isTab: Boolean = true;
|
||||
override val hasBottomBar: Boolean get() = true;
|
||||
|
||||
private var view: FragView? = null;
|
||||
|
||||
private var allowedMusic = false;
|
||||
private var allowedVideo = false;
|
||||
|
||||
|
||||
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): android.view.View {
|
||||
val newView = FragView(this, allowedMusic, allowedVideo);
|
||||
view = newView;
|
||||
return newView;
|
||||
}
|
||||
|
||||
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
|
||||
super.onShownWithView(parameter, isBack);
|
||||
view?.onShown();
|
||||
|
||||
requestPermissionMusic();
|
||||
requestPermissionVideo();
|
||||
}
|
||||
|
||||
override fun onDestroyMainView() {
|
||||
view = null;
|
||||
super.onDestroyMainView();
|
||||
}
|
||||
|
||||
fun setPermissionResultAudio(access: Boolean) {
|
||||
allowedMusic = access;
|
||||
view?.setMusicPermissions(access);
|
||||
StateApp.instance.hasMediaStoreAudioPermission = (access);
|
||||
}
|
||||
fun setPermissionResultVideo(access: Boolean) {
|
||||
allowedVideo = access;
|
||||
view?.setVideoPermissions(access);
|
||||
StateApp.instance.hasMediaStoreVideoPermission = (access);
|
||||
}
|
||||
|
||||
fun requestPermissionMusic() {
|
||||
when {
|
||||
ContextCompat.checkSelfPermission(requireContext(), android.Manifest.permission.READ_MEDIA_AUDIO) == PackageManager.PERMISSION_GRANTED -> {
|
||||
setPermissionResultAudio(true);
|
||||
}
|
||||
ActivityCompat.shouldShowRequestPermissionRationale(requireActivity(), android.Manifest.permission.READ_MEDIA_AUDIO) -> {
|
||||
UIDialogs.showDialog(requireContext(), R.drawable.ic_library,
|
||||
"Music permissions", "We require permissions to see your on-device music, denying this will hide the option to see local music.", null, 1,
|
||||
UIDialogs.Action("Ok", {
|
||||
StateApp?.instance?.activity?.requestPermissionAudio {
|
||||
setPermissionResultAudio(it);
|
||||
}
|
||||
}, UIDialogs.ActionStyle.PRIMARY),
|
||||
UIDialogs.Action("Cancel", {
|
||||
|
||||
}, UIDialogs.ActionStyle.NONE));
|
||||
}
|
||||
else -> {
|
||||
StateApp?.instance?.activity?.requestPermissionAudio {
|
||||
setPermissionResultAudio(it);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fun requestPermissionVideo() {
|
||||
when {
|
||||
ContextCompat.checkSelfPermission(requireContext(), android.Manifest.permission.READ_MEDIA_VIDEO) == PackageManager.PERMISSION_GRANTED -> {
|
||||
setPermissionResultVideo(true);
|
||||
}
|
||||
ActivityCompat.shouldShowRequestPermissionRationale(requireActivity(), android.Manifest.permission.READ_MEDIA_VIDEO) -> {
|
||||
UIDialogs.showDialog(requireContext(), R.drawable.ic_library, false,
|
||||
"Videos permissions", "We require permissions to see your on-device videos, denying this will hide the option to see local videos.", null, 1,
|
||||
UIDialogs.Action("Ok", {
|
||||
StateApp?.instance?.activity?.requestPermissionVideo {
|
||||
setPermissionResultVideo(it);
|
||||
}
|
||||
}, UIDialogs.ActionStyle.PRIMARY),
|
||||
UIDialogs.Action("Cancel", {
|
||||
|
||||
}, UIDialogs.ActionStyle.NONE));
|
||||
}
|
||||
else -> {
|
||||
StateApp?.instance?.activity?.requestPermissionVideo {
|
||||
setPermissionResultVideo(it);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
fun newInstance() = LibraryFragment().apply {}
|
||||
}
|
||||
|
||||
|
||||
class FragView: ConstraintLayout {
|
||||
val fragment: LibraryFragment;
|
||||
|
||||
var sectionArtists: LibrarySection;
|
||||
var sectionAlbums: LibrarySection;
|
||||
var sectionVideos: LibrarySection;
|
||||
var sectionFiles: LibrarySection;
|
||||
var noContent: NoResultsView;
|
||||
//var buttonFiles: BigButton;
|
||||
|
||||
val recycler: RecyclerView;
|
||||
|
||||
var adapterFiles: AnyInsertedAdapterView<FileEntry, FileViewHolder>? = null;
|
||||
|
||||
//var metaInfo: TextView;
|
||||
|
||||
var allowMusic: Boolean = false;
|
||||
var allowVideo: Boolean = false;
|
||||
|
||||
constructor(fragment: LibraryFragment, allowMusic: Boolean?, allowVideo: Boolean?) : super(fragment.requireContext()) {
|
||||
inflate(context, R.layout.fragview_library, this);
|
||||
this.fragment = fragment;
|
||||
recycler = findViewById(R.id.recycler);
|
||||
sectionArtists = LibrarySection(context)//findViewById<LibrarySection>(R.id.section_artists);
|
||||
sectionArtists.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 140.dp(resources)).apply {
|
||||
this.setMargins(0,10.dp(resources), 0, 0);
|
||||
}
|
||||
sectionAlbums = LibrarySection(context)//findViewById(R.id.section_albums);
|
||||
sectionAlbums.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 185.dp(resources)).apply {
|
||||
this.setMargins(0,0, 0, 0);
|
||||
}
|
||||
sectionVideos = LibrarySection(context)//findViewById(R.id.section_videos);
|
||||
sectionVideos.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 170.dp(resources)).apply {
|
||||
this.setMargins(0,0, 0, 0);
|
||||
}
|
||||
sectionFiles = LibrarySection(context)//findViewById(R.id.section_videos);
|
||||
sectionFiles.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 40.dp(resources)).apply {
|
||||
this.setMargins(0,0, 0, 0);
|
||||
}
|
||||
sectionFiles.setSection("Directories") {
|
||||
StateLibrary.instance.addFileDirectory({
|
||||
reloadFiles();
|
||||
}, true)
|
||||
}
|
||||
sectionFiles.setNavIcon(R.drawable.ic_add);
|
||||
//buttonFiles = findViewById<BigButton>(R.id.button_files);
|
||||
//metaInfo = findViewById(R.id.meta_info);
|
||||
|
||||
noContent = NoResultsView(context, "No directories", "No directories have been added.\nAdd them using the (+) icon.", -1, listOf());
|
||||
noContent.isVisible = false;
|
||||
|
||||
this.allowMusic = allowMusic ?: false;
|
||||
this.allowVideo = allowVideo ?: false;
|
||||
|
||||
sectionArtists.setSection("Artists", {
|
||||
if(this.allowMusic)
|
||||
fragment.navigate<LibraryArtistsFragment>();
|
||||
else
|
||||
fragment.requestPermissionMusic();
|
||||
});
|
||||
|
||||
sectionAlbums.setSection("Albums", {
|
||||
if(this.allowMusic)
|
||||
fragment.navigate<LibraryAlbumsFragment>();
|
||||
else
|
||||
fragment.requestPermissionMusic();
|
||||
});
|
||||
|
||||
|
||||
sectionVideos.setSection("Videos", {
|
||||
if(this.allowVideo)
|
||||
fragment.navigate<LibraryVideosFragment>();
|
||||
else
|
||||
fragment.requestPermissionVideo();
|
||||
});
|
||||
|
||||
reloadLibraryUI();
|
||||
|
||||
|
||||
/*
|
||||
buttonFiles.onClick.subscribe {
|
||||
fragment.navigate<LibraryFilesFragment>()
|
||||
} */
|
||||
//buttonFiles.setButtonEnabled(false);
|
||||
setMusicPermissions(allowMusic ?: false);
|
||||
setVideoPermissions(allowVideo ?: false);
|
||||
}
|
||||
|
||||
fun reloadFiles() {
|
||||
val files = StateLibrary.instance.getFileDirectories();
|
||||
adapterFiles?.setData(files);
|
||||
if(files.size == 0) {
|
||||
noContent.isVisible = true;
|
||||
}
|
||||
else
|
||||
noContent.isVisible = false;
|
||||
}
|
||||
|
||||
fun reloadLibraryUI() {
|
||||
|
||||
val adapterAlbums = sectionAlbums.getAnyAdapter<Album, AlbumTileViewHolder>({
|
||||
it.onClick.subscribe {
|
||||
if(it != null)
|
||||
fragment.navigate<LibraryAlbumFragment>(it);
|
||||
}
|
||||
});
|
||||
val adapterArtists = sectionArtists.getAnyAdapter<Artist, ArtistTileViewHolder>({
|
||||
it.onClick.subscribe {
|
||||
if(it != null)
|
||||
fragment.navigate<LibraryArtistFragment>(it);
|
||||
}
|
||||
});
|
||||
val adapterVideos = sectionVideos.getAnyAdapter<IPlatformVideo, LocalVideoTileViewHolder>({
|
||||
it.onClick.subscribe {
|
||||
if(it != null)
|
||||
fragment.navigate<VideoDetailFragment>(it);
|
||||
}
|
||||
});
|
||||
|
||||
if(this.allowMusic) {
|
||||
val artists = StateLibrary.instance.getArtists(ArtistOrdering.TrackCount);
|
||||
adapterArtists.setData(artists);
|
||||
if (artists.size == 0)
|
||||
sectionArtists.setEmpty(
|
||||
"No artists",
|
||||
"No artists were found on your device",
|
||||
-1
|
||||
);
|
||||
else
|
||||
sectionArtists.clearEmpty();
|
||||
}
|
||||
else if(Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
sectionAlbums.isVisible = false;
|
||||
}
|
||||
else {
|
||||
sectionArtists.setEmpty(
|
||||
"No Music Permissions",
|
||||
"You have not granted music access permissions to Grayjay",
|
||||
-1
|
||||
);
|
||||
}
|
||||
|
||||
if(this.allowMusic) {
|
||||
val albums = StateLibrary.instance.getAlbums();
|
||||
adapterAlbums.setData(albums);
|
||||
if (albums.size == 0)
|
||||
sectionAlbums.setEmpty("No albums", "No albums were found on your device", -1);
|
||||
else
|
||||
sectionAlbums.clearEmpty();
|
||||
}
|
||||
else if(Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
sectionArtists.isVisible = false;
|
||||
}
|
||||
else {
|
||||
sectionAlbums.setEmpty(
|
||||
"No Music Permissions",
|
||||
"You have not granted music access permissions to Grayjay",
|
||||
-1
|
||||
);
|
||||
}
|
||||
|
||||
if(this.allowVideo) {
|
||||
val videos = StateLibrary.instance.getRecentVideos(null, 20);
|
||||
adapterVideos.setData(videos);
|
||||
if (videos.size == 0)
|
||||
sectionVideos.setEmpty("No videos", "No videos were found on your device", -1);
|
||||
else
|
||||
sectionVideos.clearEmpty();
|
||||
}
|
||||
else if(Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
sectionVideos.isVisible = false;
|
||||
}
|
||||
else {
|
||||
sectionVideos.setEmpty(
|
||||
"No Video Permissions",
|
||||
"You have not granted video access permissions to Grayjay",
|
||||
-1
|
||||
);
|
||||
}
|
||||
|
||||
adapterFiles = recycler.asAnyWithViews<FileEntry, FileViewHolder>(
|
||||
arrayListOf(
|
||||
sectionArtists,
|
||||
sectionAlbums,
|
||||
sectionVideos,
|
||||
sectionFiles,
|
||||
noContent
|
||||
),
|
||||
arrayListOf(View(context).apply { this.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 20.dp(resources)) }),
|
||||
RecyclerView.VERTICAL, false, {
|
||||
it.onClick.subscribe {
|
||||
if(it != null)
|
||||
fragment.navigate<LibraryFilesFragment>(it);
|
||||
}
|
||||
it.onDelete.subscribe {
|
||||
if(it != null) {
|
||||
StateLibrary.instance.deleteFileDirectory(it.path);
|
||||
reloadFiles();
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
reloadFiles();
|
||||
}
|
||||
|
||||
fun setMusicPermissions(access: Boolean) {
|
||||
allowMusic = access;
|
||||
sectionAlbums.setContentEmptyMessage(R.drawable.ic_library, "No mediastore permissions");
|
||||
sectionArtists.setContentEmptyMessage(R.drawable.ic_library, "No mediastore permissions");
|
||||
//buttonArtists.setButtonEnabled(access);
|
||||
//metaInfo.text = listOf(
|
||||
// if(!allowMusic) "You did not give access to local music, so these options are disabled" else null,
|
||||
// if(!allowVideo) "You did not give access to local videos, so these options are disabled" else null
|
||||
//).filterNotNull().joinToString("\n");
|
||||
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
reloadLibraryUI();
|
||||
}
|
||||
}
|
||||
fun setVideoPermissions(access: Boolean) {
|
||||
allowVideo = access;
|
||||
sectionVideos.setContentEmptyMessage(R.drawable.ic_library, "No video permissions");
|
||||
//metaInfo.text = listOf(
|
||||
// if(!allowMusic) "You did not give access to local music, so these options are disabled" else null,
|
||||
// if(!allowVideo) "You did not give access to local videos, so these options are disabled" else null
|
||||
//).filterNotNull().joinToString("\n");
|
||||
// }
|
||||
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
reloadLibraryUI();
|
||||
}
|
||||
}
|
||||
|
||||
fun onShown() {
|
||||
if(didShowAlpha)
|
||||
return;
|
||||
didShowAlpha = true;
|
||||
UIDialogs.appToast("Library is in alpha\nImprovements are coming to local media playback.")
|
||||
}
|
||||
companion object {
|
||||
var didShowAlpha: Boolean = false;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+233
@@ -0,0 +1,233 @@
|
||||
package com.futo.platformplayer.fragment.mainactivity.main
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.util.TypedValue
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.AdapterView
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.EditText
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.LinearLayout.GONE
|
||||
import android.widget.LinearLayout.VISIBLE
|
||||
import android.widget.Spinner
|
||||
import android.widget.TextView
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.widget.addTextChangedListener
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.bumptech.glide.Glide
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UISlideOverlays
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.api.media.structures.AdhocPager
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.dp
|
||||
import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment
|
||||
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.Album
|
||||
import com.futo.platformplayer.states.Artist
|
||||
import com.futo.platformplayer.states.ArtistOrdering
|
||||
import com.futo.platformplayer.states.FileEntry
|
||||
import com.futo.platformplayer.states.StateLibrary
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
import com.futo.platformplayer.stores.StringStorage
|
||||
import com.futo.platformplayer.views.AnyAdapterView
|
||||
import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny
|
||||
import com.futo.platformplayer.views.AnyInsertedAdapterView
|
||||
import com.futo.platformplayer.views.AnyInsertedAdapterView.Companion.asAnyWithViews
|
||||
import com.futo.platformplayer.views.FeedStyle
|
||||
import com.futo.platformplayer.views.LibrarySection
|
||||
import com.futo.platformplayer.views.LibraryTypeHeaderView
|
||||
import com.futo.platformplayer.views.LibraryTypeHeaderView.SelectedType
|
||||
import com.futo.platformplayer.views.PillV2
|
||||
import com.futo.platformplayer.views.adapters.AnyAdapter
|
||||
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
|
||||
import com.futo.platformplayer.views.adapters.SubscriptionAdapter
|
||||
import com.futo.platformplayer.views.adapters.viewholders.AlbumTileViewHolder
|
||||
import com.futo.platformplayer.views.adapters.viewholders.ArtistTileViewHolder
|
||||
import com.futo.platformplayer.views.adapters.viewholders.FileViewHolder
|
||||
import com.futo.platformplayer.views.adapters.viewholders.LocalVideoTileViewHolder
|
||||
import com.futo.platformplayer.views.adapters.viewholders.SelectablePlaylist
|
||||
import com.futo.platformplayer.views.adapters.viewholders.TrackViewHolder
|
||||
import com.futo.platformplayer.views.others.CreatorThumbnail
|
||||
import com.futo.platformplayer.views.platform.PlatformIndicator
|
||||
|
||||
class LibrarySearchFragment : MainFragment() {
|
||||
override val isMainView : Boolean = true;
|
||||
override val isTab: Boolean = true;
|
||||
override val hasBottomBar: Boolean get() = true;
|
||||
|
||||
|
||||
var view: FragView? = null;
|
||||
|
||||
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
val view = FragView(this);
|
||||
this.view = view;
|
||||
return view;
|
||||
}
|
||||
|
||||
override fun onShown(parameter: Any?, isBack: Boolean) {
|
||||
super.onShown(parameter, isBack)
|
||||
}
|
||||
|
||||
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
|
||||
super.onShownWithView(parameter, isBack);
|
||||
view?.onShown();
|
||||
}
|
||||
|
||||
override fun onDestroyMainView() {
|
||||
view = null;
|
||||
super.onDestroyMainView();
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun newInstance() = LibrarySearchFragment().apply {}
|
||||
}
|
||||
|
||||
|
||||
class FragView: ConstraintLayout {
|
||||
val fragment: LibrarySearchFragment;
|
||||
|
||||
val pillArtist: PillV2;
|
||||
val pillAlbums: PillV2;
|
||||
val pillSongs: PillV2;
|
||||
val pills: List<PillV2>;
|
||||
|
||||
val textMetadata: TextView;
|
||||
|
||||
val recycler: RecyclerView;
|
||||
|
||||
val adapterArtists: AnyAdapterView<Artist, LibraryArtistsFragment.ArtistViewHolder>;
|
||||
val adapterSongs: AnyAdapterView<IPlatformContent, TrackViewHolder>;
|
||||
val adapterAlbums: AnyAdapterView<Album, LibraryAlbumsFragment.AlbumViewHolder>;
|
||||
|
||||
|
||||
|
||||
constructor(fragment: LibrarySearchFragment) : super(fragment.requireContext()) {
|
||||
inflate(context, R.layout.fragview_library_search, this);
|
||||
this.fragment = fragment;
|
||||
|
||||
pillArtist = findViewById(R.id.pill_artist);
|
||||
pillAlbums = findViewById(R.id.pill_albums);
|
||||
pillSongs = findViewById(R.id.pill_songs);
|
||||
pills = listOf(pillArtist, pillAlbums, pillSongs);
|
||||
|
||||
textMetadata = findViewById(R.id.text_metadata);
|
||||
|
||||
pillArtist.onClick.subscribe {
|
||||
pills.forEach { it.setIsEnabled(false) };
|
||||
pillArtist.setIsEnabled(true);
|
||||
loadArtists();
|
||||
}
|
||||
pillAlbums.onClick.subscribe {
|
||||
pills.forEach { it.setIsEnabled(false) };
|
||||
pillAlbums.setIsEnabled(true);
|
||||
loadAlbums();
|
||||
}
|
||||
pillSongs.onClick.subscribe {
|
||||
pills.forEach { it.setIsEnabled(false) };
|
||||
pillSongs.setIsEnabled(true);
|
||||
loadSongs();
|
||||
}
|
||||
|
||||
recycler = findViewById(R.id.recycler);
|
||||
adapterArtists = recycler.asAny<Artist, LibraryArtistsFragment.ArtistViewHolder>(RecyclerView.VERTICAL, false, {
|
||||
it.onClick.subscribe {
|
||||
if(it != null)
|
||||
fragment.navigate<LibraryArtistFragment>(it);
|
||||
}
|
||||
});
|
||||
adapterAlbums = recycler.asAny<Album, LibraryAlbumsFragment.AlbumViewHolder>(RecyclerView.VERTICAL, false, {
|
||||
it.onClick.subscribe {
|
||||
if(it != null)
|
||||
fragment.navigate<LibraryAlbumFragment>(it);
|
||||
}
|
||||
});
|
||||
adapterSongs = recycler.asAny<IPlatformContent, TrackViewHolder>(RecyclerView.VERTICAL, false, {
|
||||
it.onClick.subscribe {
|
||||
if(it != null && it is IPlatformVideo)
|
||||
fragment.navigate<VideoDetailFragment>(it);
|
||||
}
|
||||
});
|
||||
|
||||
fragment.topBar?.let {
|
||||
if(it is SearchTopBarFragment) {
|
||||
it.onSearch.subscribe {
|
||||
search(it);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pillArtist.setIsEnabled(true);
|
||||
loadArtists();
|
||||
}
|
||||
|
||||
fun loadArtists(){
|
||||
recycler.adapter = adapterArtists.adapter.adapter;
|
||||
fragment.topBar?.let {
|
||||
if(it is SearchTopBarFragment)
|
||||
search(it.getSearchText());
|
||||
}
|
||||
}
|
||||
fun loadAlbums() {
|
||||
recycler.adapter = adapterAlbums.adapter.adapter;
|
||||
fragment.topBar?.let {
|
||||
if(it is SearchTopBarFragment)
|
||||
search(it.getSearchText());
|
||||
}
|
||||
}
|
||||
fun loadSongs() {
|
||||
recycler.adapter = adapterSongs.adapter.adapter;
|
||||
fragment.topBar?.let {
|
||||
if(it is SearchTopBarFragment)
|
||||
search(it.getSearchText());
|
||||
}
|
||||
}
|
||||
|
||||
fun search(str: String) {
|
||||
if(recycler.adapter == adapterArtists.adapter.adapter) {
|
||||
val data = if(!str.isNullOrBlank())
|
||||
StateLibrary.instance.searchArtists(str)
|
||||
else listOf();
|
||||
adapterArtists.setData(data);
|
||||
textMetadata.text = "${data.size} artists";
|
||||
}
|
||||
else if(recycler.adapter == adapterAlbums.adapter.adapter) {
|
||||
val data = if(!str.isNullOrBlank())
|
||||
StateLibrary.instance.searchAlbums(str)
|
||||
else listOf();
|
||||
adapterAlbums.setData(data);
|
||||
textMetadata.text = "${data.size} albums";
|
||||
}
|
||||
else if(recycler.adapter == adapterSongs.adapter.adapter) {
|
||||
val data = if(!str.isNullOrBlank())
|
||||
StateLibrary.instance.searchTracks(str)
|
||||
else listOf();
|
||||
|
||||
adapterSongs.setData(data);
|
||||
textMetadata.text = "${data.size} songs";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun onShown() {
|
||||
fragment.topBar?.let {
|
||||
if(it is SearchTopBarFragment)
|
||||
it.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
package com.futo.platformplayer.fragment.mainactivity.main
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.util.TypedValue
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.AdapterView
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.EditText
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout.GONE
|
||||
import android.widget.LinearLayout.VISIBLE
|
||||
import android.widget.Spinner
|
||||
import android.widget.TextView
|
||||
import androidx.core.view.allViews
|
||||
import androidx.core.widget.addTextChangedListener
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.bumptech.glide.Glide
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.UISlideOverlays
|
||||
import com.futo.platformplayer.UISlideOverlays.Companion.showOrderOverlay
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.structures.AdhocPager
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.Album
|
||||
import com.futo.platformplayer.states.StateLibrary
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
import com.futo.platformplayer.stores.StringArrayStorage
|
||||
import com.futo.platformplayer.stores.StringStorage
|
||||
import com.futo.platformplayer.views.FeedStyle
|
||||
import com.futo.platformplayer.views.ToggleBar
|
||||
import com.futo.platformplayer.views.adapters.AnyAdapter
|
||||
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
|
||||
import com.futo.platformplayer.views.adapters.SubscriptionAdapter
|
||||
import com.futo.platformplayer.views.adapters.viewholders.SelectablePlaylist
|
||||
import com.futo.platformplayer.views.others.CreatorThumbnail
|
||||
import com.futo.platformplayer.views.platform.PlatformIndicator
|
||||
|
||||
class LibraryVideosFragment : MainFragment() {
|
||||
override val isMainView : Boolean = true;
|
||||
override val isTab: Boolean = true;
|
||||
override val hasBottomBar: Boolean get() = true;
|
||||
|
||||
private var _toggleBuckets = StateLibrary.instance.getVideoBucketNames().map { it.name }.toMutableList();
|
||||
|
||||
var view: FragView? = null;
|
||||
|
||||
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
val view = FragView(this, inflater);
|
||||
this.view = view;
|
||||
return view;
|
||||
}
|
||||
|
||||
override fun onShown(parameter: Any?, isBack: Boolean) {
|
||||
super.onShown(parameter, isBack)
|
||||
}
|
||||
|
||||
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
|
||||
super.onShownWithView(parameter, isBack);
|
||||
view?.onShown();
|
||||
}
|
||||
|
||||
override fun onDestroyMainView() {
|
||||
view = null;
|
||||
super.onDestroyMainView();
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun newInstance() = LibraryVideosFragment().apply {}
|
||||
}
|
||||
|
||||
class FragView : ContentFeedView<LibraryVideosFragment> {
|
||||
override val feedStyle: FeedStyle = FeedStyle.THUMBNAIL; //R.layout.list_creator;
|
||||
|
||||
private var _toggleBar: ToggleBar? = null;
|
||||
|
||||
constructor(fragment: LibraryVideosFragment, inflater: LayoutInflater) : super(fragment, inflater) {
|
||||
initializeToolbarContent();
|
||||
disableRefreshLayout();
|
||||
}
|
||||
|
||||
fun onShown() {
|
||||
val initialAlbums = StateLibrary.instance.getAlbums();
|
||||
Logger.i(TAG, "Initial album count: " + initialAlbums.size);
|
||||
val buckets = StateLibrary.instance.getVideoBucketNames();
|
||||
setPager(StateLibrary.instance.getVideos(fragment._toggleBuckets));
|
||||
}
|
||||
|
||||
|
||||
private val _filterLock = Object();
|
||||
fun initializeToolbarContent() {
|
||||
if(_toolbarContentView.allViews.any { it is ToggleBar })
|
||||
_toolbarContentView.removeView(_toolbarContentView.allViews.find { it is ToggleBar });
|
||||
_toggleBar = ToggleBar(context).apply {
|
||||
layoutParams =
|
||||
LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
|
||||
}
|
||||
|
||||
synchronized(_filterLock) {
|
||||
var buttonsPlugins: List<ToggleBar.Toggle> = listOf()
|
||||
buttonsPlugins =
|
||||
(StateLibrary.instance.getVideoBucketNames()
|
||||
.map { bucket ->
|
||||
ToggleBar.Toggle(bucket.name, null, fragment._toggleBuckets.contains(bucket.name), { view, active ->
|
||||
var dontSwap = false;
|
||||
if (!active) {
|
||||
if (fragment._toggleBuckets.contains(bucket.name))
|
||||
fragment._toggleBuckets.remove(bucket.name);
|
||||
} else {
|
||||
if (!fragment._toggleBuckets.contains(bucket.name)) {
|
||||
val enabledClients = StatePlatform.instance.getEnabledClients();
|
||||
val availableAfterDisable = enabledClients.count { !fragment._toggleBuckets.contains(it.id) && it.id != bucket.name };
|
||||
if(availableAfterDisable > 0)
|
||||
fragment._toggleBuckets.add(bucket.name);
|
||||
else {
|
||||
UIDialogs.appToast("Select atleast 1 bucket");
|
||||
dontSwap = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if(!dontSwap)
|
||||
reloadForFilters();
|
||||
else {
|
||||
view.setToggle(active);
|
||||
}
|
||||
}, { view, views, enabled ->
|
||||
val toDisable = views.filter { it != view && it.tag == "plugins" };
|
||||
if(!view.isActive)
|
||||
view.handleClick();
|
||||
for(tag in toDisable) {
|
||||
if(tag.isActive)
|
||||
tag.handleClick();
|
||||
}
|
||||
}).withTag("plugins")
|
||||
})
|
||||
val buttons = (buttonsPlugins)
|
||||
.sortedBy { it.name }.toTypedArray()
|
||||
|
||||
_toggleBar?.setToggles(*buttons);
|
||||
}
|
||||
|
||||
_toolbarContentView.addView(_toggleBar, 0);
|
||||
}
|
||||
|
||||
fun reloadForFilters() {
|
||||
setPager(StateLibrary.instance.getVideos(fragment._toggleBuckets));
|
||||
}
|
||||
|
||||
override fun updateSpanCount(){ }
|
||||
|
||||
|
||||
companion object {
|
||||
private const val TAG = "LibraryAlbumsFragmentsView";
|
||||
}
|
||||
}
|
||||
}
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
package com.futo.platformplayer.fragment.mainactivity.main
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.WebView
|
||||
import android.widget.ImageButton
|
||||
import android.widget.TextView
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourceAuth
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginAuthConfig
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.others.LoginWebViewClient
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.text.matches
|
||||
|
||||
|
||||
class LoginFragment : MainFragment() {
|
||||
override val isMainView : Boolean = true;
|
||||
override val isTab: Boolean = true;
|
||||
override val hasBottomBar: Boolean get() = true;
|
||||
|
||||
private var view: FragView? = null;
|
||||
|
||||
|
||||
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
val newView = FragView(this);
|
||||
view = newView;
|
||||
return newView;
|
||||
}
|
||||
|
||||
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
|
||||
super.onShownWithView(parameter, isBack);
|
||||
view?.onShown(parameter ?: throw IllegalArgumentException("No parameter for login"));
|
||||
}
|
||||
|
||||
override fun onDestroyMainView() {
|
||||
view = null;
|
||||
super.onDestroyMainView();
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun newInstance() = LoginFragment().apply {}
|
||||
|
||||
private var _callback: ((SourceAuth?) -> Unit)? = null;
|
||||
fun showLogin(config: SourcePluginConfig, callback: ((SourceAuth?) -> Unit)? = null) {
|
||||
if(_callback != null) _callback?.invoke(null);
|
||||
_callback = callback;
|
||||
StateApp.instance.activity?.navigate<LoginFragment>(config, false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class FragView: ConstraintLayout {
|
||||
val fragment: LoginFragment;
|
||||
|
||||
private val _webView: WebView;
|
||||
private val _textUrl: TextView;
|
||||
private val _buttonClose: ImageButton;
|
||||
|
||||
constructor(fragment: LoginFragment) : super(fragment.requireContext()) {
|
||||
inflate(context, R.layout.activity_login, this);
|
||||
this.fragment = fragment;
|
||||
|
||||
_textUrl = findViewById(R.id.text_url);
|
||||
_buttonClose = findViewById(R.id.button_close);
|
||||
_buttonClose.setOnClickListener {
|
||||
UIDialogs.toast("Login cancelled", false);
|
||||
fragment.close(true);
|
||||
}
|
||||
|
||||
|
||||
_webView = findViewById(R.id.web_view);
|
||||
_webView.settings.javaScriptEnabled = true;
|
||||
CookieManager.getInstance().setAcceptCookie(true);
|
||||
}
|
||||
|
||||
fun onShown(parameter: Any) {
|
||||
|
||||
|
||||
val config = parameter as? SourcePluginConfig;
|
||||
|
||||
val authConfig = if(config != null)
|
||||
config.authentication ?: throw IllegalStateException("Plugin has no authentication support");
|
||||
else if(parameter is SourcePluginAuthConfig)
|
||||
parameter
|
||||
else throw IllegalStateException("No valid configuration?");
|
||||
//TODO: Backwards compat removal?
|
||||
|
||||
_webView.settings.userAgentString = authConfig.userAgent ?: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36";
|
||||
_webView.settings.useWideViewPort = true;
|
||||
_webView.settings.loadWithOverviewMode = true;
|
||||
|
||||
val webViewClient = if(config != null) LoginWebViewClient(config) else LoginWebViewClient(authConfig);
|
||||
|
||||
webViewClient.onLogin.subscribe { auth ->
|
||||
_callback?.let {
|
||||
_callback = null;
|
||||
it.invoke(auth);
|
||||
}
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
fragment.close(true);
|
||||
}catch (ex: Throwable) {
|
||||
Logger.e(TAG, "Failed to close login", ex);
|
||||
}
|
||||
}
|
||||
};
|
||||
var isFirstLoad = true;
|
||||
val loginWarnings = authConfig.loginWarnings?.toMutableList() ?: mutableListOf<SourcePluginAuthConfig.Warning>();
|
||||
val uiMods = authConfig.uiMods?.toMutableList() ?: mutableListOf<SourcePluginAuthConfig.UIMod>();
|
||||
var currentScale = 100;
|
||||
var currentDesktop = false;
|
||||
webViewClient.onPageLoaded.subscribe { view, url ->
|
||||
_textUrl.setText(url ?: "");
|
||||
|
||||
if(loginWarnings.size > 0 && url != null) {
|
||||
synchronized(loginWarnings) {
|
||||
val warning = loginWarnings.find { url.matches(it.getRegex()) };
|
||||
if(warning != null) {
|
||||
if(warning.once == true)
|
||||
loginWarnings.remove(warning);
|
||||
UIDialogs.showDialog(context, R.drawable.ic_warning_yellow, warning.text ?: "", warning.details ?: "", null, 0,
|
||||
UIDialogs.Action("Understood", {
|
||||
}, UIDialogs.ActionStyle.PRIMARY));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(!isFirstLoad)
|
||||
return@subscribe;
|
||||
isFirstLoad = false;
|
||||
|
||||
if(!authConfig.loginButton.isNullOrEmpty() && authConfig.loginButton.matches(REGEX_LOGIN_BUTTON)) {
|
||||
Logger.i(TAG, "Clicking login button [${authConfig.loginButton}]");
|
||||
//TODO: Find most reliable way to wait for page js to finish
|
||||
view?.evaluateJavascript("setTimeout(()=> document.querySelector(\"${authConfig.loginButton}\")?.click(), 1000)", {});
|
||||
}
|
||||
}
|
||||
_webView.settings.domStorageEnabled = true;
|
||||
|
||||
_webView.webViewClient = webViewClient;
|
||||
_webView.loadUrl(authConfig.loginUrl);
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = "LoginFragment";
|
||||
private val REGEX_LOGIN_BUTTON = Regex("[a-zA-Z\\-\\.#:_ ]*");
|
||||
}
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package com.futo.platformplayer.fragment.mainactivity.main
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.AdapterView
|
||||
import android.widget.EditText
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageButton
|
||||
import android.widget.Spinner
|
||||
import android.widget.TextView
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
import com.futo.platformplayer.stores.StringStorage
|
||||
|
||||
class RecyclerFragment : MainFragment(){
|
||||
override val isMainView : Boolean = true;
|
||||
override val isTab: Boolean = true;
|
||||
override val hasBottomBar: Boolean get() = true;
|
||||
|
||||
private var view: View? = null;
|
||||
|
||||
|
||||
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): android.view.View {
|
||||
val newView = RecyclerFragment.View(inflater.context);
|
||||
view = newView;
|
||||
return newView;
|
||||
}
|
||||
|
||||
override fun onDestroyMainView() {
|
||||
view = null;
|
||||
super.onDestroyMainView();
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun newInstance() = RecyclerFragment().apply {}
|
||||
}
|
||||
|
||||
|
||||
class View: ConstraintLayout {
|
||||
constructor(context: Context, attrs : AttributeSet? = null) : super(context, attrs) {
|
||||
inflate(context, R.layout.fragview_filter_recycler, this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
package com.futo.platformplayer.fragment.mainactivity.main
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageButton
|
||||
import android.widget.LinearLayout
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.SettingsDev
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.assume
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.views.LoaderView
|
||||
import com.futo.platformplayer.views.fields.FieldForm
|
||||
import com.futo.platformplayer.views.fields.ReadOnlyTextField
|
||||
import com.google.android.material.button.MaterialButton
|
||||
|
||||
|
||||
class SettingsFragment : MainFragment() {
|
||||
override val isMainView : Boolean = true;
|
||||
override val isTab: Boolean = true;
|
||||
override val hasBottomBar: Boolean get() = true;
|
||||
|
||||
private var view: FragView? = null;
|
||||
|
||||
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): android.view.View {
|
||||
val newView = FragView(this);
|
||||
view = newView;
|
||||
return newView;
|
||||
}
|
||||
|
||||
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
|
||||
super.onShownWithView(parameter, isBack);
|
||||
_currentView = view;
|
||||
view?.onShown(parameter);
|
||||
}
|
||||
|
||||
override fun onHide() {
|
||||
super.onHide();
|
||||
onClosed.emit();
|
||||
}
|
||||
|
||||
override fun onDestroyMainView() {
|
||||
view = null;
|
||||
_currentView = null;
|
||||
super.onDestroyMainView();
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun newInstance() = SettingsFragment().apply {}
|
||||
|
||||
val onClosed = Event0();
|
||||
|
||||
private var _currentView: FragView? = null;
|
||||
val currentView: FragView?
|
||||
get() = _currentView;
|
||||
|
||||
}
|
||||
|
||||
|
||||
class FragView: ConstraintLayout {
|
||||
val fragment: SettingsFragment;
|
||||
|
||||
private val _form: FieldForm;
|
||||
private val _buttonBack: ImageButton;
|
||||
private val _loaderView: LoaderView;
|
||||
|
||||
private val _devSets: LinearLayout;
|
||||
private val _buttonDev: MaterialButton;
|
||||
|
||||
private var _isFinished = false;
|
||||
|
||||
lateinit var overlay: FrameLayout;
|
||||
|
||||
val notifPermission = "android.permission.POST_NOTIFICATIONS";
|
||||
|
||||
constructor(fragment: SettingsFragment) : super(fragment.requireContext()) {
|
||||
inflate(context, R.layout.activity_settings, this);
|
||||
this.fragment = fragment;
|
||||
|
||||
val activity = fragment.activity;
|
||||
|
||||
findViewById<LinearLayout>(R.id.container_topbar).isVisible = false;
|
||||
_form = findViewById(R.id.settings_form);
|
||||
_buttonBack = findViewById(R.id.button_back);
|
||||
_buttonDev = findViewById(R.id.button_dev);
|
||||
_devSets = findViewById(R.id.dev_settings);
|
||||
_loaderView = findViewById(R.id.loader);
|
||||
overlay = findViewById(R.id.overlay_container);
|
||||
|
||||
_form.onChanged.subscribe { field, _ ->
|
||||
Logger.i("SettingsActivity", "Setting [${field.field?.name}] changed, saving");
|
||||
_form.setObjectValues();
|
||||
Settings.instance.save();
|
||||
|
||||
if(field.descriptor?.id == "app_language") {
|
||||
Logger.i("SettingsActivity", "App language change detected, propogating to shared preferences");
|
||||
StateApp.instance.setLocaleSetting(context, Settings.instance.language.getAppLanguageLocaleString());
|
||||
}
|
||||
|
||||
if(field.descriptor?.id == "background_update" && activity is MainActivity) {
|
||||
Logger.i("SettingsActivity", "Detected change in background work ${field.value}");
|
||||
if(Settings.instance.subscriptions.subscriptionsBackgroundUpdateInterval > 0) {
|
||||
val notifManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager;
|
||||
if(!notifManager.areNotificationsEnabled()) {
|
||||
UIDialogs.toast(context, "Notifications aren't enabled");
|
||||
activity.requestNotificationPermissions("Notifications need to be enabled for background updating to function")
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
_buttonBack.setOnClickListener {
|
||||
//finish();
|
||||
}
|
||||
|
||||
_buttonDev.setOnClickListener {
|
||||
//startActivity(Intent(this, DeveloperActivity::class.java));
|
||||
fragment.navigate<DeveloperFragment>(null, true);
|
||||
}
|
||||
|
||||
//_lastActivity = this;
|
||||
|
||||
reloadSettings();
|
||||
}
|
||||
|
||||
var isFirstLoad = true;
|
||||
fun reloadSettings() {
|
||||
val firstLoad = isFirstLoad;
|
||||
isFirstLoad = false;
|
||||
_form.setSearchVisible(false);
|
||||
_loaderView.start();
|
||||
_form.fromObject(fragment.lifecycleScope, Settings.instance) {
|
||||
_loaderView.stop();
|
||||
_form.setSearchVisible(true);
|
||||
|
||||
var devCounter = 0;
|
||||
_form.findField("code")?.assume<ReadOnlyTextField>()?.setOnClickListener {
|
||||
devCounter++;
|
||||
if(devCounter > 5) {
|
||||
devCounter = 0;
|
||||
SettingsDev.instance.developerMode = true;
|
||||
SettingsDev.instance.save();
|
||||
updateDevMode();
|
||||
UIDialogs.toast(context, fragment.getString(R.string.you_are_now_in_developer_mode));
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
if(firstLoad) {
|
||||
val query = intent.getStringExtra("query");
|
||||
if(!query.isNullOrEmpty()) {
|
||||
_form.setSearchQuery(query);
|
||||
}
|
||||
}*/
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
fun onShown(str: Any? = null) {
|
||||
updateDevMode();
|
||||
if(str is String)
|
||||
_form.setSearchQuery(str);
|
||||
}
|
||||
|
||||
fun updateDevMode() {
|
||||
if(SettingsDev.instance.developerMode)
|
||||
_devSets.visibility = View.VISIBLE;
|
||||
else
|
||||
_devSets.visibility = View.GONE;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import android.content.Intent
|
||||
import android.graphics.drawable.Animatable
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.view.WindowManager
|
||||
import android.view.animation.AccelerateInterpolator
|
||||
import android.view.animation.OvershootInterpolator
|
||||
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 ->
|
||||
if (playing) {
|
||||
playPauseIcon.setImageResource(R.drawable.ic_play)
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.view.LayoutInflater
|
||||
import android.view.SoundEffectConstants
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowManager
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
@@ -309,6 +310,12 @@ class ShortsFragment : MainFragment() {
|
||||
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 {
|
||||
private const val TAG = "ShortsFragment"
|
||||
|
||||
|
||||
+60
-5
@@ -1,5 +1,8 @@
|
||||
package com.futo.platformplayer.fragment.mainactivity.main
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.drawable.Animatable
|
||||
import android.net.Uri
|
||||
@@ -32,9 +35,11 @@ import com.futo.platformplayer.views.buttons.BigButton
|
||||
import com.futo.platformplayer.views.buttons.BigButtonGroup
|
||||
import com.futo.platformplayer.views.fields.FieldForm
|
||||
import com.futo.platformplayer.views.sources.SourceHeaderView
|
||||
import com.google.gson.Gson
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
class SourceDetailFragment : MainFragment() {
|
||||
override val isMainView : Boolean = true;
|
||||
@@ -415,12 +420,40 @@ class SourceDetailFragment : MainFragment() {
|
||||
}
|
||||
|
||||
val advancedButtons = BigButtonGroup(c, "Advanced",
|
||||
BigButton(c, "Reset Settings", "Resets the settings to their default (deleting existing settings)", R.drawable.ic_refresh) {
|
||||
_config?.let {
|
||||
StatePlugins.instance.setPluginSettings(it.id, hashMapOf());
|
||||
loadConfig(it)
|
||||
}
|
||||
},
|
||||
BigButton(c, "Share Settings", "Shares the settings of this plugin as json, mostly used for bug reporting", R.drawable.ic_code) {
|
||||
|
||||
val structure = Json { this.prettyPrint = true; this.prettyPrintIndent = " " }
|
||||
.encodeToString(_settings);
|
||||
fragment.startActivity(Intent.createChooser(Intent().apply {
|
||||
action = Intent.ACTION_SEND;
|
||||
putExtra(Intent.EXTRA_TEXT, structure);
|
||||
type = "text/plain";
|
||||
}, null));
|
||||
/*
|
||||
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val clip = ClipData.newPlainText("Settings Json", structure)
|
||||
clipboard.setPrimaryClip(clip)
|
||||
UIDialogs.toast(context, "Copied", false);
|
||||
*/
|
||||
}.apply {
|
||||
this.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||
setMargins(0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics).toInt(), 0, 0);
|
||||
};
|
||||
} ,
|
||||
/*
|
||||
BigButton(c, "Edit Code", "Modify the source of this plugin", R.drawable.ic_code) {
|
||||
|
||||
}.apply {
|
||||
this.alpha = 0.5f;
|
||||
},
|
||||
if(isEmbedded) BigButton(c, "Reinstall", "Modify the source of this plugin", R.drawable.ic_refresh) {
|
||||
},*/
|
||||
if(isEmbedded) BigButton(c, "Reinstall", "Reinstall the original version that was embedded with this version of Grayjay", R.drawable.ic_refresh) {
|
||||
val embeddedConfig = StatePlugins.instance.getEmbeddedPluginConfigFromID(context, config.id);
|
||||
|
||||
UIDialogs.showDialog(context, R.drawable.ic_warning_yellow, "Are you sure you want to downgrade (${config.version}=>${embeddedConfig?.version})?",
|
||||
@@ -434,7 +467,29 @@ class SourceDetailFragment : MainFragment() {
|
||||
this.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||
setMargins(0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics).toInt(), 0, 0);
|
||||
};
|
||||
} else null
|
||||
} else
|
||||
BigButton(c, "Reinstall", "Reinstall the current version from the remote repository", R.drawable.ic_refresh) {
|
||||
var newConfig: SourcePluginConfig? = null;
|
||||
try {
|
||||
newConfig = StatePlugins.instance.requestConfig(config?.sourceUrl ?: throw IllegalArgumentException("No config"));
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e(TAG, "Failed to fetch new plugin config", ex);
|
||||
}
|
||||
UIDialogs.showDialog(context, R.drawable.ic_warning_yellow, "Are you sure you want to downgrade (${config.version}=>)?",
|
||||
"This will revert the plugin back to the originally embedded version.\nVersion change: ${config.version}=>${newConfig?.version}", null,
|
||||
0, UIDialogs.Action("Cancel", {}), UIDialogs.Action("Reinstall", {
|
||||
val url = config.sourceUrl ?: return@Action;
|
||||
StatePlugins.instance.installPlugin(context, fragment.lifecycleScope, url) {
|
||||
reloadSource(config.id);
|
||||
UIDialogs.toast(context, "Plugin reinstalled, may require refresh");
|
||||
}
|
||||
}, UIDialogs.ActionStyle.DANGEROUS));
|
||||
}.apply {
|
||||
this.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||
setMargins(0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics).toInt(), 0, 0);
|
||||
};
|
||||
}
|
||||
)
|
||||
|
||||
_sourceAdvancedButtons.removeAllViews();
|
||||
@@ -453,7 +508,7 @@ class SourceDetailFragment : MainFragment() {
|
||||
config.authentication.loginWarning, null, 0,
|
||||
UIDialogs.Action("Cancel", {}, UIDialogs.ActionStyle.NONE),
|
||||
UIDialogs.Action("Login", {
|
||||
LoginActivity.showLogin(StateApp.instance.context, config) {
|
||||
LoginFragment.showLogin(config) {//LoginActivity.showLogin(StateApp.instance.context, config) {
|
||||
try {
|
||||
StatePlugins.instance.setPluginAuth(config.id, it);
|
||||
reloadSource(config.id);
|
||||
@@ -467,7 +522,7 @@ class SourceDetailFragment : MainFragment() {
|
||||
}, UIDialogs.ActionStyle.PRIMARY))
|
||||
}
|
||||
else
|
||||
LoginActivity.showLogin(StateApp.instance.context, config) {
|
||||
LoginFragment.showLogin(config) {//LoginActivity.showLogin(StateApp.instance.context, config) {
|
||||
try {
|
||||
StatePlugins.instance.setPluginAuth(config.id, it);
|
||||
reloadSource(config.id);
|
||||
|
||||
+3
-3
@@ -24,7 +24,6 @@ import androidx.media3.common.util.UnstableApi
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.activities.SettingsActivity
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
||||
import com.futo.platformplayer.casting.StateCasting
|
||||
@@ -401,9 +400,10 @@ class VideoDetailFragment() : MainFragment() {
|
||||
_loadUrlOnCreate?.let { _viewDetail?.setVideo(it.url, it.timeSeconds, it.playWhenReady) };
|
||||
maximizeVideoDetail();
|
||||
|
||||
/*
|
||||
SettingsActivity.settingsActivityClosed.subscribe(this) {
|
||||
updateOrientation()
|
||||
}
|
||||
} */
|
||||
|
||||
StatePlayer.instance.onRotationLockChanged.subscribe(this) {
|
||||
updateOrientation()
|
||||
@@ -547,7 +547,7 @@ class VideoDetailFragment() : MainFragment() {
|
||||
super.onDestroyMainView();
|
||||
Logger.v(TAG, "onDestroyMainView");
|
||||
|
||||
SettingsActivity.settingsActivityClosed.remove(this)
|
||||
//SettingsActivity.settingsActivityClosed.remove(this)
|
||||
StatePlayer.instance.onRotationLockChanged.remove(this)
|
||||
|
||||
_landscapeOrientationListener?.disableListener()
|
||||
|
||||
+124
-49
@@ -55,6 +55,7 @@ import com.futo.platformplayer.api.media.LiveChatManager
|
||||
import com.futo.platformplayer.api.media.PlatformID
|
||||
import com.futo.platformplayer.api.media.exceptions.ContentNotAvailableYetException
|
||||
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorMembershipLink
|
||||
import com.futo.platformplayer.api.media.models.chapters.ChapterType
|
||||
import com.futo.platformplayer.api.media.models.chapters.IChapter
|
||||
@@ -77,6 +78,7 @@ import com.futo.platformplayer.api.media.models.streams.sources.LocalVideoSource
|
||||
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
||||
import com.futo.platformplayer.api.media.models.video.LocalVideoDetails
|
||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
@@ -175,6 +177,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import userpackage.Protocol
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.Locale
|
||||
@@ -244,6 +247,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
private val _buttonSubscribe: SubscribeButton;
|
||||
|
||||
private val _buttonPins: RoundButtonGroup;
|
||||
private var _loaderGameVisible = false
|
||||
//private val _buttonMore: RoundButton;
|
||||
|
||||
var preventPictureInPicture: Boolean = false
|
||||
@@ -261,7 +265,6 @@ class VideoDetailView : ConstraintLayout {
|
||||
private val _textSkip: TextView;
|
||||
private val _textResume: TextView;
|
||||
private val _layoutResume: LinearLayout;
|
||||
private var _jobHideResume: Job? = null;
|
||||
private val _layoutPlayerContainer: TouchInterceptFrameLayout;
|
||||
private val _layoutChangeBottomSection: LinearLayout;
|
||||
|
||||
@@ -336,7 +339,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
!StateCasting.instance.isCasting &&
|
||||
Settings.instance.playback.isBackgroundPictureInPicture() &&
|
||||
!isAudioOnlyUserAction &&
|
||||
isPlaying
|
||||
(isPlaying || _loaderGameVisible)
|
||||
|
||||
val onShouldEnterPictureInPictureChanged = Event0();
|
||||
|
||||
@@ -357,6 +360,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
Pair(-5 * 60, 30), //around 5 minutes, try every 30 seconds
|
||||
Pair(0, 10) //around live, try every 10 seconds
|
||||
);
|
||||
private var _subtitleLanguage: String? = null
|
||||
|
||||
@androidx.annotation.OptIn(UnstableApi::class)
|
||||
constructor(context: Context, attrs : AttributeSet? = null) : super(context, attrs) {
|
||||
@@ -548,10 +552,32 @@ class VideoDetailView : ConstraintLayout {
|
||||
_buttonMore = buttonMore;
|
||||
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 {
|
||||
if (video is TutorialFragment.TutorialVideo) {
|
||||
return@setOnClickListener
|
||||
}
|
||||
if(video is LocalVideoDetails) {
|
||||
video?.author?.let {
|
||||
if(it.url.startsWith("content://media/external/audio/artists")) {
|
||||
fragment.navigate<LibraryArtistFragment>(it.url);
|
||||
fragment.lifecycleScope.launch {
|
||||
delay(100);
|
||||
fragment.minimizeVideoDetail();
|
||||
};
|
||||
}
|
||||
}
|
||||
return@setOnClickListener;
|
||||
}
|
||||
|
||||
(video?.author ?: _searchVideo?.author)?.let {
|
||||
fragment.navigate<ChannelFragment>(it);
|
||||
@@ -614,6 +640,11 @@ class VideoDetailView : ConstraintLayout {
|
||||
}
|
||||
_player.onSourceChanged.subscribe(::onSourceChanged);
|
||||
_player.onSourceEnded.subscribe {
|
||||
if (_isCasting) {
|
||||
Logger.i(TAG, "Ignoring onSourceEnded because casting is active")
|
||||
return@subscribe
|
||||
}
|
||||
|
||||
if (!fragment.isInPictureInPicture) {
|
||||
_player.gestureControl.showControls(false);
|
||||
}
|
||||
@@ -693,6 +724,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
|
||||
val v = video;
|
||||
if (!it && v != null && v.duration - activeDevice.time.toLong() < 2L) {
|
||||
Log.i(TAG, "Next video (loop?)")
|
||||
nextVideo();
|
||||
}
|
||||
}
|
||||
@@ -872,11 +904,6 @@ class VideoDetailView : ConstraintLayout {
|
||||
|
||||
_layoutResume.setOnClickListener {
|
||||
handleSeek(_historicalPosition * 1000);
|
||||
|
||||
val job = _jobHideResume;
|
||||
_jobHideResume = null;
|
||||
job?.cancel();
|
||||
|
||||
_layoutResume.visibility = View.GONE;
|
||||
};
|
||||
|
||||
@@ -1029,7 +1056,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
_slideUpOverlay?.hide();
|
||||
}
|
||||
else null,
|
||||
if(!isLimitedVersion && !(video?.isLive ?: false))
|
||||
if(!isLimitedVersion && !(video?.isLive ?: false) && !(video is LocalVideoDetails))
|
||||
RoundButton(context, R.drawable.ic_download, context.getString(R.string.download), TAG_DOWNLOAD) {
|
||||
video?.let {
|
||||
_slideUpOverlay = UISlideOverlays.showDownloadVideoOverlay(it, _overlayContainer, context.contentResolver);
|
||||
@@ -1052,15 +1079,16 @@ class VideoDetailView : ConstraintLayout {
|
||||
_slideUpOverlay?.hide();
|
||||
}
|
||||
else null,
|
||||
RoundButton(context, R.drawable.ic_export, context.getString(R.string.page), TAG_OPEN) {
|
||||
if(!(video is LocalVideoDetails))
|
||||
RoundButton(context, R.drawable.ic_export, context.getString(R.string.page), TAG_OPEN) {
|
||||
video?.let {
|
||||
val url = video?.shareUrl ?: _searchVideo?.shareUrl ?: _url;
|
||||
fragment.navigate<BrowserFragment>(url);
|
||||
fragment.minimizeVideoDetail();
|
||||
};
|
||||
_slideUpOverlay?.hide();
|
||||
},
|
||||
if (StateSync.instance.hasAuthorizedDevice()) {
|
||||
} else null,
|
||||
if (StateSync.instance.hasAuthorizedDevice() && !(video is LocalVideoDetails)) {
|
||||
RoundButton(context, R.drawable.ic_device, context.getString(R.string.send_to_device), TAG_SEND_TO_DEVICE) {
|
||||
val devices = StateSync.instance.getAuthorizedSessions();
|
||||
val videoToSend = video ?: return@RoundButton;
|
||||
@@ -1083,10 +1111,11 @@ class VideoDetailView : ConstraintLayout {
|
||||
})
|
||||
}
|
||||
}} else null,
|
||||
RoundButton(context, R.drawable.ic_refresh, context.getString(R.string.reload), "Reload") {
|
||||
if(!(video is LocalVideoDetails))
|
||||
RoundButton(context, R.drawable.ic_refresh, context.getString(R.string.reload), "Reload") {
|
||||
reloadVideo();
|
||||
_slideUpOverlay?.hide();
|
||||
}).filterNotNull();
|
||||
} else null).filterNotNull();
|
||||
if(!_buttonPinStore.getAllValues().any())
|
||||
_buttonPins.setButtons(*(buttons + listOf(_buttonMore)).toTypedArray());
|
||||
else {
|
||||
@@ -1255,10 +1284,6 @@ class VideoDetailView : ConstraintLayout {
|
||||
MediaControlReceiver.onCloseReceived.remove(this);
|
||||
MediaControlReceiver.onBackgroundReceived.remove(this);
|
||||
MediaControlReceiver.onSeekToReceived.remove(this);
|
||||
|
||||
val job = _jobHideResume;
|
||||
_jobHideResume = null;
|
||||
job?.cancel();
|
||||
}
|
||||
|
||||
//Video Setters
|
||||
@@ -1325,7 +1350,22 @@ class VideoDetailView : ConstraintLayout {
|
||||
return;
|
||||
//Loop workaround
|
||||
if(bypassSameVideoCheck && this.video?.url == video.url && StatePlayer.instance.loopVideo) {
|
||||
_player.seekTo(0);
|
||||
Log.i(TAG, "Loop")
|
||||
if (_isCasting) {
|
||||
Log.i(TAG, "Loop casting")
|
||||
StateCasting.instance.activeDevice?.seekTo(0.0)
|
||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
delay(300)
|
||||
StateCasting.instance.activeDevice?.resumePlayback()
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "Failed to resume", e)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.i(TAG, "Loop player")
|
||||
_player.seekTo(0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1352,6 +1392,8 @@ class VideoDetailView : ConstraintLayout {
|
||||
|
||||
_minimize_title.text = video.name;
|
||||
_minimize_meta.text = video.author.name;
|
||||
StatePlayer.instance.setCurrentlyPlaying(video);
|
||||
Log.i(TAG, "setCurrentlyPlaying (setVideoOverview) ${video.url} (${video.name})")
|
||||
|
||||
val subTitleSegments : ArrayList<String> = ArrayList();
|
||||
if(video.viewCount > 0)
|
||||
@@ -1621,7 +1663,9 @@ class VideoDetailView : ConstraintLayout {
|
||||
|
||||
_buttonSubscribe.setSubscribeChannel(video.author.url);
|
||||
setDescription(video.description.fixHtmlLinks());
|
||||
_creatorThumbnail.setThumbnail(video.author.thumbnail, false);
|
||||
_creatorThumbnail.setThumbnail(video.author.thumbnail, false,
|
||||
video is LocalVideoDetails
|
||||
);
|
||||
setPolycentricProfile(null, animate = false);
|
||||
_taskLoadPolycentricProfile.run(video.author.id);
|
||||
|
||||
@@ -1649,7 +1693,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
|
||||
_rating.visibility = View.GONE;
|
||||
|
||||
if (StatePolycentric.instance.enabled) {
|
||||
if (StatePolycentric.instance.enabled && !(video is LocalVideoDetails)) {
|
||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val queryReferencesResponse = ApiMethods.getQueryReferences(
|
||||
@@ -1709,7 +1753,9 @@ class VideoDetailView : ConstraintLayout {
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to get polycentric likes/dislikes.", e);
|
||||
_rating.visibility = View.GONE;
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
_rating.visibility = View.GONE;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1774,37 +1820,20 @@ class VideoDetailView : ConstraintLayout {
|
||||
false,
|
||||
(toResume.toFloat() / 1000.0f).toLong(),
|
||||
null,
|
||||
true
|
||||
true,
|
||||
StatePlayer.instance.playlistId
|
||||
);
|
||||
Logger.i(
|
||||
TAG,
|
||||
"Historical position: $_historicalPosition, last position: $lastPositionMilliseconds"
|
||||
);
|
||||
if (_historicalPosition > 60 && video.duration - _historicalPosition > 5 && Math.abs(
|
||||
_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 = "";
|
||||
}
|
||||
updateResumeVisibilityFor(lastPositionMilliseconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StatePlayer.instance.startOrUpdateMediaSession(context, video);
|
||||
Log.i(TAG, "setCurrentlyPlaying (nextVideo) ${video.url} (${video.name})")
|
||||
StatePlayer.instance.setCurrentlyPlaying(video);
|
||||
|
||||
_liveChat?.stop();
|
||||
@@ -1826,17 +1855,19 @@ class VideoDetailView : ConstraintLayout {
|
||||
_player.updateNextPrevious();
|
||||
updateMoreButtons();
|
||||
|
||||
if (videoDetail is TutorialFragment.TutorialVideo) {
|
||||
if (videoDetail is TutorialFragment.TutorialVideo || videoDetail is LocalVideoDetails) {
|
||||
_buttonSubscribe.visibility = View.GONE
|
||||
_buttonMore.visibility = View.GONE
|
||||
_buttonPins.visibility = View.GONE
|
||||
_buttonMore.visibility = if(videoDetail is LocalVideoDetails) View.VISIBLE else View.GONE;
|
||||
_buttonPins.visibility = if(videoDetail is LocalVideoDetails) View.VISIBLE else View.GONE;
|
||||
_layoutRating.visibility = View.GONE
|
||||
_rating.visibility = View.GONE;
|
||||
_layoutChangeBottomSection.visibility = View.GONE
|
||||
} else {
|
||||
_buttonSubscribe.visibility = View.VISIBLE
|
||||
_buttonMore.visibility = View.VISIBLE
|
||||
_buttonPins.visibility = View.VISIBLE
|
||||
_layoutRating.visibility = View.VISIBLE
|
||||
_rating.visibility = View.VISIBLE;
|
||||
_layoutChangeBottomSection.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
@@ -1845,6 +1876,35 @@ class VideoDetailView : ConstraintLayout {
|
||||
_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) {
|
||||
_liveChat?.stop();
|
||||
_container_content_liveChat.cancel();
|
||||
@@ -1964,7 +2024,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
try {
|
||||
val videoSource = _lastVideoSource ?: _player.getPreferredVideoSource(video, Settings.instance.playback.getCurrentPreferredQualityPixelCount());
|
||||
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)")
|
||||
|
||||
if(videoSource == null && audioSource == null) {
|
||||
@@ -2274,6 +2334,8 @@ class VideoDetailView : ConstraintLayout {
|
||||
checkAndRemoveWatchLater();
|
||||
|
||||
var next = StatePlayer.instance.nextQueueItem(withoutRemoval || _player.duration < 100 || (_player.position.toFloat() / _player.duration) < 0.9, bypassVideoLoop);
|
||||
Log.i(TAG, "next queue item ${next?.url} (${next?.name})")
|
||||
|
||||
val autoplayVideo = _autoplayVideo
|
||||
if (next == null && autoplayVideo != null && StatePlayer.instance.autoplay) {
|
||||
Logger.i(TAG, "Found autoplay video!")
|
||||
@@ -2286,11 +2348,14 @@ class VideoDetailView : ConstraintLayout {
|
||||
if(next == null && forceLoop)
|
||||
next = StatePlayer.instance.restartQueue();
|
||||
if(next != null) {
|
||||
Logger.i(TAG, "Set video overview (next = ${next.url} (${next.name}))")
|
||||
setVideoOverview(next, true, 0, true);
|
||||
return true;
|
||||
}
|
||||
else
|
||||
else {
|
||||
Log.i(TAG, "setCurrentlyPlaying (nextVideo) null")
|
||||
StatePlayer.instance.setCurrentlyPlaying(null);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -2648,6 +2713,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
}
|
||||
}
|
||||
_lastSubtitleSource = toSet;
|
||||
_subtitleLanguage = toSet?.language
|
||||
}
|
||||
|
||||
private fun handleUnavailableVideo(msg: String? = null) {
|
||||
@@ -2670,7 +2736,11 @@ class VideoDetailView : ConstraintLayout {
|
||||
private fun fetchComments() {
|
||||
Logger.i(TAG, "fetchComments")
|
||||
video?.let {
|
||||
_commentsList.load(true) { StatePlatform.instance.getComments(it); };
|
||||
if(video is LocalVideoDetails) {
|
||||
_commentsList.clearComments();
|
||||
}
|
||||
else
|
||||
_commentsList.load(true) { StatePlatform.instance.getComments(it); };
|
||||
}
|
||||
}
|
||||
private fun fetchPolycentricComments() {
|
||||
@@ -2805,6 +2875,8 @@ class VideoDetailView : ConstraintLayout {
|
||||
_overlay_loading.visibility = View.GONE;
|
||||
(_overlay_loading_spinner.drawable as Animatable?)?.stop()
|
||||
}
|
||||
|
||||
updateResumeVisibilityFor(lastPositionMilliseconds)
|
||||
}
|
||||
|
||||
//UI Actions
|
||||
@@ -2955,6 +3027,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
}
|
||||
|
||||
onChannelClicked.subscribe {
|
||||
Logger.i(TAG, "Opening channel url: ${it.url}");
|
||||
if(it.url.isNotBlank()) {
|
||||
fragment.minimizeVideoDetail()
|
||||
fragment.navigate<ChannelFragment>(it)
|
||||
@@ -3079,7 +3152,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
if (v !is TutorialFragment.TutorialVideo) {
|
||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
val history = getHistoryIndex(v) ?: return@launch;
|
||||
StateHistory.instance.updateHistoryPosition(v, history, true, (positionMilliseconds.toFloat() / 1000.0f).toLong(), null, true);
|
||||
StateHistory.instance.updateHistoryPosition(v, history, true, (positionMilliseconds.toFloat() / 1000.0f).toLong(), null, true, StatePlayer.instance.playlistId);
|
||||
}
|
||||
}
|
||||
_lastPositionSaveTime = currentTime;
|
||||
@@ -3095,6 +3168,8 @@ class VideoDetailView : ConstraintLayout {
|
||||
handleSeek(55000);
|
||||
}
|
||||
}
|
||||
|
||||
updateResumeVisibilityFor(positionMilliseconds)
|
||||
}
|
||||
|
||||
private fun updateTracker(positionMs: Long, isPlaying: Boolean, forceUpdate: Boolean = false) {
|
||||
|
||||
+24
-10
@@ -20,6 +20,7 @@ import com.futo.platformplayer.UISlideOverlays
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.assume
|
||||
import com.futo.platformplayer.downloads.VideoDownload
|
||||
import com.futo.platformplayer.images.GlideHelper
|
||||
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
||||
import com.futo.platformplayer.states.StateDownloads
|
||||
import com.futo.platformplayer.states.StatePlaylists
|
||||
@@ -194,22 +195,35 @@ abstract class VideoListEditorView : LinearLayout {
|
||||
_textMetadata.text = parts.joinToString(" • ");
|
||||
}
|
||||
|
||||
protected fun setVideos(videos: List<IPlatformVideo>?, canEdit: Boolean) {
|
||||
if (videos != null && videos.isNotEmpty()) {
|
||||
val video = videos.first();
|
||||
protected fun setVideos(videos: List<IPlatformVideo>?, canEdit: Boolean, thumbnail: String? = null) {
|
||||
if(thumbnail != null) {
|
||||
_imagePlaylistThumbnail.let {
|
||||
Glide.with(it)
|
||||
.load(video.thumbnails.getHQThumbnail())
|
||||
.load(thumbnail)
|
||||
.placeholder(R.drawable.placeholder_video_thumbnail)
|
||||
.crossfade()
|
||||
.into(it);
|
||||
};
|
||||
} else {
|
||||
_textMetadata.text = "0 " + context.getString(R.string.videos);
|
||||
Glide.with(_imagePlaylistThumbnail)
|
||||
.load(R.drawable.placeholder_video_thumbnail)
|
||||
.into(_imagePlaylistThumbnail)
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (videos != null && videos.isNotEmpty()) {
|
||||
val video = videos.first();
|
||||
_imagePlaylistThumbnail.let {
|
||||
Glide.with(it)
|
||||
.load(video.thumbnails.getHQThumbnail())
|
||||
.placeholder(R.drawable.placeholder_video_thumbnail)
|
||||
.crossfade()
|
||||
.into(it);
|
||||
};
|
||||
} else {
|
||||
Glide.with(_imagePlaylistThumbnail)
|
||||
.load(R.drawable.placeholder_video_thumbnail)
|
||||
.into(_imagePlaylistThumbnail)
|
||||
}
|
||||
}
|
||||
if(videos == null || videos.isEmpty())
|
||||
_textMetadata.text = "0 " + context.getString(R.string.videos);
|
||||
|
||||
_loadedVideos = videos;
|
||||
_loadedVideosCanEdit = canEdit;
|
||||
_videoListEditorView.setVideos(videos, canEdit);
|
||||
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
package com.futo.platformplayer.fragment.mainactivity.topbar
|
||||
|
||||
import android.os.Bundle
|
||||
import android.util.TypedValue
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.widget.AppCompatImageView
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.api.media.IPlatformClient
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.LibraryFilesFragment
|
||||
import com.futo.platformplayer.models.Playlist
|
||||
import com.futo.platformplayer.states.FileEntry
|
||||
import com.futo.platformplayer.views.casting.CastButton
|
||||
import com.futo.polycentric.core.PolycentricProfile
|
||||
|
||||
class FilesTopBarFragment : TopFragment() {
|
||||
private var _buttonBack: ImageButton? = null;
|
||||
private var _buttonCast: CastButton? = null;
|
||||
private var _textTitle: TextView? = null;
|
||||
private var _menuItems: LinearLayout? = null;
|
||||
|
||||
private var _upHandle: (()->Unit)? = null;
|
||||
|
||||
override fun onShown(parameter: Any?) {
|
||||
setTitle(parameter);
|
||||
setMenuItems(listOf());
|
||||
}
|
||||
override fun onHide() {
|
||||
|
||||
}
|
||||
|
||||
fun setTitle(parameter: Any? = null) {
|
||||
if(parameter is IPlatformChannel) {
|
||||
_textTitle?.text = parameter.name;
|
||||
} else if(parameter is PlatformAuthorLink) {
|
||||
_textTitle?.text = parameter.name;
|
||||
} else if (parameter is Playlist) {
|
||||
_textTitle?.text = parameter.name;
|
||||
} else if (parameter is String) {
|
||||
_textTitle?.text = parameter;
|
||||
} else if (parameter is IPlatformClient) {
|
||||
_textTitle?.text = parameter.name;
|
||||
} else if (parameter is PolycentricProfile) {
|
||||
_textTitle?.text = parameter.systemState.username;
|
||||
} else if(parameter is FileEntry) {
|
||||
val treePrefix = "content://com.android.externalstorage.documents/tree/";
|
||||
if(parameter.path.startsWith(treePrefix)) {
|
||||
_textTitle?.text = parameter.path.substring(treePrefix.length - 1).replace("%3A", " ").replace("%2F", "/");
|
||||
}
|
||||
else if(parameter.path.isNullOrBlank())
|
||||
_textTitle?.text = parameter.name;
|
||||
else
|
||||
_textTitle?.text = parameter.path;
|
||||
}
|
||||
else if(parameter is LibraryFilesFragment.FileStack) {
|
||||
val treePrefix = "content://com.android.externalstorage.documents/tree/";
|
||||
if(parameter.path.startsWith(treePrefix)) {
|
||||
_textTitle?.text = parameter.path.substring(treePrefix.length - 1).replace("%3A", " ").replace("%2F", "/");
|
||||
}
|
||||
else
|
||||
_textTitle?.text = parameter.path;
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
val view = inflater.inflate(R.layout.fragment_files_top_bar, container, false);
|
||||
|
||||
val buttonBack: ImageButton = view.findViewById(R.id.button_back);
|
||||
_textTitle = view.findViewById(R.id.text_title);
|
||||
_menuItems = view.findViewById(R.id.menu_buttons)
|
||||
|
||||
buttonBack.setOnClickListener {
|
||||
if(_upHandle != null)
|
||||
_upHandle?.invoke();
|
||||
else
|
||||
closeSegment();
|
||||
};
|
||||
|
||||
_buttonBack = buttonBack;
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
fun setUpNavigate(handle: (()->Unit)? = null) {
|
||||
_upHandle = handle;
|
||||
_buttonBack?.setImageResource(if(handle == null) R.drawable.ic_back_nav else R.drawable.ic_arrow_up);
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
|
||||
_buttonBack?.setOnClickListener(null);
|
||||
_buttonBack = null;
|
||||
_buttonCast?.cleanup();
|
||||
_buttonCast = null;
|
||||
_textTitle = null;
|
||||
}
|
||||
|
||||
fun setMenuItems(items: List<Pair<Int, ()->Unit>>) {
|
||||
_menuItems?.removeAllViews();
|
||||
|
||||
val dp4 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 4f, resources.displayMetrics).toInt();
|
||||
val dp9 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 9f, resources.displayMetrics).toInt();
|
||||
|
||||
for(item in items) {
|
||||
val compatImageItem = AppCompatImageView(requireContext());
|
||||
compatImageItem.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.MATCH_PARENT);
|
||||
compatImageItem.setImageResource(item.first);
|
||||
compatImageItem.setPadding(dp4, dp9, dp4, dp9);
|
||||
compatImageItem.scaleType = ImageView.ScaleType.FIT_CENTER;
|
||||
compatImageItem.setOnClickListener {
|
||||
item.second.invoke();
|
||||
};
|
||||
|
||||
_menuItems?.addView(compatImageItem);
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun newInstance() = FilesTopBarFragment().apply { }
|
||||
}
|
||||
}
|
||||
+4
@@ -9,6 +9,8 @@ import android.widget.ImageView
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.CreatorsFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.LibraryFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.LibrarySearchFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.PlaylistFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.PlaylistsFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SuggestionsFragment
|
||||
@@ -46,6 +48,8 @@ class GeneralTopBarFragment : TopFragment() {
|
||||
navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.CREATOR));
|
||||
} else if (currentMain is PlaylistsFragment || currentMain is PlaylistFragment) {
|
||||
navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.PLAYLIST));
|
||||
} else if (currentMain is LibraryFragment) {
|
||||
navigate<LibrarySearchFragment>();
|
||||
} else {
|
||||
navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.VIDEO));
|
||||
}
|
||||
|
||||
+11
-1
@@ -18,6 +18,7 @@ import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.LibrarySearchFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SuggestionsFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SuggestionsFragmentData
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
@@ -112,7 +113,10 @@ class SearchTopBarFragment : TopFragment() {
|
||||
}
|
||||
fun clear() {
|
||||
_editSearch?.text?.clear();
|
||||
if (currentMain !is SuggestionsFragment) {
|
||||
if(currentMain is LibrarySearchFragment) {
|
||||
onSearch.emit("");
|
||||
}
|
||||
else if (currentMain !is SuggestionsFragment) {
|
||||
navigate<SuggestionsFragment>(SuggestionsFragmentData("", _searchType), false);
|
||||
} else {
|
||||
onSearch.emit("");
|
||||
@@ -190,6 +194,12 @@ class SearchTopBarFragment : TopFragment() {
|
||||
_buttonFilter?.visibility = if (visible) View.VISIBLE else View.GONE;
|
||||
}
|
||||
|
||||
fun getSearchText(): String {
|
||||
return _editSearch?.let {
|
||||
it.text.toString();
|
||||
} ?: "";
|
||||
}
|
||||
|
||||
private fun onDone() {
|
||||
val editSearch = _editSearch
|
||||
if (editSearch != null) {
|
||||
|
||||
@@ -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.IVideoUrlSource
|
||||
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.platforms.js.models.sources.JSAudioUrlRangeSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource
|
||||
@@ -134,6 +136,62 @@ class VideoHelper {
|
||||
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)
|
||||
fun convertItagSourceToChunkedDashSource(videoSource: JSVideoUrlRangeSource) : Pair<MediaSource, String> {
|
||||
val urlToUse = videoSource.getVideoUrl();
|
||||
|
||||
@@ -29,7 +29,6 @@ class GlideHelper {
|
||||
req.into(this);
|
||||
}
|
||||
|
||||
|
||||
fun RequestBuilder<Drawable>.crossfade(): RequestBuilder<Drawable> {
|
||||
return this.transition(DrawableTransitionOptions.withCrossFade());
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package com.futo.platformplayer.images;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Build;
|
||||
import android.util.Log;
|
||||
import com.bumptech.glide.Glide;
|
||||
import com.bumptech.glide.Registry;
|
||||
import com.bumptech.glide.annotation.GlideModule;
|
||||
import com.bumptech.glide.module.AppGlideModule;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.nio.ByteBuffer;
|
||||
|
||||
@GlideModule
|
||||
@@ -14,5 +17,8 @@ public class GrayjayAppGlideModule extends AppGlideModule {
|
||||
public void registerComponents(Context context, Glide glide, Registry registry) {
|
||||
Log.i("GrayjayAppGlideModule", "registerComponents called");
|
||||
registry.prepend(String.class, ByteBuffer.class, new PolycentricModelLoader.Factory());
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
registry.prepend(String.class, InputStream.class, new MediaStoreThumbnailLoader.InputStreamFactory());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.futo.platformplayer.images
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.graphics.Point
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.MediaStore
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.bumptech.glide.load.Options
|
||||
import com.bumptech.glide.load.data.LocalUriFetcher
|
||||
import com.bumptech.glide.load.model.ModelLoader
|
||||
import com.bumptech.glide.load.model.ModelLoaderFactory
|
||||
import com.bumptech.glide.load.model.MultiModelLoaderFactory
|
||||
import com.bumptech.glide.signature.ObjectKey
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.net.MalformedURLException
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
class MediaStoreThumbnailLoader private constructor() : ModelLoader<String, InputStream> {
|
||||
|
||||
override fun handles(model: String): Boolean = isMediaStoreAudioUri(model)
|
||||
|
||||
private fun isMediaStoreAudioUri(uri: String): Boolean {
|
||||
try {
|
||||
val parsed = Uri.parse(uri);
|
||||
return ContentResolver.SCHEME_CONTENT == parsed.scheme
|
||||
&& MediaStore.AUTHORITY == parsed.authority
|
||||
&& "audio" in parsed.pathSegments
|
||||
}
|
||||
catch(ex: MalformedURLException) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
override fun buildLoadData(model: String, width: Int, height: Int, options: Options): ModelLoader.LoadData<InputStream>? {
|
||||
val diskCacheKey = ObjectKey(model)
|
||||
val resolver = StateApp.instance.contextOrNull?.contentResolver ?: return null;
|
||||
val fetcher = InputStreamFetcher(resolver, Uri.parse(model), width, height)
|
||||
return ModelLoader.LoadData(diskCacheKey, fetcher)
|
||||
}
|
||||
|
||||
class InputStreamFactory() : ModelLoaderFactory<String, InputStream> {
|
||||
|
||||
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<String, InputStream> = MediaStoreThumbnailLoader()
|
||||
|
||||
override fun teardown() {
|
||||
// Do nothing.
|
||||
}
|
||||
}
|
||||
|
||||
private class InputStreamFetcher(resolver: ContentResolver, uri: Uri, private val width: Int, private val height: Int) : LocalUriFetcher<InputStream>(resolver, uri) {
|
||||
|
||||
override fun getDataClass(): Class<InputStream> = InputStream::class.java
|
||||
|
||||
@Throws(FileNotFoundException::class)
|
||||
override fun loadResource(uri: Uri, contentResolver: ContentResolver): InputStream {
|
||||
val optimalSizeOptions = Bundle(1)
|
||||
optimalSizeOptions.putParcelable(ContentResolver.EXTRA_SIZE, Point(width, height))
|
||||
|
||||
return contentResolver.openTypedAssetFile(uri, "image/*", optimalSizeOptions, null)
|
||||
?.createInputStream()
|
||||
?: throw FileNotFoundException("FileDescriptor is null for: $uri")
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun close(data: InputStream) {
|
||||
data.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,15 +14,17 @@ import java.time.ZoneOffset
|
||||
class HistoryVideo {
|
||||
var video: SerializedPlatformVideo;
|
||||
var position: Long;
|
||||
var playlistId: String? = null
|
||||
|
||||
@kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class)
|
||||
var date: OffsetDateTime;
|
||||
|
||||
|
||||
constructor(video: SerializedPlatformVideo, position: Long, date: OffsetDateTime) {
|
||||
constructor(video: SerializedPlatformVideo, position: Long, date: OffsetDateTime, playlistId: String?) {
|
||||
this.video = video;
|
||||
this.position = position;
|
||||
this.date = date;
|
||||
this.playlistId = playlistId
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +61,7 @@ class HistoryVideo {
|
||||
viewCount = -1
|
||||
);
|
||||
|
||||
return HistoryVideo(video, position, OffsetDateTime.of(LocalDateTime.ofEpochSecond(dateSec, 0, ZoneOffset.UTC), ZoneOffset.UTC));
|
||||
return HistoryVideo(video, position, OffsetDateTime.of(LocalDateTime.ofEpochSecond(dateSec, 0, ZoneOffset.UTC), ZoneOffset.UTC), null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,5 +12,6 @@ data class Telemetry(
|
||||
val brand: String,
|
||||
val manufacturer: String,
|
||||
val model: String,
|
||||
val sdkVersion: Int
|
||||
val sdkVersion: Int,
|
||||
val plugins: List<String>? = null
|
||||
) { }
|
||||
@@ -29,14 +29,25 @@ class HLS {
|
||||
val mediaRenditions = mutableListOf<MediaRendition>()
|
||||
val sessionDataList = mutableListOf<SessionData>()
|
||||
var independentSegments = false
|
||||
var version: Int? = null
|
||||
var mediaSequence: Long? = null
|
||||
val unhandled = mutableListOf<String>()
|
||||
|
||||
masterPlaylistContent.lines().forEachIndexed { index, line ->
|
||||
val lines = masterPlaylistContent.lines()
|
||||
lines.forEachIndexed { index, line ->
|
||||
when {
|
||||
line.startsWith("#EXT-X-VERSION:") -> {
|
||||
version = line.substringAfter(":").toIntOrNull()
|
||||
}
|
||||
|
||||
line.startsWith("#EXT-X-MEDIA-SEQUENCE:") -> {
|
||||
mediaSequence = line.substringAfter(":").toLongOrNull()
|
||||
}
|
||||
|
||||
line.startsWith("#EXT-X-STREAM-INF") -> {
|
||||
val nextLine = masterPlaylistContent.lines().getOrNull(index + 1)
|
||||
val nextLine = lines.getOrNull(index + 1)
|
||||
?: throw Exception("Expected URI following #EXT-X-STREAM-INF, found none")
|
||||
val url = resolveUrl(baseUrl, nextLine)
|
||||
|
||||
variantPlaylists.add(VariantPlaylistReference(url, parseStreamInfo(line)))
|
||||
}
|
||||
|
||||
@@ -52,10 +63,14 @@ class HLS {
|
||||
val sessionData = parseSessionData(line)
|
||||
sessionDataList.add(sessionData)
|
||||
}
|
||||
|
||||
else -> {
|
||||
unhandled.add(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return MasterPlaylist(variantPlaylists, mediaRenditions, sessionDataList, independentSegments)
|
||||
return MasterPlaylist(variantPlaylists, mediaRenditions, sessionDataList, independentSegments, version = version, mediaSequence = mediaSequence, unhandled = unhandled)
|
||||
}
|
||||
|
||||
fun mediaRenditionToVariant(rendition: MediaRendition): HLSVariantAudioUrlSource? {
|
||||
@@ -83,62 +98,189 @@ class HLS {
|
||||
return HLSVariantVideoUrlSource(suffix, width ?: 0, height ?: 0, "application/vnd.apple.mpegurl", reference.streamInfo.codecs ?: "", reference.streamInfo.bandwidth, 0, false, reference.url)
|
||||
}
|
||||
|
||||
private fun parseByteRange(value: String): Pair<Long, Long> {
|
||||
val trimmed = value.trim()
|
||||
require(trimmed.isNotEmpty()) { "Empty BYTERANGE value" }
|
||||
|
||||
val parts = trimmed.split('@')
|
||||
val length = parts[0].toLong()
|
||||
require(length >= 0) { "Invalid BYTERANGE length '$value'" }
|
||||
|
||||
val start = if (parts.size > 1) {
|
||||
val s = parts[1].toLong()
|
||||
require(s >= 0) { "Invalid BYTERANGE offset '$value'" }
|
||||
s
|
||||
} else {
|
||||
-1L
|
||||
}
|
||||
|
||||
return length to start
|
||||
}
|
||||
|
||||
|
||||
private fun parseAttributes(content: String): Map<String, String> {
|
||||
val index = content.indexOf(':')
|
||||
if (index < 0 || index == content.length - 1) return emptyMap()
|
||||
|
||||
val attributes = mutableMapOf<String, String>()
|
||||
val maybeAttributePairs = content.substring(index + 1).splitToSequence(',')
|
||||
|
||||
var currentPair = StringBuilder()
|
||||
for (pair in maybeAttributePairs) {
|
||||
currentPair.append(pair)
|
||||
if (currentPair.count { it == '\"' } % 2 == 0) {
|
||||
val full = currentPair.toString()
|
||||
val key = full.substringBefore("=")
|
||||
val value = full.substringAfter("=")
|
||||
attributes[key.trim()] = value.trim().removeSurrounding("\"")
|
||||
currentPair = StringBuilder()
|
||||
} else {
|
||||
currentPair.append(',')
|
||||
}
|
||||
}
|
||||
|
||||
return attributes
|
||||
}
|
||||
|
||||
fun parseVariantPlaylist(content: String, sourceUrl: String): VariantPlaylist {
|
||||
val baseUrl = URI(sourceUrl).resolve("./").toString()
|
||||
val lines = content.lines()
|
||||
val version = lines.find { it.startsWith("#EXT-X-VERSION:") }?.substringAfter(":")?.toIntOrNull()
|
||||
val targetDuration = lines.find { it.startsWith("#EXT-X-TARGETDURATION:") }?.substringAfter(":")?.toIntOrNull()
|
||||
val mediaSequence = lines.find { it.startsWith("#EXT-X-MEDIA-SEQUENCE:") }?.substringAfter(":")?.toLongOrNull()
|
||||
val discontinuitySequence = lines.find { it.startsWith("#EXT-X-DISCONTINUITY-SEQUENCE:") }?.substringAfter(":")?.toIntOrNull()
|
||||
val programDateTime = lines.find { it.startsWith("#EXT-X-PROGRAM-DATE-TIME:") }?.substringAfter(":")?.let {
|
||||
ZonedDateTime.parse(it, DateTimeFormatter.ISO_DATE_TIME)
|
||||
}
|
||||
val playlistType = lines.find { it.startsWith("#EXT-X-PLAYLIST-TYPE:") }?.substringAfter(":")
|
||||
val streamInfo = lines.find { it.startsWith("#EXT-X-STREAM-INF:") }?.let { parseStreamInfo(it) }
|
||||
|
||||
val keyInfo =
|
||||
lines.find { it.startsWith("#EXT-X-KEY:") }?.substringAfter(":")?.split(",")
|
||||
|
||||
val key = keyInfo?.find { it.startsWith("URI=") }?.substringAfter("=")?.trim('"')
|
||||
val iv =
|
||||
keyInfo?.find { it.startsWith("IV=") }?.substringAfter("=")?.substringAfter("x")
|
||||
|
||||
val decryptionInfo: DecryptionInfo? = key?.let { k ->
|
||||
DecryptionInfo(k, iv)
|
||||
}
|
||||
|
||||
val initSegment =
|
||||
lines.find { it.startsWith("#EXT-X-MAP:") }?.substringAfter(":")?.split(",")?.get(0)
|
||||
?.substringAfter("=")?.trim('"')
|
||||
var version: Int? = null
|
||||
var targetDuration: Int? = null
|
||||
var mediaSequence: Long? = null
|
||||
var discontinuitySequence: Int? = null
|
||||
var programDateTime: ZonedDateTime? = null
|
||||
var playlistType: String? = null
|
||||
var streamInfo: StreamInfo? = null
|
||||
var decryptionInfo: DecryptionInfo? = null
|
||||
var mapUrl: String? = null
|
||||
var mapBytesStart: Long = -1
|
||||
var mapBytesLength: Long = -1
|
||||
val segments = mutableListOf<Segment>()
|
||||
if (initSegment != null) {
|
||||
segments.add(MediaSegment(0.0, resolveUrl(sourceUrl, initSegment)))
|
||||
}
|
||||
val unhandled = mutableListOf<String>()
|
||||
|
||||
var currentSegment: MediaSegment? = null
|
||||
lines.forEach { line ->
|
||||
|
||||
for (rawLine in lines) {
|
||||
val line = rawLine.trim()
|
||||
if (line.isEmpty()) continue
|
||||
|
||||
when {
|
||||
line.startsWith("#EXT-X-VERSION:") -> {
|
||||
version = line.substringAfter(":").toIntOrNull()
|
||||
}
|
||||
|
||||
line.startsWith("#EXT-X-TARGETDURATION:") -> {
|
||||
targetDuration = line.substringAfter(":").toIntOrNull()
|
||||
}
|
||||
|
||||
line.startsWith("#EXT-X-MEDIA-SEQUENCE:") -> {
|
||||
mediaSequence = line.substringAfter(":").toLongOrNull()
|
||||
}
|
||||
|
||||
line.startsWith("#EXT-X-DISCONTINUITY-SEQUENCE:") -> {
|
||||
discontinuitySequence = line.substringAfter(":").toIntOrNull()
|
||||
}
|
||||
|
||||
line.startsWith("#EXT-X-PROGRAM-DATE-TIME:") -> {
|
||||
programDateTime = ZonedDateTime.parse(
|
||||
line.substringAfter(":"),
|
||||
DateTimeFormatter.ISO_DATE_TIME
|
||||
)
|
||||
}
|
||||
|
||||
line.startsWith("#EXT-X-PLAYLIST-TYPE:") -> {
|
||||
playlistType = line.substringAfter(":")
|
||||
}
|
||||
|
||||
line.startsWith("#EXT-X-STREAM-INF:") -> {
|
||||
streamInfo = parseStreamInfo(line)
|
||||
}
|
||||
|
||||
line.startsWith("#EXT-X-KEY:") -> {
|
||||
val attrs = parseAttributes(line)
|
||||
val method = attrs["METHOD"]?.ifEmpty { "AES-128" } ?: "AES-128"
|
||||
val keyUri = attrs["URI"]?.removeSurrounding("\"")
|
||||
val keyUrl = keyUri?.let { resolveUrl(baseUrl, it) }
|
||||
val ivRaw = attrs["IV"]
|
||||
val iv = ivRaw
|
||||
?.removePrefix("0x")
|
||||
?.removePrefix("0X")
|
||||
val keyFormat = attrs["KEYFORMAT"]
|
||||
val keyFormatVersions = attrs["KEYFORMATVERSIONS"]
|
||||
decryptionInfo = DecryptionInfo(method, keyUrl, iv, keyFormat, keyFormatVersions)
|
||||
}
|
||||
|
||||
line.startsWith("#EXT-X-MAP:") -> {
|
||||
val attrs = parseAttributes(line)
|
||||
attrs["URI"]?.let { uri ->
|
||||
mapUrl = resolveUrl(baseUrl, uri)
|
||||
}
|
||||
attrs["BYTERANGE"]?.let { br ->
|
||||
val (len, start) = parseByteRange(br)
|
||||
mapBytesLength = len
|
||||
mapBytesStart = start
|
||||
}
|
||||
}
|
||||
|
||||
line.startsWith("#EXTINF:") -> {
|
||||
val duration = line.substringAfter(":").substringBefore(",").toDoubleOrNull()
|
||||
?: throw Exception("Invalid segment duration format")
|
||||
val durationText = line.substringAfter(":").substringBefore(",")
|
||||
val duration = durationText.toDoubleOrNull()
|
||||
?: throw IllegalArgumentException("Invalid segment duration: '$line'")
|
||||
currentSegment = MediaSegment(duration = duration)
|
||||
}
|
||||
|
||||
line == "#EXT-X-DISCONTINUITY" -> {
|
||||
segments.add(DiscontinuitySegment())
|
||||
}
|
||||
line =="#EXT-X-ENDLIST" -> {
|
||||
|
||||
line == "#EXT-X-ENDLIST" -> {
|
||||
segments.add(EndListSegment())
|
||||
}
|
||||
else -> {
|
||||
|
||||
currentSegment != null && line.startsWith("#EXT-X-BYTERANGE:") -> {
|
||||
val br = line.substringAfter(":").trim()
|
||||
val (len, start) = parseByteRange(br)
|
||||
currentSegment!!.bytesLength = len
|
||||
currentSegment!!.bytesStart = start
|
||||
}
|
||||
|
||||
currentSegment != null && line.startsWith("#") -> {
|
||||
currentSegment!!.unhandled.add(line)
|
||||
}
|
||||
|
||||
!line.startsWith("#") -> {
|
||||
currentSegment?.let {
|
||||
it.uri = resolveUrl(sourceUrl, line)
|
||||
it.uri = resolveUrl(baseUrl, line)
|
||||
segments.add(it)
|
||||
currentSegment = null
|
||||
} ?: run {
|
||||
unhandled.add(line)
|
||||
}
|
||||
currentSegment = null
|
||||
}
|
||||
|
||||
else -> {
|
||||
unhandled.add(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return VariantPlaylist(version, targetDuration, mediaSequence, discontinuitySequence, programDateTime, playlistType, streamInfo, segments, decryptionInfo)
|
||||
return VariantPlaylist(
|
||||
version = version,
|
||||
targetDuration = targetDuration,
|
||||
mediaSequence = mediaSequence,
|
||||
discontinuitySequence = discontinuitySequence,
|
||||
programDateTime = programDateTime,
|
||||
playlistType = playlistType,
|
||||
streamInfo = streamInfo,
|
||||
segments = segments,
|
||||
decryptionInfo = decryptionInfo,
|
||||
mapUrl = mapUrl,
|
||||
mapBytesStart = mapBytesStart,
|
||||
mapBytesLength = mapBytesLength,
|
||||
unhandled = unhandled
|
||||
)
|
||||
}
|
||||
|
||||
fun parseAndGetVideoSources(source: Any, content: String, url: String): List<HLSVariantVideoUrlSource> {
|
||||
@@ -232,26 +374,6 @@ class HLS {
|
||||
return SessionData(dataId, value)
|
||||
}
|
||||
|
||||
private fun parseAttributes(content: String): Map<String, String> {
|
||||
val attributes = mutableMapOf<String, String>()
|
||||
val maybeAttributePairs = content.substringAfter(":").splitToSequence(',')
|
||||
|
||||
var currentPair = StringBuilder()
|
||||
for (pair in maybeAttributePairs) {
|
||||
currentPair.append(pair)
|
||||
if (currentPair.count { it == '\"' } % 2 == 0) { // Check if the number of quotes is even
|
||||
val key = currentPair.toString().substringBefore("=")
|
||||
val value = currentPair.toString().substringAfter("=")
|
||||
attributes[key.trim()] = value.trim().removeSurrounding("\"")
|
||||
currentPair = StringBuilder() // Reset for the next attribute
|
||||
} else {
|
||||
currentPair.append(',') // Continue building the current attribute pair
|
||||
}
|
||||
}
|
||||
|
||||
return attributes
|
||||
}
|
||||
|
||||
private val _quoteList = listOf("GROUP-ID", "NAME", "URI", "CODECS", "AUDIO", "VIDEO")
|
||||
private fun shouldQuote(key: String, value: String?): Boolean {
|
||||
if (value == null)
|
||||
@@ -345,11 +467,22 @@ class HLS {
|
||||
val variantPlaylistsRefs: List<VariantPlaylistReference>,
|
||||
val mediaRenditions: List<MediaRendition>,
|
||||
val sessionDataList: List<SessionData>,
|
||||
val independentSegments: Boolean
|
||||
val independentSegments: Boolean,
|
||||
val version: Int? = null,
|
||||
val mediaSequence: Long? = null,
|
||||
val unhandled: List<String> = emptyList()
|
||||
) {
|
||||
fun buildM3U8(): String {
|
||||
val builder = StringBuilder()
|
||||
builder.append("#EXTM3U\n")
|
||||
|
||||
version?.let {
|
||||
builder.append("#EXT-X-VERSION:$it\n")
|
||||
}
|
||||
mediaSequence?.let {
|
||||
builder.append("#EXT-X-MEDIA-SEQUENCE:$it\n")
|
||||
}
|
||||
|
||||
if (independentSegments) {
|
||||
builder.append("#EXT-X-INDEPENDENT-SEGMENTS\n")
|
||||
}
|
||||
@@ -404,9 +537,15 @@ class HLS {
|
||||
}
|
||||
|
||||
data class DecryptionInfo(
|
||||
val keyUrl: String,
|
||||
val iv: String?
|
||||
)
|
||||
val method: String,
|
||||
val keyUrl: String?,
|
||||
val iv: String?,
|
||||
val keyFormat: String?,
|
||||
val keyFormatVersions: String?
|
||||
) {
|
||||
val isEncrypted: Boolean
|
||||
get() = !method.equals("NONE", ignoreCase = true)
|
||||
}
|
||||
|
||||
data class VariantPlaylist(
|
||||
val version: Int?,
|
||||
@@ -417,7 +556,11 @@ class HLS {
|
||||
val playlistType: String?,
|
||||
val streamInfo: StreamInfo?,
|
||||
val segments: List<Segment>,
|
||||
val decryptionInfo: DecryptionInfo? = null
|
||||
val decryptionInfo: DecryptionInfo? = null,
|
||||
val mapUrl: String? = null,
|
||||
val mapBytesStart: Long = -1,
|
||||
val mapBytesLength: Long = -1,
|
||||
val unhandled: List<String> = emptyList()
|
||||
) {
|
||||
fun buildM3U8(): String = buildString {
|
||||
append("#EXTM3U\n")
|
||||
@@ -426,9 +569,50 @@ class HLS {
|
||||
mediaSequence?.let { append("#EXT-X-MEDIA-SEQUENCE:$it\n") }
|
||||
discontinuitySequence?.let { append("#EXT-X-DISCONTINUITY-SEQUENCE:$it\n") }
|
||||
playlistType?.let { append("#EXT-X-PLAYLIST-TYPE:$it\n") }
|
||||
programDateTime?.let { append("#EXT-X-PROGRAM-DATE-TIME:${it.format(DateTimeFormatter.ISO_DATE_TIME)}\n") }
|
||||
programDateTime?.let {
|
||||
append(
|
||||
"#EXT-X-PROGRAM-DATE-TIME:${
|
||||
it.withZoneSameInstant(java.time.ZoneOffset.UTC)
|
||||
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'"))
|
||||
}\n"
|
||||
)
|
||||
}
|
||||
streamInfo?.let { append(it.toM3U8Line()) }
|
||||
|
||||
decryptionInfo?.let { dec ->
|
||||
val sb = StringBuilder()
|
||||
sb.append("#EXT-X-KEY:METHOD=").append(dec.method)
|
||||
if (!dec.method.equals("NONE", ignoreCase = true)) {
|
||||
dec.keyUrl?.let { url ->
|
||||
sb.append(",URI=\"").append(url).append("\"")
|
||||
}
|
||||
dec.iv?.let { iv ->
|
||||
sb.append(",IV=0x").append(iv)
|
||||
}
|
||||
dec.keyFormat?.let { kf ->
|
||||
sb.append(",KEYFORMAT=\"").append(kf).append("\"")
|
||||
}
|
||||
dec.keyFormatVersions?.let { kfv ->
|
||||
sb.append(",KEYFORMATVERSIONS=\"").append(kfv).append("\"")
|
||||
}
|
||||
}
|
||||
append(sb.append("\n").toString())
|
||||
}
|
||||
|
||||
if (!mapUrl.isNullOrEmpty()) {
|
||||
val sb = StringBuilder()
|
||||
sb.append("#EXT-X-MAP:URI=\"").append(mapUrl).append("\"")
|
||||
if (mapBytesLength > 0) {
|
||||
if (mapBytesStart >= 0) {
|
||||
sb.append(",BYTERANGE=\"").append(mapBytesLength)
|
||||
.append("@").append(mapBytesStart).append("\"")
|
||||
} else {
|
||||
sb.append(",BYTERANGE=\"").append(mapBytesLength).append("\"")
|
||||
}
|
||||
}
|
||||
append(sb.append("\n").toString())
|
||||
}
|
||||
|
||||
segments.forEach { segment ->
|
||||
append(segment.toM3U8Line())
|
||||
}
|
||||
@@ -439,13 +623,25 @@ class HLS {
|
||||
abstract fun toM3U8Line(): String
|
||||
}
|
||||
|
||||
data class MediaSegment (
|
||||
data class MediaSegment(
|
||||
val duration: Double,
|
||||
var uri: String = ""
|
||||
var uri: String = "",
|
||||
var bytesStart: Long = -1,
|
||||
var bytesLength: Long = -1,
|
||||
val unhandled: MutableList<String> = mutableListOf()
|
||||
) : Segment() {
|
||||
override fun toM3U8Line(): String = buildString {
|
||||
append("#EXTINF:${duration},\n")
|
||||
append(uri + "\n")
|
||||
|
||||
if (bytesLength > 0) {
|
||||
if (bytesStart >= 0) {
|
||||
append("#EXT-X-BYTERANGE:${bytesLength}@${bytesStart}\n")
|
||||
} else {
|
||||
append("#EXT-X-BYTERANGE:${bytesLength}\n")
|
||||
}
|
||||
}
|
||||
|
||||
append(uri).append("\n")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.work.*
|
||||
import com.curlbind.Libcurl
|
||||
import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs.Action
|
||||
@@ -28,8 +29,6 @@ import com.futo.platformplayer.UIDialogs.Companion.showDialog
|
||||
import com.futo.platformplayer.activities.CaptchaActivity
|
||||
import com.futo.platformplayer.activities.IWithResultLauncher
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.activities.SettingsActivity
|
||||
import com.futo.platformplayer.activities.SettingsActivity.Companion.settingsActivityClosed
|
||||
import com.futo.platformplayer.api.media.platforms.js.DevJSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.background.BackgroundWorker
|
||||
@@ -38,6 +37,7 @@ import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.HomeFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SettingsFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SourceDetailFragment
|
||||
import com.futo.platformplayer.logging.AndroidLogConsumer
|
||||
import com.futo.platformplayer.logging.FileLogConsumer
|
||||
@@ -49,8 +49,11 @@ import com.futo.platformplayer.stores.FragmentedStorage
|
||||
import com.futo.platformplayer.stores.v2.ManagedStore
|
||||
import com.futo.platformplayer.views.ToastView
|
||||
import com.futo.polycentric.core.ApiMethods
|
||||
import com.futo.polycentric.core.toBase64Url
|
||||
import com.futo.platformplayer.polycentric.ModerationsManager
|
||||
import kotlinx.coroutines.*
|
||||
import java.io.File
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.system.measureTimeMillis
|
||||
@@ -65,6 +68,20 @@ class StateApp {
|
||||
|
||||
val sessionId = UUID.randomUUID().toString();
|
||||
|
||||
|
||||
var airplaneMode: Boolean = false
|
||||
get(){
|
||||
return field;
|
||||
}
|
||||
private set(value) {
|
||||
field = value;
|
||||
}
|
||||
val airplaneModeChanged = Event1<Boolean>();
|
||||
fun setAirMode(value: Boolean) {
|
||||
airplaneMode = value;
|
||||
airplaneModeChanged.emit(airplaneMode);
|
||||
}
|
||||
|
||||
var privateMode: Boolean = false
|
||||
get(){
|
||||
return field;
|
||||
@@ -78,6 +95,9 @@ class StateApp {
|
||||
privateModeChanged.emit(privateMode);
|
||||
}
|
||||
|
||||
var hasMediaStoreAudioPermission: Boolean = false;
|
||||
var hasMediaStoreVideoPermission: Boolean = false;
|
||||
|
||||
fun getExternalGeneralDirectory(context: Context): DocumentFile? {
|
||||
val generalUri = Settings.instance.storage.getStorageGeneralUri();
|
||||
if(isValidStorageUri(context, generalUri))
|
||||
@@ -159,6 +179,12 @@ class StateApp {
|
||||
?: throw IllegalStateException("Attempted to use a global context while MainActivity is no longer available");
|
||||
return thisContext;
|
||||
}
|
||||
val activity: MainActivity? get() {
|
||||
val context = contextOrNull;
|
||||
if(context is MainActivity)
|
||||
return context;
|
||||
return null;
|
||||
}
|
||||
|
||||
private var _mainId: String? = null;
|
||||
|
||||
@@ -171,6 +197,9 @@ class StateApp {
|
||||
private var _lastMeteredState: Boolean = false;
|
||||
private var _connectivityManager: ConnectivityManager? = null;
|
||||
private var _lastNetworkState: NetworkState = NetworkState.UNKNOWN;
|
||||
private var _lastConnectivityChange: OffsetDateTime? = null;
|
||||
val lastConnectivityChange
|
||||
get() = _lastConnectivityChange;
|
||||
|
||||
//Logging
|
||||
private var _fileLogConsumer: FileLogConsumer? = null;
|
||||
@@ -274,29 +303,52 @@ class StateApp {
|
||||
};
|
||||
}
|
||||
}
|
||||
fun requestDirectoryAccess(activity: IWithResultLauncher, name: String, purpose: String? = null, path: Uri?, handle: (Uri?)->Unit)
|
||||
fun requestDirectoryAccess(activity: IWithResultLauncher, name: String, purpose: String? = null, path: Uri?, handle: (Uri?)->Unit) {
|
||||
return requestDirectoryAccess(activity, name, purpose, path, handle, false);
|
||||
}
|
||||
fun requestDirectoryAccess(activity: IWithResultLauncher, name: String, purpose: String? = null, path: Uri?, handle: (Uri?)->Unit, skipDialog: Boolean = false)
|
||||
{
|
||||
if(activity is Context)
|
||||
{
|
||||
UIDialogs.showDialog(activity, R.drawable.ic_security, "Directory required for\n${name}", "Please select a directory for ${name}.\n${purpose}".trim(), null, 0,
|
||||
UIDialogs.Action("Cancel", {}),
|
||||
UIDialogs.Action("Ok", {
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
|
||||
if(path != null)
|
||||
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, path);
|
||||
intent.flags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
.or(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
.or(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
|
||||
.or(Intent.FLAG_GRANT_PREFIX_URI_PERMISSION);
|
||||
if(skipDialog) {
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
|
||||
if(path != null)
|
||||
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, path);
|
||||
intent.flags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
.or(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
.or(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
|
||||
.or(Intent.FLAG_GRANT_PREFIX_URI_PERMISSION);
|
||||
|
||||
activity.launchForResult(intent, 99) {
|
||||
if(it.resultCode == Activity.RESULT_OK) {
|
||||
handle(it.data?.data);
|
||||
}
|
||||
else
|
||||
UIDialogs.showDialogOk(context, R.drawable.ic_security_pred, "No access granted");
|
||||
};
|
||||
}
|
||||
else {
|
||||
UIDialogs.showDialog(activity, R.drawable.ic_security, "Directory required for\n${name}", "Please select a directory for ${name}.\n${purpose}".trim(), null, 0,
|
||||
UIDialogs.Action("Cancel", {}),
|
||||
UIDialogs.Action("Ok", {
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
|
||||
if(path != null)
|
||||
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, path);
|
||||
intent.flags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
.or(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
.or(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
|
||||
.or(Intent.FLAG_GRANT_PREFIX_URI_PERMISSION);
|
||||
|
||||
activity.launchForResult(intent, 99) {
|
||||
if(it.resultCode == Activity.RESULT_OK) {
|
||||
handle(it.data?.data);
|
||||
}
|
||||
else
|
||||
UIDialogs.showDialogOk(context, R.drawable.ic_security_pred, "No access granted");
|
||||
};
|
||||
}, UIDialogs.ActionStyle.PRIMARY));
|
||||
}
|
||||
|
||||
activity.launchForResult(intent, 99) {
|
||||
if(it.resultCode == Activity.RESULT_OK) {
|
||||
handle(it.data?.data);
|
||||
}
|
||||
else
|
||||
UIDialogs.showDialogOk(context, R.drawable.ic_security_pred, "No access granted");
|
||||
};
|
||||
}, UIDialogs.ActionStyle.PRIMARY));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,11 +432,44 @@ class StateApp {
|
||||
Logger.i(TAG, "MainApp Starting");
|
||||
initializeFiles(true);
|
||||
|
||||
_scope?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val caFile = AppCaUpdater.ensureCaBundle(context)
|
||||
Libcurl.setDefaultCAPath(caFile.absolutePath)
|
||||
Logger.i(TAG, "Libcurl initialized")
|
||||
} catch (t: Throwable) {
|
||||
Logger.e(TAG, "Failed to initialize Libcurl", t);
|
||||
}
|
||||
}
|
||||
|
||||
if(Settings.instance.other.polycentricLocalCache) {
|
||||
Logger.i(TAG, "Initialize Polycentric Disk Cache")
|
||||
_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");
|
||||
if (Settings.instance.logging.logLevel > LogLevel.NONE.value) {
|
||||
val fileLogConsumer = FileLogConsumer(logFile, LogLevel.fromInt(Settings.instance.logging.logLevel), false);
|
||||
@@ -424,7 +509,7 @@ class StateApp {
|
||||
StateSync.instance.start(context)
|
||||
}
|
||||
|
||||
settingsActivityClosed.subscribe {
|
||||
SettingsFragment.onClosed.subscribe {
|
||||
if (Settings.instance.synchronization.enabled) {
|
||||
StateSync.instance.start(context)
|
||||
} else {
|
||||
@@ -436,7 +521,7 @@ class StateApp {
|
||||
scopeOrNull?.launch(Dispatchers.Main) {
|
||||
try {
|
||||
if (!it.isNullOrEmpty()) {
|
||||
(SettingsActivity.getActivity() ?: contextOrNull)?.let { c ->
|
||||
(StateApp.instance.activity ?: contextOrNull)?.let { c ->
|
||||
val okButtonAction = Action(c.getString(R.string.ok), {}, ActionStyle.PRIMARY)
|
||||
val copyButtonAction = Action(c.getString(R.string.copy), {
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
@@ -565,7 +650,9 @@ class StateApp {
|
||||
scheduleBackgroundWork(context, interval != 0, interval);
|
||||
|
||||
Logger.i(TAG, "MainApp Started: Initialize [AutoBackup]");
|
||||
Settings.instance.backup.didAskAutoBackup = true; //Some users have issues with it
|
||||
if(!Settings.instance.backup.didAskAutoBackup && !Settings.instance.backup.shouldAutomaticBackup()) {
|
||||
/*
|
||||
StateAnnouncement.instance.registerAnnouncement("backup", "Set Automatic Backup", "Configure daily backups of your data to restore in case of catastrophic failure.", AnnouncementType.SESSION, null, null, "Configure", {
|
||||
if(context is IWithResultLauncher && !Settings.instance.storage.isStorageMainValid(context)) {
|
||||
UIDialogs.toast("Missing general directory");
|
||||
@@ -582,6 +669,7 @@ class StateApp {
|
||||
Settings.instance.backup.didAskAutoBackup = true;
|
||||
Settings.instance.save();
|
||||
});
|
||||
*/
|
||||
}
|
||||
else if(Settings.instance.backup.didAskAutoBackup && Settings.instance.backup.shouldAutomaticBackup() && !Settings.instance.storage.isStorageMainValid(context)) {
|
||||
if(context is IWithResultLauncher) {
|
||||
@@ -835,8 +923,11 @@ class StateApp {
|
||||
val beforeMeteredState = _lastMeteredState;
|
||||
_lastNetworkState = getCurrentNetworkState();
|
||||
_lastMeteredState = isCurrentMetered();
|
||||
if(beforeNetworkState != _lastNetworkState || beforeMeteredState != _lastMeteredState)
|
||||
if(beforeNetworkState != _lastNetworkState || beforeMeteredState != _lastMeteredState) {
|
||||
Logger.i(TAG, "Network capabilities changed (State: ${_lastNetworkState}, Metered: ${_lastMeteredState})");
|
||||
_lastConnectivityChange = OffsetDateTime.now();
|
||||
}
|
||||
|
||||
} catch(ex: Throwable) {
|
||||
Logger.w(TAG, "Failed to update network state", ex);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ class StateAssets {
|
||||
if(part == "." || part == "..") {
|
||||
if(parentAllowance <= 0)
|
||||
throw IllegalStateException("Path [${path}] attempted to escape path..");
|
||||
parts1.removeLast();
|
||||
parts1.removeAt(parts1.size - 1);
|
||||
toSkip++;
|
||||
}
|
||||
else
|
||||
|
||||
@@ -9,7 +9,6 @@ import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.activities.IWithResultLauncher
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.activities.SettingsActivity
|
||||
import com.futo.platformplayer.api.media.models.channels.SerializedChannel
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
||||
@@ -157,8 +156,8 @@ class StateBackup {
|
||||
}
|
||||
catch (exSec: FileNotFoundException) {
|
||||
Logger.e(TAG, "Failed to access backup file", exSec);
|
||||
val activity = if(SettingsActivity.getActivity() != null)
|
||||
SettingsActivity.getActivity();
|
||||
val activity = if(StateApp.instance.activity != null)
|
||||
StateApp.instance.activity
|
||||
else if(StateApp.instance.isMainActive)
|
||||
StateApp.instance.contextOrNull;
|
||||
else null;
|
||||
@@ -226,7 +225,7 @@ class StateBackup {
|
||||
StateApp.instance.contextOrNull?.let {
|
||||
val uri = FileProvider.getUriForFile(it, it.resources.getString(R.string.authority), exportFile);
|
||||
|
||||
val activity = SettingsActivity.getActivity() ?: return@let;
|
||||
val activity = StateApp.instance.activity ?: return@let;
|
||||
activity.startActivity(
|
||||
ShareCompat.IntentBuilder(activity)
|
||||
.setType("application/zip")
|
||||
@@ -366,7 +365,7 @@ class StateBackup {
|
||||
}
|
||||
val hist = StateHistory.instance.getHistoryByVideo(histObj.video, true, histObj.date);
|
||||
if(hist != null)
|
||||
StateHistory.instance.updateHistoryPosition(histObj.video, hist, true, histObj.position, histObj.date, false);
|
||||
StateHistory.instance.updateHistoryPosition(histObj.video, hist, true, histObj.position, histObj.date, false, histObj.playlistId);
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e(TAG, "Failed to import subscription group", ex);
|
||||
|
||||
@@ -439,7 +439,7 @@ class StateDownloads {
|
||||
} else {
|
||||
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> {
|
||||
@@ -543,7 +543,9 @@ class StateDownloads {
|
||||
val file = export.export(context, { progress ->
|
||||
val now = System.currentTimeMillis();
|
||||
if (lastNotifyTime == -1L || now - lastNotifyTime > 100) {
|
||||
it.setProgress(progress);
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||
it.setProgress(progress);
|
||||
}
|
||||
lastNotifyTime = now;
|
||||
}
|
||||
}, null);
|
||||
|
||||
@@ -65,7 +65,7 @@ class StateHistory {
|
||||
}
|
||||
|
||||
private var _lastHistoryBroadcast = "";
|
||||
fun updateHistoryPosition(liveObj: IPlatformVideo, index: DBHistory.Index, updateExisting: Boolean, position: Long = -1L, date: OffsetDateTime? = null, isUserAction: Boolean = false): Long {
|
||||
fun updateHistoryPosition(liveObj: IPlatformVideo, index: DBHistory.Index, updateExisting: Boolean, position: Long = -1L, date: OffsetDateTime? = null, isUserAction: Boolean = false, playlistId: String? = null): Long {
|
||||
val pos = if(position < 0) 0 else position;
|
||||
val historyVideo = index.obj;
|
||||
|
||||
@@ -86,6 +86,7 @@ class StateHistory {
|
||||
|
||||
historyVideo.position = pos;
|
||||
historyVideo.date = date ?: OffsetDateTime.now();
|
||||
historyVideo.playlistId = playlistId
|
||||
_historyDBStore.update(index.id!!, historyVideo);
|
||||
onHistoricVideoChanged.emit(liveObj, pos);
|
||||
|
||||
@@ -157,7 +158,7 @@ class StateHistory {
|
||||
UIDialogs.toast("History item null?\nNo history tracking..");
|
||||
}
|
||||
else if(create) {
|
||||
val newHistItem = HistoryVideo(SerializedPlatformVideo.fromVideo(video), 0, watchDate ?: OffsetDateTime.now());
|
||||
val newHistItem = HistoryVideo(SerializedPlatformVideo.fromVideo(video), 0, watchDate ?: OffsetDateTime.now(), StatePlayer.instance.playlistId);
|
||||
val id = _historyDBStore.insert(newHistItem);
|
||||
result = _historyDBStore.getOrNull(id);
|
||||
if(result == null)
|
||||
|
||||
@@ -0,0 +1,774 @@
|
||||
package com.futo.platformplayer.states
|
||||
|
||||
import android.content.ContentUris
|
||||
import android.content.Intent
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import android.provider.MediaStore.Audio.Artists
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.collection.emptyLongSet
|
||||
import androidx.core.database.getStringOrNull
|
||||
import androidx.core.net.toFile
|
||||
import androidx.core.net.toUri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.api.media.PlatformID
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||
import com.futo.platformplayer.api.media.models.Thumbnail
|
||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
|
||||
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.LocalVideoDetails
|
||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
||||
import com.futo.platformplayer.api.media.structures.AdhocPager
|
||||
import com.futo.platformplayer.api.media.structures.EmptyPager
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.Playlist
|
||||
import com.futo.platformplayer.states.Album.Companion.TAG
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
import com.futo.platformplayer.stores.StringArrayStorage
|
||||
import com.futo.platformplayer.toList
|
||||
import java.io.File
|
||||
import java.time.Instant
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.ConcurrentMap
|
||||
|
||||
|
||||
class StateLibrary {
|
||||
|
||||
private val _files = FragmentedStorage.get<StringArrayStorage>("libraryFiles")
|
||||
|
||||
|
||||
fun getFileDirectories(): List<FileEntry> {
|
||||
val context = StateApp.instance.contextOrNull ?: return listOf();
|
||||
return _files.getAllValues().map {
|
||||
if(it.startsWith("content://")) {
|
||||
val uri = it.toUri();
|
||||
val docFile = DocumentFile.fromTreeUri(context, uri) ?: return@map null;
|
||||
//val access = context.contentResolver.persistedUriPermissions.any { it.uri == uri && it.isReadPermission }
|
||||
if(!docFile.isDirectory) {
|
||||
_files.remove(it);
|
||||
return@map null;
|
||||
}
|
||||
if(docFile == null)
|
||||
return@map null;
|
||||
return@map FileEntry.fromFile(docFile).apply { this.removable = true }
|
||||
}
|
||||
else
|
||||
FileEntry.fromPath(it);
|
||||
}.filterNotNull();
|
||||
}
|
||||
fun deleteFileDirectory(path: String) {
|
||||
_files.remove(path);
|
||||
_files.save();
|
||||
}
|
||||
fun addFileDirectory(onAdded: ((entry: FileEntry) -> Unit)? = null, skipDialog: Boolean = false): Boolean {
|
||||
if(!StateApp.instance.isMainActive)
|
||||
return false;
|
||||
val mainActivity = StateApp.instance.contextOrNull as MainActivity? ?: return false;
|
||||
|
||||
StateApp.instance.requestDirectoryAccess(mainActivity, "Select Directory",
|
||||
"Select a directory you would like to make accessible to Grayjay", null, {
|
||||
if(it != null) {
|
||||
mainActivity.contentResolver.takePersistableUriPermission(it, Intent.FLAG_GRANT_WRITE_URI_PERMISSION.or(Intent.FLAG_GRANT_READ_URI_PERMISSION));
|
||||
try {
|
||||
val file = DocumentFile.fromTreeUri(mainActivity, it) ?: return@requestDirectoryAccess;
|
||||
val dir = FileEntry.fromFile(file);
|
||||
_files.add(dir.path);
|
||||
_files.save();
|
||||
onAdded?.invoke(dir);
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e(TAG, "Something went wrong converting requested directory", ex);
|
||||
}
|
||||
}
|
||||
}, skipDialog);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
fun searchTracks(str: String): List<IPlatformVideo> {
|
||||
if(str.isNullOrBlank())
|
||||
return listOf();
|
||||
val resolver = StateApp.instance.contextOrNull?.contentResolver;
|
||||
if(resolver == null) {
|
||||
Logger.w(TAG, "Album contentResolver not found");
|
||||
return listOf();
|
||||
}
|
||||
val cursor = resolver?.query(
|
||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, StateLibrary.PROJECTION_MEDIA,
|
||||
"LOWER(" + MediaStore.Audio.Media.DISPLAY_NAME + ") LIKE ? ", arrayOf("%" + str.trim().lowercase() + "%"),
|
||||
null) ?: return listOf();
|
||||
return cursor.use {
|
||||
cursor.moveToFirst();
|
||||
val list = mutableListOf<IPlatformVideo>()
|
||||
while(!cursor.isAfterLast) {
|
||||
list.add(StateLibrary.audioFromCursor(cursor));
|
||||
cursor.moveToNext();
|
||||
}
|
||||
return@use list;
|
||||
}
|
||||
}
|
||||
|
||||
fun getAlbums(): List<Album> {
|
||||
return Album.getAlbums();
|
||||
}
|
||||
fun getAlbum(str: String): Album? {
|
||||
val idLong = str.toLongOrNull();
|
||||
if(idLong != null)
|
||||
return getAlbum(idLong);
|
||||
return null;
|
||||
}
|
||||
fun searchAlbums(str: String): List<Album> {
|
||||
if(str.isNullOrBlank())
|
||||
return listOf();
|
||||
return Album.getAlbums("LOWER(" + MediaStore.Audio.Albums.ALBUM + ") LIKE ? ", arrayOf("%" + str.trim().lowercase() + "%"));
|
||||
}
|
||||
|
||||
fun getAlbum(id: Long): Album? {
|
||||
return Album.getAlbum(id);
|
||||
}
|
||||
|
||||
fun getArtists(ordering: ArtistOrdering): List<Artist> {
|
||||
return Artist.getArtists(ordering);
|
||||
}
|
||||
fun getArtist(str: String): Artist? {
|
||||
val idLong = str.toLongOrNull();
|
||||
if(idLong != null)
|
||||
return getArtist(idLong);
|
||||
return null;
|
||||
}
|
||||
fun searchArtists(str: String): List<Artist> {
|
||||
if(str.isNullOrBlank())
|
||||
return listOf();
|
||||
return Artist.getArtists(ArtistOrdering.TrackCount, "LOWER(" + MediaStore.Audio.Artists.ARTIST + ") LIKE ? ", arrayOf("%" + str.trim().lowercase() + "%"));
|
||||
}
|
||||
|
||||
fun getArtist(id: Long): Artist? {
|
||||
return Artist.getArtist(id);
|
||||
}
|
||||
|
||||
fun getVideos(buckets: List<String>? = null): IPager<IPlatformContent> {
|
||||
var query = if(buckets != null) "${MediaStore.Video.Media.BUCKET_DISPLAY_NAME} IN " + "(" + buckets.map { "'${it}'" }.joinToString(",") + ")" else null;
|
||||
val cursor = StateApp.instance.contextOrNull?.contentResolver?.query(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, PROJECTION_VIDEO,
|
||||
query,
|
||||
null,
|
||||
MediaStore.Video.Media.DATE_ADDED + " DESC") ?: return EmptyPager();
|
||||
|
||||
//Ongoing usage of cursor..todo disposal
|
||||
//return cursor.use {
|
||||
cursor.moveToFirst();
|
||||
val list = mutableListOf<IPlatformVideo>()
|
||||
while(!cursor.isAfterLast && list.size < 10) {
|
||||
list.add(videoFromCursor(cursor));
|
||||
cursor.moveToNext();
|
||||
}
|
||||
|
||||
return AdhocPager<IPlatformContent>({
|
||||
val list = mutableListOf<IPlatformContent>()
|
||||
while(!cursor.isAfterLast && list.size < 10) {
|
||||
list.add(videoFromCursor(cursor));
|
||||
cursor.moveToNext();
|
||||
}
|
||||
Logger.i(TAG, "Videos nextPage: ${list.size}")
|
||||
return@AdhocPager list;
|
||||
}, list);
|
||||
//}
|
||||
}
|
||||
fun getRecentVideos(buckets: List<String>? = null, count: Int = 20): List<IPlatformVideo> {
|
||||
val videoPager = getVideos(buckets);
|
||||
val items = mutableListOf<IPlatformVideo>();
|
||||
while(videoPager.getResults().size > 0 && items.size < count) {
|
||||
items.addAll(videoPager.getResults().filter { it is IPlatformVideo }.map { it as IPlatformVideo });
|
||||
if(videoPager.hasMorePages())
|
||||
videoPager.nextPage();
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
private var _cacheBucketNames: List<Bucket>? = null;
|
||||
fun getVideoBucketNames(): List<Bucket> {
|
||||
if(Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU)
|
||||
return listOf();
|
||||
if(_cacheBucketNames != null)
|
||||
return _cacheBucketNames ?: listOf();
|
||||
try {
|
||||
val cur: Cursor = StateApp.instance.contextOrNull?.contentResolver?.query(
|
||||
MediaStore.Video.Media.EXTERNAL_CONTENT_URI, arrayOf(
|
||||
MediaStore.Video.Media.BUCKET_ID,
|
||||
MediaStore.Video.Media.BUCKET_DISPLAY_NAME,
|
||||
), null, null, null
|
||||
) ?: return listOf();
|
||||
|
||||
return cur.use {
|
||||
val buckets = mutableListOf<Bucket>();
|
||||
val list = HashSet<Long>();
|
||||
if (cur.moveToFirst()) {
|
||||
var id: Long;
|
||||
var bucket: String
|
||||
do {
|
||||
try {
|
||||
id = cur.getLong(0);
|
||||
bucket = cur.getStringOrNull(1) ?: continue;
|
||||
if (!list.contains(id)) {
|
||||
list.add(id);
|
||||
buckets.add(Bucket(id, bucket));
|
||||
}
|
||||
} catch (ex: Throwable) {
|
||||
Logger.e(TAG, "Failed to parse bucket due to ${ex.message}", ex);
|
||||
}
|
||||
} while (cur.moveToNext())
|
||||
}
|
||||
_cacheBucketNames = buckets.toList()
|
||||
return@use _cacheBucketNames ?: listOf();
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e(TAG, "Buckets loading failed, returning empty");
|
||||
return listOf();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
val PROJECTION_VIDEO = arrayOf(
|
||||
MediaStore.Video.Media._ID,
|
||||
MediaStore.Video.Media.DISPLAY_NAME,
|
||||
MediaStore.Video.Media.DATE_ADDED,
|
||||
MediaStore.Video.Media.MIME_TYPE,
|
||||
MediaStore.Video.Media.BUCKET_DISPLAY_NAME
|
||||
);
|
||||
val PROJECTION_MEDIA = arrayOf(
|
||||
MediaStore.Audio.Media._ID, //0
|
||||
MediaStore.Audio.Media.DISPLAY_NAME, //1
|
||||
MediaStore.Audio.Media.ARTIST, //2
|
||||
MediaStore.Audio.Media.ARTIST_ID, //3
|
||||
MediaStore.Audio.Media.ALBUM_ID, //4
|
||||
MediaStore.Audio.Media.DURATION, //5
|
||||
MediaStore.Audio.Media.DATE_ADDED, //6
|
||||
MediaStore.Audio.Media.MIME_TYPE, //7
|
||||
MediaStore.Audio.Media.BUCKET_DISPLAY_NAME //8
|
||||
);
|
||||
|
||||
fun getDocumentTrack(url: String): IPlatformContentDetails? {
|
||||
if(!url.contains("com.android.externalstorage.documents"))
|
||||
return null;
|
||||
val docFile = DocumentFile.fromSingleUri(StateApp.instance.context, url.toUri()) ?: return null;
|
||||
|
||||
val contentUri = docFile.uri.toString();
|
||||
|
||||
val mimeType = MimeTypeMap.getFileExtensionFromUrl(contentUri);
|
||||
|
||||
if(docFile.name != null) {
|
||||
if (StateApp.instance.hasMediaStoreAudioPermission && mimeType.startsWith("audio/")) {
|
||||
val aud = findAudioByName(docFile.name!!);
|
||||
if (aud != null)
|
||||
return aud;
|
||||
}
|
||||
if (StateApp.instance.hasMediaStoreVideoPermission && mimeType.startsWith("video/")) {
|
||||
val vid = findVideoByName(docFile.name!!);
|
||||
if (vid != null)
|
||||
return vid;
|
||||
}
|
||||
}
|
||||
|
||||
return LocalVideoDetails(
|
||||
PlatformID("FILE", contentUri, null, 0, -1),
|
||||
docFile.name ?: docFile.uri.toString(), Thumbnails(arrayOf(
|
||||
Thumbnail(docFile.uri.toString(), 0)
|
||||
)), PlatformAuthorLink.UNKNOWN, contentUri, 0, mimeType, null);
|
||||
}
|
||||
|
||||
fun getAudioTrack(url: String): IPlatformContentDetails? {
|
||||
val uri = Uri.parse(url);
|
||||
val id = uri.lastPathSegment?.toLongOrNull();
|
||||
if(id == null) {
|
||||
return getDocumentTrack(url);
|
||||
}
|
||||
|
||||
val resolver = StateApp.instance.contextOrNull?.contentResolver;
|
||||
if(resolver == null) {
|
||||
Logger.w(TAG, "Album contentResolver not found");
|
||||
return null;
|
||||
}
|
||||
val cursor = resolver?.query(
|
||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, StateLibrary.PROJECTION_MEDIA, "${MediaStore.Audio.Media._ID} = ?", arrayOf(id.toString()),
|
||||
null) ?: return null;
|
||||
return cursor.use {
|
||||
cursor.moveToFirst();
|
||||
if(cursor.isAfterLast)
|
||||
return@use null;
|
||||
return@use audioFromCursor(cursor);
|
||||
}
|
||||
}
|
||||
fun findAudioByName(name: String): IPlatformContentDetails? {
|
||||
val resolver = StateApp.instance.contextOrNull?.contentResolver;
|
||||
if(resolver == null) {
|
||||
Logger.w(TAG, "Audio contentResolver not found");
|
||||
return null;
|
||||
}
|
||||
val cursor = resolver?.query(
|
||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, StateLibrary.PROJECTION_MEDIA, "${MediaStore.Audio.Media.DISPLAY_NAME} = ?", arrayOf(name),
|
||||
null) ?: return null;
|
||||
return cursor.use {
|
||||
cursor.moveToFirst();
|
||||
if(cursor.isAfterLast)
|
||||
return null;
|
||||
return@use audioFromCursor(cursor);
|
||||
}
|
||||
}
|
||||
fun getVideoTrack(url: String): IPlatformContentDetails? {
|
||||
val uri = Uri.parse(url);
|
||||
val id = uri.lastPathSegment?.toLongOrNull();
|
||||
if(id == null)
|
||||
return getDocumentTrack(url);
|
||||
|
||||
val resolver = StateApp.instance.contextOrNull?.contentResolver;
|
||||
if(resolver == null) {
|
||||
Logger.w(TAG, "Album contentResolver not found");
|
||||
return null;
|
||||
}
|
||||
val cursor = resolver?.query(
|
||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, StateLibrary.PROJECTION_VIDEO, "${MediaStore.Video.Media._ID} = ?", arrayOf(id.toString()),
|
||||
null) ?: return null;
|
||||
return cursor.use {
|
||||
cursor.moveToFirst();
|
||||
if(cursor.isAfterLast)
|
||||
return@use null;
|
||||
return@use videoFromCursor(cursor);
|
||||
}
|
||||
}
|
||||
fun findVideoByName(name: String): IPlatformContentDetails? {
|
||||
val resolver = StateApp.instance.contextOrNull?.contentResolver;
|
||||
if(resolver == null) {
|
||||
Logger.w(TAG, "Album contentResolver not found");
|
||||
return null;
|
||||
}
|
||||
val cursor = resolver?.query(
|
||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, StateLibrary.PROJECTION_VIDEO, "${MediaStore.Video.Media.DISPLAY_NAME} = ?", arrayOf(name),
|
||||
null) ?: return null;
|
||||
return cursor.use {
|
||||
cursor.moveToFirst();
|
||||
if(cursor.isAfterLast)
|
||||
return@use null;
|
||||
return@use videoFromCursor(cursor);
|
||||
}
|
||||
}
|
||||
|
||||
fun audioFromCursor(cursor: Cursor): IPlatformVideoDetails {
|
||||
val id = cursor.getString(0);
|
||||
val displayName = cursor.getString(1);
|
||||
val author = cursor.getString(2);
|
||||
val authorId = cursor.getStringOrNull(3);
|
||||
val albumId = cursor.getLong(4);
|
||||
val duration = cursor.getLong(5).let { if(it > 0) it / 1000 else 0 };
|
||||
val date = cursor.getLong(6);
|
||||
val contentType = cursor.getString(7);
|
||||
val category = cursor.getString(8);
|
||||
|
||||
val idLong = id.toLongOrNull();
|
||||
val contentUrl = if(idLong != null )
|
||||
ContentUris.withAppendedId(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, idLong).toString();
|
||||
else
|
||||
"";
|
||||
|
||||
val authorIdLong = authorId?.toLongOrNull();
|
||||
val authorUrl = if(authorIdLong != null)
|
||||
ContentUris.withAppendedId(MediaStore.Audio.Artists.EXTERNAL_CONTENT_URI, authorIdLong).toString();
|
||||
else
|
||||
"";
|
||||
|
||||
|
||||
val albumContentUrl = if(albumId > 0)
|
||||
ContentUris.withAppendedId(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, albumId)?.toString()
|
||||
else null;
|
||||
|
||||
val dateObj = if(date > 0)
|
||||
OffsetDateTime.ofInstant(Instant.ofEpochSecond(date), ZoneOffset.UTC)
|
||||
else null;
|
||||
|
||||
val authorObj = if(!author.isNullOrBlank())
|
||||
PlatformAuthorLink(
|
||||
if(authorId != null) PlatformID("LOCAL", authorId) else PlatformID.NONE,
|
||||
author,
|
||||
authorUrl, null, null)
|
||||
else PlatformAuthorLink.UNKNOWN;
|
||||
|
||||
return LocalVideoDetails(
|
||||
PlatformID("FILE", contentUrl, null, 0, -1),
|
||||
displayName, Thumbnails(arrayOf(
|
||||
Thumbnail(albumContentUrl ?: contentUrl, 0)
|
||||
)), authorObj, contentUrl, duration, contentType, dateObj);
|
||||
}
|
||||
fun videoFromCursor(cursor: Cursor): IPlatformVideoDetails {
|
||||
val id = cursor.getString(0);
|
||||
val displayName = cursor.getString(1);
|
||||
val author = null;//cursor.getString(2);
|
||||
val date = cursor.getLong(2);
|
||||
val contentType = cursor.getString(3);
|
||||
val category = cursor.getString(4);
|
||||
|
||||
val idLong = id.toLongOrNull();
|
||||
val contentUrl = if(idLong != null )
|
||||
ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, idLong).toString();
|
||||
else
|
||||
"";
|
||||
|
||||
val dateObj = if(date > 0)
|
||||
OffsetDateTime.ofInstant(Instant.ofEpochSecond(date), ZoneOffset.UTC)
|
||||
else null;
|
||||
|
||||
val authorObj = if(!author.isNullOrBlank())
|
||||
PlatformAuthorLink(PlatformID.NONE, author, "", null, null)
|
||||
else PlatformAuthorLink.UNKNOWN;
|
||||
|
||||
return LocalVideoDetails(
|
||||
PlatformID("FILE", contentUrl, null, 0, -1),
|
||||
displayName, Thumbnails(arrayOf(
|
||||
Thumbnail(contentUrl, 0)
|
||||
)), authorObj, contentUrl, -1, contentType, dateObj);
|
||||
}
|
||||
|
||||
private var _instance : StateLibrary? = null;
|
||||
val instance : StateLibrary
|
||||
get(){
|
||||
if(_instance == null)
|
||||
_instance = StateLibrary();
|
||||
return _instance!!;
|
||||
};
|
||||
|
||||
fun finish() {
|
||||
_instance?.let {
|
||||
_instance = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Bucket(val id: Long, val name: String);
|
||||
|
||||
|
||||
enum class ArtistOrdering {
|
||||
Alphabethic,
|
||||
TrackCount,
|
||||
AlbumCount
|
||||
}
|
||||
class Artist {
|
||||
val id: String;
|
||||
val name: String;
|
||||
val countTracks: Int;
|
||||
val countAlbums: Int;
|
||||
val thumbnail: String?;
|
||||
val contentUrl: String?;
|
||||
|
||||
constructor(name: String, countTracks: Int = -1, countAlbums: Int = -1, thumbnail: String? = null, id: String? = null, contentUrl: String? = null) {
|
||||
this.id = id ?: ID_UNKNOWN;
|
||||
this.name = name;
|
||||
this.thumbnail = thumbnail;
|
||||
this.countTracks = countTracks;
|
||||
this.countAlbums = countAlbums;
|
||||
this.contentUrl = contentUrl;
|
||||
}
|
||||
|
||||
fun getAlbums(): List<Album> {
|
||||
return Album.getArtistAlbums(id.toLongOrNull() ?: return listOf());
|
||||
}
|
||||
|
||||
fun toPlaylist(tracks: List<IPlatformVideo>? = null): Playlist {
|
||||
return Playlist(name, tracks?.map { SerializedPlatformVideo.fromVideo(it) } ?: getAudioTracks().toList().filter { it is IPlatformVideo }.map { SerializedPlatformVideo.fromVideo(it as IPlatformVideo) })
|
||||
}
|
||||
|
||||
fun getAudioTracks(): IPager<IPlatformContent> {
|
||||
val idLong = id.toLongOrNull() ?: return EmptyPager();
|
||||
return AdhocPager({ listOf() }, getTracksPager(idLong));
|
||||
}
|
||||
|
||||
fun getThumbnailOrAlbum(): String? {
|
||||
return thumbnail ?: tryGetArtistThumbnail(id.toLongOrNull());
|
||||
}
|
||||
|
||||
companion object {
|
||||
val ID_UNKNOWN = "UNKNOWN";
|
||||
val PROJECTION: Array<String> = arrayOf(Artists._ID,
|
||||
Artists.ARTIST,
|
||||
Artists.NUMBER_OF_TRACKS,
|
||||
Artists.NUMBER_OF_ALBUMS);
|
||||
|
||||
val thumbnailCache = ConcurrentHashMap<Long, String>();
|
||||
|
||||
fun tryGetArtistThumbnail(artistId: Long?): String? {
|
||||
if(artistId == null)
|
||||
return null;
|
||||
if(thumbnailCache.containsKey(artistId))
|
||||
return thumbnailCache.get(artistId);
|
||||
else {
|
||||
val album = Album.getArtistAlbumWithThumbnail(artistId);
|
||||
thumbnailCache.put(artistId, album?.thumbnail ?: "");
|
||||
return album?.thumbnail;
|
||||
}
|
||||
}
|
||||
|
||||
fun fromCursor(cursor: Cursor): Artist {
|
||||
val id = cursor.getString(0);
|
||||
val artist = cursor.getString(1);
|
||||
val numTracks = cursor.getInt(2);
|
||||
val numAlbums = cursor.getInt(3);
|
||||
|
||||
val idLong = id.toLongOrNull();
|
||||
val uri = if(idLong != null) ContentUris.withAppendedId(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, idLong) else null;
|
||||
|
||||
return Artist(artist, numTracks, numAlbums, null, id, uri?.toString());
|
||||
}
|
||||
|
||||
fun getArtist(id: Long): Artist? {
|
||||
val resolver = StateApp.instance.contextOrNull?.contentResolver;
|
||||
if(resolver == null) {
|
||||
Logger.w(TAG, "Artist contentResolver not found");
|
||||
return null
|
||||
}
|
||||
val cursor = resolver.query(MediaStore.Audio.Artists.EXTERNAL_CONTENT_URI,
|
||||
Artist.PROJECTION,
|
||||
"${MediaStore.Audio.Artists._ID} = ?",
|
||||
arrayOf(id.toString()), null) ?: return null;
|
||||
return cursor.use {
|
||||
cursor.moveToFirst();
|
||||
if(cursor.isAfterLast)
|
||||
return@use null;
|
||||
return@use Artist.fromCursor(cursor);
|
||||
}
|
||||
}
|
||||
fun getArtists(ordering: ArtistOrdering = ArtistOrdering.Alphabethic, query: String? = null, args: Array<String>? = null): List<Artist> {
|
||||
val ordering = when(ordering) {
|
||||
ArtistOrdering.Alphabethic -> Artists.ARTIST + " ASC";
|
||||
ArtistOrdering.AlbumCount -> Artists.NUMBER_OF_ALBUMS + " DESC";
|
||||
ArtistOrdering.TrackCount -> Artists.NUMBER_OF_TRACKS + " DESC";
|
||||
else -> null
|
||||
}
|
||||
|
||||
val cursor = StateApp.instance.contextOrNull?.contentResolver?.query(Artists.EXTERNAL_CONTENT_URI, PROJECTION,
|
||||
query,
|
||||
args,
|
||||
ordering) ?: return listOf();
|
||||
return cursor.use {
|
||||
cursor.moveToFirst();
|
||||
val list = mutableListOf<Artist>()
|
||||
while(!cursor.isAfterLast) {
|
||||
val artist = fromCursor(cursor);
|
||||
cursor.moveToNext();
|
||||
if(artist.name == "<unknown>")
|
||||
continue; //TODO: Better way of detecting unknown?
|
||||
list.add(artist);
|
||||
}
|
||||
return@use list;
|
||||
}
|
||||
}
|
||||
|
||||
fun getTracksPager(artistId: Long): List<IPlatformVideo> {
|
||||
val resolver = StateApp.instance.contextOrNull?.contentResolver;
|
||||
if(resolver == null) {
|
||||
Logger.w(TAG, "Album contentResolver not found");
|
||||
return listOf();
|
||||
}
|
||||
val cursor = resolver?.query(
|
||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, StateLibrary.PROJECTION_MEDIA, "${MediaStore.Audio.Media.ARTIST_ID} = ?", arrayOf(artistId.toString()),
|
||||
null) ?: return listOf();
|
||||
return cursor.use {
|
||||
cursor.moveToFirst();
|
||||
val list = mutableListOf<IPlatformVideo>()
|
||||
while(!cursor.isAfterLast) {
|
||||
list.add(StateLibrary.audioFromCursor(cursor));
|
||||
cursor.moveToNext();
|
||||
}
|
||||
return@use list;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Album {
|
||||
val id: String;
|
||||
val name: String;
|
||||
val artist: String?;
|
||||
val countTracks: Int;
|
||||
val thumbnail: String?;
|
||||
|
||||
constructor(name: String, countTracks: Int = -1, artist: String? = null, id: String? = null, thumbnail: String? = null) {
|
||||
this.id = id ?: ID_UNKNOWN;
|
||||
this.name = name;
|
||||
this.artist = artist;
|
||||
this.countTracks = countTracks;
|
||||
this.thumbnail = thumbnail;
|
||||
}
|
||||
|
||||
fun getTracks(): List<IPlatformVideo> {
|
||||
return getAlbumTracks(id.toLongOrNull() ?: return listOf())
|
||||
}
|
||||
|
||||
fun toPlaylist(tracks: List<IPlatformVideo>? = null): Playlist {
|
||||
return Playlist(name, tracks?.map { SerializedPlatformVideo.fromVideo(it) } ?: getTracks().map { SerializedPlatformVideo.fromVideo(it) })
|
||||
}
|
||||
|
||||
companion object {
|
||||
val TAG = "StateLibrary";
|
||||
val ID_UNKNOWN = "UNKNOWN";
|
||||
val PROJECTION = arrayOf(MediaStore.Audio.Albums.ALBUM_ID,
|
||||
MediaStore.Audio.Albums.ALBUM,
|
||||
MediaStore.Audio.Albums.NUMBER_OF_SONGS,
|
||||
MediaStore.Audio.Albums.ARTIST);
|
||||
|
||||
fun fromCursor(cursor: Cursor): Album {
|
||||
val id = cursor.getString(0);
|
||||
val album = cursor.getString(1);
|
||||
val numTracks = cursor.getInt(2);
|
||||
val artist = cursor.getString(3);
|
||||
|
||||
val idLong = id.toLongOrNull();
|
||||
val uri = if(idLong != null) ContentUris.withAppendedId(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, idLong) else null;
|
||||
return Album(album, numTracks, artist, id, uri?.toString());
|
||||
}
|
||||
|
||||
fun getAlbumTracks(albumId: Long): List<IPlatformVideo> {
|
||||
val resolver = StateApp.instance.contextOrNull?.contentResolver;
|
||||
if(resolver == null) {
|
||||
Logger.w(TAG, "Album contentResolver not found");
|
||||
return listOf();
|
||||
}
|
||||
val cursor = resolver?.query(
|
||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, StateLibrary.PROJECTION_MEDIA, "${MediaStore.Audio.Media.ALBUM_ID} = ?", arrayOf(albumId.toString()),
|
||||
null) ?: return listOf();
|
||||
return cursor.use {
|
||||
cursor.moveToFirst();
|
||||
val list = mutableListOf<IPlatformVideo>()
|
||||
while(!cursor.isAfterLast) {
|
||||
list.add(StateLibrary.audioFromCursor(cursor));
|
||||
cursor.moveToNext();
|
||||
}
|
||||
return@use list;
|
||||
}
|
||||
}
|
||||
fun getAlbum(id: Long): Album? {
|
||||
val resolver = StateApp.instance.contextOrNull?.contentResolver;
|
||||
if(resolver == null) {
|
||||
Logger.w(TAG, "Album contentResolver not found");
|
||||
return null
|
||||
}
|
||||
val cursor = resolver.query(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI,
|
||||
PROJECTION,
|
||||
"${MediaStore.Audio.Albums.ALBUM_ID} = ?",
|
||||
arrayOf(id.toString()), null) ?: return null;
|
||||
return cursor.use {
|
||||
cursor.moveToFirst();
|
||||
if(cursor.isAfterLast)
|
||||
return@use null;
|
||||
return@use fromCursor(cursor);
|
||||
}
|
||||
}
|
||||
fun getAlbums(query: String? = null, args: Array<String>? = null): List<Album> {
|
||||
val resolver = StateApp.instance.contextOrNull?.contentResolver;
|
||||
if(resolver == null) {
|
||||
Logger.w(TAG, "Album contentResolver not found");
|
||||
return listOf();
|
||||
}
|
||||
val cursor = resolver?.query(
|
||||
MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, PROJECTION, query, args,
|
||||
MediaStore.Audio.Albums.ALBUM + " ASC") ?: return listOf();
|
||||
return cursor.use {
|
||||
cursor.moveToFirst();
|
||||
val list = mutableListOf<Album>()
|
||||
while(!cursor.isAfterLast) {
|
||||
list.add(fromCursor(cursor));
|
||||
cursor.moveToNext();
|
||||
}
|
||||
return@use list;
|
||||
}
|
||||
}
|
||||
fun getArtistAlbums(artistId: Long): List<Album> {
|
||||
val resolver = StateApp.instance.contextOrNull?.contentResolver;
|
||||
if(resolver == null) {
|
||||
Logger.w(TAG, "Album contentResolver not found");
|
||||
return listOf();
|
||||
}
|
||||
val cursor = resolver?.query(
|
||||
MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, PROJECTION, "${MediaStore.Audio.Media.ARTIST_ID} = ?", arrayOf(artistId.toString()),
|
||||
MediaStore.Audio.Albums.ALBUM + " ASC") ?: return listOf();
|
||||
return cursor.use {
|
||||
cursor.moveToFirst();
|
||||
val list = mutableListOf<Album>()
|
||||
while(!cursor.isAfterLast) {
|
||||
list.add(fromCursor(cursor));
|
||||
cursor.moveToNext();
|
||||
}
|
||||
return@use list;
|
||||
}
|
||||
}
|
||||
fun getArtistAlbumWithThumbnail(artistId: Long): Album? {
|
||||
val resolver = StateApp.instance.contextOrNull?.contentResolver;
|
||||
if(resolver == null) {
|
||||
Logger.w(TAG, "Album contentResolver not found");
|
||||
return null;
|
||||
}
|
||||
val cursor = resolver?.query(
|
||||
MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, PROJECTION, "${MediaStore.Audio.Media.ARTIST_ID} = ?", arrayOf(artistId.toString()),
|
||||
MediaStore.Audio.Albums.ALBUM + " ASC") ?: return null;
|
||||
return cursor.use {
|
||||
cursor.moveToFirst();
|
||||
while(!cursor.isAfterLast) {
|
||||
val album = fromCursor(cursor);
|
||||
if(album.thumbnail != null)
|
||||
return album
|
||||
cursor.moveToNext();
|
||||
}
|
||||
return@use null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class FileEntry(
|
||||
val path: String,
|
||||
val name: String,
|
||||
val isDirectory: Boolean = false,
|
||||
val thumbnail: String? = null,
|
||||
|
||||
var removable: Boolean = false
|
||||
) {
|
||||
|
||||
fun getSubFiles(): List<FileEntry> {
|
||||
if(isDirectory) {
|
||||
if(path.startsWith("content://"))
|
||||
return DocumentFile.fromTreeUri(StateApp.instance.context, path.toUri())?.listFiles()
|
||||
?.map { fromFile(it) } ?: return listOf();
|
||||
return File(path).listFiles()
|
||||
.map { fromFile(it) }
|
||||
}
|
||||
return listOf();
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromPath(path: String): FileEntry {
|
||||
/*
|
||||
val cursor = StateApp.instance.context.contentResolver.query(path.toUri(), null, null, null, null);
|
||||
cursor?.moveToFirst();
|
||||
val fileName = cursor?.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME));
|
||||
cursor?.close();
|
||||
return FileEntry(path, fileName, );
|
||||
*/
|
||||
val file = File(path);
|
||||
return FileEntry(file.path, file.name, file.isDirectory);
|
||||
}
|
||||
fun fromFile(file: File): FileEntry {
|
||||
return FileEntry(file.path, file.name, file.isDirectory);
|
||||
}
|
||||
fun fromFile(file: DocumentFile): FileEntry {
|
||||
return FileEntry(file.uri.toString(), file.name ?: "", file.isDirectory);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.api.media.platforms.js.DevJSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.platforms.local.LocalClient
|
||||
import com.futo.platformplayer.api.media.structures.EmptyPager
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.api.media.structures.MultiChronoContentPager
|
||||
@@ -75,6 +76,7 @@ class StatePlatform {
|
||||
private val _cache : LruCache<String, CachedPlatformContent> = LruCache<String, CachedPlatformContent>(VIDEO_CACHE);
|
||||
|
||||
//Clients
|
||||
private val _localClient = LocalClient();
|
||||
private val _enabledClientsPersistent = FragmentedStorage.get<StringArrayStorage>("enabledClients");
|
||||
private val _platformOrderPersistent = FragmentedStorage.get<StringArrayStorage>("platformOrder");
|
||||
private val _clientsLock = Object();
|
||||
@@ -117,6 +119,7 @@ class StatePlatform {
|
||||
_enabledClients.find { _instantClientPool.getClientPooled(it).isContentDetailsUrl(url) }?.let {
|
||||
_mainClientPool.getClientPooled(it).getContentDetails(url)
|
||||
}
|
||||
?: (if(_localClient.isContentDetailsUrl(url)) _localClient.getContentDetails(url) else null)
|
||||
?: throw NoPlatformClientException("No client enabled that supports this url ($url)");
|
||||
}
|
||||
else {
|
||||
@@ -124,6 +127,7 @@ class StatePlatform {
|
||||
_enabledClients.find { _instantClientPool.getClientPooled(it).isContentDetailsUrl(url) }?.let {
|
||||
_privateClientPool.getClientPooled(it).getContentDetails(url)
|
||||
}
|
||||
?: (if(_localClient.isContentDetailsUrl(url)) _localClient.getContentDetails(url) else null)
|
||||
?: throw NoPlatformClientException("No client enabled that supports this url ($url)");
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.futo.platformplayer.states
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
@@ -111,10 +112,20 @@ class StatePlayer {
|
||||
val onPlayerOpened = Event0();
|
||||
val onPlayerClosed = Event0();
|
||||
|
||||
var currentVideo: IPlatformVideoDetails? = null
|
||||
var currentVideo: IPlatformVideo? = null
|
||||
private set;
|
||||
|
||||
fun setCurrentlyPlaying(video: IPlatformVideoDetails?) {
|
||||
private var _currentPlaylistId: String? = null
|
||||
val playlistId: String? get() = if (_queueType == TYPE_PLAYLIST) _currentPlaylistId else null
|
||||
|
||||
init {
|
||||
onQueueChanged.subscribe {
|
||||
updateLastQueue()
|
||||
}
|
||||
}
|
||||
|
||||
fun setCurrentlyPlaying(video: IPlatformVideo?) {
|
||||
Log.i(TAG, "setCurrentlyPlaying ${video?.url} (${video?.name})")
|
||||
currentVideo = video;
|
||||
}
|
||||
|
||||
@@ -125,6 +136,7 @@ class StatePlayer {
|
||||
onPlayerOpened.emit();
|
||||
}
|
||||
fun setPlayerClosed() {
|
||||
Log.i(TAG, "setCurrentlyPlaying (setPlayerClosed) null")
|
||||
setCurrentlyPlaying(null);
|
||||
isOpen = false;
|
||||
clearQueue();
|
||||
@@ -269,23 +281,6 @@ class StatePlayer {
|
||||
}
|
||||
onQueueChanged.emit(true);
|
||||
}
|
||||
fun setPlaylist(playlist: IPlatformPlaylistDetails, toPlayIndex: Int = 0, focus: Boolean = false, shuffle: Boolean = false) {
|
||||
synchronized(_queue) {
|
||||
_queue.clear();
|
||||
setQueueType(TYPE_PLAYLIST);
|
||||
_queueName = playlist.name;
|
||||
_queue.addAll(playlist.contents.getResults());
|
||||
queueFocused = focus;
|
||||
queueShuffle = shuffle;
|
||||
if (shuffle) {
|
||||
createShuffledQueue();
|
||||
}
|
||||
_queuePosition = toPlayIndex;
|
||||
}
|
||||
playlist.id.value?.let { StatePlaylists.instance.didPlay(it); };
|
||||
|
||||
onQueueChanged.emit(true);
|
||||
}
|
||||
fun setPlaylist(playlist: Playlist, toPlayIndex: Int = 0, focus: Boolean = false, shuffle: Boolean = false) {
|
||||
synchronized(_queue) {
|
||||
_queue.clear();
|
||||
@@ -299,6 +294,7 @@ class StatePlayer {
|
||||
}
|
||||
_queuePosition = toPlayIndex;
|
||||
}
|
||||
_currentPlaylistId = playlist.id
|
||||
StatePlaylists.instance.didPlay(playlist.id);
|
||||
|
||||
onQueueChanged.emit(true);
|
||||
@@ -384,6 +380,27 @@ class StatePlayer {
|
||||
setQueuePosition(video);
|
||||
}
|
||||
}
|
||||
|
||||
fun updateLastQueue() {
|
||||
val queueVideos = synchronized(_queue) {
|
||||
if (!_queue.isEmpty()) {
|
||||
return@synchronized _queue.map { SerializedPlatformVideo.fromVideo(it) }.toList()
|
||||
}
|
||||
|
||||
return@synchronized null
|
||||
}
|
||||
|
||||
if (queueVideos != null) {
|
||||
Logger.i(TAG, "Update last queue: ${queueVideos.size} videos.")
|
||||
val playlist = StatePlaylists.instance.getPlaylist(StatePlaylists.LAST_QUEUE_PLAYLIST_ID)?.apply {
|
||||
videos.clear()
|
||||
videos.addAll(queueVideos)
|
||||
} ?: Playlist("Last Queue", queueVideos).apply {
|
||||
id = StatePlaylists.LAST_QUEUE_PLAYLIST_ID
|
||||
}
|
||||
StatePlaylists.instance.createOrUpdatePlaylist(playlist)
|
||||
}
|
||||
}
|
||||
fun setQueuePosition(video: IPlatformVideo) {
|
||||
synchronized(_queue) {
|
||||
if (getCurrentQueueItem() == video) {
|
||||
|
||||
@@ -200,10 +200,10 @@ class StatePlaylists {
|
||||
}
|
||||
|
||||
fun getLastPlayedPlaylist() : Playlist? {
|
||||
return playlistStore.queryItem { it.maxByOrNull { x -> x.datePlayed } };
|
||||
return playlistStore.queryItem { it.filter { x -> x.id != StatePlaylists.LAST_QUEUE_PLAYLIST_ID }.maxByOrNull { x -> x.datePlayed } };
|
||||
}
|
||||
fun getLastUpdatedPlaylist() : Playlist? {
|
||||
return playlistStore.queryItem { it.maxByOrNull { x -> x.dateUpdate } };
|
||||
return playlistStore.queryItem { it.filter { x -> x.id != StatePlaylists.LAST_QUEUE_PLAYLIST_ID }.maxByOrNull { x -> x.dateUpdate } };
|
||||
}
|
||||
|
||||
fun getPlaylists() : List<Playlist> {
|
||||
@@ -394,6 +394,7 @@ class StatePlaylists {
|
||||
|
||||
companion object {
|
||||
val TAG = "StatePlaylists";
|
||||
val LAST_QUEUE_PLAYLIST_ID = "a70a3287-45dd-4227-832c-6ecde7fb1bf6"
|
||||
private var _instance : StatePlaylists? = null;
|
||||
private var _lockObject = Object()
|
||||
val instance : StatePlaylists
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.futo.platformplayer.api.media.platforms.js.SourceAuth
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourceCaptchaData
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.LoginFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SourceDetailFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SourceDetailFragment.Companion
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
@@ -167,7 +168,7 @@ class StatePlugins {
|
||||
if(config.authentication == null)
|
||||
return false;
|
||||
|
||||
LoginActivity.showLogin(context, config) {
|
||||
LoginFragment.showLogin(config) {//LoginActivity.showLogin(context, config) {
|
||||
try {
|
||||
StatePlugins.instance.setPluginAuth(config.id, it);
|
||||
} catch (e: Throwable) {
|
||||
@@ -300,6 +301,7 @@ class StatePlugins {
|
||||
StateAssets.readAssetBinRelative(context, assetConfigPath, config.iconUrl);
|
||||
else null;
|
||||
|
||||
//config.version = config.version - 1;
|
||||
createPlugin(config, script, icon, true);
|
||||
return true;
|
||||
}
|
||||
@@ -317,6 +319,15 @@ class StatePlugins {
|
||||
installPlugins(context, scope, sourceUrls.drop(1), handler);
|
||||
}
|
||||
}
|
||||
fun requestConfig(sourceUrl: String): SourcePluginConfig {
|
||||
val configResp = ManagedHttpClient().get(sourceUrl);
|
||||
if(!configResp.isOk)
|
||||
throw IllegalStateException("Failed request with ${configResp.code}");
|
||||
val configJson = configResp.body?.string();
|
||||
if(configJson.isNullOrEmpty())
|
||||
throw IllegalStateException("No response");
|
||||
return SourcePluginConfig.fromJson(configJson, sourceUrl);
|
||||
}
|
||||
fun installPlugin(context: Context, scope: CoroutineScope, sourceUrl: String, handler: ((Boolean) -> Unit)? = null) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val client = ManagedHttpClient();
|
||||
@@ -329,6 +340,7 @@ class StatePlugins {
|
||||
if(configJson.isNullOrEmpty())
|
||||
throw IllegalStateException("No response");
|
||||
config = SourcePluginConfig.fromJson(configJson, sourceUrl);
|
||||
//config.version = config.version - 1;
|
||||
}
|
||||
catch(ex: SerializationException) {
|
||||
Logger.e(TAG, "Failed decode config", ex);
|
||||
@@ -642,6 +654,9 @@ class StatePlugins {
|
||||
val descriptor = getPlugin(id) ?: throw IllegalArgumentException("Plugin [${id}] does not exist");
|
||||
descriptor.updateAuth(auth);
|
||||
_plugins.save(descriptor);
|
||||
|
||||
if(auth != null)
|
||||
UIDialogs.appToast("Plugin ${descriptor?.config?.name} logged in");
|
||||
}
|
||||
|
||||
@Serializable
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user