mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-16 13:02:39 +02:00
Compare commits
65 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 09fd4c0881 | |||
| 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 |
@@ -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)
|
||||
|
||||
//JS
|
||||
implementation 'com.caoccao.javet:javet-v8-android:5.0.1'
|
||||
implementation 'com.caoccao.javet:javet-v8-android:4.1.5'
|
||||
|
||||
//Exoplayer
|
||||
implementation 'androidx.media3:media3-exoplayer:1.8.0'
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.FutoVideo"
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:replace="android:enableOnBackInvokedCallback"
|
||||
android:enableOnBackInvokedCallback="false"
|
||||
tools:targetApi="31"
|
||||
android:largeHeap="true">
|
||||
<provider
|
||||
@@ -61,6 +63,7 @@
|
||||
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|uiMode"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar"
|
||||
android:windowSoftInputMode="adjustPan"
|
||||
android:launchMode="singleInstance"
|
||||
android:resizeableActivity="true"
|
||||
android:supportsPictureInPicture="true">
|
||||
@@ -249,5 +252,21 @@
|
||||
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>
|
||||
</manifest>
|
||||
|
||||
@@ -875,9 +875,9 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@DropdownFieldOptionsId(R.array.auto_update_when_array)
|
||||
var check: Int = 0;
|
||||
|
||||
@FormField(R.string.background_download, FieldForm.DROPDOWN, R.string.configure_if_background_download_should_be_used, 1)
|
||||
@DropdownFieldOptionsId(R.array.background_download)
|
||||
var backgroundDownload: Int = 0;
|
||||
@FormField(R.string.background_download, FieldForm.TOGGLE, R.string.configure_if_background_download_should_be_used, 1)
|
||||
//@DropdownFieldOptionsId(R.array.background_download)
|
||||
var shouldBackgroundDownload: Boolean = false;
|
||||
|
||||
@FormField(R.string.download_when, FieldForm.DROPDOWN, R.string.configure_when_updates_should_be_downloaded, 2)
|
||||
@DropdownFieldOptionsId(R.array.when_download)
|
||||
@@ -1052,6 +1052,8 @@ class Settings : FragmentedStorageFileJson() {
|
||||
|
||||
@FormField(R.string.polycentric_local_cache, FieldForm.TOGGLE, R.string.polycentric_local_cache_description, 7)
|
||||
var polycentricLocalCache: Boolean = true;
|
||||
|
||||
var showPrivacyModeDialog: Boolean = true;
|
||||
}
|
||||
|
||||
@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 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 cancelButtonAction = Action(context.getString(R.string.cancel), cancelAction ?: {}, ActionStyle.ACCENT)
|
||||
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) {
|
||||
@@ -403,13 +405,6 @@ class UIDialogs {
|
||||
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) {
|
||||
if(!store.hasMissingReconstructions())
|
||||
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.res.Resources
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.icu.util.Output
|
||||
import android.os.Build
|
||||
import android.os.Looper
|
||||
import android.os.OperationCanceledException
|
||||
@@ -44,6 +42,9 @@ import java.util.*
|
||||
import java.util.concurrent.ThreadLocalRandom
|
||||
import java.util.zip.GZIPInputStream
|
||||
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 ";
|
||||
fun getRandomString(sizeOfRandomString: Int): String {
|
||||
@@ -114,23 +115,6 @@ fun DocumentFile.writeBytes(context: Context, byteArray: ByteArray) = context.co
|
||||
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) {
|
||||
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.pm.PackageManager
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Color
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
@@ -211,7 +212,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
|
||||
//State
|
||||
private val _queue: LinkedList<Pair<MainFragment, Any?>> = LinkedList();
|
||||
lateinit var fragCurrent: MainFragment private set;
|
||||
var fragCurrent: MainFragment? = null; private set;
|
||||
private var _parameterCurrent: Any? = null;
|
||||
|
||||
var fragBeforeOverlay: MainFragment? = null; private set;
|
||||
@@ -244,19 +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)
|
||||
|
||||
@@ -332,6 +320,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
}
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
window.isNavigationBarContrastEnforced = false
|
||||
}
|
||||
|
||||
//Preload common files to memory
|
||||
FragmentedStorage.get<SubscriptionStorage>();
|
||||
FragmentedStorage.get<Settings>();
|
||||
@@ -418,12 +410,17 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
updateSegmentPaddings();
|
||||
};
|
||||
_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 =
|
||||
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 15f, resources.displayMetrics);
|
||||
else
|
||||
}
|
||||
else {
|
||||
Logger.i(TAG, "onTransition Setting elevation lower");
|
||||
_fragContainerOverlay.elevation =
|
||||
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
_fragVideoDetail.onCloseEvent.subscribe {
|
||||
@@ -566,7 +563,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
defaultTab.action(_fragBotBarMenu);
|
||||
StateSubscriptions.instance;
|
||||
|
||||
fragCurrent.onShown(null, false);
|
||||
fragCurrent?.onShown(null, false);
|
||||
|
||||
//Other stuff
|
||||
rootView.progress = 0f;
|
||||
@@ -621,6 +618,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
sharedPreferences.edit().putBoolean("IsFirstBoot", false).apply()
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && Settings.instance.autoUpdate.isAutoUpdateEnabled() && Settings.instance.autoUpdate.shouldBackgroundDownload) {
|
||||
requestNotificationPermissions("You have enabled background updating.\n\nGrayjay uses notifications to inform you when a new app update is available.");
|
||||
}
|
||||
|
||||
val submissionStatus = FragmentedStorage.get<StringStorage>("subscriptionSubmissionStatus")
|
||||
|
||||
val numSubscriptions = StateSubscriptions.instance.getSubscriptionCount()
|
||||
@@ -1153,7 +1154,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
if (_fragVideoDetail.state == VideoDetailFragment.State.MAXIMIZED && _fragVideoDetail.onBackPressed())
|
||||
return;
|
||||
|
||||
if (!fragCurrent.onBackPressed())
|
||||
if (!(fragCurrent?.onBackPressed() ?: true))
|
||||
closeSegment();
|
||||
}
|
||||
|
||||
@@ -1231,27 +1232,27 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
return;
|
||||
}
|
||||
|
||||
fragCurrent.onHide();
|
||||
fragCurrent?.onHide();
|
||||
|
||||
if (segment.isMainView) {
|
||||
var transaction = supportFragmentManager.beginTransaction();
|
||||
if (segment.topBar != null) {
|
||||
if (segment.topBar != fragCurrent.topBar) {
|
||||
if (segment.topBar != fragCurrent?.topBar) {
|
||||
transaction = transaction
|
||||
.show(segment.topBar as Fragment)
|
||||
.replace(R.id.fragment_top_bar, segment.topBar as Fragment);
|
||||
fragCurrent.topBar?.onHide();
|
||||
fragCurrent?.topBar?.onHide();
|
||||
}
|
||||
} else if (fragCurrent.topBar != null)
|
||||
transaction.hide(fragCurrent.topBar as Fragment);
|
||||
} else if (fragCurrent?.topBar != null)
|
||||
transaction.hide(fragCurrent?.topBar as Fragment);
|
||||
|
||||
transaction = transaction.replace(R.id.fragment_main, segment);
|
||||
|
||||
if (segment.hasBottomBar) {
|
||||
if (!fragCurrent.hasBottomBar)
|
||||
if (!(fragCurrent?.hasBottomBar ?: false))
|
||||
transaction = transaction.show(_fragBotBarMenu);
|
||||
} else {
|
||||
if (fragCurrent.hasBottomBar)
|
||||
if (fragCurrent?.hasBottomBar ?: false)
|
||||
transaction = transaction.hide(_fragBotBarMenu);
|
||||
}
|
||||
transaction.commitNow();
|
||||
@@ -1264,10 +1265,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
}
|
||||
}
|
||||
|
||||
if (fragCurrent.isHistory && withHistory && _queue.lastOrNull() != fragCurrent)
|
||||
_queue.add(Pair(fragCurrent, _parameterCurrent));
|
||||
if (fragCurrent?.isHistory ?: false && withHistory && _queue.lastOrNull() != fragCurrent)
|
||||
_queue.add(Pair(fragCurrent!!, _parameterCurrent));
|
||||
|
||||
if (segment.isOverlay && !fragCurrent.isOverlay && withHistory)// && fragCurrent.isHistory)
|
||||
if (segment.isOverlay && !(fragCurrent?.isOverlay ?: false) && withHistory)// && fragCurrent.isHistory)
|
||||
fragBeforeOverlay = fragCurrent;
|
||||
|
||||
fragCurrent = segment;
|
||||
@@ -1298,11 +1299,24 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
navigate(last.first, last.second, false, true);
|
||||
} else {
|
||||
if (_fragVideoDetail.state == VideoDetailFragment.State.CLOSED) {
|
||||
Logger.i(TAG, "Closing activity because _fragVideoDetail.state == closed");
|
||||
finish();
|
||||
} 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?", {
|
||||
finish();
|
||||
})
|
||||
*/
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1364,7 +1378,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
|
||||
private fun updateSegmentPaddings() {
|
||||
var paddingBottom = 0f;
|
||||
if (fragCurrent.hasBottomBar)
|
||||
if (fragCurrent?.hasBottomBar ?: false)
|
||||
paddingBottom += HEIGHT_MENU_DP;
|
||||
|
||||
_fragContainerOverlay.setPadding(
|
||||
|
||||
+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")
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -73,10 +73,10 @@ open class LocalVideoDetails(
|
||||
override val video: IVideoSourceDescriptor = (if(mimeType?.startsWith("audio/") ?: false)
|
||||
(LocalVideoUnMuxedSourceDescriptor(
|
||||
arrayOf(),
|
||||
arrayOf(LocalAudioContentSource(url, mimeType ?: "", name))
|
||||
arrayOf(LocalAudioContentSource(url, mimeType ?: "", name, duration))
|
||||
))
|
||||
else (LocalVideoMuxedSourceDescriptor(
|
||||
LocalVideoContentSource(url, mimeType ?: "", name)
|
||||
LocalVideoContentSource(url, mimeType ?: "", name, duration)
|
||||
))
|
||||
);
|
||||
override val preview: ISerializedVideoSourceDescriptor? = null;
|
||||
|
||||
@@ -153,8 +153,8 @@ open class JSClient : IPlatformClient {
|
||||
_captcha = descriptor.getCaptchaData();
|
||||
flags = descriptor.flags.toTypedArray();
|
||||
|
||||
_httpClient = JSHttpClient(this, null, _captcha);
|
||||
_httpClientAuth = JSHttpClient(this, _auth, _captcha);
|
||||
_httpClient = JSHttpClient(this, null, _captcha, config);
|
||||
_httpClientAuth = JSHttpClient(this, _auth, _captcha, config);
|
||||
_plugin = V8Plugin(context, descriptor.config, null, _httpClient, _httpClientAuth);
|
||||
_plugin.withDependency(context, "scripts/polyfil.js");
|
||||
_plugin.withDependency(context, "scripts/source.js");
|
||||
@@ -186,8 +186,8 @@ open class JSClient : IPlatformClient {
|
||||
_captcha = descriptor.getCaptchaData();
|
||||
flags = descriptor.flags.toTypedArray();
|
||||
|
||||
_httpClient = JSHttpClient(this, null, _captcha);
|
||||
_httpClientAuth = JSHttpClient(this, _auth, _captcha);
|
||||
_httpClient = JSHttpClient(this, null, _captcha, config);
|
||||
_httpClientAuth = JSHttpClient(this, _auth, _captcha, config);
|
||||
_plugin = V8Plugin(context, descriptor.config, script, _httpClient, _httpClientAuth);
|
||||
_plugin.withDependency(context, "scripts/polyfil.js");
|
||||
_plugin.withDependency(context, "scripts/source.js");
|
||||
|
||||
+1
@@ -23,6 +23,7 @@ import java.util.UUID
|
||||
class JSHttpClient : ManagedHttpClient {
|
||||
private val _jsClient: JSClient?;
|
||||
private val _jsConfig: SourcePluginConfig?;
|
||||
val config get() = _jsConfig
|
||||
private val _auth: SourceAuth?;
|
||||
private val _captcha: SourceCaptchaData?;
|
||||
|
||||
|
||||
+30
-4
@@ -17,11 +17,14 @@ import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.invokeV8
|
||||
import com.futo.platformplayer.invokeV8Void
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.Base64
|
||||
|
||||
class JSRequestExecutor {
|
||||
class JSRequestExecutor: AutoCloseable {
|
||||
private val _plugin: JSClient;
|
||||
private val _config: IV8PluginConfig;
|
||||
private var _executor: V8ValueObject;
|
||||
@@ -29,6 +32,9 @@ class JSRequestExecutor {
|
||||
|
||||
private val hasCleanup: Boolean;
|
||||
|
||||
private var _cleanLock = Any();
|
||||
private var _cleaned: Boolean = false;
|
||||
|
||||
constructor(plugin: JSClient, executor: V8ValueObject) {
|
||||
this._plugin = plugin;
|
||||
this._executor = executor;
|
||||
@@ -102,8 +108,12 @@ class JSRequestExecutor {
|
||||
|
||||
|
||||
open fun cleanup() {
|
||||
if (!hasCleanup || _executor.isClosed)
|
||||
return;
|
||||
synchronized(_cleanLock) {
|
||||
if (!hasCleanup || _executor.isClosed || _cleaned)
|
||||
return;
|
||||
_cleaned = true;
|
||||
}
|
||||
Logger.i("JSRequestExecutor", "JSRequestExecutor cleanup requested");
|
||||
_plugin.busy {
|
||||
if(_plugin is DevJSClient)
|
||||
StateDeveloper.instance.handleDevCall(_plugin.devID, "requestExecutor.executeRequest()") {
|
||||
@@ -125,9 +135,25 @@ class JSRequestExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
protected fun finalize() {
|
||||
override fun close() {
|
||||
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..?
|
||||
|
||||
+2
-2
@@ -23,10 +23,10 @@ class LocalAudioContentSource : IAudioSource {
|
||||
|
||||
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";
|
||||
container = mime;
|
||||
duration = 0;
|
||||
this.duration = duration;
|
||||
|
||||
this.contentUrl = contentUrl;
|
||||
}
|
||||
|
||||
+2
-2
@@ -22,12 +22,12 @@ class LocalVideoContentSource: IVideoSource {
|
||||
|
||||
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";
|
||||
width = 0;
|
||||
height = 0;
|
||||
container = mime;
|
||||
duration = 0;
|
||||
this.duration = duration;
|
||||
this.contentUrl = contentUrl;
|
||||
}
|
||||
}
|
||||
@@ -239,7 +239,7 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
|
||||
}
|
||||
|
||||
DeviceConnectionState.Disconnected -> {
|
||||
connectionState = CastConnectionState.CONNECTING
|
||||
connectionState = CastConnectionState.DISCONNECTED
|
||||
onConnectionStateChanged.emit(CastConnectionState.DISCONNECTED)
|
||||
}
|
||||
}
|
||||
@@ -268,4 +268,4 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
|
||||
companion object {
|
||||
private val TAG = "CastingDeviceExp"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.content.Context
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.core.net.toUri
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
@@ -14,6 +15,7 @@ import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.http.server.HttpHeaders
|
||||
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.HttpContentUriHandler
|
||||
import com.futo.platformplayer.api.http.server.handlers.HttpFileHandler
|
||||
import com.futo.platformplayer.api.http.server.handlers.HttpFunctionHandler
|
||||
import com.futo.platformplayer.api.http.server.handlers.HttpProxyHandler
|
||||
@@ -34,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.JSDashManifestRawSource
|
||||
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.builders.DashBuilder
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
@@ -235,9 +239,9 @@ abstract class StateCasting {
|
||||
Logger.i(TAG, "Connect to device ${device.name}")
|
||||
}
|
||||
|
||||
fun metadataFromVideo(video: IPlatformVideoDetails): Metadata {
|
||||
fun metadataFromVideo(video: IPlatformVideoDetails, videoThumbnailOverrideUrl: String? = null): Metadata {
|
||||
return Metadata(
|
||||
title = video.name, thumbnailUrl = video.thumbnails.getHQThumbnail()
|
||||
title = video.name, thumbnailUrl = videoThumbnailOverrideUrl ?: video.thumbnails.getHQThumbnail()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -371,6 +375,12 @@ abstract class StateCasting {
|
||||
} else if (audioSource is LocalAudioSource) {
|
||||
Logger.i(TAG, "Casting as local audio");
|
||||
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) {
|
||||
Logger.i(TAG, "Casting as JSDashManifestRawSource video");
|
||||
castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, null, null, resumePosition, speed, castId, onLoadingEstimate, onLoading);
|
||||
@@ -461,6 +471,65 @@ abstract class StateCasting {
|
||||
}
|
||||
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> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
|
||||
@@ -1280,10 +1349,14 @@ abstract class StateCasting {
|
||||
}
|
||||
|
||||
if (audioSource != null && audioSource.hasRequestExecutor) {
|
||||
val oldExecutor = _audioExecutor;
|
||||
oldExecutor?.closeAsync();
|
||||
_audioExecutor = audioSource.getRequestExecutor()
|
||||
}
|
||||
|
||||
if (videoSource != null && videoSource.hasRequestExecutor) {
|
||||
val oldExecutor = _videoExecutor;
|
||||
oldExecutor?.closeAsync();
|
||||
_videoExecutor = videoSource.getRequestExecutor()
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ import com.google.gson.FieldAttributes
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.JsonArray
|
||||
import com.google.gson.JsonParser
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.lang.reflect.Field
|
||||
@@ -269,10 +271,12 @@ class DeveloperEndpoints(private val context: Context) {
|
||||
context.respondCode(403, "This plugin doesn't support auth");
|
||||
return;
|
||||
}
|
||||
LoginFragment.showLogin(config){
|
||||
_testPluginVariables.clear();
|
||||
_testPlugin = V8Plugin(StateApp.instance.context, config, null, JSHttpClient(null, null, null, config), JSHttpClient(null, it, null, config));
|
||||
};
|
||||
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) {
|
||||
_testPluginVariables.clear();
|
||||
|
||||
@@ -16,9 +16,11 @@ import android.widget.Button
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.UpdateDownloadService
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.copyToOutputStream
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
@@ -34,6 +36,8 @@ import java.io.InputStream
|
||||
class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
|
||||
companion object {
|
||||
private val TAG = "AutoUpdateDialog";
|
||||
|
||||
var currentDialog: AutoUpdateDialog? = null
|
||||
}
|
||||
|
||||
private lateinit var _buttonNever: Button;
|
||||
@@ -46,7 +50,6 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
|
||||
private var _maxVersion: Int = 0;
|
||||
|
||||
private var _updating: Boolean = false;
|
||||
private var _apkFile: File? = null;
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
@@ -80,19 +83,26 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
|
||||
return@setOnClickListener;
|
||||
}
|
||||
|
||||
_updating = true;
|
||||
update();
|
||||
if (Settings.instance.autoUpdate.shouldBackgroundDownload) {
|
||||
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) {
|
||||
_apkFile = apkFile;
|
||||
super.show()
|
||||
currentDialog = this
|
||||
}
|
||||
|
||||
override fun dismiss() {
|
||||
super.dismiss()
|
||||
InstallReceiver.onReceiveResult.clear();
|
||||
currentDialog = null
|
||||
Logger.i(TAG, "Cleared InstallReceiver.onReceiveResult handler.")
|
||||
}
|
||||
|
||||
@@ -118,21 +128,14 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
var inputStream: InputStream? = null;
|
||||
try {
|
||||
val apkFile = _apkFile;
|
||||
if (apkFile != null) {
|
||||
inputStream = apkFile.inputStream();
|
||||
val dataLength = apkFile.length();
|
||||
val client = ManagedHttpClient();
|
||||
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 {
|
||||
val client = ManagedHttpClient();
|
||||
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.");
|
||||
}
|
||||
throw Exception("Failed to download latest version of app.");
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Exception thrown while downloading and installing latest version of app.", e);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package com.futo.platformplayer.downloads
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaCodec
|
||||
import android.media.MediaExtractor
|
||||
import android.media.MediaMuxer
|
||||
import android.util.Log
|
||||
import com.arthenica.ffmpegkit.FFmpegKit
|
||||
import com.arthenica.ffmpegkit.ReturnCode
|
||||
@@ -585,55 +588,44 @@ class VideoDownload {
|
||||
return cipher.doFinal(encryptedSegment)
|
||||
}
|
||||
|
||||
private fun remuxWithFfmpegInPlace(inputFile: File): Boolean {
|
||||
val inputPath = inputFile.absolutePath
|
||||
if (!inputFile.exists()) {
|
||||
Logger.w(TAG, "remuxWithFfmpegInPlace: input does not exist: $inputPath")
|
||||
return false
|
||||
}
|
||||
private suspend fun combineSegments(context: Context, segmentFiles: List<File>, targetFile: File) = withContext(Dispatchers.IO) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
val fileList = File(context.cacheDir, "fileList-${UUID.randomUUID()}.txt")
|
||||
fileList.writeText(segmentFiles.joinToString("\n") { "file '${it.absolutePath}'" })
|
||||
|
||||
val parent = inputFile.parentFile
|
||||
if (parent == null) {
|
||||
Logger.w(TAG, "remuxWithFfmpegInPlace: input has no parent: $inputPath")
|
||||
return false
|
||||
}
|
||||
|
||||
val tmpFile = File(parent, inputFile.nameWithoutExtension + "_fixed." + inputFile.extension)
|
||||
val cmd = buildString {
|
||||
append("-y ")
|
||||
append("-i \"").append(inputFile.absolutePath).append("\" ")
|
||||
append("-c copy ")
|
||||
append("-movflags +faststart ")
|
||||
append("\"").append(tmpFile.absolutePath).append("\"")
|
||||
}
|
||||
|
||||
Logger.i(TAG, "FFmpeg remux command: $cmd")
|
||||
|
||||
val session = FFmpegKit.execute(cmd)
|
||||
val returnCode = session.returnCode
|
||||
|
||||
if (ReturnCode.isSuccess(returnCode)) {
|
||||
val newLen = tmpFile.length()
|
||||
|
||||
if (!inputFile.delete()) {
|
||||
Logger.w(TAG, "remuxWithFfmpegInPlace: failed to delete original: ${inputFile.absolutePath}")
|
||||
val cmd = "-f concat -safe 0 -i \"${fileList.absolutePath}\" -c copy \"${targetFile.absolutePath}\""
|
||||
val statisticsCallback = StatisticsCallback { _ ->
|
||||
//TODO: Show progress?
|
||||
}
|
||||
|
||||
if (!tmpFile.renameTo(inputFile)) {
|
||||
Logger.w(TAG, "remuxWithFfmpegInPlace: failed to move tmp: ${tmpFile.absolutePath}")
|
||||
} else {
|
||||
Logger.i(TAG, "remuxWithFfmpegInPlace: success for $inputPath (size=$newLen bytes)")
|
||||
}
|
||||
val executorService = Executors.newSingleThreadExecutor()
|
||||
val session = FFmpegKit.executeAsync(cmd,
|
||||
{ session ->
|
||||
if (ReturnCode.isSuccess(session.returnCode)) {
|
||||
fileList.delete()
|
||||
continuation.resumeWith(Result.success(Unit))
|
||||
} else {
|
||||
val errorMessage = if (ReturnCode.isCancel(session.returnCode)) {
|
||||
"Command cancelled"
|
||||
} else {
|
||||
"Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}"
|
||||
}
|
||||
fileList.delete()
|
||||
continuation.resumeWithException(RuntimeException(errorMessage))
|
||||
}
|
||||
},
|
||||
{ Logger.v(TAG, it.message) },
|
||||
statisticsCallback,
|
||||
executorService
|
||||
)
|
||||
|
||||
return true
|
||||
} else {
|
||||
Logger.e(TAG, "FFmpeg remux failed for $inputPath. rc=$returnCode, logs=${session.allLogsAsString}")
|
||||
tmpFile.delete()
|
||||
return false
|
||||
continuation.invokeOnCancellation {
|
||||
session.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, source: JSSource?, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||
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()
|
||||
|
||||
@@ -678,6 +670,7 @@ class VideoDownload {
|
||||
.array()
|
||||
}
|
||||
|
||||
val segmentFiles = arrayListOf<File>()
|
||||
try {
|
||||
val playlistHeaders = mutableMapOf<String, String>()
|
||||
val modifiedPlaylistReq = modifier?.modifyRequest(hlsUrl, playlistHeaders)
|
||||
@@ -713,123 +706,134 @@ class VideoDownload {
|
||||
val mediaSequence = variantPlaylist.mediaSequence ?: 0L
|
||||
val rangeOffsets = mutableMapOf<String, Long>()
|
||||
|
||||
targetFile.outputStream().use { outStr ->
|
||||
if (!variantPlaylist.mapUrl.isNullOrEmpty()) {
|
||||
if (isCancelled) throw CancellationException("Cancelled")
|
||||
if (!variantPlaylist.mapUrl.isNullOrEmpty()) {
|
||||
if (isCancelled) throw CancellationException("Cancelled")
|
||||
|
||||
Logger.i(TAG, "Downloading HLS initialization map")
|
||||
Logger.i(TAG, "Downloading HLS initialization map")
|
||||
|
||||
var mapRangeStart: Long? = null
|
||||
var mapRangeLength: Long? = null
|
||||
var mapRangeStart: Long? = null
|
||||
var mapRangeLength: Long? = null
|
||||
|
||||
if (variantPlaylist.mapBytesLength > 0) {
|
||||
mapRangeLength = variantPlaylist.mapBytesLength
|
||||
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
|
||||
}
|
||||
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)
|
||||
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 (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.")
|
||||
}
|
||||
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()
|
||||
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
|
||||
|
||||
outStr.write(segmentBytes)
|
||||
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++
|
||||
} finally {
|
||||
outStr.close()
|
||||
}
|
||||
downloadedTotalLength += mapBytes.size
|
||||
}
|
||||
|
||||
remuxWithFfmpegInPlace(targetFile)
|
||||
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())
|
||||
@@ -843,6 +847,11 @@ class VideoDownload {
|
||||
targetFile.delete()
|
||||
throw ex
|
||||
}
|
||||
finally {
|
||||
for (segmentFile in segmentFiles) {
|
||||
segmentFile.delete()
|
||||
}
|
||||
}
|
||||
|
||||
return downloadedTotalLength
|
||||
}
|
||||
@@ -856,6 +865,7 @@ class VideoDownload {
|
||||
val sourceLength: Long?;
|
||||
val fileStream = FileOutputStream(targetFile);
|
||||
|
||||
var executor: JSRequestExecutor? = null;
|
||||
try{
|
||||
var manifest = source.manifest;
|
||||
if(source.hasGenerate)
|
||||
@@ -872,7 +882,7 @@ class VideoDownload {
|
||||
if(foundCues.count() <= 0)
|
||||
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();
|
||||
else
|
||||
null;
|
||||
@@ -931,6 +941,7 @@ class VideoDownload {
|
||||
}
|
||||
finally {
|
||||
fileStream.close();
|
||||
executor?.closeAsync()
|
||||
}
|
||||
return sourceLength!!;
|
||||
}
|
||||
|
||||
@@ -23,10 +23,7 @@ object Libcurl {
|
||||
var body: ByteArray? = null,
|
||||
var impersonateTarget: String = "chrome136",
|
||||
var useBuiltInHeaders: Boolean = true,
|
||||
var timeoutMs: Int = 30_000,
|
||||
var cookieJarPath: String? = null,
|
||||
var sendCookies: Boolean = true,
|
||||
var persistCookies: Boolean = true,
|
||||
var timeoutMs: Int = 30_000
|
||||
)
|
||||
|
||||
@Keep
|
||||
@@ -121,12 +118,6 @@ object Libcurl {
|
||||
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
|
||||
if (!method.equals("GET", ignoreCase = true)) {
|
||||
checkOK(ce_setopt_str(easy, CURLOPT.CUSTOMREQUEST, method))
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.futo.platformplayer.engine.packages
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
import com.caoccao.javet.annotations.V8Convert
|
||||
import com.caoccao.javet.annotations.V8Function
|
||||
import com.caoccao.javet.annotations.V8Property
|
||||
@@ -10,9 +12,13 @@ import com.caoccao.javet.values.V8Value
|
||||
import com.caoccao.javet.values.primitive.V8ValueString
|
||||
import com.caoccao.javet.values.reference.V8ValueTypedArray
|
||||
import com.curlbind.Libcurl
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.engine.V8Plugin
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
|
||||
import com.futo.platformplayer.engine.internal.IV8Convertable
|
||||
import com.futo.platformplayer.engine.internal.V8BindObject
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
@@ -43,8 +49,8 @@ class PackageHttpImp : V8Package {
|
||||
|
||||
constructor(plugin: V8Plugin, config: IV8PluginConfig) : super(plugin) {
|
||||
_config = config
|
||||
_packageClient = PackageHttpClient(this, withAuth = false)
|
||||
_packageClientAuth = PackageHttpClient(this, withAuth = true)
|
||||
_packageClient = PackageHttpClient(this, plugin.httpClient)
|
||||
_packageClientAuth = PackageHttpClient(this, plugin.httpClientAuth)
|
||||
}
|
||||
|
||||
fun cleanup() {
|
||||
@@ -95,7 +101,10 @@ class PackageHttpImp : V8Package {
|
||||
|
||||
@V8Function
|
||||
fun newClient(withAuth: Boolean): PackageHttpClient {
|
||||
val client = PackageHttpClient(this, withAuth)
|
||||
val httpClient = if(withAuth) _plugin.httpClientAuth.clone() else _plugin.httpClient.clone();
|
||||
if(httpClient is JSHttpClient)
|
||||
_plugin.registerHttpClient(httpClient);
|
||||
val client = PackageHttpClient(this, httpClient)
|
||||
client.clientId()?.let { _clients[it] = client }
|
||||
return client
|
||||
}
|
||||
@@ -672,9 +681,6 @@ class PackageHttpImp : V8Package {
|
||||
@Transient
|
||||
private val _package: PackageHttpImp
|
||||
|
||||
@Transient
|
||||
private val _withAuth: Boolean
|
||||
|
||||
val parentConfig: IV8PluginConfig
|
||||
get() = _package._config
|
||||
|
||||
@@ -686,44 +692,21 @@ class PackageHttpImp : V8Package {
|
||||
|
||||
@Volatile
|
||||
private var timeoutMs: Int = 30_000
|
||||
|
||||
@Volatile
|
||||
private var sendCookies: Boolean = true
|
||||
|
||||
@Volatile
|
||||
private var updateCookies: Boolean = true
|
||||
|
||||
@Volatile
|
||||
private var allowNewCookies: Boolean = true
|
||||
|
||||
@Volatile
|
||||
private var cookieJarPath: String? = null
|
||||
|
||||
@Volatile
|
||||
private var impersonateTarget: String = "chrome136"
|
||||
|
||||
@Volatile
|
||||
private var useBuiltInHeaders: Boolean = true
|
||||
|
||||
@Transient
|
||||
private val _client: ManagedHttpClient;
|
||||
|
||||
@V8Property
|
||||
fun clientId(): String? = _clientId
|
||||
|
||||
constructor(pack: PackageHttpImp, withAuth: Boolean) : super() {
|
||||
constructor(pack: PackageHttpImp, baseClient: ManagedHttpClient) : super() {
|
||||
_package = pack
|
||||
_withAuth = withAuth
|
||||
}
|
||||
|
||||
private fun ensureCookieJarPath(): String {
|
||||
val existing = cookieJarPath
|
||||
if (existing != null) return existing
|
||||
|
||||
val tmp = System.getProperty("java.io.tmpdir") ?: "/data/local/tmp"
|
||||
val safeName = parentConfig.name.replace(Regex("[^a-zA-Z0-9._-]"), "_")
|
||||
val fileName =
|
||||
if (_withAuth) "imphttp.$safeName.auth.cookies.txt" else "imphttp.$safeName.cookies.txt"
|
||||
val path = if (tmp.endsWith("/")) tmp + fileName else "$tmp/$fileName"
|
||||
cookieJarPath = path
|
||||
return path
|
||||
_client = baseClient
|
||||
}
|
||||
|
||||
@V8Function
|
||||
@@ -737,17 +720,18 @@ class PackageHttpImp : V8Package {
|
||||
|
||||
@V8Function
|
||||
fun setDoApplyCookies(apply: Boolean) {
|
||||
sendCookies = apply
|
||||
if(_client is JSHttpClient)
|
||||
_client.doApplyCookies = apply;
|
||||
}
|
||||
|
||||
@V8Function
|
||||
fun setDoUpdateCookies(update: Boolean) {
|
||||
updateCookies = update
|
||||
if(_client is JSHttpClient)
|
||||
_client.doUpdateCookies = update;
|
||||
}
|
||||
|
||||
@V8Function
|
||||
fun setDoAllowNewCookies(allow: Boolean) {
|
||||
allowNewCookies = allow
|
||||
if(_client is JSHttpClient)
|
||||
_client.doAllowNewCookies = allow;
|
||||
}
|
||||
|
||||
@V8Function
|
||||
@@ -1060,18 +1044,29 @@ class PackageHttpImp : V8Package {
|
||||
private fun performCurl(
|
||||
method: String,
|
||||
url: String,
|
||||
headers: Map<String, String>,
|
||||
hs: Map<String, String>, //TODO: Why is this not a Map<String, List<String>>
|
||||
bodyBytes: ByteArray?,
|
||||
impersonateTargetOverride: String? = null,
|
||||
useBuiltInHeadersOverride: Boolean? = null,
|
||||
timeoutMsOverride: Int? = null
|
||||
): Libcurl.Response {
|
||||
val jar = ensureCookieJarPath()
|
||||
val client = _client
|
||||
if (client is JSHttpClient) {
|
||||
if (!(client.config?.isUrlAllowed(url) ?: false)) {
|
||||
throw Exception( "Attempted to access non-whitelisted url: $url\nAdd it to your config");
|
||||
}
|
||||
}
|
||||
|
||||
val finalImpersonateTarget = impersonateTargetOverride ?: this.impersonateTarget
|
||||
val finalUseBuiltInHeaders = useBuiltInHeadersOverride ?: this.useBuiltInHeaders
|
||||
val finalTimeoutMs = timeoutMsOverride ?: this.timeoutMs
|
||||
|
||||
val uri = url.toUri()
|
||||
val headers = hs.toMutableMap()
|
||||
if (client is JSHttpClient) {
|
||||
client.applyHeaders(uri, headers, _client.isLoggedIn, true)
|
||||
}
|
||||
|
||||
val req = Libcurl.Request(
|
||||
url = url,
|
||||
method = method,
|
||||
@@ -1079,12 +1074,13 @@ class PackageHttpImp : V8Package {
|
||||
body = bodyBytes,
|
||||
impersonateTarget = finalImpersonateTarget,
|
||||
useBuiltInHeaders = finalUseBuiltInHeaders,
|
||||
timeoutMs = finalTimeoutMs,
|
||||
cookieJarPath = jar,
|
||||
sendCookies = sendCookies,
|
||||
persistCookies = updateCookies && allowNewCookies
|
||||
timeoutMs = finalTimeoutMs
|
||||
)
|
||||
return Libcurl.perform(req)
|
||||
val resp = Libcurl.perform(req)
|
||||
if (client is JSHttpClient) {
|
||||
client.processRequest(method, resp.status, uri, resp.headers)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
private fun executeRequest(
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.MainFragment
|
||||
|
||||
open class MainActivityFragment : Fragment() {
|
||||
protected val currentMain : MainFragment
|
||||
protected val currentMain : MainFragment?
|
||||
get() {
|
||||
isValidMainActivity();
|
||||
return (activity as MainActivity).fragCurrent;
|
||||
|
||||
+31
-9
@@ -102,6 +102,8 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
|
||||
private var currentButtonDefinitions: List<ButtonDefinition>? = null;
|
||||
|
||||
private var moreColumns = 3;
|
||||
|
||||
constructor(fragment: MenuBottomBarFragment, inflater: LayoutInflater) : super(inflater.context) {
|
||||
_fragment = fragment;
|
||||
_inflater = inflater;
|
||||
@@ -152,6 +154,17 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +183,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
setMoreVisible(false);
|
||||
}
|
||||
})
|
||||
moreColumns = columns;
|
||||
val layoutManager = GridLayoutManager(context, columns, GridLayoutManager.VERTICAL, true);
|
||||
_layoutMoreButtons.layoutManager = layoutManager;
|
||||
|
||||
@@ -321,29 +335,37 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
_layoutMoreButtons.removeAllViews();
|
||||
|
||||
var insertedButtons = 0;
|
||||
//Force settings to be first
|
||||
val settingsIndex = buttons.indexOfFirst { b -> b.id == 7 };
|
||||
if (settingsIndex != -1) {
|
||||
val button = buttons[settingsIndex]
|
||||
buttons.removeAt(settingsIndex)
|
||||
buttons.add(0, button)
|
||||
//insertedButtons++;
|
||||
}
|
||||
//Force buy to be on top for more buttons
|
||||
val buyIndex = buttons.indexOfFirst { b -> b.id == 98 };
|
||||
if (buyIndex != -1) {
|
||||
val button = buttons[buyIndex]
|
||||
buttons.removeAt(buyIndex)
|
||||
buttons.add(0, button)
|
||||
insertedButtons++;
|
||||
buttons.add(button)
|
||||
//insertedButtons++;
|
||||
}
|
||||
//Force faq to be second
|
||||
val faqIndex = buttons.indexOfFirst { b -> b.id == 97 };
|
||||
if (faqIndex != -1) {
|
||||
val button = buttons[faqIndex]
|
||||
buttons.removeAt(faqIndex)
|
||||
buttons.add(if (insertedButtons == 1) 1 else 0, button)
|
||||
insertedButtons++;
|
||||
buttons.add(button)
|
||||
//insertedButtons++;
|
||||
}
|
||||
//Force privacy to be third
|
||||
val privacyIndex = buttons.indexOfFirst { b -> b.id == 96 };
|
||||
if (privacyIndex != -1) {
|
||||
val button = buttons[privacyIndex]
|
||||
buttons.removeAt(privacyIndex)
|
||||
buttons.add(if (insertedButtons == 2) 2 else (if(insertedButtons == 1) 1 else 0), button)
|
||||
insertedButtons++;
|
||||
buttons.add(button)
|
||||
//insertedButtons++;
|
||||
}
|
||||
|
||||
val newButtons = mutableListOf<MenuButtonItem>();
|
||||
@@ -591,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(9, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscription_group_menu, canToggle = true, { it.currentMain is SubscriptionGroupListFragment }, { it.navigate<SubscriptionGroupListFragment>(withHistory = false) }),
|
||||
ButtonDefinition(10, R.drawable.ic_help_square, R.drawable.ic_help_square_fill, R.string.tutorials, canToggle = true, { it.currentMain is TutorialFragment }, { it.navigate<TutorialFragment>(withHistory = false) }),
|
||||
ButtonDefinition(7, R.drawable.ic_settings, R.drawable.ic_settings_filled, R.string.settings, canToggle = false, { false }, {
|
||||
ButtonDefinition(7, R.drawable.ic_settings, R.drawable.ic_settings_filled, R.string.settings, canToggle = false, { it.currentMain is SettingsFragment }, {
|
||||
it.navigate<SettingsFragment>();
|
||||
/*
|
||||
val c = it.context ?: return@ButtonDefinition;
|
||||
@@ -602,7 +624,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
if (c is Activity) {
|
||||
c.overridePendingTransition(R.anim.slide_in_up, R.anim.slide_darken);
|
||||
}*/
|
||||
}),
|
||||
}),/*
|
||||
ButtonDefinition(96, R.drawable.ic_disabled_visible, R.drawable.ic_disabled_visible, R.string.privacy_mode, canToggle = true, { false }, {
|
||||
UIDialogs.showDialog(it.context ?: return@ButtonDefinition, R.drawable.ic_disabled_visible_purple, "Privacy Mode",
|
||||
"All requests will be processed anonymously (any logins will be disabled except for the personalized home page), local playback and history tracking will also be disabled.\n\nTap the icon to disable.", null, 0,
|
||||
@@ -612,7 +634,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
UIDialogs.Action("Enable", {
|
||||
StateApp.instance.setPrivacyMode(true);
|
||||
}, UIDialogs.ActionStyle.PRIMARY));
|
||||
}),
|
||||
}),*/
|
||||
ButtonDefinition(97, R.drawable.ic_quiz, R.drawable.ic_quiz_fill, R.string.faq, canToggle = true, { false }, {
|
||||
it.navigate<BrowserFragment>(Settings.URL_FAQ, withHistory = false);
|
||||
})
|
||||
|
||||
+10
-4
@@ -20,6 +20,7 @@ import androidx.lifecycle.lifecycleScope
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import com.bumptech.glide.Glide
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.UISlideOverlays
|
||||
import com.futo.platformplayer.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.overlays.slideup.SlideUpMenuOverlay
|
||||
import com.futo.platformplayer.views.subscriptions.SubscribeButton
|
||||
import com.futo.platformplayer.withTimestamp
|
||||
import com.futo.polycentric.core.ApiMethods
|
||||
import com.futo.polycentric.core.PolycentricProfile
|
||||
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
|
||||
@@ -198,8 +200,12 @@ class ChannelFragment : MainFragment() {
|
||||
adapter.onContentClicked.subscribe { v, _ ->
|
||||
when (v) {
|
||||
is IPlatformVideo -> {
|
||||
StatePlayer.instance.clearQueue()
|
||||
fragment.navigate<VideoDetailFragment>(v).maximizeVideoDetail()
|
||||
//StatePlayer.instance.clearQueue()
|
||||
if (StatePlayer.instance.hasQueue) {
|
||||
StatePlayer.instance.insertToQueue(v, true);
|
||||
} else {
|
||||
fragment.navigate<VideoDetailFragment>(v).maximizeVideoDetail();
|
||||
}
|
||||
}
|
||||
|
||||
is IPlatformPlaylist -> {
|
||||
@@ -244,7 +250,7 @@ class ChannelFragment : MainFragment() {
|
||||
adapter.onContentUrlClicked.subscribe { url, contentType ->
|
||||
when (contentType) {
|
||||
ContentType.MEDIA -> {
|
||||
StatePlayer.instance.clearQueue()
|
||||
StatePlayer.instance.clearQueue();
|
||||
fragment.navigate<VideoDetailFragment>(url).maximizeVideoDetail()
|
||||
}
|
||||
|
||||
@@ -403,7 +409,7 @@ class ChannelFragment : MainFragment() {
|
||||
_fragment.topBar?.onShown(channel)
|
||||
|
||||
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)
|
||||
.replace("{channelName}", channel.name),
|
||||
{
|
||||
|
||||
@@ -55,7 +55,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
||||
protected val _toolbarContentView: LinearLayout;
|
||||
protected val _bottomContentView: LinearLayout;
|
||||
|
||||
private var _loading: Boolean = true;
|
||||
private var _loading: Boolean = false;
|
||||
|
||||
private val _pagerLock = Object();
|
||||
private var _cache: ItemCache<TResult>? = null;
|
||||
@@ -180,10 +180,9 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
||||
|
||||
val visibleItemCount = _recyclerResults.childCount;
|
||||
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) {
|
||||
//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) {
|
||||
loadNextPage();
|
||||
}
|
||||
}
|
||||
@@ -197,57 +196,44 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
||||
}
|
||||
|
||||
private fun ensureEnoughContentVisible(filteredResults: List<TConverted>) {
|
||||
val canScroll = if (recyclerData.results.isEmpty()) false else {
|
||||
val height = resources.displayMetrics.heightPixels;
|
||||
_recyclerResults.post {
|
||||
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) {
|
||||
loadNextPage();
|
||||
setLoading(true);
|
||||
}
|
||||
delay(backoff.toLong());
|
||||
if (automaticNextPageCounterSaved == _automaticNextPageCounter) {
|
||||
withContext(Dispatchers.Main) {
|
||||
loadNextPage();
|
||||
}
|
||||
} else {
|
||||
withContext(Dispatchers.Main) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
withContext(Dispatchers.Main) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else
|
||||
loadNextPage();
|
||||
}
|
||||
else
|
||||
loadNextPage();
|
||||
} else {
|
||||
Logger.i(TAG, "ensureEnoughContentVisible automaticNextPageCounter reset");
|
||||
_automaticNextPageCounter = 0;
|
||||
}
|
||||
} else {
|
||||
Logger.i(TAG, "ensureEnoughContentVisible automaticNextPageCounter reset");
|
||||
_automaticNextPageCounter = 0;
|
||||
}
|
||||
}
|
||||
fun resetAutomaticNextPageCounter(){
|
||||
@@ -484,7 +470,9 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
||||
recyclerData.resultsUnfiltered.addAll(toAdd);
|
||||
recyclerData.adapter.notifyDataSetChanged();
|
||||
recyclerData.loadedFeedStyle = feedStyle;
|
||||
ensureEnoughContentVisible(filteredResults)
|
||||
setLoading(false)
|
||||
if(pager.hasMorePages())
|
||||
ensureEnoughContentVisible(filteredResults)
|
||||
}
|
||||
|
||||
private fun detachPagerEvents() {
|
||||
|
||||
+3
-1
@@ -365,8 +365,10 @@ class HomeFragment : MainFragment() {
|
||||
finishRefreshLayoutLoader();
|
||||
setLoading(false);
|
||||
setPager(pager);
|
||||
if(pager.getResults().isEmpty() && !pager.hasMorePages())
|
||||
if(pager.getResults().isEmpty() && !pager.hasMorePages()) {
|
||||
setLoading(false);
|
||||
setEmptyPager(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-4
@@ -124,11 +124,10 @@ class LibraryFilesFragment : MainFragment() {
|
||||
}
|
||||
}
|
||||
fun leaveDirectory() {
|
||||
if(navStack.size > 1) {
|
||||
navStack.removeLast();
|
||||
openDirectory(navStack.last());
|
||||
if (navStack.size > 1) {
|
||||
navStack.removeAt(navStack.size - 1)
|
||||
openDirectory(navStack.last())
|
||||
}
|
||||
else {}
|
||||
}
|
||||
fun openDirectory(stack: FileStack, addToStack: Boolean = false) {
|
||||
if(addToStack)
|
||||
|
||||
-1
@@ -96,7 +96,6 @@ class LibraryVideosFragment : MainFragment() {
|
||||
fun onShown() {
|
||||
val initialAlbums = StateLibrary.instance.getAlbums();
|
||||
Logger.i(TAG, "Initial album count: " + initialAlbums.size);
|
||||
val buckets = StateLibrary.instance.getVideoBucketNames();
|
||||
setPager(StateLibrary.instance.getVideos(fragment._toggleBuckets));
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@ class LoginFragment : MainFragment() {
|
||||
fun showLogin(config: SourcePluginConfig, callback: ((SourceAuth?) -> Unit)? = null) {
|
||||
if(_callback != null) _callback?.invoke(null);
|
||||
_callback = callback;
|
||||
StateApp.instance.activity?.navigate<LoginFragment>(config, false);
|
||||
StateApp.instance.activity?.navigate<LoginFragment>(config, true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -16,6 +16,7 @@ import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||
import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
|
||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
|
||||
@@ -363,6 +364,7 @@ class RemotePlaylistFragment : MainFragment() {
|
||||
_imagePlaylistThumbnail.let {
|
||||
Glide.with(it)
|
||||
.load(video.thumbnails.getHQThumbnail())
|
||||
.withMaxSizePx()
|
||||
.placeholder(R.drawable.placeholder_video_thumbnail)
|
||||
.crossfade()
|
||||
.into(it);
|
||||
|
||||
@@ -2,7 +2,9 @@ package com.futo.platformplayer.fragment.mainactivity.main
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.drawable.Animatable
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.view.WindowManager
|
||||
@@ -13,10 +15,15 @@ import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Format
|
||||
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.Settings
|
||||
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.Companion.PREFERED_AUDIO_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.ContentType
|
||||
import com.futo.polycentric.core.Models
|
||||
@@ -851,9 +859,8 @@ class ShortView : FrameLayout {
|
||||
}
|
||||
|
||||
val thumbnail = videoDetails.thumbnails.getHQThumbnail()
|
||||
/*
|
||||
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>?) {
|
||||
player.setArtwork(resource.toDrawable(resources))
|
||||
}
|
||||
@@ -863,7 +870,6 @@ class ShortView : FrameLayout {
|
||||
}
|
||||
})
|
||||
else player.setArtwork(null)
|
||||
*/
|
||||
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
|
||||
+36
-27
@@ -50,7 +50,7 @@ class VideoDetailFragment() : MainFragment() {
|
||||
|
||||
private var _isActive: Boolean = false;
|
||||
|
||||
private var _viewDetail : VideoDetailView? = null;
|
||||
var _viewDetail : VideoDetailView? = null;
|
||||
private var _view : SingleViewTouchableMotionLayout? = null;
|
||||
|
||||
var isFullscreen : Boolean = false;
|
||||
@@ -356,38 +356,46 @@ class VideoDetailFragment() : MainFragment() {
|
||||
override fun onTransitionChange(motionLayout: MotionLayout?, startId: Int, endId: Int, progress: Float) {
|
||||
_viewDetail?.stopAllGestures()
|
||||
|
||||
if (state != State.MINIMIZED && 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)) {
|
||||
if (!isTransitioning && (progress < 0.9 && progress > 0.1)) {
|
||||
isTransitioning = true;
|
||||
onTransitioning.emit(isTransitioning);
|
||||
|
||||
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 onTransitionTrigger(p0: MotionLayout?, p1: Int, p2: Boolean, p3: Float) { }
|
||||
override fun onTransitionTrigger(p0: MotionLayout?, p1: Int, p2: Boolean, p3: Float) {
|
||||
}
|
||||
});
|
||||
|
||||
_view?.let {
|
||||
@@ -446,7 +454,8 @@ class VideoDetailFragment() : MainFragment() {
|
||||
if (viewDetail.shouldEnterPictureInPicture) {
|
||||
_leavingPiP = false
|
||||
}
|
||||
if(Build.VERSION.SDK_INT < Build.VERSION_CODES.S && viewDetail.preventPictureInPicture == false && !StateCasting.instance.isCasting && Settings.instance.playback.isBackgroundPictureInPicture() && !viewDetail.isAudioOnlyUserAction) {
|
||||
val 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();
|
||||
if(params != null) {
|
||||
Logger.i(TAG, "enterPictureInPictureMode")
|
||||
|
||||
+7
-3
@@ -42,6 +42,7 @@ import androidx.media3.datasource.HttpDataSource
|
||||
import androidx.media3.ui.PlayerControlView
|
||||
import androidx.media3.ui.TimeBar
|
||||
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.BuildConfig
|
||||
@@ -161,6 +162,7 @@ import com.futo.platformplayer.views.subscriptions.SubscribeButton
|
||||
import com.futo.platformplayer.views.video.FutoVideoPlayer
|
||||
import com.futo.platformplayer.views.video.FutoVideoPlayerBase
|
||||
import com.futo.platformplayer.views.videometa.UpNextView
|
||||
import com.futo.platformplayer.withMaxSizePx
|
||||
import com.futo.polycentric.core.ApiMethods
|
||||
import com.futo.polycentric.core.ContentType
|
||||
import com.futo.polycentric.core.Models
|
||||
@@ -552,12 +554,12 @@ class VideoDetailView : ConstraintLayout {
|
||||
_buttonMore = buttonMore;
|
||||
updateMoreButtons();
|
||||
|
||||
val handleLoaderGameVisibilityChanged = { b: Boolean ->
|
||||
val handleLoaderGameVisibilityChanged: (Boolean) -> Unit = { b: Boolean ->
|
||||
_loaderGameVisible = b
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
onShouldEnterPictureInPictureChanged.emit()
|
||||
updateResumeVisibilityFor(lastPositionMilliseconds)
|
||||
}
|
||||
updateResumeVisibilityFor(lastPositionMilliseconds)
|
||||
}
|
||||
_player.loaderGameVisibilityChanged.subscribe(handleLoaderGameVisibilityChanged)
|
||||
_cast.loaderGameVisibilityChanged.subscribe(handleLoaderGameVisibilityChanged)
|
||||
@@ -2049,7 +2051,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
} else {
|
||||
val thumbnail = video.thumbnails.getHQThumbnail();
|
||||
if ((videoSource == null) && !thumbnail.isNullOrBlank()) // || _player.isAudioMode
|
||||
Glide.with(context).asBitmap().load(thumbnail)
|
||||
Glide.with(context).asBitmap().load(thumbnail).withMaxSizePx()
|
||||
.into(object: CustomTarget<Bitmap>() {
|
||||
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
||||
_player.setArtwork(BitmapDrawable(resources, resource));
|
||||
@@ -3357,9 +3359,11 @@ class VideoDetailView : ConstraintLayout {
|
||||
false
|
||||
else {
|
||||
isLoginStop = true;
|
||||
onMinimize.emit();
|
||||
StatePlugins.instance.loginPlugin(context, id) {
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
fetchVideo();
|
||||
onMaximize.emit(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -14,6 +14,7 @@ import android.widget.TextView
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.setPadding
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.UISlideOverlays
|
||||
@@ -28,6 +29,7 @@ import com.futo.platformplayer.toHumanDuration
|
||||
import com.futo.platformplayer.toHumanTime
|
||||
import com.futo.platformplayer.views.SearchView
|
||||
import com.futo.platformplayer.views.lists.VideoListEditorView
|
||||
import com.futo.platformplayer.withMaxSizePx
|
||||
|
||||
abstract class VideoListEditorView : LinearLayout {
|
||||
private var _videoListEditorView: VideoListEditorView;
|
||||
@@ -211,6 +213,7 @@ abstract class VideoListEditorView : LinearLayout {
|
||||
_imagePlaylistThumbnail.let {
|
||||
Glide.with(it)
|
||||
.load(video.thumbnails.getHQThumbnail())
|
||||
.withMaxSizePx()
|
||||
.placeholder(R.drawable.placeholder_video_thumbnail)
|
||||
.crossfade()
|
||||
.into(it);
|
||||
|
||||
+6
-1
@@ -1,5 +1,6 @@
|
||||
package com.futo.platformplayer.fragment.mainactivity.topbar
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
@@ -49,7 +50,11 @@ class GeneralTopBarFragment : TopFragment() {
|
||||
} else if (currentMain is PlaylistsFragment || currentMain is PlaylistFragment) {
|
||||
navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.PLAYLIST));
|
||||
} 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 {
|
||||
navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.VIDEO));
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ import android.graphics.drawable.Drawable
|
||||
import android.widget.ImageView
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.RequestBuilder
|
||||
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions
|
||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||
import com.futo.platformplayer.withMaxSizePx
|
||||
|
||||
class GlideHelper {
|
||||
|
||||
@@ -14,7 +16,7 @@ class GlideHelper {
|
||||
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 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?
|
||||
val fallbackUrl = if (isHQ) thumbnails.getLQThumbnail() else thumbnails.getHQThumbnail();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.futo.platformplayer.receivers
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
@@ -26,14 +27,24 @@ class InstallReceiver : BroadcastReceiver() {
|
||||
val activityIntent: Intent? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
intent.getParcelableExtra(Intent.EXTRA_INTENT)
|
||||
}
|
||||
|
||||
if (activityIntent == 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;
|
||||
}
|
||||
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_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));
|
||||
else -> {
|
||||
val msg = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE);
|
||||
Logger.w(TAG, "Received unknown install status $status, message=$msg")
|
||||
onReceiveResult.emit(msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import android.util.Log
|
||||
import android.view.KeyEvent
|
||||
import androidx.core.app.NotificationCompat
|
||||
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
|
||||
@@ -38,6 +39,7 @@ import com.futo.platformplayer.receivers.MediaControlReceiver
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StatePlayer
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
import com.futo.platformplayer.withMaxSizePx
|
||||
|
||||
class MediaPlaybackService : Service() {
|
||||
private val TAG = "MediaPlaybackService";
|
||||
@@ -172,21 +174,26 @@ class MediaPlaybackService : Service() {
|
||||
}
|
||||
|
||||
fun closeMediaSession() {
|
||||
Logger.v(TAG, "closeMediaSession");
|
||||
stopForeground(STOP_FOREGROUND_REMOVE);
|
||||
Logger.v(TAG, "closeMediaSession")
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
|
||||
abandonAudioFocus()
|
||||
|
||||
val notifManager = _notificationManager;
|
||||
Logger.i(TAG, "Cancelling playback notification (notifManager: ${notifManager != null})");
|
||||
notifManager?.cancel(MEDIA_NOTIF_ID);
|
||||
_notif_last_video = null;
|
||||
_notif_last_bitmap = null;
|
||||
_mediaSession = null;
|
||||
val notifManager = _notificationManager
|
||||
Logger.i(TAG, "Cancelling playback notification (notifManager: ${notifManager != null})")
|
||||
notifManager?.cancel(MEDIA_NOTIF_ID)
|
||||
|
||||
if(_instance == this)
|
||||
_instance = null;
|
||||
this.stopSelf();
|
||||
_notif_last_video = null
|
||||
_notif_last_bitmap = null
|
||||
|
||||
_mediaSession?.isActive = false
|
||||
_mediaSession?.release()
|
||||
_mediaSession = null
|
||||
|
||||
if (_instance == this)
|
||||
_instance = null
|
||||
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
fun updateMediaSession(videoUpdated: IPlatformVideo?) {
|
||||
@@ -206,37 +213,37 @@ class MediaPlaybackService : Service() {
|
||||
if(_notificationChannel == null || _mediaSession == null)
|
||||
setupNotificationRequirements();
|
||||
|
||||
_mediaSession?.setMetadata(
|
||||
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());
|
||||
updateMediaMetadata(video, lastBitmap)
|
||||
|
||||
val thumbnail = video.thumbnails.getHQThumbnail();
|
||||
|
||||
_notif_last_video = video;
|
||||
|
||||
if(isUpdating)
|
||||
notifyMediaSession(video, _notif_last_bitmap);
|
||||
notifyMediaSession(video, _notif_last_bitmap?.takeIf { !it.isRecycled });
|
||||
else if(thumbnail != null) {
|
||||
notifyMediaSession(video, null);
|
||||
val tag = video;
|
||||
Glide.with(this).asBitmap()
|
||||
.load(thumbnail)
|
||||
.withMaxSizePx()
|
||||
.into(object: CustomTarget<Bitmap>() {
|
||||
override fun onResourceReady(resource: Bitmap,transition: Transition<in Bitmap>?) {
|
||||
if(tag == _notif_last_video) {
|
||||
notifyMediaSession(video, resource)
|
||||
_mediaSession?.setMetadata(
|
||||
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, resource)
|
||||
.build());
|
||||
if (tag != _notif_last_video) return
|
||||
if (resource.isRecycled) {
|
||||
notifyMediaSession(video, null)
|
||||
return
|
||||
}
|
||||
|
||||
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?) {
|
||||
if(tag == _notif_last_video)
|
||||
@@ -247,6 +254,19 @@ class MediaPlaybackService : Service() {
|
||||
else
|
||||
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 {
|
||||
return NotificationCompat.Action.Builder(icon, title, intent).build();
|
||||
}
|
||||
|
||||
@@ -436,9 +436,9 @@ class StateApp {
|
||||
try {
|
||||
val caFile = AppCaUpdater.ensureCaBundle(context)
|
||||
Libcurl.setDefaultCAPath(caFile.absolutePath)
|
||||
Logger.i(TAG, "Libcurl initialized")
|
||||
} catch (t: Throwable) {
|
||||
val fallback = File(context.noBackupFilesDir, "curl-ca-bundle.pem")
|
||||
if (fallback.exists()) Libcurl.setDefaultCAPath(fallback.absolutePath)
|
||||
Logger.e(TAG, "Failed to initialize Libcurl", t);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -572,30 +572,39 @@ class StateApp {
|
||||
DownloadService.getOrCreateService(context);
|
||||
}
|
||||
|
||||
Logger.i(TAG, "MainApp Started: Initialize [AutoUpdate]");
|
||||
val autoUpdateEnabled = Settings.instance.autoUpdate.isAutoUpdateEnabled();
|
||||
val shouldDownload = Settings.instance.autoUpdate.shouldDownload();
|
||||
val backgroundDownload = Settings.instance.autoUpdate.backgroundDownload == 1;
|
||||
when {
|
||||
//Background download
|
||||
autoUpdateEnabled && shouldDownload && backgroundDownload -> {
|
||||
StateUpdate.instance.setShouldBackgroundUpdate(true);
|
||||
}
|
||||
if (Settings.instance.autoUpdate.isAutoUpdateEnabled()) {
|
||||
if (Settings.instance.autoUpdate.shouldBackgroundDownload) {
|
||||
Logger.i(TAG, "MainApp Started: Initialize [AutoUpdate Background]");
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build();
|
||||
|
||||
autoUpdateEnabled && !shouldDownload && backgroundDownload -> {
|
||||
Logger.i(TAG, "Auto update skipped due to wrong network state");
|
||||
}
|
||||
val periodicRequest = PeriodicWorkRequest.Builder(
|
||||
UpdateCheckWorker::class.java,
|
||||
12, TimeUnit.HOURS
|
||||
)
|
||||
.setConstraints(constraints)
|
||||
.build();
|
||||
|
||||
//Foreground download
|
||||
autoUpdateEnabled -> {
|
||||
val wm = WorkManager.getInstance(context);
|
||||
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) {
|
||||
StateUpdate.instance.checkForUpdates(context, false)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
Logger.i(TAG, "Auto update disabled");
|
||||
}
|
||||
} else {
|
||||
Logger.i(TAG, "AutoUpdate disabled");
|
||||
}
|
||||
|
||||
Logger.i(TAG, "MainApp Started: Initialize [Noisy]");
|
||||
@@ -781,24 +790,20 @@ class StateApp {
|
||||
Logger.i("StateApp", "No AutoBackup configured");
|
||||
}
|
||||
|
||||
|
||||
fun scheduleBackgroundWork(context: Context, active: Boolean = true, intervalMinutes: Int = 60 * 12) {
|
||||
try {
|
||||
val wm = WorkManager.getInstance(context);
|
||||
|
||||
if(active) {
|
||||
if(BuildConfig.DEBUG)
|
||||
if (active) {
|
||||
if (BuildConfig.DEBUG)
|
||||
UIDialogs.toast(context, "Scheduling background every ${intervalMinutes} minutes");
|
||||
|
||||
val req = PeriodicWorkRequest.Builder(BackgroundWorker::class.java, intervalMinutes.toLong(), TimeUnit.MINUTES, 5, TimeUnit.MINUTES)
|
||||
.setConstraints(Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.UNMETERED)
|
||||
.build())
|
||||
.build();
|
||||
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.UNMETERED).build()).build();
|
||||
wm.enqueueUniquePeriodicWork("backgroundSubscriptions", ExistingPeriodicWorkPolicy.UPDATE, req);
|
||||
} else {
|
||||
wm.cancelUniqueWork("backgroundSubscriptions");
|
||||
}
|
||||
else
|
||||
wm.cancelAllWork();
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to schedule background subscription updates.", e)
|
||||
UIDialogs.toast(context, "Background subscription update failed: " + e.message)
|
||||
@@ -806,6 +811,7 @@ class StateApp {
|
||||
}
|
||||
|
||||
|
||||
|
||||
private suspend fun migrateStores(context: Context, managedStores: List<ManagedStore<*>>, index: Int) {
|
||||
if(managedStores.size <= index)
|
||||
return;
|
||||
@@ -903,15 +909,6 @@ class StateApp {
|
||||
try {
|
||||
if(FragmentedStorage.isInitialized && Settings.instance.downloads.shouldDownload())
|
||||
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) {
|
||||
Logger.w(TAG, "Failed to handle capabilities changed event", ex);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ class StateAssets {
|
||||
if(part == "." || part == "..") {
|
||||
if(parentAllowance <= 0)
|
||||
throw IllegalStateException("Path [${path}] attempted to escape path..");
|
||||
parts1.removeLast();
|
||||
parts1.removeAt(parts1.size - 1);
|
||||
toSkip++;
|
||||
}
|
||||
else
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package com.futo.platformplayer.states
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.Intent
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.MediaStore
|
||||
import android.provider.MediaStore.Audio.Artists
|
||||
import android.webkit.MimeTypeMap
|
||||
@@ -154,34 +156,101 @@ class StateLibrary {
|
||||
fun getArtist(id: Long): Artist? {
|
||||
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> {
|
||||
var query = if(buckets != null) "${MediaStore.Video.Media.BUCKET_DISPLAY_NAME} IN " + "(" + buckets.map { "'${it}'" }.joinToString(",") + ")" else null;
|
||||
val cursor = StateApp.instance.contextOrNull?.contentResolver?.query(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, PROJECTION_VIDEO,
|
||||
query,
|
||||
null,
|
||||
MediaStore.Video.Media.DATE_ADDED + " DESC") ?: return EmptyPager();
|
||||
if (!buckets.isNullOrEmpty()) {
|
||||
val placeholders = buckets.joinToString(",") { "?" }
|
||||
selection = "${MediaStore.Video.Media.BUCKET_DISPLAY_NAME} IN ($placeholders)"
|
||||
selectionArgs = buckets.toTypedArray()
|
||||
} else {
|
||||
selection = null
|
||||
selectionArgs = null
|
||||
}
|
||||
|
||||
//Ongoing usage of cursor..todo disposal
|
||||
//return cursor.use {
|
||||
cursor.moveToFirst();
|
||||
val list = mutableListOf<IPlatformVideo>()
|
||||
while(!cursor.isAfterLast && list.size < 10) {
|
||||
list.add(videoFromCursor(cursor));
|
||||
cursor.moveToNext();
|
||||
val collectionUri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)
|
||||
} else {
|
||||
MediaStore.Video.Media.EXTERNAL_CONTENT_URI
|
||||
}
|
||||
|
||||
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<IPlatformContent>({
|
||||
val list = mutableListOf<IPlatformContent>()
|
||||
while(!cursor.isAfterLast && list.size < 10) {
|
||||
list.add(videoFromCursor(cursor));
|
||||
cursor.moveToNext();
|
||||
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()
|
||||
}
|
||||
Logger.i(TAG, "Videos nextPage: ${list.size}")
|
||||
return@AdhocPager list;
|
||||
}, list);
|
||||
//}
|
||||
|
||||
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> {
|
||||
val videoPager = getVideos(buckets);
|
||||
val items = mutableListOf<IPlatformVideo>();
|
||||
@@ -193,48 +262,80 @@ class StateLibrary {
|
||||
return items;
|
||||
}
|
||||
|
||||
private var _cacheBucketNames: List<Bucket>? = null;
|
||||
fun getVideoBucketNames(): List<Bucket> {
|
||||
if(Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU)
|
||||
return listOf();
|
||||
if(_cacheBucketNames != null)
|
||||
return _cacheBucketNames ?: listOf();
|
||||
try {
|
||||
val cur: Cursor = StateApp.instance.contextOrNull?.contentResolver?.query(
|
||||
MediaStore.Video.Media.EXTERNAL_CONTENT_URI, arrayOf(
|
||||
MediaStore.Video.Media.BUCKET_ID,
|
||||
MediaStore.Video.Media.BUCKET_DISPLAY_NAME,
|
||||
), null, null, null
|
||||
) ?: return listOf();
|
||||
@Volatile
|
||||
private var _cachedVideoBuckets: List<Bucket>? = null
|
||||
private val _bucketCacheLock = Any()
|
||||
|
||||
return cur.use {
|
||||
val buckets = mutableListOf<Bucket>();
|
||||
val list = HashSet<Long>();
|
||||
if (cur.moveToFirst()) {
|
||||
var id: Long;
|
||||
var bucket: String
|
||||
do {
|
||||
try {
|
||||
id = cur.getLong(0);
|
||||
bucket = cur.getStringOrNull(1) ?: continue;
|
||||
if (!list.contains(id)) {
|
||||
list.add(id);
|
||||
buckets.add(Bucket(id, bucket));
|
||||
}
|
||||
} catch (ex: Throwable) {
|
||||
Logger.e(TAG, "Failed to parse bucket due to ${ex.message}", ex);
|
||||
}
|
||||
} while (cur.moveToNext())
|
||||
fun getVideoBucketNames(forceRefresh: Boolean = false): List<Bucket> {
|
||||
if (!forceRefresh) {
|
||||
_cachedVideoBuckets?.let { return it }
|
||||
}
|
||||
|
||||
val resolver = StateApp.instance.contextOrNull?.contentResolver
|
||||
?: 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>()
|
||||
}
|
||||
_cacheBucketNames = buckets.toList()
|
||||
return@use _cacheBucketNames ?: listOf();
|
||||
|
||||
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>()
|
||||
|
||||
do {
|
||||
try {
|
||||
val id = cursor.getLong(idxId)
|
||||
if (!seenIds.add(id)) {
|
||||
continue
|
||||
}
|
||||
|
||||
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 (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 }
|
||||
}
|
||||
return emptyList()
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e(TAG, "Buckets loading failed, returning empty");
|
||||
return listOf();
|
||||
|
||||
synchronized(_bucketCacheLock) {
|
||||
if (!forceRefresh) {
|
||||
_cachedVideoBuckets?.let { return it }
|
||||
}
|
||||
_cachedVideoBuckets = loadedBuckets
|
||||
return loadedBuckets
|
||||
}
|
||||
}
|
||||
fun invalidateVideoBucketNamesCache() {
|
||||
_cachedVideoBuckets = null
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
@@ -243,7 +344,8 @@ class StateLibrary {
|
||||
MediaStore.Video.Media.DISPLAY_NAME,
|
||||
MediaStore.Video.Media.DATE_ADDED,
|
||||
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(
|
||||
MediaStore.Audio.Media._ID, //0
|
||||
@@ -386,9 +488,10 @@ class StateLibrary {
|
||||
"";
|
||||
|
||||
|
||||
val albumContentUrl = if(albumId > 0)
|
||||
ContentUris.withAppendedId(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, albumId)?.toString()
|
||||
else null;
|
||||
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)
|
||||
OffsetDateTime.ofInstant(Instant.ofEpochSecond(date), ZoneOffset.UTC)
|
||||
@@ -414,6 +517,8 @@ class StateLibrary {
|
||||
val date = cursor.getLong(2);
|
||||
val contentType = cursor.getString(3);
|
||||
val category = cursor.getString(4);
|
||||
val durationMs = cursor.getLong(5)
|
||||
val duration = if (durationMs > 0) durationMs / 1000 else -1
|
||||
|
||||
val idLong = id.toLongOrNull();
|
||||
val contentUrl = if(idLong != null )
|
||||
@@ -433,7 +538,7 @@ class StateLibrary {
|
||||
PlatformID("FILE", contentUrl, null, 0, -1),
|
||||
displayName, Thumbnails(arrayOf(
|
||||
Thumbnail(contentUrl, 0)
|
||||
)), authorObj, contentUrl, -1, contentType, dateObj);
|
||||
)), authorObj, contentUrl, duration, contentType, dateObj);
|
||||
}
|
||||
|
||||
private var _instance : StateLibrary? = null;
|
||||
@@ -521,11 +626,12 @@ class Artist {
|
||||
val numTracks = cursor.getInt(2);
|
||||
val numAlbums = cursor.getInt(3);
|
||||
|
||||
val idLong = id.toLongOrNull();
|
||||
val uri = if(idLong != null) ContentUris.withAppendedId(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, idLong) else null;
|
||||
val idLong = id.toLongOrNull()
|
||||
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? {
|
||||
val resolver = StateApp.instance.contextOrNull?.contentResolver;
|
||||
@@ -629,9 +735,10 @@ class Album {
|
||||
val numTracks = cursor.getInt(2);
|
||||
val artist = cursor.getString(3);
|
||||
|
||||
val idLong = id.toLongOrNull();
|
||||
val uri = if(idLong != null) ContentUris.withAppendedId(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, idLong) else null;
|
||||
return Album(album, numTracks, artist, id, uri?.toString());
|
||||
val idLong = id.toLongOrNull()
|
||||
val albumArtBase = Uri.parse("content://media/external/audio/albumart")
|
||||
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> {
|
||||
|
||||
@@ -10,6 +10,7 @@ import android.graphics.drawable.Drawable
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
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.activities.MainActivity
|
||||
@@ -22,6 +23,7 @@ import com.futo.platformplayer.serializers.PlatformContentSerializer
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
import com.futo.platformplayer.toHumanNowDiffString
|
||||
import com.futo.platformplayer.toHumanNowDiffStringMinDay
|
||||
import com.futo.platformplayer.withMaxSizePx
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
class StateNotifications {
|
||||
@@ -96,6 +98,7 @@ class StateNotifications {
|
||||
if(thumbnail != null)
|
||||
Glide.with(context).asBitmap()
|
||||
.load(thumbnail)
|
||||
.withMaxSizePx()
|
||||
.into(object: CustomTarget<Bitmap>() {
|
||||
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
||||
notifyNewContent(context, manager, notificationChannel, id, content, resource);
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
package com.futo.platformplayer.states
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.DefaultLoadControl
|
||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||
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 com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
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.IPlatformVideoDetails
|
||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
||||
@@ -21,8 +26,10 @@ import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.Playlist
|
||||
import com.futo.platformplayer.services.MediaPlaybackService
|
||||
import com.futo.platformplayer.video.PlayerManager
|
||||
import com.google.common.collect.Iterables
|
||||
import kotlin.random.Random
|
||||
|
||||
|
||||
/***
|
||||
* Used to keep track of queue and other player related stuff
|
||||
*/
|
||||
@@ -240,17 +247,29 @@ class StatePlayer {
|
||||
}
|
||||
|
||||
private fun createShuffledQueue() {
|
||||
val currentItem = getCurrentQueueItem();
|
||||
if (_queuePosition == -1 || currentItem == null) {
|
||||
_queueShuffled = _queue.shuffled().toMutableList()
|
||||
return;
|
||||
if (_queue.isEmpty()) {
|
||||
_queueShuffled = mutableListOf()
|
||||
return
|
||||
}
|
||||
|
||||
val nextItems = _queue.subList(Math.min(_queuePosition + 1, _queue.size - 1), _queue.size).shuffled();
|
||||
val previousItems = _queue.subList(0, _queuePosition).shuffled();
|
||||
_queueShuffled = (previousItems + currentItem + nextItems).toMutableList();
|
||||
val currentItem = getCurrentQueueItem()
|
||||
if (currentItem == null || _queuePosition !in _queue.indices) {
|
||||
_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) {
|
||||
val isLastVideo = _queuePosition + 1 >= _queue.size;
|
||||
if (isLastVideo) {
|
||||
@@ -662,6 +681,30 @@ class StatePlayer {
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun createExoPlayer(context : Context): ExoPlayer {
|
||||
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(
|
||||
DefaultLoadControl.Builder()
|
||||
.setAllocator(DefaultAllocator(true, BUFFER_SIZE))
|
||||
|
||||
@@ -169,6 +169,9 @@ class StatePlugins {
|
||||
return false;
|
||||
|
||||
LoginFragment.showLogin(config) {//LoginActivity.showLogin(context, config) {
|
||||
|
||||
if(it == null)
|
||||
return@showLogin;
|
||||
try {
|
||||
StatePlugins.instance.setPluginAuth(config.id, it);
|
||||
} catch (e: Throwable) {
|
||||
|
||||
@@ -15,146 +15,6 @@ import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
|
||||
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) {
|
||||
try {
|
||||
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? {
|
||||
val response = client.get(VERSION_URL);
|
||||
if (!response.isOk || response.body == null) {
|
||||
@@ -267,6 +108,22 @@ class StateUpdate {
|
||||
}
|
||||
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() {
|
||||
_instance?.let {
|
||||
_instance = null;
|
||||
|
||||
@@ -11,7 +11,7 @@ class SearchHistoryStorage : FragmentedStorageFileJson() {
|
||||
if (!lastQueries.contains(text)) {
|
||||
lastQueries.add(0, text);
|
||||
if (lastQueries.size > 10)
|
||||
lastQueries.removeLast();
|
||||
lastQueries.removeAt(lastQueries.size - 1);
|
||||
}
|
||||
else {
|
||||
lastQueries.remove(text);
|
||||
|
||||
@@ -7,10 +7,12 @@ import android.widget.TextView
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
||||
import com.futo.platformplayer.models.Playlist
|
||||
import com.futo.platformplayer.withMaxSizePx
|
||||
|
||||
class PlaylistsViewHolder : ViewHolder {
|
||||
private val _root: ConstraintLayout;
|
||||
@@ -44,6 +46,7 @@ class PlaylistsViewHolder : ViewHolder {
|
||||
if (p.videos.isNotEmpty()) {
|
||||
Glide.with(_imageThumbnail)
|
||||
.load(p.videos[0].thumbnails.getMinimumThumbnail(380))
|
||||
.withMaxSizePx()
|
||||
.placeholder(R.drawable.placeholder_video_thumbnail)
|
||||
.crossfade()
|
||||
.into(_imageThumbnail);
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
@@ -23,6 +24,7 @@ import com.futo.platformplayer.toHumanNumber
|
||||
import com.futo.platformplayer.toHumanTime
|
||||
import com.futo.platformplayer.views.others.ProgressBar
|
||||
import com.futo.platformplayer.views.platform.PlatformIndicator
|
||||
import com.futo.platformplayer.withMaxSizePx
|
||||
|
||||
class VideoListEditorViewHolder : ViewHolder {
|
||||
private val _root: ConstraintLayout;
|
||||
@@ -89,6 +91,7 @@ class VideoListEditorViewHolder : ViewHolder {
|
||||
fun bind(v: IPlatformVideo, canEdit: Boolean) {
|
||||
Glide.with(_imageThumbnail)
|
||||
.load(v.thumbnails.getHQThumbnail())
|
||||
.withMaxSizePx()
|
||||
.placeholder(R.drawable.placeholder_video_thumbnail)
|
||||
.crossfade()
|
||||
.into(_imageThumbnail);
|
||||
|
||||
+3
@@ -7,6 +7,7 @@ import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.core.view.isVisible
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
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.toHumanTime
|
||||
import com.futo.platformplayer.views.adapters.AnyAdapter
|
||||
import com.futo.platformplayer.withMaxSizePx
|
||||
import com.google.android.material.imageview.ShapeableImageView
|
||||
|
||||
|
||||
@@ -49,6 +51,7 @@ class LocalVideoTileViewHolder(val _viewGroup: ViewGroup) : AnyAdapter.AnyViewHo
|
||||
Glide.with(it)
|
||||
.load(content.thumbnails.getHQThumbnail())
|
||||
.placeholder(R.drawable.unknown_music)
|
||||
.withMaxSizePx()
|
||||
.into(it)
|
||||
else
|
||||
Glide.with(it).load(R.drawable.unknown_music).into(it);
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.futo.platformplayer.views.buttons
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Looper
|
||||
import android.util.AttributeSet
|
||||
import android.util.TypedValue
|
||||
import android.view.View
|
||||
@@ -98,46 +99,58 @@ open class BigButton : LinearLayout {
|
||||
return this;
|
||||
}
|
||||
|
||||
fun withIcon(resourceId: Int, rounded: Boolean = false): BigButton {
|
||||
private fun applyIcon(resourceId: Int, rounded: Boolean) {
|
||||
if (resourceId != -1) {
|
||||
_icon.visibility = View.VISIBLE;
|
||||
_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;
|
||||
_icon.visibility = View.VISIBLE
|
||||
_icon.setImageResource(resourceId)
|
||||
} else {
|
||||
_icon.scaleType = ImageView.ScaleType.CENTER_CROP;
|
||||
_icon.shapeAppearanceModel = ShapeAppearanceModel();
|
||||
_icon.visibility = View.GONE
|
||||
}
|
||||
|
||||
return this;
|
||||
applyRounded(rounded)
|
||||
}
|
||||
|
||||
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 {
|
||||
_icon.visibility = View.VISIBLE;
|
||||
_icon.setImageBitmap(bitmap);
|
||||
|
||||
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;
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
applyIcon(bitmap, rounded)
|
||||
} else {
|
||||
_icon.scaleType = ImageView.ScaleType.CENTER_CROP;
|
||||
_icon.shapeAppearanceModel = ShapeAppearanceModel();
|
||||
post { applyIcon(bitmap, rounded) }
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.casting.CastConnectionState
|
||||
import com.futo.platformplayer.casting.CastingDevice
|
||||
import com.futo.platformplayer.casting.StateCasting
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
|
||||
@@ -22,18 +23,16 @@ class CastButton : androidx.appcompat.widget.AppCompatImageButton {
|
||||
visibility = View.GONE;
|
||||
}
|
||||
|
||||
StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, _ ->
|
||||
updateCastState();
|
||||
StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { d, _ ->
|
||||
updateCastState(d);
|
||||
};
|
||||
|
||||
updateCastState();
|
||||
updateCastState(StateCasting.instance.activeDevice);
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateCastState() {
|
||||
private fun updateCastState(d: CastingDevice?) {
|
||||
val c = context ?: return;
|
||||
val d = StateCasting.instance.activeDevice;
|
||||
|
||||
val activeColor = ContextCompat.getColor(c, R.color.colorPrimary);
|
||||
val connectingColor = ContextCompat.getColor(c, R.color.gray_c3);
|
||||
val inactiveColor = ContextCompat.getColor(c, R.color.white);
|
||||
|
||||
@@ -18,6 +18,7 @@ import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.ui.DefaultTimeBar
|
||||
import androidx.media3.ui.TimeBar
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.api.media.models.chapters.IChapter
|
||||
@@ -32,6 +33,7 @@ import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StatePlayer
|
||||
import com.futo.platformplayer.views.TargetTapLoaderView
|
||||
import com.futo.platformplayer.views.behavior.GestureControlView
|
||||
import com.futo.platformplayer.withMaxSizePx
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -306,6 +308,7 @@ class CastView : ConstraintLayout {
|
||||
Glide.with(_thumbnail)
|
||||
.load(video.thumbnails.getHQThumbnail())
|
||||
.placeholder(R.drawable.placeholder_video_thumbnail)
|
||||
.withMaxSizePx()
|
||||
.into(_thumbnail);
|
||||
_textPosition.text = (position * 1000).formatDuration();
|
||||
_textDuration.text = (video.duration * 1000).formatDuration();
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||
import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.downloads.VideoDownload
|
||||
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
||||
@@ -61,6 +62,7 @@ class ActiveDownloadItem: LinearLayout {
|
||||
|
||||
Glide.with(_videoImage)
|
||||
.load(download.thumbnail)
|
||||
.withMaxSizePx()
|
||||
.crossfade()
|
||||
.into(_videoImage);
|
||||
|
||||
|
||||
@@ -5,9 +5,11 @@ import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
||||
import com.futo.platformplayer.models.PlaylistDownloaded
|
||||
import com.futo.platformplayer.withMaxSizePx
|
||||
|
||||
class PlaylistDownloadItem(context: Context, playlistName: String, playlistThumbnail: String?, val obj: Any): LinearLayout(context) {
|
||||
init { inflate(context, R.layout.list_downloaded_playlist, this) }
|
||||
@@ -19,6 +21,7 @@ class PlaylistDownloadItem(context: Context, playlistName: String, playlistThumb
|
||||
imageText.text = playlistName;
|
||||
Glide.with(imageView)
|
||||
.load(playlistThumbnail)
|
||||
.withMaxSizePx()
|
||||
.crossfade()
|
||||
.into(imageView);
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ class VideoListEditorView : FrameLayout {
|
||||
executeDelete()
|
||||
}, cancelAction = {
|
||||
|
||||
}, doNotAskAgainAction = {
|
||||
}, dismissAction = {}, doNotAskAgainAction = {
|
||||
Settings.instance.other.playlistDeleteConfirmation = false
|
||||
Settings.instance.save()
|
||||
})
|
||||
|
||||
@@ -15,6 +15,7 @@ import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.ui.PlayerControlView
|
||||
import androidx.media3.ui.PlayerView
|
||||
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
|
||||
@@ -25,6 +26,7 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
||||
import com.futo.platformplayer.helpers.VideoHelper
|
||||
import com.futo.platformplayer.toHumanTime
|
||||
import com.futo.platformplayer.video.PlayerManager
|
||||
import com.futo.platformplayer.withMaxSizePx
|
||||
|
||||
|
||||
class FutoThumbnailPlayer : FutoVideoPlayerBase {
|
||||
@@ -135,7 +137,7 @@ class FutoThumbnailPlayer : FutoVideoPlayerBase {
|
||||
if (videoSource == null && audioSource != null) {
|
||||
val thumbnail = video.thumbnails.getHQThumbnail();
|
||||
if (!thumbnail.isNullOrBlank()) {
|
||||
Glide.with(videoView).asBitmap().load(thumbnail).into(_loadArtwork);
|
||||
Glide.with(videoView).asBitmap().load(thumbnail).withMaxSizePx().into(_loadArtwork);
|
||||
} else {
|
||||
Glide.with(videoView).clear(_loadArtwork);
|
||||
setArtwork(null);
|
||||
|
||||
@@ -54,6 +54,7 @@ import com.futo.platformplayer.states.StatePlayer
|
||||
import com.futo.platformplayer.views.TargetTapLoaderView
|
||||
import com.futo.platformplayer.views.behavior.GestureControlView
|
||||
import com.futo.platformplayer.views.others.ProgressBar
|
||||
import com.futo.platformplayer.withMaxSizePx
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
@@ -488,7 +489,7 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
||||
|
||||
StatePlayer.instance.onQueueChanged.subscribe(this) {
|
||||
CoroutineScope(Dispatchers.Main).launch(Dispatchers.Main) {
|
||||
setLoopVisible(!StatePlayer.instance.hasQueue)
|
||||
//setLoopVisible(!StatePlayer.instance.hasQueue)
|
||||
updateNextPrevious();
|
||||
}
|
||||
}
|
||||
@@ -885,12 +886,12 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
||||
}
|
||||
fun updateLoopVideoUI() {
|
||||
if(StatePlayer.instance.loopVideo) {
|
||||
_control_loop.setImageResource(R.drawable.ic_loop_active);
|
||||
_control_loop_fullscreen.setImageResource(R.drawable.ic_loop_active);
|
||||
_control_loop.setImageResource(R.drawable.ic_repeat_one_active);
|
||||
_control_loop_fullscreen.setImageResource(R.drawable.ic_repeat_one_active);
|
||||
}
|
||||
else {
|
||||
_control_loop.setImageResource(R.drawable.ic_loop);
|
||||
_control_loop_fullscreen.setImageResource(R.drawable.ic_loop);
|
||||
_control_loop.setImageResource(R.drawable.ic_repeat_one);
|
||||
_control_loop_fullscreen.setImageResource(R.drawable.ic_repeat_one);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -928,11 +929,9 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
||||
override fun switchToAudioMode(video: IPlatformVideoDetails?) {
|
||||
super.switchToAudioMode(video)
|
||||
|
||||
//This causes issues, and is in general confusing, needs improvements
|
||||
/*
|
||||
val thumbnail = video?.thumbnails?.getHQThumbnail()
|
||||
if (!thumbnail.isNullOrBlank()) {
|
||||
Glide.with(context).asBitmap().load(thumbnail)
|
||||
Glide.with(context).asBitmap().load(thumbnail).withMaxSizePx()
|
||||
.into(object : CustomTarget<Bitmap>() {
|
||||
override fun onResourceReady(
|
||||
resource: Bitmap,
|
||||
@@ -946,6 +945,5 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
||||
}
|
||||
})
|
||||
}
|
||||
*/
|
||||
}
|
||||
}
|
||||
+12
@@ -111,6 +111,10 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource {
|
||||
* @return This factory.
|
||||
*/
|
||||
public Factory setRequestExecutor(@Nullable JSRequestExecutor requestExecutor) {
|
||||
JSRequestExecutor oldExecutor = this.requestExecutor;
|
||||
if(oldExecutor != null) {
|
||||
oldExecutor.closeAsync();
|
||||
}
|
||||
this.requestExecutor = requestExecutor;
|
||||
return this;
|
||||
}
|
||||
@@ -123,6 +127,10 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource {
|
||||
* @return This factory.
|
||||
*/
|
||||
public Factory setRequestExecutor2(@Nullable JSRequestExecutor requestExecutor) {
|
||||
JSRequestExecutor oldExecutor = this.requestExecutor2;
|
||||
if(oldExecutor != null) {
|
||||
oldExecutor.closeAsync();
|
||||
}
|
||||
this.requestExecutor2 = requestExecutor;
|
||||
return this;
|
||||
}
|
||||
@@ -508,6 +516,10 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource {
|
||||
|
||||
@Override
|
||||
public void close() throws HttpDataSourceException {
|
||||
if(requestExecutor != null)
|
||||
requestExecutor.closeAsync();
|
||||
if(requestExecutor2 != null)
|
||||
requestExecutor2.closeAsync();
|
||||
try {
|
||||
@Nullable InputStream inputStream = this.inputStream;
|
||||
if (inputStream != null) {
|
||||
|
||||
@@ -11,11 +11,13 @@ import android.widget.TextView
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||
import com.futo.platformplayer.states.StatePlayer
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.toHumanNowDiffString
|
||||
import com.futo.platformplayer.toHumanNumber
|
||||
import com.futo.platformplayer.withMaxSizePx
|
||||
|
||||
class UpNextView : LinearLayout {
|
||||
private val _layoutContainer: LinearLayout;
|
||||
@@ -160,6 +162,7 @@ class UpNextView : LinearLayout {
|
||||
_textChannelName.text = nextItem.author.name;
|
||||
Glide.with(_imageThumbnail)
|
||||
.load(nextItem.thumbnails.getHQThumbnail())
|
||||
.withMaxSizePx()
|
||||
.placeholder(R.drawable.placeholder_video_thumbnail)
|
||||
.into(_imageThumbnail);
|
||||
Glide.with(_imageChannelThumbnail)
|
||||
|
||||
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M472.31,587.69L472.31,407.69L424.62,407.69L424.62,372.31L507.69,372.31L507.69,587.69L472.31,587.69ZM292.31,840L160,707.69L292.31,575.38L320.62,604.15L237.08,687.69L692.31,687.69L692.31,527.69L732.31,527.69L732.31,727.69L237.08,727.69L320.62,811.23L292.31,840ZM227.69,432.31L227.69,232.31L722.92,232.31L639.38,148.77L667.69,120L800,252.31L667.69,384.62L639.38,355.85L722.92,272.31L267.69,272.31L267.69,432.31L227.69,432.31Z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="@color/colorPrimary"
|
||||
android:pathData="M472.31,587.69L472.31,407.69L424.62,407.69L424.62,372.31L507.69,372.31L507.69,587.69L472.31,587.69ZM292.31,840L160,707.69L292.31,575.38L320.62,604.15L237.08,687.69L692.31,687.69L692.31,527.69L732.31,527.69L732.31,727.69L237.08,727.69L320.62,811.23L292.31,840ZM227.69,432.31L227.69,232.31L722.92,232.31L639.38,148.77L667.69,120L800,252.31L667.69,384.62L639.38,355.85L722.92,272.31L267.69,272.31L267.69,432.31L227.69,432.31Z"/>
|
||||
</vector>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
@@ -77,12 +77,13 @@
|
||||
android:layout_width="50dp"
|
||||
android:layout_height="50dp"
|
||||
android:contentDescription="@string/cd_incognito_button"
|
||||
android:src="@drawable/ic_disabled_visible_purple"
|
||||
android:src="@drawable/incognito_purple"
|
||||
android:background="@drawable/background_button_round_black"
|
||||
android:scaleType="fitCenter"
|
||||
android:visibility="visible"
|
||||
android:layout_marginLeft="10dp"
|
||||
android:layout_marginBottom="10dp"
|
||||
android:padding="8dp"
|
||||
android:elevation="50dp"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintBottom_toTopOf="@id/toast_view" />
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/update"
|
||||
android:text="@string/download"
|
||||
android:textSize="14dp"
|
||||
android:textColor="@color/white"
|
||||
android:fontFamily="@font/inter_regular"
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
<ImageView
|
||||
android:layout_width="30dp"
|
||||
android:layout_height="30dp"
|
||||
android:src="@drawable/ic_disabled_visible" />
|
||||
android:src="@drawable/incognito" />
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
@@ -109,6 +109,7 @@
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
android:layoutDirection="rtl"
|
||||
|
||||
android:gravity="end">
|
||||
</androidx.recyclerview.widget.RecyclerView>
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
android:scaleType="fitCenter"
|
||||
android:clickable="true"
|
||||
android:padding="12dp"
|
||||
app:srcCompat="@drawable/ic_loop" />
|
||||
app:srcCompat="@drawable/ic_repeat_one" />
|
||||
<ImageButton
|
||||
android:id="@+id/button_settings"
|
||||
android:layout_width="50dp"
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
android:scaleType="fitCenter"
|
||||
android:clickable="true"
|
||||
android:padding="12dp"
|
||||
app:srcCompat="@drawable/ic_loop" />
|
||||
app:srcCompat="@drawable/ic_repeat_one" />
|
||||
<ImageButton
|
||||
android:id="@+id/button_settings"
|
||||
android:layout_width="50dp"
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<string name="subscriptions">Subscriptions</string>
|
||||
<string name="loading">Loading</string>
|
||||
<string name="retry">Retry</string>
|
||||
<string name="install_failed_device_installer_broken">Failed to start system installer. Your device’s ROM is not compatible with automatic updates.</string>
|
||||
<string name="cancel">Cancel</string>
|
||||
<string name="failed_to_retrieve_data_are_you_connected">Failed to retrieve data, are you connected?</string>
|
||||
<string name="settings">Settings</string>
|
||||
|
||||
@@ -116,4 +116,14 @@
|
||||
<item name="android:fontFamily">@font/inter_regular</item>
|
||||
</style>
|
||||
|
||||
<style name="Theme.App.TransparentNoUi" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
||||
<item name="android:windowIsTranslucent">true</item>
|
||||
<item name="android:windowBackground">@android:color/transparent</item>
|
||||
<item name="android:windowNoTitle">true</item>
|
||||
<item name="android:windowFullscreen">true</item>
|
||||
<item name="android:colorBackgroundCacheHint">@null</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
<item name="windowActionBar">false</item>
|
||||
</style>
|
||||
|
||||
</resources>
|
||||
Submodule app/src/stable/assets/sources/kick updated: 9b3c7ea213...96503584d9
Submodule app/src/stable/assets/sources/odysee updated: 89ad7e9a4b...98a8df5a60
Submodule app/src/stable/assets/sources/rumble updated: 2864a541e6...d24fc4cf8e
Submodule app/src/stable/assets/sources/youtube updated: 4f0037a19d...ec5359ae16
Submodule app/src/unstable/assets/sources/kick updated: 9b3c7ea213...96503584d9
Submodule app/src/unstable/assets/sources/odysee updated: 89ad7e9a4b...98a8df5a60
Submodule app/src/unstable/assets/sources/rumble updated: 2864a541e6...d24fc4cf8e
Submodule app/src/unstable/assets/sources/youtube updated: 4f0037a19d...ec5359ae16
+1
-1
Submodule dep/polycentricandroid updated: 642747fae8...3f14a31b95
Reference in New Issue
Block a user