mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-29 19:13:01 +02:00
Compare commits
111 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1667866a35 | |||
| 035125d0f8 | |||
| 1bb0cdc405 | |||
| 86019c80a1 | |||
| 8c640d3def | |||
| 7ed1e8a28b | |||
| 3dcfe8c340 | |||
| 042ced81ef | |||
| b37f48380b | |||
| 0a02169782 | |||
| f12e4390f3 | |||
| 82ab45d04e | |||
| 7f77c39296 | |||
| 99eee4f6ee | |||
| 68886502d1 | |||
| 26461c21c4 | |||
| 300466f722 | |||
| 961710cc8b | |||
| eba995f87d | |||
| a67244e79a | |||
| 70502a7651 | |||
| 36b4f5b41d | |||
| def39ba397 | |||
| 49d59f4466 | |||
| 1c9becc2ba | |||
| 1cde591061 | |||
| 8ac18f053c | |||
| 56bdae9ff1 | |||
| 74ddfe9f0e | |||
| acb9500e2a | |||
| 45f621763a | |||
| 0abc65a9bd | |||
| 6d6309973e | |||
| 92ec085d25 | |||
| 767a8befaa | |||
| 09763320dd | |||
| 27fb2997f9 | |||
| 0f46bc5888 | |||
| dccf4fcf3c | |||
| da7fef1ecd | |||
| 58a89a00ef | |||
| f2efc603ba | |||
| 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 | |||
| fc2aba0120 | |||
| e4f51bb130 | |||
| 9e9d26c752 | |||
| 5c5dd3af44 | |||
| d6468ba283 | |||
| 62a2f42d68 | |||
| 7c70e58129 |
@@ -64,12 +64,6 @@
|
|||||||
[submodule "app/src/stable/assets/sources/bilibili"]
|
[submodule "app/src/stable/assets/sources/bilibili"]
|
||||||
path = app/src/stable/assets/sources/bilibili
|
path = app/src/stable/assets/sources/bilibili
|
||||||
url = ../plugins/bilibili.git
|
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"]
|
[submodule "app/src/stable/assets/sources/bitchute"]
|
||||||
path = app/src/stable/assets/sources/bitchute
|
path = app/src/stable/assets/sources/bitchute
|
||||||
url = ../plugins/bitchute.git
|
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
|
||||||
+1
-1
@@ -181,7 +181,7 @@ dependencies {
|
|||||||
implementation 'com.google.code.gson:gson:2.13.2' //Used for complex/anonymous cases like during development conversions (eg. V8RemoteObject)
|
implementation 'com.google.code.gson:gson:2.13.2' //Used for complex/anonymous cases like during development conversions (eg. V8RemoteObject)
|
||||||
|
|
||||||
//JS
|
//JS
|
||||||
implementation 'com.caoccao.javet:javet-v8-android:5.0.1'
|
implementation 'com.caoccao.javet:javet-v8-android:4.1.5'
|
||||||
|
|
||||||
//Exoplayer
|
//Exoplayer
|
||||||
implementation 'androidx.media3:media3-exoplayer:1.8.0'
|
implementation 'androidx.media3:media3-exoplayer:1.8.0'
|
||||||
|
|||||||
@@ -29,6 +29,8 @@
|
|||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.FutoVideo"
|
android:theme="@style/Theme.FutoVideo"
|
||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
|
tools:replace="android:enableOnBackInvokedCallback"
|
||||||
|
android:enableOnBackInvokedCallback="false"
|
||||||
tools:targetApi="31"
|
tools:targetApi="31"
|
||||||
android:largeHeap="true">
|
android:largeHeap="true">
|
||||||
<provider
|
<provider
|
||||||
@@ -61,6 +63,7 @@
|
|||||||
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|uiMode"
|
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|uiMode"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar"
|
android:theme="@style/Theme.FutoVideo.NoActionBar"
|
||||||
|
android:windowSoftInputMode="adjustPan"
|
||||||
android:launchMode="singleInstance"
|
android:launchMode="singleInstance"
|
||||||
android:resizeableActivity="true"
|
android:resizeableActivity="true"
|
||||||
android:supportsPictureInPicture="true">
|
android:supportsPictureInPicture="true">
|
||||||
@@ -245,5 +248,25 @@
|
|||||||
android:name=".activities.PolycentricModerationActivity"
|
android:name=".activities.PolycentricModerationActivity"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:screenOrientation="portrait" />
|
android:screenOrientation="portrait" />
|
||||||
|
<activity
|
||||||
|
android:name=".activities.QRCodeFullscreenActivity"
|
||||||
|
android:screenOrientation="sensorPortrait"
|
||||||
|
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||||
|
<service
|
||||||
|
android:name=".UpdateDownloadService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="dataSync" />
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".UpdateActionReceiver"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".activities.InstallUpdateActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:theme="@style/Theme.App.TransparentNoUi"
|
||||||
|
android:excludeFromRecents="true"
|
||||||
|
android:finishOnTaskLaunch="true" />
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -429,6 +429,9 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
6 -> 1.75f;
|
6 -> 1.75f;
|
||||||
7 -> 2.0f;
|
7 -> 2.0f;
|
||||||
8 -> 2.25f;
|
8 -> 2.25f;
|
||||||
|
9 -> 2.5f;
|
||||||
|
10 -> 2.75f;
|
||||||
|
11 -> 3.0f;
|
||||||
else -> 1.0f;
|
else -> 1.0f;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -725,7 +728,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
@AdvancedField
|
@AdvancedField
|
||||||
@FormField(R.string.experimental_cast, FieldForm.TOGGLE, R.string.experimental_cast_description, 6)
|
@FormField(R.string.experimental_cast, FieldForm.TOGGLE, R.string.experimental_cast_description, 6)
|
||||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||||
var experimentalCasting: Boolean = false
|
var experimentalCasting: Boolean = true
|
||||||
|
|
||||||
/*TODO: Should we have a different casting quality?
|
/*TODO: Should we have a different casting quality?
|
||||||
@FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3)
|
@FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3)
|
||||||
@@ -1049,6 +1052,8 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
|
|
||||||
@FormField(R.string.polycentric_local_cache, FieldForm.TOGGLE, R.string.polycentric_local_cache_description, 7)
|
@FormField(R.string.polycentric_local_cache, FieldForm.TOGGLE, R.string.polycentric_local_cache_description, 7)
|
||||||
var polycentricLocalCache: Boolean = true;
|
var polycentricLocalCache: Boolean = true;
|
||||||
|
|
||||||
|
var showPrivacyModeDialog: Boolean = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@FormField(R.string.gesture_controls, FieldForm.GROUP, -1, 19)
|
@FormField(R.string.gesture_controls, FieldForm.GROUP, -1, 19)
|
||||||
|
|||||||
@@ -370,17 +370,19 @@ class UIDialogs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null) {
|
fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null, dismissAction: (() -> Unit)? = null): AlertDialog {
|
||||||
val confirmButtonAction = Action(context.getString(R.string.confirm), action, ActionStyle.PRIMARY)
|
val confirmButtonAction = Action(context.getString(R.string.confirm), action, ActionStyle.PRIMARY)
|
||||||
val cancelButtonAction = Action(context.getString(R.string.cancel), cancelAction ?: {}, ActionStyle.ACCENT)
|
val cancelButtonAction = Action(context.getString(R.string.cancel), cancelAction ?: {}, ActionStyle.ACCENT)
|
||||||
showDialog(context, R.drawable.ic_error, text, null, null, 0, cancelButtonAction, confirmButtonAction)
|
return showDialog(context, R.drawable.ic_error, text, null, null, 0, cancelButtonAction, confirmButtonAction).apply {
|
||||||
|
setOnDismissListener { dismissAction?.invoke() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null, doNotAskAgainAction: (() -> Unit)? = null) {
|
fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null, dismissAction: (() -> Unit)? = null, doNotAskAgainAction: (() -> Unit)? = null): AlertDialog {
|
||||||
val confirmButtonAction = Action(context.getString(R.string.confirm), action, ActionStyle.PRIMARY)
|
val confirmButtonAction = Action(context.getString(R.string.confirm), action, ActionStyle.PRIMARY)
|
||||||
val cancelButtonAction = Action(context.getString(R.string.cancel), cancelAction ?: {}, ActionStyle.ACCENT)
|
val cancelButtonAction = Action(context.getString(R.string.cancel), cancelAction ?: {}, ActionStyle.ACCENT)
|
||||||
val doNotAskAgain = Action(context.getString(R.string.do_not_ask_again), doNotAskAgainAction ?: {}, ActionStyle.NONE)
|
val doNotAskAgain = Action(context.getString(R.string.do_not_ask_again), doNotAskAgainAction ?: {}, ActionStyle.NONE)
|
||||||
showDialog(context, R.drawable.ic_error, text, null, null, 0, doNotAskAgain, cancelButtonAction, confirmButtonAction)
|
return showDialog(context, R.drawable.ic_error, text, null, null, 0, doNotAskAgain, cancelButtonAction, confirmButtonAction)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showUpdateAvailableDialog(context: Context, lastVersion: Int, hideExceptionButtons: Boolean = false) {
|
fun showUpdateAvailableDialog(context: Context, lastVersion: Int, hideExceptionButtons: Boolean = false) {
|
||||||
@@ -403,13 +405,6 @@ class UIDialogs {
|
|||||||
dialog.setMaxVersion(lastVersion);
|
dialog.setMaxVersion(lastVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showInstallDownloadedUpdateDialog(context: Context, apkFile: File) {
|
|
||||||
val dialog = AutoUpdateDialog(context);
|
|
||||||
registerDialogOpened(dialog);
|
|
||||||
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
|
||||||
dialog.showPredownloaded(apkFile);
|
|
||||||
}
|
|
||||||
|
|
||||||
fun showMigrateDialog(context: Context, store: ManagedStore<*>, onConcluded: ()->Unit) {
|
fun showMigrateDialog(context: Context, store: ManagedStore<*>, onConcluded: ()->Unit) {
|
||||||
if(!store.hasMissingReconstructions())
|
if(!store.hasMissingReconstructions())
|
||||||
onConcluded();
|
onConcluded();
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package com.futo.platformplayer
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.futo.platformplayer.activities.MainActivity
|
||||||
|
import com.futo.platformplayer.dialogs.AutoUpdateDialog
|
||||||
|
import com.futo.platformplayer.states.StateApp
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class UpdateActionReceiver : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
when (intent.action) {
|
||||||
|
UpdateNotificationManager.ACTION_UPDATE_YES -> handleUpdateYes(context, intent)
|
||||||
|
UpdateNotificationManager.ACTION_UPDATE_NO -> handleUpdateNo(context)
|
||||||
|
UpdateNotificationManager.ACTION_UPDATE_NEVER -> handleUpdateNever(context)
|
||||||
|
UpdateNotificationManager.ACTION_DOWNLOAD_CANCEL -> handleDownloadCancel(context, intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleUpdateYes(context: Context, intent: Intent) {
|
||||||
|
AutoUpdateDialog.currentDialog?.dismiss()
|
||||||
|
|
||||||
|
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
|
||||||
|
if (version == 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_AVAILABLE)
|
||||||
|
|
||||||
|
val serviceIntent = Intent(context, UpdateDownloadService::class.java).apply {
|
||||||
|
putExtra(UpdateDownloadService.EXTRA_VERSION, version)
|
||||||
|
}
|
||||||
|
ContextCompat.startForegroundService(context, serviceIntent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleUpdateNo(context: Context) {
|
||||||
|
AutoUpdateDialog.currentDialog?.dismiss()
|
||||||
|
NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_AVAILABLE)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleUpdateNever(context: Context) {
|
||||||
|
AutoUpdateDialog.currentDialog?.dismiss()
|
||||||
|
Settings.instance.autoUpdate.check = 1
|
||||||
|
Settings.instance.save()
|
||||||
|
|
||||||
|
UpdateNotificationManager.cancelAll(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleDownloadCancel(context: Context, intent: Intent) {
|
||||||
|
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
|
||||||
|
|
||||||
|
val cancelIntent = Intent(context, UpdateDownloadService::class.java).apply {
|
||||||
|
putExtra(UpdateDownloadService.EXTRA_CANCEL, true)
|
||||||
|
putExtra(UpdateDownloadService.EXTRA_VERSION, version)
|
||||||
|
}
|
||||||
|
ContextCompat.startForegroundService(context, cancelIntent)
|
||||||
|
|
||||||
|
NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_DOWNLOADING)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package com.futo.platformplayer
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.states.StateApp
|
||||||
|
import com.futo.platformplayer.states.StateUpdate
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
class UpdateCheckWorker(appContext: Context, workerParams: WorkerParameters) : CoroutineWorker(appContext, workerParams) {
|
||||||
|
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
if (!Settings.instance.autoUpdate.isAutoUpdateEnabled()) {
|
||||||
|
Logger.i(TAG, "Auto-update disabled, skipping worker run")
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val client = ManagedHttpClient()
|
||||||
|
val latestVersion = StateUpdate.Companion.instance.downloadVersionCode(client)
|
||||||
|
|
||||||
|
if (latestVersion == null) {
|
||||||
|
Logger.w(TAG, "Failed to fetch latest version in worker")
|
||||||
|
return@withContext Result.retry()
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentVersion = BuildConfig.VERSION_CODE
|
||||||
|
Logger.i(TAG, "Worker check: current=$currentVersion, latest=$latestVersion")
|
||||||
|
|
||||||
|
if (latestVersion <= currentVersion) {
|
||||||
|
return@withContext Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateNotificationManager.showUpdateAvailableNotification(applicationContext, latestVersion)
|
||||||
|
|
||||||
|
if (StateApp.instance.isMainActive) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
StateApp.withContext { ctx ->
|
||||||
|
try {
|
||||||
|
UIDialogs.showUpdateAvailableDialog(ctx, latestVersion, false)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Logger.w(TAG, "Failed to show in-app update dialog from worker", t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Result.success()
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Logger.w(TAG, "Exception in UpdateCheckWorker", t)
|
||||||
|
Result.retry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "UpdateCheckWorker"
|
||||||
|
const val UNIQUE_WORK_NAME = "updateCheck"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
package com.futo.platformplayer
|
||||||
|
|
||||||
|
import android.app.Dialog
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.IBinder
|
||||||
|
import com.futo.platformplayer.UIDialogs.ActionStyle
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.states.StateApp
|
||||||
|
import com.futo.platformplayer.states.StateUpdate
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
|
||||||
|
class UpdateDownloadService : Service() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "UpdateDownloadService"
|
||||||
|
const val EXTRA_VERSION = "version"
|
||||||
|
const val EXTRA_CANCEL = "cancel"
|
||||||
|
private const val MAX_RETRIES = 5
|
||||||
|
private const val INITIAL_BACKOFF_MS = 5_000L
|
||||||
|
private const val BUFFER_SIZE = 8 * 1024
|
||||||
|
|
||||||
|
var updateDownloadedDialog: Dialog? = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private val job = SupervisorJob()
|
||||||
|
private val scope = CoroutineScope(Dispatchers.IO + job)
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var isDownloading: Boolean = false
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var cancelRequested: Boolean = false
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
if (intent == null) {
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent.getBooleanExtra(EXTRA_CANCEL, false)) {
|
||||||
|
cancelRequested = true
|
||||||
|
Logger.i(TAG, "Download cancel requested")
|
||||||
|
stopForeground(Service.STOP_FOREGROUND_REMOVE)
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
val version = intent.getIntExtra(EXTRA_VERSION, 0)
|
||||||
|
if (version == 0) {
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDownloading) {
|
||||||
|
Logger.i(TAG, "Download already in progress, ignoring new start")
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
isDownloading = true
|
||||||
|
cancelRequested = false
|
||||||
|
|
||||||
|
val notification = UpdateNotificationManager.buildDownloadProgressNotification(this, version, 0, true)
|
||||||
|
startForeground(UpdateNotificationManager.NOTIF_ID_DOWNLOADING, notification)
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
downloadApk(version)
|
||||||
|
}
|
||||||
|
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
job.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun downloadApk(version: Int) {
|
||||||
|
val apkFile = StateUpdate.getApkFile(this, version)
|
||||||
|
val partialFile = StateUpdate.getPartialApkFile(this, version)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (apkFile.exists() && apkFile.length() > 0L) {
|
||||||
|
Logger.i(TAG, "APK already downloaded at ${apkFile.absolutePath}")
|
||||||
|
onDownloadComplete(version, apkFile)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var backoffMs = INITIAL_BACKOFF_MS
|
||||||
|
|
||||||
|
for (attempt in 0 until MAX_RETRIES) {
|
||||||
|
if (cancelRequested) {
|
||||||
|
Logger.i(TAG, "Download cancelled before attempt ${attempt + 1}")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
performDownload(StateUpdate.APK_URL, partialFile, version)
|
||||||
|
|
||||||
|
if (!cancelRequested) {
|
||||||
|
if (apkFile.exists()) {
|
||||||
|
apkFile.delete()
|
||||||
|
}
|
||||||
|
if (!partialFile.renameTo(apkFile)) {
|
||||||
|
throw IllegalStateException("Failed to rename partial APK file")
|
||||||
|
}
|
||||||
|
onDownloadComplete(version, apkFile)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
if (cancelRequested) {
|
||||||
|
Logger.i(TAG, "Download cancelled by user", t)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt == MAX_RETRIES - 1) {
|
||||||
|
Logger.e(TAG, "Download failed after ${attempt + 1} attempts", t)
|
||||||
|
UpdateNotificationManager.showDownloadFailedNotification(this, version, t)
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
Logger.w(TAG, "Download attempt ${attempt + 1} failed, retrying in ${backoffMs / 1000}s", t)
|
||||||
|
delay(backoffMs)
|
||||||
|
backoffMs *= 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isDownloading = false
|
||||||
|
cancelRequested = false
|
||||||
|
stopForeground(Service.STOP_FOREGROUND_REMOVE)
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun performDownload(url: String, partialFile: File, version: Int) {
|
||||||
|
var startOffset = if (partialFile.exists()) partialFile.length() else 0L
|
||||||
|
Logger.i(TAG, "Starting download. url=$url, existingBytes=$startOffset")
|
||||||
|
|
||||||
|
var connection: HttpURLConnection? = null
|
||||||
|
try {
|
||||||
|
connection = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||||
|
connectTimeout = 15_000
|
||||||
|
readTimeout = 30_000
|
||||||
|
if (startOffset > 0L) {
|
||||||
|
setRequestProperty("Range", "bytes=$startOffset-")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.connect()
|
||||||
|
val responseCode = connection.responseCode
|
||||||
|
|
||||||
|
if (responseCode == HttpURLConnection.HTTP_OK && startOffset > 0L) {
|
||||||
|
Logger.w(TAG, "Server ignored Range header, restarting download from scratch")
|
||||||
|
partialFile.delete()
|
||||||
|
startOffset = 0L
|
||||||
|
} else if (responseCode != HttpURLConnection.HTTP_OK &&
|
||||||
|
responseCode != HttpURLConnection.HTTP_PARTIAL) {
|
||||||
|
throw IllegalStateException("Unexpected HTTP response code $responseCode")
|
||||||
|
}
|
||||||
|
|
||||||
|
val contentLength = connection.contentLengthLong
|
||||||
|
val totalBytes = if (contentLength > 0L) startOffset + contentLength else -1L
|
||||||
|
|
||||||
|
val buffer = ByteArray(BUFFER_SIZE)
|
||||||
|
var downloaded = 0L
|
||||||
|
var lastProgress = -1
|
||||||
|
|
||||||
|
connection.inputStream.use { input ->
|
||||||
|
FileOutputStream(partialFile, startOffset > 0L).use { output ->
|
||||||
|
while (!cancelRequested) {
|
||||||
|
val read = input.read(buffer)
|
||||||
|
if (read == -1) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
output.write(buffer, 0, read)
|
||||||
|
downloaded += read
|
||||||
|
|
||||||
|
if (totalBytes > 0L) {
|
||||||
|
val progress = (((startOffset + downloaded) * 100L) / totalBytes).toInt()
|
||||||
|
if (progress != lastProgress) {
|
||||||
|
lastProgress = progress
|
||||||
|
val safeProgress = when {
|
||||||
|
progress < 0 -> 0
|
||||||
|
progress > 100 -> 100
|
||||||
|
else -> progress
|
||||||
|
}
|
||||||
|
UpdateNotificationManager.updateDownloadProgress(this, version, safeProgress, false)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
UpdateNotificationManager.updateDownloadProgress(this, version, 0, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
output.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancelRequested) {
|
||||||
|
throw CancellationException("Download cancelled")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalBytes > 0L && startOffset + downloaded < totalBytes) {
|
||||||
|
throw IllegalStateException("Download incomplete: expected=$totalBytes, got=${startOffset + downloaded}")
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
connection?.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onDownloadComplete(version: Int, apkFile: File) {
|
||||||
|
Logger.i(TAG, "Download complete for version=$version, file=${apkFile.absolutePath}")
|
||||||
|
UpdateNotificationManager.showDownloadCompleteNotification(this, version, apkFile)
|
||||||
|
|
||||||
|
if (StateApp.instance.isMainActive) {
|
||||||
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||||
|
StateApp.withContext { ctx ->
|
||||||
|
try {
|
||||||
|
updateDownloadedDialog = UIDialogs.showDialog(ctx, R.drawable.foreground,
|
||||||
|
"Update downloaded",
|
||||||
|
"Would you like to install it now?", null, 0,
|
||||||
|
UIDialogs.Action("Cancel", {
|
||||||
|
updateDownloadedDialog = null
|
||||||
|
}, ActionStyle.NONE, true),
|
||||||
|
UIDialogs.Action("Install", {
|
||||||
|
UpdateNotificationManager.cancelAll(ctx)
|
||||||
|
UpdateInstaller.startInstall(ctx, apkFile)
|
||||||
|
}, ActionStyle.PRIMARY, true));
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Logger.w(TAG, "Failed to show in-app update downloaded dialog", t)
|
||||||
|
updateDownloadedDialog = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package com.futo.platformplayer
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.PendingIntent.FLAG_MUTABLE
|
||||||
|
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
import android.app.PendingIntent.getBroadcast
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageInstaller
|
||||||
|
import android.graphics.drawable.Animatable
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.view.View
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.receivers.InstallReceiver
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.GlobalScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
import java.io.InputStream
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import com.futo.platformplayer.dialogs.AutoUpdateDialog
|
||||||
|
import com.futo.platformplayer.states.StateApp
|
||||||
|
|
||||||
|
object UpdateInstaller {
|
||||||
|
private const val TAG = "UpdateInstaller"
|
||||||
|
|
||||||
|
@SuppressLint("RequestInstallPackagesPolicy")
|
||||||
|
fun startInstall(context: Context, apkFile: File) {
|
||||||
|
if (!apkFile.exists()) {
|
||||||
|
Logger.w(TAG, "APK file does not exist: ${apkFile.absolutePath}")
|
||||||
|
UIDialogs.toast(context, "Update file missing")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (BuildConfig.IS_PLAYSTORE_BUILD) {
|
||||||
|
UIDialogs.toast(context, "Updates are managed by the Play Store")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val pm = context.packageManager
|
||||||
|
if (!pm.canRequestPackageInstalls()) {
|
||||||
|
UIDialogs.toast(context, "Allow this app to install updates, then try again")
|
||||||
|
|
||||||
|
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
|
||||||
|
data = "package:${context.packageName}".toUri()
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
context.startActivity(intent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to check unknown sources permission", t)
|
||||||
|
}
|
||||||
|
|
||||||
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
|
var inputStream: InputStream? = null
|
||||||
|
var session: PackageInstaller.Session? = null
|
||||||
|
try {
|
||||||
|
|
||||||
|
val packageInstaller: PackageInstaller = context.packageManager.packageInstaller
|
||||||
|
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
|
||||||
|
val sessionId = packageInstaller.createSession(params)
|
||||||
|
session = packageInstaller.openSession(sessionId)
|
||||||
|
|
||||||
|
inputStream = apkFile.inputStream()
|
||||||
|
val dataLength = apkFile.length()
|
||||||
|
|
||||||
|
session.openWrite("package", 0, dataLength).use { sessionStream ->
|
||||||
|
inputStream.copyToOutputStream(dataLength, sessionStream) { _ -> }
|
||||||
|
session.fsync(sessionStream)
|
||||||
|
}
|
||||||
|
|
||||||
|
val intent = Intent(context, InstallReceiver::class.java)
|
||||||
|
val pendingIntent = getBroadcast(context, 0, intent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
|
||||||
|
val statusReceiver = pendingIntent.intentSender
|
||||||
|
|
||||||
|
InstallReceiver.onReceiveResult.subscribe(this) { message ->
|
||||||
|
InstallReceiver.onReceiveResult.clear();
|
||||||
|
onReceiveResult(context, message);
|
||||||
|
};
|
||||||
|
Logger.i(TAG, "Committing install session for ${apkFile.absolutePath}")
|
||||||
|
session.commit(statusReceiver)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.w(TAG, "Exception while installing update", e)
|
||||||
|
session?.abandon()
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
UIDialogs.toast(context, "Failed to install update: ${e.message}")
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
session?.close()
|
||||||
|
inputStream?.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
private fun onReceiveResult(context: Context, result: String?) {
|
||||||
|
InstallReceiver.onReceiveResult.remove(this);
|
||||||
|
UIDialogs.showGeneralErrorDialog(context, "Install failed due to:\n" + result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package com.futo.platformplayer
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.PendingIntent.FLAG_MUTABLE
|
||||||
|
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
import android.app.PendingIntent.getBroadcast
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.futo.platformplayer.activities.InstallUpdateActivity
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
object UpdateNotificationManager {
|
||||||
|
private const val CHANNEL_ID = "app_updates"
|
||||||
|
private const val CHANNEL_NAME = "App updates"
|
||||||
|
private const val CHANNEL_DESCRIPTION = "Notifications about new app versions"
|
||||||
|
|
||||||
|
const val ACTION_UPDATE_YES = "com.futo.platformplayer.UPDATE_YES"
|
||||||
|
const val ACTION_UPDATE_NO = "com.futo.platformplayer.UPDATE_NO"
|
||||||
|
const val ACTION_UPDATE_NEVER = "com.futo.platformplayer.UPDATE_NEVER"
|
||||||
|
const val ACTION_DOWNLOAD_CANCEL = "com.futo.platformplayer.UPDATE_CANCEL"
|
||||||
|
const val ACTION_INSTALL_NOW = "com.futo.platformplayer.UPDATE_INSTALL"
|
||||||
|
private const val REQUEST_CODE_INSTALL = 1001
|
||||||
|
|
||||||
|
const val EXTRA_VERSION = "version"
|
||||||
|
const val EXTRA_APK_PATH = "apk_path"
|
||||||
|
|
||||||
|
const val NOTIF_ID_AVAILABLE = 2001
|
||||||
|
const val NOTIF_ID_DOWNLOADING = 2002
|
||||||
|
const val NOTIF_ID_READY = 2003
|
||||||
|
|
||||||
|
fun ensureChannel(context: Context) {
|
||||||
|
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
if (manager.getNotificationChannel(CHANNEL_ID) == null) {
|
||||||
|
val channel = NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT).apply {
|
||||||
|
description = CHANNEL_DESCRIPTION
|
||||||
|
enableVibration(false)
|
||||||
|
enableLights(false)
|
||||||
|
setSound(null, null)
|
||||||
|
}
|
||||||
|
manager.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun showUpdateAvailableNotification(context: Context, version: Int) {
|
||||||
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureChannel(context)
|
||||||
|
|
||||||
|
val yesIntent = Intent(context, UpdateActionReceiver::class.java).apply {
|
||||||
|
action = ACTION_UPDATE_YES
|
||||||
|
putExtra(EXTRA_VERSION, version)
|
||||||
|
}
|
||||||
|
val yesPendingIntent = getBroadcast(context, 0, yesIntent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
|
||||||
|
val noIntent = Intent(context, UpdateActionReceiver::class.java).apply {
|
||||||
|
action = ACTION_UPDATE_NO
|
||||||
|
putExtra(EXTRA_VERSION, version)
|
||||||
|
}
|
||||||
|
val noPendingIntent = getBroadcast(context, 1, noIntent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
|
||||||
|
val neverIntent = Intent(context, UpdateActionReceiver::class.java).apply {
|
||||||
|
action = ACTION_UPDATE_NEVER
|
||||||
|
putExtra(EXTRA_VERSION, version)
|
||||||
|
}
|
||||||
|
val neverPendingIntent = getBroadcast(context, 2, neverIntent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
|
||||||
|
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.foreground)
|
||||||
|
.setContentTitle("Update available")
|
||||||
|
.setContentText("A new version ($version) is available.")
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setSilent(true)
|
||||||
|
.addAction(0, "Never", neverPendingIntent)
|
||||||
|
.addAction(0, "Not now", noPendingIntent)
|
||||||
|
.addAction(0, "Download", yesPendingIntent)
|
||||||
|
|
||||||
|
NotificationManagerCompat.from(context).notify(NOTIF_ID_AVAILABLE, builder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildDownloadProgressNotification(context: Context, version: Int, progress: Int, indeterminate: Boolean): Notification {
|
||||||
|
ensureChannel(context)
|
||||||
|
|
||||||
|
val cancelIntent = Intent(context, UpdateActionReceiver::class.java).apply {
|
||||||
|
action = ACTION_DOWNLOAD_CANCEL
|
||||||
|
putExtra(EXTRA_VERSION, version)
|
||||||
|
}
|
||||||
|
val cancelPendingIntent = getBroadcast(
|
||||||
|
context,
|
||||||
|
3,
|
||||||
|
cancelIntent,
|
||||||
|
FLAG_MUTABLE or FLAG_UPDATE_CURRENT
|
||||||
|
)
|
||||||
|
|
||||||
|
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.foreground)
|
||||||
|
.setContentTitle("Downloading update")
|
||||||
|
.setContentText("Downloading version $version")
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setSilent(true)
|
||||||
|
.addAction(0, "Cancel", cancelPendingIntent)
|
||||||
|
|
||||||
|
if (indeterminate) {
|
||||||
|
builder.setProgress(0, 0, true)
|
||||||
|
} else {
|
||||||
|
builder.setProgress(100, progress, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateDownloadProgress(context: Context, version: Int, progress: Int, indeterminate: Boolean) {
|
||||||
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val notification = buildDownloadProgressNotification(context, version, progress, indeterminate)
|
||||||
|
NotificationManagerCompat.from(context).notify(NOTIF_ID_DOWNLOADING, notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun showDownloadCompleteNotification(context: Context, version: Int, apkFile: File) {
|
||||||
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ensureChannel(context)
|
||||||
|
|
||||||
|
val installIntent = InstallUpdateActivity.createIntent(context, version, apkFile.absolutePath)
|
||||||
|
val installPendingIntent = PendingIntent.getActivity(context, REQUEST_CODE_INSTALL, installIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||||
|
|
||||||
|
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.foreground)
|
||||||
|
.setContentTitle("Update downloaded")
|
||||||
|
.setContentText("Tap to install version $version.")
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setSilent(true)
|
||||||
|
.addAction(0, "Install", installPendingIntent)
|
||||||
|
|
||||||
|
NotificationManagerCompat.from(context).notify(NOTIF_ID_READY, builder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun showDownloadFailedNotification(context: Context, version: Int, error: Throwable?) {
|
||||||
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ensureChannel(context)
|
||||||
|
|
||||||
|
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.foreground)
|
||||||
|
.setContentTitle("Failed to download update")
|
||||||
|
.setContentText(error?.message ?: "Unknown error")
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setSilent(true)
|
||||||
|
|
||||||
|
NotificationManagerCompat.from(context).notify(NOTIF_ID_READY, builder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelAll(context: Context) {
|
||||||
|
NotificationManagerCompat.from(context).cancel(NOTIF_ID_AVAILABLE)
|
||||||
|
NotificationManagerCompat.from(context).cancel(NOTIF_ID_DOWNLOADING)
|
||||||
|
NotificationManagerCompat.from(context).cancel(NOTIF_ID_READY)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,6 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
|
||||||
import android.icu.util.Output
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.os.OperationCanceledException
|
import android.os.OperationCanceledException
|
||||||
@@ -44,6 +42,9 @@ import java.util.*
|
|||||||
import java.util.concurrent.ThreadLocalRandom
|
import java.util.concurrent.ThreadLocalRandom
|
||||||
import java.util.zip.GZIPInputStream
|
import java.util.zip.GZIPInputStream
|
||||||
import java.util.zip.GZIPOutputStream
|
import java.util.zip.GZIPOutputStream
|
||||||
|
import androidx.core.graphics.scale
|
||||||
|
import com.bumptech.glide.RequestBuilder
|
||||||
|
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||||
|
|
||||||
private val _allowedCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz ";
|
private val _allowedCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz ";
|
||||||
fun getRandomString(sizeOfRandomString: Int): String {
|
fun getRandomString(sizeOfRandomString: Int): String {
|
||||||
@@ -101,7 +102,7 @@ fun String.isHexColor(): Boolean {
|
|||||||
|
|
||||||
fun IPlatformClient.fromPool(pool: PlatformMultiClientPool) = pool.getClientPooled(this);
|
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.getInputStream(context: Context) = context.contentResolver.openInputStream(this.uri);
|
||||||
fun DocumentFile.getOutputStream(context: Context) = context.contentResolver.openOutputStream(this.uri);
|
fun DocumentFile.getOutputStream(context: Context) = context.contentResolver.openOutputStream(this.uri);
|
||||||
@@ -114,23 +115,6 @@ fun DocumentFile.writeBytes(context: Context, byteArray: ByteArray) = context.co
|
|||||||
it.flush();
|
it.flush();
|
||||||
};
|
};
|
||||||
|
|
||||||
fun loadBitmap(url: String): Bitmap {
|
|
||||||
try {
|
|
||||||
val client = ManagedHttpClient();
|
|
||||||
val response = client.get(url);
|
|
||||||
if (response.isOk && response.body != null) {
|
|
||||||
val bitmapStream = response.body.byteStream();
|
|
||||||
val bitmap = BitmapFactory.decodeStream(bitmapStream);
|
|
||||||
return bitmap;
|
|
||||||
} else {
|
|
||||||
throw Exception("Failed to find data at URL.");
|
|
||||||
}
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.w("Utility", "Exception thrown while downloading bitmap.", e);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun TextView.setPlatformPlayerLinkMovementMethod(context: Context) {
|
fun TextView.setPlatformPlayerLinkMovementMethod(context: Context) {
|
||||||
this.movementMethod = PlatformLinkMovementMethod(context);
|
this.movementMethod = PlatformLinkMovementMethod(context);
|
||||||
}
|
}
|
||||||
@@ -458,4 +442,11 @@ fun addressScore(addr: InetAddress): Int {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun <T> Enumeration<T>.toList(): List<T> = Collections.list(this)
|
fun <T> Enumeration<T>.toList(): List<T> = Collections.list(this)
|
||||||
|
|
||||||
|
fun <T> RequestBuilder<T>.withMaxSizePx(maxSizePx: Int = 1920): RequestBuilder<T> {
|
||||||
|
return this;
|
||||||
|
//.downsample(DownsampleStrategy.AT_MOST)
|
||||||
|
//.override(maxSizePx, maxSizePx)
|
||||||
|
//.centerInside()
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.futo.platformplayer.activities
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import com.futo.platformplayer.UIDialogs
|
||||||
|
import com.futo.platformplayer.UpdateInstaller
|
||||||
|
import com.futo.platformplayer.UpdateNotificationManager
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class InstallUpdateActivity : AppCompatActivity() {
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
|
||||||
|
val apkPath = intent.getStringExtra(UpdateNotificationManager.EXTRA_APK_PATH)
|
||||||
|
|
||||||
|
if (version == 0 || apkPath.isNullOrEmpty()) {
|
||||||
|
Logger.w("InstallUpdateActivity", "Missing version or apkPath")
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val apkFile = File(apkPath)
|
||||||
|
if (!apkFile.exists()) {
|
||||||
|
Logger.w("InstallUpdateActivity", "APK file does not exist: $apkPath")
|
||||||
|
UIDialogs.Companion.toast(this, "Update file missing")
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateInstaller.startInstall(this, apkFile)
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun createIntent(context: Context, version: Int, apkPath: String): Intent =
|
||||||
|
Intent(context, InstallUpdateActivity::class.java).apply {
|
||||||
|
putExtra(UpdateNotificationManager.EXTRA_VERSION, version)
|
||||||
|
putExtra(UpdateNotificationManager.EXTRA_APK_PATH, apkPath)
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import android.content.Intent
|
|||||||
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
|
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
|
import android.graphics.Color
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
@@ -33,7 +34,6 @@ import androidx.lifecycle.Lifecycle
|
|||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.lifecycle.withStateAtLeast
|
import androidx.lifecycle.withStateAtLeast
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import com.curlbind.Libcurl
|
|
||||||
import com.futo.platformplayer.BuildConfig
|
import com.futo.platformplayer.BuildConfig
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.RootInsetsController
|
import com.futo.platformplayer.RootInsetsController
|
||||||
@@ -67,6 +67,7 @@ import com.futo.platformplayer.fragment.mainactivity.main.LibraryFilesFragment
|
|||||||
import com.futo.platformplayer.fragment.mainactivity.main.LibraryFragment
|
import com.futo.platformplayer.fragment.mainactivity.main.LibraryFragment
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.LibrarySearchFragment
|
import com.futo.platformplayer.fragment.mainactivity.main.LibrarySearchFragment
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.LibraryVideosFragment
|
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.MainFragment
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.PlaylistFragment
|
import com.futo.platformplayer.fragment.mainactivity.main.PlaylistFragment
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.PlaylistSearchResultsFragment
|
import com.futo.platformplayer.fragment.mainactivity.main.PlaylistSearchResultsFragment
|
||||||
@@ -202,6 +203,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
lateinit var _fragLibraryFiles: LibraryFilesFragment;
|
lateinit var _fragLibraryFiles: LibraryFilesFragment;
|
||||||
lateinit var _fragSettings: SettingsFragment;
|
lateinit var _fragSettings: SettingsFragment;
|
||||||
lateinit var _fragDeveloper: DeveloperFragment;
|
lateinit var _fragDeveloper: DeveloperFragment;
|
||||||
|
lateinit var _fragLogin: LoginFragment;
|
||||||
|
|
||||||
lateinit var _fragBrowser: BrowserFragment;
|
lateinit var _fragBrowser: BrowserFragment;
|
||||||
|
|
||||||
@@ -210,7 +212,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
|
|
||||||
//State
|
//State
|
||||||
private val _queue: LinkedList<Pair<MainFragment, Any?>> = LinkedList();
|
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;
|
private var _parameterCurrent: Any? = null;
|
||||||
|
|
||||||
var fragBeforeOverlay: MainFragment? = null; private set;
|
var fragBeforeOverlay: MainFragment? = null; private set;
|
||||||
@@ -243,17 +245,6 @@ 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)
|
val mainId = UUID.randomUUID().toString().substring(0, 5)
|
||||||
|
|
||||||
@@ -329,6 +320,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
window.isNavigationBarContrastEnforced = false
|
||||||
|
}
|
||||||
|
|
||||||
//Preload common files to memory
|
//Preload common files to memory
|
||||||
FragmentedStorage.get<SubscriptionStorage>();
|
FragmentedStorage.get<SubscriptionStorage>();
|
||||||
FragmentedStorage.get<Settings>();
|
FragmentedStorage.get<Settings>();
|
||||||
@@ -396,6 +391,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
_fragLibrarySearch = LibrarySearchFragment.newInstance();
|
_fragLibrarySearch = LibrarySearchFragment.newInstance();
|
||||||
_fragSettings = SettingsFragment.newInstance();
|
_fragSettings = SettingsFragment.newInstance();
|
||||||
_fragDeveloper = DeveloperFragment.newInstance();
|
_fragDeveloper = DeveloperFragment.newInstance();
|
||||||
|
_fragLogin = LoginFragment.newInstance();
|
||||||
|
|
||||||
_fragBrowser = BrowserFragment.newInstance();
|
_fragBrowser = BrowserFragment.newInstance();
|
||||||
|
|
||||||
@@ -414,12 +410,17 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
updateSegmentPaddings();
|
updateSegmentPaddings();
|
||||||
};
|
};
|
||||||
_fragVideoDetail.onTransitioning.subscribe {
|
_fragVideoDetail.onTransitioning.subscribe {
|
||||||
if (it || _fragVideoDetail.state != VideoDetailFragment.State.MINIMIZED)
|
if (it || _fragVideoDetail.state != VideoDetailFragment.State.MINIMIZED) {
|
||||||
|
Logger.i(TAG, "onTransition Setting elevation higher");
|
||||||
_fragContainerOverlay.elevation =
|
_fragContainerOverlay.elevation =
|
||||||
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 15f, resources.displayMetrics);
|
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 15f, resources.displayMetrics);
|
||||||
else
|
}
|
||||||
|
else {
|
||||||
|
Logger.i(TAG, "onTransition Setting elevation lower");
|
||||||
_fragContainerOverlay.elevation =
|
_fragContainerOverlay.elevation =
|
||||||
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics);
|
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_fragVideoDetail.onCloseEvent.subscribe {
|
_fragVideoDetail.onCloseEvent.subscribe {
|
||||||
@@ -562,7 +563,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
defaultTab.action(_fragBotBarMenu);
|
defaultTab.action(_fragBotBarMenu);
|
||||||
StateSubscriptions.instance;
|
StateSubscriptions.instance;
|
||||||
|
|
||||||
fragCurrent.onShown(null, false);
|
fragCurrent?.onShown(null, false);
|
||||||
|
|
||||||
//Other stuff
|
//Other stuff
|
||||||
rootView.progress = 0f;
|
rootView.progress = 0f;
|
||||||
@@ -617,6 +618,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
sharedPreferences.edit().putBoolean("IsFirstBoot", false).apply()
|
sharedPreferences.edit().putBoolean("IsFirstBoot", false).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && Settings.instance.autoUpdate.isAutoUpdateEnabled()) {
|
||||||
|
requestNotificationPermissions("Grayjay uses notifications to inform you when a new app update is available.");
|
||||||
|
}
|
||||||
|
|
||||||
val submissionStatus = FragmentedStorage.get<StringStorage>("subscriptionSubmissionStatus")
|
val submissionStatus = FragmentedStorage.get<StringStorage>("subscriptionSubmissionStatus")
|
||||||
|
|
||||||
val numSubscriptions = StateSubscriptions.instance.getSubscriptionCount()
|
val numSubscriptions = StateSubscriptions.instance.getSubscriptionCount()
|
||||||
@@ -1149,7 +1154,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
if (_fragVideoDetail.state == VideoDetailFragment.State.MAXIMIZED && _fragVideoDetail.onBackPressed())
|
if (_fragVideoDetail.state == VideoDetailFragment.State.MAXIMIZED && _fragVideoDetail.onBackPressed())
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (!fragCurrent.onBackPressed())
|
if (!(fragCurrent?.onBackPressed() ?: true))
|
||||||
closeSegment();
|
closeSegment();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1200,6 +1205,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
|
* 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
|
* A parameter can be provided which becomes available in the onShow of said fragment
|
||||||
@@ -1222,27 +1232,27 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
fragCurrent.onHide();
|
fragCurrent?.onHide();
|
||||||
|
|
||||||
if (segment.isMainView) {
|
if (segment.isMainView) {
|
||||||
var transaction = supportFragmentManager.beginTransaction();
|
var transaction = supportFragmentManager.beginTransaction();
|
||||||
if (segment.topBar != null) {
|
if (segment.topBar != null) {
|
||||||
if (segment.topBar != fragCurrent.topBar) {
|
if (segment.topBar != fragCurrent?.topBar) {
|
||||||
transaction = transaction
|
transaction = transaction
|
||||||
.show(segment.topBar as Fragment)
|
.show(segment.topBar as Fragment)
|
||||||
.replace(R.id.fragment_top_bar, 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)
|
} else if (fragCurrent?.topBar != null)
|
||||||
transaction.hide(fragCurrent.topBar as Fragment);
|
transaction.hide(fragCurrent?.topBar as Fragment);
|
||||||
|
|
||||||
transaction = transaction.replace(R.id.fragment_main, segment);
|
transaction = transaction.replace(R.id.fragment_main, segment);
|
||||||
|
|
||||||
if (segment.hasBottomBar) {
|
if (segment.hasBottomBar) {
|
||||||
if (!fragCurrent.hasBottomBar)
|
if (!(fragCurrent?.hasBottomBar ?: false))
|
||||||
transaction = transaction.show(_fragBotBarMenu);
|
transaction = transaction.show(_fragBotBarMenu);
|
||||||
} else {
|
} else {
|
||||||
if (fragCurrent.hasBottomBar)
|
if (fragCurrent?.hasBottomBar ?: false)
|
||||||
transaction = transaction.hide(_fragBotBarMenu);
|
transaction = transaction.hide(_fragBotBarMenu);
|
||||||
}
|
}
|
||||||
transaction.commitNow();
|
transaction.commitNow();
|
||||||
@@ -1255,10 +1265,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fragCurrent.isHistory && withHistory && _queue.lastOrNull() != fragCurrent)
|
if (fragCurrent?.isHistory ?: false && withHistory && _queue.lastOrNull() != fragCurrent)
|
||||||
_queue.add(Pair(fragCurrent, _parameterCurrent));
|
_queue.add(Pair(fragCurrent!!, _parameterCurrent));
|
||||||
|
|
||||||
if (segment.isOverlay && !fragCurrent.isOverlay && withHistory)// && fragCurrent.isHistory)
|
if (segment.isOverlay && !(fragCurrent?.isOverlay ?: false) && withHistory)// && fragCurrent.isHistory)
|
||||||
fragBeforeOverlay = fragCurrent;
|
fragBeforeOverlay = fragCurrent;
|
||||||
|
|
||||||
fragCurrent = segment;
|
fragCurrent = segment;
|
||||||
@@ -1289,11 +1299,24 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
navigate(last.first, last.second, false, true);
|
navigate(last.first, last.second, false, true);
|
||||||
} else {
|
} else {
|
||||||
if (_fragVideoDetail.state == VideoDetailFragment.State.CLOSED) {
|
if (_fragVideoDetail.state == VideoDetailFragment.State.CLOSED) {
|
||||||
|
Logger.i(TAG, "Closing activity because _fragVideoDetail.state == closed");
|
||||||
finish();
|
finish();
|
||||||
} else {
|
} else {
|
||||||
|
//UIDialogs.toast("Grayjay continues in background because of an open video.")
|
||||||
|
if(Settings.instance.playback.isBackgroundPictureInPicture()) {
|
||||||
|
try {
|
||||||
|
_fragVideoDetail._viewDetail?.startPictureInPicture();
|
||||||
|
_fragVideoDetail?.forcePictureInPicture();
|
||||||
|
} catch (ex: Throwable) {
|
||||||
|
} //Fail silently
|
||||||
|
}
|
||||||
|
else
|
||||||
|
moveTaskToBack(false);
|
||||||
|
/*
|
||||||
UIDialogs.showConfirmationDialog(this, "There is a video playing, are you sure you want to exit the app?", {
|
UIDialogs.showConfirmationDialog(this, "There is a video playing, are you sure you want to exit the app?", {
|
||||||
finish();
|
finish();
|
||||||
})
|
})
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1347,6 +1370,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
LibrarySearchFragment::class -> _fragLibrarySearch as T;
|
LibrarySearchFragment::class -> _fragLibrarySearch as T;
|
||||||
SettingsFragment:: class -> _fragSettings as T;
|
SettingsFragment:: class -> _fragSettings as T;
|
||||||
DeveloperFragment::class -> _fragDeveloper 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");
|
else -> throw IllegalArgumentException("Fragment type ${T::class.java.name} is not available in MainActivity");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1354,7 +1378,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
|
|
||||||
private fun updateSegmentPaddings() {
|
private fun updateSegmentPaddings() {
|
||||||
var paddingBottom = 0f;
|
var paddingBottom = 0f;
|
||||||
if (fragCurrent.hasBottomBar)
|
if (fragCurrent?.hasBottomBar ?: false)
|
||||||
paddingBottom += HEIGHT_MENU_DP;
|
paddingBottom += HEIGHT_MENU_DP;
|
||||||
|
|
||||||
_fragContainerOverlay.setPadding(
|
_fragContainerOverlay.setPadding(
|
||||||
@@ -1371,6 +1395,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 notifPermission = "android.permission.POST_NOTIFICATIONS";
|
||||||
val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
|
val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
|
||||||
|
|||||||
@@ -13,15 +13,18 @@ import android.view.View
|
|||||||
import android.widget.ImageButton
|
import android.widget.ImageButton
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||||
import com.futo.platformplayer.states.StateApp
|
import com.futo.platformplayer.states.StateApp
|
||||||
import com.futo.platformplayer.states.StateApp.Companion.withContext
|
import com.futo.platformplayer.states.StateApp.Companion.withContext
|
||||||
import com.futo.platformplayer.states.StatePolycentric
|
import com.futo.platformplayer.states.StatePolycentric
|
||||||
import com.futo.platformplayer.views.buttons.BigButton
|
import com.futo.platformplayer.views.buttons.BigButton
|
||||||
|
import com.futo.platformplayer.activities.QRCodeFullscreenActivity
|
||||||
import com.futo.polycentric.core.ContentType
|
import com.futo.polycentric.core.ContentType
|
||||||
import com.futo.polycentric.core.SignedEvent
|
import com.futo.polycentric.core.SignedEvent
|
||||||
import com.futo.polycentric.core.StorageTypeCRDTItem
|
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.Store
|
||||||
import com.futo.polycentric.core.toBase64Url
|
import com.futo.polycentric.core.toBase64Url
|
||||||
import com.google.zxing.BarcodeFormat
|
import com.google.zxing.BarcodeFormat
|
||||||
|
import com.google.zxing.EncodeHintType
|
||||||
import com.google.zxing.MultiFormatWriter
|
import com.google.zxing.MultiFormatWriter
|
||||||
import com.google.zxing.common.BitMatrix
|
import com.google.zxing.common.BitMatrix
|
||||||
|
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -41,11 +46,27 @@ import userpackage.Protocol.URLInfo
|
|||||||
class PolycentricBackupActivity : AppCompatActivity() {
|
class PolycentricBackupActivity : AppCompatActivity() {
|
||||||
private lateinit var _buttonShare: BigButton;
|
private lateinit var _buttonShare: BigButton;
|
||||||
private lateinit var _buttonCopy: BigButton;
|
private lateinit var _buttonCopy: BigButton;
|
||||||
|
private lateinit var _buttonExportFile: BigButton;
|
||||||
private lateinit var _imageQR: ImageView;
|
private lateinit var _imageQR: ImageView;
|
||||||
private lateinit var _exportBundle: String;
|
private lateinit var _exportBundle: String;
|
||||||
private lateinit var _textQR: TextView;
|
private lateinit var _textQR: TextView;
|
||||||
|
private lateinit var _textQRHint: TextView;
|
||||||
private lateinit var _loader: View
|
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?) {
|
override fun attachBaseContext(newBase: Context?) {
|
||||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||||
}
|
}
|
||||||
@@ -57,8 +78,10 @@ class PolycentricBackupActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
_buttonShare = findViewById(R.id.button_share)
|
_buttonShare = findViewById(R.id.button_share)
|
||||||
_buttonCopy = findViewById(R.id.button_copy)
|
_buttonCopy = findViewById(R.id.button_copy)
|
||||||
|
_buttonExportFile = findViewById(R.id.button_export_file)
|
||||||
_imageQR = findViewById(R.id.image_qr)
|
_imageQR = findViewById(R.id.image_qr)
|
||||||
_textQR = findViewById(R.id.text_qr)
|
_textQR = findViewById(R.id.text_qr)
|
||||||
|
_textQRHint = findViewById(R.id.text_qr_hint)
|
||||||
_loader = findViewById(R.id.progress_loader)
|
_loader = findViewById(R.id.progress_loader)
|
||||||
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
|
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
|
||||||
finish();
|
finish();
|
||||||
@@ -66,14 +89,23 @@ class PolycentricBackupActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
_imageQR.visibility = View.INVISIBLE
|
_imageQR.visibility = View.INVISIBLE
|
||||||
_textQR.visibility = View.INVISIBLE
|
_textQR.visibility = View.INVISIBLE
|
||||||
|
_textQRHint.visibility = View.INVISIBLE
|
||||||
_loader.visibility = View.VISIBLE
|
_loader.visibility = View.VISIBLE
|
||||||
_buttonShare.visibility = View.INVISIBLE
|
_buttonShare.visibility = View.INVISIBLE
|
||||||
_buttonCopy.visibility = View.INVISIBLE
|
_buttonCopy.visibility = View.INVISIBLE
|
||||||
|
_buttonExportFile.visibility = View.INVISIBLE
|
||||||
|
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
|
val bundle = withContext(Dispatchers.IO) { createExportBundle() }
|
||||||
|
_exportBundle = bundle
|
||||||
|
Logger.i(TAG, "Export bundle created, length: ${bundle.length}")
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val pair = withContext(Dispatchers.IO) {
|
val pair = withContext(Dispatchers.IO) {
|
||||||
val bundle = createExportBundle()
|
if (!isContentSuitableForQRCode(bundle)) {
|
||||||
|
throw Exception("Data too big for QR code generation")
|
||||||
|
}
|
||||||
|
|
||||||
val dimension = TypedValue.applyDimension(
|
val dimension = TypedValue.applyDimension(
|
||||||
TypedValue.COMPLEX_UNIT_DIP, 200f, resources.displayMetrics
|
TypedValue.COMPLEX_UNIT_DIP, 200f, resources.displayMetrics
|
||||||
).toInt()
|
).toInt()
|
||||||
@@ -81,18 +113,35 @@ class PolycentricBackupActivity : AppCompatActivity() {
|
|||||||
Pair(bundle, qr)
|
Pair(bundle, qr)
|
||||||
}
|
}
|
||||||
|
|
||||||
_exportBundle = pair.first
|
|
||||||
_imageQR.setImageBitmap(pair.second)
|
_imageQR.setImageBitmap(pair.second)
|
||||||
_imageQR.visibility = View.VISIBLE
|
_imageQR.visibility = View.VISIBLE
|
||||||
_textQR.visibility = View.VISIBLE
|
_textQR.visibility = View.VISIBLE
|
||||||
|
_textQRHint.visibility = View.VISIBLE
|
||||||
_buttonShare.visibility = View.VISIBLE
|
_buttonShare.visibility = View.VISIBLE
|
||||||
_buttonCopy.visibility = View.VISIBLE
|
_buttonCopy.visibility = View.VISIBLE
|
||||||
|
|
||||||
|
_imageQR.setOnClickListener {
|
||||||
|
val intent = QRCodeFullscreenActivity.createIntent(this@PolycentricBackupActivity, _exportBundle)
|
||||||
|
startActivity(intent)
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} 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
|
_imageQR.visibility = View.INVISIBLE
|
||||||
_textQR.visibility = View.INVISIBLE
|
|
||||||
_buttonShare.visibility = View.INVISIBLE
|
|
||||||
_buttonCopy.visibility = View.INVISIBLE
|
|
||||||
} finally {
|
} finally {
|
||||||
_loader.visibility = View.GONE
|
_loader.visibility = View.GONE
|
||||||
}
|
}
|
||||||
@@ -108,11 +157,29 @@ class PolycentricBackupActivity : AppCompatActivity() {
|
|||||||
val clip = ClipData.newPlainText(getString(R.string.copied_text), _exportBundle);
|
val clip = ClipData.newPlainText(getString(R.string.copied_text), _exportBundle);
|
||||||
clipboard.setPrimaryClip(clip);
|
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 {
|
private fun generateQRCode(content: String, width: Int, height: Int): Bitmap {
|
||||||
val bitMatrix = MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, width, height);
|
if (!isContentSuitableForQRCode(content)) {
|
||||||
return bitMatrixToBitmap(bitMatrix);
|
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 {
|
private fun bitMatrixToBitmap(matrix: BitMatrix): Bitmap {
|
||||||
@@ -203,7 +270,8 @@ class PolycentricBackupActivity : AppCompatActivity() {
|
|||||||
.setBody(exportBundle.toByteString())
|
.setBody(exportBundle.toByteString())
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
return "polycentric://" + urlInfo.toByteArray().toBase64Url()
|
val data = urlInfo.toByteArray()
|
||||||
|
return "polycentric://" + data.toBase64Url()
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
+133
-61
@@ -32,100 +32,166 @@ import userpackage.Protocol
|
|||||||
import userpackage.Protocol.ExportBundle
|
import userpackage.Protocol.ExportBundle
|
||||||
|
|
||||||
class PolycentricImportProfileActivity : AppCompatActivity() {
|
class PolycentricImportProfileActivity : AppCompatActivity() {
|
||||||
private lateinit var _buttonHelp: ImageButton;
|
private lateinit var _buttonHelp: ImageButton
|
||||||
private lateinit var _buttonScanProfile: LinearLayout;
|
private lateinit var _buttonScanProfile: LinearLayout
|
||||||
private lateinit var _buttonImportProfile: LinearLayout;
|
private lateinit var _buttonImportFile: LinearLayout
|
||||||
private lateinit var _editProfile: EditText;
|
private lateinit var _buttonImportProfile: LinearLayout
|
||||||
private lateinit var _loaderOverlay: LoaderOverlay;
|
private lateinit var _editProfile: EditText
|
||||||
|
private lateinit var _loaderOverlay: LoaderOverlay
|
||||||
|
|
||||||
private val _qrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
private val _qrCodeResultLauncher =
|
||||||
val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data)
|
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
scanResult?.let {
|
val scanResult =
|
||||||
if (it.contents != null) {
|
IntentIntegrator.parseActivityResult(result.resultCode, result.data)
|
||||||
val scannedUrl = it.contents
|
scanResult?.let {
|
||||||
import(scannedUrl)
|
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?) {
|
override fun attachBaseContext(newBase: Context?) {
|
||||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState)
|
||||||
setContentView(R.layout.activity_polycentric_import_profile);
|
setContentView(R.layout.activity_polycentric_import_profile)
|
||||||
setNavigationBarColorAndIcons();
|
setNavigationBarColorAndIcons()
|
||||||
|
|
||||||
_buttonHelp = findViewById(R.id.button_help);
|
_buttonHelp = findViewById(R.id.button_help)
|
||||||
_buttonScanProfile = findViewById(R.id.button_scan_profile);
|
_buttonScanProfile = findViewById(R.id.button_scan_profile)
|
||||||
_buttonImportProfile = findViewById(R.id.button_import_profile);
|
_buttonImportFile = findViewById(R.id.button_import_file)
|
||||||
_loaderOverlay = findViewById(R.id.loader_overlay);
|
_buttonImportProfile = findViewById(R.id.button_import_profile)
|
||||||
_editProfile = findViewById(R.id.edit_profile);
|
_loaderOverlay = findViewById(R.id.loader_overlay)
|
||||||
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
|
_editProfile = findViewById(R.id.edit_profile)
|
||||||
finish();
|
findViewById<ImageButton>(R.id.button_back).setOnClickListener { finish() }
|
||||||
};
|
|
||||||
|
|
||||||
_buttonHelp.setOnClickListener {
|
_buttonHelp.setOnClickListener {
|
||||||
startActivity(Intent(this, PolycentricWhyActivity::class.java));
|
startActivity(Intent(this, PolycentricWhyActivity::class.java))
|
||||||
};
|
}
|
||||||
|
|
||||||
_buttonScanProfile.setOnClickListener {
|
_buttonScanProfile.setOnClickListener {
|
||||||
val integrator = IntentIntegrator(this)
|
val integrator = IntentIntegrator(this)
|
||||||
integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE)
|
integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE)
|
||||||
integrator.setPrompt(getString(R.string.scan_a_qr_code))
|
integrator.setPrompt(getString(R.string.scan_a_qr_code))
|
||||||
integrator.setOrientationLocked(true);
|
integrator.setOrientationLocked(true)
|
||||||
integrator.setCameraId(0)
|
integrator.setCameraId(0)
|
||||||
integrator.setBeepEnabled(false)
|
integrator.setBeepEnabled(false)
|
||||||
integrator.setBarcodeImageEnabled(true)
|
integrator.setBarcodeImageEnabled(true)
|
||||||
integrator.setCaptureActivity(QRCaptureActivity::class.java);
|
integrator.setCaptureActivity(QRCaptureActivity::class.java)
|
||||||
_qrCodeResultLauncher.launch(integrator.createScanIntent())
|
_qrCodeResultLauncher.launch(integrator.createScanIntent())
|
||||||
};
|
}
|
||||||
|
|
||||||
|
_buttonImportFile.setOnClickListener { _filePickerLauncher.launch("text/plain") }
|
||||||
|
|
||||||
_buttonImportProfile.setOnClickListener {
|
_buttonImportProfile.setOnClickListener {
|
||||||
if (_editProfile.text.isEmpty()) {
|
if (_editProfile.text.isEmpty()) {
|
||||||
UIDialogs.toast(this, getString(R.string.text_field_does_not_contain_any_data));
|
UIDialogs.toast(this, getString(R.string.text_field_does_not_contain_any_data))
|
||||||
return@setOnClickListener;
|
return@setOnClickListener
|
||||||
}
|
}
|
||||||
|
|
||||||
import(_editProfile.text.toString());
|
import(_editProfile.text.toString())
|
||||||
};
|
}
|
||||||
|
|
||||||
val url = intent.getStringExtra("url");
|
val url = intent.getStringExtra("url")
|
||||||
if (url != null) {
|
if (url != null) {
|
||||||
import(url);
|
import(url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun import(url: String) {
|
private fun import(url: String) {
|
||||||
if (!url.startsWith("polycentric://")) {
|
if (!url.startsWith("polycentric://")) {
|
||||||
UIDialogs.toast(this, getString(R.string.not_a_valid_url));
|
UIDialogs.toast(this, getString(R.string.not_a_valid_url))
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_loaderOverlay.show()
|
_loaderOverlay.show()
|
||||||
|
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val data = url.substring("polycentric://".length).base64UrlToByteArray();
|
val data = url.substring("polycentric://".length).base64UrlToByteArray()
|
||||||
val urlInfo = Protocol.URLInfo.parseFrom(data);
|
val urlInfo = Protocol.URLInfo.parseFrom(data)
|
||||||
|
|
||||||
if (urlInfo.urlType != 3L) {
|
if (urlInfo.urlType != 3L) {
|
||||||
throw Exception("Expected urlInfo struct of type ExportBundle")
|
throw Exception("Expected urlInfo struct of type ExportBundle")
|
||||||
}
|
}
|
||||||
|
|
||||||
val exportBundle = ExportBundle.parseFrom(urlInfo.body);
|
val exportBundle = ExportBundle.parseFrom(urlInfo.body)
|
||||||
val keyPair = KeyPair.fromProto(exportBundle.keyPair);
|
val keyPair = KeyPair.fromProto(exportBundle.keyPair)
|
||||||
|
|
||||||
val existingProcessSecret = Store.instance.getProcessSecret(keyPair.publicKey);
|
val existingProcessSecret = Store.instance.getProcessSecret(keyPair.publicKey)
|
||||||
if (existingProcessSecret != null) {
|
if (existingProcessSecret != null) {
|
||||||
withContext(Dispatchers.Main) {
|
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());
|
val processSecret = ProcessSecret(keyPair, Process.random())
|
||||||
Store.instance.addProcessSecret(processSecret);
|
Store.instance.addProcessSecret(processSecret)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
PolycentricStorage.instance.addProcessSecret(processSecret)
|
PolycentricStorage.instance.addProcessSecret(processSecret)
|
||||||
@@ -133,37 +199,43 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
|
|||||||
Logger.e(TAG, "Failed to save process secret to secret storage.", e)
|
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) {
|
for (e in exportBundle.events.eventsList) {
|
||||||
try {
|
try {
|
||||||
val se = SignedEvent.fromProto(e);
|
val se = SignedEvent.fromProto(e)
|
||||||
Store.instance.putSignedEvent(se);
|
Store.instance.putSignedEvent(se)
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Logger.w(TAG, "Ignored invalid event", e);
|
Logger.w(TAG, "Ignored invalid event", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
StatePolycentric.instance.setProcessHandle(processHandle);
|
StatePolycentric.instance.setProcessHandle(processHandle)
|
||||||
processHandle.fullyBackfillClient(ApiMethods.SERVER);
|
processHandle.fullyBackfillClient(ApiMethods.SERVER)
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
startActivity(Intent(this@PolycentricImportProfileActivity, PolycentricProfileActivity::class.java));
|
startActivity(
|
||||||
finish();
|
Intent(
|
||||||
|
this@PolycentricImportProfileActivity,
|
||||||
|
PolycentricProfileActivity::class.java
|
||||||
|
)
|
||||||
|
)
|
||||||
|
finish()
|
||||||
}
|
}
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Logger.w(TAG, "Failed to import profile", e);
|
Logger.w(TAG, "Failed to import profile", e)
|
||||||
withContext(Dispatchers.Main) {
|
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 {
|
} finally {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) { _loaderOverlay.hide() }
|
||||||
_loaderOverlay.hide();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "PolycentricImportProfileActivity";
|
private const val TAG = "PolycentricImportProfileActivity"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+318
@@ -0,0 +1,318 @@
|
|||||||
|
package com.futo.platformplayer.api.http.server.handlers
|
||||||
|
|
||||||
|
import android.content.ContentResolver
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import android.provider.OpenableColumns
|
||||||
|
import com.futo.platformplayer.api.http.server.HttpContext
|
||||||
|
import com.futo.platformplayer.api.http.server.HttpHeaders
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import java.io.FileNotFoundException
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
class HttpContentUriHandler(
|
||||||
|
method: String,
|
||||||
|
path: String,
|
||||||
|
private val contentResolver: ContentResolver,
|
||||||
|
private val uri: Uri,
|
||||||
|
private val explicitContentType: String? = null
|
||||||
|
) : HttpHandler(method, path) {
|
||||||
|
|
||||||
|
override fun handle(httpContext: HttpContext) {
|
||||||
|
val resolver = contentResolver
|
||||||
|
val requestHeaders = httpContext.headers
|
||||||
|
val responseHeaders = this.headers.clone()
|
||||||
|
|
||||||
|
val meta = try {
|
||||||
|
queryMetadata(resolver, uri)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Failed to query metadata for $uri", e)
|
||||||
|
httpContext.respondCode(404, responseHeaders)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val contentType = explicitContentType
|
||||||
|
?: resolver.getType(uri)
|
||||||
|
?: "application/octet-stream"
|
||||||
|
responseHeaders["Content-Type"] = contentType
|
||||||
|
|
||||||
|
meta.lastModifiedMillis?.let { lastModified ->
|
||||||
|
responseHeaders["Last-Modified"] = httpDateFormat.format(Date(lastModified))
|
||||||
|
|
||||||
|
val ifModifiedSinceHeader = requestHeaders["If-Modified-Since"]
|
||||||
|
if (ifModifiedSinceHeader != null) {
|
||||||
|
val ifModifiedSince = try {
|
||||||
|
httpDateFormat.parse(ifModifiedSinceHeader)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ifModifiedSince != null && lastModified <= ifModifiedSince.time) {
|
||||||
|
httpContext.respondCode(304, responseHeaders)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val safeName = (meta.displayName ?: "content.bin").replace("\"", "\\\"")
|
||||||
|
responseHeaders["Content-Disposition"] = "attachment; filename=\"$safeName\""
|
||||||
|
|
||||||
|
val length = meta.size
|
||||||
|
if (length == null) {
|
||||||
|
Logger.i(TAG, "Streaming $uri with unknown length; Range not supported")
|
||||||
|
responseHeaders.remove("Content-Length")
|
||||||
|
responseHeaders.remove("Content-Range")
|
||||||
|
responseHeaders.remove("Accept-Ranges")
|
||||||
|
|
||||||
|
stream(
|
||||||
|
httpContext = httpContext,
|
||||||
|
resolver = resolver,
|
||||||
|
uri = uri,
|
||||||
|
statusCode = 200,
|
||||||
|
headers = responseHeaders,
|
||||||
|
start = null,
|
||||||
|
length = null
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
responseHeaders["Accept-Ranges"] = "bytes"
|
||||||
|
|
||||||
|
val rangeHeader = requestHeaders["Range"]
|
||||||
|
if (rangeHeader.isNullOrBlank()) {
|
||||||
|
responseHeaders["Content-Length"] = length.toString()
|
||||||
|
Logger.i(TAG, "Sending full content for $uri, length=$length")
|
||||||
|
|
||||||
|
stream(
|
||||||
|
httpContext = httpContext,
|
||||||
|
resolver = resolver,
|
||||||
|
uri = uri,
|
||||||
|
statusCode = 200,
|
||||||
|
headers = responseHeaders,
|
||||||
|
start = 0L,
|
||||||
|
length = length
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val range = parseRange(rangeHeader, length)
|
||||||
|
if (range == null) {
|
||||||
|
Logger.w(TAG, "Invalid Range '$rangeHeader' for $uri (length=$length)")
|
||||||
|
responseHeaders["Content-Range"] = "bytes */$length"
|
||||||
|
httpContext.respondCode(416, responseHeaders)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val start = range.first
|
||||||
|
val endInclusive = range.last
|
||||||
|
val bytesToSend = endInclusive - start + 1
|
||||||
|
|
||||||
|
responseHeaders["Content-Range"] = "bytes $start-$endInclusive/$length"
|
||||||
|
responseHeaders["Content-Length"] = bytesToSend.toString()
|
||||||
|
Logger.i(TAG, "Sending range $start-$endInclusive (length=$bytesToSend) of $length for $uri")
|
||||||
|
|
||||||
|
stream(
|
||||||
|
httpContext = httpContext,
|
||||||
|
resolver = resolver,
|
||||||
|
uri = uri,
|
||||||
|
statusCode = 206,
|
||||||
|
headers = responseHeaders,
|
||||||
|
start = start,
|
||||||
|
length = bytesToSend
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ContentMeta(
|
||||||
|
val displayName: String?,
|
||||||
|
val size: Long?,
|
||||||
|
val lastModifiedMillis: Long?
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun queryMetadata(resolver: ContentResolver, uri: Uri): ContentMeta {
|
||||||
|
var displayName: String? = null
|
||||||
|
var size: Long? = null
|
||||||
|
var lastModifiedMillis: Long? = null
|
||||||
|
|
||||||
|
resolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||||
|
if (cursor.moveToFirst()) {
|
||||||
|
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||||
|
if (nameIndex != -1 && !cursor.isNull(nameIndex)) {
|
||||||
|
displayName = cursor.getString(nameIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||||
|
if (sizeIndex != -1 && !cursor.isNull(sizeIndex)) {
|
||||||
|
val s = cursor.getLong(sizeIndex)
|
||||||
|
if (s >= 0) size = s // -1 means unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
val dateModifiedIndex = cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED)
|
||||||
|
if (dateModifiedIndex != -1 && !cursor.isNull(dateModifiedIndex)) {
|
||||||
|
val seconds = cursor.getLong(dateModifiedIndex)
|
||||||
|
if (seconds > 0) {
|
||||||
|
lastModifiedMillis = seconds * 1000L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastModifiedMillis == null) {
|
||||||
|
val dateAddedIndex = cursor.getColumnIndex(MediaStore.MediaColumns.DATE_ADDED)
|
||||||
|
if (dateAddedIndex != -1 && !cursor.isNull(dateAddedIndex)) {
|
||||||
|
val seconds = cursor.getLong(dateAddedIndex)
|
||||||
|
if (seconds > 0) {
|
||||||
|
lastModifiedMillis = seconds * 1000L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (displayName == null) {
|
||||||
|
displayName = uri.lastPathSegment
|
||||||
|
}
|
||||||
|
|
||||||
|
if (size == null) {
|
||||||
|
try {
|
||||||
|
resolver.openAssetFileDescriptor(uri, "r")?.use { afd ->
|
||||||
|
val assetLen = afd.length
|
||||||
|
if (assetLen >= 0) {
|
||||||
|
size = assetLen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
return ContentMeta(
|
||||||
|
displayName = displayName,
|
||||||
|
size = size,
|
||||||
|
lastModifiedMillis = lastModifiedMillis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseRange(header: String, totalLength: Long): LongRange? {
|
||||||
|
if (totalLength <= 0L) return null
|
||||||
|
|
||||||
|
val prefix = "bytes="
|
||||||
|
if (!header.startsWith(prefix, ignoreCase = true)) return null
|
||||||
|
|
||||||
|
val spec = header.substring(prefix.length).trim()
|
||||||
|
if (spec.isEmpty()) return null
|
||||||
|
|
||||||
|
if (spec.contains(",")) return null
|
||||||
|
|
||||||
|
val dashIndex = spec.indexOf('-')
|
||||||
|
if (dashIndex < 0) return null
|
||||||
|
|
||||||
|
val startPart = spec.substring(0, dashIndex).trim()
|
||||||
|
val endPart = spec.substring(dashIndex + 1).trim()
|
||||||
|
|
||||||
|
return when {
|
||||||
|
startPart.isNotEmpty() -> {
|
||||||
|
val start = startPart.toLongOrNull() ?: return null
|
||||||
|
if (start < 0 || start >= totalLength) return null
|
||||||
|
|
||||||
|
val end = if (endPart.isNotEmpty()) {
|
||||||
|
val rawEnd = endPart.toLongOrNull() ?: return null
|
||||||
|
if (rawEnd < start) return null
|
||||||
|
rawEnd.coerceAtMost(totalLength - 1)
|
||||||
|
} else {
|
||||||
|
totalLength - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
start..end
|
||||||
|
}
|
||||||
|
|
||||||
|
endPart.isNotEmpty() -> {
|
||||||
|
val suffixLen = endPart.toLongOrNull() ?: return null
|
||||||
|
if (suffixLen <= 0L) return null
|
||||||
|
|
||||||
|
if (suffixLen >= totalLength) {
|
||||||
|
0L..(totalLength - 1)
|
||||||
|
} else {
|
||||||
|
val start = totalLength - suffixLen
|
||||||
|
val end = totalLength - 1
|
||||||
|
start..end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stream(httpContext: HttpContext, resolver: ContentResolver, uri: Uri, statusCode: Int, headers: HttpHeaders, start: Long?, length: Long?) {
|
||||||
|
try {
|
||||||
|
val input = resolver.openInputStream(uri)
|
||||||
|
if (input == null) {
|
||||||
|
Logger.w(TAG, "Content not found: $uri")
|
||||||
|
httpContext.respondCode(404, headers)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
input.use { inputStream ->
|
||||||
|
httpContext.respond(statusCode, headers) { outputStream ->
|
||||||
|
try {
|
||||||
|
val offset = start ?: 0L
|
||||||
|
if (offset > 0L) {
|
||||||
|
skipFully(inputStream, offset)
|
||||||
|
}
|
||||||
|
copyStream(inputStream, outputStream, length)
|
||||||
|
outputStream.flush()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Error while streaming $uri (start=$start, length=$length)", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: FileNotFoundException) {
|
||||||
|
Logger.w(TAG, "Content not found: $uri", e)
|
||||||
|
httpContext.respondCode(404, headers)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Failed to open stream for $uri", e)
|
||||||
|
httpContext.respondCode(500, headers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun copyStream(input: InputStream, output: OutputStream, limit: Long?) {
|
||||||
|
val buffer = ByteArray(8192)
|
||||||
|
if (limit == null) {
|
||||||
|
while (true) {
|
||||||
|
val read = input.read(buffer)
|
||||||
|
if (read < 0) break
|
||||||
|
output.write(buffer, 0, read)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var remaining = limit
|
||||||
|
while (remaining > 0L) {
|
||||||
|
val toRead = remaining.coerceAtMost(buffer.size.toLong()).toInt()
|
||||||
|
val read = input.read(buffer, 0, toRead)
|
||||||
|
if (read < 0) break
|
||||||
|
output.write(buffer, 0, read)
|
||||||
|
remaining -= read.toLong()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun skipFully(input: InputStream, bytesToSkip: Long) {
|
||||||
|
var remaining = bytesToSkip
|
||||||
|
while (remaining > 0L) {
|
||||||
|
val skipped = input.skip(remaining)
|
||||||
|
if (skipped <= 0L) {
|
||||||
|
val b = input.read()
|
||||||
|
if (b == -1) break
|
||||||
|
remaining -= 1L
|
||||||
|
} else {
|
||||||
|
remaining -= skipped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "HttpContentUriHandler"
|
||||||
|
|
||||||
|
private val httpDateFormat = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss 'GMT'", Locale.US).apply {
|
||||||
|
timeZone = TimeZone.getTimeZone("GMT")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+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.HttpContext
|
||||||
import com.futo.platformplayer.api.http.server.HttpHeaders
|
import com.futo.platformplayer.api.http.server.HttpHeaders
|
||||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
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.logging.Logger
|
||||||
import com.futo.platformplayer.parsers.HttpResponseParser
|
import com.futo.platformplayer.parsers.HttpResponseParser
|
||||||
import com.futo.platformplayer.readLine
|
import com.futo.platformplayer.readLine
|
||||||
@@ -27,6 +28,7 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String, priv
|
|||||||
private var _injectReferer = false;
|
private var _injectReferer = false;
|
||||||
|
|
||||||
private val _client = ManagedHttpClient();
|
private val _client = ManagedHttpClient();
|
||||||
|
private var _requestModifier: ((String, Map<String, String>) -> IRequest)? = null;
|
||||||
|
|
||||||
override fun handle(context: HttpContext) {
|
override fun handle(context: HttpContext) {
|
||||||
if (useTcp) {
|
if (useTcp) {
|
||||||
@@ -43,21 +45,33 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String, priv
|
|||||||
for (injectHeader in _injectRequestHeader)
|
for (injectHeader in _injectRequestHeader)
|
||||||
proxyHeaders[injectHeader.first] = injectHeader.second;
|
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)
|
if(_injectHost)
|
||||||
proxyHeaders.put("Host", parsed.host!!);
|
proxyHeaders.put("Host", parsed.host!!);
|
||||||
if(_injectReferer)
|
if(_injectReferer)
|
||||||
proxyHeaders.put("Referer", targetUrl);
|
proxyHeaders.put("Referer", url);
|
||||||
|
|
||||||
val useMethod = if (method == "inherit") context.method else method;
|
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"));
|
Logger.i(TAG, "handleWithOkHttp Headers:" + proxyHeaders.map { "${it.key}: ${it.value}" }.joinToString("\n"));
|
||||||
|
|
||||||
val resp = when (useMethod) {
|
val resp = when (useMethod) {
|
||||||
"GET" -> _client.get(targetUrl, proxyHeaders);
|
"GET" -> _client.get(url, proxyHeaders);
|
||||||
"POST" -> _client.post(targetUrl, content ?: "", proxyHeaders);
|
"POST" -> _client.post(url, content ?: "", proxyHeaders);
|
||||||
"HEAD" -> _client.head(targetUrl, proxyHeaders)
|
"HEAD" -> _client.head(url, proxyHeaders)
|
||||||
else -> _client.requestMethod(useMethod, targetUrl, proxyHeaders);
|
else -> _client.requestMethod(useMethod, url, proxyHeaders);
|
||||||
};
|
};
|
||||||
|
|
||||||
Logger.i(TAG, "Proxied Response [${resp.code}]");
|
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)
|
for (injectHeader in _injectRequestHeader)
|
||||||
proxyHeaders[injectHeader.first] = injectHeader.second;
|
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)
|
if(_injectHost)
|
||||||
proxyHeaders.put("Host", parsed.host!!);
|
proxyHeaders.put("Host", parsed.host!!);
|
||||||
if(_injectReferer)
|
if(_injectReferer)
|
||||||
proxyHeaders.put("Referer", targetUrl);
|
proxyHeaders.put("Referer", url);
|
||||||
|
|
||||||
val useMethod = if (method == "inherit") context.method else method;
|
val useMethod = if (method == "inherit") context.method else method;
|
||||||
Logger.i(TAG, "handleWithTcp Proxied Request ${useMethod}: ${parsed}");
|
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");
|
_ignoreRequestHeaders.add("referer");
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
fun withRequestModifier(modifier: (String, Map<String, String>) -> IRequest) : HttpProxyHandler {
|
||||||
|
_requestModifier = modifier;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "HttpProxyHandler"
|
private const val TAG = "HttpProxyHandler"
|
||||||
|
|||||||
+2
-2
@@ -73,10 +73,10 @@ open class LocalVideoDetails(
|
|||||||
override val video: IVideoSourceDescriptor = (if(mimeType?.startsWith("audio/") ?: false)
|
override val video: IVideoSourceDescriptor = (if(mimeType?.startsWith("audio/") ?: false)
|
||||||
(LocalVideoUnMuxedSourceDescriptor(
|
(LocalVideoUnMuxedSourceDescriptor(
|
||||||
arrayOf(),
|
arrayOf(),
|
||||||
arrayOf(LocalAudioContentSource(url, mimeType ?: "", name))
|
arrayOf(LocalAudioContentSource(url, mimeType ?: "", name, duration))
|
||||||
))
|
))
|
||||||
else (LocalVideoMuxedSourceDescriptor(
|
else (LocalVideoMuxedSourceDescriptor(
|
||||||
LocalVideoContentSource(url, mimeType ?: "", name)
|
LocalVideoContentSource(url, mimeType ?: "", name, duration)
|
||||||
))
|
))
|
||||||
);
|
);
|
||||||
override val preview: ISerializedVideoSourceDescriptor? = null;
|
override val preview: ISerializedVideoSourceDescriptor? = null;
|
||||||
|
|||||||
@@ -153,8 +153,8 @@ open class JSClient : IPlatformClient {
|
|||||||
_captcha = descriptor.getCaptchaData();
|
_captcha = descriptor.getCaptchaData();
|
||||||
flags = descriptor.flags.toTypedArray();
|
flags = descriptor.flags.toTypedArray();
|
||||||
|
|
||||||
_httpClient = JSHttpClient(this, null, _captcha);
|
_httpClient = JSHttpClient(this, null, _captcha, config);
|
||||||
_httpClientAuth = JSHttpClient(this, _auth, _captcha);
|
_httpClientAuth = JSHttpClient(this, _auth, _captcha, config);
|
||||||
_plugin = V8Plugin(context, descriptor.config, null, _httpClient, _httpClientAuth);
|
_plugin = V8Plugin(context, descriptor.config, null, _httpClient, _httpClientAuth);
|
||||||
_plugin.withDependency(context, "scripts/polyfil.js");
|
_plugin.withDependency(context, "scripts/polyfil.js");
|
||||||
_plugin.withDependency(context, "scripts/source.js");
|
_plugin.withDependency(context, "scripts/source.js");
|
||||||
@@ -186,8 +186,8 @@ open class JSClient : IPlatformClient {
|
|||||||
_captcha = descriptor.getCaptchaData();
|
_captcha = descriptor.getCaptchaData();
|
||||||
flags = descriptor.flags.toTypedArray();
|
flags = descriptor.flags.toTypedArray();
|
||||||
|
|
||||||
_httpClient = JSHttpClient(this, null, _captcha);
|
_httpClient = JSHttpClient(this, null, _captcha, config);
|
||||||
_httpClientAuth = JSHttpClient(this, _auth, _captcha);
|
_httpClientAuth = JSHttpClient(this, _auth, _captcha, config);
|
||||||
_plugin = V8Plugin(context, descriptor.config, script, _httpClient, _httpClientAuth);
|
_plugin = V8Plugin(context, descriptor.config, script, _httpClient, _httpClientAuth);
|
||||||
_plugin.withDependency(context, "scripts/polyfil.js");
|
_plugin.withDependency(context, "scripts/polyfil.js");
|
||||||
_plugin.withDependency(context, "scripts/source.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.Contextual
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.Transient
|
||||||
import java.util.Dictionary
|
import java.util.Dictionary
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -27,7 +28,7 @@ class SourcePluginAuthConfig(
|
|||||||
val details: String? = null,
|
val details: String? = null,
|
||||||
val once: Boolean? = true
|
val once: Boolean? = true
|
||||||
) {
|
) {
|
||||||
@Contextual
|
@Transient
|
||||||
private var _regex: Regex? = null;
|
private var _regex: Regex? = null;
|
||||||
|
|
||||||
fun getRegex(): Regex {
|
fun getRegex(): Regex {
|
||||||
|
|||||||
+1
-1
@@ -23,7 +23,7 @@ class SourcePluginConfig(
|
|||||||
//Script
|
//Script
|
||||||
val repositoryUrl: String? = null,
|
val repositoryUrl: String? = null,
|
||||||
val scriptUrl: String = "",
|
val scriptUrl: String = "",
|
||||||
val version: Int = -1,
|
var version: Int = -1,
|
||||||
|
|
||||||
val iconUrl: String? = null,
|
val iconUrl: String? = null,
|
||||||
var id: String = UUID.randomUUID().toString(),
|
var id: String = UUID.randomUUID().toString(),
|
||||||
|
|||||||
+71
@@ -23,6 +23,7 @@ import java.util.UUID
|
|||||||
class JSHttpClient : ManagedHttpClient {
|
class JSHttpClient : ManagedHttpClient {
|
||||||
private val _jsClient: JSClient?;
|
private val _jsClient: JSClient?;
|
||||||
private val _jsConfig: SourcePluginConfig?;
|
private val _jsConfig: SourcePluginConfig?;
|
||||||
|
val config get() = _jsConfig
|
||||||
private val _auth: SourceAuth?;
|
private val _auth: SourceAuth?;
|
||||||
private val _captcha: SourceCaptchaData?;
|
private val _captcha: SourceCaptchaData?;
|
||||||
|
|
||||||
@@ -254,6 +255,76 @@ class JSHttpClient : ManagedHttpClient {
|
|||||||
|
|
||||||
return resp;
|
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> {
|
private fun cookieStringToPair(cookie: String): Pair<String, String> {
|
||||||
val cookieKey = cookie.substring(0, cookie.indexOf("="));
|
val cookieKey = cookie.substring(0, cookie.indexOf("="));
|
||||||
|
|||||||
+30
-4
@@ -17,11 +17,14 @@ import com.futo.platformplayer.getOrThrow
|
|||||||
import com.futo.platformplayer.invokeV8
|
import com.futo.platformplayer.invokeV8
|
||||||
import com.futo.platformplayer.invokeV8Void
|
import com.futo.platformplayer.invokeV8Void
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.states.StateApp
|
||||||
import com.futo.platformplayer.states.StateDeveloper
|
import com.futo.platformplayer.states.StateDeveloper
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import java.util.Base64
|
import java.util.Base64
|
||||||
|
|
||||||
class JSRequestExecutor {
|
class JSRequestExecutor: AutoCloseable {
|
||||||
private val _plugin: JSClient;
|
private val _plugin: JSClient;
|
||||||
private val _config: IV8PluginConfig;
|
private val _config: IV8PluginConfig;
|
||||||
private var _executor: V8ValueObject;
|
private var _executor: V8ValueObject;
|
||||||
@@ -29,6 +32,9 @@ class JSRequestExecutor {
|
|||||||
|
|
||||||
private val hasCleanup: Boolean;
|
private val hasCleanup: Boolean;
|
||||||
|
|
||||||
|
private var _cleanLock = Any();
|
||||||
|
private var _cleaned: Boolean = false;
|
||||||
|
|
||||||
constructor(plugin: JSClient, executor: V8ValueObject) {
|
constructor(plugin: JSClient, executor: V8ValueObject) {
|
||||||
this._plugin = plugin;
|
this._plugin = plugin;
|
||||||
this._executor = executor;
|
this._executor = executor;
|
||||||
@@ -102,8 +108,12 @@ class JSRequestExecutor {
|
|||||||
|
|
||||||
|
|
||||||
open fun cleanup() {
|
open fun cleanup() {
|
||||||
if (!hasCleanup || _executor.isClosed)
|
synchronized(_cleanLock) {
|
||||||
return;
|
if (!hasCleanup || _executor.isClosed || _cleaned)
|
||||||
|
return;
|
||||||
|
_cleaned = true;
|
||||||
|
}
|
||||||
|
Logger.i("JSRequestExecutor", "JSRequestExecutor cleanup requested");
|
||||||
_plugin.busy {
|
_plugin.busy {
|
||||||
if(_plugin is DevJSClient)
|
if(_plugin is DevJSClient)
|
||||||
StateDeveloper.instance.handleDevCall(_plugin.devID, "requestExecutor.executeRequest()") {
|
StateDeveloper.instance.handleDevCall(_plugin.devID, "requestExecutor.executeRequest()") {
|
||||||
@@ -125,9 +135,25 @@ class JSRequestExecutor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected fun finalize() {
|
override fun close() {
|
||||||
cleanup();
|
cleanup();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun closeAsync() {
|
||||||
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO){
|
||||||
|
try {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
catch(ex: Throwable) {
|
||||||
|
Logger.e("JSRequestExecutor", "Cleanup failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
protected fun finalize() {
|
||||||
|
cleanup();
|
||||||
|
}*/
|
||||||
}
|
}
|
||||||
|
|
||||||
//TODO: are these available..?
|
//TODO: are these available..?
|
||||||
|
|||||||
+2
-2
@@ -23,10 +23,10 @@ class LocalAudioContentSource : IAudioSource {
|
|||||||
|
|
||||||
var contentUrl: String;
|
var contentUrl: String;
|
||||||
|
|
||||||
constructor(contentUrl: String, mime: String, name: String? = null) {
|
constructor(contentUrl: String, mime: String, name: String? = null, duration: Long = 0) {
|
||||||
this.name = name ?: "File";
|
this.name = name ?: "File";
|
||||||
container = mime;
|
container = mime;
|
||||||
duration = 0;
|
this.duration = duration;
|
||||||
|
|
||||||
this.contentUrl = contentUrl;
|
this.contentUrl = contentUrl;
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -22,12 +22,12 @@ class LocalVideoContentSource: IVideoSource {
|
|||||||
|
|
||||||
var contentUrl: String;
|
var contentUrl: String;
|
||||||
|
|
||||||
constructor(contentUrl: String, mime: String, name: String? = null) {
|
constructor(contentUrl: String, mime: String, name: String? = null, duration: Long = 0) {
|
||||||
this.name = name ?: "File";
|
this.name = name ?: "File";
|
||||||
width = 0;
|
width = 0;
|
||||||
height = 0;
|
height = 0;
|
||||||
container = mime;
|
container = mime;
|
||||||
duration = 0;
|
this.duration = duration;
|
||||||
this.contentUrl = contentUrl;
|
this.contentUrl = contentUrl;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -239,7 +239,7 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
DeviceConnectionState.Disconnected -> {
|
DeviceConnectionState.Disconnected -> {
|
||||||
connectionState = CastConnectionState.CONNECTING
|
connectionState = CastConnectionState.DISCONNECTED
|
||||||
onConnectionStateChanged.emit(CastConnectionState.DISCONNECTED)
|
onConnectionStateChanged.emit(CastConnectionState.DISCONNECTED)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -268,4 +268,4 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
|
|||||||
companion object {
|
companion object {
|
||||||
private val TAG = "CastingDeviceExp"
|
private val TAG = "CastingDeviceExp"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import android.content.Context
|
|||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.core.net.toUri
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.Settings
|
import com.futo.platformplayer.Settings
|
||||||
@@ -14,9 +15,11 @@ import com.futo.platformplayer.api.http.ManagedHttpClient
|
|||||||
import com.futo.platformplayer.api.http.server.HttpHeaders
|
import com.futo.platformplayer.api.http.server.HttpHeaders
|
||||||
import com.futo.platformplayer.api.http.server.ManagedHttpServer
|
import com.futo.platformplayer.api.http.server.ManagedHttpServer
|
||||||
import com.futo.platformplayer.api.http.server.handlers.HttpConstantHandler
|
import com.futo.platformplayer.api.http.server.handlers.HttpConstantHandler
|
||||||
|
import com.futo.platformplayer.api.http.server.handlers.HttpContentUriHandler
|
||||||
import com.futo.platformplayer.api.http.server.handlers.HttpFileHandler
|
import com.futo.platformplayer.api.http.server.handlers.HttpFileHandler
|
||||||
import com.futo.platformplayer.api.http.server.handlers.HttpFunctionHandler
|
import com.futo.platformplayer.api.http.server.handlers.HttpFunctionHandler
|
||||||
import com.futo.platformplayer.api.http.server.handlers.HttpProxyHandler
|
import com.futo.platformplayer.api.http.server.handlers.HttpProxyHandler
|
||||||
|
import com.futo.platformplayer.api.media.models.modifier.IRequestModifier
|
||||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
||||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource
|
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource
|
||||||
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudioSource
|
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudioSource
|
||||||
@@ -33,6 +36,8 @@ import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManif
|
|||||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource
|
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource
|
||||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource
|
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource
|
||||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
|
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
|
||||||
|
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalAudioContentSource
|
||||||
|
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoContentSource
|
||||||
import com.futo.platformplayer.awaitCancelConverted
|
import com.futo.platformplayer.awaitCancelConverted
|
||||||
import com.futo.platformplayer.builders.DashBuilder
|
import com.futo.platformplayer.builders.DashBuilder
|
||||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||||
@@ -234,9 +239,9 @@ abstract class StateCasting {
|
|||||||
Logger.i(TAG, "Connect to device ${device.name}")
|
Logger.i(TAG, "Connect to device ${device.name}")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun metadataFromVideo(video: IPlatformVideoDetails): Metadata {
|
fun metadataFromVideo(video: IPlatformVideoDetails, videoThumbnailOverrideUrl: String? = null): Metadata {
|
||||||
return Metadata(
|
return Metadata(
|
||||||
title = video.name, thumbnailUrl = video.thumbnails.getHQThumbnail()
|
title = video.name, thumbnailUrl = videoThumbnailOverrideUrl ?: video.thumbnails.getHQThumbnail()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,20 +300,63 @@ abstract class StateCasting {
|
|||||||
val url = getLocalUrl(ad);
|
val url = getLocalUrl(ad);
|
||||||
val id = UUID.randomUUID();
|
val id = UUID.randomUUID();
|
||||||
|
|
||||||
|
|
||||||
if (videoSource is IVideoUrlSource) {
|
if (videoSource is IVideoUrlSource) {
|
||||||
val videoPath = "/video-${id}"
|
val videoPath = "/video-$id"
|
||||||
val videoUrl = if(proxyStreams) url + videoPath else videoSource.getVideoUrl();
|
val upstreamUrl = videoSource.getVideoUrl()
|
||||||
Logger.i(TAG, "Casting as singular video");
|
val videoUrl = if (proxyStreams) url + videoPath else upstreamUrl
|
||||||
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video));
|
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) {
|
} else if (audioSource is IAudioUrlSource) {
|
||||||
val audioPath = "/audio-${id}"
|
val audioPath = "/audio-$id"
|
||||||
val audioUrl = if(proxyStreams) url + audioPath else audioSource.getAudioUrl();
|
val upstreamUrl = audioSource.getAudioUrl()
|
||||||
Logger.i(TAG, "Casting as singular audio");
|
val audioUrl = if (proxyStreams) url + audioPath else upstreamUrl
|
||||||
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video));
|
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) {
|
} else if (videoSource is IHLSManifestSource) {
|
||||||
if (proxyStreams || deviceProto == CastProtocolType.CHROMECAST) {
|
if (proxyStreams || deviceProto == CastProtocolType.CHROMECAST) {
|
||||||
Logger.i(TAG, "Casting as proxied HLS");
|
Logger.i(TAG, "Casting as proxied HLS");
|
||||||
castProxiedHls(video, videoSource.url, videoSource.codec, resumePosition, speed);
|
castProxiedHls(video, videoSource.url, videoSource.codec, resumePosition, speed, (videoSource as JSSource?)?.getRequestModifier());
|
||||||
} else {
|
} else {
|
||||||
Logger.i(TAG, "Casting as non-proxied HLS");
|
Logger.i(TAG, "Casting as non-proxied HLS");
|
||||||
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video));
|
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video));
|
||||||
@@ -316,7 +364,7 @@ abstract class StateCasting {
|
|||||||
} else if (audioSource is IHLSManifestAudioSource) {
|
} else if (audioSource is IHLSManifestAudioSource) {
|
||||||
if (proxyStreams || deviceProto == CastProtocolType.CHROMECAST) {
|
if (proxyStreams || deviceProto == CastProtocolType.CHROMECAST) {
|
||||||
Logger.i(TAG, "Casting as proxied audio HLS");
|
Logger.i(TAG, "Casting as proxied audio HLS");
|
||||||
castProxiedHls(video, audioSource.url, audioSource.codec, resumePosition, speed);
|
castProxiedHls(video, audioSource.url, audioSource.codec, resumePosition, speed, (audioSource as JSSource?)?.getRequestModifier());
|
||||||
} else {
|
} else {
|
||||||
Logger.i(TAG, "Casting as non-proxied audio HLS");
|
Logger.i(TAG, "Casting as non-proxied audio HLS");
|
||||||
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video));
|
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video));
|
||||||
@@ -327,6 +375,12 @@ abstract class StateCasting {
|
|||||||
} else if (audioSource is LocalAudioSource) {
|
} else if (audioSource is LocalAudioSource) {
|
||||||
Logger.i(TAG, "Casting as local audio");
|
Logger.i(TAG, "Casting as local audio");
|
||||||
castLocalAudio(video, audioSource, resumePosition, speed);
|
castLocalAudio(video, audioSource, resumePosition, speed);
|
||||||
|
} else if (videoSource is LocalVideoContentSource) {
|
||||||
|
Logger.i(TAG, "Casting as local video");
|
||||||
|
castLocalVideo(contentResolver, video, videoSource, resumePosition, speed);
|
||||||
|
} else if (audioSource is LocalAudioContentSource) {
|
||||||
|
Logger.i(TAG, "Casting as local audio");
|
||||||
|
castLocalAudio(contentResolver, video, audioSource, resumePosition, speed);
|
||||||
} else if (videoSource is JSDashManifestRawSource) {
|
} else if (videoSource is JSDashManifestRawSource) {
|
||||||
Logger.i(TAG, "Casting as JSDashManifestRawSource video");
|
Logger.i(TAG, "Casting as JSDashManifestRawSource video");
|
||||||
castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, null, null, resumePosition, speed, castId, onLoadingEstimate, onLoading);
|
castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, null, null, resumePosition, speed, castId, onLoadingEstimate, onLoading);
|
||||||
@@ -347,6 +401,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 {
|
fun resumeVideo(): Boolean {
|
||||||
val ad = activeDevice ?: return false;
|
val ad = activeDevice ?: return false;
|
||||||
try {
|
try {
|
||||||
@@ -412,6 +471,65 @@ abstract class StateCasting {
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun castLocalVideo(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: LocalVideoContentSource, resumePosition: Double, speed: Double?) : List<String> {
|
||||||
|
val ad = activeDevice ?: return listOf();
|
||||||
|
|
||||||
|
val url = getLocalUrl(ad);
|
||||||
|
val id = UUID.randomUUID();
|
||||||
|
val videoPath = "/video-${id}"
|
||||||
|
val videoUrl = url + videoPath;
|
||||||
|
val thumbnailPath = "/thumbnail-${id}"
|
||||||
|
val thumbnailUrl = url + thumbnailPath;
|
||||||
|
val thumbnailContentUrl = video.thumbnails.getHQThumbnail()
|
||||||
|
|
||||||
|
if (thumbnailContentUrl != null) {
|
||||||
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
|
HttpContentUriHandler("GET", thumbnailPath, contentResolver, thumbnailContentUrl.toUri())
|
||||||
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
|
).withTag("cast");
|
||||||
|
}
|
||||||
|
|
||||||
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
|
HttpContentUriHandler("GET", videoPath, contentResolver, videoSource.contentUrl.toUri())
|
||||||
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
|
).withTag("cast");
|
||||||
|
|
||||||
|
Logger.i(TAG, "Casting local video (videoUrl: $videoUrl).");
|
||||||
|
ad.loadVideo("BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video, if (thumbnailContentUrl != null) thumbnailUrl else null));
|
||||||
|
|
||||||
|
return listOf(videoUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun castLocalAudio(contentResolver: ContentResolver, video: IPlatformVideoDetails, audioSource: LocalAudioContentSource, resumePosition: Double, speed: Double?) : List<String> {
|
||||||
|
val ad = activeDevice ?: return listOf();
|
||||||
|
|
||||||
|
val url = getLocalUrl(ad);
|
||||||
|
val id = UUID.randomUUID();
|
||||||
|
val audioPath = "/audio-${id}"
|
||||||
|
val audioUrl = url + audioPath;
|
||||||
|
val thumbnailPath = "/thumbnail-${id}"
|
||||||
|
val thumbnailUrl = url + thumbnailPath;
|
||||||
|
val thumbnailContentUrl = video.thumbnails.getHQThumbnail()
|
||||||
|
|
||||||
|
if (thumbnailContentUrl != null) {
|
||||||
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
|
HttpContentUriHandler("GET", thumbnailPath, contentResolver, thumbnailContentUrl.toUri())
|
||||||
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
|
).withTag("cast");
|
||||||
|
}
|
||||||
|
|
||||||
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
|
HttpContentUriHandler("GET", audioPath, contentResolver, audioSource.contentUrl.toUri())
|
||||||
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
|
).withTag("cast");
|
||||||
|
|
||||||
|
Logger.i(TAG, "Casting local audio (audioUrl: $audioUrl).");
|
||||||
|
ad.loadVideo("BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video, if (thumbnailContentUrl != null) thumbnailUrl else null));
|
||||||
|
|
||||||
|
return listOf(audioUrl);
|
||||||
|
}
|
||||||
|
|
||||||
private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double, speed: Double?) : List<String> {
|
private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double, speed: Double?) : List<String> {
|
||||||
val ad = activeDevice ?: return listOf();
|
val ad = activeDevice ?: return listOf();
|
||||||
|
|
||||||
@@ -665,7 +783,8 @@ abstract class StateCasting {
|
|||||||
sourceUrl: String,
|
sourceUrl: String,
|
||||||
codec: String?,
|
codec: String?,
|
||||||
resumePosition: Double,
|
resumePosition: Double,
|
||||||
speed: Double?
|
speed: Double?,
|
||||||
|
requestModifier: IRequestModifier?
|
||||||
): List<String> {
|
): List<String> {
|
||||||
_castServer.removeAllHandlers("castProxiedHlsMaster")
|
_castServer.removeAllHandlers("castProxiedHlsMaster")
|
||||||
|
|
||||||
@@ -686,7 +805,9 @@ abstract class StateCasting {
|
|||||||
val headers = masterContext.headers.clone()
|
val headers = masterContext.headers.clone()
|
||||||
headers["Content-Type"] = "application/vnd.apple.mpegurl";
|
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}" }
|
check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" }
|
||||||
|
|
||||||
val masterPlaylistContent = masterPlaylistResponse.body?.string()
|
val masterPlaylistContent = masterPlaylistResponse.body?.string()
|
||||||
@@ -706,7 +827,7 @@ abstract class StateCasting {
|
|||||||
val variantPlaylist =
|
val variantPlaylist =
|
||||||
HLS.parseVariantPlaylist(masterPlaylistContent, sourceUrl)
|
HLS.parseVariantPlaylist(masterPlaylistContent, sourceUrl)
|
||||||
val proxiedVariantPlaylist =
|
val proxiedVariantPlaylist =
|
||||||
proxyVariantPlaylist(url, id, variantPlaylist, video.isLive)
|
proxyVariantPlaylist(url, id, variantPlaylist, video.isLive, requestModifier)
|
||||||
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
|
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
|
||||||
masterContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
|
masterContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
|
||||||
return@HttpFunctionHandler
|
return@HttpFunctionHandler
|
||||||
@@ -747,7 +868,7 @@ abstract class StateCasting {
|
|||||||
val variantPlaylist =
|
val variantPlaylist =
|
||||||
HLS.parseVariantPlaylist(vpContent, variantPlaylistRef.url)
|
HLS.parseVariantPlaylist(vpContent, variantPlaylistRef.url)
|
||||||
val proxiedVariantPlaylist =
|
val proxiedVariantPlaylist =
|
||||||
proxyVariantPlaylist(url, playlistId, variantPlaylist, video.isLive)
|
proxyVariantPlaylist(url, playlistId, variantPlaylist, video.isLive, requestModifier)
|
||||||
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
|
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
|
||||||
vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
|
vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
|
||||||
}.withHeader("Access-Control-Allow-Origin", "*"), true
|
}.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
@@ -784,7 +905,7 @@ abstract class StateCasting {
|
|||||||
val variantPlaylist =
|
val variantPlaylist =
|
||||||
HLS.parseVariantPlaylist(vpContent, mediaRendition.uri)
|
HLS.parseVariantPlaylist(vpContent, mediaRendition.uri)
|
||||||
val proxiedVariantPlaylist = proxyVariantPlaylist(
|
val proxiedVariantPlaylist = proxyVariantPlaylist(
|
||||||
url, playlistId, variantPlaylist, video.isLive
|
url, playlistId, variantPlaylist, video.isLive, requestModifier
|
||||||
)
|
)
|
||||||
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
|
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
|
||||||
vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
|
vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
|
||||||
@@ -826,13 +947,13 @@ abstract class StateCasting {
|
|||||||
return listOf(hlsUrl);
|
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>()
|
val newSegments = arrayListOf<HLS.Segment>()
|
||||||
|
|
||||||
if (proxySegments) {
|
if (proxySegments) {
|
||||||
variantPlaylist.segments.forEachIndexed { index, segment ->
|
variantPlaylist.segments.forEachIndexed { index, segment ->
|
||||||
val sequenceNumber = (variantPlaylist.mediaSequence ?: 0) + index.toLong()
|
val sequenceNumber = (variantPlaylist.mediaSequence ?: 0) + index.toLong()
|
||||||
newSegments.add(proxySegment(url, playlistId, segment, sequenceNumber))
|
newSegments.add(proxySegment(url, playlistId, segment, sequenceNumber, requestModifier))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
newSegments.addAll(variantPlaylist.segments)
|
newSegments.addAll(variantPlaylist.segments)
|
||||||
@@ -850,7 +971,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) {
|
if (segment is HLS.MediaSegment) {
|
||||||
val newSegmentPath = "/hls-playlist-${playlistId}-segment-${index}"
|
val newSegmentPath = "/hls-playlist-${playlistId}-segment-${index}"
|
||||||
val newSegmentUrl = url + newSegmentPath;
|
val newSegmentUrl = url + newSegmentPath;
|
||||||
@@ -858,6 +979,7 @@ abstract class StateCasting {
|
|||||||
if (_castServer.getHandler("GET", newSegmentPath) == null) {
|
if (_castServer.getHandler("GET", newSegmentPath) == null) {
|
||||||
_castServer.addHandlerWithAllowAllOptions(
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
HttpProxyHandler("GET", newSegmentPath, segment.uri, true)
|
HttpProxyHandler("GET", newSegmentPath, segment.uri, true)
|
||||||
|
.withIRequestModifier(requestModifier)
|
||||||
.withInjectedHost()
|
.withInjectedHost()
|
||||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
).withTag("castProxiedHlsVariant")
|
).withTag("castProxiedHlsVariant")
|
||||||
@@ -1227,10 +1349,14 @@ abstract class StateCasting {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (audioSource != null && audioSource.hasRequestExecutor) {
|
if (audioSource != null && audioSource.hasRequestExecutor) {
|
||||||
|
val oldExecutor = _audioExecutor;
|
||||||
|
oldExecutor?.closeAsync();
|
||||||
_audioExecutor = audioSource.getRequestExecutor()
|
_audioExecutor = audioSource.getRequestExecutor()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (videoSource != null && videoSource.hasRequestExecutor) {
|
if (videoSource != null && videoSource.hasRequestExecutor) {
|
||||||
|
val oldExecutor = _videoExecutor;
|
||||||
|
oldExecutor?.closeAsync();
|
||||||
_videoExecutor = videoSource.getRequestExecutor()
|
_videoExecutor = videoSource.getRequestExecutor()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.gsonStandard
|
||||||
import com.futo.platformplayer.engine.dev.V8RemoteObject.Companion.serialize
|
import com.futo.platformplayer.engine.dev.V8RemoteObject.Companion.serialize
|
||||||
import com.futo.platformplayer.engine.packages.PackageHttp
|
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.logging.Logger
|
||||||
import com.futo.platformplayer.states.StateApp
|
import com.futo.platformplayer.states.StateApp
|
||||||
import com.futo.platformplayer.states.StateAssets
|
import com.futo.platformplayer.states.StateAssets
|
||||||
@@ -28,6 +29,8 @@ import com.google.gson.FieldAttributes
|
|||||||
import com.google.gson.GsonBuilder
|
import com.google.gson.GsonBuilder
|
||||||
import com.google.gson.JsonArray
|
import com.google.gson.JsonArray
|
||||||
import com.google.gson.JsonParser
|
import com.google.gson.JsonParser
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import java.lang.reflect.Field
|
import java.lang.reflect.Field
|
||||||
@@ -268,11 +271,17 @@ class DeveloperEndpoints(private val context: Context) {
|
|||||||
context.respondCode(403, "This plugin doesn't support auth");
|
context.respondCode(403, "This plugin doesn't support auth");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||||
|
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) {
|
LoginActivity.showLogin(StateApp.instance.context, config) {
|
||||||
_testPluginVariables.clear();
|
_testPluginVariables.clear();
|
||||||
_testPlugin = V8Plugin(StateApp.instance.context, config, null, JSHttpClient(null, null, null, config), JSHttpClient(null, it, null, config));
|
_testPlugin = V8Plugin(StateApp.instance.context, config, null, JSHttpClient(null, null, null, config), JSHttpClient(null, it, null, config));
|
||||||
|
}; */
|
||||||
};
|
|
||||||
context.respondCode(200, "Login started");
|
context.respondCode(200, "Login started");
|
||||||
}
|
}
|
||||||
catch(ex: Throwable) {
|
catch(ex: Throwable) {
|
||||||
|
|||||||
@@ -16,9 +16,11 @@ import android.widget.Button
|
|||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.Settings
|
import com.futo.platformplayer.Settings
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
|
import com.futo.platformplayer.UpdateDownloadService
|
||||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||||
import com.futo.platformplayer.copyToOutputStream
|
import com.futo.platformplayer.copyToOutputStream
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
@@ -34,6 +36,8 @@ import java.io.InputStream
|
|||||||
class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
|
class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
|
||||||
companion object {
|
companion object {
|
||||||
private val TAG = "AutoUpdateDialog";
|
private val TAG = "AutoUpdateDialog";
|
||||||
|
|
||||||
|
var currentDialog: AutoUpdateDialog? = null
|
||||||
}
|
}
|
||||||
|
|
||||||
private lateinit var _buttonNever: Button;
|
private lateinit var _buttonNever: Button;
|
||||||
@@ -46,7 +50,6 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
|
|||||||
private var _maxVersion: Int = 0;
|
private var _maxVersion: Int = 0;
|
||||||
|
|
||||||
private var _updating: Boolean = false;
|
private var _updating: Boolean = false;
|
||||||
private var _apkFile: File? = null;
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
@@ -80,19 +83,26 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
|
|||||||
return@setOnClickListener;
|
return@setOnClickListener;
|
||||||
}
|
}
|
||||||
|
|
||||||
_updating = true;
|
if (Settings.instance.autoUpdate.backgroundDownload == 1) {
|
||||||
update();
|
val ctx = context.applicationContext;
|
||||||
|
val intent = Intent(ctx, UpdateDownloadService::class.java);
|
||||||
|
intent.putExtra(UpdateDownloadService.EXTRA_VERSION, _maxVersion);
|
||||||
|
ContextCompat.startForegroundService(ctx, intent);
|
||||||
|
UIDialogs.toast(context, "Downloading update in background");
|
||||||
|
dismiss();
|
||||||
|
} else {
|
||||||
|
_updating = true;
|
||||||
|
update();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
fun showPredownloaded(apkFile: File) {
|
currentDialog = this
|
||||||
_apkFile = apkFile;
|
|
||||||
super.show()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun dismiss() {
|
override fun dismiss() {
|
||||||
super.dismiss()
|
super.dismiss()
|
||||||
InstallReceiver.onReceiveResult.clear();
|
InstallReceiver.onReceiveResult.clear();
|
||||||
|
currentDialog = null
|
||||||
Logger.i(TAG, "Cleared InstallReceiver.onReceiveResult handler.")
|
Logger.i(TAG, "Cleared InstallReceiver.onReceiveResult handler.")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,21 +128,14 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
|
|||||||
GlobalScope.launch(Dispatchers.IO) {
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
var inputStream: InputStream? = null;
|
var inputStream: InputStream? = null;
|
||||||
try {
|
try {
|
||||||
val apkFile = _apkFile;
|
val client = ManagedHttpClient();
|
||||||
if (apkFile != null) {
|
val response = client.get(StateUpdate.APK_URL);
|
||||||
inputStream = apkFile.inputStream();
|
if (response.isOk && response.body != null) {
|
||||||
val dataLength = apkFile.length();
|
inputStream = response.body.byteStream();
|
||||||
|
val dataLength = response.body.contentLength();
|
||||||
install(inputStream, dataLength);
|
install(inputStream, dataLength);
|
||||||
} else {
|
} else {
|
||||||
val client = ManagedHttpClient();
|
throw Exception("Failed to download latest version of app.");
|
||||||
val response = client.get(StateUpdate.APK_URL);
|
|
||||||
if (response.isOk && response.body != null) {
|
|
||||||
inputStream = response.body.byteStream();
|
|
||||||
val dataLength = response.body.contentLength();
|
|
||||||
install(inputStream, dataLength);
|
|
||||||
} else {
|
|
||||||
throw Exception("Failed to download latest version of app.");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Logger.w(TAG, "Exception thrown while downloading and installing latest version of app.", e);
|
Logger.w(TAG, "Exception thrown while downloading and installing latest version of app.", e);
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ class PluginUpdateDialog : AlertDialog {
|
|||||||
|
|
||||||
private lateinit var _buttonCancel1: Button;
|
private lateinit var _buttonCancel1: Button;
|
||||||
private lateinit var _buttonCancel2: Button;
|
private lateinit var _buttonCancel2: Button;
|
||||||
|
private lateinit var _buttonAlways: LinearLayout;
|
||||||
private lateinit var _buttonUpdate: LinearLayout;
|
private lateinit var _buttonUpdate: LinearLayout;
|
||||||
|
|
||||||
private lateinit var _buttonOk: LinearLayout;
|
private lateinit var _buttonOk: LinearLayout;
|
||||||
@@ -58,6 +59,7 @@ class PluginUpdateDialog : AlertDialog {
|
|||||||
private lateinit var _textProgres: TextView;
|
private lateinit var _textProgres: TextView;
|
||||||
private lateinit var _textError: TextView;
|
private lateinit var _textError: TextView;
|
||||||
private lateinit var _textResult: TextView;
|
private lateinit var _textResult: TextView;
|
||||||
|
private lateinit var _textChangelogResult: TextView;
|
||||||
|
|
||||||
private lateinit var _uiChoiceTop: FrameLayout;
|
private lateinit var _uiChoiceTop: FrameLayout;
|
||||||
private lateinit var _uiProgressTop: FrameLayout;
|
private lateinit var _uiProgressTop: FrameLayout;
|
||||||
@@ -89,6 +91,7 @@ class PluginUpdateDialog : AlertDialog {
|
|||||||
|
|
||||||
_buttonCancel1 = findViewById(R.id.button_cancel_1);
|
_buttonCancel1 = findViewById(R.id.button_cancel_1);
|
||||||
_buttonCancel2 = findViewById(R.id.button_cancel_2);
|
_buttonCancel2 = findViewById(R.id.button_cancel_2);
|
||||||
|
_buttonAlways = findViewById(R.id.button_always);
|
||||||
_buttonUpdate = findViewById(R.id.button_update);
|
_buttonUpdate = findViewById(R.id.button_update);
|
||||||
|
|
||||||
_buttonOk = findViewById(R.id.button_ok);
|
_buttonOk = findViewById(R.id.button_ok);
|
||||||
@@ -99,6 +102,7 @@ class PluginUpdateDialog : AlertDialog {
|
|||||||
_textProgres = findViewById(R.id.text_progress);
|
_textProgres = findViewById(R.id.text_progress);
|
||||||
_textError = findViewById(R.id.text_error);
|
_textError = findViewById(R.id.text_error);
|
||||||
_textResult = findViewById(R.id.text_result);
|
_textResult = findViewById(R.id.text_result);
|
||||||
|
_textChangelogResult = findViewById(R.id.text_changelog_result);
|
||||||
|
|
||||||
_uiChoiceTop = findViewById(R.id.dialog_ui_choice_top);
|
_uiChoiceTop = findViewById(R.id.dialog_ui_choice_top);
|
||||||
_uiProgressTop = findViewById(R.id.dialog_ui_progress_top);
|
_uiProgressTop = findViewById(R.id.dialog_ui_progress_top);
|
||||||
@@ -119,17 +123,24 @@ class PluginUpdateDialog : AlertDialog {
|
|||||||
val changelog = _newConfig.changelog!![changelogVersion]!!;
|
val changelog = _newConfig.changelog!![changelogVersion]!!;
|
||||||
if(changelog.size > 1) {
|
if(changelog.size > 1) {
|
||||||
_textChangelog.text = "Changelog (${_newConfig.version})\n" + changelog.map { " - " + it.trim() }.joinToString("\n");
|
_textChangelog.text = "Changelog (${_newConfig.version})\n" + changelog.map { " - " + it.trim() }.joinToString("\n");
|
||||||
|
_textChangelogResult.text = _textChangelog.text;
|
||||||
}
|
}
|
||||||
else if(changelog.size == 1) {
|
else if(changelog.size == 1) {
|
||||||
_textChangelog.text = "Changelog (${_newConfig.version})\n" + changelog[0].trim();
|
_textChangelog.text = "Changelog (${_newConfig.version})\n" + changelog[0].trim();
|
||||||
|
_textChangelogResult.text = _textChangelog.text;
|
||||||
}
|
}
|
||||||
else
|
else {
|
||||||
_textChangelog.visibility = View.GONE;
|
_textChangelog.visibility = View.GONE;
|
||||||
} else
|
_textChangelogResult.visibility = View.GONE;
|
||||||
_textChangelog.visibility = View.GONE;
|
}
|
||||||
|
} else {
|
||||||
|
_textChangelog.visibility = View.GONE;
|
||||||
|
_textChangelogResult.visibility = View.GONE;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch(ex: Throwable) {
|
catch(ex: Throwable) {
|
||||||
_textChangelog.visibility = View.GONE;
|
_textChangelog.visibility = View.GONE;
|
||||||
|
_textChangelogResult.visibility = View.GONE;
|
||||||
Logger.e(TAG, "Invalid changelog? ", ex);
|
Logger.e(TAG, "Invalid changelog? ", ex);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,6 +156,18 @@ class PluginUpdateDialog : AlertDialog {
|
|||||||
_isUpdating = true;
|
_isUpdating = true;
|
||||||
update();
|
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)
|
Glide.with(_iconPlugin)
|
||||||
.load(_oldConfig.absoluteIconUrl)
|
.load(_oldConfig.absoluteIconUrl)
|
||||||
@@ -158,7 +181,8 @@ class PluginUpdateDialog : AlertDialog {
|
|||||||
if (_isUpdating)
|
if (_isUpdating)
|
||||||
return;
|
return;
|
||||||
_isUpdating = true;
|
_isUpdating = true;
|
||||||
update();
|
|
||||||
|
update(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -167,7 +191,7 @@ class PluginUpdateDialog : AlertDialog {
|
|||||||
super.dismiss();
|
super.dismiss();
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun update() {
|
private fun update(automatic: Boolean = false) {
|
||||||
_uiChoiceTop.visibility = View.GONE;
|
_uiChoiceTop.visibility = View.GONE;
|
||||||
_uiRiskTop.visibility = View.GONE;
|
_uiRiskTop.visibility = View.GONE;
|
||||||
_uiChoiceBot.visibility = View.GONE;
|
_uiChoiceBot.visibility = View.GONE;
|
||||||
@@ -187,9 +211,16 @@ class PluginUpdateDialog : AlertDialog {
|
|||||||
val scope = StateApp.instance.scopeOrNull;
|
val scope = StateApp.instance.scopeOrNull;
|
||||||
scope?.launch(Dispatchers.IO) {
|
scope?.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
_textProgres.setText("Loading current script file...");
|
||||||
|
}
|
||||||
val client = ManagedHttpClient();
|
val client = ManagedHttpClient();
|
||||||
|
client.setTimeout(10000);
|
||||||
val script = StatePlugins.instance.getScript(_oldConfig.id) ?: "";
|
val script = StatePlugins.instance.getScript(_oldConfig.id) ?: "";
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
_textProgres.setText("Requesting new script file...");
|
||||||
|
}
|
||||||
val newScript = client.get(_newConfig.absoluteScriptUrl)?.body?.string();
|
val newScript = client.get(_newConfig.absoluteScriptUrl)?.body?.string();
|
||||||
if(newScript.isNullOrEmpty())
|
if(newScript.isNullOrEmpty())
|
||||||
throw IllegalStateException("No script found");
|
throw IllegalStateException("No script found");
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package com.futo.platformplayer.downloads
|
package com.futo.platformplayer.downloads
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.media.MediaCodec
|
||||||
|
import android.media.MediaExtractor
|
||||||
|
import android.media.MediaMuxer
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.arthenica.ffmpegkit.FFmpegKit
|
import com.arthenica.ffmpegkit.FFmpegKit
|
||||||
import com.arthenica.ffmpegkit.ReturnCode
|
import com.arthenica.ffmpegkit.ReturnCode
|
||||||
@@ -8,6 +11,7 @@ import com.arthenica.ffmpegkit.StatisticsCallback
|
|||||||
import com.futo.platformplayer.Settings
|
import com.futo.platformplayer.Settings
|
||||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||||
import com.futo.platformplayer.api.media.PlatformID
|
import com.futo.platformplayer.api.media.PlatformID
|
||||||
|
import com.futo.platformplayer.api.media.models.modifier.IRequestModifier
|
||||||
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
|
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
|
||||||
import com.futo.platformplayer.api.media.models.streams.sources.AudioUrlSource
|
import com.futo.platformplayer.api.media.models.streams.sources.AudioUrlSource
|
||||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
||||||
@@ -136,6 +140,8 @@ class VideoDownload {
|
|||||||
|
|
||||||
var hasVideoRequestExecutor: Boolean = false;
|
var hasVideoRequestExecutor: Boolean = false;
|
||||||
var hasAudioRequestExecutor: Boolean = false;
|
var hasAudioRequestExecutor: Boolean = false;
|
||||||
|
var hasVideoRequestModifier: Boolean = false;
|
||||||
|
var hasAudioRequestModifier: Boolean = false;
|
||||||
|
|
||||||
var progress: Double = 0.0;
|
var progress: Double = 0.0;
|
||||||
var isCancelled = false;
|
var isCancelled = false;
|
||||||
@@ -203,8 +209,10 @@ class VideoDownload {
|
|||||||
this.prepareTime = OffsetDateTime.now();
|
this.prepareTime = OffsetDateTime.now();
|
||||||
this.hasVideoRequestExecutor = videoSource is JSSource && videoSource.hasRequestExecutor;
|
this.hasVideoRequestExecutor = videoSource is JSSource && videoSource.hasRequestExecutor;
|
||||||
this.hasAudioRequestExecutor = audioSource is JSSource && audioSource.hasRequestExecutor;
|
this.hasAudioRequestExecutor = audioSource is JSSource && audioSource.hasRequestExecutor;
|
||||||
this.requiresLiveVideoSource = this.hasVideoRequestExecutor || (videoSource is JSDashManifestRawSource && videoSource.hasGenerate);
|
this.hasVideoRequestModifier = videoSource is JSSource && videoSource.hasRequestModifier;
|
||||||
this.requiresLiveAudioSource = this.hasAudioRequestExecutor || (audioSource is JSDashManifestRawAudioSource && audioSource.hasGenerate);
|
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.targetVideoName = videoSource?.name;
|
||||||
this.targetAudioName = audioSource?.name;
|
this.targetAudioName = audioSource?.name;
|
||||||
this.targetPixelCount = if(videoSource != null) (videoSource.width * videoSource.height).toLong() else null;
|
this.targetPixelCount = if(videoSource != null) (videoSource.width * videoSource.height).toLong() else null;
|
||||||
@@ -478,8 +486,8 @@ class VideoDownload {
|
|||||||
|
|
||||||
if(actualVideoSource is IVideoUrlSource)
|
if(actualVideoSource is IVideoUrlSource)
|
||||||
videoFileSize = when (videoSource!!.container) {
|
videoFileSize = when (videoSource!!.container) {
|
||||||
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "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, 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) {
|
else if(actualVideoSource is JSDashManifestRawSource) {
|
||||||
videoFileSize = downloadDashFileSource("Video", client, actualVideoSource, File(downloadDir, videoFileName!!), progressCallback);
|
videoFileSize = downloadDashFileSource("Video", client, actualVideoSource, File(downloadDir, videoFileName!!), progressCallback);
|
||||||
@@ -518,8 +526,8 @@ class VideoDownload {
|
|||||||
|
|
||||||
if(actualAudioSource is IAudioUrlSource)
|
if(actualAudioSource is IAudioUrlSource)
|
||||||
audioFileSize = when (audioSource!!.container) {
|
audioFileSize = when (audioSource!!.container) {
|
||||||
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "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, 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) {
|
else if(actualAudioSource is JSDashManifestRawAudioSource) {
|
||||||
audioFileSize = downloadDashFileSource("Audio", client, actualAudioSource, File(downloadDir, audioFileName!!), progressCallback);
|
audioFileSize = downloadDashFileSource("Audio", client, actualAudioSource, File(downloadDir, audioFileName!!), progressCallback);
|
||||||
@@ -580,83 +588,12 @@ class VideoDownload {
|
|||||||
return cipher.doFinal(encryptedSegment)
|
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) {
|
private suspend fun combineSegments(context: Context, segmentFiles: List<File>, targetFile: File) = withContext(Dispatchers.IO) {
|
||||||
suspendCancellableCoroutine { continuation ->
|
suspendCancellableCoroutine { continuation ->
|
||||||
val cmd =
|
val fileList = File(context.cacheDir, "fileList-${UUID.randomUUID()}.txt")
|
||||||
"-i \"concat:${segmentFiles.joinToString("|")}\" -c copy \"${targetFile.absolutePath}\""
|
fileList.writeText(segmentFiles.joinToString("\n") { "file '${it.absolutePath}'" })
|
||||||
|
|
||||||
|
val cmd = "-f concat -safe 0 -i \"${fileList.absolutePath}\" -c copy \"${targetFile.absolutePath}\""
|
||||||
val statisticsCallback = StatisticsCallback { _ ->
|
val statisticsCallback = StatisticsCallback { _ ->
|
||||||
//TODO: Show progress?
|
//TODO: Show progress?
|
||||||
}
|
}
|
||||||
@@ -665,6 +602,7 @@ class VideoDownload {
|
|||||||
val session = FFmpegKit.executeAsync(cmd,
|
val session = FFmpegKit.executeAsync(cmd,
|
||||||
{ session ->
|
{ session ->
|
||||||
if (ReturnCode.isSuccess(session.returnCode)) {
|
if (ReturnCode.isSuccess(session.returnCode)) {
|
||||||
|
fileList.delete()
|
||||||
continuation.resumeWith(Result.success(Unit))
|
continuation.resumeWith(Result.success(Unit))
|
||||||
} else {
|
} else {
|
||||||
val errorMessage = if (ReturnCode.isCancel(session.returnCode)) {
|
val errorMessage = if (ReturnCode.isCancel(session.returnCode)) {
|
||||||
@@ -672,6 +610,7 @@ class VideoDownload {
|
|||||||
} else {
|
} else {
|
||||||
"Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}"
|
"Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}"
|
||||||
}
|
}
|
||||||
|
fileList.delete()
|
||||||
continuation.resumeWithException(RuntimeException(errorMessage))
|
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 {
|
private fun downloadDashFileSource(name: String, client: ManagedHttpClient, source: IJSDashManifestRawSource, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||||
if(targetFile.exists())
|
if(targetFile.exists())
|
||||||
targetFile.delete();
|
targetFile.delete();
|
||||||
@@ -695,6 +865,7 @@ class VideoDownload {
|
|||||||
val sourceLength: Long?;
|
val sourceLength: Long?;
|
||||||
val fileStream = FileOutputStream(targetFile);
|
val fileStream = FileOutputStream(targetFile);
|
||||||
|
|
||||||
|
var executor: JSRequestExecutor? = null;
|
||||||
try{
|
try{
|
||||||
var manifest = source.manifest;
|
var manifest = source.manifest;
|
||||||
if(source.hasGenerate)
|
if(source.hasGenerate)
|
||||||
@@ -711,10 +882,15 @@ class VideoDownload {
|
|||||||
if(foundCues.count() <= 0)
|
if(foundCues.count() <= 0)
|
||||||
throw IllegalStateException("No Cues found in manifest (unsupported dash?)");
|
throw IllegalStateException("No Cues found in manifest (unsupported dash?)");
|
||||||
|
|
||||||
val executor = if(source is JSSource && source.hasRequestExecutor)
|
executor = if(source is JSSource && source.hasRequestExecutor)
|
||||||
source.getRequestExecutor();
|
source.getRequestExecutor();
|
||||||
else
|
else
|
||||||
null;
|
null;
|
||||||
|
|
||||||
|
val modifier = if (source is JSSource && source.hasRequestModifier)
|
||||||
|
source.getRequestModifier();
|
||||||
|
else
|
||||||
|
null;
|
||||||
val speedTracker = SpeedTracker(1000);
|
val speedTracker = SpeedTracker(1000);
|
||||||
|
|
||||||
Logger.i(TAG, "Download $name Dash, CueCount: " + foundCues.count().toString());
|
Logger.i(TAG, "Download $name Dash, CueCount: " + foundCues.count().toString());
|
||||||
@@ -726,12 +902,14 @@ class VideoDownload {
|
|||||||
val t = cue.groupValues[1];
|
val t = cue.groupValues[1];
|
||||||
val d = cue.groupValues[2];
|
val d = cue.groupValues[2];
|
||||||
|
|
||||||
|
|
||||||
val url = foundTemplateUrl.replace("\$Number\$", (indexCounter).toString());
|
val url = foundTemplateUrl.replace("\$Number\$", (indexCounter).toString());
|
||||||
|
val modified = modifier?.modifyRequest(url, mapOf());
|
||||||
|
|
||||||
val data = if(executor != null)
|
val data = if(executor != null)
|
||||||
executor.executeRequest("GET", url, null, mapOf());
|
executor.executeRequest("GET", modified?.url ?: url, null, modified?.headers ?: mapOf());
|
||||||
else {
|
else {
|
||||||
val resp = client.get(url, mutableMapOf());
|
val resp = client.get(modified?.url ?: url, modified?.headers?.toMutableMap() ?: mutableMapOf());
|
||||||
if(!resp.isOk)
|
if(!resp.isOk)
|
||||||
throw IllegalStateException("Dash request failed for index " + indexCounter.toString() + ", with code: " + resp.code.toString());
|
throw IllegalStateException("Dash request failed for index " + indexCounter.toString() + ", with code: " + resp.code.toString());
|
||||||
resp.body!!.bytes()
|
resp.body!!.bytes()
|
||||||
@@ -763,10 +941,11 @@ class VideoDownload {
|
|||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
fileStream.close();
|
fileStream.close();
|
||||||
|
executor?.closeAsync()
|
||||||
}
|
}
|
||||||
return sourceLength!!;
|
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())
|
if(targetFile.exists())
|
||||||
targetFile.delete();
|
targetFile.delete();
|
||||||
|
|
||||||
@@ -775,7 +954,12 @@ class VideoDownload {
|
|||||||
val sourceLength: Long?;
|
val sourceLength: Long?;
|
||||||
val fileStream = FileOutputStream(targetFile);
|
val fileStream = FileOutputStream(targetFile);
|
||||||
|
|
||||||
try{
|
val modifier = if (source is JSSource && source.hasRequestModifier)
|
||||||
|
source.getRequestModifier();
|
||||||
|
else
|
||||||
|
null;
|
||||||
|
|
||||||
|
try {
|
||||||
val head = client.tryHead(videoUrl);
|
val head = client.tryHead(videoUrl);
|
||||||
val relatedPlugin = (video?.url ?: videoDetails?.url)?.let { StatePlatform.instance.getContentClient(it) }?.let { if(it is JSClient) it else null };
|
val relatedPlugin = (video?.url ?: videoDetails?.url)?.let { StatePlatform.instance.getContentClient(it) }?.let { if(it is JSClient) it else null };
|
||||||
if(Settings.instance.downloads.byteRangeDownload && head?.containsKey("accept-ranges") == true && head.containsKey("content-length"))
|
if(Settings.instance.downloads.byteRangeDownload && head?.containsKey("accept-ranges") == true && head.containsKey("content-length"))
|
||||||
@@ -786,12 +970,12 @@ class VideoDownload {
|
|||||||
Logger.i(TAG, "Download $name ByteRange Parallel (${concurrency}): " + videoUrl);
|
Logger.i(TAG, "Download $name ByteRange Parallel (${concurrency}): " + videoUrl);
|
||||||
sourceLength = head["content-length"]!!.toLong();
|
sourceLength = head["content-length"]!!.toLong();
|
||||||
onProgress(sourceLength, 0, 0);
|
onProgress(sourceLength, 0, 0);
|
||||||
downloadSource_Ranges(name, client, fileStream, videoUrl, sourceLength, 1024*512, concurrency, onProgress);
|
downloadSource_Ranges(name, client, modifier, fileStream, videoUrl, sourceLength, 1024*512, concurrency, onProgress);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
Logger.i(TAG, "Download $name Sequential");
|
Logger.i(TAG, "Download $name Sequential");
|
||||||
try {
|
try {
|
||||||
sourceLength = downloadSource_Sequential(client, fileStream, videoUrl, null, 0, onProgress);
|
sourceLength = downloadSource_Sequential(client, modifier, fileStream, videoUrl, null, 0, onProgress);
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Logger.w(TAG, "Failed to download sequentially (url = $videoUrl)")
|
Logger.w(TAG, "Failed to download sequentially (url = $videoUrl)")
|
||||||
throw e
|
throw e
|
||||||
@@ -842,7 +1026,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;
|
val progressRate: Int = 4096 * 5;
|
||||||
var lastProgressCount: Int = 0;
|
var lastProgressCount: Int = 0;
|
||||||
val speedRate: Int = 4096 * 5;
|
val speedRate: Int = 4096 * 5;
|
||||||
@@ -851,7 +1035,12 @@ class VideoDownload {
|
|||||||
|
|
||||||
var lastSpeed: Long = 0;
|
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) {
|
if (!result.isOk) {
|
||||||
result.body?.close()
|
result.body?.close()
|
||||||
throw IllegalStateException("Failed to download source. Web[${result.code}] Error");
|
throw IllegalStateException("Failed to download source. Web[${result.code}] Error");
|
||||||
@@ -988,7 +1177,7 @@ class VideoDownload {
|
|||||||
onProgress(sourceLength, totalRead, 0)
|
onProgress(sourceLength, totalRead, 0)
|
||||||
return sourceLength
|
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;
|
val progressRate: Int = 4096 * 5;
|
||||||
var lastProgressCount: Int = 0;
|
var lastProgressCount: Int = 0;
|
||||||
val speedRate: Int = 4096 * 5;
|
val speedRate: Int = 4096 * 5;
|
||||||
@@ -1007,7 +1196,7 @@ class VideoDownload {
|
|||||||
|
|
||||||
Logger.i(TAG, "Download ${name} Batch #${reqCount} [${concurrency}] (${lastSpeed.toHumanBytesSpeed()})");
|
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);
|
rangeSize, 1024 * 64);
|
||||||
|
|
||||||
for(byteRange in byteRangeResults) {
|
for(byteRange in byteRangeResults) {
|
||||||
@@ -1038,7 +1227,7 @@ class VideoDownload {
|
|||||||
onProgress(sourceLength, totalRead, 0);
|
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>>>();
|
val tasks = mutableListOf<ForkJoinTask<Triple<ByteArray, Long, Long>>>();
|
||||||
var readPosition = rangePosition;
|
var readPosition = rangePosition;
|
||||||
for(i in 0 until concurrency) {
|
for(i in 0 until concurrency) {
|
||||||
@@ -1052,21 +1241,25 @@ class VideoDownload {
|
|||||||
else readPosition + toRead;
|
else readPosition + toRead;
|
||||||
|
|
||||||
tasks.add(pool.submit<Triple<ByteArray, Long, Long>> {
|
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;
|
readPosition = rangeEnd + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
return tasks.map { it.get() };
|
return tasks.map { it.get() };
|
||||||
}
|
}
|
||||||
private fun requestByteRange(client: ManagedHttpClient, url: String, rangeStart: Long, rangeEnd: Long): Triple<ByteArray, Long, Long> {
|
private fun requestByteRange(client: ManagedHttpClient, modifier: IRequestModifier?, url: String, rangeStart: Long, rangeEnd: Long): Triple<ByteArray, Long, Long> {
|
||||||
var retryCount = 0
|
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) {
|
while (retryCount <= 3) {
|
||||||
try {
|
try {
|
||||||
val toRead = rangeEnd - rangeStart;
|
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) {
|
if (!req.isOk) {
|
||||||
val bodyString = req.body?.string()
|
val bodyString = req.body?.string()
|
||||||
req.body?.close()
|
req.body?.close()
|
||||||
|
|||||||
@@ -23,10 +23,7 @@ object Libcurl {
|
|||||||
var body: ByteArray? = null,
|
var body: ByteArray? = null,
|
||||||
var impersonateTarget: String = "chrome136",
|
var impersonateTarget: String = "chrome136",
|
||||||
var useBuiltInHeaders: Boolean = true,
|
var useBuiltInHeaders: Boolean = true,
|
||||||
var timeoutMs: Int = 30_000,
|
var timeoutMs: Int = 30_000
|
||||||
var cookieJarPath: String? = null,
|
|
||||||
var sendCookies: Boolean = true,
|
|
||||||
var persistCookies: Boolean = true,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@Keep
|
@Keep
|
||||||
@@ -121,12 +118,6 @@ object Libcurl {
|
|||||||
if (slist != 0L) checkOK(ce_setopt_ptr(easy, CURLOPT.HTTPHEADER, slist))
|
if (slist != 0L) checkOK(ce_setopt_ptr(easy, CURLOPT.HTTPHEADER, slist))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.sendCookies || req.persistCookies) {
|
|
||||||
val jar = (req.cookieJarPath ?: defaultCookieJarPath())
|
|
||||||
if (req.sendCookies) checkOK(ce_setopt_str(easy, CURLOPT.COOKIEFILE, jar))
|
|
||||||
if (req.persistCookies) checkOK(ce_setopt_str(easy, CURLOPT.COOKIEJAR, jar))
|
|
||||||
}
|
|
||||||
|
|
||||||
val method = req.method
|
val method = req.method
|
||||||
if (!method.equals("GET", ignoreCase = true)) {
|
if (!method.equals("GET", ignoreCase = true)) {
|
||||||
checkOK(ce_setopt_str(easy, CURLOPT.CUSTOMREQUEST, method))
|
checkOK(ce_setopt_str(easy, CURLOPT.CUSTOMREQUEST, method))
|
||||||
|
|||||||
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
|
import com.futo.platformplayer.fragment.mainactivity.main.MainFragment
|
||||||
|
|
||||||
open class MainActivityFragment : Fragment() {
|
open class MainActivityFragment : Fragment() {
|
||||||
protected val currentMain : MainFragment
|
protected val currentMain : MainFragment?
|
||||||
get() {
|
get() {
|
||||||
isValidMainActivity();
|
isValidMainActivity();
|
||||||
return (activity as MainActivity).fragCurrent;
|
return (activity as MainActivity).fragCurrent;
|
||||||
|
|||||||
+210
-20
@@ -8,18 +8,25 @@ import android.app.Activity
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.*
|
import android.widget.*
|
||||||
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||||||
import androidx.core.animation.doOnEnd
|
import androidx.core.animation.doOnEnd
|
||||||
|
import androidx.core.view.isVisible
|
||||||
|
import androidx.core.view.updateLayoutParams
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.media3.common.util.UnstableApi
|
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.R
|
||||||
import com.futo.platformplayer.Settings
|
import com.futo.platformplayer.Settings
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.activities.MainActivity
|
import com.futo.platformplayer.activities.MainActivity
|
||||||
|
import com.futo.platformplayer.constructs.Event1
|
||||||
import com.futo.platformplayer.dp
|
import com.futo.platformplayer.dp
|
||||||
import com.futo.platformplayer.fragment.mainactivity.MainActivityFragment
|
import com.futo.platformplayer.fragment.mainactivity.MainActivityFragment
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.*
|
import com.futo.platformplayer.fragment.mainactivity.main.*
|
||||||
@@ -27,6 +34,10 @@ import com.futo.platformplayer.logging.Logger
|
|||||||
import com.futo.platformplayer.states.StateApp
|
import com.futo.platformplayer.states.StateApp
|
||||||
import com.futo.platformplayer.states.StatePayment
|
import com.futo.platformplayer.states.StatePayment
|
||||||
import com.futo.platformplayer.states.StateSubscriptions
|
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.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlin.math.floor
|
import kotlin.math.floor
|
||||||
@@ -69,9 +80,15 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
|||||||
private val _inflater: LayoutInflater;
|
private val _inflater: LayoutInflater;
|
||||||
private val _subscribedActivity: MainActivity?;
|
private val _subscribedActivity: MainActivity?;
|
||||||
|
|
||||||
|
private val _containerMoreHeader: ConstraintLayout;
|
||||||
|
private val _toggleAirplaneMode: LinearLayout;
|
||||||
|
private val _togglePrivacy: LinearLayout;
|
||||||
|
|
||||||
private var _overlayMore: FrameLayout;
|
private var _overlayMore: FrameLayout;
|
||||||
private var _overlayMoreBackground: 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 _layoutBottomBarButtons: LinearLayout;
|
||||||
|
|
||||||
private var _moreVisible = false;
|
private var _moreVisible = false;
|
||||||
@@ -85,15 +102,90 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
|||||||
|
|
||||||
private var currentButtonDefinitions: List<ButtonDefinition>? = null;
|
private var currentButtonDefinitions: List<ButtonDefinition>? = null;
|
||||||
|
|
||||||
|
private var moreColumns = 3;
|
||||||
|
|
||||||
constructor(fragment: MenuBottomBarFragment, inflater: LayoutInflater) : super(inflater.context) {
|
constructor(fragment: MenuBottomBarFragment, inflater: LayoutInflater) : super(inflater.context) {
|
||||||
_fragment = fragment;
|
_fragment = fragment;
|
||||||
_inflater = inflater;
|
_inflater = inflater;
|
||||||
inflater.inflate(R.layout.fragment_overview_bottom_bar, this);
|
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");
|
||||||
|
|
||||||
|
if(Settings.instance.other.showPrivacyModeDialog)
|
||||||
|
UIDialogs.showDialog(it.context ?: return@setOnClickListener, R.drawable.incognito, "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,
|
||||||
|
UIDialogs.Action("Don't show again", {
|
||||||
|
Settings.instance.other.showPrivacyModeDialog = false;
|
||||||
|
Settings.instance.save();
|
||||||
|
}, UIDialogs.ActionStyle.NONE),
|
||||||
|
UIDialogs.Action("Understood", {
|
||||||
|
|
||||||
|
}, UIDialogs.ActionStyle.PRIMARY));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_overlayMore = findViewById(R.id.more_overlay);
|
_overlayMore = findViewById(R.id.more_overlay);
|
||||||
_overlayMoreBackground = findViewById(R.id.more_overlay_background);
|
_overlayMoreBackground = findViewById(R.id.more_overlay_background);
|
||||||
_layoutMoreButtons = findViewById(R.id.more_menu_buttons);
|
_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); };
|
_overlayMoreBackground.setOnClickListener { setMoreVisible(false); };
|
||||||
|
|
||||||
@@ -120,6 +212,8 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun setMoreVisible(visible: Boolean) {
|
private fun setMoreVisible(visible: Boolean) {
|
||||||
|
|
||||||
|
//TODO: issues with these bools
|
||||||
if (_moreVisibleAnimating) {
|
if (_moreVisibleAnimating) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -128,9 +222,12 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
val height = _moreButtons.firstOrNull()?.let {
|
val height = _moreButtons.firstOrNull()?.let {
|
||||||
it.height.toFloat() + (it.layoutParams as MarginLayoutParams).bottomMargin
|
it.height.toFloat() + (it.layoutParams as MarginLayoutParams).bottomMargin
|
||||||
} ?: return
|
} ?: return
|
||||||
|
*/
|
||||||
|
|
||||||
_moreVisibleAnimating = true
|
_moreVisibleAnimating = true
|
||||||
val moreOverlayBackground = _overlayMoreBackground
|
val moreOverlayBackground = _overlayMoreBackground
|
||||||
@@ -142,14 +239,17 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
|||||||
moreOverlay.visibility = VISIBLE
|
moreOverlay.visibility = VISIBLE
|
||||||
val animations = arrayListOf<Animator>()
|
val animations = arrayListOf<Animator>()
|
||||||
animations.add(ObjectAnimator.ofFloat(moreOverlayBackground, "alpha", 0.0f, 1.0f).setDuration(duration))
|
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 {
|
_bottomButtons.find { it.definition.id == 99 }?.let {
|
||||||
animations.add(ObjectAnimator.ofFloat(it, "alpha", 0.4f, 1.0f)
|
animations.add(ObjectAnimator.ofFloat(it, "alpha", 0.5f, 1.0f)
|
||||||
.setDuration(duration));
|
.setDuration(duration));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
animations.add(ObjectAnimator.ofFloat(_layoutMoreButtons, "translationY", resources.displayMetrics.heightPixels.toFloat(), 0.0f).setDuration(duration))
|
||||||
for ((index, button) in _moreButtons.withIndex()) {
|
for ((index, button) in _moreButtons.withIndex()) {
|
||||||
val i = _moreButtons.size - index
|
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()
|
val animatorSet = AnimatorSet()
|
||||||
@@ -164,14 +264,21 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
|||||||
animations
|
animations
|
||||||
.add(ObjectAnimator.ofFloat(moreOverlayBackground, "alpha", 1.0f, 0.0f)
|
.add(ObjectAnimator.ofFloat(moreOverlayBackground, "alpha", 1.0f, 0.0f)
|
||||||
.setDuration(duration))
|
.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 {
|
_bottomButtons.find { it.definition.id == 99 }?.let {
|
||||||
animations.add(ObjectAnimator.ofFloat(it, "alpha", 1.0f, 0.4f)
|
animations.add(ObjectAnimator.ofFloat(it, "alpha", 1.0f, 0.5f)
|
||||||
.setDuration(duration));
|
.setDuration(duration));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
animations.add(ObjectAnimator.ofFloat(_layoutMoreButtons, "translationY", 0.0f, resources.displayMetrics.heightPixels.toFloat()).setDuration(duration))
|
||||||
for ((index, button) in _moreButtons.withIndex()) {
|
for ((index, button) in _moreButtons.withIndex()) {
|
||||||
val i = _moreButtons.size - index
|
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()
|
val animatorSet = AnimatorSet()
|
||||||
@@ -183,11 +290,12 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
|||||||
animatorSet.playTogether(animations)
|
animatorSet.playTogether(animations)
|
||||||
animatorSet.start()
|
animatorSet.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateBottomMenuButtons(buttons: MutableList<ButtonDefinition>, hasMore: Boolean) {
|
private fun updateBottomMenuButtons(buttons: MutableList<ButtonDefinition>, hasMore: Boolean) {
|
||||||
if (hasMore) {
|
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();
|
_bottomButtons.clear();
|
||||||
@@ -227,32 +335,42 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
|||||||
_layoutMoreButtons.removeAllViews();
|
_layoutMoreButtons.removeAllViews();
|
||||||
|
|
||||||
var insertedButtons = 0;
|
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
|
//Force buy to be on top for more buttons
|
||||||
val buyIndex = buttons.indexOfFirst { b -> b.id == 98 };
|
val buyIndex = buttons.indexOfFirst { b -> b.id == 98 };
|
||||||
if (buyIndex != -1) {
|
if (buyIndex != -1) {
|
||||||
val button = buttons[buyIndex]
|
val button = buttons[buyIndex]
|
||||||
buttons.removeAt(buyIndex)
|
buttons.removeAt(buyIndex)
|
||||||
buttons.add(0, button)
|
buttons.add(button)
|
||||||
insertedButtons++;
|
//insertedButtons++;
|
||||||
}
|
}
|
||||||
//Force faq to be second
|
//Force faq to be second
|
||||||
val faqIndex = buttons.indexOfFirst { b -> b.id == 97 };
|
val faqIndex = buttons.indexOfFirst { b -> b.id == 97 };
|
||||||
if (faqIndex != -1) {
|
if (faqIndex != -1) {
|
||||||
val button = buttons[faqIndex]
|
val button = buttons[faqIndex]
|
||||||
buttons.removeAt(faqIndex)
|
buttons.removeAt(faqIndex)
|
||||||
buttons.add(if (insertedButtons == 1) 1 else 0, button)
|
buttons.add(button)
|
||||||
insertedButtons++;
|
//insertedButtons++;
|
||||||
}
|
}
|
||||||
//Force privacy to be third
|
//Force privacy to be third
|
||||||
val privacyIndex = buttons.indexOfFirst { b -> b.id == 96 };
|
val privacyIndex = buttons.indexOfFirst { b -> b.id == 96 };
|
||||||
if (privacyIndex != -1) {
|
if (privacyIndex != -1) {
|
||||||
val button = buttons[privacyIndex]
|
val button = buttons[privacyIndex]
|
||||||
buttons.removeAt(privacyIndex)
|
buttons.removeAt(privacyIndex)
|
||||||
buttons.add(if (insertedButtons == 2) 2 else (if(insertedButtons == 1) 1 else 0), button)
|
buttons.add(button)
|
||||||
insertedButtons++;
|
//insertedButtons++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val newButtons = mutableListOf<MenuButtonItem>();
|
||||||
for (data in buttons) {
|
for (data in buttons) {
|
||||||
|
/*
|
||||||
val button = MenuButton(context, data, _fragment, true);
|
val button = MenuButton(context, data, _fragment, true);
|
||||||
button.setOnClickListener {
|
button.setOnClickListener {
|
||||||
updateMenuIcons()
|
updateMenuIcons()
|
||||||
@@ -262,7 +380,12 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
|||||||
|
|
||||||
_moreButtons.add(button);
|
_moreButtons.add(button);
|
||||||
_layoutMoreButtons.addView(button);
|
_layoutMoreButtons.addView(button);
|
||||||
|
*/
|
||||||
|
val buttonItem = MenuButtonItem(data);
|
||||||
|
newButtons.add(buttonItem);
|
||||||
}
|
}
|
||||||
|
_layoutMoreButtonsAdapter.setData(newButtons);
|
||||||
|
_layoutMoreButtonsAdapter.notifyContentChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateMenuIcons() {
|
private fun updateMenuIcons() {
|
||||||
@@ -350,6 +473,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 {
|
class MenuButton: LinearLayout {
|
||||||
val definition: ButtonDefinition;
|
val definition: ButtonDefinition;
|
||||||
|
|
||||||
@@ -369,7 +557,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
|||||||
this.alpha = 1f;
|
this.alpha = 1f;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
this.alpha = 0.4f;
|
this.alpha = 0.5f;
|
||||||
}
|
}
|
||||||
|
|
||||||
_textButton = findViewById(R.id.text_button);
|
_textButton = findViewById(R.id.text_button);
|
||||||
@@ -389,7 +577,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
|||||||
this.alpha = 1f;
|
this.alpha = 1f;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
this.alpha = 0.4f;
|
this.alpha = 0.5f;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -413,7 +601,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) }),
|
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) }),
|
||||||
ButtonDefinition(12, R.drawable.ic_library, R.drawable.ic_library, R.string.library, canToggle = false, { it.currentMain is LibraryFragment }, { it.navigate<LibraryFragment>(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(2, R.drawable.ic_creators, R.drawable.ic_creators_filled, R.string.creators, canToggle = false, { it.currentMain is CreatorsFragment }, { it.navigate<CreatorsFragment>(withHistory = false) }),
|
||||||
ButtonDefinition(3, R.drawable.ic_sources, R.drawable.ic_sources_filled, R.string.sources, canToggle = false, { it.currentMain is SourcesFragment }, { it.navigate<SourcesFragment>(withHistory = false) }),
|
ButtonDefinition(3, R.drawable.ic_sources, R.drawable.ic_sources_filled, R.string.sources, canToggle = false, { it.currentMain is SourcesFragment }, { it.navigate<SourcesFragment>(withHistory = false) }),
|
||||||
ButtonDefinition(4, R.drawable.ic_playlist, R.drawable.ic_playlist_filled, R.string.playlists, canToggle = false, { it.currentMain is PlaylistsFragment }, { it.navigate<PlaylistsFragment>(withHistory = false) }),
|
ButtonDefinition(4, R.drawable.ic_playlist, R.drawable.ic_playlist_filled, R.string.playlists, canToggle = false, { it.currentMain is PlaylistsFragment }, { it.navigate<PlaylistsFragment>(withHistory = false) }),
|
||||||
@@ -423,7 +613,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
|||||||
ButtonDefinition(8, R.drawable.ic_chat, R.drawable.ic_chat_filled, R.string.comments, canToggle = true, { it.currentMain is CommentsFragment }, { it.navigate<CommentsFragment>(withHistory = false) }),
|
ButtonDefinition(8, R.drawable.ic_chat, R.drawable.ic_chat_filled, R.string.comments, canToggle = true, { it.currentMain is CommentsFragment }, { it.navigate<CommentsFragment>(withHistory = false) }),
|
||||||
ButtonDefinition(9, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscription_group_menu, canToggle = true, { it.currentMain is SubscriptionGroupListFragment }, { it.navigate<SubscriptionGroupListFragment>(withHistory = false) }),
|
ButtonDefinition(9, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscription_group_menu, canToggle = true, { it.currentMain is SubscriptionGroupListFragment }, { it.navigate<SubscriptionGroupListFragment>(withHistory = false) }),
|
||||||
ButtonDefinition(10, R.drawable.ic_help_square, R.drawable.ic_help_square_fill, R.string.tutorials, canToggle = true, { it.currentMain is TutorialFragment }, { it.navigate<TutorialFragment>(withHistory = false) }),
|
ButtonDefinition(10, R.drawable.ic_help_square, R.drawable.ic_help_square_fill, R.string.tutorials, canToggle = true, { it.currentMain is TutorialFragment }, { it.navigate<TutorialFragment>(withHistory = false) }),
|
||||||
ButtonDefinition(7, R.drawable.ic_settings, R.drawable.ic_settings_filled, R.string.settings, canToggle = false, { false }, {
|
ButtonDefinition(7, R.drawable.ic_settings, R.drawable.ic_settings_filled, R.string.settings, canToggle = false, { it.currentMain is SettingsFragment }, {
|
||||||
it.navigate<SettingsFragment>();
|
it.navigate<SettingsFragment>();
|
||||||
/*
|
/*
|
||||||
val c = it.context ?: return@ButtonDefinition;
|
val c = it.context ?: return@ButtonDefinition;
|
||||||
@@ -434,7 +624,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
|||||||
if (c is Activity) {
|
if (c is Activity) {
|
||||||
c.overridePendingTransition(R.anim.slide_in_up, R.anim.slide_darken);
|
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 }, {
|
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",
|
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,
|
"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,
|
||||||
@@ -444,14 +634,14 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
|||||||
UIDialogs.Action("Enable", {
|
UIDialogs.Action("Enable", {
|
||||||
StateApp.instance.setPrivacyMode(true);
|
StateApp.instance.setPrivacyMode(true);
|
||||||
}, UIDialogs.ActionStyle.PRIMARY));
|
}, UIDialogs.ActionStyle.PRIMARY));
|
||||||
}),
|
}),*/
|
||||||
ButtonDefinition(97, R.drawable.ic_quiz, R.drawable.ic_quiz_fill, R.string.faq, canToggle = true, { false }, {
|
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);
|
it.navigate<BrowserFragment>(Settings.URL_FAQ, withHistory = false);
|
||||||
})
|
})
|
||||||
//96 is reserved for privacy button
|
//96 is reserved for privacy button
|
||||||
//98 is reserved for buy button
|
//98 is reserved for buy button
|
||||||
//99 is reserved for more button
|
//99 is reserved for more button
|
||||||
);
|
).filterNotNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
data class ButtonDefinition(
|
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() {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-4
@@ -20,6 +20,7 @@ import androidx.lifecycle.lifecycleScope
|
|||||||
import androidx.viewpager2.widget.ViewPager2
|
import androidx.viewpager2.widget.ViewPager2
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
|
import com.futo.platformplayer.Settings
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.UISlideOverlays
|
import com.futo.platformplayer.UISlideOverlays
|
||||||
import com.futo.platformplayer.api.media.PlatformID
|
import com.futo.platformplayer.api.media.PlatformID
|
||||||
@@ -55,6 +56,7 @@ import com.futo.platformplayer.views.adapters.ChannelViewPagerAdapter
|
|||||||
import com.futo.platformplayer.views.others.CreatorThumbnail
|
import com.futo.platformplayer.views.others.CreatorThumbnail
|
||||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
|
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
|
||||||
import com.futo.platformplayer.views.subscriptions.SubscribeButton
|
import com.futo.platformplayer.views.subscriptions.SubscribeButton
|
||||||
|
import com.futo.platformplayer.withTimestamp
|
||||||
import com.futo.polycentric.core.ApiMethods
|
import com.futo.polycentric.core.ApiMethods
|
||||||
import com.futo.polycentric.core.PolycentricProfile
|
import com.futo.polycentric.core.PolycentricProfile
|
||||||
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
|
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
|
||||||
@@ -198,8 +200,12 @@ class ChannelFragment : MainFragment() {
|
|||||||
adapter.onContentClicked.subscribe { v, _ ->
|
adapter.onContentClicked.subscribe { v, _ ->
|
||||||
when (v) {
|
when (v) {
|
||||||
is IPlatformVideo -> {
|
is IPlatformVideo -> {
|
||||||
StatePlayer.instance.clearQueue()
|
//StatePlayer.instance.clearQueue()
|
||||||
fragment.navigate<VideoDetailFragment>(v).maximizeVideoDetail()
|
if (StatePlayer.instance.hasQueue) {
|
||||||
|
StatePlayer.instance.insertToQueue(v, true);
|
||||||
|
} else {
|
||||||
|
fragment.navigate<VideoDetailFragment>(v).maximizeVideoDetail();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is IPlatformPlaylist -> {
|
is IPlatformPlaylist -> {
|
||||||
@@ -244,7 +250,7 @@ class ChannelFragment : MainFragment() {
|
|||||||
adapter.onContentUrlClicked.subscribe { url, contentType ->
|
adapter.onContentUrlClicked.subscribe { url, contentType ->
|
||||||
when (contentType) {
|
when (contentType) {
|
||||||
ContentType.MEDIA -> {
|
ContentType.MEDIA -> {
|
||||||
StatePlayer.instance.clearQueue()
|
StatePlayer.instance.clearQueue();
|
||||||
fragment.navigate<VideoDetailFragment>(url).maximizeVideoDetail()
|
fragment.navigate<VideoDetailFragment>(url).maximizeVideoDetail()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,7 +409,7 @@ class ChannelFragment : MainFragment() {
|
|||||||
_fragment.topBar?.onShown(channel)
|
_fragment.topBar?.onShown(channel)
|
||||||
|
|
||||||
val buttons = arrayListOf(Pair(R.drawable.ic_playlist_add) {
|
val buttons = arrayListOf(Pair(R.drawable.ic_playlist_add) {
|
||||||
UIDialogs.showConfirmationDialog(context,
|
val dialog = UIDialogs.showConfirmationDialog(context,
|
||||||
context.getString(R.string.do_you_want_to_convert_channel_channelname_to_a_playlist)
|
context.getString(R.string.do_you_want_to_convert_channel_channelname_to_a_playlist)
|
||||||
.replace("{channelName}", channel.name),
|
.replace("{channelName}", channel.name),
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import android.view.LayoutInflater
|
|||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.*
|
import android.widget.*
|
||||||
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.recyclerview.widget.GridLayoutManager
|
import androidx.recyclerview.widget.GridLayoutManager
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
@@ -39,7 +40,7 @@ import java.time.OffsetDateTime
|
|||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
|
|
||||||
abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : LinearLayout where TPager : IPager<TResult>, TViewHolder : RecyclerView.ViewHolder, TFragment : MainFragment {
|
abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : LinearLayout where TPager : IPager<TResult>, TViewHolder : RecyclerView.ViewHolder, TFragment : MainFragment {
|
||||||
protected val _feedRoot: FrameLayout;
|
protected val _feedRoot: ConstraintLayout;
|
||||||
protected val _recyclerResults: RecyclerView;
|
protected val _recyclerResults: RecyclerView;
|
||||||
protected val _overlayContainer: FrameLayout;
|
protected val _overlayContainer: FrameLayout;
|
||||||
protected val _swipeRefresh: SwipeRefreshLayout;
|
protected val _swipeRefresh: SwipeRefreshLayout;
|
||||||
@@ -52,8 +53,9 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
|||||||
private val _emptyPagerContainer: FrameLayout;
|
private val _emptyPagerContainer: FrameLayout;
|
||||||
|
|
||||||
protected val _toolbarContentView: LinearLayout;
|
protected val _toolbarContentView: LinearLayout;
|
||||||
|
protected val _bottomContentView: LinearLayout;
|
||||||
|
|
||||||
private var _loading: Boolean = true;
|
private var _loading: Boolean = false;
|
||||||
|
|
||||||
private val _pagerLock = Object();
|
private val _pagerLock = Object();
|
||||||
private var _cache: ItemCache<TResult>? = null;
|
private var _cache: ItemCache<TResult>? = null;
|
||||||
@@ -136,6 +138,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
|||||||
setActiveTags(null);
|
setActiveTags(null);
|
||||||
|
|
||||||
_toolbarContentView = findViewById(R.id.container_toolbar_content);
|
_toolbarContentView = findViewById(R.id.container_toolbar_content);
|
||||||
|
_bottomContentView = findViewById(R.id.container_bottom);
|
||||||
|
|
||||||
_nextPageHandler = TaskHandler<TPager, Pair<TPager, List<TResult>>>({fragment.lifecycleScope}, {
|
_nextPageHandler = TaskHandler<TPager, Pair<TPager, List<TResult>>>({fragment.lifecycleScope}, {
|
||||||
if (it is IAsyncPager<*>)
|
if (it is IAsyncPager<*>)
|
||||||
@@ -177,10 +180,9 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
|||||||
|
|
||||||
val visibleItemCount = _recyclerResults.childCount;
|
val visibleItemCount = _recyclerResults.childCount;
|
||||||
val firstVisibleItem = recyclerData.layoutManager.findFirstVisibleItemPosition()
|
val firstVisibleItem = recyclerData.layoutManager.findFirstVisibleItemPosition()
|
||||||
//Logger.i(TAG, "onScrolled loadNextPage visibleItemCount=$visibleItemCount firstVisibleItem=$visibleItemCount")
|
//Logger.i(TAG, "onScrolled loadNextPage(): firstVisibleItem=$firstVisibleItem visibleItemCount=$visibleItemCount visibleThreshold=$visibleThreshold recyclerData.results.size=${recyclerData.results.size}")
|
||||||
|
|
||||||
if (!_loading && firstVisibleItem + visibleItemCount + visibleThreshold >= recyclerData.results.size && firstVisibleItem > 0) {
|
if (!_loading && firstVisibleItem + visibleItemCount + visibleThreshold >= recyclerData.results.size) {
|
||||||
//Logger.i(TAG, "onScrolled loadNextPage(): firstVisibleItem=$firstVisibleItem visibleItemCount=$visibleItemCount visibleThreshold=$visibleThreshold recyclerData.results.size=${recyclerData.results.size}")
|
|
||||||
loadNextPage();
|
loadNextPage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -194,57 +196,44 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun ensureEnoughContentVisible(filteredResults: List<TConverted>) {
|
private fun ensureEnoughContentVisible(filteredResults: List<TConverted>) {
|
||||||
val canScroll = if (recyclerData.results.isEmpty()) false else {
|
_recyclerResults.post {
|
||||||
val height = resources.displayMetrics.heightPixels;
|
val canScroll = _recyclerResults.canScrollVertically(1)
|
||||||
|
Logger.i(
|
||||||
|
TAG,
|
||||||
|
"ensureEnoughContentVisible loadNextPage canScroll=$canScroll _automaticNextPageCounter=$_automaticNextPageCounter"
|
||||||
|
)
|
||||||
|
if (!canScroll || filteredResults.isEmpty()) {
|
||||||
|
_automaticNextPageCounter++
|
||||||
|
if (_automaticNextPageCounter < _automaticBackoff.size) {
|
||||||
|
if (_automaticNextPageCounter > 0) {
|
||||||
|
val automaticNextPageCounterSaved = _automaticNextPageCounter;
|
||||||
|
fragment.lifecycleScope.launch(Dispatchers.Default) {
|
||||||
|
val backoff = _automaticBackoff[Math.min(
|
||||||
|
_automaticBackoff.size - 1,
|
||||||
|
_automaticNextPageCounter
|
||||||
|
)];
|
||||||
|
|
||||||
val layoutManager = recyclerData.layoutManager
|
|
||||||
val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition()
|
|
||||||
val firstVisibleItemView = if(firstVisibleItemPosition != RecyclerView.NO_POSITION) layoutManager.findViewByPosition(firstVisibleItemPosition) else null;
|
|
||||||
val lastVisibleItemPosition = layoutManager.findLastCompletelyVisibleItemPosition();
|
|
||||||
val lastVisibleItemView = if(lastVisibleItemPosition != RecyclerView.NO_POSITION) layoutManager.findViewByPosition(lastVisibleItemPosition) else null;
|
|
||||||
val rows = if(recyclerData.layoutManager is GridLayoutManager) Math.max(1, recyclerData.results.size / recyclerData.layoutManager.spanCount) else 1;
|
|
||||||
val rowsHeight = (firstVisibleItemView?.height ?: 0) * rows;
|
|
||||||
if(lastVisibleItemView != null && lastVisibleItemPosition == (recyclerData.results.size - 1)) {
|
|
||||||
false;
|
|
||||||
}
|
|
||||||
else if (firstVisibleItemView != null && height != null && rowsHeight < height) {
|
|
||||||
false;
|
|
||||||
} else {
|
|
||||||
true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.i(TAG, "ensureEnoughContentVisible loadNextPage canScroll=$canScroll _automaticNextPageCounter=$_automaticNextPageCounter")
|
|
||||||
if (!canScroll || filteredResults.isEmpty()) {
|
|
||||||
_automaticNextPageCounter++
|
|
||||||
if(_automaticNextPageCounter < _automaticBackoff.size) {
|
|
||||||
if(_automaticNextPageCounter > 0) {
|
|
||||||
val automaticNextPageCounterSaved = _automaticNextPageCounter;
|
|
||||||
fragment.lifecycleScope.launch(Dispatchers.Default) {
|
|
||||||
val backoff = _automaticBackoff[Math.min(_automaticBackoff.size - 1, _automaticNextPageCounter)];
|
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
setLoading(true);
|
|
||||||
}
|
|
||||||
delay(backoff.toLong());
|
|
||||||
if(automaticNextPageCounterSaved == _automaticNextPageCounter) {
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
loadNextPage();
|
setLoading(true);
|
||||||
|
}
|
||||||
|
delay(backoff.toLong());
|
||||||
|
if (automaticNextPageCounterSaved == _automaticNextPageCounter) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
loadNextPage();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
} else
|
||||||
withContext(Dispatchers.Main) {
|
loadNextPage();
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else
|
} else {
|
||||||
loadNextPage();
|
Logger.i(TAG, "ensureEnoughContentVisible automaticNextPageCounter reset");
|
||||||
|
_automaticNextPageCounter = 0;
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
Logger.i(TAG, "ensureEnoughContentVisible automaticNextPageCounter reset");
|
|
||||||
_automaticNextPageCounter = 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fun resetAutomaticNextPageCounter(){
|
fun resetAutomaticNextPageCounter(){
|
||||||
@@ -481,7 +470,9 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
|||||||
recyclerData.resultsUnfiltered.addAll(toAdd);
|
recyclerData.resultsUnfiltered.addAll(toAdd);
|
||||||
recyclerData.adapter.notifyDataSetChanged();
|
recyclerData.adapter.notifyDataSetChanged();
|
||||||
recyclerData.loadedFeedStyle = feedStyle;
|
recyclerData.loadedFeedStyle = feedStyle;
|
||||||
ensureEnoughContentVisible(filteredResults)
|
setLoading(false)
|
||||||
|
if(pager.hasMorePages())
|
||||||
|
ensureEnoughContentVisible(filteredResults)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun detachPagerEvents() {
|
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.StateHistory
|
||||||
import com.futo.platformplayer.states.StatePlatform
|
import com.futo.platformplayer.states.StatePlatform
|
||||||
import com.futo.platformplayer.states.StatePlayer
|
import com.futo.platformplayer.states.StatePlayer
|
||||||
|
import com.futo.platformplayer.states.StatePlaylists
|
||||||
import com.futo.platformplayer.states.StatePlugins
|
import com.futo.platformplayer.states.StatePlugins
|
||||||
import com.futo.platformplayer.views.ToggleBar
|
import com.futo.platformplayer.views.ToggleBar
|
||||||
import com.futo.platformplayer.views.adapters.HistoryListViewHolder
|
import com.futo.platformplayer.views.adapters.HistoryListViewHolder
|
||||||
@@ -243,12 +244,23 @@ class HistoryFragment : MainFragment() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
val inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager;
|
|
||||||
val diff = v.video.duration - v.position;
|
val diff = v.video.duration - v.position;
|
||||||
val vid: Any = if (diff > 5) { v.video.withTimestamp(v.position) } else { v.video };
|
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();
|
_editSearch.clearFocus();
|
||||||
|
val inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager;
|
||||||
inputMethodManager.hideSoftInputFromWindow(_editSearch.windowToken, 0);
|
inputMethodManager.hideSoftInputFromWindow(_editSearch.windowToken, 0);
|
||||||
|
|
||||||
_fragment.lifecycleScope.launch(Dispatchers.Main) {
|
_fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||||
|
|||||||
+3
-1
@@ -365,8 +365,10 @@ class HomeFragment : MainFragment() {
|
|||||||
finishRefreshLayoutLoader();
|
finishRefreshLayoutLoader();
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setPager(pager);
|
setPager(pager);
|
||||||
if(pager.getResults().isEmpty() && !pager.hasMorePages())
|
if(pager.getResults().isEmpty() && !pager.hasMorePages()) {
|
||||||
|
setLoading(false);
|
||||||
setEmptyPager(true);
|
setEmptyPager(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+23
-6
@@ -14,6 +14,7 @@ import android.widget.ImageView
|
|||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.appcompat.widget.AppCompatImageView
|
import androidx.appcompat.widget.AppCompatImageView
|
||||||
|
import androidx.core.view.isVisible
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.FragmentManager
|
import androidx.fragment.app.FragmentManager
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
@@ -22,6 +23,7 @@ import androidx.recyclerview.widget.GridLayoutManager
|
|||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||||
import androidx.viewpager2.widget.ViewPager2
|
import androidx.viewpager2.widget.ViewPager2
|
||||||
|
import com.bumptech.glide.Glide
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.UISlideOverlays
|
import com.futo.platformplayer.UISlideOverlays
|
||||||
@@ -319,8 +321,7 @@ class LibraryArtistFragment : MainFragment() {
|
|||||||
|
|
||||||
_fragment.topBar?.onShown(channel)
|
_fragment.topBar?.onShown(channel)
|
||||||
|
|
||||||
val buttons = arrayListOf(Pair(R.drawable.ic_playlist_add) {
|
val buttons = arrayListOf<Pair<Int, ()->Unit>>();
|
||||||
})
|
|
||||||
|
|
||||||
_fragment.lifecycleScope.launch(Dispatchers.IO) {
|
_fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||||
val plugin = StatePlatform.instance.getChannelClientOrNull(channel.contentUrl ?: return@launch)
|
val plugin = StatePlatform.instance.getChannelClientOrNull(channel.contentUrl ?: return@launch)
|
||||||
@@ -337,8 +338,7 @@ class LibraryArtistFragment : MainFragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_buttonSubscribe.visibility = GONE;
|
_buttonSubscribe.visibility = GONE;
|
||||||
_buttonSubscriptionSettings.visibility =
|
_buttonSubscriptionSettings.visibility = View.GONE
|
||||||
if (_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE
|
|
||||||
_textChannel.text = channel.name
|
_textChannel.text = channel.name
|
||||||
_textChannelSub.text = "${channel.countTracks} songs, ${channel.countAlbums} albums";
|
_textChannelSub.text = "${channel.countTracks} songs, ${channel.countAlbums} albums";
|
||||||
|
|
||||||
@@ -361,7 +361,21 @@ class LibraryArtistFragment : MainFragment() {
|
|||||||
(_viewPager.adapter as ArtistViewPagerAdapter).artist = channel
|
(_viewPager.adapter as ArtistViewPagerAdapter).artist = channel
|
||||||
|
|
||||||
|
|
||||||
_viewPager.adapter!!.notifyDataSetChanged()
|
_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
|
this.channel = channel
|
||||||
}
|
}
|
||||||
@@ -506,7 +520,10 @@ class LibraryArtistFragment : MainFragment() {
|
|||||||
|
|
||||||
val playlist = _artist?.toPlaylist();
|
val playlist = _artist?.toPlaylist();
|
||||||
if (playlist != null) {
|
if (playlist != null) {
|
||||||
val index = playlist.videos.indexOf(c);
|
val sameVideo = playlist.videos.find { it.name == c.name };
|
||||||
|
val index = sameVideo?.let {
|
||||||
|
playlist.videos.indexOf(sameVideo)
|
||||||
|
} ?: -1;
|
||||||
if (index == -1)
|
if (index == -1)
|
||||||
return@subscribe;
|
return@subscribe;
|
||||||
|
|
||||||
|
|||||||
+35
-5
@@ -8,25 +8,32 @@ import android.view.View
|
|||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.ImageButton
|
import android.widget.ImageButton
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
|
import android.widget.LinearLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
|
import androidx.core.net.toUri
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.recyclerview.widget.GridLayoutManager
|
import androidx.recyclerview.widget.GridLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.futo.platformplayer.R
|
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.AdhocPager
|
||||||
import com.futo.platformplayer.api.media.structures.EmptyPager
|
import com.futo.platformplayer.api.media.structures.EmptyPager
|
||||||
import com.futo.platformplayer.api.media.structures.IPager
|
import com.futo.platformplayer.api.media.structures.IPager
|
||||||
import com.futo.platformplayer.constructs.Event1
|
import com.futo.platformplayer.constructs.Event1
|
||||||
import com.futo.platformplayer.fragment.mainactivity.topbar.FilesTopBarFragment
|
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.FileEntry
|
||||||
import com.futo.platformplayer.states.StateLibrary
|
import com.futo.platformplayer.states.StateLibrary
|
||||||
|
import com.futo.platformplayer.states.StatePlayer
|
||||||
import com.futo.platformplayer.views.FeedStyle
|
import com.futo.platformplayer.views.FeedStyle
|
||||||
import com.futo.platformplayer.views.NoResultsView
|
import com.futo.platformplayer.views.NoResultsView
|
||||||
import com.futo.platformplayer.views.adapters.AnyAdapter
|
import com.futo.platformplayer.views.adapters.AnyAdapter
|
||||||
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
|
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
|
||||||
import com.futo.platformplayer.views.adapters.viewholders.FileViewHolder
|
import com.futo.platformplayer.views.adapters.viewholders.FileViewHolder
|
||||||
import com.futo.platformplayer.views.buttons.BigButton
|
import com.futo.platformplayer.views.buttons.BigButton
|
||||||
|
import com.futo.platformplayer.views.buttons.ButtonsContainer
|
||||||
|
|
||||||
class LibraryFilesFragment : MainFragment() {
|
class LibraryFilesFragment : MainFragment() {
|
||||||
override val isMainView : Boolean = true;
|
override val isMainView : Boolean = true;
|
||||||
@@ -70,6 +77,7 @@ class LibraryFilesFragment : MainFragment() {
|
|||||||
private var root: FileEntry? = null;
|
private var root: FileEntry? = null;
|
||||||
|
|
||||||
constructor(fragment: LibraryFilesFragment, inflater: LayoutInflater) : super(fragment, inflater) {
|
constructor(fragment: LibraryFilesFragment, inflater: LayoutInflater) : super(fragment, inflater) {
|
||||||
|
disableRefreshLayout();
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onShown(parameter: Any? = null) {
|
fun onShown(parameter: Any? = null) {
|
||||||
@@ -78,6 +86,7 @@ class LibraryFilesFragment : MainFragment() {
|
|||||||
}
|
}
|
||||||
fun loadTop() {
|
fun loadTop() {
|
||||||
var initialDirectories = listOf<FileEntry>();
|
var initialDirectories = listOf<FileEntry>();
|
||||||
|
var path = "";
|
||||||
if(root == null) {
|
if(root == null) {
|
||||||
initialDirectories = StateLibrary.instance.getFileDirectories();
|
initialDirectories = StateLibrary.instance.getFileDirectories();
|
||||||
if (initialDirectories.size == 0) {
|
if (initialDirectories.size == 0) {
|
||||||
@@ -101,9 +110,10 @@ class LibraryFilesFragment : MainFragment() {
|
|||||||
it.isVisible = false;
|
it.isVisible = false;
|
||||||
}
|
}
|
||||||
initialDirectories = root?.getSubFiles() ?: listOf();
|
initialDirectories = root?.getSubFiles() ?: listOf();
|
||||||
|
path = root?.path ?: "";
|
||||||
}
|
}
|
||||||
navStack.clear();
|
navStack.clear();
|
||||||
val entry = FileStack("", initialDirectories);
|
val entry = FileStack(path, initialDirectories);
|
||||||
navStack.add(entry);
|
navStack.add(entry);
|
||||||
openDirectory(navStack.last());
|
openDirectory(navStack.last());
|
||||||
fragment.topBar?.let {
|
fragment.topBar?.let {
|
||||||
@@ -114,11 +124,10 @@ class LibraryFilesFragment : MainFragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
fun leaveDirectory() {
|
fun leaveDirectory() {
|
||||||
if(navStack.size > 1) {
|
if (navStack.size > 1) {
|
||||||
navStack.removeLast();
|
navStack.removeAt(navStack.size - 1)
|
||||||
openDirectory(navStack.last());
|
openDirectory(navStack.last())
|
||||||
}
|
}
|
||||||
else {}
|
|
||||||
}
|
}
|
||||||
fun openDirectory(stack: FileStack, addToStack: Boolean = false) {
|
fun openDirectory(stack: FileStack, addToStack: Boolean = false) {
|
||||||
if(addToStack)
|
if(addToStack)
|
||||||
@@ -139,6 +148,27 @@ class LibraryFilesFragment : MainFragment() {
|
|||||||
setPager(AdhocPager<FileEntry>({ listOf(); }, stack.files));
|
setPager(AdhocPager<FileEntry>({ listOf(); }, stack.files));
|
||||||
setLoading(false);
|
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 {
|
fragment.topBar?.let {
|
||||||
if(it is FilesTopBarFragment) {
|
if(it is FilesTopBarFragment) {
|
||||||
if(navStack.size > 1)
|
if(navStack.size > 1)
|
||||||
|
|||||||
+141
-45
@@ -2,6 +2,7 @@ package com.futo.platformplayer.fragment.mainactivity.main
|
|||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
@@ -11,11 +12,13 @@ import android.view.ViewGroup
|
|||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.collection.emptyLongSet
|
||||||
import androidx.constraintlayout.widget.ConstraintLayout
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||||||
import androidx.core.app.ActivityCompat
|
import androidx.core.app.ActivityCompat
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.core.view.updateLayoutParams
|
import androidx.core.view.updateLayoutParams
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
@@ -34,6 +37,7 @@ import com.futo.platformplayer.views.AnyInsertedAdapterView
|
|||||||
import com.futo.platformplayer.views.AnyInsertedAdapterView.Companion.asAnyWithTop
|
import com.futo.platformplayer.views.AnyInsertedAdapterView.Companion.asAnyWithTop
|
||||||
import com.futo.platformplayer.views.AnyInsertedAdapterView.Companion.asAnyWithViews
|
import com.futo.platformplayer.views.AnyInsertedAdapterView.Companion.asAnyWithViews
|
||||||
import com.futo.platformplayer.views.LibrarySection
|
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.AnyAdapter
|
||||||
import com.futo.platformplayer.views.adapters.InsertedViewAdapter
|
import com.futo.platformplayer.views.adapters.InsertedViewAdapter
|
||||||
import com.futo.platformplayer.views.adapters.viewholders.AlbumTileViewHolder
|
import com.futo.platformplayer.views.adapters.viewholders.AlbumTileViewHolder
|
||||||
@@ -41,6 +45,9 @@ import com.futo.platformplayer.views.adapters.viewholders.ArtistTileViewHolder
|
|||||||
import com.futo.platformplayer.views.adapters.viewholders.FileViewHolder
|
import com.futo.platformplayer.views.adapters.viewholders.FileViewHolder
|
||||||
import com.futo.platformplayer.views.adapters.viewholders.LocalVideoTileViewHolder
|
import com.futo.platformplayer.views.adapters.viewholders.LocalVideoTileViewHolder
|
||||||
import com.futo.platformplayer.views.buttons.BigButton
|
import com.futo.platformplayer.views.buttons.BigButton
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import okhttp3.Dispatcher
|
||||||
|
|
||||||
|
|
||||||
class LibraryFragment : MainFragment() {
|
class LibraryFragment : MainFragment() {
|
||||||
@@ -93,14 +100,18 @@ class LibraryFragment : MainFragment() {
|
|||||||
UIDialogs.showDialog(requireContext(), R.drawable.ic_library,
|
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,
|
"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", {
|
UIDialogs.Action("Ok", {
|
||||||
permissionReqAudio.launch(android.Manifest.permission.READ_MEDIA_AUDIO);
|
StateApp?.instance?.activity?.requestPermissionAudio {
|
||||||
|
setPermissionResultAudio(it);
|
||||||
|
}
|
||||||
}, UIDialogs.ActionStyle.PRIMARY),
|
}, UIDialogs.ActionStyle.PRIMARY),
|
||||||
UIDialogs.Action("Cancel", {
|
UIDialogs.Action("Cancel", {
|
||||||
|
|
||||||
}, UIDialogs.ActionStyle.NONE));
|
}, UIDialogs.ActionStyle.NONE));
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
permissionReqAudio.launch(android.Manifest.permission.READ_MEDIA_AUDIO);
|
StateApp?.instance?.activity?.requestPermissionAudio {
|
||||||
|
setPermissionResultAudio(it);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -113,24 +124,22 @@ class LibraryFragment : MainFragment() {
|
|||||||
UIDialogs.showDialog(requireContext(), R.drawable.ic_library, false,
|
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,
|
"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", {
|
UIDialogs.Action("Ok", {
|
||||||
permissionReqVideo.launch(android.Manifest.permission.READ_MEDIA_VIDEO);
|
StateApp?.instance?.activity?.requestPermissionVideo {
|
||||||
|
setPermissionResultVideo(it);
|
||||||
|
}
|
||||||
}, UIDialogs.ActionStyle.PRIMARY),
|
}, UIDialogs.ActionStyle.PRIMARY),
|
||||||
UIDialogs.Action("Cancel", {
|
UIDialogs.Action("Cancel", {
|
||||||
|
|
||||||
}, UIDialogs.ActionStyle.NONE));
|
}, UIDialogs.ActionStyle.NONE));
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
permissionReqVideo.launch(android.Manifest.permission.READ_MEDIA_VIDEO);
|
StateApp?.instance?.activity?.requestPermissionVideo {
|
||||||
|
setPermissionResultVideo(it);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val permissionReqAudio = registerForActivityResult(ActivityResultContracts.RequestPermission(), { isGranted ->
|
|
||||||
setPermissionResultAudio(isGranted);
|
|
||||||
});
|
|
||||||
val permissionReqVideo = registerForActivityResult(ActivityResultContracts.RequestPermission(), { isGranted ->
|
|
||||||
setPermissionResultVideo(isGranted);
|
|
||||||
});
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun newInstance() = LibraryFragment().apply {}
|
fun newInstance() = LibraryFragment().apply {}
|
||||||
@@ -144,11 +153,12 @@ class LibraryFragment : MainFragment() {
|
|||||||
var sectionAlbums: LibrarySection;
|
var sectionAlbums: LibrarySection;
|
||||||
var sectionVideos: LibrarySection;
|
var sectionVideos: LibrarySection;
|
||||||
var sectionFiles: LibrarySection;
|
var sectionFiles: LibrarySection;
|
||||||
|
var noContent: NoResultsView;
|
||||||
//var buttonFiles: BigButton;
|
//var buttonFiles: BigButton;
|
||||||
|
|
||||||
val recycler: RecyclerView;
|
val recycler: RecyclerView;
|
||||||
|
|
||||||
val adapterFiles: AnyInsertedAdapterView<FileEntry, FileViewHolder>;
|
var adapterFiles: AnyInsertedAdapterView<FileEntry, FileViewHolder>? = null;
|
||||||
|
|
||||||
//var metaInfo: TextView;
|
//var metaInfo: TextView;
|
||||||
|
|
||||||
@@ -184,6 +194,9 @@ class LibraryFragment : MainFragment() {
|
|||||||
//buttonFiles = findViewById<BigButton>(R.id.button_files);
|
//buttonFiles = findViewById<BigButton>(R.id.button_files);
|
||||||
//metaInfo = findViewById(R.id.meta_info);
|
//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.allowMusic = allowMusic ?: false;
|
||||||
this.allowVideo = allowVideo ?: false;
|
this.allowVideo = allowVideo ?: false;
|
||||||
|
|
||||||
@@ -193,14 +206,6 @@ class LibraryFragment : MainFragment() {
|
|||||||
else
|
else
|
||||||
fragment.requestPermissionMusic();
|
fragment.requestPermissionMusic();
|
||||||
});
|
});
|
||||||
val adapterArtists = sectionArtists.getAnyAdapter<Artist, ArtistTileViewHolder>({
|
|
||||||
it.onClick.subscribe {
|
|
||||||
if(it != null)
|
|
||||||
fragment.navigate<LibraryArtistFragment>(it);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
val artists = StateLibrary.instance.getArtists(ArtistOrdering.TrackCount);
|
|
||||||
adapterArtists.setData(artists);
|
|
||||||
|
|
||||||
sectionAlbums.setSection("Albums", {
|
sectionAlbums.setSection("Albums", {
|
||||||
if(this.allowMusic)
|
if(this.allowMusic)
|
||||||
@@ -208,14 +213,6 @@ class LibraryFragment : MainFragment() {
|
|||||||
else
|
else
|
||||||
fragment.requestPermissionMusic();
|
fragment.requestPermissionMusic();
|
||||||
});
|
});
|
||||||
val adapterAlbums = sectionAlbums.getAnyAdapter<Album, AlbumTileViewHolder>({
|
|
||||||
it.onClick.subscribe {
|
|
||||||
if(it != null)
|
|
||||||
fragment.navigate<LibraryAlbumFragment>(it);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
val albums = StateLibrary.instance.getAlbums();
|
|
||||||
adapterAlbums.setData(albums);
|
|
||||||
|
|
||||||
|
|
||||||
sectionVideos.setSection("Videos", {
|
sectionVideos.setSection("Videos", {
|
||||||
@@ -224,21 +221,118 @@ class LibraryFragment : MainFragment() {
|
|||||||
else
|
else
|
||||||
fragment.requestPermissionVideo();
|
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>({
|
val adapterVideos = sectionVideos.getAnyAdapter<IPlatformVideo, LocalVideoTileViewHolder>({
|
||||||
it.onClick.subscribe {
|
it.onClick.subscribe {
|
||||||
if(it != null)
|
if(it != null)
|
||||||
fragment.navigate<VideoDetailFragment>(it);
|
fragment.navigate<VideoDetailFragment>(it);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
val videos = StateLibrary.instance.getRecentVideos(null, 20);
|
|
||||||
adapterVideos.setData(videos);
|
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>(
|
adapterFiles = recycler.asAnyWithViews<FileEntry, FileViewHolder>(
|
||||||
arrayListOf(
|
arrayListOf(
|
||||||
sectionArtists,
|
sectionArtists,
|
||||||
sectionAlbums,
|
sectionAlbums,
|
||||||
sectionVideos,
|
sectionVideos,
|
||||||
sectionFiles
|
sectionFiles,
|
||||||
|
noContent
|
||||||
),
|
),
|
||||||
arrayListOf(View(context).apply { this.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 20.dp(resources)) }),
|
arrayListOf(View(context).apply { this.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 20.dp(resources)) }),
|
||||||
RecyclerView.VERTICAL, false, {
|
RecyclerView.VERTICAL, false, {
|
||||||
@@ -255,23 +349,8 @@ class LibraryFragment : MainFragment() {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
reloadFiles();
|
reloadFiles();
|
||||||
|
|
||||||
|
|
||||||
/*
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
fun setMusicPermissions(access: Boolean) {
|
fun setMusicPermissions(access: Boolean) {
|
||||||
allowMusic = access;
|
allowMusic = access;
|
||||||
sectionAlbums.setContentEmptyMessage(R.drawable.ic_library, "No mediastore permissions");
|
sectionAlbums.setContentEmptyMessage(R.drawable.ic_library, "No mediastore permissions");
|
||||||
@@ -281,6 +360,10 @@ class LibraryFragment : MainFragment() {
|
|||||||
// if(!allowMusic) "You did not give access to local music, so these options are disabled" else null,
|
// 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
|
// if(!allowVideo) "You did not give access to local videos, so these options are disabled" else null
|
||||||
//).filterNotNull().joinToString("\n");
|
//).filterNotNull().joinToString("\n");
|
||||||
|
|
||||||
|
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||||
|
reloadLibraryUI();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fun setVideoPermissions(access: Boolean) {
|
fun setVideoPermissions(access: Boolean) {
|
||||||
allowVideo = access;
|
allowVideo = access;
|
||||||
@@ -289,9 +372,22 @@ class LibraryFragment : MainFragment() {
|
|||||||
// if(!allowMusic) "You did not give access to local music, so these options are disabled" else null,
|
// 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
|
// if(!allowVideo) "You did not give access to local videos, so these options are disabled" else null
|
||||||
//).filterNotNull().joinToString("\n");
|
//).filterNotNull().joinToString("\n");
|
||||||
|
// }
|
||||||
|
|
||||||
|
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||||
|
reloadLibraryUI();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onShown() {
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
-1
@@ -96,7 +96,6 @@ class LibraryVideosFragment : MainFragment() {
|
|||||||
fun onShown() {
|
fun onShown() {
|
||||||
val initialAlbums = StateLibrary.instance.getAlbums();
|
val initialAlbums = StateLibrary.instance.getAlbums();
|
||||||
Logger.i(TAG, "Initial album count: " + initialAlbums.size);
|
Logger.i(TAG, "Initial album count: " + initialAlbums.size);
|
||||||
val buckets = StateLibrary.instance.getVideoBucketNames();
|
|
||||||
setPager(StateLibrary.instance.getVideos(fragment._toggleBuckets));
|
setPager(StateLibrary.instance.getVideos(fragment._toggleBuckets));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+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, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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\\-\\.#:_ ]*");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
@@ -16,6 +16,7 @@ import androidx.lifecycle.lifecycleScope
|
|||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
|
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||||
import com.futo.platformplayer.*
|
import com.futo.platformplayer.*
|
||||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
|
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
|
||||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
|
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
|
||||||
@@ -363,6 +364,7 @@ class RemotePlaylistFragment : MainFragment() {
|
|||||||
_imagePlaylistThumbnail.let {
|
_imagePlaylistThumbnail.let {
|
||||||
Glide.with(it)
|
Glide.with(it)
|
||||||
.load(video.thumbnails.getHQThumbnail())
|
.load(video.thumbnails.getHQThumbnail())
|
||||||
|
.withMaxSizePx()
|
||||||
.placeholder(R.drawable.placeholder_video_thumbnail)
|
.placeholder(R.drawable.placeholder_video_thumbnail)
|
||||||
.crossfade()
|
.crossfade()
|
||||||
.into(it);
|
.into(it);
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package com.futo.platformplayer.fragment.mainactivity.main
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.graphics.Bitmap
|
||||||
import android.graphics.drawable.Animatable
|
import android.graphics.drawable.Animatable
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
@@ -13,10 +15,15 @@ import android.widget.ImageView
|
|||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.constraintlayout.widget.ConstraintLayout
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
import androidx.core.graphics.drawable.toDrawable
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.media3.common.C
|
import androidx.media3.common.C
|
||||||
import androidx.media3.common.Format
|
import androidx.media3.common.Format
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import com.bumptech.glide.Glide
|
||||||
|
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||||
|
import com.bumptech.glide.request.target.CustomTarget
|
||||||
|
import com.bumptech.glide.request.transition.Transition
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.Settings
|
import com.futo.platformplayer.Settings
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
@@ -71,6 +78,7 @@ import com.futo.platformplayer.views.video.FutoShortPlayer
|
|||||||
import com.futo.platformplayer.views.video.FutoVideoPlayerBase
|
import com.futo.platformplayer.views.video.FutoVideoPlayerBase
|
||||||
import com.futo.platformplayer.views.video.FutoVideoPlayerBase.Companion.PREFERED_AUDIO_CONTAINERS
|
import com.futo.platformplayer.views.video.FutoVideoPlayerBase.Companion.PREFERED_AUDIO_CONTAINERS
|
||||||
import com.futo.platformplayer.views.video.FutoVideoPlayerBase.Companion.PREFERED_VIDEO_CONTAINERS
|
import com.futo.platformplayer.views.video.FutoVideoPlayerBase.Companion.PREFERED_VIDEO_CONTAINERS
|
||||||
|
import com.futo.platformplayer.withMaxSizePx
|
||||||
import com.futo.polycentric.core.ApiMethods
|
import com.futo.polycentric.core.ApiMethods
|
||||||
import com.futo.polycentric.core.ContentType
|
import com.futo.polycentric.core.ContentType
|
||||||
import com.futo.polycentric.core.Models
|
import com.futo.polycentric.core.Models
|
||||||
@@ -851,9 +859,8 @@ class ShortView : FrameLayout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val thumbnail = videoDetails.thumbnails.getHQThumbnail()
|
val thumbnail = videoDetails.thumbnails.getHQThumbnail()
|
||||||
/*
|
|
||||||
if (videoSource == null && !thumbnail.isNullOrBlank()) Glide.with(context).asBitmap()
|
if (videoSource == null && !thumbnail.isNullOrBlank()) Glide.with(context).asBitmap()
|
||||||
.load(thumbnail).into(object : CustomTarget<Bitmap>() {
|
.load(thumbnail).withMaxSizePx().into(object : CustomTarget<Bitmap>() {
|
||||||
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
||||||
player.setArtwork(resource.toDrawable(resources))
|
player.setArtwork(resource.toDrawable(resources))
|
||||||
}
|
}
|
||||||
@@ -863,7 +870,6 @@ class ShortView : FrameLayout {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
else player.setArtwork(null)
|
else player.setArtwork(null)
|
||||||
*/
|
|
||||||
|
|
||||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
+26
-4
@@ -453,7 +453,7 @@ class SourceDetailFragment : MainFragment() {
|
|||||||
}.apply {
|
}.apply {
|
||||||
this.alpha = 0.5f;
|
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);
|
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})?",
|
UIDialogs.showDialog(context, R.drawable.ic_warning_yellow, "Are you sure you want to downgrade (${config.version}=>${embeddedConfig?.version})?",
|
||||||
@@ -467,7 +467,29 @@ class SourceDetailFragment : MainFragment() {
|
|||||||
this.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).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);
|
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();
|
_sourceAdvancedButtons.removeAllViews();
|
||||||
@@ -486,7 +508,7 @@ class SourceDetailFragment : MainFragment() {
|
|||||||
config.authentication.loginWarning, null, 0,
|
config.authentication.loginWarning, null, 0,
|
||||||
UIDialogs.Action("Cancel", {}, UIDialogs.ActionStyle.NONE),
|
UIDialogs.Action("Cancel", {}, UIDialogs.ActionStyle.NONE),
|
||||||
UIDialogs.Action("Login", {
|
UIDialogs.Action("Login", {
|
||||||
LoginActivity.showLogin(StateApp.instance.context, config) {
|
LoginFragment.showLogin(config) {//LoginActivity.showLogin(StateApp.instance.context, config) {
|
||||||
try {
|
try {
|
||||||
StatePlugins.instance.setPluginAuth(config.id, it);
|
StatePlugins.instance.setPluginAuth(config.id, it);
|
||||||
reloadSource(config.id);
|
reloadSource(config.id);
|
||||||
@@ -500,7 +522,7 @@ class SourceDetailFragment : MainFragment() {
|
|||||||
}, UIDialogs.ActionStyle.PRIMARY))
|
}, UIDialogs.ActionStyle.PRIMARY))
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
LoginActivity.showLogin(StateApp.instance.context, config) {
|
LoginFragment.showLogin(config) {//LoginActivity.showLogin(StateApp.instance.context, config) {
|
||||||
try {
|
try {
|
||||||
StatePlugins.instance.setPluginAuth(config.id, it);
|
StatePlugins.instance.setPluginAuth(config.id, it);
|
||||||
reloadSource(config.id);
|
reloadSource(config.id);
|
||||||
|
|||||||
+36
-27
@@ -50,7 +50,7 @@ class VideoDetailFragment() : MainFragment() {
|
|||||||
|
|
||||||
private var _isActive: Boolean = false;
|
private var _isActive: Boolean = false;
|
||||||
|
|
||||||
private var _viewDetail : VideoDetailView? = null;
|
var _viewDetail : VideoDetailView? = null;
|
||||||
private var _view : SingleViewTouchableMotionLayout? = null;
|
private var _view : SingleViewTouchableMotionLayout? = null;
|
||||||
|
|
||||||
var isFullscreen : Boolean = false;
|
var isFullscreen : Boolean = false;
|
||||||
@@ -356,38 +356,46 @@ class VideoDetailFragment() : MainFragment() {
|
|||||||
override fun onTransitionChange(motionLayout: MotionLayout?, startId: Int, endId: Int, progress: Float) {
|
override fun onTransitionChange(motionLayout: MotionLayout?, startId: Int, endId: Int, progress: Float) {
|
||||||
_viewDetail?.stopAllGestures()
|
_viewDetail?.stopAllGestures()
|
||||||
|
|
||||||
if (state != State.MINIMIZED && progress < 0.1) {
|
if (!isTransitioning && (progress < 0.9 && progress > 0.1)) {
|
||||||
state = State.MINIMIZED;
|
|
||||||
isMinimizingFromFullScreen = false
|
|
||||||
onMinimize.emit();
|
|
||||||
}
|
|
||||||
else if (state != State.MAXIMIZED && progress > 0.9) {
|
|
||||||
if (_isInitialMaximize) {
|
|
||||||
state = State.CLOSED;
|
|
||||||
_isInitialMaximize = false;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
state = State.MAXIMIZED;
|
|
||||||
onMaximized.emit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isTransitioning && (progress > 0.95 || progress < 0.05)) {
|
|
||||||
isTransitioning = false;
|
|
||||||
onTransitioning.emit(isTransitioning);
|
|
||||||
|
|
||||||
if(isInPictureInPicture) leavePictureInPictureMode(false); //Workaround to prevent getting stuck in p2p
|
|
||||||
}
|
|
||||||
else if (!isTransitioning && (progress < 0.95 && progress > 0.05)) {
|
|
||||||
isTransitioning = true;
|
isTransitioning = true;
|
||||||
onTransitioning.emit(isTransitioning);
|
onTransitioning.emit(isTransitioning);
|
||||||
|
|
||||||
if(isInPictureInPicture) leavePictureInPictureMode(false); //Workaround to prevent getting stuck in p2p
|
if(isInPictureInPicture) leavePictureInPictureMode(false); //Workaround to prevent getting stuck in p2p
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
override fun onTransitionCompleted(motionLayout: MotionLayout?, currentId: Int) { }
|
override fun onTransitionCompleted(motionLayout: MotionLayout?, currentId: Int) {
|
||||||
|
val progress = motionLayout?.progress ?: return;
|
||||||
|
|
||||||
|
if (state != State.MINIMIZED && progress < 0.1) {
|
||||||
|
state = State.MINIMIZED;
|
||||||
|
isMinimizingFromFullScreen = false
|
||||||
|
onMinimize.emit();
|
||||||
|
}
|
||||||
|
else if (state != State.MAXIMIZED && progress > 0.9) {
|
||||||
|
state = State.MAXIMIZED;
|
||||||
|
onMaximized.emit();
|
||||||
|
/*
|
||||||
|
if (_isInitialMaximize) {
|
||||||
|
//state = State.CLOSED; Causes issues? might no longer be needed
|
||||||
|
_isInitialMaximize = false;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
state = State.MAXIMIZED;
|
||||||
|
onMaximized.emit();
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTransitioning && (progress > 0.6 || progress < 0.4)) {
|
||||||
|
isTransitioning = false;
|
||||||
|
onTransitioning.emit(isTransitioning);
|
||||||
|
|
||||||
|
if(isInPictureInPicture) leavePictureInPictureMode(false); //Workaround to prevent getting stuck in p2p
|
||||||
|
}
|
||||||
|
}
|
||||||
override fun onTransitionStarted(p0: MotionLayout?, p1: Int, p2: Int) { }
|
override fun onTransitionStarted(p0: MotionLayout?, p1: Int, p2: Int) { }
|
||||||
override fun onTransitionTrigger(p0: MotionLayout?, p1: Int, p2: Boolean, p3: Float) { }
|
override fun onTransitionTrigger(p0: MotionLayout?, p1: Int, p2: Boolean, p3: Float) {
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
_view?.let {
|
_view?.let {
|
||||||
@@ -446,7 +454,8 @@ class VideoDetailFragment() : MainFragment() {
|
|||||||
if (viewDetail.shouldEnterPictureInPicture) {
|
if (viewDetail.shouldEnterPictureInPicture) {
|
||||||
_leavingPiP = false
|
_leavingPiP = false
|
||||||
}
|
}
|
||||||
if(Build.VERSION.SDK_INT < Build.VERSION_CODES.S && viewDetail.preventPictureInPicture == false && !StateCasting.instance.isCasting && Settings.instance.playback.isBackgroundPictureInPicture() && !viewDetail.isAudioOnlyUserAction) {
|
val shouldPiP = Settings.instance.playback.isBackgroundPictureInPicture()
|
||||||
|
if(Build.VERSION.SDK_INT < Build.VERSION_CODES.S && viewDetail.preventPictureInPicture == false && !StateCasting.instance.isCasting && shouldPiP && !viewDetail.isAudioOnlyUserAction) {
|
||||||
val params = _viewDetail?.getPictureInPictureParams();
|
val params = _viewDetail?.getPictureInPictureParams();
|
||||||
if(params != null) {
|
if(params != null) {
|
||||||
Logger.i(TAG, "enterPictureInPictureMode")
|
Logger.i(TAG, "enterPictureInPictureMode")
|
||||||
|
|||||||
+81
-20
@@ -42,6 +42,7 @@ import androidx.media3.datasource.HttpDataSource
|
|||||||
import androidx.media3.ui.PlayerControlView
|
import androidx.media3.ui.PlayerControlView
|
||||||
import androidx.media3.ui.TimeBar
|
import androidx.media3.ui.TimeBar
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
|
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||||
import com.bumptech.glide.request.target.CustomTarget
|
import com.bumptech.glide.request.target.CustomTarget
|
||||||
import com.bumptech.glide.request.transition.Transition
|
import com.bumptech.glide.request.transition.Transition
|
||||||
import com.futo.platformplayer.BuildConfig
|
import com.futo.platformplayer.BuildConfig
|
||||||
@@ -55,6 +56,7 @@ import com.futo.platformplayer.api.media.LiveChatManager
|
|||||||
import com.futo.platformplayer.api.media.PlatformID
|
import com.futo.platformplayer.api.media.PlatformID
|
||||||
import com.futo.platformplayer.api.media.exceptions.ContentNotAvailableYetException
|
import com.futo.platformplayer.api.media.exceptions.ContentNotAvailableYetException
|
||||||
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
|
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
|
||||||
|
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||||
import com.futo.platformplayer.api.media.models.PlatformAuthorMembershipLink
|
import com.futo.platformplayer.api.media.models.PlatformAuthorMembershipLink
|
||||||
import com.futo.platformplayer.api.media.models.chapters.ChapterType
|
import com.futo.platformplayer.api.media.models.chapters.ChapterType
|
||||||
import com.futo.platformplayer.api.media.models.chapters.IChapter
|
import com.futo.platformplayer.api.media.models.chapters.IChapter
|
||||||
@@ -77,6 +79,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.subtitles.ISubtitleSource
|
||||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
||||||
|
import com.futo.platformplayer.api.media.models.video.LocalVideoDetails
|
||||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
||||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||||
@@ -159,6 +162,7 @@ import com.futo.platformplayer.views.subscriptions.SubscribeButton
|
|||||||
import com.futo.platformplayer.views.video.FutoVideoPlayer
|
import com.futo.platformplayer.views.video.FutoVideoPlayer
|
||||||
import com.futo.platformplayer.views.video.FutoVideoPlayerBase
|
import com.futo.platformplayer.views.video.FutoVideoPlayerBase
|
||||||
import com.futo.platformplayer.views.videometa.UpNextView
|
import com.futo.platformplayer.views.videometa.UpNextView
|
||||||
|
import com.futo.platformplayer.withMaxSizePx
|
||||||
import com.futo.polycentric.core.ApiMethods
|
import com.futo.polycentric.core.ApiMethods
|
||||||
import com.futo.polycentric.core.ContentType
|
import com.futo.polycentric.core.ContentType
|
||||||
import com.futo.polycentric.core.Models
|
import com.futo.polycentric.core.Models
|
||||||
@@ -175,6 +179,7 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
import userpackage.Protocol
|
import userpackage.Protocol
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
@@ -549,12 +554,12 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
_buttonMore = buttonMore;
|
_buttonMore = buttonMore;
|
||||||
updateMoreButtons();
|
updateMoreButtons();
|
||||||
|
|
||||||
val handleLoaderGameVisibilityChanged = { b: Boolean ->
|
val handleLoaderGameVisibilityChanged: (Boolean) -> Unit = { b: Boolean ->
|
||||||
_loaderGameVisible = b
|
_loaderGameVisible = b
|
||||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||||
onShouldEnterPictureInPictureChanged.emit()
|
onShouldEnterPictureInPictureChanged.emit()
|
||||||
|
updateResumeVisibilityFor(lastPositionMilliseconds)
|
||||||
}
|
}
|
||||||
updateResumeVisibilityFor(lastPositionMilliseconds)
|
|
||||||
}
|
}
|
||||||
_player.loaderGameVisibilityChanged.subscribe(handleLoaderGameVisibilityChanged)
|
_player.loaderGameVisibilityChanged.subscribe(handleLoaderGameVisibilityChanged)
|
||||||
_cast.loaderGameVisibilityChanged.subscribe(handleLoaderGameVisibilityChanged)
|
_cast.loaderGameVisibilityChanged.subscribe(handleLoaderGameVisibilityChanged)
|
||||||
@@ -563,6 +568,18 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
if (video is TutorialFragment.TutorialVideo) {
|
if (video is TutorialFragment.TutorialVideo) {
|
||||||
return@setOnClickListener
|
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 {
|
(video?.author ?: _searchVideo?.author)?.let {
|
||||||
fragment.navigate<ChannelFragment>(it);
|
fragment.navigate<ChannelFragment>(it);
|
||||||
@@ -625,6 +642,11 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
}
|
}
|
||||||
_player.onSourceChanged.subscribe(::onSourceChanged);
|
_player.onSourceChanged.subscribe(::onSourceChanged);
|
||||||
_player.onSourceEnded.subscribe {
|
_player.onSourceEnded.subscribe {
|
||||||
|
if (_isCasting) {
|
||||||
|
Logger.i(TAG, "Ignoring onSourceEnded because casting is active")
|
||||||
|
return@subscribe
|
||||||
|
}
|
||||||
|
|
||||||
if (!fragment.isInPictureInPicture) {
|
if (!fragment.isInPictureInPicture) {
|
||||||
_player.gestureControl.showControls(false);
|
_player.gestureControl.showControls(false);
|
||||||
}
|
}
|
||||||
@@ -704,6 +726,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
|
|
||||||
val v = video;
|
val v = video;
|
||||||
if (!it && v != null && v.duration - activeDevice.time.toLong() < 2L) {
|
if (!it && v != null && v.duration - activeDevice.time.toLong() < 2L) {
|
||||||
|
Log.i(TAG, "Next video (loop?)")
|
||||||
nextVideo();
|
nextVideo();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1035,7 +1058,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
_slideUpOverlay?.hide();
|
_slideUpOverlay?.hide();
|
||||||
}
|
}
|
||||||
else null,
|
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) {
|
RoundButton(context, R.drawable.ic_download, context.getString(R.string.download), TAG_DOWNLOAD) {
|
||||||
video?.let {
|
video?.let {
|
||||||
_slideUpOverlay = UISlideOverlays.showDownloadVideoOverlay(it, _overlayContainer, context.contentResolver);
|
_slideUpOverlay = UISlideOverlays.showDownloadVideoOverlay(it, _overlayContainer, context.contentResolver);
|
||||||
@@ -1058,15 +1081,16 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
_slideUpOverlay?.hide();
|
_slideUpOverlay?.hide();
|
||||||
}
|
}
|
||||||
else null,
|
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 {
|
video?.let {
|
||||||
val url = video?.shareUrl ?: _searchVideo?.shareUrl ?: _url;
|
val url = video?.shareUrl ?: _searchVideo?.shareUrl ?: _url;
|
||||||
fragment.navigate<BrowserFragment>(url);
|
fragment.navigate<BrowserFragment>(url);
|
||||||
fragment.minimizeVideoDetail();
|
fragment.minimizeVideoDetail();
|
||||||
};
|
};
|
||||||
_slideUpOverlay?.hide();
|
_slideUpOverlay?.hide();
|
||||||
},
|
} else null,
|
||||||
if (StateSync.instance.hasAuthorizedDevice()) {
|
if (StateSync.instance.hasAuthorizedDevice() && !(video is LocalVideoDetails)) {
|
||||||
RoundButton(context, R.drawable.ic_device, context.getString(R.string.send_to_device), TAG_SEND_TO_DEVICE) {
|
RoundButton(context, R.drawable.ic_device, context.getString(R.string.send_to_device), TAG_SEND_TO_DEVICE) {
|
||||||
val devices = StateSync.instance.getAuthorizedSessions();
|
val devices = StateSync.instance.getAuthorizedSessions();
|
||||||
val videoToSend = video ?: return@RoundButton;
|
val videoToSend = video ?: return@RoundButton;
|
||||||
@@ -1089,10 +1113,11 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}} else null,
|
}} 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();
|
reloadVideo();
|
||||||
_slideUpOverlay?.hide();
|
_slideUpOverlay?.hide();
|
||||||
}).filterNotNull();
|
} else null).filterNotNull();
|
||||||
if(!_buttonPinStore.getAllValues().any())
|
if(!_buttonPinStore.getAllValues().any())
|
||||||
_buttonPins.setButtons(*(buttons + listOf(_buttonMore)).toTypedArray());
|
_buttonPins.setButtons(*(buttons + listOf(_buttonMore)).toTypedArray());
|
||||||
else {
|
else {
|
||||||
@@ -1327,7 +1352,22 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
return;
|
return;
|
||||||
//Loop workaround
|
//Loop workaround
|
||||||
if(bypassSameVideoCheck && this.video?.url == video.url && StatePlayer.instance.loopVideo) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1355,6 +1395,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
_minimize_title.text = video.name;
|
_minimize_title.text = video.name;
|
||||||
_minimize_meta.text = video.author.name;
|
_minimize_meta.text = video.author.name;
|
||||||
StatePlayer.instance.setCurrentlyPlaying(video);
|
StatePlayer.instance.setCurrentlyPlaying(video);
|
||||||
|
Log.i(TAG, "setCurrentlyPlaying (setVideoOverview) ${video.url} (${video.name})")
|
||||||
|
|
||||||
val subTitleSegments : ArrayList<String> = ArrayList();
|
val subTitleSegments : ArrayList<String> = ArrayList();
|
||||||
if(video.viewCount > 0)
|
if(video.viewCount > 0)
|
||||||
@@ -1624,7 +1665,9 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
|
|
||||||
_buttonSubscribe.setSubscribeChannel(video.author.url);
|
_buttonSubscribe.setSubscribeChannel(video.author.url);
|
||||||
setDescription(video.description.fixHtmlLinks());
|
setDescription(video.description.fixHtmlLinks());
|
||||||
_creatorThumbnail.setThumbnail(video.author.thumbnail, false);
|
_creatorThumbnail.setThumbnail(video.author.thumbnail, false,
|
||||||
|
video is LocalVideoDetails
|
||||||
|
);
|
||||||
setPolycentricProfile(null, animate = false);
|
setPolycentricProfile(null, animate = false);
|
||||||
_taskLoadPolycentricProfile.run(video.author.id);
|
_taskLoadPolycentricProfile.run(video.author.id);
|
||||||
|
|
||||||
@@ -1652,7 +1695,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
|
|
||||||
_rating.visibility = View.GONE;
|
_rating.visibility = View.GONE;
|
||||||
|
|
||||||
if (StatePolycentric.instance.enabled) {
|
if (StatePolycentric.instance.enabled && !(video is LocalVideoDetails)) {
|
||||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val queryReferencesResponse = ApiMethods.getQueryReferences(
|
val queryReferencesResponse = ApiMethods.getQueryReferences(
|
||||||
@@ -1712,7 +1755,9 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
}
|
}
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Logger.e(TAG, "Failed to get polycentric likes/dislikes.", e);
|
Logger.e(TAG, "Failed to get polycentric likes/dislikes.", e);
|
||||||
_rating.visibility = View.GONE;
|
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||||
|
_rating.visibility = View.GONE;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1777,7 +1822,8 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
false,
|
false,
|
||||||
(toResume.toFloat() / 1000.0f).toLong(),
|
(toResume.toFloat() / 1000.0f).toLong(),
|
||||||
null,
|
null,
|
||||||
true
|
true,
|
||||||
|
StatePlayer.instance.playlistId
|
||||||
);
|
);
|
||||||
Logger.i(
|
Logger.i(
|
||||||
TAG,
|
TAG,
|
||||||
@@ -1789,6 +1835,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
StatePlayer.instance.startOrUpdateMediaSession(context, video);
|
StatePlayer.instance.startOrUpdateMediaSession(context, video);
|
||||||
|
Log.i(TAG, "setCurrentlyPlaying (nextVideo) ${video.url} (${video.name})")
|
||||||
StatePlayer.instance.setCurrentlyPlaying(video);
|
StatePlayer.instance.setCurrentlyPlaying(video);
|
||||||
|
|
||||||
_liveChat?.stop();
|
_liveChat?.stop();
|
||||||
@@ -1810,17 +1857,19 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
_player.updateNextPrevious();
|
_player.updateNextPrevious();
|
||||||
updateMoreButtons();
|
updateMoreButtons();
|
||||||
|
|
||||||
if (videoDetail is TutorialFragment.TutorialVideo) {
|
if (videoDetail is TutorialFragment.TutorialVideo || videoDetail is LocalVideoDetails) {
|
||||||
_buttonSubscribe.visibility = View.GONE
|
_buttonSubscribe.visibility = View.GONE
|
||||||
_buttonMore.visibility = View.GONE
|
_buttonMore.visibility = if(videoDetail is LocalVideoDetails) View.VISIBLE else View.GONE;
|
||||||
_buttonPins.visibility = View.GONE
|
_buttonPins.visibility = if(videoDetail is LocalVideoDetails) View.VISIBLE else View.GONE;
|
||||||
_layoutRating.visibility = View.GONE
|
_layoutRating.visibility = View.GONE
|
||||||
|
_rating.visibility = View.GONE;
|
||||||
_layoutChangeBottomSection.visibility = View.GONE
|
_layoutChangeBottomSection.visibility = View.GONE
|
||||||
} else {
|
} else {
|
||||||
_buttonSubscribe.visibility = View.VISIBLE
|
_buttonSubscribe.visibility = View.VISIBLE
|
||||||
_buttonMore.visibility = View.VISIBLE
|
_buttonMore.visibility = View.VISIBLE
|
||||||
_buttonPins.visibility = View.VISIBLE
|
_buttonPins.visibility = View.VISIBLE
|
||||||
_layoutRating.visibility = View.VISIBLE
|
_layoutRating.visibility = View.VISIBLE
|
||||||
|
_rating.visibility = View.VISIBLE;
|
||||||
_layoutChangeBottomSection.visibility = View.VISIBLE
|
_layoutChangeBottomSection.visibility = View.VISIBLE
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2002,7 +2051,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
} else {
|
} else {
|
||||||
val thumbnail = video.thumbnails.getHQThumbnail();
|
val thumbnail = video.thumbnails.getHQThumbnail();
|
||||||
if ((videoSource == null) && !thumbnail.isNullOrBlank()) // || _player.isAudioMode
|
if ((videoSource == null) && !thumbnail.isNullOrBlank()) // || _player.isAudioMode
|
||||||
Glide.with(context).asBitmap().load(thumbnail)
|
Glide.with(context).asBitmap().load(thumbnail).withMaxSizePx()
|
||||||
.into(object: CustomTarget<Bitmap>() {
|
.into(object: CustomTarget<Bitmap>() {
|
||||||
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
||||||
_player.setArtwork(BitmapDrawable(resources, resource));
|
_player.setArtwork(BitmapDrawable(resources, resource));
|
||||||
@@ -2287,6 +2336,8 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
checkAndRemoveWatchLater();
|
checkAndRemoveWatchLater();
|
||||||
|
|
||||||
var next = StatePlayer.instance.nextQueueItem(withoutRemoval || _player.duration < 100 || (_player.position.toFloat() / _player.duration) < 0.9, bypassVideoLoop);
|
var next = StatePlayer.instance.nextQueueItem(withoutRemoval || _player.duration < 100 || (_player.position.toFloat() / _player.duration) < 0.9, bypassVideoLoop);
|
||||||
|
Log.i(TAG, "next queue item ${next?.url} (${next?.name})")
|
||||||
|
|
||||||
val autoplayVideo = _autoplayVideo
|
val autoplayVideo = _autoplayVideo
|
||||||
if (next == null && autoplayVideo != null && StatePlayer.instance.autoplay) {
|
if (next == null && autoplayVideo != null && StatePlayer.instance.autoplay) {
|
||||||
Logger.i(TAG, "Found autoplay video!")
|
Logger.i(TAG, "Found autoplay video!")
|
||||||
@@ -2299,11 +2350,14 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
if(next == null && forceLoop)
|
if(next == null && forceLoop)
|
||||||
next = StatePlayer.instance.restartQueue();
|
next = StatePlayer.instance.restartQueue();
|
||||||
if(next != null) {
|
if(next != null) {
|
||||||
|
Logger.i(TAG, "Set video overview (next = ${next.url} (${next.name}))")
|
||||||
setVideoOverview(next, true, 0, true);
|
setVideoOverview(next, true, 0, true);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
else
|
else {
|
||||||
|
Log.i(TAG, "setCurrentlyPlaying (nextVideo) null")
|
||||||
StatePlayer.instance.setCurrentlyPlaying(null);
|
StatePlayer.instance.setCurrentlyPlaying(null);
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2684,7 +2738,11 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
private fun fetchComments() {
|
private fun fetchComments() {
|
||||||
Logger.i(TAG, "fetchComments")
|
Logger.i(TAG, "fetchComments")
|
||||||
video?.let {
|
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() {
|
private fun fetchPolycentricComments() {
|
||||||
@@ -2971,6 +3029,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onChannelClicked.subscribe {
|
onChannelClicked.subscribe {
|
||||||
|
Logger.i(TAG, "Opening channel url: ${it.url}");
|
||||||
if(it.url.isNotBlank()) {
|
if(it.url.isNotBlank()) {
|
||||||
fragment.minimizeVideoDetail()
|
fragment.minimizeVideoDetail()
|
||||||
fragment.navigate<ChannelFragment>(it)
|
fragment.navigate<ChannelFragment>(it)
|
||||||
@@ -3095,7 +3154,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
if (v !is TutorialFragment.TutorialVideo) {
|
if (v !is TutorialFragment.TutorialVideo) {
|
||||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||||
val history = getHistoryIndex(v) ?: return@launch;
|
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;
|
_lastPositionSaveTime = currentTime;
|
||||||
@@ -3300,9 +3359,11 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
false
|
false
|
||||||
else {
|
else {
|
||||||
isLoginStop = true;
|
isLoginStop = true;
|
||||||
|
onMinimize.emit();
|
||||||
StatePlugins.instance.loginPlugin(context, id) {
|
StatePlugins.instance.loginPlugin(context, id) {
|
||||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||||
fetchVideo();
|
fetchVideo();
|
||||||
|
onMaximize.emit(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
@@ -14,6 +14,7 @@ import android.widget.TextView
|
|||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.core.view.setPadding
|
import androidx.core.view.setPadding
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
|
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.UISlideOverlays
|
import com.futo.platformplayer.UISlideOverlays
|
||||||
@@ -28,6 +29,7 @@ import com.futo.platformplayer.toHumanDuration
|
|||||||
import com.futo.platformplayer.toHumanTime
|
import com.futo.platformplayer.toHumanTime
|
||||||
import com.futo.platformplayer.views.SearchView
|
import com.futo.platformplayer.views.SearchView
|
||||||
import com.futo.platformplayer.views.lists.VideoListEditorView
|
import com.futo.platformplayer.views.lists.VideoListEditorView
|
||||||
|
import com.futo.platformplayer.withMaxSizePx
|
||||||
|
|
||||||
abstract class VideoListEditorView : LinearLayout {
|
abstract class VideoListEditorView : LinearLayout {
|
||||||
private var _videoListEditorView: VideoListEditorView;
|
private var _videoListEditorView: VideoListEditorView;
|
||||||
@@ -211,6 +213,7 @@ abstract class VideoListEditorView : LinearLayout {
|
|||||||
_imagePlaylistThumbnail.let {
|
_imagePlaylistThumbnail.let {
|
||||||
Glide.with(it)
|
Glide.with(it)
|
||||||
.load(video.thumbnails.getHQThumbnail())
|
.load(video.thumbnails.getHQThumbnail())
|
||||||
|
.withMaxSizePx()
|
||||||
.placeholder(R.drawable.placeholder_video_thumbnail)
|
.placeholder(R.drawable.placeholder_video_thumbnail)
|
||||||
.crossfade()
|
.crossfade()
|
||||||
.into(it);
|
.into(it);
|
||||||
|
|||||||
+6
-1
@@ -1,5 +1,6 @@
|
|||||||
package com.futo.platformplayer.fragment.mainactivity.topbar
|
package com.futo.platformplayer.fragment.mainactivity.topbar
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
@@ -49,7 +50,11 @@ class GeneralTopBarFragment : TopFragment() {
|
|||||||
} else if (currentMain is PlaylistsFragment || currentMain is PlaylistFragment) {
|
} else if (currentMain is PlaylistsFragment || currentMain is PlaylistFragment) {
|
||||||
navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.PLAYLIST));
|
navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.PLAYLIST));
|
||||||
} else if (currentMain is LibraryFragment) {
|
} else if (currentMain is LibraryFragment) {
|
||||||
navigate<LibrarySearchFragment>();
|
if(Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
UIDialogs.toast("Your Android version is too old for Mediastore search", true);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
navigate<LibrarySearchFragment>();
|
||||||
} else {
|
} else {
|
||||||
navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.VIDEO));
|
navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.VIDEO));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import android.graphics.drawable.Drawable
|
|||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.RequestBuilder
|
import com.bumptech.glide.RequestBuilder
|
||||||
|
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||||
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions
|
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions
|
||||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||||
|
import com.futo.platformplayer.withMaxSizePx
|
||||||
|
|
||||||
class GlideHelper {
|
class GlideHelper {
|
||||||
|
|
||||||
@@ -14,7 +16,7 @@ class GlideHelper {
|
|||||||
fun ImageView.loadThumbnails(thumbnails: Thumbnails, isHQ: Boolean = true, continuation: ((RequestBuilder<Drawable>) -> Unit)? = null) {
|
fun ImageView.loadThumbnails(thumbnails: Thumbnails, isHQ: Boolean = true, continuation: ((RequestBuilder<Drawable>) -> Unit)? = null) {
|
||||||
val url = if(isHQ) thumbnails.getHQThumbnail() ?: thumbnails.getLQThumbnail() else thumbnails.getLQThumbnail();
|
val url = if(isHQ) thumbnails.getHQThumbnail() ?: thumbnails.getLQThumbnail() else thumbnails.getLQThumbnail();
|
||||||
|
|
||||||
val req = Glide.with(this).load(url);
|
val req = Glide.with(this).load(url).withMaxSizePx()
|
||||||
|
|
||||||
if (thumbnails.hasMultiple() && false) { //TODO: Resolve issue where fallback triggered on second loads?
|
if (thumbnails.hasMultiple() && false) { //TODO: Resolve issue where fallback triggered on second loads?
|
||||||
val fallbackUrl = if (isHQ) thumbnails.getLQThumbnail() else thumbnails.getHQThumbnail();
|
val fallbackUrl = if (isHQ) thumbnails.getLQThumbnail() else thumbnails.getHQThumbnail();
|
||||||
|
|||||||
@@ -14,15 +14,17 @@ import java.time.ZoneOffset
|
|||||||
class HistoryVideo {
|
class HistoryVideo {
|
||||||
var video: SerializedPlatformVideo;
|
var video: SerializedPlatformVideo;
|
||||||
var position: Long;
|
var position: Long;
|
||||||
|
var playlistId: String? = null
|
||||||
|
|
||||||
@kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class)
|
@kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class)
|
||||||
var date: OffsetDateTime;
|
var date: OffsetDateTime;
|
||||||
|
|
||||||
|
|
||||||
constructor(video: SerializedPlatformVideo, position: Long, date: OffsetDateTime) {
|
constructor(video: SerializedPlatformVideo, position: Long, date: OffsetDateTime, playlistId: String?) {
|
||||||
this.video = video;
|
this.video = video;
|
||||||
this.position = position;
|
this.position = position;
|
||||||
this.date = date;
|
this.date = date;
|
||||||
|
this.playlistId = playlistId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -59,7 +61,7 @@ class HistoryVideo {
|
|||||||
viewCount = -1
|
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 brand: String,
|
||||||
val manufacturer: String,
|
val manufacturer: String,
|
||||||
val model: 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 mediaRenditions = mutableListOf<MediaRendition>()
|
||||||
val sessionDataList = mutableListOf<SessionData>()
|
val sessionDataList = mutableListOf<SessionData>()
|
||||||
var independentSegments = false
|
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 {
|
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") -> {
|
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")
|
?: throw Exception("Expected URI following #EXT-X-STREAM-INF, found none")
|
||||||
val url = resolveUrl(baseUrl, nextLine)
|
val url = resolveUrl(baseUrl, nextLine)
|
||||||
|
|
||||||
variantPlaylists.add(VariantPlaylistReference(url, parseStreamInfo(line)))
|
variantPlaylists.add(VariantPlaylistReference(url, parseStreamInfo(line)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,10 +63,14 @@ class HLS {
|
|||||||
val sessionData = parseSessionData(line)
|
val sessionData = parseSessionData(line)
|
||||||
sessionDataList.add(sessionData)
|
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? {
|
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)
|
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 {
|
fun parseVariantPlaylist(content: String, sourceUrl: String): VariantPlaylist {
|
||||||
|
val baseUrl = URI(sourceUrl).resolve("./").toString()
|
||||||
val lines = content.lines()
|
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 =
|
var version: Int? = null
|
||||||
lines.find { it.startsWith("#EXT-X-KEY:") }?.substringAfter(":")?.split(",")
|
var targetDuration: Int? = null
|
||||||
|
var mediaSequence: Long? = null
|
||||||
val key = keyInfo?.find { it.startsWith("URI=") }?.substringAfter("=")?.trim('"')
|
var discontinuitySequence: Int? = null
|
||||||
val iv =
|
var programDateTime: ZonedDateTime? = null
|
||||||
keyInfo?.find { it.startsWith("IV=") }?.substringAfter("=")?.substringAfter("x")
|
var playlistType: String? = null
|
||||||
|
var streamInfo: StreamInfo? = null
|
||||||
val decryptionInfo: DecryptionInfo? = key?.let { k ->
|
var decryptionInfo: DecryptionInfo? = null
|
||||||
DecryptionInfo(k, iv)
|
var mapUrl: String? = null
|
||||||
}
|
var mapBytesStart: Long = -1
|
||||||
|
var mapBytesLength: Long = -1
|
||||||
val initSegment =
|
|
||||||
lines.find { it.startsWith("#EXT-X-MAP:") }?.substringAfter(":")?.split(",")?.get(0)
|
|
||||||
?.substringAfter("=")?.trim('"')
|
|
||||||
val segments = mutableListOf<Segment>()
|
val segments = mutableListOf<Segment>()
|
||||||
if (initSegment != null) {
|
val unhandled = mutableListOf<String>()
|
||||||
segments.add(MediaSegment(0.0, resolveUrl(sourceUrl, initSegment)))
|
|
||||||
}
|
|
||||||
|
|
||||||
var currentSegment: MediaSegment? = null
|
var currentSegment: MediaSegment? = null
|
||||||
lines.forEach { line ->
|
|
||||||
|
for (rawLine in lines) {
|
||||||
|
val line = rawLine.trim()
|
||||||
|
if (line.isEmpty()) continue
|
||||||
|
|
||||||
when {
|
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:") -> {
|
line.startsWith("#EXTINF:") -> {
|
||||||
val duration = line.substringAfter(":").substringBefore(",").toDoubleOrNull()
|
val durationText = line.substringAfter(":").substringBefore(",")
|
||||||
?: throw Exception("Invalid segment duration format")
|
val duration = durationText.toDoubleOrNull()
|
||||||
|
?: throw IllegalArgumentException("Invalid segment duration: '$line'")
|
||||||
currentSegment = MediaSegment(duration = duration)
|
currentSegment = MediaSegment(duration = duration)
|
||||||
}
|
}
|
||||||
|
|
||||||
line == "#EXT-X-DISCONTINUITY" -> {
|
line == "#EXT-X-DISCONTINUITY" -> {
|
||||||
segments.add(DiscontinuitySegment())
|
segments.add(DiscontinuitySegment())
|
||||||
}
|
}
|
||||||
line =="#EXT-X-ENDLIST" -> {
|
|
||||||
|
line == "#EXT-X-ENDLIST" -> {
|
||||||
segments.add(EndListSegment())
|
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 {
|
currentSegment?.let {
|
||||||
it.uri = resolveUrl(sourceUrl, line)
|
it.uri = resolveUrl(baseUrl, line)
|
||||||
segments.add(it)
|
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> {
|
fun parseAndGetVideoSources(source: Any, content: String, url: String): List<HLSVariantVideoUrlSource> {
|
||||||
@@ -232,26 +374,6 @@ class HLS {
|
|||||||
return SessionData(dataId, value)
|
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 val _quoteList = listOf("GROUP-ID", "NAME", "URI", "CODECS", "AUDIO", "VIDEO")
|
||||||
private fun shouldQuote(key: String, value: String?): Boolean {
|
private fun shouldQuote(key: String, value: String?): Boolean {
|
||||||
if (value == null)
|
if (value == null)
|
||||||
@@ -345,11 +467,22 @@ class HLS {
|
|||||||
val variantPlaylistsRefs: List<VariantPlaylistReference>,
|
val variantPlaylistsRefs: List<VariantPlaylistReference>,
|
||||||
val mediaRenditions: List<MediaRendition>,
|
val mediaRenditions: List<MediaRendition>,
|
||||||
val sessionDataList: List<SessionData>,
|
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 {
|
fun buildM3U8(): String {
|
||||||
val builder = StringBuilder()
|
val builder = StringBuilder()
|
||||||
builder.append("#EXTM3U\n")
|
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) {
|
if (independentSegments) {
|
||||||
builder.append("#EXT-X-INDEPENDENT-SEGMENTS\n")
|
builder.append("#EXT-X-INDEPENDENT-SEGMENTS\n")
|
||||||
}
|
}
|
||||||
@@ -404,9 +537,15 @@ class HLS {
|
|||||||
}
|
}
|
||||||
|
|
||||||
data class DecryptionInfo(
|
data class DecryptionInfo(
|
||||||
val keyUrl: String,
|
val method: String,
|
||||||
val iv: 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(
|
data class VariantPlaylist(
|
||||||
val version: Int?,
|
val version: Int?,
|
||||||
@@ -417,7 +556,11 @@ class HLS {
|
|||||||
val playlistType: String?,
|
val playlistType: String?,
|
||||||
val streamInfo: StreamInfo?,
|
val streamInfo: StreamInfo?,
|
||||||
val segments: List<Segment>,
|
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 {
|
fun buildM3U8(): String = buildString {
|
||||||
append("#EXTM3U\n")
|
append("#EXTM3U\n")
|
||||||
@@ -426,9 +569,50 @@ class HLS {
|
|||||||
mediaSequence?.let { append("#EXT-X-MEDIA-SEQUENCE:$it\n") }
|
mediaSequence?.let { append("#EXT-X-MEDIA-SEQUENCE:$it\n") }
|
||||||
discontinuitySequence?.let { append("#EXT-X-DISCONTINUITY-SEQUENCE:$it\n") }
|
discontinuitySequence?.let { append("#EXT-X-DISCONTINUITY-SEQUENCE:$it\n") }
|
||||||
playlistType?.let { append("#EXT-X-PLAYLIST-TYPE:$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()) }
|
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 ->
|
segments.forEach { segment ->
|
||||||
append(segment.toM3U8Line())
|
append(segment.toM3U8Line())
|
||||||
}
|
}
|
||||||
@@ -439,13 +623,25 @@ class HLS {
|
|||||||
abstract fun toM3U8Line(): String
|
abstract fun toM3U8Line(): String
|
||||||
}
|
}
|
||||||
|
|
||||||
data class MediaSegment (
|
data class MediaSegment(
|
||||||
val duration: Double,
|
val duration: Double,
|
||||||
var uri: String = ""
|
var uri: String = "",
|
||||||
|
var bytesStart: Long = -1,
|
||||||
|
var bytesLength: Long = -1,
|
||||||
|
val unhandled: MutableList<String> = mutableListOf()
|
||||||
) : Segment() {
|
) : Segment() {
|
||||||
override fun toM3U8Line(): String = buildString {
|
override fun toM3U8Line(): String = buildString {
|
||||||
append("#EXTINF:${duration},\n")
|
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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.futo.platformplayer.receivers
|
package com.futo.platformplayer.receivers
|
||||||
|
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
@@ -26,14 +27,24 @@ class InstallReceiver : BroadcastReceiver() {
|
|||||||
val activityIntent: Intent? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
val activityIntent: Intent? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
|
intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
|
||||||
} else {
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
intent.getParcelableExtra(Intent.EXTRA_INTENT)
|
intent.getParcelableExtra(Intent.EXTRA_INTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activityIntent == null) {
|
if (activityIntent == null) {
|
||||||
Logger.w(TAG, "Received STATUS_PENDING_USER_ACTION and activity intent is null.")
|
Logger.w(TAG, "Received STATUS_PENDING_USER_ACTION and activity intent is null.")
|
||||||
|
onReceiveResult.emit(context.getString(R.string.install_failed_device_installer_broken))
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
context.startActivity(activityIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
|
||||||
|
activityIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
|
||||||
|
try {
|
||||||
|
context.startActivity(activityIntent)
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
Logger.e(TAG, "System installer cannot handle CONFIRM_INSTALL intent. ROM is broken; falling back / reporting error.", e)
|
||||||
|
onReceiveResult.emit(context.getString(R.string.install_failed_device_installer_broken))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
PackageInstaller.STATUS_SUCCESS -> onReceiveResult.emit(null);
|
PackageInstaller.STATUS_SUCCESS -> onReceiveResult.emit(null);
|
||||||
PackageInstaller.STATUS_FAILURE -> onReceiveResult.emit(context.getString(R.string.general_failure));
|
PackageInstaller.STATUS_FAILURE -> onReceiveResult.emit(context.getString(R.string.general_failure));
|
||||||
@@ -45,6 +56,7 @@ class InstallReceiver : BroadcastReceiver() {
|
|||||||
PackageInstaller.STATUS_FAILURE_STORAGE -> onReceiveResult.emit(context.getString(R.string.not_enough_storage));
|
PackageInstaller.STATUS_FAILURE_STORAGE -> onReceiveResult.emit(context.getString(R.string.not_enough_storage));
|
||||||
else -> {
|
else -> {
|
||||||
val msg = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE);
|
val msg = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE);
|
||||||
|
Logger.w(TAG, "Received unknown install status $status, message=$msg")
|
||||||
onReceiveResult.emit(msg)
|
onReceiveResult.emit(msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import android.util.Log
|
|||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
|
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||||
import com.bumptech.glide.request.target.CustomTarget
|
import com.bumptech.glide.request.target.CustomTarget
|
||||||
import com.bumptech.glide.request.transition.Transition
|
import com.bumptech.glide.request.transition.Transition
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
@@ -38,6 +39,7 @@ import com.futo.platformplayer.receivers.MediaControlReceiver
|
|||||||
import com.futo.platformplayer.states.StatePlatform
|
import com.futo.platformplayer.states.StatePlatform
|
||||||
import com.futo.platformplayer.states.StatePlayer
|
import com.futo.platformplayer.states.StatePlayer
|
||||||
import com.futo.platformplayer.stores.FragmentedStorage
|
import com.futo.platformplayer.stores.FragmentedStorage
|
||||||
|
import com.futo.platformplayer.withMaxSizePx
|
||||||
|
|
||||||
class MediaPlaybackService : Service() {
|
class MediaPlaybackService : Service() {
|
||||||
private val TAG = "MediaPlaybackService";
|
private val TAG = "MediaPlaybackService";
|
||||||
@@ -172,21 +174,26 @@ class MediaPlaybackService : Service() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun closeMediaSession() {
|
fun closeMediaSession() {
|
||||||
Logger.v(TAG, "closeMediaSession");
|
Logger.v(TAG, "closeMediaSession")
|
||||||
stopForeground(STOP_FOREGROUND_REMOVE);
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
|
||||||
abandonAudioFocus()
|
abandonAudioFocus()
|
||||||
|
|
||||||
val notifManager = _notificationManager;
|
val notifManager = _notificationManager
|
||||||
Logger.i(TAG, "Cancelling playback notification (notifManager: ${notifManager != null})");
|
Logger.i(TAG, "Cancelling playback notification (notifManager: ${notifManager != null})")
|
||||||
notifManager?.cancel(MEDIA_NOTIF_ID);
|
notifManager?.cancel(MEDIA_NOTIF_ID)
|
||||||
_notif_last_video = null;
|
|
||||||
_notif_last_bitmap = null;
|
|
||||||
_mediaSession = null;
|
|
||||||
|
|
||||||
if(_instance == this)
|
_notif_last_video = null
|
||||||
_instance = null;
|
_notif_last_bitmap = null
|
||||||
this.stopSelf();
|
|
||||||
|
_mediaSession?.isActive = false
|
||||||
|
_mediaSession?.release()
|
||||||
|
_mediaSession = null
|
||||||
|
|
||||||
|
if (_instance == this)
|
||||||
|
_instance = null
|
||||||
|
|
||||||
|
stopSelf()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateMediaSession(videoUpdated: IPlatformVideo?) {
|
fun updateMediaSession(videoUpdated: IPlatformVideo?) {
|
||||||
@@ -206,37 +213,37 @@ class MediaPlaybackService : Service() {
|
|||||||
if(_notificationChannel == null || _mediaSession == null)
|
if(_notificationChannel == null || _mediaSession == null)
|
||||||
setupNotificationRequirements();
|
setupNotificationRequirements();
|
||||||
|
|
||||||
_mediaSession?.setMetadata(
|
updateMediaMetadata(video, lastBitmap)
|
||||||
MediaMetadataCompat.Builder()
|
|
||||||
.putString(MediaMetadata.METADATA_KEY_ARTIST, video.author.name)
|
|
||||||
.putString(MediaMetadata.METADATA_KEY_TITLE, video.name)
|
|
||||||
.putLong(MediaMetadata.METADATA_KEY_DURATION, video.duration * 1000)
|
|
||||||
.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, lastBitmap)
|
|
||||||
.build());
|
|
||||||
|
|
||||||
val thumbnail = video.thumbnails.getHQThumbnail();
|
val thumbnail = video.thumbnails.getHQThumbnail();
|
||||||
|
|
||||||
_notif_last_video = video;
|
_notif_last_video = video;
|
||||||
|
|
||||||
if(isUpdating)
|
if(isUpdating)
|
||||||
notifyMediaSession(video, _notif_last_bitmap);
|
notifyMediaSession(video, _notif_last_bitmap?.takeIf { !it.isRecycled });
|
||||||
else if(thumbnail != null) {
|
else if(thumbnail != null) {
|
||||||
notifyMediaSession(video, null);
|
notifyMediaSession(video, null);
|
||||||
val tag = video;
|
val tag = video;
|
||||||
Glide.with(this).asBitmap()
|
Glide.with(this).asBitmap()
|
||||||
.load(thumbnail)
|
.load(thumbnail)
|
||||||
|
.withMaxSizePx()
|
||||||
.into(object: CustomTarget<Bitmap>() {
|
.into(object: CustomTarget<Bitmap>() {
|
||||||
override fun onResourceReady(resource: Bitmap,transition: Transition<in Bitmap>?) {
|
override fun onResourceReady(resource: Bitmap,transition: Transition<in Bitmap>?) {
|
||||||
if(tag == _notif_last_video) {
|
if (tag != _notif_last_video) return
|
||||||
notifyMediaSession(video, resource)
|
if (resource.isRecycled) {
|
||||||
_mediaSession?.setMetadata(
|
notifyMediaSession(video, null)
|
||||||
MediaMetadataCompat.Builder()
|
return
|
||||||
.putString(MediaMetadata.METADATA_KEY_ARTIST, video.author.name)
|
|
||||||
.putString(MediaMetadata.METADATA_KEY_TITLE, video.name)
|
|
||||||
.putLong(MediaMetadata.METADATA_KEY_DURATION, video.duration * 1000)
|
|
||||||
.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, resource)
|
|
||||||
.build());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val albumArt = resource.copy(
|
||||||
|
resource.config ?: Bitmap.Config.ARGB_8888,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
|
||||||
|
_notif_last_bitmap = albumArt
|
||||||
|
|
||||||
|
notifyMediaSession(video, albumArt)
|
||||||
|
updateMediaMetadata(video, albumArt)
|
||||||
}
|
}
|
||||||
override fun onLoadCleared(placeholder: Drawable?) {
|
override fun onLoadCleared(placeholder: Drawable?) {
|
||||||
if(tag == _notif_last_video)
|
if(tag == _notif_last_video)
|
||||||
@@ -247,6 +254,19 @@ class MediaPlaybackService : Service() {
|
|||||||
else
|
else
|
||||||
notifyMediaSession(video, null);
|
notifyMediaSession(video, null);
|
||||||
}
|
}
|
||||||
|
private fun updateMediaMetadata(video: IPlatformVideo, bitmap: Bitmap?) {
|
||||||
|
val builder = MediaMetadataCompat.Builder()
|
||||||
|
.putString(MediaMetadata.METADATA_KEY_ARTIST, video.author.name)
|
||||||
|
.putString(MediaMetadata.METADATA_KEY_TITLE, video.name)
|
||||||
|
.putLong(MediaMetadata.METADATA_KEY_DURATION, video.duration * 1000)
|
||||||
|
|
||||||
|
val safeBitmap = bitmap?.takeIf { !it.isRecycled }
|
||||||
|
if (safeBitmap != null) {
|
||||||
|
builder.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, safeBitmap)
|
||||||
|
}
|
||||||
|
|
||||||
|
_mediaSession?.setMetadata(builder.build())
|
||||||
|
}
|
||||||
private fun generateMediaAction(icon: Int, title: String, intent: PendingIntent) : NotificationCompat.Action {
|
private fun generateMediaAction(icon: Int, title: String, intent: PendingIntent) : NotificationCompat.Action {
|
||||||
return NotificationCompat.Action.Builder(icon, title, intent).build();
|
return NotificationCompat.Action.Builder(icon, title, intent).build();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,20 @@ class StateApp {
|
|||||||
|
|
||||||
val sessionId = UUID.randomUUID().toString();
|
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
|
var privateMode: Boolean = false
|
||||||
get(){
|
get(){
|
||||||
return field;
|
return field;
|
||||||
@@ -422,9 +436,9 @@ class StateApp {
|
|||||||
try {
|
try {
|
||||||
val caFile = AppCaUpdater.ensureCaBundle(context)
|
val caFile = AppCaUpdater.ensureCaBundle(context)
|
||||||
Libcurl.setDefaultCAPath(caFile.absolutePath)
|
Libcurl.setDefaultCAPath(caFile.absolutePath)
|
||||||
|
Logger.i(TAG, "Libcurl initialized")
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
val fallback = File(context.noBackupFilesDir, "curl-ca-bundle.pem")
|
Logger.e(TAG, "Failed to initialize Libcurl", t);
|
||||||
if (fallback.exists()) Libcurl.setDefaultCAPath(fallback.absolutePath)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -558,30 +572,39 @@ class StateApp {
|
|||||||
DownloadService.getOrCreateService(context);
|
DownloadService.getOrCreateService(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.i(TAG, "MainApp Started: Initialize [AutoUpdate]");
|
if (Settings.instance.autoUpdate.isAutoUpdateEnabled()) {
|
||||||
val autoUpdateEnabled = Settings.instance.autoUpdate.isAutoUpdateEnabled();
|
if (Settings.instance.autoUpdate.backgroundDownload == 1) {
|
||||||
val shouldDownload = Settings.instance.autoUpdate.shouldDownload();
|
Logger.i(TAG, "MainApp Started: Initialize [AutoUpdate Background]");
|
||||||
val backgroundDownload = Settings.instance.autoUpdate.backgroundDownload == 1;
|
val constraints = Constraints.Builder()
|
||||||
when {
|
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||||
//Background download
|
.build();
|
||||||
autoUpdateEnabled && shouldDownload && backgroundDownload -> {
|
|
||||||
StateUpdate.instance.setShouldBackgroundUpdate(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
autoUpdateEnabled && !shouldDownload && backgroundDownload -> {
|
val periodicRequest = PeriodicWorkRequest.Builder(
|
||||||
Logger.i(TAG, "Auto update skipped due to wrong network state");
|
UpdateCheckWorker::class.java,
|
||||||
}
|
12, TimeUnit.HOURS
|
||||||
|
)
|
||||||
|
.setConstraints(constraints)
|
||||||
|
.build();
|
||||||
|
|
||||||
//Foreground download
|
val wm = WorkManager.getInstance(context);
|
||||||
autoUpdateEnabled -> {
|
wm.enqueueUniquePeriodicWork(
|
||||||
|
UpdateCheckWorker.UNIQUE_WORK_NAME,
|
||||||
|
ExistingPeriodicWorkPolicy.UPDATE,
|
||||||
|
periodicRequest
|
||||||
|
);
|
||||||
|
|
||||||
|
val oneTimeRequest = OneTimeWorkRequest.Builder(UpdateCheckWorker::class.java)
|
||||||
|
.setConstraints(constraints)
|
||||||
|
.build();
|
||||||
|
wm.enqueue(oneTimeRequest);
|
||||||
|
} else {
|
||||||
|
Logger.i(TAG, "MainApp Started: Initialize [AutoUpdate]");
|
||||||
scopeOrNull?.launch(Dispatchers.IO) {
|
scopeOrNull?.launch(Dispatchers.IO) {
|
||||||
StateUpdate.instance.checkForUpdates(context, false)
|
StateUpdate.instance.checkForUpdates(context, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
else -> {
|
Logger.i(TAG, "AutoUpdate disabled");
|
||||||
Logger.i(TAG, "Auto update disabled");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.i(TAG, "MainApp Started: Initialize [Noisy]");
|
Logger.i(TAG, "MainApp Started: Initialize [Noisy]");
|
||||||
@@ -767,24 +790,20 @@ class StateApp {
|
|||||||
Logger.i("StateApp", "No AutoBackup configured");
|
Logger.i("StateApp", "No AutoBackup configured");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fun scheduleBackgroundWork(context: Context, active: Boolean = true, intervalMinutes: Int = 60 * 12) {
|
fun scheduleBackgroundWork(context: Context, active: Boolean = true, intervalMinutes: Int = 60 * 12) {
|
||||||
try {
|
try {
|
||||||
val wm = WorkManager.getInstance(context);
|
val wm = WorkManager.getInstance(context);
|
||||||
|
|
||||||
if(active) {
|
if (active) {
|
||||||
if(BuildConfig.DEBUG)
|
if (BuildConfig.DEBUG)
|
||||||
UIDialogs.toast(context, "Scheduling background every ${intervalMinutes} minutes");
|
UIDialogs.toast(context, "Scheduling background every ${intervalMinutes} minutes");
|
||||||
|
|
||||||
val req = PeriodicWorkRequest.Builder(BackgroundWorker::class.java, intervalMinutes.toLong(), TimeUnit.MINUTES, 5, TimeUnit.MINUTES)
|
val req = PeriodicWorkRequest.Builder(BackgroundWorker::class.java, intervalMinutes.toLong(), TimeUnit.MINUTES, 5, TimeUnit.MINUTES)
|
||||||
.setConstraints(Constraints.Builder()
|
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.UNMETERED).build()).build();
|
||||||
.setRequiredNetworkType(NetworkType.UNMETERED)
|
|
||||||
.build())
|
|
||||||
.build();
|
|
||||||
wm.enqueueUniquePeriodicWork("backgroundSubscriptions", ExistingPeriodicWorkPolicy.UPDATE, req);
|
wm.enqueueUniquePeriodicWork("backgroundSubscriptions", ExistingPeriodicWorkPolicy.UPDATE, req);
|
||||||
|
} else {
|
||||||
|
wm.cancelUniqueWork("backgroundSubscriptions");
|
||||||
}
|
}
|
||||||
else
|
|
||||||
wm.cancelAllWork();
|
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Logger.e(TAG, "Failed to schedule background subscription updates.", e)
|
Logger.e(TAG, "Failed to schedule background subscription updates.", e)
|
||||||
UIDialogs.toast(context, "Background subscription update failed: " + e.message)
|
UIDialogs.toast(context, "Background subscription update failed: " + e.message)
|
||||||
@@ -792,6 +811,7 @@ class StateApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private suspend fun migrateStores(context: Context, managedStores: List<ManagedStore<*>>, index: Int) {
|
private suspend fun migrateStores(context: Context, managedStores: List<ManagedStore<*>>, index: Int) {
|
||||||
if(managedStores.size <= index)
|
if(managedStores.size <= index)
|
||||||
return;
|
return;
|
||||||
@@ -889,15 +909,6 @@ class StateApp {
|
|||||||
try {
|
try {
|
||||||
if(FragmentedStorage.isInitialized && Settings.instance.downloads.shouldDownload())
|
if(FragmentedStorage.isInitialized && Settings.instance.downloads.shouldDownload())
|
||||||
StateDownloads.instance.checkForDownloadsTodos();
|
StateDownloads.instance.checkForDownloadsTodos();
|
||||||
|
|
||||||
val autoUpdateEnabled = Settings.instance.autoUpdate.isAutoUpdateEnabled();
|
|
||||||
val shouldDownload = Settings.instance.autoUpdate.shouldDownload();
|
|
||||||
val backgroundDownload = Settings.instance.autoUpdate.backgroundDownload == 1;
|
|
||||||
if (autoUpdateEnabled && shouldDownload && backgroundDownload) {
|
|
||||||
StateUpdate.instance.setShouldBackgroundUpdate(true);
|
|
||||||
} else {
|
|
||||||
StateUpdate.instance.setShouldBackgroundUpdate(false);
|
|
||||||
}
|
|
||||||
} catch(ex: Throwable) {
|
} catch(ex: Throwable) {
|
||||||
Logger.w(TAG, "Failed to handle capabilities changed event", ex);
|
Logger.w(TAG, "Failed to handle capabilities changed event", ex);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class StateAssets {
|
|||||||
if(part == "." || part == "..") {
|
if(part == "." || part == "..") {
|
||||||
if(parentAllowance <= 0)
|
if(parentAllowance <= 0)
|
||||||
throw IllegalStateException("Path [${path}] attempted to escape path..");
|
throw IllegalStateException("Path [${path}] attempted to escape path..");
|
||||||
parts1.removeLast();
|
parts1.removeAt(parts1.size - 1);
|
||||||
toSkip++;
|
toSkip++;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -365,7 +365,7 @@ class StateBackup {
|
|||||||
}
|
}
|
||||||
val hist = StateHistory.instance.getHistoryByVideo(histObj.video, true, histObj.date);
|
val hist = StateHistory.instance.getHistoryByVideo(histObj.video, true, histObj.date);
|
||||||
if(hist != null)
|
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) {
|
catch(ex: Throwable) {
|
||||||
Logger.e(TAG, "Failed to import subscription group", ex);
|
Logger.e(TAG, "Failed to import subscription group", ex);
|
||||||
|
|||||||
@@ -543,7 +543,9 @@ class StateDownloads {
|
|||||||
val file = export.export(context, { progress ->
|
val file = export.export(context, { progress ->
|
||||||
val now = System.currentTimeMillis();
|
val now = System.currentTimeMillis();
|
||||||
if (lastNotifyTime == -1L || now - lastNotifyTime > 100) {
|
if (lastNotifyTime == -1L || now - lastNotifyTime > 100) {
|
||||||
it.setProgress(progress);
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||||
|
it.setProgress(progress);
|
||||||
|
}
|
||||||
lastNotifyTime = now;
|
lastNotifyTime = now;
|
||||||
}
|
}
|
||||||
}, null);
|
}, null);
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ class StateHistory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var _lastHistoryBroadcast = "";
|
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 pos = if(position < 0) 0 else position;
|
||||||
val historyVideo = index.obj;
|
val historyVideo = index.obj;
|
||||||
|
|
||||||
@@ -86,6 +86,7 @@ class StateHistory {
|
|||||||
|
|
||||||
historyVideo.position = pos;
|
historyVideo.position = pos;
|
||||||
historyVideo.date = date ?: OffsetDateTime.now();
|
historyVideo.date = date ?: OffsetDateTime.now();
|
||||||
|
historyVideo.playlistId = playlistId
|
||||||
_historyDBStore.update(index.id!!, historyVideo);
|
_historyDBStore.update(index.id!!, historyVideo);
|
||||||
onHistoricVideoChanged.emit(liveObj, pos);
|
onHistoricVideoChanged.emit(liveObj, pos);
|
||||||
|
|
||||||
@@ -157,7 +158,7 @@ class StateHistory {
|
|||||||
UIDialogs.toast("History item null?\nNo history tracking..");
|
UIDialogs.toast("History item null?\nNo history tracking..");
|
||||||
}
|
}
|
||||||
else if(create) {
|
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);
|
val id = _historyDBStore.insert(newHistItem);
|
||||||
result = _historyDBStore.getOrNull(id);
|
result = _historyDBStore.getOrNull(id);
|
||||||
if(result == null)
|
if(result == null)
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
package com.futo.platformplayer.states
|
package com.futo.platformplayer.states
|
||||||
|
|
||||||
|
import android.content.ContentResolver
|
||||||
import android.content.ContentUris
|
import android.content.ContentUris
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.provider.MediaStore.Audio.Artists
|
import android.provider.MediaStore.Audio.Artists
|
||||||
import android.webkit.MimeTypeMap
|
import android.webkit.MimeTypeMap
|
||||||
|
import androidx.collection.emptyLongSet
|
||||||
import androidx.core.database.getStringOrNull
|
import androidx.core.database.getStringOrNull
|
||||||
import androidx.core.net.toFile
|
import androidx.core.net.toFile
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
@@ -35,6 +39,8 @@ import java.io.File
|
|||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.ConcurrentMap
|
||||||
|
|
||||||
|
|
||||||
class StateLibrary {
|
class StateLibrary {
|
||||||
@@ -102,13 +108,15 @@ class StateLibrary {
|
|||||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, StateLibrary.PROJECTION_MEDIA,
|
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, StateLibrary.PROJECTION_MEDIA,
|
||||||
"LOWER(" + MediaStore.Audio.Media.DISPLAY_NAME + ") LIKE ? ", arrayOf("%" + str.trim().lowercase() + "%"),
|
"LOWER(" + MediaStore.Audio.Media.DISPLAY_NAME + ") LIKE ? ", arrayOf("%" + str.trim().lowercase() + "%"),
|
||||||
null) ?: return listOf();
|
null) ?: return listOf();
|
||||||
cursor.moveToFirst();
|
return cursor.use {
|
||||||
val list = mutableListOf<IPlatformVideo>()
|
cursor.moveToFirst();
|
||||||
while(!cursor.isAfterLast) {
|
val list = mutableListOf<IPlatformVideo>()
|
||||||
list.add(StateLibrary.audioFromCursor(cursor));
|
while(!cursor.isAfterLast) {
|
||||||
cursor.moveToNext();
|
list.add(StateLibrary.audioFromCursor(cursor));
|
||||||
|
cursor.moveToNext();
|
||||||
|
}
|
||||||
|
return@use list;
|
||||||
}
|
}
|
||||||
return list;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getAlbums(): List<Album> {
|
fun getAlbums(): List<Album> {
|
||||||
@@ -148,29 +156,101 @@ class StateLibrary {
|
|||||||
fun getArtist(id: Long): Artist? {
|
fun getArtist(id: Long): Artist? {
|
||||||
return Artist.getArtist(id);
|
return Artist.getArtist(id);
|
||||||
}
|
}
|
||||||
|
fun getVideos(
|
||||||
|
buckets: List<String>? = null,
|
||||||
|
pageSize: Int = 20
|
||||||
|
): IPager<IPlatformContent> {
|
||||||
|
val resolver = StateApp.instance.contextOrNull?.contentResolver ?: return EmptyPager()
|
||||||
|
val selection: String?
|
||||||
|
val selectionArgs: Array<String>?
|
||||||
|
|
||||||
fun getVideos(buckets: List<String>? = null): IPager<IPlatformContent> {
|
if (!buckets.isNullOrEmpty()) {
|
||||||
var query = if(buckets != null) "${MediaStore.Video.Media.BUCKET_DISPLAY_NAME} IN " + "(" + buckets.map { "'${it}'" }.joinToString(",") + ")" else null;
|
val placeholders = buckets.joinToString(",") { "?" }
|
||||||
val cursor = StateApp.instance.contextOrNull?.contentResolver?.query(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, PROJECTION_VIDEO,
|
selection = "${MediaStore.Video.Media.BUCKET_DISPLAY_NAME} IN ($placeholders)"
|
||||||
query,
|
selectionArgs = buckets.toTypedArray()
|
||||||
null,
|
} else {
|
||||||
MediaStore.Video.Media.DATE_ADDED + " DESC") ?: return EmptyPager();
|
selection = null
|
||||||
cursor.moveToFirst();
|
selectionArgs = null
|
||||||
val list = mutableListOf<IPlatformVideo>()
|
|
||||||
while(!cursor.isAfterLast && list.size < 10) {
|
|
||||||
list.add(videoFromCursor(cursor));
|
|
||||||
cursor.moveToNext();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return AdhocPager<IPlatformContent>({
|
val collectionUri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
val list = mutableListOf<IPlatformContent>()
|
MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)
|
||||||
while(!cursor.isAfterLast && list.size < 10) {
|
} else {
|
||||||
list.add(videoFromCursor(cursor));
|
MediaStore.Video.Media.EXTERNAL_CONTENT_URI
|
||||||
cursor.moveToNext();
|
}
|
||||||
|
|
||||||
|
var nextPageIndex = 0
|
||||||
|
fun loadPage(pageIndex: Int): List<IPlatformContent> {
|
||||||
|
Logger.i(TAG, "loadPage $pageIndex")
|
||||||
|
val offset = pageIndex * pageSize
|
||||||
|
|
||||||
|
val queryArgs = Bundle().apply {
|
||||||
|
selection?.let {
|
||||||
|
putString(ContentResolver.QUERY_ARG_SQL_SELECTION, it)
|
||||||
|
}
|
||||||
|
selectionArgs?.let {
|
||||||
|
putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, it)
|
||||||
|
}
|
||||||
|
|
||||||
|
putStringArray(
|
||||||
|
ContentResolver.QUERY_ARG_SORT_COLUMNS,
|
||||||
|
arrayOf(
|
||||||
|
MediaStore.Video.Media.DATE_ADDED,
|
||||||
|
MediaStore.Video.Media._ID
|
||||||
|
)
|
||||||
|
)
|
||||||
|
putInt(
|
||||||
|
ContentResolver.QUERY_ARG_SORT_DIRECTION,
|
||||||
|
ContentResolver.QUERY_SORT_DIRECTION_DESCENDING
|
||||||
|
)
|
||||||
|
|
||||||
|
putInt(ContentResolver.QUERY_ARG_LIMIT, pageSize)
|
||||||
|
putInt(ContentResolver.QUERY_ARG_OFFSET, offset)
|
||||||
}
|
}
|
||||||
return@AdhocPager list;
|
|
||||||
}, list);
|
val cursor = resolver.query(
|
||||||
|
collectionUri,
|
||||||
|
PROJECTION_VIDEO,
|
||||||
|
queryArgs,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
|
||||||
|
if (cursor == null) {
|
||||||
|
Logger.i(TAG, "loadPage $pageIndex null, returning empty list")
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor.use { c ->
|
||||||
|
if (!c.moveToFirst()) {
|
||||||
|
Logger.i(TAG, "loadPage $pageIndex moveToFirst failed, returning empty list")
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
val list = ArrayList<IPlatformContent>(pageSize)
|
||||||
|
do {
|
||||||
|
list.add(videoFromCursor(c))
|
||||||
|
} while (c.moveToNext() && list.size < pageSize)
|
||||||
|
|
||||||
|
Logger.i(TAG, "loadPage $pageIndex found ${list.size} items")
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val firstPage = loadPage(0)
|
||||||
|
if (firstPage.isEmpty()) {
|
||||||
|
return EmptyPager()
|
||||||
|
}
|
||||||
|
nextPageIndex = 1
|
||||||
|
|
||||||
|
return AdhocPager<IPlatformContent>({
|
||||||
|
val page = loadPage(nextPageIndex)
|
||||||
|
nextPageIndex++
|
||||||
|
|
||||||
|
Logger.i(TAG, "loadPage nextPage: ${page.size}")
|
||||||
|
page
|
||||||
|
}, firstPage)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getRecentVideos(buckets: List<String>? = null, count: Int = 20): List<IPlatformVideo> {
|
fun getRecentVideos(buckets: List<String>? = null, count: Int = 20): List<IPlatformVideo> {
|
||||||
val videoPager = getVideos(buckets);
|
val videoPager = getVideos(buckets);
|
||||||
val items = mutableListOf<IPlatformVideo>();
|
val items = mutableListOf<IPlatformVideo>();
|
||||||
@@ -182,64 +262,101 @@ class StateLibrary {
|
|||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
private var _cacheBucketNames: List<Bucket>? = null;
|
@Volatile
|
||||||
fun getVideoBucketNames(): List<Bucket> {
|
private var _cachedVideoBuckets: List<Bucket>? = null
|
||||||
if(_cacheBucketNames != null)
|
private val _bucketCacheLock = Any()
|
||||||
return _cacheBucketNames ?: listOf();
|
|
||||||
try {
|
fun getVideoBucketNames(forceRefresh: Boolean = false): List<Bucket> {
|
||||||
val cur: Cursor = StateApp.instance.contextOrNull?.contentResolver?.query(
|
if (!forceRefresh) {
|
||||||
MediaStore.Video.Media.EXTERNAL_CONTENT_URI, arrayOf(
|
_cachedVideoBuckets?.let { return it }
|
||||||
MediaStore.Video.Media.BUCKET_ID,
|
}
|
||||||
MediaStore.Video.Media.BUCKET_DISPLAY_NAME,
|
|
||||||
), null, null, null
|
val resolver = StateApp.instance.contextOrNull?.contentResolver
|
||||||
) ?: return listOf();
|
?: return emptyList()
|
||||||
|
|
||||||
|
val projection = arrayOf(
|
||||||
|
MediaStore.Video.VideoColumns.BUCKET_ID,
|
||||||
|
MediaStore.Video.VideoColumns.BUCKET_DISPLAY_NAME
|
||||||
|
)
|
||||||
|
|
||||||
|
val sortOrder = "${MediaStore.Video.VideoColumns.BUCKET_DISPLAY_NAME} COLLATE NOCASE ASC"
|
||||||
|
val loadedBuckets: List<Bucket> = try {
|
||||||
|
resolver.query(
|
||||||
|
MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
|
||||||
|
projection,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
sortOrder
|
||||||
|
)?.use { cursor ->
|
||||||
|
if (!cursor.moveToFirst()) {
|
||||||
|
return@use emptyList<Bucket>()
|
||||||
|
}
|
||||||
|
|
||||||
|
val idxId = cursor.getColumnIndexOrThrow(MediaStore.Video.VideoColumns.BUCKET_ID)
|
||||||
|
val idxName = cursor.getColumnIndexOrThrow(MediaStore.Video.VideoColumns.BUCKET_DISPLAY_NAME)
|
||||||
|
val seenIds = HashSet<Long>()
|
||||||
|
val buckets = ArrayList<Bucket>()
|
||||||
|
|
||||||
val buckets = mutableListOf<Bucket>();
|
|
||||||
val list = HashSet<Long>();
|
|
||||||
if (cur.moveToFirst()) {
|
|
||||||
var id: Long;
|
|
||||||
var bucket: String
|
|
||||||
do {
|
do {
|
||||||
try {
|
try {
|
||||||
id = cur.getLong(0);
|
val id = cursor.getLong(idxId)
|
||||||
bucket = cur.getStringOrNull(1) ?: continue;
|
if (!seenIds.add(id)) {
|
||||||
if (!list.contains(id)) {
|
continue
|
||||||
list.add(id);
|
|
||||||
buckets.add(Bucket(id, bucket));
|
|
||||||
}
|
}
|
||||||
} catch (ex: Throwable) {
|
|
||||||
Logger.e(TAG, "Failed to parse bucket due to ${ex.message}", ex);
|
val name = cursor.getStringOrNull(idxName) ?: continue
|
||||||
|
buckets.add(Bucket(id, name))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Failed to parse video bucket row: ${e.message}", e)
|
||||||
}
|
}
|
||||||
} while (cur.moveToNext())
|
} while (cursor.moveToNext())
|
||||||
|
|
||||||
|
buckets
|
||||||
|
} ?: emptyList()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Buckets loading failed, returning empty: ${e.message}", e)
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadedBuckets.isEmpty()) {
|
||||||
|
if (!forceRefresh) {
|
||||||
|
_cachedVideoBuckets?.let { return it }
|
||||||
}
|
}
|
||||||
_cacheBucketNames = buckets.toList()
|
return emptyList()
|
||||||
return _cacheBucketNames ?: listOf();
|
|
||||||
}
|
}
|
||||||
catch(ex: Throwable) {
|
|
||||||
Logger.e(TAG, "Buckets loading failed, returning empty");
|
synchronized(_bucketCacheLock) {
|
||||||
return listOf();
|
if (!forceRefresh) {
|
||||||
|
_cachedVideoBuckets?.let { return it }
|
||||||
|
}
|
||||||
|
_cachedVideoBuckets = loadedBuckets
|
||||||
|
return loadedBuckets
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
fun invalidateVideoBucketNamesCache() {
|
||||||
|
_cachedVideoBuckets = null
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
val PROJECTION_VIDEO = arrayOf(
|
val PROJECTION_VIDEO = arrayOf(
|
||||||
MediaStore.Video.Media._ID,
|
MediaStore.Video.Media._ID,
|
||||||
MediaStore.Video.Media.DISPLAY_NAME,
|
MediaStore.Video.Media.DISPLAY_NAME,
|
||||||
MediaStore.Video.Media.AUTHOR,
|
|
||||||
MediaStore.Video.Media.DATE_ADDED,
|
MediaStore.Video.Media.DATE_ADDED,
|
||||||
MediaStore.Video.Media.MIME_TYPE,
|
MediaStore.Video.Media.MIME_TYPE,
|
||||||
MediaStore.Video.Media.BUCKET_DISPLAY_NAME
|
MediaStore.Video.Media.BUCKET_DISPLAY_NAME,
|
||||||
|
MediaStore.Video.Media.DURATION
|
||||||
);
|
);
|
||||||
val PROJECTION_MEDIA = arrayOf(
|
val PROJECTION_MEDIA = arrayOf(
|
||||||
MediaStore.Audio.Media._ID, //0
|
MediaStore.Audio.Media._ID, //0
|
||||||
MediaStore.Audio.Media.DISPLAY_NAME, //1
|
MediaStore.Audio.Media.DISPLAY_NAME, //1
|
||||||
MediaStore.Audio.Media.ARTIST, //2
|
MediaStore.Audio.Media.ARTIST, //2
|
||||||
MediaStore.Audio.Media.ALBUM_ID, //3
|
MediaStore.Audio.Media.ARTIST_ID, //3
|
||||||
MediaStore.Audio.Media.DURATION, //4
|
MediaStore.Audio.Media.ALBUM_ID, //4
|
||||||
MediaStore.Audio.Media.DATE_ADDED, //5
|
MediaStore.Audio.Media.DURATION, //5
|
||||||
MediaStore.Audio.Media.MIME_TYPE, //6
|
MediaStore.Audio.Media.DATE_ADDED, //6
|
||||||
MediaStore.Audio.Media.BUCKET_DISPLAY_NAME //7
|
MediaStore.Audio.Media.MIME_TYPE, //7
|
||||||
|
MediaStore.Audio.Media.BUCKET_DISPLAY_NAME //8
|
||||||
);
|
);
|
||||||
|
|
||||||
fun getDocumentTrack(url: String): IPlatformContentDetails? {
|
fun getDocumentTrack(url: String): IPlatformContentDetails? {
|
||||||
@@ -286,10 +403,12 @@ class StateLibrary {
|
|||||||
val cursor = resolver?.query(
|
val cursor = resolver?.query(
|
||||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, StateLibrary.PROJECTION_MEDIA, "${MediaStore.Audio.Media._ID} = ?", arrayOf(id.toString()),
|
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, StateLibrary.PROJECTION_MEDIA, "${MediaStore.Audio.Media._ID} = ?", arrayOf(id.toString()),
|
||||||
null) ?: return null;
|
null) ?: return null;
|
||||||
cursor.moveToFirst();
|
return cursor.use {
|
||||||
if(cursor.isAfterLast)
|
cursor.moveToFirst();
|
||||||
return null;
|
if(cursor.isAfterLast)
|
||||||
return audioFromCursor(cursor);
|
return@use null;
|
||||||
|
return@use audioFromCursor(cursor);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fun findAudioByName(name: String): IPlatformContentDetails? {
|
fun findAudioByName(name: String): IPlatformContentDetails? {
|
||||||
val resolver = StateApp.instance.contextOrNull?.contentResolver;
|
val resolver = StateApp.instance.contextOrNull?.contentResolver;
|
||||||
@@ -300,10 +419,12 @@ class StateLibrary {
|
|||||||
val cursor = resolver?.query(
|
val cursor = resolver?.query(
|
||||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, StateLibrary.PROJECTION_MEDIA, "${MediaStore.Audio.Media.DISPLAY_NAME} = ?", arrayOf(name),
|
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, StateLibrary.PROJECTION_MEDIA, "${MediaStore.Audio.Media.DISPLAY_NAME} = ?", arrayOf(name),
|
||||||
null) ?: return null;
|
null) ?: return null;
|
||||||
cursor.moveToFirst();
|
return cursor.use {
|
||||||
if(cursor.isAfterLast)
|
cursor.moveToFirst();
|
||||||
return null;
|
if(cursor.isAfterLast)
|
||||||
return audioFromCursor(cursor);
|
return null;
|
||||||
|
return@use audioFromCursor(cursor);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fun getVideoTrack(url: String): IPlatformContentDetails? {
|
fun getVideoTrack(url: String): IPlatformContentDetails? {
|
||||||
val uri = Uri.parse(url);
|
val uri = Uri.parse(url);
|
||||||
@@ -319,10 +440,12 @@ class StateLibrary {
|
|||||||
val cursor = resolver?.query(
|
val cursor = resolver?.query(
|
||||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, StateLibrary.PROJECTION_VIDEO, "${MediaStore.Video.Media._ID} = ?", arrayOf(id.toString()),
|
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, StateLibrary.PROJECTION_VIDEO, "${MediaStore.Video.Media._ID} = ?", arrayOf(id.toString()),
|
||||||
null) ?: return null;
|
null) ?: return null;
|
||||||
cursor.moveToFirst();
|
return cursor.use {
|
||||||
if(cursor.isAfterLast)
|
cursor.moveToFirst();
|
||||||
return null;
|
if(cursor.isAfterLast)
|
||||||
return videoFromCursor(cursor);
|
return@use null;
|
||||||
|
return@use videoFromCursor(cursor);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fun findVideoByName(name: String): IPlatformContentDetails? {
|
fun findVideoByName(name: String): IPlatformContentDetails? {
|
||||||
val resolver = StateApp.instance.contextOrNull?.contentResolver;
|
val resolver = StateApp.instance.contextOrNull?.contentResolver;
|
||||||
@@ -333,21 +456,24 @@ class StateLibrary {
|
|||||||
val cursor = resolver?.query(
|
val cursor = resolver?.query(
|
||||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, StateLibrary.PROJECTION_VIDEO, "${MediaStore.Video.Media.DISPLAY_NAME} = ?", arrayOf(name),
|
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, StateLibrary.PROJECTION_VIDEO, "${MediaStore.Video.Media.DISPLAY_NAME} = ?", arrayOf(name),
|
||||||
null) ?: return null;
|
null) ?: return null;
|
||||||
cursor.moveToFirst();
|
return cursor.use {
|
||||||
if(cursor.isAfterLast)
|
cursor.moveToFirst();
|
||||||
return null;
|
if(cursor.isAfterLast)
|
||||||
return videoFromCursor(cursor);
|
return@use null;
|
||||||
|
return@use videoFromCursor(cursor);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun audioFromCursor(cursor: Cursor): IPlatformVideoDetails {
|
fun audioFromCursor(cursor: Cursor): IPlatformVideoDetails {
|
||||||
val id = cursor.getString(0);
|
val id = cursor.getString(0);
|
||||||
val displayName = cursor.getString(1);
|
val displayName = cursor.getString(1);
|
||||||
val author = cursor.getString(2);
|
val author = cursor.getString(2);
|
||||||
val albumId = cursor.getLong(3);
|
val authorId = cursor.getStringOrNull(3);
|
||||||
val duration = cursor.getLong(4).let { if(it > 0) it / 1000 else 0 };
|
val albumId = cursor.getLong(4);
|
||||||
val date = cursor.getLong(5);
|
val duration = cursor.getLong(5).let { if(it > 0) it / 1000 else 0 };
|
||||||
val contentType = cursor.getString(6);
|
val date = cursor.getLong(6);
|
||||||
val category = cursor.getString(7);
|
val contentType = cursor.getString(7);
|
||||||
|
val category = cursor.getString(8);
|
||||||
|
|
||||||
val idLong = id.toLongOrNull();
|
val idLong = id.toLongOrNull();
|
||||||
val contentUrl = if(idLong != null )
|
val contentUrl = if(idLong != null )
|
||||||
@@ -355,16 +481,27 @@ class StateLibrary {
|
|||||||
else
|
else
|
||||||
"";
|
"";
|
||||||
|
|
||||||
val albumContentUrl = if(albumId > 0)
|
val authorIdLong = authorId?.toLongOrNull();
|
||||||
ContentUris.withAppendedId(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, albumId)?.toString()
|
val authorUrl = if(authorIdLong != null)
|
||||||
else null;
|
ContentUris.withAppendedId(MediaStore.Audio.Artists.EXTERNAL_CONTENT_URI, authorIdLong).toString();
|
||||||
|
else
|
||||||
|
"";
|
||||||
|
|
||||||
|
|
||||||
|
val albumArtBase = Uri.parse("content://media/external/audio/albumart")
|
||||||
|
val albumContentUrl = if (albumId > 0)
|
||||||
|
ContentUris.withAppendedId(albumArtBase, albumId).toString()
|
||||||
|
else null
|
||||||
|
|
||||||
val dateObj = if(date > 0)
|
val dateObj = if(date > 0)
|
||||||
OffsetDateTime.ofInstant(Instant.ofEpochSecond(date), ZoneOffset.UTC)
|
OffsetDateTime.ofInstant(Instant.ofEpochSecond(date), ZoneOffset.UTC)
|
||||||
else null;
|
else null;
|
||||||
|
|
||||||
val authorObj = if(!author.isNullOrBlank())
|
val authorObj = if(!author.isNullOrBlank())
|
||||||
PlatformAuthorLink(PlatformID.NONE, author, "", null, null)
|
PlatformAuthorLink(
|
||||||
|
if(authorId != null) PlatformID("LOCAL", authorId) else PlatformID.NONE,
|
||||||
|
author,
|
||||||
|
authorUrl, null, null)
|
||||||
else PlatformAuthorLink.UNKNOWN;
|
else PlatformAuthorLink.UNKNOWN;
|
||||||
|
|
||||||
return LocalVideoDetails(
|
return LocalVideoDetails(
|
||||||
@@ -376,10 +513,12 @@ class StateLibrary {
|
|||||||
fun videoFromCursor(cursor: Cursor): IPlatformVideoDetails {
|
fun videoFromCursor(cursor: Cursor): IPlatformVideoDetails {
|
||||||
val id = cursor.getString(0);
|
val id = cursor.getString(0);
|
||||||
val displayName = cursor.getString(1);
|
val displayName = cursor.getString(1);
|
||||||
val author = cursor.getString(2);
|
val author = null;//cursor.getString(2);
|
||||||
val date = cursor.getLong(3);
|
val date = cursor.getLong(2);
|
||||||
val contentType = cursor.getString(4);
|
val contentType = cursor.getString(3);
|
||||||
val category = cursor.getString(5);
|
val category = cursor.getString(4);
|
||||||
|
val durationMs = cursor.getLong(5)
|
||||||
|
val duration = if (durationMs > 0) durationMs / 1000 else -1
|
||||||
|
|
||||||
val idLong = id.toLongOrNull();
|
val idLong = id.toLongOrNull();
|
||||||
val contentUrl = if(idLong != null )
|
val contentUrl = if(idLong != null )
|
||||||
@@ -399,7 +538,7 @@ class StateLibrary {
|
|||||||
PlatformID("FILE", contentUrl, null, 0, -1),
|
PlatformID("FILE", contentUrl, null, 0, -1),
|
||||||
displayName, Thumbnails(arrayOf(
|
displayName, Thumbnails(arrayOf(
|
||||||
Thumbnail(contentUrl, 0)
|
Thumbnail(contentUrl, 0)
|
||||||
)), authorObj, contentUrl, -1, contentType, dateObj);
|
)), authorObj, contentUrl, duration, contentType, dateObj);
|
||||||
}
|
}
|
||||||
|
|
||||||
private var _instance : StateLibrary? = null;
|
private var _instance : StateLibrary? = null;
|
||||||
@@ -456,6 +595,10 @@ class Artist {
|
|||||||
return AdhocPager({ listOf() }, getTracksPager(idLong));
|
return AdhocPager({ listOf() }, getTracksPager(idLong));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getThumbnailOrAlbum(): String? {
|
||||||
|
return thumbnail ?: tryGetArtistThumbnail(id.toLongOrNull());
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
val ID_UNKNOWN = "UNKNOWN";
|
val ID_UNKNOWN = "UNKNOWN";
|
||||||
val PROJECTION: Array<String> = arrayOf(Artists._ID,
|
val PROJECTION: Array<String> = arrayOf(Artists._ID,
|
||||||
@@ -463,17 +606,32 @@ class Artist {
|
|||||||
Artists.NUMBER_OF_TRACKS,
|
Artists.NUMBER_OF_TRACKS,
|
||||||
Artists.NUMBER_OF_ALBUMS);
|
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 {
|
fun fromCursor(cursor: Cursor): Artist {
|
||||||
val id = cursor.getString(0);
|
val id = cursor.getString(0);
|
||||||
val artist = cursor.getString(1);
|
val artist = cursor.getString(1);
|
||||||
val numTracks = cursor.getInt(2);
|
val numTracks = cursor.getInt(2);
|
||||||
val numAlbums = cursor.getInt(3);
|
val numAlbums = cursor.getInt(3);
|
||||||
|
|
||||||
val idLong = id.toLongOrNull();
|
val idLong = id.toLongOrNull()
|
||||||
val uri = if(idLong != null) ContentUris.withAppendedId(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, idLong) else null;
|
val uri = if (idLong != null)
|
||||||
|
ContentUris.withAppendedId(MediaStore.Audio.Artists.EXTERNAL_CONTENT_URI, idLong)
|
||||||
|
else null
|
||||||
|
|
||||||
return Artist(artist, numTracks, numAlbums, null, id, uri?.toString());
|
return Artist(artist, numTracks, numAlbums, null, id, uri?.toString()) }
|
||||||
}
|
|
||||||
|
|
||||||
fun getArtist(id: Long): Artist? {
|
fun getArtist(id: Long): Artist? {
|
||||||
val resolver = StateApp.instance.contextOrNull?.contentResolver;
|
val resolver = StateApp.instance.contextOrNull?.contentResolver;
|
||||||
@@ -484,12 +642,13 @@ class Artist {
|
|||||||
val cursor = resolver.query(MediaStore.Audio.Artists.EXTERNAL_CONTENT_URI,
|
val cursor = resolver.query(MediaStore.Audio.Artists.EXTERNAL_CONTENT_URI,
|
||||||
Artist.PROJECTION,
|
Artist.PROJECTION,
|
||||||
"${MediaStore.Audio.Artists._ID} = ?",
|
"${MediaStore.Audio.Artists._ID} = ?",
|
||||||
arrayOf(id.toString()), null) ?:
|
arrayOf(id.toString()), null) ?: return null;
|
||||||
return null;
|
return cursor.use {
|
||||||
cursor.moveToFirst();
|
cursor.moveToFirst();
|
||||||
if(cursor.isAfterLast)
|
if(cursor.isAfterLast)
|
||||||
return null;
|
return@use null;
|
||||||
return Artist.fromCursor(cursor);
|
return@use Artist.fromCursor(cursor);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fun getArtists(ordering: ArtistOrdering = ArtistOrdering.Alphabethic, query: String? = null, args: Array<String>? = null): List<Artist> {
|
fun getArtists(ordering: ArtistOrdering = ArtistOrdering.Alphabethic, query: String? = null, args: Array<String>? = null): List<Artist> {
|
||||||
val ordering = when(ordering) {
|
val ordering = when(ordering) {
|
||||||
@@ -503,13 +662,18 @@ class Artist {
|
|||||||
query,
|
query,
|
||||||
args,
|
args,
|
||||||
ordering) ?: return listOf();
|
ordering) ?: return listOf();
|
||||||
cursor.moveToFirst();
|
return cursor.use {
|
||||||
val list = mutableListOf<Artist>()
|
cursor.moveToFirst();
|
||||||
while(!cursor.isAfterLast) {
|
val list = mutableListOf<Artist>()
|
||||||
list.add(fromCursor(cursor));
|
while(!cursor.isAfterLast) {
|
||||||
cursor.moveToNext();
|
val artist = fromCursor(cursor);
|
||||||
|
cursor.moveToNext();
|
||||||
|
if(artist.name == "<unknown>")
|
||||||
|
continue; //TODO: Better way of detecting unknown?
|
||||||
|
list.add(artist);
|
||||||
|
}
|
||||||
|
return@use list;
|
||||||
}
|
}
|
||||||
return list;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getTracksPager(artistId: Long): List<IPlatformVideo> {
|
fun getTracksPager(artistId: Long): List<IPlatformVideo> {
|
||||||
@@ -521,13 +685,15 @@ class Artist {
|
|||||||
val cursor = resolver?.query(
|
val cursor = resolver?.query(
|
||||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, StateLibrary.PROJECTION_MEDIA, "${MediaStore.Audio.Media.ARTIST_ID} = ?", arrayOf(artistId.toString()),
|
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, StateLibrary.PROJECTION_MEDIA, "${MediaStore.Audio.Media.ARTIST_ID} = ?", arrayOf(artistId.toString()),
|
||||||
null) ?: return listOf();
|
null) ?: return listOf();
|
||||||
cursor.moveToFirst();
|
return cursor.use {
|
||||||
val list = mutableListOf<IPlatformVideo>()
|
cursor.moveToFirst();
|
||||||
while(!cursor.isAfterLast) {
|
val list = mutableListOf<IPlatformVideo>()
|
||||||
list.add(StateLibrary.audioFromCursor(cursor));
|
while(!cursor.isAfterLast) {
|
||||||
cursor.moveToNext();
|
list.add(StateLibrary.audioFromCursor(cursor));
|
||||||
|
cursor.moveToNext();
|
||||||
|
}
|
||||||
|
return@use list;
|
||||||
}
|
}
|
||||||
return list;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -569,9 +735,10 @@ class Album {
|
|||||||
val numTracks = cursor.getInt(2);
|
val numTracks = cursor.getInt(2);
|
||||||
val artist = cursor.getString(3);
|
val artist = cursor.getString(3);
|
||||||
|
|
||||||
val idLong = id.toLongOrNull();
|
val idLong = id.toLongOrNull()
|
||||||
val uri = if(idLong != null) ContentUris.withAppendedId(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, idLong) else null;
|
val albumArtBase = Uri.parse("content://media/external/audio/albumart")
|
||||||
return Album(album, numTracks, artist, id, uri?.toString());
|
val uri = if (idLong != null) ContentUris.withAppendedId(albumArtBase, idLong) else null
|
||||||
|
return Album(album, numTracks, artist, id, uri?.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getAlbumTracks(albumId: Long): List<IPlatformVideo> {
|
fun getAlbumTracks(albumId: Long): List<IPlatformVideo> {
|
||||||
@@ -583,13 +750,15 @@ class Album {
|
|||||||
val cursor = resolver?.query(
|
val cursor = resolver?.query(
|
||||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, StateLibrary.PROJECTION_MEDIA, "${MediaStore.Audio.Media.ALBUM_ID} = ?", arrayOf(albumId.toString()),
|
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, StateLibrary.PROJECTION_MEDIA, "${MediaStore.Audio.Media.ALBUM_ID} = ?", arrayOf(albumId.toString()),
|
||||||
null) ?: return listOf();
|
null) ?: return listOf();
|
||||||
cursor.moveToFirst();
|
return cursor.use {
|
||||||
val list = mutableListOf<IPlatformVideo>()
|
cursor.moveToFirst();
|
||||||
while(!cursor.isAfterLast) {
|
val list = mutableListOf<IPlatformVideo>()
|
||||||
list.add(StateLibrary.audioFromCursor(cursor));
|
while(!cursor.isAfterLast) {
|
||||||
cursor.moveToNext();
|
list.add(StateLibrary.audioFromCursor(cursor));
|
||||||
|
cursor.moveToNext();
|
||||||
|
}
|
||||||
|
return@use list;
|
||||||
}
|
}
|
||||||
return list;
|
|
||||||
}
|
}
|
||||||
fun getAlbum(id: Long): Album? {
|
fun getAlbum(id: Long): Album? {
|
||||||
val resolver = StateApp.instance.contextOrNull?.contentResolver;
|
val resolver = StateApp.instance.contextOrNull?.contentResolver;
|
||||||
@@ -600,12 +769,13 @@ class Album {
|
|||||||
val cursor = resolver.query(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI,
|
val cursor = resolver.query(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI,
|
||||||
PROJECTION,
|
PROJECTION,
|
||||||
"${MediaStore.Audio.Albums.ALBUM_ID} = ?",
|
"${MediaStore.Audio.Albums.ALBUM_ID} = ?",
|
||||||
arrayOf(id.toString()), null) ?:
|
arrayOf(id.toString()), null) ?: return null;
|
||||||
return null;
|
return cursor.use {
|
||||||
cursor.moveToFirst();
|
cursor.moveToFirst();
|
||||||
if(cursor.isAfterLast)
|
if(cursor.isAfterLast)
|
||||||
return null;
|
return@use null;
|
||||||
return fromCursor(cursor);
|
return@use fromCursor(cursor);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fun getAlbums(query: String? = null, args: Array<String>? = null): List<Album> {
|
fun getAlbums(query: String? = null, args: Array<String>? = null): List<Album> {
|
||||||
val resolver = StateApp.instance.contextOrNull?.contentResolver;
|
val resolver = StateApp.instance.contextOrNull?.contentResolver;
|
||||||
@@ -616,13 +786,15 @@ class Album {
|
|||||||
val cursor = resolver?.query(
|
val cursor = resolver?.query(
|
||||||
MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, PROJECTION, query, args,
|
MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, PROJECTION, query, args,
|
||||||
MediaStore.Audio.Albums.ALBUM + " ASC") ?: return listOf();
|
MediaStore.Audio.Albums.ALBUM + " ASC") ?: return listOf();
|
||||||
cursor.moveToFirst();
|
return cursor.use {
|
||||||
val list = mutableListOf<Album>()
|
cursor.moveToFirst();
|
||||||
while(!cursor.isAfterLast) {
|
val list = mutableListOf<Album>()
|
||||||
list.add(fromCursor(cursor));
|
while(!cursor.isAfterLast) {
|
||||||
cursor.moveToNext();
|
list.add(fromCursor(cursor));
|
||||||
|
cursor.moveToNext();
|
||||||
|
}
|
||||||
|
return@use list;
|
||||||
}
|
}
|
||||||
return list;
|
|
||||||
}
|
}
|
||||||
fun getArtistAlbums(artistId: Long): List<Album> {
|
fun getArtistAlbums(artistId: Long): List<Album> {
|
||||||
val resolver = StateApp.instance.contextOrNull?.contentResolver;
|
val resolver = StateApp.instance.contextOrNull?.contentResolver;
|
||||||
@@ -633,13 +805,35 @@ class Album {
|
|||||||
val cursor = resolver?.query(
|
val cursor = resolver?.query(
|
||||||
MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, PROJECTION, "${MediaStore.Audio.Media.ARTIST_ID} = ?", arrayOf(artistId.toString()),
|
MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, PROJECTION, "${MediaStore.Audio.Media.ARTIST_ID} = ?", arrayOf(artistId.toString()),
|
||||||
MediaStore.Audio.Albums.ALBUM + " ASC") ?: return listOf();
|
MediaStore.Audio.Albums.ALBUM + " ASC") ?: return listOf();
|
||||||
cursor.moveToFirst();
|
return cursor.use {
|
||||||
val list = mutableListOf<Album>()
|
cursor.moveToFirst();
|
||||||
while(!cursor.isAfterLast) {
|
val list = mutableListOf<Album>()
|
||||||
list.add(fromCursor(cursor));
|
while(!cursor.isAfterLast) {
|
||||||
cursor.moveToNext();
|
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;
|
||||||
}
|
}
|
||||||
return list;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import android.graphics.drawable.Drawable
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
|
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||||
import com.bumptech.glide.request.target.CustomTarget
|
import com.bumptech.glide.request.target.CustomTarget
|
||||||
import com.bumptech.glide.request.transition.Transition
|
import com.bumptech.glide.request.transition.Transition
|
||||||
import com.futo.platformplayer.activities.MainActivity
|
import com.futo.platformplayer.activities.MainActivity
|
||||||
@@ -22,6 +23,7 @@ import com.futo.platformplayer.serializers.PlatformContentSerializer
|
|||||||
import com.futo.platformplayer.stores.FragmentedStorage
|
import com.futo.platformplayer.stores.FragmentedStorage
|
||||||
import com.futo.platformplayer.toHumanNowDiffString
|
import com.futo.platformplayer.toHumanNowDiffString
|
||||||
import com.futo.platformplayer.toHumanNowDiffStringMinDay
|
import com.futo.platformplayer.toHumanNowDiffStringMinDay
|
||||||
|
import com.futo.platformplayer.withMaxSizePx
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
|
|
||||||
class StateNotifications {
|
class StateNotifications {
|
||||||
@@ -96,6 +98,7 @@ class StateNotifications {
|
|||||||
if(thumbnail != null)
|
if(thumbnail != null)
|
||||||
Glide.with(context).asBitmap()
|
Glide.with(context).asBitmap()
|
||||||
.load(thumbnail)
|
.load(thumbnail)
|
||||||
|
.withMaxSizePx()
|
||||||
.into(object: CustomTarget<Bitmap>() {
|
.into(object: CustomTarget<Bitmap>() {
|
||||||
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
||||||
notifyNewContent(context, manager, notificationChannel, id, content, resource);
|
notifyNewContent(context, manager, notificationChannel, id, content, resource);
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
package com.futo.platformplayer.states
|
package com.futo.platformplayer.states
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.media3.common.C
|
import androidx.media3.common.C
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.exoplayer.DefaultLoadControl
|
import androidx.media3.exoplayer.DefaultLoadControl
|
||||||
|
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import androidx.media3.exoplayer.Renderer
|
||||||
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||||
|
import androidx.media3.exoplayer.text.TextOutput
|
||||||
|
import androidx.media3.exoplayer.text.TextRenderer
|
||||||
import androidx.media3.exoplayer.upstream.DefaultAllocator
|
import androidx.media3.exoplayer.upstream.DefaultAllocator
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.Settings
|
import com.futo.platformplayer.Settings
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
|
|
||||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
||||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
||||||
@@ -20,8 +26,10 @@ import com.futo.platformplayer.logging.Logger
|
|||||||
import com.futo.platformplayer.models.Playlist
|
import com.futo.platformplayer.models.Playlist
|
||||||
import com.futo.platformplayer.services.MediaPlaybackService
|
import com.futo.platformplayer.services.MediaPlaybackService
|
||||||
import com.futo.platformplayer.video.PlayerManager
|
import com.futo.platformplayer.video.PlayerManager
|
||||||
|
import com.google.common.collect.Iterables
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
|
||||||
/***
|
/***
|
||||||
* Used to keep track of queue and other player related stuff
|
* Used to keep track of queue and other player related stuff
|
||||||
*/
|
*/
|
||||||
@@ -114,7 +122,17 @@ class StatePlayer {
|
|||||||
var currentVideo: IPlatformVideo? = null
|
var currentVideo: IPlatformVideo? = null
|
||||||
private set;
|
private set;
|
||||||
|
|
||||||
|
private var _currentPlaylistId: String? = null
|
||||||
|
val playlistId: String? get() = if (_queueType == TYPE_PLAYLIST) _currentPlaylistId else null
|
||||||
|
|
||||||
|
init {
|
||||||
|
onQueueChanged.subscribe {
|
||||||
|
updateLastQueue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun setCurrentlyPlaying(video: IPlatformVideo?) {
|
fun setCurrentlyPlaying(video: IPlatformVideo?) {
|
||||||
|
Log.i(TAG, "setCurrentlyPlaying ${video?.url} (${video?.name})")
|
||||||
currentVideo = video;
|
currentVideo = video;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +143,7 @@ class StatePlayer {
|
|||||||
onPlayerOpened.emit();
|
onPlayerOpened.emit();
|
||||||
}
|
}
|
||||||
fun setPlayerClosed() {
|
fun setPlayerClosed() {
|
||||||
|
Log.i(TAG, "setCurrentlyPlaying (setPlayerClosed) null")
|
||||||
setCurrentlyPlaying(null);
|
setCurrentlyPlaying(null);
|
||||||
isOpen = false;
|
isOpen = false;
|
||||||
clearQueue();
|
clearQueue();
|
||||||
@@ -228,17 +247,29 @@ class StatePlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun createShuffledQueue() {
|
private fun createShuffledQueue() {
|
||||||
val currentItem = getCurrentQueueItem();
|
if (_queue.isEmpty()) {
|
||||||
if (_queuePosition == -1 || currentItem == null) {
|
_queueShuffled = mutableListOf()
|
||||||
_queueShuffled = _queue.shuffled().toMutableList()
|
return
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val nextItems = _queue.subList(Math.min(_queuePosition + 1, _queue.size - 1), _queue.size).shuffled();
|
val currentItem = getCurrentQueueItem()
|
||||||
val previousItems = _queue.subList(0, _queuePosition).shuffled();
|
if (currentItem == null || _queuePosition !in _queue.indices) {
|
||||||
_queueShuffled = (previousItems + currentItem + nextItems).toMutableList();
|
_queueShuffled = _queue.shuffled().toMutableList()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val previousItems = _queue
|
||||||
|
.take(_queuePosition)
|
||||||
|
.shuffled()
|
||||||
|
|
||||||
|
val nextItems = _queue
|
||||||
|
.drop(_queuePosition + 1)
|
||||||
|
.shuffled()
|
||||||
|
|
||||||
|
_queueShuffled = (previousItems + currentItem + nextItems).toMutableList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private fun addToShuffledQueue(video: IPlatformVideo) {
|
private fun addToShuffledQueue(video: IPlatformVideo) {
|
||||||
val isLastVideo = _queuePosition + 1 >= _queue.size;
|
val isLastVideo = _queuePosition + 1 >= _queue.size;
|
||||||
if (isLastVideo) {
|
if (isLastVideo) {
|
||||||
@@ -269,23 +300,6 @@ class StatePlayer {
|
|||||||
}
|
}
|
||||||
onQueueChanged.emit(true);
|
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) {
|
fun setPlaylist(playlist: Playlist, toPlayIndex: Int = 0, focus: Boolean = false, shuffle: Boolean = false) {
|
||||||
synchronized(_queue) {
|
synchronized(_queue) {
|
||||||
_queue.clear();
|
_queue.clear();
|
||||||
@@ -299,6 +313,7 @@ class StatePlayer {
|
|||||||
}
|
}
|
||||||
_queuePosition = toPlayIndex;
|
_queuePosition = toPlayIndex;
|
||||||
}
|
}
|
||||||
|
_currentPlaylistId = playlist.id
|
||||||
StatePlaylists.instance.didPlay(playlist.id);
|
StatePlaylists.instance.didPlay(playlist.id);
|
||||||
|
|
||||||
onQueueChanged.emit(true);
|
onQueueChanged.emit(true);
|
||||||
@@ -384,6 +399,27 @@ class StatePlayer {
|
|||||||
setQueuePosition(video);
|
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) {
|
fun setQueuePosition(video: IPlatformVideo) {
|
||||||
synchronized(_queue) {
|
synchronized(_queue) {
|
||||||
if (getCurrentQueueItem() == video) {
|
if (getCurrentQueueItem() == video) {
|
||||||
@@ -645,6 +681,30 @@ class StatePlayer {
|
|||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
private fun createExoPlayer(context : Context): ExoPlayer {
|
private fun createExoPlayer(context : Context): ExoPlayer {
|
||||||
return ExoPlayer.Builder(context)
|
return ExoPlayer.Builder(context)
|
||||||
|
.setRenderersFactory(
|
||||||
|
object : DefaultRenderersFactory(context) {
|
||||||
|
override fun buildTextRenderers(
|
||||||
|
context: Context,
|
||||||
|
output: TextOutput,
|
||||||
|
outputLooper: Looper,
|
||||||
|
extensionRendererMode: Int,
|
||||||
|
out: java.util.ArrayList<Renderer>
|
||||||
|
) {
|
||||||
|
super.buildTextRenderers(
|
||||||
|
context,
|
||||||
|
output,
|
||||||
|
outputLooper,
|
||||||
|
extensionRendererMode,
|
||||||
|
out
|
||||||
|
)
|
||||||
|
(Iterables.getLast<Renderer?>(out) as TextRenderer)
|
||||||
|
.experimentalSetLegacyDecodingEnabled(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.setMediaSourceFactory(
|
||||||
|
DefaultMediaSourceFactory(context)
|
||||||
|
.experimentalParseSubtitlesDuringExtraction(false)
|
||||||
|
)
|
||||||
.setLoadControl(
|
.setLoadControl(
|
||||||
DefaultLoadControl.Builder()
|
DefaultLoadControl.Builder()
|
||||||
.setAllocator(DefaultAllocator(true, BUFFER_SIZE))
|
.setAllocator(DefaultAllocator(true, BUFFER_SIZE))
|
||||||
|
|||||||
@@ -200,10 +200,10 @@ class StatePlaylists {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun getLastPlayedPlaylist() : Playlist? {
|
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? {
|
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> {
|
fun getPlaylists() : List<Playlist> {
|
||||||
@@ -394,6 +394,7 @@ class StatePlaylists {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
val TAG = "StatePlaylists";
|
val TAG = "StatePlaylists";
|
||||||
|
val LAST_QUEUE_PLAYLIST_ID = "a70a3287-45dd-4227-832c-6ecde7fb1bf6"
|
||||||
private var _instance : StatePlaylists? = null;
|
private var _instance : StatePlaylists? = null;
|
||||||
private var _lockObject = Object()
|
private var _lockObject = Object()
|
||||||
val instance : StatePlaylists
|
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.SourceCaptchaData
|
||||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor
|
import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor
|
||||||
|
import com.futo.platformplayer.fragment.mainactivity.main.LoginFragment
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.SourceDetailFragment
|
import com.futo.platformplayer.fragment.mainactivity.main.SourceDetailFragment
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.SourceDetailFragment.Companion
|
import com.futo.platformplayer.fragment.mainactivity.main.SourceDetailFragment.Companion
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
@@ -167,7 +168,10 @@ class StatePlugins {
|
|||||||
if(config.authentication == null)
|
if(config.authentication == null)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
LoginActivity.showLogin(context, config) {
|
LoginFragment.showLogin(config) {//LoginActivity.showLogin(context, config) {
|
||||||
|
|
||||||
|
if(it == null)
|
||||||
|
return@showLogin;
|
||||||
try {
|
try {
|
||||||
StatePlugins.instance.setPluginAuth(config.id, it);
|
StatePlugins.instance.setPluginAuth(config.id, it);
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
@@ -300,6 +304,7 @@ class StatePlugins {
|
|||||||
StateAssets.readAssetBinRelative(context, assetConfigPath, config.iconUrl);
|
StateAssets.readAssetBinRelative(context, assetConfigPath, config.iconUrl);
|
||||||
else null;
|
else null;
|
||||||
|
|
||||||
|
//config.version = config.version - 1;
|
||||||
createPlugin(config, script, icon, true);
|
createPlugin(config, script, icon, true);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -317,6 +322,15 @@ class StatePlugins {
|
|||||||
installPlugins(context, scope, sourceUrls.drop(1), handler);
|
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) {
|
fun installPlugin(context: Context, scope: CoroutineScope, sourceUrl: String, handler: ((Boolean) -> Unit)? = null) {
|
||||||
scope.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
val client = ManagedHttpClient();
|
val client = ManagedHttpClient();
|
||||||
@@ -329,6 +343,7 @@ class StatePlugins {
|
|||||||
if(configJson.isNullOrEmpty())
|
if(configJson.isNullOrEmpty())
|
||||||
throw IllegalStateException("No response");
|
throw IllegalStateException("No response");
|
||||||
config = SourcePluginConfig.fromJson(configJson, sourceUrl);
|
config = SourcePluginConfig.fromJson(configJson, sourceUrl);
|
||||||
|
//config.version = config.version - 1;
|
||||||
}
|
}
|
||||||
catch(ex: SerializationException) {
|
catch(ex: SerializationException) {
|
||||||
Logger.e(TAG, "Failed decode config", ex);
|
Logger.e(TAG, "Failed decode config", ex);
|
||||||
@@ -642,6 +657,9 @@ class StatePlugins {
|
|||||||
val descriptor = getPlugin(id) ?: throw IllegalArgumentException("Plugin [${id}] does not exist");
|
val descriptor = getPlugin(id) ?: throw IllegalArgumentException("Plugin [${id}] does not exist");
|
||||||
descriptor.updateAuth(auth);
|
descriptor.updateAuth(auth);
|
||||||
_plugins.save(descriptor);
|
_plugins.save(descriptor);
|
||||||
|
|
||||||
|
if(auth != null)
|
||||||
|
UIDialogs.appToast("Plugin ${descriptor?.config?.name} logged in");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|||||||
@@ -463,7 +463,7 @@ class StateSync {
|
|||||||
for(video in history){
|
for(video in history){
|
||||||
val hist = StateHistory.instance.getHistoryByVideo(video.video, true, video.date);
|
val hist = StateHistory.instance.getHistoryByVideo(video.video, true, video.date);
|
||||||
if(hist != null)
|
if(hist != null)
|
||||||
StateHistory.instance.updateHistoryPosition(video.video, hist, true, video.position, video.date)
|
StateHistory.instance.updateHistoryPosition(video.video, hist, true, video.position, video.date, false, video.playlistId)
|
||||||
if(lastHistory < video.date)
|
if(lastHistory < video.date)
|
||||||
lastHistory = video.date;
|
lastHistory = video.date;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ class StateTelemetry {
|
|||||||
Build.BRAND,
|
Build.BRAND,
|
||||||
Build.MANUFACTURER,
|
Build.MANUFACTURER,
|
||||||
Build.MODEL,
|
Build.MODEL,
|
||||||
Build.VERSION.SDK_INT
|
Build.VERSION.SDK_INT,
|
||||||
|
StatePlatform.instance.getEnabledClients().map { it.id }.toList()
|
||||||
);
|
);
|
||||||
|
|
||||||
val headers = hashMapOf(
|
val headers = hashMapOf(
|
||||||
|
|||||||
@@ -15,146 +15,6 @@ import java.io.InputStream
|
|||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
|
|
||||||
class StateUpdate {
|
class StateUpdate {
|
||||||
private var _backgroundUpdateFinished = false;
|
|
||||||
private var _gettingOrDownloadingLastApk = false;
|
|
||||||
private var _shouldBackgroundUpdate = false;
|
|
||||||
private val _lockObject = Object();
|
|
||||||
|
|
||||||
private fun getOrDownloadLastApkFile(filesDir: File): File? {
|
|
||||||
try {
|
|
||||||
Logger.i(TAG, "Started getting or downloading latest APK file.");
|
|
||||||
|
|
||||||
if (!_shouldBackgroundUpdate) {
|
|
||||||
Logger.i(TAG, "Update download cancelled 1.");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.i(TAG, "Started background update download.");
|
|
||||||
val client = ManagedHttpClient();
|
|
||||||
val latestVersion = downloadVersionCode(client);
|
|
||||||
if (!_shouldBackgroundUpdate) {
|
|
||||||
Logger.i(TAG, "Update download cancelled 2.");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (latestVersion != null) {
|
|
||||||
val currentVersion = BuildConfig.VERSION_CODE;
|
|
||||||
Logger.i(TAG, "Current version ${currentVersion} latest version ${latestVersion}.");
|
|
||||||
|
|
||||||
if (latestVersion <= currentVersion) {
|
|
||||||
Logger.i(TAG, "Already up to date.");
|
|
||||||
_backgroundUpdateFinished = true;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
val outputDirectory = File(filesDir, "autoupdate");
|
|
||||||
if (!outputDirectory.exists()) {
|
|
||||||
outputDirectory.mkdirs();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!_shouldBackgroundUpdate) {
|
|
||||||
Logger.i(TAG, "Update download cancelled 3.");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
val apkOutputFile = File(outputDirectory, "last_version.apk");
|
|
||||||
val versionOutputFile = File(outputDirectory, "last_version.txt");
|
|
||||||
|
|
||||||
var cachedVersionInvalid = false;
|
|
||||||
if (!versionOutputFile.exists() || !apkOutputFile.exists()) {
|
|
||||||
Logger.i(TAG, "No downloaded version exists.");
|
|
||||||
cachedVersionInvalid = true;
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
val downloadedVersion = versionOutputFile.readText().toInt();
|
|
||||||
Logger.i(TAG, "Downloaded version is $downloadedVersion.");
|
|
||||||
if (downloadedVersion != latestVersion) {
|
|
||||||
Logger.i(TAG, "Downloaded version is not newest version.");
|
|
||||||
cachedVersionInvalid = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch(ex: Throwable) {
|
|
||||||
Logger.w(TAG, "Deleted version file as it was inaccessible");
|
|
||||||
versionOutputFile.delete();
|
|
||||||
cachedVersionInvalid = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!_shouldBackgroundUpdate) {
|
|
||||||
Logger.i(TAG, "Update download cancelled 4.");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cachedVersionInvalid) {
|
|
||||||
Logger.i(TAG, "Downloading new APK to '${apkOutputFile.path}'...");
|
|
||||||
downloadApkToFile(client, apkOutputFile) { !_shouldBackgroundUpdate };
|
|
||||||
versionOutputFile.writeText(latestVersion.toString());
|
|
||||||
|
|
||||||
Logger.i(TAG, "Downloaded APK to '${apkOutputFile.path}'.");
|
|
||||||
} else {
|
|
||||||
Logger.i(TAG, "Latest APK is already downloaded in '${apkOutputFile.path}'...");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!_shouldBackgroundUpdate) {
|
|
||||||
Logger.i(TAG, "Update download cancelled 5.");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return apkOutputFile;
|
|
||||||
} else {
|
|
||||||
Logger.w(TAG, "Failed to retrieve version from version URL.");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.e(TAG, "Failed to download APK.", e);
|
|
||||||
return null;
|
|
||||||
} finally {
|
|
||||||
_gettingOrDownloadingLastApk = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setShouldBackgroundUpdate(shouldBackgroundUpdate: Boolean) {
|
|
||||||
synchronized (_lockObject) {
|
|
||||||
if (_backgroundUpdateFinished) {
|
|
||||||
_shouldBackgroundUpdate = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_shouldBackgroundUpdate = shouldBackgroundUpdate;
|
|
||||||
if (shouldBackgroundUpdate && !_gettingOrDownloadingLastApk) {
|
|
||||||
Logger.i(TAG, "Auto Updating in Background");
|
|
||||||
|
|
||||||
_gettingOrDownloadingLastApk = true;
|
|
||||||
StateApp.withContext { context ->
|
|
||||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
val file = getOrDownloadLastApkFile(context.filesDir);
|
|
||||||
if (file == null) {
|
|
||||||
Logger.i(TAG, "Failed to get or download update.");
|
|
||||||
return@launch;
|
|
||||||
}
|
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
try {
|
|
||||||
context.let { c ->
|
|
||||||
_backgroundUpdateFinished = true;
|
|
||||||
UIDialogs.showInstallDownloadedUpdateDialog(c, file);
|
|
||||||
};
|
|
||||||
Logger.i(TAG, "Showing install dialog for '${file.path}'.");
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
context.let { c -> UIDialogs.toast(c, "Failed to show update dialog"); };
|
|
||||||
Logger.w(TAG, "Error occurred in update dialog.", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.e(TAG, "Failed to get last downloaded APK file.", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun checkForUpdates(context: Context, showUpToDateToast: Boolean, hideExceptionButtons: Boolean = false) = withContext(Dispatchers.IO) {
|
suspend fun checkForUpdates(context: Context, showUpToDateToast: Boolean, hideExceptionButtons: Boolean = false) = withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val client = ManagedHttpClient();
|
val client = ManagedHttpClient();
|
||||||
@@ -196,25 +56,6 @@ class StateUpdate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun downloadApkToFile(client: ManagedHttpClient, destinationFile: File, isCancelled: (() -> Boolean)? = null) {
|
|
||||||
var apkStream: InputStream? = null;
|
|
||||||
var outputStream: OutputStream? = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
val response = client.get(APK_URL);
|
|
||||||
if (response.isOk && response.body != null) {
|
|
||||||
apkStream = response.body.byteStream();
|
|
||||||
outputStream = destinationFile.outputStream();
|
|
||||||
apkStream.copyToOutputStream(outputStream, isCancelled);
|
|
||||||
apkStream.close();
|
|
||||||
outputStream.close();
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
apkStream?.close();
|
|
||||||
outputStream?.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun downloadVersionCode(client: ManagedHttpClient): Int? {
|
fun downloadVersionCode(client: ManagedHttpClient): Int? {
|
||||||
val response = client.get(VERSION_URL);
|
val response = client.get(VERSION_URL);
|
||||||
if (!response.isOk || response.body == null) {
|
if (!response.isOk || response.body == null) {
|
||||||
@@ -267,6 +108,22 @@ class StateUpdate {
|
|||||||
}
|
}
|
||||||
val CHANGELOG_BASE_URL = "https://releases.grayjay.app/changelogs";
|
val CHANGELOG_BASE_URL = "https://releases.grayjay.app/changelogs";
|
||||||
|
|
||||||
|
fun getApkFile(context: Context, version: Int): File {
|
||||||
|
val dir = File(context.filesDir, "updates");
|
||||||
|
if (!dir.exists()) {
|
||||||
|
dir.mkdirs();
|
||||||
|
}
|
||||||
|
return File(dir, "app-${DESIRED_ABI}-${version}.apk");
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPartialApkFile(context: Context, version: Int): File {
|
||||||
|
val dir = File(context.filesDir, "updates");
|
||||||
|
if (!dir.exists()) {
|
||||||
|
dir.mkdirs();
|
||||||
|
}
|
||||||
|
return File(dir, "app-${DESIRED_ABI}-${version}.apk.part");
|
||||||
|
}
|
||||||
|
|
||||||
fun finish() {
|
fun finish() {
|
||||||
_instance?.let {
|
_instance?.let {
|
||||||
_instance = null;
|
_instance = null;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class SearchHistoryStorage : FragmentedStorageFileJson() {
|
|||||||
if (!lastQueries.contains(text)) {
|
if (!lastQueries.contains(text)) {
|
||||||
lastQueries.add(0, text);
|
lastQueries.add(0, text);
|
||||||
if (lastQueries.size > 10)
|
if (lastQueries.size > 10)
|
||||||
lastQueries.removeLast();
|
lastQueries.removeAt(lastQueries.size - 1);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
lastQueries.remove(text);
|
lastQueries.remove(text);
|
||||||
|
|||||||
@@ -22,13 +22,14 @@ class LibrarySection: ConstraintLayout {
|
|||||||
val imageNavigate: ImageView;
|
val imageNavigate: ImageView;
|
||||||
val recycler: RecyclerView;
|
val recycler: RecyclerView;
|
||||||
|
|
||||||
|
val noContent: NoResultsView;
|
||||||
|
|
||||||
constructor(context: Context, attr: AttributeSet? = null) : super(context, attr) {
|
constructor(context: Context, attr: AttributeSet? = null) : super(context, attr) {
|
||||||
inflate(context, R.layout.view_library_section, this);
|
inflate(context, R.layout.view_library_section, this);
|
||||||
textName = findViewById(R.id.text_label)
|
textName = findViewById(R.id.text_label)
|
||||||
imageNavigate = findViewById(R.id.image_nav)
|
imageNavigate = findViewById(R.id.image_nav)
|
||||||
recycler = findViewById(R.id.recycler_collection);
|
recycler = findViewById(R.id.recycler_collection);
|
||||||
|
noContent = findViewById(R.id.container_no_content);
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setNavIcon(resId: Int) {
|
fun setNavIcon(resId: Int) {
|
||||||
@@ -46,4 +47,14 @@ class LibrarySection: ConstraintLayout {
|
|||||||
textName.text = title;
|
textName.text = title;
|
||||||
imageNavigate.setOnClickListener { onOpen.invoke() };
|
imageNavigate.setOnClickListener { onOpen.invoke() };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setEmpty(title: String, txt: String, iconId: Int) {
|
||||||
|
noContent.isVisible = true;
|
||||||
|
recycler.isVisible = false;
|
||||||
|
noContent.setText(title, txt, iconId);
|
||||||
|
}
|
||||||
|
fun clearEmpty() {
|
||||||
|
noContent.isVisible = false;
|
||||||
|
recycler.isVisible = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.futo.platformplayer.views
|
package com.futo.platformplayer.views
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.util.AttributeSet
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
@@ -15,6 +16,13 @@ class NoResultsView: ConstraintLayout {
|
|||||||
val icon: ImageView;
|
val icon: ImageView;
|
||||||
val containerExtraViews: LinearLayout;
|
val containerExtraViews: LinearLayout;
|
||||||
|
|
||||||
|
constructor(context: Context, attributes: AttributeSet? = null) : super(context, attributes){
|
||||||
|
inflate(context, R.layout.view_no_results, this);
|
||||||
|
textTitle = findViewById(R.id.text_title)
|
||||||
|
textCentered = findViewById(R.id.text_centered);
|
||||||
|
icon = findViewById(R.id.icon);
|
||||||
|
containerExtraViews = findViewById(R.id.container_extra_views);
|
||||||
|
}
|
||||||
|
|
||||||
constructor(context: Context, title: String, text: String, iconId: Int, extraViews: List<View>) : super(context) {
|
constructor(context: Context, title: String, text: String, iconId: Int, extraViews: List<View>) : super(context) {
|
||||||
inflate(context, R.layout.view_no_results, this);
|
inflate(context, R.layout.view_no_results, this);
|
||||||
@@ -22,13 +30,21 @@ class NoResultsView: ConstraintLayout {
|
|||||||
textCentered = findViewById(R.id.text_centered);
|
textCentered = findViewById(R.id.text_centered);
|
||||||
icon = findViewById(R.id.icon);
|
icon = findViewById(R.id.icon);
|
||||||
containerExtraViews = findViewById(R.id.container_extra_views);
|
containerExtraViews = findViewById(R.id.container_extra_views);
|
||||||
|
|
||||||
|
setText(title, text, iconId, extraViews);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun setText(title: String, text: String, iconId: Int = -1, extraViews: List<View>? = null) {
|
||||||
textTitle.text = title;
|
textTitle.text = title;
|
||||||
textCentered.text = text;
|
textCentered.text = text;
|
||||||
icon.setImageResource(iconId);
|
|
||||||
if(iconId < 0)
|
if(iconId < 0)
|
||||||
icon.visibility = GONE;
|
icon.visibility = GONE;
|
||||||
|
else
|
||||||
|
icon.setImageResource(iconId);
|
||||||
|
|
||||||
for(view in extraViews)
|
if(extraViews != null)
|
||||||
containerExtraViews.addView(view);
|
for(view in extraViews)
|
||||||
|
containerExtraViews.addView(view);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,9 +3,7 @@ package com.futo.platformplayer.views.adapters
|
|||||||
import android.content.ClipData
|
import android.content.ClipData
|
||||||
import android.content.ClipboardManager
|
import android.content.ClipboardManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.view.GestureDetector
|
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.MotionEvent
|
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
@@ -46,6 +44,7 @@ class CommentViewHolder : ViewHolder {
|
|||||||
private val _imageLikeIcon: ImageView;
|
private val _imageLikeIcon: ImageView;
|
||||||
private val _textLikes: TextView;
|
private val _textLikes: TextView;
|
||||||
private val _imageDislikeIcon: ImageView;
|
private val _imageDislikeIcon: ImageView;
|
||||||
|
private val _buttonCopy: PillButton;
|
||||||
private val _textDislikes: TextView;
|
private val _textDislikes: TextView;
|
||||||
private val _buttonReplies: PillButton;
|
private val _buttonReplies: PillButton;
|
||||||
private val _layoutRating: LinearLayout;
|
private val _layoutRating: LinearLayout;
|
||||||
@@ -69,6 +68,7 @@ class CommentViewHolder : ViewHolder {
|
|||||||
_textMetadata = itemView.findViewById(R.id.text_metadata);
|
_textMetadata = itemView.findViewById(R.id.text_metadata);
|
||||||
_textBody = itemView.findViewById(R.id.text_body);
|
_textBody = itemView.findViewById(R.id.text_body);
|
||||||
_imageLikeIcon = itemView.findViewById(R.id.image_like_icon);
|
_imageLikeIcon = itemView.findViewById(R.id.image_like_icon);
|
||||||
|
_buttonCopy = itemView.findViewById(R.id.image_copy);
|
||||||
_textLikes = itemView.findViewById(R.id.text_likes);
|
_textLikes = itemView.findViewById(R.id.text_likes);
|
||||||
_imageDislikeIcon = itemView.findViewById(R.id.image_dislike_icon);
|
_imageDislikeIcon = itemView.findViewById(R.id.image_dislike_icon);
|
||||||
_textDislikes = itemView.findViewById(R.id.text_dislikes);
|
_textDislikes = itemView.findViewById(R.id.text_dislikes);
|
||||||
@@ -103,7 +103,8 @@ class CommentViewHolder : ViewHolder {
|
|||||||
StatePolycentric.instance.updateLikeMap(c.reference, args.hasLiked, args.hasDisliked)
|
StatePolycentric.instance.updateLikeMap(c.reference, args.hasLiked, args.hasDisliked)
|
||||||
};
|
};
|
||||||
|
|
||||||
_layoutComment.setOnLongClickListener {
|
_buttonCopy.setTransparant()
|
||||||
|
_buttonCopy.onClick.subscribe {
|
||||||
val clipboard = viewGroup.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
val clipboard = viewGroup.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
val text = comment?.message.orEmpty()
|
val text = comment?.message.orEmpty()
|
||||||
val clip = ClipData.newPlainText("Comment", text)
|
val clip = ClipData.newPlainText("Comment", text)
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ import android.widget.TextView
|
|||||||
import androidx.constraintlayout.widget.ConstraintLayout
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||||||
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
|
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.constructs.Event0
|
import com.futo.platformplayer.constructs.Event0
|
||||||
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
||||||
import com.futo.platformplayer.models.Playlist
|
import com.futo.platformplayer.models.Playlist
|
||||||
|
import com.futo.platformplayer.withMaxSizePx
|
||||||
|
|
||||||
class PlaylistsViewHolder : ViewHolder {
|
class PlaylistsViewHolder : ViewHolder {
|
||||||
private val _root: ConstraintLayout;
|
private val _root: ConstraintLayout;
|
||||||
@@ -44,6 +46,7 @@ class PlaylistsViewHolder : ViewHolder {
|
|||||||
if (p.videos.isNotEmpty()) {
|
if (p.videos.isNotEmpty()) {
|
||||||
Glide.with(_imageThumbnail)
|
Glide.with(_imageThumbnail)
|
||||||
.load(p.videos[0].thumbnails.getMinimumThumbnail(380))
|
.load(p.videos[0].thumbnails.getMinimumThumbnail(380))
|
||||||
|
.withMaxSizePx()
|
||||||
.placeholder(R.drawable.placeholder_video_thumbnail)
|
.placeholder(R.drawable.placeholder_video_thumbnail)
|
||||||
.crossfade()
|
.crossfade()
|
||||||
.into(_imageThumbnail);
|
.into(_imageThumbnail);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import androidx.constraintlayout.widget.ConstraintLayout
|
|||||||
import androidx.recyclerview.widget.ItemTouchHelper
|
import androidx.recyclerview.widget.ItemTouchHelper
|
||||||
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
|
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||||
import com.futo.platformplayer.constructs.Event1
|
import com.futo.platformplayer.constructs.Event1
|
||||||
@@ -23,6 +24,7 @@ import com.futo.platformplayer.toHumanNumber
|
|||||||
import com.futo.platformplayer.toHumanTime
|
import com.futo.platformplayer.toHumanTime
|
||||||
import com.futo.platformplayer.views.others.ProgressBar
|
import com.futo.platformplayer.views.others.ProgressBar
|
||||||
import com.futo.platformplayer.views.platform.PlatformIndicator
|
import com.futo.platformplayer.views.platform.PlatformIndicator
|
||||||
|
import com.futo.platformplayer.withMaxSizePx
|
||||||
|
|
||||||
class VideoListEditorViewHolder : ViewHolder {
|
class VideoListEditorViewHolder : ViewHolder {
|
||||||
private val _root: ConstraintLayout;
|
private val _root: ConstraintLayout;
|
||||||
@@ -89,6 +91,7 @@ class VideoListEditorViewHolder : ViewHolder {
|
|||||||
fun bind(v: IPlatformVideo, canEdit: Boolean) {
|
fun bind(v: IPlatformVideo, canEdit: Boolean) {
|
||||||
Glide.with(_imageThumbnail)
|
Glide.with(_imageThumbnail)
|
||||||
.load(v.thumbnails.getHQThumbnail())
|
.load(v.thumbnails.getHQThumbnail())
|
||||||
|
.withMaxSizePx()
|
||||||
.placeholder(R.drawable.placeholder_video_thumbnail)
|
.placeholder(R.drawable.placeholder_video_thumbnail)
|
||||||
.crossfade()
|
.crossfade()
|
||||||
.into(_imageThumbnail);
|
.into(_imageThumbnail);
|
||||||
|
|||||||
+5
-4
@@ -37,13 +37,14 @@ class ArtistTileViewHolder(val _viewGroup: ViewGroup) : AnyAdapter.AnyViewHolder
|
|||||||
override fun bind(artist: Artist) {
|
override fun bind(artist: Artist) {
|
||||||
_artist = artist;
|
_artist = artist;
|
||||||
_imageThumbnail?.let {
|
_imageThumbnail?.let {
|
||||||
if (artist.thumbnail != null)
|
val thumbnail = artist.getThumbnailOrAlbum();
|
||||||
|
if (thumbnail != null)
|
||||||
Glide.with(it)
|
Glide.with(it)
|
||||||
.load(artist.thumbnail)
|
.load(thumbnail)
|
||||||
.placeholder(R.drawable.unknown_music)
|
.placeholder(R.drawable.ic_artist)
|
||||||
.into(it)
|
.into(it)
|
||||||
else
|
else
|
||||||
Glide.with(it).load(R.drawable.unknown_music).into(it);
|
Glide.with(it).load(R.drawable.ic_artist).into(it);
|
||||||
};
|
};
|
||||||
|
|
||||||
_textName.text = artist.name;
|
_textName.text = artist.name;
|
||||||
|
|||||||
+2
-2
@@ -42,11 +42,11 @@ class FileViewHolder(private val _viewGroup: ViewGroup) : AnyAdapter.AnyViewHold
|
|||||||
_file = file;
|
_file = file;
|
||||||
_imageThumbnail?.let {
|
_imageThumbnail?.let {
|
||||||
if(file.isDirectory)
|
if(file.isDirectory)
|
||||||
it.setImageResource(R.drawable.ic_library);
|
it.setImageResource(R.drawable.ic_folder);
|
||||||
else {
|
else {
|
||||||
Glide.with(it)
|
Glide.with(it)
|
||||||
.load(file.thumbnail)
|
.load(file.thumbnail)
|
||||||
.placeholder(R.drawable.ic_music)
|
.placeholder(R.drawable.ic_song)
|
||||||
.into(it)
|
.into(it)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+3
@@ -7,6 +7,7 @@ import android.widget.ImageView
|
|||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
|
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||||
@@ -16,6 +17,7 @@ import com.futo.platformplayer.states.Artist
|
|||||||
import com.futo.platformplayer.toHumanNowDiffString
|
import com.futo.platformplayer.toHumanNowDiffString
|
||||||
import com.futo.platformplayer.toHumanTime
|
import com.futo.platformplayer.toHumanTime
|
||||||
import com.futo.platformplayer.views.adapters.AnyAdapter
|
import com.futo.platformplayer.views.adapters.AnyAdapter
|
||||||
|
import com.futo.platformplayer.withMaxSizePx
|
||||||
import com.google.android.material.imageview.ShapeableImageView
|
import com.google.android.material.imageview.ShapeableImageView
|
||||||
|
|
||||||
|
|
||||||
@@ -49,6 +51,7 @@ class LocalVideoTileViewHolder(val _viewGroup: ViewGroup) : AnyAdapter.AnyViewHo
|
|||||||
Glide.with(it)
|
Glide.with(it)
|
||||||
.load(content.thumbnails.getHQThumbnail())
|
.load(content.thumbnails.getHQThumbnail())
|
||||||
.placeholder(R.drawable.unknown_music)
|
.placeholder(R.drawable.unknown_music)
|
||||||
|
.withMaxSizePx()
|
||||||
.into(it)
|
.into(it)
|
||||||
else
|
else
|
||||||
Glide.with(it).load(R.drawable.unknown_music).into(it);
|
Glide.with(it).load(R.drawable.unknown_music).into(it);
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import kotlinx.coroutines.launch
|
|||||||
import java.text.DecimalFormat
|
import java.text.DecimalFormat
|
||||||
import java.text.DecimalFormatSymbols
|
import java.text.DecimalFormatSymbols
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
|
||||||
class GestureControlView : LinearLayout {
|
class GestureControlView : LinearLayout {
|
||||||
@@ -114,6 +115,7 @@ class GestureControlView : LinearLayout {
|
|||||||
val onZoom = Event1<Float>();
|
val onZoom = Event1<Float>();
|
||||||
val onSoundAdjusted = Event1<Float>();
|
val onSoundAdjusted = Event1<Float>();
|
||||||
val onToggleFullscreen = Event0();
|
val onToggleFullscreen = Event0();
|
||||||
|
val onTogglePlayPause = Event0();
|
||||||
val onSpeedHoldStart = Event0()
|
val onSpeedHoldStart = Event0()
|
||||||
val onSpeedHoldEnd = Event0()
|
val onSpeedHoldEnd = Event0()
|
||||||
|
|
||||||
@@ -269,8 +271,19 @@ class GestureControlView : LinearLayout {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
val rewinding = (ev.x / width) < 0.5;
|
val centerArea = if (height != 0) 0.33f + (0.20f - 0.33f) * (((width.toFloat() / height.toFloat()) - 1f) / ((20f / 9f) - 1f)).coerceIn(0f, 1f) else 0.2f
|
||||||
startFastForward(rewinding);
|
val rewindArea = (1 - centerArea) / 2
|
||||||
|
val forwardArea = rewindArea
|
||||||
|
assert(abs(centerArea + rewindArea + forwardArea - 1) < 0.01)
|
||||||
|
|
||||||
|
val xfrac = ev.x / width
|
||||||
|
if (xfrac <= rewindArea) {
|
||||||
|
startFastForward(true)
|
||||||
|
} else if (xfrac >= 1 - forwardArea) {
|
||||||
|
startFastForward(false)
|
||||||
|
} else {
|
||||||
|
onTogglePlayPause.emit()
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.futo.platformplayer.views.buttons
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
|
import android.os.Looper
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
import android.util.TypedValue
|
import android.util.TypedValue
|
||||||
import android.view.View
|
import android.view.View
|
||||||
@@ -98,46 +99,58 @@ open class BigButton : LinearLayout {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
fun withIcon(resourceId: Int, rounded: Boolean = false): BigButton {
|
private fun applyIcon(resourceId: Int, rounded: Boolean) {
|
||||||
if (resourceId != -1) {
|
if (resourceId != -1) {
|
||||||
_icon.visibility = View.VISIBLE;
|
_icon.visibility = View.VISIBLE
|
||||||
_icon.setImageResource(resourceId);
|
_icon.setImageResource(resourceId)
|
||||||
} else
|
|
||||||
_icon.visibility = View.GONE;
|
|
||||||
|
|
||||||
if (rounded) {
|
|
||||||
val shapeAppearanceModel = ShapeAppearanceModel().toBuilder()
|
|
||||||
.setAllCornerSizes(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 16.0f, context.resources.displayMetrics))
|
|
||||||
.build();
|
|
||||||
|
|
||||||
_icon.scaleType = ImageView.ScaleType.FIT_CENTER;
|
|
||||||
_icon.shapeAppearanceModel = shapeAppearanceModel;
|
|
||||||
} else {
|
} else {
|
||||||
_icon.scaleType = ImageView.ScaleType.CENTER_CROP;
|
_icon.visibility = View.GONE
|
||||||
_icon.shapeAppearanceModel = ShapeAppearanceModel();
|
|
||||||
}
|
}
|
||||||
|
applyRounded(rounded)
|
||||||
return this;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun withIcon(resourceId: Int, rounded: Boolean = false): BigButton {
|
||||||
|
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||||
|
applyIcon(resourceId, rounded)
|
||||||
|
} else {
|
||||||
|
post { applyIcon(resourceId, rounded) }
|
||||||
|
}
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
fun withIcon(bitmap: Bitmap, rounded: Boolean = false): BigButton {
|
fun withIcon(bitmap: Bitmap, rounded: Boolean = false): BigButton {
|
||||||
_icon.visibility = View.VISIBLE;
|
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||||
_icon.setImageBitmap(bitmap);
|
applyIcon(bitmap, rounded)
|
||||||
|
|
||||||
if (rounded) {
|
|
||||||
val shapeAppearanceModel = ShapeAppearanceModel().toBuilder()
|
|
||||||
.setAllCornerSizes(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 16.0f, context.resources.displayMetrics))
|
|
||||||
.build();
|
|
||||||
|
|
||||||
_icon.scaleType = ImageView.ScaleType.FIT_CENTER;
|
|
||||||
_icon.shapeAppearanceModel = shapeAppearanceModel;
|
|
||||||
} else {
|
} else {
|
||||||
_icon.scaleType = ImageView.ScaleType.CENTER_CROP;
|
post { applyIcon(bitmap, rounded) }
|
||||||
_icon.shapeAppearanceModel = ShapeAppearanceModel();
|
|
||||||
}
|
}
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
return this;
|
private fun applyRounded(rounded: Boolean) {
|
||||||
|
if (rounded) {
|
||||||
|
val radiusPx = TypedValue.applyDimension(
|
||||||
|
TypedValue.COMPLEX_UNIT_DIP,
|
||||||
|
16.0f,
|
||||||
|
context.resources.displayMetrics
|
||||||
|
)
|
||||||
|
val shapeAppearanceModel = ShapeAppearanceModel()
|
||||||
|
.toBuilder()
|
||||||
|
.setAllCornerSizes(radiusPx)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
_icon.scaleType = ImageView.ScaleType.FIT_CENTER
|
||||||
|
_icon.shapeAppearanceModel = shapeAppearanceModel
|
||||||
|
} else {
|
||||||
|
_icon.scaleType = ImageView.ScaleType.CENTER_CROP
|
||||||
|
_icon.shapeAppearanceModel = ShapeAppearanceModel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyIcon(bitmap: Bitmap, rounded: Boolean) {
|
||||||
|
_icon.visibility = View.VISIBLE
|
||||||
|
_icon.setImageBitmap(bitmap)
|
||||||
|
applyRounded(rounded)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun withBackground(resourceId: Int): BigButton {
|
fun withBackground(resourceId: Int): BigButton {
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.futo.platformplayer.views.buttons
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.collection.emptyLongSet
|
||||||
|
import com.futo.platformplayer.R
|
||||||
|
import com.futo.platformplayer.constructs.Event0
|
||||||
|
import com.futo.platformplayer.views.pills.PillButton
|
||||||
|
|
||||||
|
class ButtonsContainer : LinearLayout {
|
||||||
|
|
||||||
|
val container_buttons: LinearLayout
|
||||||
|
|
||||||
|
var currentButtons: List<Button> = listOf();
|
||||||
|
|
||||||
|
constructor(context: Context, buttons: List<Button>) : super(context) {
|
||||||
|
inflate(context, R.layout.view_buttons, this)
|
||||||
|
container_buttons = findViewById(R.id.container_buttons);
|
||||||
|
setButtons(buttons);
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setButtons(buttons: List<Button>) {
|
||||||
|
this.currentButtons = buttons;
|
||||||
|
container_buttons.removeAllViews();
|
||||||
|
for(button in buttons) {
|
||||||
|
container_buttons.addView(StandardButton(context, button.name) {
|
||||||
|
button?.handler?.invoke();
|
||||||
|
}.apply {
|
||||||
|
this.layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||||
|
this.weight = 1f;
|
||||||
|
};
|
||||||
|
if(button.background != null)
|
||||||
|
this.withBackground(button.background);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Button(
|
||||||
|
val name: String,
|
||||||
|
val background: Int?,
|
||||||
|
val handler: (()->Unit),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.futo.platformplayer.views.buttons
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.TextView
|
||||||
|
import com.futo.platformplayer.R
|
||||||
|
|
||||||
|
class StandardButton : LinearLayout {
|
||||||
|
private val _root: LinearLayout;
|
||||||
|
private val _text: TextView;
|
||||||
|
|
||||||
|
constructor(context: Context, text: String, onClick: ()->Unit) : super(context) {
|
||||||
|
inflate(context, R.layout.view_button_standard, this);
|
||||||
|
_root = findViewById(R.id.root);
|
||||||
|
_text = findViewById(R.id.text_button);
|
||||||
|
_text.text = text;
|
||||||
|
_root.setOnClickListener {
|
||||||
|
onClick.invoke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun withPrimaryBackground(): StandardButton {
|
||||||
|
_root.setBackgroundResource(R.drawable.background_button_primary)
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
fun withAccentBackground(): StandardButton {
|
||||||
|
_root.setBackgroundResource(R.drawable.background_button_accent)
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
fun withBackground(id: Int): StandardButton {
|
||||||
|
_root.setBackgroundResource(id);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import com.futo.platformplayer.R
|
|||||||
import com.futo.platformplayer.Settings
|
import com.futo.platformplayer.Settings
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.casting.CastConnectionState
|
import com.futo.platformplayer.casting.CastConnectionState
|
||||||
|
import com.futo.platformplayer.casting.CastingDevice
|
||||||
import com.futo.platformplayer.casting.StateCasting
|
import com.futo.platformplayer.casting.StateCasting
|
||||||
import com.futo.platformplayer.constructs.Event1
|
import com.futo.platformplayer.constructs.Event1
|
||||||
|
|
||||||
@@ -22,18 +23,16 @@ class CastButton : androidx.appcompat.widget.AppCompatImageButton {
|
|||||||
visibility = View.GONE;
|
visibility = View.GONE;
|
||||||
}
|
}
|
||||||
|
|
||||||
StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, _ ->
|
StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { d, _ ->
|
||||||
updateCastState();
|
updateCastState(d);
|
||||||
};
|
};
|
||||||
|
|
||||||
updateCastState();
|
updateCastState(StateCasting.instance.activeDevice);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateCastState() {
|
private fun updateCastState(d: CastingDevice?) {
|
||||||
val c = context ?: return;
|
val c = context ?: return;
|
||||||
val d = StateCasting.instance.activeDevice;
|
|
||||||
|
|
||||||
val activeColor = ContextCompat.getColor(c, R.color.colorPrimary);
|
val activeColor = ContextCompat.getColor(c, R.color.colorPrimary);
|
||||||
val connectingColor = ContextCompat.getColor(c, R.color.gray_c3);
|
val connectingColor = ContextCompat.getColor(c, R.color.gray_c3);
|
||||||
val inactiveColor = ContextCompat.getColor(c, R.color.white);
|
val inactiveColor = ContextCompat.getColor(c, R.color.white);
|
||||||
|
|||||||
@@ -13,10 +13,12 @@ import android.widget.ImageView
|
|||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.constraintlayout.widget.ConstraintLayout
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.ui.DefaultTimeBar
|
import androidx.media3.ui.DefaultTimeBar
|
||||||
import androidx.media3.ui.TimeBar
|
import androidx.media3.ui.TimeBar
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
|
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.Settings
|
import com.futo.platformplayer.Settings
|
||||||
import com.futo.platformplayer.api.media.models.chapters.IChapter
|
import com.futo.platformplayer.api.media.models.chapters.IChapter
|
||||||
@@ -31,6 +33,7 @@ import com.futo.platformplayer.logging.Logger
|
|||||||
import com.futo.platformplayer.states.StatePlayer
|
import com.futo.platformplayer.states.StatePlayer
|
||||||
import com.futo.platformplayer.views.TargetTapLoaderView
|
import com.futo.platformplayer.views.TargetTapLoaderView
|
||||||
import com.futo.platformplayer.views.behavior.GestureControlView
|
import com.futo.platformplayer.views.behavior.GestureControlView
|
||||||
|
import com.futo.platformplayer.withMaxSizePx
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -43,6 +46,7 @@ class CastView : ConstraintLayout {
|
|||||||
private val _buttonSettings: ImageButton;
|
private val _buttonSettings: ImageButton;
|
||||||
private val _buttonLoop: ImageButton;
|
private val _buttonLoop: ImageButton;
|
||||||
private val _buttonPlay: ImageButton;
|
private val _buttonPlay: ImageButton;
|
||||||
|
private val _buttonAutoplay: ImageButton;
|
||||||
private val _buttonPrevious: ImageButton;
|
private val _buttonPrevious: ImageButton;
|
||||||
private val _buttonNext: ImageButton;
|
private val _buttonNext: ImageButton;
|
||||||
private val _buttonPause: ImageButton;
|
private val _buttonPause: ImageButton;
|
||||||
@@ -78,6 +82,7 @@ class CastView : ConstraintLayout {
|
|||||||
_buttonMinimize = findViewById(R.id.button_minimize);
|
_buttonMinimize = findViewById(R.id.button_minimize);
|
||||||
_buttonSettings = findViewById(R.id.button_settings);
|
_buttonSettings = findViewById(R.id.button_settings);
|
||||||
_buttonLoop = findViewById(R.id.button_loop);
|
_buttonLoop = findViewById(R.id.button_loop);
|
||||||
|
_buttonAutoplay = findViewById(R.id.button_autoplay);
|
||||||
_buttonPlay = findViewById(R.id.button_play);
|
_buttonPlay = findViewById(R.id.button_play);
|
||||||
_buttonPrevious = findViewById(R.id.button_previous);
|
_buttonPrevious = findViewById(R.id.button_previous);
|
||||||
_buttonNext = findViewById(R.id.button_next);
|
_buttonNext = findViewById(R.id.button_next);
|
||||||
@@ -119,6 +124,15 @@ class CastView : ConstraintLayout {
|
|||||||
Logger.e(TAG, "Failed to change playback speed to previous hold playback speed: $e")
|
Logger.e(TAG, "Failed to change playback speed to previous hold playback speed: $e")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
_gestureControlView.onTogglePlayPause.subscribe {
|
||||||
|
StateCasting.instance.activeDevice?.let { d ->
|
||||||
|
if (d.isPlaying) {
|
||||||
|
d.pausePlayback()
|
||||||
|
} else {
|
||||||
|
d.resumePlayback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_gestureControlView.onSeek.subscribe {
|
_gestureControlView.onSeek.subscribe {
|
||||||
val d = StateCasting.instance.activeDevice ?: return@subscribe;
|
val d = StateCasting.instance.activeDevice ?: return@subscribe;
|
||||||
@@ -169,6 +183,17 @@ class CastView : ConstraintLayout {
|
|||||||
updateNextPrevious();
|
updateNextPrevious();
|
||||||
_buttonPrevious.setOnClickListener { onPrevious.emit() };
|
_buttonPrevious.setOnClickListener { onPrevious.emit() };
|
||||||
_buttonNext.setOnClickListener { onNext.emit() };
|
_buttonNext.setOnClickListener { onNext.emit() };
|
||||||
|
|
||||||
|
_buttonAutoplay.setOnClickListener {
|
||||||
|
StatePlayer.instance.autoplay = !StatePlayer.instance.autoplay;
|
||||||
|
updateAutoplayButton()
|
||||||
|
}
|
||||||
|
updateAutoplayButton()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateAutoplayButton() {
|
||||||
|
_buttonAutoplay.setColorFilter(ContextCompat.getColor(context, if (StatePlayer.instance.autoplay) com.futo.futopay.R.color.primary else R.color.white))
|
||||||
|
_buttonAutoplay.setColorFilter(ContextCompat.getColor(context, if (StatePlayer.instance.autoplay) com.futo.futopay.R.color.primary else R.color.white))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateCurrentChapter(chaptPos: Long, isScrub: Boolean = false): Boolean {
|
private fun updateCurrentChapter(chaptPos: Long, isScrub: Boolean = false): Boolean {
|
||||||
@@ -283,6 +308,7 @@ class CastView : ConstraintLayout {
|
|||||||
Glide.with(_thumbnail)
|
Glide.with(_thumbnail)
|
||||||
.load(video.thumbnails.getHQThumbnail())
|
.load(video.thumbnails.getHQThumbnail())
|
||||||
.placeholder(R.drawable.placeholder_video_thumbnail)
|
.placeholder(R.drawable.placeholder_video_thumbnail)
|
||||||
|
.withMaxSizePx()
|
||||||
.into(_thumbnail);
|
.into(_thumbnail);
|
||||||
_textPosition.text = (position * 1000).formatDuration();
|
_textPosition.text = (position * 1000).formatDuration();
|
||||||
_textDuration.text = (video.duration * 1000).formatDuration();
|
_textDuration.text = (video.duration * 1000).formatDuration();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import android.widget.ImageView
|
|||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
|
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||||
import com.futo.platformplayer.*
|
import com.futo.platformplayer.*
|
||||||
import com.futo.platformplayer.downloads.VideoDownload
|
import com.futo.platformplayer.downloads.VideoDownload
|
||||||
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
||||||
@@ -61,6 +62,7 @@ class ActiveDownloadItem: LinearLayout {
|
|||||||
|
|
||||||
Glide.with(_videoImage)
|
Glide.with(_videoImage)
|
||||||
.load(download.thumbnail)
|
.load(download.thumbnail)
|
||||||
|
.withMaxSizePx()
|
||||||
.crossfade()
|
.crossfade()
|
||||||
.into(_videoImage);
|
.into(_videoImage);
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import android.widget.ImageView
|
|||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
|
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
||||||
import com.futo.platformplayer.models.PlaylistDownloaded
|
import com.futo.platformplayer.models.PlaylistDownloaded
|
||||||
|
import com.futo.platformplayer.withMaxSizePx
|
||||||
|
|
||||||
class PlaylistDownloadItem(context: Context, playlistName: String, playlistThumbnail: String?, val obj: Any): LinearLayout(context) {
|
class PlaylistDownloadItem(context: Context, playlistName: String, playlistThumbnail: String?, val obj: Any): LinearLayout(context) {
|
||||||
init { inflate(context, R.layout.list_downloaded_playlist, this) }
|
init { inflate(context, R.layout.list_downloaded_playlist, this) }
|
||||||
@@ -19,6 +21,7 @@ class PlaylistDownloadItem(context: Context, playlistName: String, playlistThumb
|
|||||||
imageText.text = playlistName;
|
imageText.text = playlistName;
|
||||||
Glide.with(imageView)
|
Glide.with(imageView)
|
||||||
.load(playlistThumbnail)
|
.load(playlistThumbnail)
|
||||||
|
.withMaxSizePx()
|
||||||
.crossfade()
|
.crossfade()
|
||||||
.into(imageView);
|
.into(imageView);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ class VideoListEditorView : FrameLayout {
|
|||||||
executeDelete()
|
executeDelete()
|
||||||
}, cancelAction = {
|
}, cancelAction = {
|
||||||
|
|
||||||
}, doNotAskAgainAction = {
|
}, dismissAction = {}, doNotAskAgainAction = {
|
||||||
Settings.instance.other.playlistDeleteConfirmation = false
|
Settings.instance.other.playlistDeleteConfirmation = false
|
||||||
Settings.instance.save()
|
Settings.instance.save()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -54,9 +54,14 @@ class CreatorThumbnail : ConstraintLayout {
|
|||||||
setNewActivity(false);
|
setNewActivity(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setThumbnail(url: String?, animate: Boolean) {
|
fun setThumbnail(url: String?, animate: Boolean, isArtist: Boolean = false) {
|
||||||
if (url == null) {
|
if (url == null) {
|
||||||
clear();
|
if(isArtist) {
|
||||||
|
_imageChannelThumbnail.setImageResource(R.drawable.ic_artist);
|
||||||
|
_imageChannelThumbnail.visibility = View.VISIBLE;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
clear();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,18 +83,21 @@ class CreatorThumbnail : ConstraintLayout {
|
|||||||
} else {
|
} else {
|
||||||
setHarborAvailable(false, animate, null);
|
setHarborAvailable(false, animate, null);
|
||||||
}
|
}
|
||||||
|
var placeholder = R.drawable.placeholder_channel_thumbnail;
|
||||||
|
if(url.startsWith("content://") || isArtist)
|
||||||
|
placeholder = R.drawable.ic_artist;
|
||||||
|
|
||||||
if (animate) {
|
if (animate) {
|
||||||
Glide.with(_imageChannelThumbnail)
|
Glide.with(_imageChannelThumbnail)
|
||||||
.load(url)
|
.load(url)
|
||||||
.placeholder(R.drawable.placeholder_channel_thumbnail)
|
.placeholder(placeholder)
|
||||||
.diskCacheStrategy(DiskCacheStrategy.AUTOMATIC)
|
.diskCacheStrategy(DiskCacheStrategy.AUTOMATIC)
|
||||||
.crossfade()
|
.crossfade()
|
||||||
.into(_imageChannelThumbnail)
|
.into(_imageChannelThumbnail)
|
||||||
} else {
|
} else {
|
||||||
Glide.with(_imageChannelThumbnail)
|
Glide.with(_imageChannelThumbnail)
|
||||||
.load(url)
|
.load(url)
|
||||||
.placeholder(R.drawable.placeholder_channel_thumbnail)
|
.placeholder(placeholder)
|
||||||
.diskCacheStrategy(DiskCacheStrategy.AUTOMATIC)
|
.diskCacheStrategy(DiskCacheStrategy.AUTOMATIC)
|
||||||
.into(_imageChannelThumbnail);
|
.into(_imageChannelThumbnail);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,22 +7,27 @@ import android.view.View
|
|||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.core.view.isVisible
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.constructs.Event0
|
import com.futo.platformplayer.constructs.Event0
|
||||||
|
import com.futo.platformplayer.dp
|
||||||
import com.futo.platformplayer.views.LoaderView
|
import com.futo.platformplayer.views.LoaderView
|
||||||
|
|
||||||
class PillButton : LinearLayout {
|
class PillButton : LinearLayout {
|
||||||
|
val root: LinearLayout;
|
||||||
val icon: ImageView;
|
val icon: ImageView;
|
||||||
val text: TextView;
|
val text: TextView;
|
||||||
val loaderView: LoaderView;
|
val loaderView: LoaderView;
|
||||||
val onClick = Event0();
|
val onClick = Event0();
|
||||||
private var _isLoading = false;
|
private var _isLoading = false;
|
||||||
|
|
||||||
constructor(context : Context, attrs : AttributeSet) : super(context, attrs) {
|
constructor(context : Context, attrs : AttributeSet?) : super(context, attrs) {
|
||||||
LayoutInflater.from(context).inflate(R.layout.pill_button, this, true);
|
LayoutInflater.from(context).inflate(R.layout.pill_button, this, true);
|
||||||
icon = findViewById(R.id.pill_icon);
|
icon = findViewById(R.id.pill_icon);
|
||||||
text = findViewById(R.id.pill_text);
|
text = findViewById(R.id.pill_text);
|
||||||
loaderView = findViewById(R.id.loader)
|
loaderView = findViewById(R.id.loader)
|
||||||
|
root = findViewById<LinearLayout>(R.id.root);
|
||||||
|
|
||||||
val attrArr = context.obtainStyledAttributes(attrs, R.styleable.PillButton, 0, 0);
|
val attrArr = context.obtainStyledAttributes(attrs, R.styleable.PillButton, 0, 0);
|
||||||
val attrIconRef = attrArr.getResourceId(R.styleable.PillButton_pillIcon, -1);
|
val attrIconRef = attrArr.getResourceId(R.styleable.PillButton_pillIcon, -1);
|
||||||
@@ -34,6 +39,13 @@ class PillButton : LinearLayout {
|
|||||||
val attrText = attrArr.getText(R.styleable.PillButton_pillText) ?: "";
|
val attrText = attrArr.getText(R.styleable.PillButton_pillText) ?: "";
|
||||||
text.text = attrText;
|
text.text = attrText;
|
||||||
|
|
||||||
|
if(text.text.isNullOrBlank()) {
|
||||||
|
val dp6 = 6.dp(resources);
|
||||||
|
val dp7 = 7.dp(resources);
|
||||||
|
val dp12 = 12.dp(resources);
|
||||||
|
root.setPadding(dp7, dp6, dp7, dp7)
|
||||||
|
}
|
||||||
|
|
||||||
findViewById<LinearLayout>(R.id.root).setOnClickListener {
|
findViewById<LinearLayout>(R.id.root).setOnClickListener {
|
||||||
if (_isLoading) {
|
if (_isLoading) {
|
||||||
return@setOnClickListener
|
return@setOnClickListener
|
||||||
@@ -43,6 +55,10 @@ class PillButton : LinearLayout {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setTransparant() {
|
||||||
|
root.setBackgroundColor(0);
|
||||||
|
}
|
||||||
|
|
||||||
fun setLoading(loading: Boolean) {
|
fun setLoading(loading: Boolean) {
|
||||||
if (loading == _isLoading) {
|
if (loading == _isLoading) {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
|||||||
import com.futo.platformplayer.api.media.models.comments.LazyComment
|
import com.futo.platformplayer.api.media.models.comments.LazyComment
|
||||||
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
|
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
|
||||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
||||||
|
import com.futo.platformplayer.api.media.structures.EmptyPager
|
||||||
import com.futo.platformplayer.api.media.structures.IAsyncPager
|
import com.futo.platformplayer.api.media.structures.IAsyncPager
|
||||||
import com.futo.platformplayer.api.media.structures.IPager
|
import com.futo.platformplayer.api.media.structures.IPager
|
||||||
import com.futo.platformplayer.constructs.Event1
|
import com.futo.platformplayer.constructs.Event1
|
||||||
@@ -224,6 +225,12 @@ class CommentsList : ConstraintLayout {
|
|||||||
_commentsPager = pager;
|
_commentsPager = pager;
|
||||||
onCommentsLoaded.emit(_comments.size);
|
onCommentsLoaded.emit(_comments.size);
|
||||||
}
|
}
|
||||||
|
fun clearComments() {
|
||||||
|
_comments.clear();
|
||||||
|
_adapterComments.notifyDataSetChanged();
|
||||||
|
_commentsPager = EmptyPager();
|
||||||
|
onCommentsLoaded.emit(0);
|
||||||
|
}
|
||||||
|
|
||||||
fun load(readonly: Boolean, loader: suspend () -> IPager<IPlatformComment>) {
|
fun load(readonly: Boolean, loader: suspend () -> IPager<IPlatformComment>) {
|
||||||
cancel();
|
cancel();
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import androidx.media3.common.util.UnstableApi
|
|||||||
import androidx.media3.ui.PlayerControlView
|
import androidx.media3.ui.PlayerControlView
|
||||||
import androidx.media3.ui.PlayerView
|
import androidx.media3.ui.PlayerView
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
|
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||||
import com.bumptech.glide.request.target.CustomTarget
|
import com.bumptech.glide.request.target.CustomTarget
|
||||||
import com.bumptech.glide.request.transition.Transition
|
import com.bumptech.glide.request.transition.Transition
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
@@ -25,6 +26,7 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
|||||||
import com.futo.platformplayer.helpers.VideoHelper
|
import com.futo.platformplayer.helpers.VideoHelper
|
||||||
import com.futo.platformplayer.toHumanTime
|
import com.futo.platformplayer.toHumanTime
|
||||||
import com.futo.platformplayer.video.PlayerManager
|
import com.futo.platformplayer.video.PlayerManager
|
||||||
|
import com.futo.platformplayer.withMaxSizePx
|
||||||
|
|
||||||
|
|
||||||
class FutoThumbnailPlayer : FutoVideoPlayerBase {
|
class FutoThumbnailPlayer : FutoVideoPlayerBase {
|
||||||
@@ -135,7 +137,7 @@ class FutoThumbnailPlayer : FutoVideoPlayerBase {
|
|||||||
if (videoSource == null && audioSource != null) {
|
if (videoSource == null && audioSource != null) {
|
||||||
val thumbnail = video.thumbnails.getHQThumbnail();
|
val thumbnail = video.thumbnails.getHQThumbnail();
|
||||||
if (!thumbnail.isNullOrBlank()) {
|
if (!thumbnail.isNullOrBlank()) {
|
||||||
Glide.with(videoView).asBitmap().load(thumbnail).into(_loadArtwork);
|
Glide.with(videoView).asBitmap().load(thumbnail).withMaxSizePx().into(_loadArtwork);
|
||||||
} else {
|
} else {
|
||||||
Glide.with(videoView).clear(_loadArtwork);
|
Glide.with(videoView).clear(_loadArtwork);
|
||||||
setArtwork(null);
|
setArtwork(null);
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import com.futo.platformplayer.states.StatePlayer
|
|||||||
import com.futo.platformplayer.views.TargetTapLoaderView
|
import com.futo.platformplayer.views.TargetTapLoaderView
|
||||||
import com.futo.platformplayer.views.behavior.GestureControlView
|
import com.futo.platformplayer.views.behavior.GestureControlView
|
||||||
import com.futo.platformplayer.views.others.ProgressBar
|
import com.futo.platformplayer.views.others.ProgressBar
|
||||||
|
import com.futo.platformplayer.withMaxSizePx
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.GlobalScope
|
import kotlinx.coroutines.GlobalScope
|
||||||
@@ -285,6 +286,15 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
|||||||
player.play()
|
player.play()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
gestureControl.onTogglePlayPause.subscribe {
|
||||||
|
exoPlayer?.player?.let { player ->
|
||||||
|
if (player.playWhenReady) {
|
||||||
|
player.pause()
|
||||||
|
} else {
|
||||||
|
player.play()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
gestureControl.onSpeedHoldEnd.subscribe {
|
gestureControl.onSpeedHoldEnd.subscribe {
|
||||||
exoPlayer?.player?.let { player ->
|
exoPlayer?.player?.let { player ->
|
||||||
if (!_speedHoldWasPlaying) player.pause()
|
if (!_speedHoldWasPlaying) player.pause()
|
||||||
@@ -479,7 +489,7 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
|||||||
|
|
||||||
StatePlayer.instance.onQueueChanged.subscribe(this) {
|
StatePlayer.instance.onQueueChanged.subscribe(this) {
|
||||||
CoroutineScope(Dispatchers.Main).launch(Dispatchers.Main) {
|
CoroutineScope(Dispatchers.Main).launch(Dispatchers.Main) {
|
||||||
setLoopVisible(!StatePlayer.instance.hasQueue)
|
//setLoopVisible(!StatePlayer.instance.hasQueue)
|
||||||
updateNextPrevious();
|
updateNextPrevious();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -876,12 +886,12 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
|||||||
}
|
}
|
||||||
fun updateLoopVideoUI() {
|
fun updateLoopVideoUI() {
|
||||||
if(StatePlayer.instance.loopVideo) {
|
if(StatePlayer.instance.loopVideo) {
|
||||||
_control_loop.setImageResource(R.drawable.ic_loop_active);
|
_control_loop.setImageResource(R.drawable.ic_repeat_one_active);
|
||||||
_control_loop_fullscreen.setImageResource(R.drawable.ic_loop_active);
|
_control_loop_fullscreen.setImageResource(R.drawable.ic_repeat_one_active);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
_control_loop.setImageResource(R.drawable.ic_loop);
|
_control_loop.setImageResource(R.drawable.ic_repeat_one);
|
||||||
_control_loop_fullscreen.setImageResource(R.drawable.ic_loop);
|
_control_loop_fullscreen.setImageResource(R.drawable.ic_repeat_one);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -919,11 +929,9 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
|||||||
override fun switchToAudioMode(video: IPlatformVideoDetails?) {
|
override fun switchToAudioMode(video: IPlatformVideoDetails?) {
|
||||||
super.switchToAudioMode(video)
|
super.switchToAudioMode(video)
|
||||||
|
|
||||||
//This causes issues, and is in general confusing, needs improvements
|
|
||||||
/*
|
|
||||||
val thumbnail = video?.thumbnails?.getHQThumbnail()
|
val thumbnail = video?.thumbnails?.getHQThumbnail()
|
||||||
if (!thumbnail.isNullOrBlank()) {
|
if (!thumbnail.isNullOrBlank()) {
|
||||||
Glide.with(context).asBitmap().load(thumbnail)
|
Glide.with(context).asBitmap().load(thumbnail).withMaxSizePx()
|
||||||
.into(object : CustomTarget<Bitmap>() {
|
.into(object : CustomTarget<Bitmap>() {
|
||||||
override fun onResourceReady(
|
override fun onResourceReady(
|
||||||
resource: Bitmap,
|
resource: Bitmap,
|
||||||
@@ -937,6 +945,5 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
*/
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -923,7 +923,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
|||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
private fun loadSelectedSources(play: Boolean, resume: Boolean): Boolean {
|
private fun loadSelectedSources(play: Boolean, resume: Boolean): Boolean {
|
||||||
val sourceVideo = if(!isAudioMode || _lastAudioMediaSource == null) _lastVideoMediaSource else null;
|
val sourceVideo = _lastVideoMediaSource;
|
||||||
val sourceAudio = _lastAudioMediaSource;
|
val sourceAudio = _lastAudioMediaSource;
|
||||||
val sourceSubs = _lastSubtitleMediaSource;
|
val sourceSubs = _lastSubtitleMediaSource;
|
||||||
|
|
||||||
|
|||||||
+12
@@ -111,6 +111,10 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource {
|
|||||||
* @return This factory.
|
* @return This factory.
|
||||||
*/
|
*/
|
||||||
public Factory setRequestExecutor(@Nullable JSRequestExecutor requestExecutor) {
|
public Factory setRequestExecutor(@Nullable JSRequestExecutor requestExecutor) {
|
||||||
|
JSRequestExecutor oldExecutor = this.requestExecutor;
|
||||||
|
if(oldExecutor != null) {
|
||||||
|
oldExecutor.closeAsync();
|
||||||
|
}
|
||||||
this.requestExecutor = requestExecutor;
|
this.requestExecutor = requestExecutor;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
@@ -123,6 +127,10 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource {
|
|||||||
* @return This factory.
|
* @return This factory.
|
||||||
*/
|
*/
|
||||||
public Factory setRequestExecutor2(@Nullable JSRequestExecutor requestExecutor) {
|
public Factory setRequestExecutor2(@Nullable JSRequestExecutor requestExecutor) {
|
||||||
|
JSRequestExecutor oldExecutor = this.requestExecutor2;
|
||||||
|
if(oldExecutor != null) {
|
||||||
|
oldExecutor.closeAsync();
|
||||||
|
}
|
||||||
this.requestExecutor2 = requestExecutor;
|
this.requestExecutor2 = requestExecutor;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
@@ -508,6 +516,10 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void close() throws HttpDataSourceException {
|
public void close() throws HttpDataSourceException {
|
||||||
|
if(requestExecutor != null)
|
||||||
|
requestExecutor.closeAsync();
|
||||||
|
if(requestExecutor2 != null)
|
||||||
|
requestExecutor2.closeAsync();
|
||||||
try {
|
try {
|
||||||
@Nullable InputStream inputStream = this.inputStream;
|
@Nullable InputStream inputStream = this.inputStream;
|
||||||
if (inputStream != null) {
|
if (inputStream != null) {
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ import android.widget.TextView
|
|||||||
import androidx.constraintlayout.widget.ConstraintLayout
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
|
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||||
import com.futo.platformplayer.states.StatePlayer
|
import com.futo.platformplayer.states.StatePlayer
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.constructs.Event0
|
import com.futo.platformplayer.constructs.Event0
|
||||||
import com.futo.platformplayer.toHumanNowDiffString
|
import com.futo.platformplayer.toHumanNowDiffString
|
||||||
import com.futo.platformplayer.toHumanNumber
|
import com.futo.platformplayer.toHumanNumber
|
||||||
|
import com.futo.platformplayer.withMaxSizePx
|
||||||
|
|
||||||
class UpNextView : LinearLayout {
|
class UpNextView : LinearLayout {
|
||||||
private val _layoutContainer: LinearLayout;
|
private val _layoutContainer: LinearLayout;
|
||||||
@@ -160,6 +162,7 @@ class UpNextView : LinearLayout {
|
|||||||
_textChannelName.text = nextItem.author.name;
|
_textChannelName.text = nextItem.author.name;
|
||||||
Glide.with(_imageThumbnail)
|
Glide.with(_imageThumbnail)
|
||||||
.load(nextItem.thumbnails.getHQThumbnail())
|
.load(nextItem.thumbnails.getHQThumbnail())
|
||||||
|
.withMaxSizePx()
|
||||||
.placeholder(R.drawable.placeholder_video_thumbnail)
|
.placeholder(R.drawable.placeholder_video_thumbnail)
|
||||||
.into(_imageThumbnail);
|
.into(_imageThumbnail);
|
||||||
Glide.with(_imageChannelThumbnail)
|
Glide.with(_imageChannelThumbnail)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user