Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay

This commit is contained in:
Kelvin K
2025-12-02 15:21:36 -06:00
36 changed files with 1112 additions and 442 deletions
+9
View File
@@ -252,5 +252,14 @@
android:name=".activities.QRCodeFullscreenActivity" android:name=".activities.QRCodeFullscreenActivity"
android:screenOrientation="sensorPortrait" android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" /> android:theme="@style/Theme.FutoVideo.NoActionBar" />
<service
android:name=".UpdateDownloadService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<receiver
android:name=".UpdateActionReceiver"
android:exported="false" />
</application> </application>
</manifest> </manifest>
@@ -403,13 +403,6 @@ class UIDialogs {
dialog.setMaxVersion(lastVersion); dialog.setMaxVersion(lastVersion);
} }
fun showInstallDownloadedUpdateDialog(context: Context, apkFile: File) {
val dialog = AutoUpdateDialog(context);
registerDialogOpened(dialog);
dialog.setOnDismissListener { registerDialogClosed(dialog) };
dialog.showPredownloaded(apkFile);
}
fun showMigrateDialog(context: Context, store: ManagedStore<*>, onConcluded: ()->Unit) { fun showMigrateDialog(context: Context, store: ManagedStore<*>, onConcluded: ()->Unit) {
if(!store.hasMissingReconstructions()) if(!store.hasMissingReconstructions())
onConcluded(); onConcluded();
@@ -0,0 +1,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.Intent
import android.content.res.Resources import android.content.res.Resources
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.icu.util.Output
import android.os.Build import android.os.Build
import android.os.Looper import android.os.Looper
import android.os.OperationCanceledException import android.os.OperationCanceledException
@@ -44,6 +42,9 @@ import java.util.*
import java.util.concurrent.ThreadLocalRandom import java.util.concurrent.ThreadLocalRandom
import java.util.zip.GZIPInputStream import java.util.zip.GZIPInputStream
import java.util.zip.GZIPOutputStream import java.util.zip.GZIPOutputStream
import androidx.core.graphics.scale
import com.bumptech.glide.RequestBuilder
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
private val _allowedCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz "; private val _allowedCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz ";
fun getRandomString(sizeOfRandomString: Int): String { fun getRandomString(sizeOfRandomString: Int): String {
@@ -114,23 +115,6 @@ fun DocumentFile.writeBytes(context: Context, byteArray: ByteArray) = context.co
it.flush(); it.flush();
}; };
fun loadBitmap(url: String): Bitmap {
try {
val client = ManagedHttpClient();
val response = client.get(url);
if (response.isOk && response.body != null) {
val bitmapStream = response.body.byteStream();
val bitmap = BitmapFactory.decodeStream(bitmapStream);
return bitmap;
} else {
throw Exception("Failed to find data at URL.");
}
} catch (e: Throwable) {
Logger.w("Utility", "Exception thrown while downloading bitmap.", e);
throw e;
}
}
fun TextView.setPlatformPlayerLinkMovementMethod(context: Context) { fun TextView.setPlatformPlayerLinkMovementMethod(context: Context) {
this.movementMethod = PlatformLinkMovementMethod(context); this.movementMethod = PlatformLinkMovementMethod(context);
} }
@@ -458,4 +442,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
}
@@ -245,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) val mainId = UUID.randomUUID().toString().substring(0, 5)
@@ -631,6 +618,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
sharedPreferences.edit().putBoolean("IsFirstBoot", false).apply() sharedPreferences.edit().putBoolean("IsFirstBoot", false).apply()
} }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && Settings.instance.autoUpdate.isAutoUpdateEnabled()) {
requestNotificationPermissions("Grayjay uses notifications to inform you when a new app update is available.");
}
val submissionStatus = FragmentedStorage.get<StringStorage>("subscriptionSubmissionStatus") val submissionStatus = FragmentedStorage.get<StringStorage>("subscriptionSubmissionStatus")
val numSubscriptions = StateSubscriptions.instance.getSubscriptionCount() val numSubscriptions = StateSubscriptions.instance.getSubscriptionCount()
@@ -16,9 +16,11 @@ import android.widget.Button
import android.widget.ImageView import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.core.content.ContextCompat
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UpdateDownloadService
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.copyToOutputStream import com.futo.platformplayer.copyToOutputStream
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
@@ -46,7 +48,6 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
private var _maxVersion: Int = 0; private var _maxVersion: Int = 0;
private var _updating: Boolean = false; private var _updating: Boolean = false;
private var _apkFile: File? = null;
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
@@ -80,14 +81,19 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
return@setOnClickListener; return@setOnClickListener;
} }
_updating = true; if (Settings.instance.autoUpdate.backgroundDownload == 1) {
update(); val ctx = context.applicationContext;
val intent = Intent(ctx, UpdateDownloadService::class.java);
intent.putExtra(UpdateDownloadService.EXTRA_VERSION, _maxVersion);
ContextCompat.startForegroundService(ctx, intent);
UIDialogs.toast(context, "Downloading update in background");
dismiss();
} else {
_updating = true;
update();
}
}; };
}
fun showPredownloaded(apkFile: File) {
_apkFile = apkFile;
super.show()
} }
override fun dismiss() { override fun dismiss() {
@@ -118,21 +124,14 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
var inputStream: InputStream? = null; var inputStream: InputStream? = null;
try { try {
val apkFile = _apkFile; val client = ManagedHttpClient();
if (apkFile != null) { val response = client.get(StateUpdate.APK_URL);
inputStream = apkFile.inputStream(); if (response.isOk && response.body != null) {
val dataLength = apkFile.length(); inputStream = response.body.byteStream();
val dataLength = response.body.contentLength();
install(inputStream, dataLength); install(inputStream, dataLength);
} else { } else {
val client = ManagedHttpClient(); throw Exception("Failed to download latest version of app.");
val response = client.get(StateUpdate.APK_URL);
if (response.isOk && response.body != null) {
inputStream = response.body.byteStream();
val dataLength = response.body.contentLength();
install(inputStream, dataLength);
} else {
throw Exception("Failed to download latest version of app.");
}
} }
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.w(TAG, "Exception thrown while downloading and installing latest version of app.", e); Logger.w(TAG, "Exception thrown while downloading and installing latest version of app.", e);
@@ -55,7 +55,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
protected val _toolbarContentView: LinearLayout; protected val _toolbarContentView: LinearLayout;
protected val _bottomContentView: LinearLayout; protected val _bottomContentView: LinearLayout;
private var _loading: Boolean = true; private var _loading: Boolean = false;
private val _pagerLock = Object(); private val _pagerLock = Object();
private var _cache: ItemCache<TResult>? = null; private var _cache: ItemCache<TResult>? = null;
@@ -180,10 +180,9 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
val visibleItemCount = _recyclerResults.childCount; val visibleItemCount = _recyclerResults.childCount;
val firstVisibleItem = recyclerData.layoutManager.findFirstVisibleItemPosition() val firstVisibleItem = recyclerData.layoutManager.findFirstVisibleItemPosition()
//Logger.i(TAG, "onScrolled loadNextPage visibleItemCount=$visibleItemCount firstVisibleItem=$visibleItemCount") //Logger.i(TAG, "onScrolled loadNextPage(): firstVisibleItem=$firstVisibleItem visibleItemCount=$visibleItemCount visibleThreshold=$visibleThreshold recyclerData.results.size=${recyclerData.results.size}")
if (!_loading && firstVisibleItem + visibleItemCount + visibleThreshold >= recyclerData.results.size && firstVisibleItem > 0) { if (!_loading && firstVisibleItem + visibleItemCount + visibleThreshold >= recyclerData.results.size) {
//Logger.i(TAG, "onScrolled loadNextPage(): firstVisibleItem=$firstVisibleItem visibleItemCount=$visibleItemCount visibleThreshold=$visibleThreshold recyclerData.results.size=${recyclerData.results.size}")
loadNextPage(); loadNextPage();
} }
} }
@@ -197,57 +196,44 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
} }
private fun ensureEnoughContentVisible(filteredResults: List<TConverted>) { private fun ensureEnoughContentVisible(filteredResults: List<TConverted>) {
val canScroll = if (recyclerData.results.isEmpty()) false else { _recyclerResults.post {
val height = resources.displayMetrics.heightPixels; val canScroll = _recyclerResults.canScrollVertically(1)
Logger.i(
TAG,
"ensureEnoughContentVisible loadNextPage canScroll=$canScroll _automaticNextPageCounter=$_automaticNextPageCounter"
)
if (!canScroll || filteredResults.isEmpty()) {
_automaticNextPageCounter++
if (_automaticNextPageCounter < _automaticBackoff.size) {
if (_automaticNextPageCounter > 0) {
val automaticNextPageCounterSaved = _automaticNextPageCounter;
fragment.lifecycleScope.launch(Dispatchers.Default) {
val backoff = _automaticBackoff[Math.min(
_automaticBackoff.size - 1,
_automaticNextPageCounter
)];
val layoutManager = recyclerData.layoutManager
val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition()
val firstVisibleItemView = if(firstVisibleItemPosition != RecyclerView.NO_POSITION) layoutManager.findViewByPosition(firstVisibleItemPosition) else null;
val lastVisibleItemPosition = layoutManager.findLastCompletelyVisibleItemPosition();
val lastVisibleItemView = if(lastVisibleItemPosition != RecyclerView.NO_POSITION) layoutManager.findViewByPosition(lastVisibleItemPosition) else null;
val rows = if(recyclerData.layoutManager is GridLayoutManager) Math.max(1, recyclerData.results.size / recyclerData.layoutManager.spanCount) else 1;
val rowsHeight = (firstVisibleItemView?.height ?: 0) * rows;
if(lastVisibleItemView != null && lastVisibleItemPosition == (recyclerData.results.size - 1)) {
false;
}
else if (firstVisibleItemView != null && height != null && rowsHeight < height) {
false;
} else {
true;
}
}
Logger.i(TAG, "ensureEnoughContentVisible loadNextPage canScroll=$canScroll _automaticNextPageCounter=$_automaticNextPageCounter")
if (!canScroll || filteredResults.isEmpty()) {
_automaticNextPageCounter++
if(_automaticNextPageCounter < _automaticBackoff.size) {
if(_automaticNextPageCounter > 0) {
val automaticNextPageCounterSaved = _automaticNextPageCounter;
fragment.lifecycleScope.launch(Dispatchers.Default) {
val backoff = _automaticBackoff[Math.min(_automaticBackoff.size - 1, _automaticNextPageCounter)];
withContext(Dispatchers.Main) {
setLoading(true);
}
delay(backoff.toLong());
if(automaticNextPageCounterSaved == _automaticNextPageCounter) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
loadNextPage(); setLoading(true);
}
delay(backoff.toLong());
if (automaticNextPageCounterSaved == _automaticNextPageCounter) {
withContext(Dispatchers.Main) {
loadNextPage();
}
} else {
withContext(Dispatchers.Main) {
setLoading(false);
}
} }
} }
else { } else
withContext(Dispatchers.Main) { loadNextPage();
setLoading(false);
}
}
}
} }
else } else {
loadNextPage(); Logger.i(TAG, "ensureEnoughContentVisible automaticNextPageCounter reset");
_automaticNextPageCounter = 0;
} }
} else {
Logger.i(TAG, "ensureEnoughContentVisible automaticNextPageCounter reset");
_automaticNextPageCounter = 0;
} }
} }
fun resetAutomaticNextPageCounter(){ fun resetAutomaticNextPageCounter(){
@@ -484,6 +470,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
recyclerData.resultsUnfiltered.addAll(toAdd); recyclerData.resultsUnfiltered.addAll(toAdd);
recyclerData.adapter.notifyDataSetChanged(); recyclerData.adapter.notifyDataSetChanged();
recyclerData.loadedFeedStyle = feedStyle; recyclerData.loadedFeedStyle = feedStyle;
setLoading(false)
if(pager.hasMorePages()) if(pager.hasMorePages())
ensureEnoughContentVisible(filteredResults) ensureEnoughContentVisible(filteredResults)
} }
@@ -96,7 +96,6 @@ class LibraryVideosFragment : MainFragment() {
fun onShown() { fun onShown() {
val initialAlbums = StateLibrary.instance.getAlbums(); val initialAlbums = StateLibrary.instance.getAlbums();
Logger.i(TAG, "Initial album count: " + initialAlbums.size); Logger.i(TAG, "Initial album count: " + initialAlbums.size);
val buckets = StateLibrary.instance.getVideoBucketNames();
setPager(StateLibrary.instance.getVideos(fragment._toggleBuckets)); setPager(StateLibrary.instance.getVideos(fragment._toggleBuckets));
} }
@@ -16,6 +16,7 @@ import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.futo.platformplayer.* import com.futo.platformplayer.*
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
@@ -363,6 +364,7 @@ class RemotePlaylistFragment : MainFragment() {
_imagePlaylistThumbnail.let { _imagePlaylistThumbnail.let {
Glide.with(it) Glide.with(it)
.load(video.thumbnails.getHQThumbnail()) .load(video.thumbnails.getHQThumbnail())
.withMaxSizePx()
.placeholder(R.drawable.placeholder_video_thumbnail) .placeholder(R.drawable.placeholder_video_thumbnail)
.crossfade() .crossfade()
.into(it); .into(it);
@@ -2,7 +2,9 @@ package com.futo.platformplayer.fragment.mainactivity.main
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap
import android.graphics.drawable.Animatable import android.graphics.drawable.Animatable
import android.graphics.drawable.Drawable
import android.util.AttributeSet import android.util.AttributeSet
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.WindowManager import android.view.WindowManager
@@ -13,10 +15,15 @@ import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.graphics.drawable.toDrawable
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.Format import androidx.media3.common.Format
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
@@ -71,6 +78,7 @@ import com.futo.platformplayer.views.video.FutoShortPlayer
import com.futo.platformplayer.views.video.FutoVideoPlayerBase import com.futo.platformplayer.views.video.FutoVideoPlayerBase
import com.futo.platformplayer.views.video.FutoVideoPlayerBase.Companion.PREFERED_AUDIO_CONTAINERS import com.futo.platformplayer.views.video.FutoVideoPlayerBase.Companion.PREFERED_AUDIO_CONTAINERS
import com.futo.platformplayer.views.video.FutoVideoPlayerBase.Companion.PREFERED_VIDEO_CONTAINERS import com.futo.platformplayer.views.video.FutoVideoPlayerBase.Companion.PREFERED_VIDEO_CONTAINERS
import com.futo.platformplayer.withMaxSizePx
import com.futo.polycentric.core.ApiMethods import com.futo.polycentric.core.ApiMethods
import com.futo.polycentric.core.ContentType import com.futo.polycentric.core.ContentType
import com.futo.polycentric.core.Models import com.futo.polycentric.core.Models
@@ -851,9 +859,8 @@ class ShortView : FrameLayout {
} }
val thumbnail = videoDetails.thumbnails.getHQThumbnail() val thumbnail = videoDetails.thumbnails.getHQThumbnail()
/*
if (videoSource == null && !thumbnail.isNullOrBlank()) Glide.with(context).asBitmap() if (videoSource == null && !thumbnail.isNullOrBlank()) Glide.with(context).asBitmap()
.load(thumbnail).into(object : CustomTarget<Bitmap>() { .load(thumbnail).withMaxSizePx().into(object : CustomTarget<Bitmap>() {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) { override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
player.setArtwork(resource.toDrawable(resources)) player.setArtwork(resource.toDrawable(resources))
} }
@@ -863,7 +870,6 @@ class ShortView : FrameLayout {
} }
}) })
else player.setArtwork(null) else player.setArtwork(null)
*/
fragment.lifecycleScope.launch(Dispatchers.Main) { fragment.lifecycleScope.launch(Dispatchers.Main) {
try { try {
@@ -42,6 +42,7 @@ import androidx.media3.datasource.HttpDataSource
import androidx.media3.ui.PlayerControlView import androidx.media3.ui.PlayerControlView
import androidx.media3.ui.TimeBar import androidx.media3.ui.TimeBar
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition import com.bumptech.glide.request.transition.Transition
import com.futo.platformplayer.BuildConfig import com.futo.platformplayer.BuildConfig
@@ -161,6 +162,7 @@ import com.futo.platformplayer.views.subscriptions.SubscribeButton
import com.futo.platformplayer.views.video.FutoVideoPlayer import com.futo.platformplayer.views.video.FutoVideoPlayer
import com.futo.platformplayer.views.video.FutoVideoPlayerBase import com.futo.platformplayer.views.video.FutoVideoPlayerBase
import com.futo.platformplayer.views.videometa.UpNextView import com.futo.platformplayer.views.videometa.UpNextView
import com.futo.platformplayer.withMaxSizePx
import com.futo.polycentric.core.ApiMethods import com.futo.polycentric.core.ApiMethods
import com.futo.polycentric.core.ContentType import com.futo.polycentric.core.ContentType
import com.futo.polycentric.core.Models import com.futo.polycentric.core.Models
@@ -552,12 +554,12 @@ class VideoDetailView : ConstraintLayout {
_buttonMore = buttonMore; _buttonMore = buttonMore;
updateMoreButtons(); updateMoreButtons();
val handleLoaderGameVisibilityChanged = { b: Boolean -> val handleLoaderGameVisibilityChanged: (Boolean) -> Unit = { b: Boolean ->
_loaderGameVisible = b _loaderGameVisible = b
fragment.lifecycleScope.launch(Dispatchers.Main) { fragment.lifecycleScope.launch(Dispatchers.Main) {
onShouldEnterPictureInPictureChanged.emit() onShouldEnterPictureInPictureChanged.emit()
updateResumeVisibilityFor(lastPositionMilliseconds)
} }
updateResumeVisibilityFor(lastPositionMilliseconds)
} }
_player.loaderGameVisibilityChanged.subscribe(handleLoaderGameVisibilityChanged) _player.loaderGameVisibilityChanged.subscribe(handleLoaderGameVisibilityChanged)
_cast.loaderGameVisibilityChanged.subscribe(handleLoaderGameVisibilityChanged) _cast.loaderGameVisibilityChanged.subscribe(handleLoaderGameVisibilityChanged)
@@ -2049,7 +2051,7 @@ class VideoDetailView : ConstraintLayout {
} else { } else {
val thumbnail = video.thumbnails.getHQThumbnail(); val thumbnail = video.thumbnails.getHQThumbnail();
if ((videoSource == null) && !thumbnail.isNullOrBlank()) // || _player.isAudioMode if ((videoSource == null) && !thumbnail.isNullOrBlank()) // || _player.isAudioMode
Glide.with(context).asBitmap().load(thumbnail) Glide.with(context).asBitmap().load(thumbnail).withMaxSizePx()
.into(object: CustomTarget<Bitmap>() { .into(object: CustomTarget<Bitmap>() {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) { override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
_player.setArtwork(BitmapDrawable(resources, resource)); _player.setArtwork(BitmapDrawable(resources, resource));
@@ -14,6 +14,7 @@ import android.widget.TextView
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.setPadding import androidx.core.view.setPadding
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UISlideOverlays import com.futo.platformplayer.UISlideOverlays
@@ -28,6 +29,7 @@ import com.futo.platformplayer.toHumanDuration
import com.futo.platformplayer.toHumanTime import com.futo.platformplayer.toHumanTime
import com.futo.platformplayer.views.SearchView import com.futo.platformplayer.views.SearchView
import com.futo.platformplayer.views.lists.VideoListEditorView import com.futo.platformplayer.views.lists.VideoListEditorView
import com.futo.platformplayer.withMaxSizePx
abstract class VideoListEditorView : LinearLayout { abstract class VideoListEditorView : LinearLayout {
private var _videoListEditorView: VideoListEditorView; private var _videoListEditorView: VideoListEditorView;
@@ -211,6 +213,7 @@ abstract class VideoListEditorView : LinearLayout {
_imagePlaylistThumbnail.let { _imagePlaylistThumbnail.let {
Glide.with(it) Glide.with(it)
.load(video.thumbnails.getHQThumbnail()) .load(video.thumbnails.getHQThumbnail())
.withMaxSizePx()
.placeholder(R.drawable.placeholder_video_thumbnail) .placeholder(R.drawable.placeholder_video_thumbnail)
.crossfade() .crossfade()
.into(it); .into(it);
@@ -4,8 +4,10 @@ import android.graphics.drawable.Drawable
import android.widget.ImageView import android.widget.ImageView
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.RequestBuilder import com.bumptech.glide.RequestBuilder
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions
import com.futo.platformplayer.api.media.models.Thumbnails import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.withMaxSizePx
class GlideHelper { class GlideHelper {
@@ -14,7 +16,7 @@ class GlideHelper {
fun ImageView.loadThumbnails(thumbnails: Thumbnails, isHQ: Boolean = true, continuation: ((RequestBuilder<Drawable>) -> Unit)? = null) { fun ImageView.loadThumbnails(thumbnails: Thumbnails, isHQ: Boolean = true, continuation: ((RequestBuilder<Drawable>) -> Unit)? = null) {
val url = if(isHQ) thumbnails.getHQThumbnail() ?: thumbnails.getLQThumbnail() else thumbnails.getLQThumbnail(); val url = if(isHQ) thumbnails.getHQThumbnail() ?: thumbnails.getLQThumbnail() else thumbnails.getLQThumbnail();
val req = Glide.with(this).load(url); val req = Glide.with(this).load(url).withMaxSizePx()
if (thumbnails.hasMultiple() && false) { //TODO: Resolve issue where fallback triggered on second loads? if (thumbnails.hasMultiple() && false) { //TODO: Resolve issue where fallback triggered on second loads?
val fallbackUrl = if (isHQ) thumbnails.getLQThumbnail() else thumbnails.getHQThumbnail(); val fallbackUrl = if (isHQ) thumbnails.getLQThumbnail() else thumbnails.getHQThumbnail();
@@ -1,5 +1,6 @@
package com.futo.platformplayer.receivers package com.futo.platformplayer.receivers
import android.content.ActivityNotFoundException
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@@ -26,14 +27,24 @@ class InstallReceiver : BroadcastReceiver() {
val activityIntent: Intent? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { val activityIntent: Intent? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java) intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
} else { } else {
@Suppress("DEPRECATION")
intent.getParcelableExtra(Intent.EXTRA_INTENT) intent.getParcelableExtra(Intent.EXTRA_INTENT)
} }
if (activityIntent == null) { if (activityIntent == null) {
Logger.w(TAG, "Received STATUS_PENDING_USER_ACTION and activity intent is null.") Logger.w(TAG, "Received STATUS_PENDING_USER_ACTION and activity intent is null.")
onReceiveResult.emit(context.getString(R.string.install_failed_device_installer_broken))
return; return;
} }
context.startActivity(activityIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
activityIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
try {
context.startActivity(activityIntent)
} catch (e: ActivityNotFoundException) {
Logger.e(TAG, "System installer cannot handle CONFIRM_INSTALL intent. ROM is broken; falling back / reporting error.", e)
onReceiveResult.emit(context.getString(R.string.install_failed_device_installer_broken))
}
} }
PackageInstaller.STATUS_SUCCESS -> onReceiveResult.emit(null); PackageInstaller.STATUS_SUCCESS -> onReceiveResult.emit(null);
PackageInstaller.STATUS_FAILURE -> onReceiveResult.emit(context.getString(R.string.general_failure)); PackageInstaller.STATUS_FAILURE -> onReceiveResult.emit(context.getString(R.string.general_failure));
@@ -45,6 +56,7 @@ class InstallReceiver : BroadcastReceiver() {
PackageInstaller.STATUS_FAILURE_STORAGE -> onReceiveResult.emit(context.getString(R.string.not_enough_storage)); PackageInstaller.STATUS_FAILURE_STORAGE -> onReceiveResult.emit(context.getString(R.string.not_enough_storage));
else -> { else -> {
val msg = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE); val msg = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE);
Logger.w(TAG, "Received unknown install status $status, message=$msg")
onReceiveResult.emit(msg) onReceiveResult.emit(msg)
} }
} }
@@ -26,6 +26,7 @@ import android.util.Log
import android.view.KeyEvent import android.view.KeyEvent
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition import com.bumptech.glide.request.transition.Transition
import com.futo.platformplayer.R import com.futo.platformplayer.R
@@ -38,6 +39,7 @@ import com.futo.platformplayer.receivers.MediaControlReceiver
import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.withMaxSizePx
class MediaPlaybackService : Service() { class MediaPlaybackService : Service() {
private val TAG = "MediaPlaybackService"; private val TAG = "MediaPlaybackService";
@@ -172,21 +174,26 @@ class MediaPlaybackService : Service() {
} }
fun closeMediaSession() { fun closeMediaSession() {
Logger.v(TAG, "closeMediaSession"); Logger.v(TAG, "closeMediaSession")
stopForeground(STOP_FOREGROUND_REMOVE); stopForeground(STOP_FOREGROUND_REMOVE)
abandonAudioFocus() abandonAudioFocus()
val notifManager = _notificationManager; val notifManager = _notificationManager
Logger.i(TAG, "Cancelling playback notification (notifManager: ${notifManager != null})"); Logger.i(TAG, "Cancelling playback notification (notifManager: ${notifManager != null})")
notifManager?.cancel(MEDIA_NOTIF_ID); notifManager?.cancel(MEDIA_NOTIF_ID)
_notif_last_video = null;
_notif_last_bitmap = null;
_mediaSession = null;
if(_instance == this) _notif_last_video = null
_instance = null; _notif_last_bitmap = null
this.stopSelf();
_mediaSession?.isActive = false
_mediaSession?.release()
_mediaSession = null
if (_instance == this)
_instance = null
stopSelf()
} }
fun updateMediaSession(videoUpdated: IPlatformVideo?) { fun updateMediaSession(videoUpdated: IPlatformVideo?) {
@@ -206,37 +213,37 @@ class MediaPlaybackService : Service() {
if(_notificationChannel == null || _mediaSession == null) if(_notificationChannel == null || _mediaSession == null)
setupNotificationRequirements(); setupNotificationRequirements();
_mediaSession?.setMetadata( updateMediaMetadata(video, lastBitmap)
MediaMetadataCompat.Builder()
.putString(MediaMetadata.METADATA_KEY_ARTIST, video.author.name)
.putString(MediaMetadata.METADATA_KEY_TITLE, video.name)
.putLong(MediaMetadata.METADATA_KEY_DURATION, video.duration * 1000)
.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, lastBitmap)
.build());
val thumbnail = video.thumbnails.getHQThumbnail(); val thumbnail = video.thumbnails.getHQThumbnail();
_notif_last_video = video; _notif_last_video = video;
if(isUpdating) if(isUpdating)
notifyMediaSession(video, _notif_last_bitmap); notifyMediaSession(video, _notif_last_bitmap?.takeIf { !it.isRecycled });
else if(thumbnail != null) { else if(thumbnail != null) {
notifyMediaSession(video, null); notifyMediaSession(video, null);
val tag = video; val tag = video;
Glide.with(this).asBitmap() Glide.with(this).asBitmap()
.load(thumbnail) .load(thumbnail)
.withMaxSizePx()
.into(object: CustomTarget<Bitmap>() { .into(object: CustomTarget<Bitmap>() {
override fun onResourceReady(resource: Bitmap,transition: Transition<in Bitmap>?) { override fun onResourceReady(resource: Bitmap,transition: Transition<in Bitmap>?) {
if(tag == _notif_last_video) { if (tag != _notif_last_video) return
notifyMediaSession(video, resource) if (resource.isRecycled) {
_mediaSession?.setMetadata( notifyMediaSession(video, null)
MediaMetadataCompat.Builder() return
.putString(MediaMetadata.METADATA_KEY_ARTIST, video.author.name)
.putString(MediaMetadata.METADATA_KEY_TITLE, video.name)
.putLong(MediaMetadata.METADATA_KEY_DURATION, video.duration * 1000)
.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, resource)
.build());
} }
val albumArt = resource.copy(
resource.config ?: Bitmap.Config.ARGB_8888,
false
)
_notif_last_bitmap = albumArt
notifyMediaSession(video, albumArt)
updateMediaMetadata(video, albumArt)
} }
override fun onLoadCleared(placeholder: Drawable?) { override fun onLoadCleared(placeholder: Drawable?) {
if(tag == _notif_last_video) if(tag == _notif_last_video)
@@ -247,6 +254,19 @@ class MediaPlaybackService : Service() {
else else
notifyMediaSession(video, null); notifyMediaSession(video, null);
} }
private fun updateMediaMetadata(video: IPlatformVideo, bitmap: Bitmap?) {
val builder = MediaMetadataCompat.Builder()
.putString(MediaMetadata.METADATA_KEY_ARTIST, video.author.name)
.putString(MediaMetadata.METADATA_KEY_TITLE, video.name)
.putLong(MediaMetadata.METADATA_KEY_DURATION, video.duration * 1000)
val safeBitmap = bitmap?.takeIf { !it.isRecycled }
if (safeBitmap != null) {
builder.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, safeBitmap)
}
_mediaSession?.setMetadata(builder.build())
}
private fun generateMediaAction(icon: Int, title: String, intent: PendingIntent) : NotificationCompat.Action { private fun generateMediaAction(icon: Int, title: String, intent: PendingIntent) : NotificationCompat.Action {
return NotificationCompat.Action.Builder(icon, title, intent).build(); return NotificationCompat.Action.Builder(icon, title, intent).build();
} }
@@ -572,30 +572,39 @@ class StateApp {
DownloadService.getOrCreateService(context); DownloadService.getOrCreateService(context);
} }
Logger.i(TAG, "MainApp Started: Initialize [AutoUpdate]"); if (Settings.instance.autoUpdate.isAutoUpdateEnabled()) {
val autoUpdateEnabled = Settings.instance.autoUpdate.isAutoUpdateEnabled(); if (Settings.instance.autoUpdate.backgroundDownload == 1) {
val shouldDownload = Settings.instance.autoUpdate.shouldDownload(); Logger.i(TAG, "MainApp Started: Initialize [AutoUpdate Background]");
val backgroundDownload = Settings.instance.autoUpdate.backgroundDownload == 1; val constraints = Constraints.Builder()
when { .setRequiredNetworkType(NetworkType.CONNECTED)
//Background download .build();
autoUpdateEnabled && shouldDownload && backgroundDownload -> {
StateUpdate.instance.setShouldBackgroundUpdate(true);
}
autoUpdateEnabled && !shouldDownload && backgroundDownload -> { val periodicRequest = PeriodicWorkRequest.Builder(
Logger.i(TAG, "Auto update skipped due to wrong network state"); UpdateCheckWorker::class.java,
} 12, TimeUnit.HOURS
)
.setConstraints(constraints)
.build();
//Foreground download val wm = WorkManager.getInstance(context);
autoUpdateEnabled -> { wm.enqueueUniquePeriodicWork(
UpdateCheckWorker.UNIQUE_WORK_NAME,
ExistingPeriodicWorkPolicy.UPDATE,
periodicRequest
);
val oneTimeRequest = OneTimeWorkRequest.Builder(UpdateCheckWorker::class.java)
.setConstraints(constraints)
.build();
wm.enqueue(oneTimeRequest);
} else {
Logger.i(TAG, "MainApp Started: Initialize [AutoUpdate]");
scopeOrNull?.launch(Dispatchers.IO) { scopeOrNull?.launch(Dispatchers.IO) {
StateUpdate.instance.checkForUpdates(context, false) StateUpdate.instance.checkForUpdates(context, false)
} }
} }
} else {
else -> { Logger.i(TAG, "AutoUpdate disabled");
Logger.i(TAG, "Auto update disabled");
}
} }
Logger.i(TAG, "MainApp Started: Initialize [Noisy]"); Logger.i(TAG, "MainApp Started: Initialize [Noisy]");
@@ -781,24 +790,20 @@ class StateApp {
Logger.i("StateApp", "No AutoBackup configured"); Logger.i("StateApp", "No AutoBackup configured");
} }
fun scheduleBackgroundWork(context: Context, active: Boolean = true, intervalMinutes: Int = 60 * 12) { fun scheduleBackgroundWork(context: Context, active: Boolean = true, intervalMinutes: Int = 60 * 12) {
try { try {
val wm = WorkManager.getInstance(context); val wm = WorkManager.getInstance(context);
if(active) { if (active) {
if(BuildConfig.DEBUG) if (BuildConfig.DEBUG)
UIDialogs.toast(context, "Scheduling background every ${intervalMinutes} minutes"); UIDialogs.toast(context, "Scheduling background every ${intervalMinutes} minutes");
val req = PeriodicWorkRequest.Builder(BackgroundWorker::class.java, intervalMinutes.toLong(), TimeUnit.MINUTES, 5, TimeUnit.MINUTES) val req = PeriodicWorkRequest.Builder(BackgroundWorker::class.java, intervalMinutes.toLong(), TimeUnit.MINUTES, 5, TimeUnit.MINUTES)
.setConstraints(Constraints.Builder() .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.UNMETERED).build()).build();
.setRequiredNetworkType(NetworkType.UNMETERED)
.build())
.build();
wm.enqueueUniquePeriodicWork("backgroundSubscriptions", ExistingPeriodicWorkPolicy.UPDATE, req); wm.enqueueUniquePeriodicWork("backgroundSubscriptions", ExistingPeriodicWorkPolicy.UPDATE, req);
} else {
wm.cancelUniqueWork("backgroundSubscriptions");
} }
else
wm.cancelAllWork();
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.e(TAG, "Failed to schedule background subscription updates.", e) Logger.e(TAG, "Failed to schedule background subscription updates.", e)
UIDialogs.toast(context, "Background subscription update failed: " + e.message) UIDialogs.toast(context, "Background subscription update failed: " + e.message)
@@ -806,6 +811,7 @@ class StateApp {
} }
private suspend fun migrateStores(context: Context, managedStores: List<ManagedStore<*>>, index: Int) { private suspend fun migrateStores(context: Context, managedStores: List<ManagedStore<*>>, index: Int) {
if(managedStores.size <= index) if(managedStores.size <= index)
return; return;
@@ -903,15 +909,6 @@ class StateApp {
try { try {
if(FragmentedStorage.isInitialized && Settings.instance.downloads.shouldDownload()) if(FragmentedStorage.isInitialized && Settings.instance.downloads.shouldDownload())
StateDownloads.instance.checkForDownloadsTodos(); StateDownloads.instance.checkForDownloadsTodos();
val autoUpdateEnabled = Settings.instance.autoUpdate.isAutoUpdateEnabled();
val shouldDownload = Settings.instance.autoUpdate.shouldDownload();
val backgroundDownload = Settings.instance.autoUpdate.backgroundDownload == 1;
if (autoUpdateEnabled && shouldDownload && backgroundDownload) {
StateUpdate.instance.setShouldBackgroundUpdate(true);
} else {
StateUpdate.instance.setShouldBackgroundUpdate(false);
}
} catch(ex: Throwable) { } catch(ex: Throwable) {
Logger.w(TAG, "Failed to handle capabilities changed event", ex); Logger.w(TAG, "Failed to handle capabilities changed event", ex);
} }
@@ -1,10 +1,12 @@
package com.futo.platformplayer.states package com.futo.platformplayer.states
import android.content.ContentResolver
import android.content.ContentUris import android.content.ContentUris
import android.content.Intent import android.content.Intent
import android.database.Cursor import android.database.Cursor
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle
import android.provider.MediaStore import android.provider.MediaStore
import android.provider.MediaStore.Audio.Artists import android.provider.MediaStore.Audio.Artists
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
@@ -154,34 +156,101 @@ class StateLibrary {
fun getArtist(id: Long): Artist? { fun getArtist(id: Long): Artist? {
return Artist.getArtist(id); return Artist.getArtist(id);
} }
fun getVideos(
buckets: List<String>? = null,
pageSize: Int = 20
): IPager<IPlatformContent> {
val resolver = StateApp.instance.contextOrNull?.contentResolver ?: return EmptyPager()
val selection: String?
val selectionArgs: Array<String>?
fun getVideos(buckets: List<String>? = null): IPager<IPlatformContent> { if (!buckets.isNullOrEmpty()) {
var query = if(buckets != null) "${MediaStore.Video.Media.BUCKET_DISPLAY_NAME} IN " + "(" + buckets.map { "'${it}'" }.joinToString(",") + ")" else null; val placeholders = buckets.joinToString(",") { "?" }
val cursor = StateApp.instance.contextOrNull?.contentResolver?.query(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, PROJECTION_VIDEO, selection = "${MediaStore.Video.Media.BUCKET_DISPLAY_NAME} IN ($placeholders)"
query, selectionArgs = buckets.toTypedArray()
null, } else {
MediaStore.Video.Media.DATE_ADDED + " DESC") ?: return EmptyPager(); selection = null
selectionArgs = null
}
//Ongoing usage of cursor..todo disposal val collectionUri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
//return cursor.use { MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)
cursor.moveToFirst(); } else {
val list = mutableListOf<IPlatformVideo>() MediaStore.Video.Media.EXTERNAL_CONTENT_URI
while(!cursor.isAfterLast && list.size < 10) { }
list.add(videoFromCursor(cursor));
cursor.moveToNext(); var nextPageIndex = 0
fun loadPage(pageIndex: Int): List<IPlatformContent> {
Logger.i(TAG, "loadPage $pageIndex")
val offset = pageIndex * pageSize
val queryArgs = Bundle().apply {
selection?.let {
putString(ContentResolver.QUERY_ARG_SQL_SELECTION, it)
}
selectionArgs?.let {
putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, it)
}
putStringArray(
ContentResolver.QUERY_ARG_SORT_COLUMNS,
arrayOf(
MediaStore.Video.Media.DATE_ADDED,
MediaStore.Video.Media._ID
)
)
putInt(
ContentResolver.QUERY_ARG_SORT_DIRECTION,
ContentResolver.QUERY_SORT_DIRECTION_DESCENDING
)
putInt(ContentResolver.QUERY_ARG_LIMIT, pageSize)
putInt(ContentResolver.QUERY_ARG_OFFSET, offset)
} }
return AdhocPager<IPlatformContent>({ val cursor = resolver.query(
val list = mutableListOf<IPlatformContent>() collectionUri,
while(!cursor.isAfterLast && list.size < 10) { PROJECTION_VIDEO,
list.add(videoFromCursor(cursor)); queryArgs,
cursor.moveToNext(); 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; val list = ArrayList<IPlatformContent>(pageSize)
}, list); do {
//} list.add(videoFromCursor(c))
} while (c.moveToNext() && list.size < pageSize)
Logger.i(TAG, "loadPage $pageIndex found ${list.size} items")
return list
}
}
val firstPage = loadPage(0)
if (firstPage.isEmpty()) {
return EmptyPager()
}
nextPageIndex = 1
return AdhocPager<IPlatformContent>({
val page = loadPage(nextPageIndex)
nextPageIndex++
Logger.i(TAG, "loadPage nextPage: ${page.size}")
page
}, firstPage)
} }
fun getRecentVideos(buckets: List<String>? = null, count: Int = 20): List<IPlatformVideo> { fun getRecentVideos(buckets: List<String>? = null, count: Int = 20): List<IPlatformVideo> {
val videoPager = getVideos(buckets); val videoPager = getVideos(buckets);
val items = mutableListOf<IPlatformVideo>(); val items = mutableListOf<IPlatformVideo>();
@@ -193,48 +262,80 @@ class StateLibrary {
return items; return items;
} }
private var _cacheBucketNames: List<Bucket>? = null; @Volatile
fun getVideoBucketNames(): List<Bucket> { private var _cachedVideoBuckets: List<Bucket>? = null
if(Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) private val _bucketCacheLock = Any()
return listOf();
if(_cacheBucketNames != null)
return _cacheBucketNames ?: listOf();
try {
val cur: Cursor = StateApp.instance.contextOrNull?.contentResolver?.query(
MediaStore.Video.Media.EXTERNAL_CONTENT_URI, arrayOf(
MediaStore.Video.Media.BUCKET_ID,
MediaStore.Video.Media.BUCKET_DISPLAY_NAME,
), null, null, null
) ?: return listOf();
return cur.use { fun getVideoBucketNames(forceRefresh: Boolean = false): List<Bucket> {
val buckets = mutableListOf<Bucket>(); if (!forceRefresh) {
val list = HashSet<Long>(); _cachedVideoBuckets?.let { return it }
if (cur.moveToFirst()) { }
var id: Long;
var bucket: String val resolver = StateApp.instance.contextOrNull?.contentResolver
do { ?: return emptyList()
try {
id = cur.getLong(0); val projection = arrayOf(
bucket = cur.getStringOrNull(1) ?: continue; MediaStore.Video.VideoColumns.BUCKET_ID,
if (!list.contains(id)) { MediaStore.Video.VideoColumns.BUCKET_DISPLAY_NAME
list.add(id); )
buckets.add(Bucket(id, bucket));
} val sortOrder = "${MediaStore.Video.VideoColumns.BUCKET_DISPLAY_NAME} COLLATE NOCASE ASC"
} catch (ex: Throwable) { val loadedBuckets: List<Bucket> = try {
Logger.e(TAG, "Failed to parse bucket due to ${ex.message}", ex); resolver.query(
} MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
} while (cur.moveToNext()) 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"); synchronized(_bucketCacheLock) {
return listOf(); if (!forceRefresh) {
_cachedVideoBuckets?.let { return it }
}
_cachedVideoBuckets = loadedBuckets
return loadedBuckets
} }
} }
fun invalidateVideoBucketNamesCache() {
_cachedVideoBuckets = null
}
companion object { companion object {
@@ -10,6 +10,7 @@ import android.graphics.drawable.Drawable
import android.os.Build import android.os.Build
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition import com.bumptech.glide.request.transition.Transition
import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.activities.MainActivity
@@ -22,6 +23,7 @@ import com.futo.platformplayer.serializers.PlatformContentSerializer
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.toHumanNowDiffString import com.futo.platformplayer.toHumanNowDiffString
import com.futo.platformplayer.toHumanNowDiffStringMinDay import com.futo.platformplayer.toHumanNowDiffStringMinDay
import com.futo.platformplayer.withMaxSizePx
import java.time.OffsetDateTime import java.time.OffsetDateTime
class StateNotifications { class StateNotifications {
@@ -96,6 +98,7 @@ class StateNotifications {
if(thumbnail != null) if(thumbnail != null)
Glide.with(context).asBitmap() Glide.with(context).asBitmap()
.load(thumbnail) .load(thumbnail)
.withMaxSizePx()
.into(object: CustomTarget<Bitmap>() { .into(object: CustomTarget<Bitmap>() {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) { override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
notifyNewContent(context, manager, notificationChannel, id, content, resource); notifyNewContent(context, manager, notificationChannel, id, content, resource);
@@ -247,17 +247,29 @@ class StatePlayer {
} }
private fun createShuffledQueue() { private fun createShuffledQueue() {
val currentItem = getCurrentQueueItem(); if (_queue.isEmpty()) {
if (_queuePosition == -1 || currentItem == null) { _queueShuffled = mutableListOf()
_queueShuffled = _queue.shuffled().toMutableList() return
return;
} }
val nextItems = _queue.subList(Math.min(_queuePosition + 1, _queue.size - 1), _queue.size).shuffled(); val currentItem = getCurrentQueueItem()
val previousItems = _queue.subList(0, _queuePosition).shuffled(); if (currentItem == null || _queuePosition !in _queue.indices) {
_queueShuffled = (previousItems + currentItem + nextItems).toMutableList(); _queueShuffled = _queue.shuffled().toMutableList()
return
}
val previousItems = _queue
.take(_queuePosition)
.shuffled()
val nextItems = _queue
.drop(_queuePosition + 1)
.shuffled()
_queueShuffled = (previousItems + currentItem + nextItems).toMutableList()
} }
private fun addToShuffledQueue(video: IPlatformVideo) { private fun addToShuffledQueue(video: IPlatformVideo) {
val isLastVideo = _queuePosition + 1 >= _queue.size; val isLastVideo = _queuePosition + 1 >= _queue.size;
if (isLastVideo) { if (isLastVideo) {
@@ -15,146 +15,6 @@ import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
class StateUpdate { class StateUpdate {
private var _backgroundUpdateFinished = false;
private var _gettingOrDownloadingLastApk = false;
private var _shouldBackgroundUpdate = false;
private val _lockObject = Object();
private fun getOrDownloadLastApkFile(filesDir: File): File? {
try {
Logger.i(TAG, "Started getting or downloading latest APK file.");
if (!_shouldBackgroundUpdate) {
Logger.i(TAG, "Update download cancelled 1.");
return null;
}
Logger.i(TAG, "Started background update download.");
val client = ManagedHttpClient();
val latestVersion = downloadVersionCode(client);
if (!_shouldBackgroundUpdate) {
Logger.i(TAG, "Update download cancelled 2.");
return null;
}
if (latestVersion != null) {
val currentVersion = BuildConfig.VERSION_CODE;
Logger.i(TAG, "Current version ${currentVersion} latest version ${latestVersion}.");
if (latestVersion <= currentVersion) {
Logger.i(TAG, "Already up to date.");
_backgroundUpdateFinished = true;
return null;
}
val outputDirectory = File(filesDir, "autoupdate");
if (!outputDirectory.exists()) {
outputDirectory.mkdirs();
}
if (!_shouldBackgroundUpdate) {
Logger.i(TAG, "Update download cancelled 3.");
return null;
}
val apkOutputFile = File(outputDirectory, "last_version.apk");
val versionOutputFile = File(outputDirectory, "last_version.txt");
var cachedVersionInvalid = false;
if (!versionOutputFile.exists() || !apkOutputFile.exists()) {
Logger.i(TAG, "No downloaded version exists.");
cachedVersionInvalid = true;
} else {
try {
val downloadedVersion = versionOutputFile.readText().toInt();
Logger.i(TAG, "Downloaded version is $downloadedVersion.");
if (downloadedVersion != latestVersion) {
Logger.i(TAG, "Downloaded version is not newest version.");
cachedVersionInvalid = true;
}
}
catch(ex: Throwable) {
Logger.w(TAG, "Deleted version file as it was inaccessible");
versionOutputFile.delete();
cachedVersionInvalid = true;
}
}
if (!_shouldBackgroundUpdate) {
Logger.i(TAG, "Update download cancelled 4.");
return null;
}
if (cachedVersionInvalid) {
Logger.i(TAG, "Downloading new APK to '${apkOutputFile.path}'...");
downloadApkToFile(client, apkOutputFile) { !_shouldBackgroundUpdate };
versionOutputFile.writeText(latestVersion.toString());
Logger.i(TAG, "Downloaded APK to '${apkOutputFile.path}'.");
} else {
Logger.i(TAG, "Latest APK is already downloaded in '${apkOutputFile.path}'...");
}
if (!_shouldBackgroundUpdate) {
Logger.i(TAG, "Update download cancelled 5.");
return null;
}
return apkOutputFile;
} else {
Logger.w(TAG, "Failed to retrieve version from version URL.");
return null;
}
} catch (e: Throwable) {
Logger.e(TAG, "Failed to download APK.", e);
return null;
} finally {
_gettingOrDownloadingLastApk = false;
}
}
fun setShouldBackgroundUpdate(shouldBackgroundUpdate: Boolean) {
synchronized (_lockObject) {
if (_backgroundUpdateFinished) {
_shouldBackgroundUpdate = false;
return;
}
_shouldBackgroundUpdate = shouldBackgroundUpdate;
if (shouldBackgroundUpdate && !_gettingOrDownloadingLastApk) {
Logger.i(TAG, "Auto Updating in Background");
_gettingOrDownloadingLastApk = true;
StateApp.withContext { context ->
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try {
val file = getOrDownloadLastApkFile(context.filesDir);
if (file == null) {
Logger.i(TAG, "Failed to get or download update.");
return@launch;
}
withContext(Dispatchers.Main) {
try {
context.let { c ->
_backgroundUpdateFinished = true;
UIDialogs.showInstallDownloadedUpdateDialog(c, file);
};
Logger.i(TAG, "Showing install dialog for '${file.path}'.");
} catch (e: Throwable) {
context.let { c -> UIDialogs.toast(c, "Failed to show update dialog"); };
Logger.w(TAG, "Error occurred in update dialog.", e);
}
}
} catch (e: Throwable) {
Logger.e(TAG, "Failed to get last downloaded APK file.", e)
}
}
}
}
}
}
suspend fun checkForUpdates(context: Context, showUpToDateToast: Boolean, hideExceptionButtons: Boolean = false) = withContext(Dispatchers.IO) { suspend fun checkForUpdates(context: Context, showUpToDateToast: Boolean, hideExceptionButtons: Boolean = false) = withContext(Dispatchers.IO) {
try { try {
val client = ManagedHttpClient(); val client = ManagedHttpClient();
@@ -196,25 +56,6 @@ class StateUpdate {
} }
} }
private fun downloadApkToFile(client: ManagedHttpClient, destinationFile: File, isCancelled: (() -> Boolean)? = null) {
var apkStream: InputStream? = null;
var outputStream: OutputStream? = null;
try {
val response = client.get(APK_URL);
if (response.isOk && response.body != null) {
apkStream = response.body.byteStream();
outputStream = destinationFile.outputStream();
apkStream.copyToOutputStream(outputStream, isCancelled);
apkStream.close();
outputStream.close();
}
} finally {
apkStream?.close();
outputStream?.close();
}
}
fun downloadVersionCode(client: ManagedHttpClient): Int? { fun downloadVersionCode(client: ManagedHttpClient): Int? {
val response = client.get(VERSION_URL); val response = client.get(VERSION_URL);
if (!response.isOk || response.body == null) { if (!response.isOk || response.body == null) {
@@ -267,6 +108,22 @@ class StateUpdate {
} }
val CHANGELOG_BASE_URL = "https://releases.grayjay.app/changelogs"; val CHANGELOG_BASE_URL = "https://releases.grayjay.app/changelogs";
fun getApkFile(context: Context, version: Int): File {
val dir = File(context.filesDir, "updates");
if (!dir.exists()) {
dir.mkdirs();
}
return File(dir, "app-${DESIRED_ABI}-${version}.apk");
}
fun getPartialApkFile(context: Context, version: Int): File {
val dir = File(context.filesDir, "updates");
if (!dir.exists()) {
dir.mkdirs();
}
return File(dir, "app-${DESIRED_ABI}-${version}.apk.part");
}
fun finish() { fun finish() {
_instance?.let { _instance?.let {
_instance = null; _instance = null;
@@ -7,10 +7,12 @@ import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout
import androidx.recyclerview.widget.RecyclerView.ViewHolder import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.models.Playlist import com.futo.platformplayer.models.Playlist
import com.futo.platformplayer.withMaxSizePx
class PlaylistsViewHolder : ViewHolder { class PlaylistsViewHolder : ViewHolder {
private val _root: ConstraintLayout; private val _root: ConstraintLayout;
@@ -44,6 +46,7 @@ class PlaylistsViewHolder : ViewHolder {
if (p.videos.isNotEmpty()) { if (p.videos.isNotEmpty()) {
Glide.with(_imageThumbnail) Glide.with(_imageThumbnail)
.load(p.videos[0].thumbnails.getMinimumThumbnail(380)) .load(p.videos[0].thumbnails.getMinimumThumbnail(380))
.withMaxSizePx()
.placeholder(R.drawable.placeholder_video_thumbnail) .placeholder(R.drawable.placeholder_video_thumbnail)
.crossfade() .crossfade()
.into(_imageThumbnail); .into(_imageThumbnail);
@@ -12,6 +12,7 @@ import androidx.constraintlayout.widget.ConstraintLayout
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView.ViewHolder import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
@@ -23,6 +24,7 @@ import com.futo.platformplayer.toHumanNumber
import com.futo.platformplayer.toHumanTime import com.futo.platformplayer.toHumanTime
import com.futo.platformplayer.views.others.ProgressBar import com.futo.platformplayer.views.others.ProgressBar
import com.futo.platformplayer.views.platform.PlatformIndicator import com.futo.platformplayer.views.platform.PlatformIndicator
import com.futo.platformplayer.withMaxSizePx
class VideoListEditorViewHolder : ViewHolder { class VideoListEditorViewHolder : ViewHolder {
private val _root: ConstraintLayout; private val _root: ConstraintLayout;
@@ -89,6 +91,7 @@ class VideoListEditorViewHolder : ViewHolder {
fun bind(v: IPlatformVideo, canEdit: Boolean) { fun bind(v: IPlatformVideo, canEdit: Boolean) {
Glide.with(_imageThumbnail) Glide.with(_imageThumbnail)
.load(v.thumbnails.getHQThumbnail()) .load(v.thumbnails.getHQThumbnail())
.withMaxSizePx()
.placeholder(R.drawable.placeholder_video_thumbnail) .placeholder(R.drawable.placeholder_video_thumbnail)
.crossfade() .crossfade()
.into(_imageThumbnail); .into(_imageThumbnail);
@@ -7,6 +7,7 @@ import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.IPlatformVideo
@@ -16,6 +17,7 @@ import com.futo.platformplayer.states.Artist
import com.futo.platformplayer.toHumanNowDiffString import com.futo.platformplayer.toHumanNowDiffString
import com.futo.platformplayer.toHumanTime import com.futo.platformplayer.toHumanTime
import com.futo.platformplayer.views.adapters.AnyAdapter import com.futo.platformplayer.views.adapters.AnyAdapter
import com.futo.platformplayer.withMaxSizePx
import com.google.android.material.imageview.ShapeableImageView import com.google.android.material.imageview.ShapeableImageView
@@ -49,6 +51,7 @@ class LocalVideoTileViewHolder(val _viewGroup: ViewGroup) : AnyAdapter.AnyViewHo
Glide.with(it) Glide.with(it)
.load(content.thumbnails.getHQThumbnail()) .load(content.thumbnails.getHQThumbnail())
.placeholder(R.drawable.unknown_music) .placeholder(R.drawable.unknown_music)
.withMaxSizePx()
.into(it) .into(it)
else else
Glide.with(it).load(R.drawable.unknown_music).into(it); Glide.with(it).load(R.drawable.unknown_music).into(it);
@@ -2,6 +2,7 @@ package com.futo.platformplayer.views.buttons
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.os.Looper
import android.util.AttributeSet import android.util.AttributeSet
import android.util.TypedValue import android.util.TypedValue
import android.view.View import android.view.View
@@ -98,46 +99,58 @@ open class BigButton : LinearLayout {
return this; return this;
} }
fun withIcon(resourceId: Int, rounded: Boolean = false): BigButton { private fun applyIcon(resourceId: Int, rounded: Boolean) {
if (resourceId != -1) { if (resourceId != -1) {
_icon.visibility = View.VISIBLE; _icon.visibility = View.VISIBLE
_icon.setImageResource(resourceId); _icon.setImageResource(resourceId)
} else
_icon.visibility = View.GONE;
if (rounded) {
val shapeAppearanceModel = ShapeAppearanceModel().toBuilder()
.setAllCornerSizes(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 16.0f, context.resources.displayMetrics))
.build();
_icon.scaleType = ImageView.ScaleType.FIT_CENTER;
_icon.shapeAppearanceModel = shapeAppearanceModel;
} else { } else {
_icon.scaleType = ImageView.ScaleType.CENTER_CROP; _icon.visibility = View.GONE
_icon.shapeAppearanceModel = ShapeAppearanceModel();
} }
applyRounded(rounded)
return this;
} }
fun withIcon(resourceId: Int, rounded: Boolean = false): BigButton {
if (Looper.myLooper() == Looper.getMainLooper()) {
applyIcon(resourceId, rounded)
} else {
post { applyIcon(resourceId, rounded) }
}
return this
}
fun withIcon(bitmap: Bitmap, rounded: Boolean = false): BigButton { fun withIcon(bitmap: Bitmap, rounded: Boolean = false): BigButton {
_icon.visibility = View.VISIBLE; if (Looper.myLooper() == Looper.getMainLooper()) {
_icon.setImageBitmap(bitmap); applyIcon(bitmap, rounded)
if (rounded) {
val shapeAppearanceModel = ShapeAppearanceModel().toBuilder()
.setAllCornerSizes(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 16.0f, context.resources.displayMetrics))
.build();
_icon.scaleType = ImageView.ScaleType.FIT_CENTER;
_icon.shapeAppearanceModel = shapeAppearanceModel;
} else { } else {
_icon.scaleType = ImageView.ScaleType.CENTER_CROP; post { applyIcon(bitmap, rounded) }
_icon.shapeAppearanceModel = ShapeAppearanceModel();
} }
return this
}
return this; private fun applyRounded(rounded: Boolean) {
if (rounded) {
val radiusPx = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
16.0f,
context.resources.displayMetrics
)
val shapeAppearanceModel = ShapeAppearanceModel()
.toBuilder()
.setAllCornerSizes(radiusPx)
.build()
_icon.scaleType = ImageView.ScaleType.FIT_CENTER
_icon.shapeAppearanceModel = shapeAppearanceModel
} else {
_icon.scaleType = ImageView.ScaleType.CENTER_CROP
_icon.shapeAppearanceModel = ShapeAppearanceModel()
}
}
private fun applyIcon(bitmap: Bitmap, rounded: Boolean) {
_icon.visibility = View.VISIBLE
_icon.setImageBitmap(bitmap)
applyRounded(rounded)
} }
fun withBackground(resourceId: Int): BigButton { fun withBackground(resourceId: Int): BigButton {
@@ -18,6 +18,7 @@ import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.DefaultTimeBar import androidx.media3.ui.DefaultTimeBar
import androidx.media3.ui.TimeBar import androidx.media3.ui.TimeBar
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.api.media.models.chapters.IChapter import com.futo.platformplayer.api.media.models.chapters.IChapter
@@ -32,6 +33,7 @@ import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.views.TargetTapLoaderView import com.futo.platformplayer.views.TargetTapLoaderView
import com.futo.platformplayer.views.behavior.GestureControlView import com.futo.platformplayer.views.behavior.GestureControlView
import com.futo.platformplayer.withMaxSizePx
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -306,6 +308,7 @@ class CastView : ConstraintLayout {
Glide.with(_thumbnail) Glide.with(_thumbnail)
.load(video.thumbnails.getHQThumbnail()) .load(video.thumbnails.getHQThumbnail())
.placeholder(R.drawable.placeholder_video_thumbnail) .placeholder(R.drawable.placeholder_video_thumbnail)
.withMaxSizePx()
.into(_thumbnail); .into(_thumbnail);
_textPosition.text = (position * 1000).formatDuration(); _textPosition.text = (position * 1000).formatDuration();
_textDuration.text = (video.duration * 1000).formatDuration(); _textDuration.text = (video.duration * 1000).formatDuration();
@@ -6,6 +6,7 @@ import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.futo.platformplayer.* import com.futo.platformplayer.*
import com.futo.platformplayer.downloads.VideoDownload import com.futo.platformplayer.downloads.VideoDownload
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
@@ -61,6 +62,7 @@ class ActiveDownloadItem: LinearLayout {
Glide.with(_videoImage) Glide.with(_videoImage)
.load(download.thumbnail) .load(download.thumbnail)
.withMaxSizePx()
.crossfade() .crossfade()
.into(_videoImage); .into(_videoImage);
@@ -5,9 +5,11 @@ import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.models.PlaylistDownloaded import com.futo.platformplayer.models.PlaylistDownloaded
import com.futo.platformplayer.withMaxSizePx
class PlaylistDownloadItem(context: Context, playlistName: String, playlistThumbnail: String?, val obj: Any): LinearLayout(context) { class PlaylistDownloadItem(context: Context, playlistName: String, playlistThumbnail: String?, val obj: Any): LinearLayout(context) {
init { inflate(context, R.layout.list_downloaded_playlist, this) } init { inflate(context, R.layout.list_downloaded_playlist, this) }
@@ -19,6 +21,7 @@ class PlaylistDownloadItem(context: Context, playlistName: String, playlistThumb
imageText.text = playlistName; imageText.text = playlistName;
Glide.with(imageView) Glide.with(imageView)
.load(playlistThumbnail) .load(playlistThumbnail)
.withMaxSizePx()
.crossfade() .crossfade()
.into(imageView); .into(imageView);
} }
@@ -15,6 +15,7 @@ import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.PlayerControlView import androidx.media3.ui.PlayerControlView
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition import com.bumptech.glide.request.transition.Transition
import com.futo.platformplayer.R import com.futo.platformplayer.R
@@ -25,6 +26,7 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.helpers.VideoHelper import com.futo.platformplayer.helpers.VideoHelper
import com.futo.platformplayer.toHumanTime import com.futo.platformplayer.toHumanTime
import com.futo.platformplayer.video.PlayerManager import com.futo.platformplayer.video.PlayerManager
import com.futo.platformplayer.withMaxSizePx
class FutoThumbnailPlayer : FutoVideoPlayerBase { class FutoThumbnailPlayer : FutoVideoPlayerBase {
@@ -135,7 +137,7 @@ class FutoThumbnailPlayer : FutoVideoPlayerBase {
if (videoSource == null && audioSource != null) { if (videoSource == null && audioSource != null) {
val thumbnail = video.thumbnails.getHQThumbnail(); val thumbnail = video.thumbnails.getHQThumbnail();
if (!thumbnail.isNullOrBlank()) { if (!thumbnail.isNullOrBlank()) {
Glide.with(videoView).asBitmap().load(thumbnail).into(_loadArtwork); Glide.with(videoView).asBitmap().load(thumbnail).withMaxSizePx().into(_loadArtwork);
} else { } else {
Glide.with(videoView).clear(_loadArtwork); Glide.with(videoView).clear(_loadArtwork);
setArtwork(null); setArtwork(null);
@@ -54,6 +54,7 @@ import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.views.TargetTapLoaderView import com.futo.platformplayer.views.TargetTapLoaderView
import com.futo.platformplayer.views.behavior.GestureControlView import com.futo.platformplayer.views.behavior.GestureControlView
import com.futo.platformplayer.views.others.ProgressBar import com.futo.platformplayer.views.others.ProgressBar
import com.futo.platformplayer.withMaxSizePx
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
@@ -928,11 +929,9 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
override fun switchToAudioMode(video: IPlatformVideoDetails?) { override fun switchToAudioMode(video: IPlatformVideoDetails?) {
super.switchToAudioMode(video) super.switchToAudioMode(video)
//This causes issues, and is in general confusing, needs improvements
/*
val thumbnail = video?.thumbnails?.getHQThumbnail() val thumbnail = video?.thumbnails?.getHQThumbnail()
if (!thumbnail.isNullOrBlank()) { if (!thumbnail.isNullOrBlank()) {
Glide.with(context).asBitmap().load(thumbnail) Glide.with(context).asBitmap().load(thumbnail).withMaxSizePx()
.into(object : CustomTarget<Bitmap>() { .into(object : CustomTarget<Bitmap>() {
override fun onResourceReady( override fun onResourceReady(
resource: Bitmap, resource: Bitmap,
@@ -946,6 +945,5 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
} }
}) })
} }
*/
} }
} }
@@ -11,11 +11,13 @@ import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.toHumanNowDiffString import com.futo.platformplayer.toHumanNowDiffString
import com.futo.platformplayer.toHumanNumber import com.futo.platformplayer.toHumanNumber
import com.futo.platformplayer.withMaxSizePx
class UpNextView : LinearLayout { class UpNextView : LinearLayout {
private val _layoutContainer: LinearLayout; private val _layoutContainer: LinearLayout;
@@ -160,6 +162,7 @@ class UpNextView : LinearLayout {
_textChannelName.text = nextItem.author.name; _textChannelName.text = nextItem.author.name;
Glide.with(_imageThumbnail) Glide.with(_imageThumbnail)
.load(nextItem.thumbnails.getHQThumbnail()) .load(nextItem.thumbnails.getHQThumbnail())
.withMaxSizePx()
.placeholder(R.drawable.placeholder_video_thumbnail) .placeholder(R.drawable.placeholder_video_thumbnail)
.into(_imageThumbnail); .into(_imageThumbnail);
Glide.with(_imageChannelThumbnail) Glide.with(_imageChannelThumbnail)
+1 -1
View File
@@ -106,7 +106,7 @@
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@string/update" android:text="@string/download"
android:textSize="14dp" android:textSize="14dp"
android:textColor="@color/white" android:textColor="@color/white"
android:fontFamily="@font/inter_regular" android:fontFamily="@font/inter_regular"
+1
View File
@@ -25,6 +25,7 @@
<string name="subscriptions">Subscriptions</string> <string name="subscriptions">Subscriptions</string>
<string name="loading">Loading</string> <string name="loading">Loading</string>
<string name="retry">Retry</string> <string name="retry">Retry</string>
<string name="install_failed_device_installer_broken">Failed to start system installer. Your devices ROM is not compatible with automatic updates.</string>
<string name="cancel">Cancel</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="failed_to_retrieve_data_are_you_connected">Failed to retrieve data, are you connected?</string>
<string name="settings">Settings</string> <string name="settings">Settings</string>