mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-18 13:52:39 +02:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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,14 @@
|
||||
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" />
|
||||
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -403,13 +403,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,90 @@
|
||||
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.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)
|
||||
UpdateNotificationManager.ACTION_INSTALL_NOW -> handleInstallNow(context, intent)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleUpdateYes(context: Context, intent: Intent) {
|
||||
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
|
||||
if (version == 0) {
|
||||
return
|
||||
}
|
||||
|
||||
NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_AVAILABLE)
|
||||
|
||||
if (Settings.instance.autoUpdate.backgroundDownload == 1) {
|
||||
val serviceIntent = Intent(context, UpdateDownloadService::class.java).apply {
|
||||
putExtra(UpdateDownloadService.EXTRA_VERSION, version)
|
||||
}
|
||||
ContextCompat.startForegroundService(context, serviceIntent)
|
||||
} else {
|
||||
if (StateApp.instance.isMainActive) {
|
||||
StateApp.withContext { ctx ->
|
||||
UIDialogs.showUpdateAvailableDialog(ctx, version, false)
|
||||
}
|
||||
} else {
|
||||
val startIntent = Intent(context, MainActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
putExtra("SHOW_UPDATE_DIALOG_VERSION", version)
|
||||
}
|
||||
context.startActivity(startIntent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleUpdateNo(context: Context) {
|
||||
NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_AVAILABLE)
|
||||
}
|
||||
|
||||
private fun handleUpdateNever(context: Context) {
|
||||
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)
|
||||
}
|
||||
|
||||
private fun handleInstallNow(context: Context, intent: Intent) {
|
||||
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
|
||||
val apkPath = intent.getStringExtra(UpdateNotificationManager.EXTRA_APK_PATH)
|
||||
|
||||
if (version == 0 || apkPath.isNullOrEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val apkFile = File(apkPath)
|
||||
if (!apkFile.exists()) {
|
||||
return
|
||||
}
|
||||
|
||||
UpdateNotificationManager.cancelAll(context)
|
||||
UpdateInstaller.startInstall(context, apkFile)
|
||||
}
|
||||
}
|
||||
@@ -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,230 @@
|
||||
package com.futo.platformplayer
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
UIDialogs.showConfirmationDialog(ctx, "Update downloaded, press confirm to install", {
|
||||
UpdateNotificationManager.cancelAll(ctx)
|
||||
UpdateInstaller.startInstall(ctx, apkFile)
|
||||
}, {})
|
||||
} catch (t: Throwable) {
|
||||
Logger.w(TAG, "Failed to show in-app update downloaded dialog", t)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
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.provider.Settings
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package com.futo.platformplayer
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
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 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"
|
||||
|
||||
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)
|
||||
channel.description = CHANNEL_DESCRIPTION
|
||||
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)
|
||||
.addAction(0, "Download", yesPendingIntent)
|
||||
.addAction(0, "Not now", noPendingIntent)
|
||||
.addAction(0, "Never", neverPendingIntent)
|
||||
|
||||
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)
|
||||
.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 = Intent(context, UpdateActionReceiver::class.java).apply {
|
||||
action = ACTION_INSTALL_NOW
|
||||
putExtra(EXTRA_VERSION, version)
|
||||
putExtra(EXTRA_APK_PATH, apkFile.absolutePath)
|
||||
}
|
||||
val installPendingIntent = getBroadcast(
|
||||
context,
|
||||
4,
|
||||
installIntent,
|
||||
FLAG_MUTABLE or FLAG_UPDATE_CURRENT
|
||||
)
|
||||
|
||||
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)
|
||||
.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)
|
||||
|
||||
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,17 @@ 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, useCenterCrop: Boolean = false): RequestBuilder<T> {
|
||||
var builder = this
|
||||
.downsample(DownsampleStrategy.AT_MOST)
|
||||
.override(maxSizePx, maxSizePx)
|
||||
builder = if (useCenterCrop) {
|
||||
builder.centerCrop()
|
||||
} else {
|
||||
builder.fitCenter()
|
||||
}
|
||||
|
||||
return builder
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 {
|
||||
@@ -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()) {
|
||||
requestNotificationPermissions("Grayjay uses notifications to inform you when a new app update is available.");
|
||||
}
|
||||
|
||||
val submissionStatus = FragmentedStorage.get<StringStorage>("subscriptionSubmissionStatus")
|
||||
|
||||
val numSubscriptions = StateSubscriptions.instance.getSubscriptionCount()
|
||||
|
||||
+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..?
|
||||
|
||||
@@ -1280,10 +1280,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
|
||||
@@ -46,7 +48,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,14 +81,19 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
|
||||
return@setOnClickListener;
|
||||
}
|
||||
|
||||
_updating = true;
|
||||
update();
|
||||
if (Settings.instance.autoUpdate.backgroundDownload == 1) {
|
||||
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()
|
||||
}
|
||||
|
||||
override fun dismiss() {
|
||||
@@ -118,21 +124,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!!;
|
||||
}
|
||||
|
||||
+30
-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,16 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
else {
|
||||
StateApp.instance.setPrivacyMode(true);
|
||||
UIDialogs.appToast("Privacy mode enabled");
|
||||
|
||||
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 +182,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
setMoreVisible(false);
|
||||
}
|
||||
})
|
||||
moreColumns = columns;
|
||||
val layoutManager = GridLayoutManager(context, columns, GridLayoutManager.VERTICAL, true);
|
||||
_layoutMoreButtons.layoutManager = layoutManager;
|
||||
|
||||
@@ -321,29 +334,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 +612,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 +623,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 +633,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);
|
||||
})
|
||||
|
||||
@@ -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,6 +470,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
||||
recyclerData.resultsUnfiltered.addAll(toAdd);
|
||||
recyclerData.adapter.notifyDataSetChanged();
|
||||
recyclerData.loadedFeedStyle = feedStyle;
|
||||
setLoading(false)
|
||||
if(pager.hasMorePages())
|
||||
ensureEnoughContentVisible(filteredResults)
|
||||
}
|
||||
|
||||
+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));
|
||||
}
|
||||
|
||||
|
||||
+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 {
|
||||
|
||||
+13
-9
@@ -356,6 +356,16 @@ class VideoDetailFragment() : MainFragment() {
|
||||
override fun onTransitionChange(motionLayout: MotionLayout?, startId: Int, endId: Int, progress: Float) {
|
||||
_viewDetail?.stopAllGestures()
|
||||
|
||||
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) {
|
||||
val progress = motionLayout?.progress ?: return;
|
||||
|
||||
if (state != State.MINIMIZED && progress < 0.1) {
|
||||
state = State.MINIMIZED;
|
||||
isMinimizingFromFullScreen = false
|
||||
@@ -372,22 +382,16 @@ class VideoDetailFragment() : MainFragment() {
|
||||
}
|
||||
}
|
||||
|
||||
if (isTransitioning && (progress > 0.95 || progress < 0.05)) {
|
||||
if (isTransitioning && (progress > 0.6 || progress < 0.4)) {
|
||||
isTransitioning = false;
|
||||
onTransitioning.emit(isTransitioning);
|
||||
|
||||
if(isInPictureInPicture) leavePictureInPictureMode(false); //Workaround to prevent getting stuck in p2p
|
||||
}
|
||||
else if (!isTransitioning && (progress < 0.95 && progress > 0.05)) {
|
||||
isTransitioning = true;
|
||||
onTransitioning.emit(isTransitioning);
|
||||
|
||||
if(isInPictureInPicture) leavePictureInPictureMode(false); //Workaround to prevent getting stuck in p2p
|
||||
}
|
||||
}
|
||||
override fun onTransitionCompleted(motionLayout: MotionLayout?, currentId: Int) { }
|
||||
override fun onTransitionStarted(p0: MotionLayout?, p1: Int, p2: Int) { }
|
||||
override fun onTransitionTrigger(p0: MotionLayout?, p1: Int, p2: Boolean, p3: Float) { }
|
||||
override fun onTransitionTrigger(p0: MotionLayout?, p1: Int, p2: Boolean, p3: Float) {
|
||||
}
|
||||
});
|
||||
|
||||
_view?.let {
|
||||
|
||||
+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);
|
||||
|
||||
@@ -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.backgroundDownload == 1) {
|
||||
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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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.
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
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