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:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<service
android:name=".UpdateDownloadService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<receiver
android:name=".UpdateActionReceiver"
android:exported="false" />
</application>
</manifest>
@@ -403,13 +403,6 @@ class UIDialogs {
dialog.setMaxVersion(lastVersion);
}
fun showInstallDownloadedUpdateDialog(context: Context, apkFile: File) {
val dialog = AutoUpdateDialog(context);
registerDialogOpened(dialog);
dialog.setOnDismissListener { registerDialogClosed(dialog) };
dialog.showPredownloaded(apkFile);
}
fun showMigrateDialog(context: Context, store: ManagedStore<*>, onConcluded: ()->Unit) {
if(!store.hasMissingReconstructions())
onConcluded();
@@ -0,0 +1,90 @@
package com.futo.platformplayer
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.states.StateApp
import java.io.File
class UpdateActionReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
UpdateNotificationManager.ACTION_UPDATE_YES -> handleUpdateYes(context, intent)
UpdateNotificationManager.ACTION_UPDATE_NO -> handleUpdateNo(context)
UpdateNotificationManager.ACTION_UPDATE_NEVER -> handleUpdateNever(context)
UpdateNotificationManager.ACTION_DOWNLOAD_CANCEL -> handleDownloadCancel(context, intent)
UpdateNotificationManager.ACTION_INSTALL_NOW -> handleInstallNow(context, intent)
}
}
private fun handleUpdateYes(context: Context, intent: Intent) {
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
if (version == 0) {
return
}
NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_AVAILABLE)
if (Settings.instance.autoUpdate.backgroundDownload == 1) {
val serviceIntent = Intent(context, UpdateDownloadService::class.java).apply {
putExtra(UpdateDownloadService.EXTRA_VERSION, version)
}
ContextCompat.startForegroundService(context, serviceIntent)
} else {
if (StateApp.instance.isMainActive) {
StateApp.withContext { ctx ->
UIDialogs.showUpdateAvailableDialog(ctx, version, false)
}
} else {
val startIntent = Intent(context, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
putExtra("SHOW_UPDATE_DIALOG_VERSION", version)
}
context.startActivity(startIntent)
}
}
}
private fun handleUpdateNo(context: Context) {
NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_AVAILABLE)
}
private fun handleUpdateNever(context: Context) {
Settings.instance.autoUpdate.check = 1
Settings.instance.save()
UpdateNotificationManager.cancelAll(context)
}
private fun handleDownloadCancel(context: Context, intent: Intent) {
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
val cancelIntent = Intent(context, UpdateDownloadService::class.java).apply {
putExtra(UpdateDownloadService.EXTRA_CANCEL, true)
putExtra(UpdateDownloadService.EXTRA_VERSION, version)
}
ContextCompat.startForegroundService(context, cancelIntent)
NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_DOWNLOADING)
}
private fun handleInstallNow(context: Context, intent: Intent) {
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
val apkPath = intent.getStringExtra(UpdateNotificationManager.EXTRA_APK_PATH)
if (version == 0 || apkPath.isNullOrEmpty()) {
return
}
val apkFile = File(apkPath)
if (!apkFile.exists()) {
return
}
UpdateNotificationManager.cancelAll(context)
UpdateInstaller.startInstall(context, apkFile)
}
}
@@ -0,0 +1,64 @@
package com.futo.platformplayer
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateUpdate
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class UpdateCheckWorker(appContext: Context, workerParams: WorkerParameters) : CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result {
if (!Settings.instance.autoUpdate.isAutoUpdateEnabled()) {
Logger.i(TAG, "Auto-update disabled, skipping worker run")
return Result.success()
}
return withContext(Dispatchers.IO) {
try {
val client = ManagedHttpClient()
val latestVersion = StateUpdate.Companion.instance.downloadVersionCode(client)
if (latestVersion == null) {
Logger.w(TAG, "Failed to fetch latest version in worker")
return@withContext Result.retry()
}
val currentVersion = BuildConfig.VERSION_CODE
Logger.i(TAG, "Worker check: current=$currentVersion, latest=$latestVersion")
if (latestVersion <= currentVersion) {
return@withContext Result.success()
}
UpdateNotificationManager.showUpdateAvailableNotification(applicationContext, latestVersion)
if (StateApp.instance.isMainActive) {
withContext(Dispatchers.Main) {
StateApp.withContext { ctx ->
try {
UIDialogs.showUpdateAvailableDialog(ctx, latestVersion, false)
} catch (t: Throwable) {
Logger.w(TAG, "Failed to show in-app update dialog from worker", t)
}
}
}
}
Result.success()
} catch (t: Throwable) {
Logger.w(TAG, "Exception in UpdateCheckWorker", t)
Result.retry()
}
}
}
companion object {
private const val TAG = "UpdateCheckWorker"
const val UNIQUE_WORK_NAME = "updateCheck"
}
}
@@ -0,0 +1,230 @@
package com.futo.platformplayer
import android.app.Service
import android.content.Intent
import android.os.IBinder
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateUpdate
import kotlinx.coroutines.*
import java.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection
import java.net.URL
class UpdateDownloadService : Service() {
companion object {
private const val TAG = "UpdateDownloadService"
const val EXTRA_VERSION = "version"
const val EXTRA_CANCEL = "cancel"
private const val MAX_RETRIES = 5
private const val INITIAL_BACKOFF_MS = 5_000L
private const val BUFFER_SIZE = 8 * 1024
}
private val job = SupervisorJob()
private val scope = CoroutineScope(Dispatchers.IO + job)
@Volatile
private var isDownloading: Boolean = false
@Volatile
private var cancelRequested: Boolean = false
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent == null) {
stopSelf()
return START_NOT_STICKY
}
if (intent.getBooleanExtra(EXTRA_CANCEL, false)) {
cancelRequested = true
Logger.i(TAG, "Download cancel requested")
stopForeground(Service.STOP_FOREGROUND_REMOVE)
stopSelf()
return START_NOT_STICKY
}
val version = intent.getIntExtra(EXTRA_VERSION, 0)
if (version == 0) {
stopSelf()
return START_NOT_STICKY
}
if (isDownloading) {
Logger.i(TAG, "Download already in progress, ignoring new start")
return START_STICKY
}
isDownloading = true
cancelRequested = false
val notification = UpdateNotificationManager.buildDownloadProgressNotification(this, version, 0, true)
startForeground(UpdateNotificationManager.NOTIF_ID_DOWNLOADING, notification)
scope.launch {
downloadApk(version)
}
return START_STICKY
}
override fun onDestroy() {
super.onDestroy()
job.cancel()
}
private suspend fun downloadApk(version: Int) {
val apkFile = StateUpdate.getApkFile(this, version)
val partialFile = StateUpdate.getPartialApkFile(this, version)
try {
if (apkFile.exists() && apkFile.length() > 0L) {
Logger.i(TAG, "APK already downloaded at ${apkFile.absolutePath}")
onDownloadComplete(version, apkFile)
return
}
var backoffMs = INITIAL_BACKOFF_MS
for (attempt in 0 until MAX_RETRIES) {
if (cancelRequested) {
Logger.i(TAG, "Download cancelled before attempt ${attempt + 1}")
break
}
try {
performDownload(StateUpdate.APK_URL, partialFile, version)
if (!cancelRequested) {
if (apkFile.exists()) {
apkFile.delete()
}
if (!partialFile.renameTo(apkFile)) {
throw IllegalStateException("Failed to rename partial APK file")
}
onDownloadComplete(version, apkFile)
}
break
} catch (t: Throwable) {
if (cancelRequested) {
Logger.i(TAG, "Download cancelled by user", t)
break
}
if (attempt == MAX_RETRIES - 1) {
Logger.e(TAG, "Download failed after ${attempt + 1} attempts", t)
UpdateNotificationManager.showDownloadFailedNotification(this, version, t)
break
} else {
Logger.w(TAG, "Download attempt ${attempt + 1} failed, retrying in ${backoffMs / 1000}s", t)
delay(backoffMs)
backoffMs *= 2
}
}
}
} finally {
isDownloading = false
cancelRequested = false
stopForeground(Service.STOP_FOREGROUND_REMOVE)
stopSelf()
}
}
private fun performDownload(url: String, partialFile: File, version: Int) {
var startOffset = if (partialFile.exists()) partialFile.length() else 0L
Logger.i(TAG, "Starting download. url=$url, existingBytes=$startOffset")
var connection: HttpURLConnection? = null
try {
connection = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = 15_000
readTimeout = 30_000
if (startOffset > 0L) {
setRequestProperty("Range", "bytes=$startOffset-")
}
}
connection.connect()
val responseCode = connection.responseCode
if (responseCode == HttpURLConnection.HTTP_OK && startOffset > 0L) {
Logger.w(TAG, "Server ignored Range header, restarting download from scratch")
partialFile.delete()
startOffset = 0L
} else if (responseCode != HttpURLConnection.HTTP_OK &&
responseCode != HttpURLConnection.HTTP_PARTIAL) {
throw IllegalStateException("Unexpected HTTP response code $responseCode")
}
val contentLength = connection.contentLengthLong
val totalBytes = if (contentLength > 0L) startOffset + contentLength else -1L
val buffer = ByteArray(BUFFER_SIZE)
var downloaded = 0L
var lastProgress = -1
connection.inputStream.use { input ->
FileOutputStream(partialFile, startOffset > 0L).use { output ->
while (!cancelRequested) {
val read = input.read(buffer)
if (read == -1) {
break
}
output.write(buffer, 0, read)
downloaded += read
if (totalBytes > 0L) {
val progress = (((startOffset + downloaded) * 100L) / totalBytes).toInt()
if (progress != lastProgress) {
lastProgress = progress
val safeProgress = when {
progress < 0 -> 0
progress > 100 -> 100
else -> progress
}
UpdateNotificationManager.updateDownloadProgress(this, version, safeProgress, false)
}
} else {
UpdateNotificationManager.updateDownloadProgress(this, version, 0, true)
}
}
output.flush()
}
}
if (cancelRequested) {
throw CancellationException("Download cancelled")
}
if (totalBytes > 0L && startOffset + downloaded < totalBytes) {
throw IllegalStateException("Download incomplete: expected=$totalBytes, got=${startOffset + downloaded}")
}
} finally {
connection?.disconnect()
}
}
private fun onDownloadComplete(version: Int, apkFile: File) {
Logger.i(TAG, "Download complete for version=$version, file=${apkFile.absolutePath}")
UpdateNotificationManager.showDownloadCompleteNotification(this, version, apkFile)
if (StateApp.instance.isMainActive) {
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
StateApp.withContext { ctx ->
try {
UIDialogs.showConfirmationDialog(ctx, "Update downloaded, press confirm to install", {
UpdateNotificationManager.cancelAll(ctx)
UpdateInstaller.startInstall(ctx, apkFile)
}, {})
} catch (t: Throwable) {
Logger.w(TAG, "Failed to show in-app update downloaded dialog", t)
}
}
}
}
}
}
@@ -0,0 +1,89 @@
package com.futo.platformplayer
import android.annotation.SuppressLint
import android.app.PendingIntent.FLAG_MUTABLE
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.app.PendingIntent.getBroadcast
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.provider.Settings
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.receivers.InstallReceiver
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.io.InputStream
import androidx.core.net.toUri
object UpdateInstaller {
private const val TAG = "UpdateInstaller"
@SuppressLint("RequestInstallPackagesPolicy")
fun startInstall(context: Context, apkFile: File) {
if (!apkFile.exists()) {
Logger.w(TAG, "APK file does not exist: ${apkFile.absolutePath}")
UIDialogs.toast(context, "Update file missing")
return
}
if (BuildConfig.IS_PLAYSTORE_BUILD) {
UIDialogs.toast(context, "Updates are managed by the Play Store")
return
}
try {
val pm = context.packageManager
if (!pm.canRequestPackageInstalls()) {
UIDialogs.toast(context, "Allow this app to install updates, then try again")
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
data = "package:${context.packageName}".toUri()
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
return
}
} catch (t: Throwable) {
Logger.e(TAG, "Failed to check unknown sources permission", t)
}
GlobalScope.launch(Dispatchers.IO) {
var inputStream: InputStream? = null
var session: PackageInstaller.Session? = null
try {
val packageInstaller: PackageInstaller = context.packageManager.packageInstaller
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
val sessionId = packageInstaller.createSession(params)
session = packageInstaller.openSession(sessionId)
inputStream = apkFile.inputStream()
val dataLength = apkFile.length()
session.openWrite("package", 0, dataLength).use { sessionStream ->
inputStream.copyToOutputStream(dataLength, sessionStream) { _ -> }
session.fsync(sessionStream)
}
val intent = Intent(context, InstallReceiver::class.java)
val pendingIntent = getBroadcast(context, 0, intent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
val statusReceiver = pendingIntent.intentSender
Logger.i(TAG, "Committing install session for ${apkFile.absolutePath}")
session.commit(statusReceiver)
} catch (e: Throwable) {
Logger.w(TAG, "Exception while installing update", e)
session?.abandon()
withContext(Dispatchers.Main) {
UIDialogs.toast(context, "Failed to install update: ${e.message}")
}
} finally {
session?.close()
inputStream?.close()
}
}
}
}
@@ -0,0 +1,171 @@
package com.futo.platformplayer
import android.Manifest
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent.FLAG_MUTABLE
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.app.PendingIntent.getBroadcast
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import java.io.File
object UpdateNotificationManager {
private const val CHANNEL_ID = "app_updates"
private const val CHANNEL_NAME = "App updates"
private const val CHANNEL_DESCRIPTION = "Notifications about new app versions"
const val ACTION_UPDATE_YES = "com.futo.platformplayer.UPDATE_YES"
const val ACTION_UPDATE_NO = "com.futo.platformplayer.UPDATE_NO"
const val ACTION_UPDATE_NEVER = "com.futo.platformplayer.UPDATE_NEVER"
const val ACTION_DOWNLOAD_CANCEL = "com.futo.platformplayer.UPDATE_CANCEL"
const val ACTION_INSTALL_NOW = "com.futo.platformplayer.UPDATE_INSTALL"
const val EXTRA_VERSION = "version"
const val EXTRA_APK_PATH = "apk_path"
const val NOTIF_ID_AVAILABLE = 2001
const val NOTIF_ID_DOWNLOADING = 2002
const val NOTIF_ID_READY = 2003
fun ensureChannel(context: Context) {
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (manager.getNotificationChannel(CHANNEL_ID) == null) {
val channel = NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT)
channel.description = CHANNEL_DESCRIPTION
manager.createNotificationChannel(channel)
}
}
fun showUpdateAvailableNotification(context: Context, version: Int) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
return
}
ensureChannel(context)
val yesIntent = Intent(context, UpdateActionReceiver::class.java).apply {
action = ACTION_UPDATE_YES
putExtra(EXTRA_VERSION, version)
}
val yesPendingIntent = getBroadcast(context, 0, yesIntent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
val noIntent = Intent(context, UpdateActionReceiver::class.java).apply {
action = ACTION_UPDATE_NO
putExtra(EXTRA_VERSION, version)
}
val noPendingIntent = getBroadcast(context, 1, noIntent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
val neverIntent = Intent(context, UpdateActionReceiver::class.java).apply {
action = ACTION_UPDATE_NEVER
putExtra(EXTRA_VERSION, version)
}
val neverPendingIntent = getBroadcast(context, 2, neverIntent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.foreground)
.setContentTitle("Update available")
.setContentText("A new version ($version) is available.")
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true)
.addAction(0, "Download", yesPendingIntent)
.addAction(0, "Not now", noPendingIntent)
.addAction(0, "Never", neverPendingIntent)
NotificationManagerCompat.from(context).notify(NOTIF_ID_AVAILABLE, builder.build())
}
fun buildDownloadProgressNotification(context: Context, version: Int, progress: Int, indeterminate: Boolean): Notification {
ensureChannel(context)
val cancelIntent = Intent(context, UpdateActionReceiver::class.java).apply {
action = ACTION_DOWNLOAD_CANCEL
putExtra(EXTRA_VERSION, version)
}
val cancelPendingIntent = getBroadcast(
context,
3,
cancelIntent,
FLAG_MUTABLE or FLAG_UPDATE_CURRENT
)
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.foreground)
.setContentTitle("Downloading update")
.setContentText("Downloading version $version")
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOngoing(true)
.addAction(0, "Cancel", cancelPendingIntent)
if (indeterminate) {
builder.setProgress(0, 0, true)
} else {
builder.setProgress(100, progress, false)
}
return builder.build()
}
fun updateDownloadProgress(context: Context, version: Int, progress: Int, indeterminate: Boolean) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
return
}
val notification = buildDownloadProgressNotification(context, version, progress, indeterminate)
NotificationManagerCompat.from(context).notify(NOTIF_ID_DOWNLOADING, notification)
}
fun showDownloadCompleteNotification(context: Context, version: Int, apkFile: File) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
return
}
ensureChannel(context)
val installIntent = Intent(context, UpdateActionReceiver::class.java).apply {
action = ACTION_INSTALL_NOW
putExtra(EXTRA_VERSION, version)
putExtra(EXTRA_APK_PATH, apkFile.absolutePath)
}
val installPendingIntent = getBroadcast(
context,
4,
installIntent,
FLAG_MUTABLE or FLAG_UPDATE_CURRENT
)
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.foreground)
.setContentTitle("Update downloaded")
.setContentText("Tap to install version $version.")
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true)
.addAction(0, "Install", installPendingIntent)
NotificationManagerCompat.from(context).notify(NOTIF_ID_READY, builder.build())
}
fun showDownloadFailedNotification(context: Context, version: Int, error: Throwable?) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
return
}
ensureChannel(context)
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.foreground)
.setContentTitle("Failed to download update")
.setContentText(error?.message ?: "Unknown error")
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true)
NotificationManagerCompat.from(context).notify(NOTIF_ID_READY, builder.build())
}
fun cancelAll(context: Context) {
NotificationManagerCompat.from(context).cancel(NOTIF_ID_AVAILABLE)
NotificationManagerCompat.from(context).cancel(NOTIF_ID_DOWNLOADING)
NotificationManagerCompat.from(context).cancel(NOTIF_ID_READY)
}
}
@@ -5,8 +5,6 @@ import android.content.Context
import android.content.Intent
import android.content.res.Resources
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.icu.util.Output
import android.os.Build
import android.os.Looper
import android.os.OperationCanceledException
@@ -44,6 +42,9 @@ import java.util.*
import java.util.concurrent.ThreadLocalRandom
import java.util.zip.GZIPInputStream
import java.util.zip.GZIPOutputStream
import androidx.core.graphics.scale
import com.bumptech.glide.RequestBuilder
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
private val _allowedCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz ";
fun getRandomString(sizeOfRandomString: Int): String {
@@ -114,23 +115,6 @@ fun DocumentFile.writeBytes(context: Context, byteArray: ByteArray) = context.co
it.flush();
};
fun loadBitmap(url: String): Bitmap {
try {
val client = ManagedHttpClient();
val response = client.get(url);
if (response.isOk && response.body != null) {
val bitmapStream = response.body.byteStream();
val bitmap = BitmapFactory.decodeStream(bitmapStream);
return bitmap;
} else {
throw Exception("Failed to find data at URL.");
}
} catch (e: Throwable) {
Logger.w("Utility", "Exception thrown while downloading bitmap.", e);
throw e;
}
}
fun TextView.setPlatformPlayerLinkMovementMethod(context: Context) {
this.movementMethod = PlatformLinkMovementMethod(context);
}
@@ -458,4 +442,17 @@ fun addressScore(addr: InetAddress): Int {
}
}
fun <T> Enumeration<T>.toList(): List<T> = Collections.list(this)
fun <T> Enumeration<T>.toList(): List<T> = Collections.list(this)
fun <T> RequestBuilder<T>.withMaxSizePx(maxSizePx: Int = 1920, useCenterCrop: Boolean = false): RequestBuilder<T> {
var builder = this
.downsample(DownsampleStrategy.AT_MOST)
.override(maxSizePx, maxSizePx)
builder = if (useCenterCrop) {
builder.centerCrop()
} else {
builder.fitCenter()
}
return builder
}
@@ -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)
@@ -631,6 +618,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
sharedPreferences.edit().putBoolean("IsFirstBoot", false).apply()
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && Settings.instance.autoUpdate.isAutoUpdateEnabled()) {
requestNotificationPermissions("Grayjay uses notifications to inform you when a new app update is available.");
}
val submissionStatus = FragmentedStorage.get<StringStorage>("subscriptionSubmissionStatus")
val numSubscriptions = StateSubscriptions.instance.getSubscriptionCount()
@@ -16,9 +16,11 @@ import android.widget.Button
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.content.ContextCompat
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UpdateDownloadService
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.copyToOutputStream
import com.futo.platformplayer.logging.Logger
@@ -46,7 +48,6 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
private var _maxVersion: Int = 0;
private var _updating: Boolean = false;
private var _apkFile: File? = null;
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
@@ -80,14 +81,19 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
return@setOnClickListener;
}
_updating = true;
update();
if (Settings.instance.autoUpdate.backgroundDownload == 1) {
val ctx = context.applicationContext;
val intent = Intent(ctx, UpdateDownloadService::class.java);
intent.putExtra(UpdateDownloadService.EXTRA_VERSION, _maxVersion);
ContextCompat.startForegroundService(ctx, intent);
UIDialogs.toast(context, "Downloading update in background");
dismiss();
} else {
_updating = true;
update();
}
};
}
fun showPredownloaded(apkFile: File) {
_apkFile = apkFile;
super.show()
}
override fun dismiss() {
@@ -118,21 +124,14 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
GlobalScope.launch(Dispatchers.IO) {
var inputStream: InputStream? = null;
try {
val apkFile = _apkFile;
if (apkFile != null) {
inputStream = apkFile.inputStream();
val dataLength = apkFile.length();
val client = ManagedHttpClient();
val response = client.get(StateUpdate.APK_URL);
if (response.isOk && response.body != null) {
inputStream = response.body.byteStream();
val dataLength = response.body.contentLength();
install(inputStream, dataLength);
} else {
val client = ManagedHttpClient();
val response = client.get(StateUpdate.APK_URL);
if (response.isOk && response.body != null) {
inputStream = response.body.byteStream();
val dataLength = response.body.contentLength();
install(inputStream, dataLength);
} else {
throw Exception("Failed to download latest version of app.");
}
throw Exception("Failed to download latest version of app.");
}
} catch (e: Throwable) {
Logger.w(TAG, "Exception thrown while downloading and installing latest version of app.", e);
@@ -55,7 +55,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
protected val _toolbarContentView: LinearLayout;
protected val _bottomContentView: LinearLayout;
private var _loading: Boolean = true;
private var _loading: Boolean = false;
private val _pagerLock = Object();
private var _cache: ItemCache<TResult>? = null;
@@ -180,10 +180,9 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
val visibleItemCount = _recyclerResults.childCount;
val firstVisibleItem = recyclerData.layoutManager.findFirstVisibleItemPosition()
//Logger.i(TAG, "onScrolled loadNextPage visibleItemCount=$visibleItemCount firstVisibleItem=$visibleItemCount")
//Logger.i(TAG, "onScrolled loadNextPage(): firstVisibleItem=$firstVisibleItem visibleItemCount=$visibleItemCount visibleThreshold=$visibleThreshold recyclerData.results.size=${recyclerData.results.size}")
if (!_loading && firstVisibleItem + visibleItemCount + visibleThreshold >= recyclerData.results.size && firstVisibleItem > 0) {
//Logger.i(TAG, "onScrolled loadNextPage(): firstVisibleItem=$firstVisibleItem visibleItemCount=$visibleItemCount visibleThreshold=$visibleThreshold recyclerData.results.size=${recyclerData.results.size}")
if (!_loading && firstVisibleItem + visibleItemCount + visibleThreshold >= recyclerData.results.size) {
loadNextPage();
}
}
@@ -197,57 +196,44 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
}
private fun ensureEnoughContentVisible(filteredResults: List<TConverted>) {
val canScroll = if (recyclerData.results.isEmpty()) false else {
val height = resources.displayMetrics.heightPixels;
_recyclerResults.post {
val canScroll = _recyclerResults.canScrollVertically(1)
Logger.i(
TAG,
"ensureEnoughContentVisible loadNextPage canScroll=$canScroll _automaticNextPageCounter=$_automaticNextPageCounter"
)
if (!canScroll || filteredResults.isEmpty()) {
_automaticNextPageCounter++
if (_automaticNextPageCounter < _automaticBackoff.size) {
if (_automaticNextPageCounter > 0) {
val automaticNextPageCounterSaved = _automaticNextPageCounter;
fragment.lifecycleScope.launch(Dispatchers.Default) {
val backoff = _automaticBackoff[Math.min(
_automaticBackoff.size - 1,
_automaticNextPageCounter
)];
val layoutManager = recyclerData.layoutManager
val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition()
val firstVisibleItemView = if(firstVisibleItemPosition != RecyclerView.NO_POSITION) layoutManager.findViewByPosition(firstVisibleItemPosition) else null;
val lastVisibleItemPosition = layoutManager.findLastCompletelyVisibleItemPosition();
val lastVisibleItemView = if(lastVisibleItemPosition != RecyclerView.NO_POSITION) layoutManager.findViewByPosition(lastVisibleItemPosition) else null;
val rows = if(recyclerData.layoutManager is GridLayoutManager) Math.max(1, recyclerData.results.size / recyclerData.layoutManager.spanCount) else 1;
val rowsHeight = (firstVisibleItemView?.height ?: 0) * rows;
if(lastVisibleItemView != null && lastVisibleItemPosition == (recyclerData.results.size - 1)) {
false;
}
else if (firstVisibleItemView != null && height != null && rowsHeight < height) {
false;
} else {
true;
}
}
Logger.i(TAG, "ensureEnoughContentVisible loadNextPage canScroll=$canScroll _automaticNextPageCounter=$_automaticNextPageCounter")
if (!canScroll || filteredResults.isEmpty()) {
_automaticNextPageCounter++
if(_automaticNextPageCounter < _automaticBackoff.size) {
if(_automaticNextPageCounter > 0) {
val automaticNextPageCounterSaved = _automaticNextPageCounter;
fragment.lifecycleScope.launch(Dispatchers.Default) {
val backoff = _automaticBackoff[Math.min(_automaticBackoff.size - 1, _automaticNextPageCounter)];
withContext(Dispatchers.Main) {
setLoading(true);
}
delay(backoff.toLong());
if(automaticNextPageCounterSaved == _automaticNextPageCounter) {
withContext(Dispatchers.Main) {
loadNextPage();
setLoading(true);
}
delay(backoff.toLong());
if (automaticNextPageCounterSaved == _automaticNextPageCounter) {
withContext(Dispatchers.Main) {
loadNextPage();
}
} else {
withContext(Dispatchers.Main) {
setLoading(false);
}
}
}
else {
withContext(Dispatchers.Main) {
setLoading(false);
}
}
}
} else
loadNextPage();
}
else
loadNextPage();
} else {
Logger.i(TAG, "ensureEnoughContentVisible automaticNextPageCounter reset");
_automaticNextPageCounter = 0;
}
} else {
Logger.i(TAG, "ensureEnoughContentVisible automaticNextPageCounter reset");
_automaticNextPageCounter = 0;
}
}
fun resetAutomaticNextPageCounter(){
@@ -484,6 +470,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
recyclerData.resultsUnfiltered.addAll(toAdd);
recyclerData.adapter.notifyDataSetChanged();
recyclerData.loadedFeedStyle = feedStyle;
setLoading(false)
if(pager.hasMorePages())
ensureEnoughContentVisible(filteredResults)
}
@@ -96,7 +96,6 @@ class LibraryVideosFragment : MainFragment() {
fun onShown() {
val initialAlbums = StateLibrary.instance.getAlbums();
Logger.i(TAG, "Initial album count: " + initialAlbums.size);
val buckets = StateLibrary.instance.getVideoBucketNames();
setPager(StateLibrary.instance.getVideos(fragment._toggleBuckets));
}
@@ -16,6 +16,7 @@ import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.futo.platformplayer.*
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
@@ -363,6 +364,7 @@ class RemotePlaylistFragment : MainFragment() {
_imagePlaylistThumbnail.let {
Glide.with(it)
.load(video.thumbnails.getHQThumbnail())
.withMaxSizePx()
.placeholder(R.drawable.placeholder_video_thumbnail)
.crossfade()
.into(it);
@@ -2,7 +2,9 @@ package com.futo.platformplayer.fragment.mainactivity.main
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.drawable.Animatable
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.WindowManager
@@ -13,10 +15,15 @@ import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.graphics.drawable.toDrawable
import androidx.lifecycle.lifecycleScope
import androidx.media3.common.C
import androidx.media3.common.Format
import androidx.media3.common.util.UnstableApi
import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
@@ -71,6 +78,7 @@ import com.futo.platformplayer.views.video.FutoShortPlayer
import com.futo.platformplayer.views.video.FutoVideoPlayerBase
import com.futo.platformplayer.views.video.FutoVideoPlayerBase.Companion.PREFERED_AUDIO_CONTAINERS
import com.futo.platformplayer.views.video.FutoVideoPlayerBase.Companion.PREFERED_VIDEO_CONTAINERS
import com.futo.platformplayer.withMaxSizePx
import com.futo.polycentric.core.ApiMethods
import com.futo.polycentric.core.ContentType
import com.futo.polycentric.core.Models
@@ -851,9 +859,8 @@ class ShortView : FrameLayout {
}
val thumbnail = videoDetails.thumbnails.getHQThumbnail()
/*
if (videoSource == null && !thumbnail.isNullOrBlank()) Glide.with(context).asBitmap()
.load(thumbnail).into(object : CustomTarget<Bitmap>() {
.load(thumbnail).withMaxSizePx().into(object : CustomTarget<Bitmap>() {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
player.setArtwork(resource.toDrawable(resources))
}
@@ -863,7 +870,6 @@ class ShortView : FrameLayout {
}
})
else player.setArtwork(null)
*/
fragment.lifecycleScope.launch(Dispatchers.Main) {
try {
@@ -42,6 +42,7 @@ import androidx.media3.datasource.HttpDataSource
import androidx.media3.ui.PlayerControlView
import androidx.media3.ui.TimeBar
import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition
import com.futo.platformplayer.BuildConfig
@@ -161,6 +162,7 @@ import com.futo.platformplayer.views.subscriptions.SubscribeButton
import com.futo.platformplayer.views.video.FutoVideoPlayer
import com.futo.platformplayer.views.video.FutoVideoPlayerBase
import com.futo.platformplayer.views.videometa.UpNextView
import com.futo.platformplayer.withMaxSizePx
import com.futo.polycentric.core.ApiMethods
import com.futo.polycentric.core.ContentType
import com.futo.polycentric.core.Models
@@ -552,12 +554,12 @@ class VideoDetailView : ConstraintLayout {
_buttonMore = buttonMore;
updateMoreButtons();
val handleLoaderGameVisibilityChanged = { b: Boolean ->
val handleLoaderGameVisibilityChanged: (Boolean) -> Unit = { b: Boolean ->
_loaderGameVisible = b
fragment.lifecycleScope.launch(Dispatchers.Main) {
onShouldEnterPictureInPictureChanged.emit()
updateResumeVisibilityFor(lastPositionMilliseconds)
}
updateResumeVisibilityFor(lastPositionMilliseconds)
}
_player.loaderGameVisibilityChanged.subscribe(handleLoaderGameVisibilityChanged)
_cast.loaderGameVisibilityChanged.subscribe(handleLoaderGameVisibilityChanged)
@@ -2049,7 +2051,7 @@ class VideoDetailView : ConstraintLayout {
} else {
val thumbnail = video.thumbnails.getHQThumbnail();
if ((videoSource == null) && !thumbnail.isNullOrBlank()) // || _player.isAudioMode
Glide.with(context).asBitmap().load(thumbnail)
Glide.with(context).asBitmap().load(thumbnail).withMaxSizePx()
.into(object: CustomTarget<Bitmap>() {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
_player.setArtwork(BitmapDrawable(resources, resource));
@@ -14,6 +14,7 @@ import android.widget.TextView
import androidx.core.view.isVisible
import androidx.core.view.setPadding
import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UISlideOverlays
@@ -28,6 +29,7 @@ import com.futo.platformplayer.toHumanDuration
import com.futo.platformplayer.toHumanTime
import com.futo.platformplayer.views.SearchView
import com.futo.platformplayer.views.lists.VideoListEditorView
import com.futo.platformplayer.withMaxSizePx
abstract class VideoListEditorView : LinearLayout {
private var _videoListEditorView: VideoListEditorView;
@@ -211,6 +213,7 @@ abstract class VideoListEditorView : LinearLayout {
_imagePlaylistThumbnail.let {
Glide.with(it)
.load(video.thumbnails.getHQThumbnail())
.withMaxSizePx()
.placeholder(R.drawable.placeholder_video_thumbnail)
.crossfade()
.into(it);
@@ -4,8 +4,10 @@ import android.graphics.drawable.Drawable
import android.widget.ImageView
import com.bumptech.glide.Glide
import com.bumptech.glide.RequestBuilder
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.withMaxSizePx
class GlideHelper {
@@ -14,7 +16,7 @@ class GlideHelper {
fun ImageView.loadThumbnails(thumbnails: Thumbnails, isHQ: Boolean = true, continuation: ((RequestBuilder<Drawable>) -> Unit)? = null) {
val url = if(isHQ) thumbnails.getHQThumbnail() ?: thumbnails.getLQThumbnail() else thumbnails.getLQThumbnail();
val req = Glide.with(this).load(url);
val req = Glide.with(this).load(url).withMaxSizePx()
if (thumbnails.hasMultiple() && false) { //TODO: Resolve issue where fallback triggered on second loads?
val fallbackUrl = if (isHQ) thumbnails.getLQThumbnail() else thumbnails.getHQThumbnail();
@@ -1,5 +1,6 @@
package com.futo.platformplayer.receivers
import android.content.ActivityNotFoundException
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
@@ -26,14 +27,24 @@ class InstallReceiver : BroadcastReceiver() {
val activityIntent: Intent? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra(Intent.EXTRA_INTENT)
}
if (activityIntent == null) {
Logger.w(TAG, "Received STATUS_PENDING_USER_ACTION and activity intent is null.")
onReceiveResult.emit(context.getString(R.string.install_failed_device_installer_broken))
return;
}
context.startActivity(activityIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
activityIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
try {
context.startActivity(activityIntent)
} catch (e: ActivityNotFoundException) {
Logger.e(TAG, "System installer cannot handle CONFIRM_INSTALL intent. ROM is broken; falling back / reporting error.", e)
onReceiveResult.emit(context.getString(R.string.install_failed_device_installer_broken))
}
}
PackageInstaller.STATUS_SUCCESS -> onReceiveResult.emit(null);
PackageInstaller.STATUS_FAILURE -> onReceiveResult.emit(context.getString(R.string.general_failure));
@@ -45,6 +56,7 @@ class InstallReceiver : BroadcastReceiver() {
PackageInstaller.STATUS_FAILURE_STORAGE -> onReceiveResult.emit(context.getString(R.string.not_enough_storage));
else -> {
val msg = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE);
Logger.w(TAG, "Received unknown install status $status, message=$msg")
onReceiveResult.emit(msg)
}
}
@@ -26,6 +26,7 @@ import android.util.Log
import android.view.KeyEvent
import androidx.core.app.NotificationCompat
import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition
import com.futo.platformplayer.R
@@ -38,6 +39,7 @@ import com.futo.platformplayer.receivers.MediaControlReceiver
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.withMaxSizePx
class MediaPlaybackService : Service() {
private val TAG = "MediaPlaybackService";
@@ -172,21 +174,26 @@ class MediaPlaybackService : Service() {
}
fun closeMediaSession() {
Logger.v(TAG, "closeMediaSession");
stopForeground(STOP_FOREGROUND_REMOVE);
Logger.v(TAG, "closeMediaSession")
stopForeground(STOP_FOREGROUND_REMOVE)
abandonAudioFocus()
val notifManager = _notificationManager;
Logger.i(TAG, "Cancelling playback notification (notifManager: ${notifManager != null})");
notifManager?.cancel(MEDIA_NOTIF_ID);
_notif_last_video = null;
_notif_last_bitmap = null;
_mediaSession = null;
val notifManager = _notificationManager
Logger.i(TAG, "Cancelling playback notification (notifManager: ${notifManager != null})")
notifManager?.cancel(MEDIA_NOTIF_ID)
if(_instance == this)
_instance = null;
this.stopSelf();
_notif_last_video = null
_notif_last_bitmap = null
_mediaSession?.isActive = false
_mediaSession?.release()
_mediaSession = null
if (_instance == this)
_instance = null
stopSelf()
}
fun updateMediaSession(videoUpdated: IPlatformVideo?) {
@@ -206,37 +213,37 @@ class MediaPlaybackService : Service() {
if(_notificationChannel == null || _mediaSession == null)
setupNotificationRequirements();
_mediaSession?.setMetadata(
MediaMetadataCompat.Builder()
.putString(MediaMetadata.METADATA_KEY_ARTIST, video.author.name)
.putString(MediaMetadata.METADATA_KEY_TITLE, video.name)
.putLong(MediaMetadata.METADATA_KEY_DURATION, video.duration * 1000)
.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, lastBitmap)
.build());
updateMediaMetadata(video, lastBitmap)
val thumbnail = video.thumbnails.getHQThumbnail();
_notif_last_video = video;
if(isUpdating)
notifyMediaSession(video, _notif_last_bitmap);
notifyMediaSession(video, _notif_last_bitmap?.takeIf { !it.isRecycled });
else if(thumbnail != null) {
notifyMediaSession(video, null);
val tag = video;
Glide.with(this).asBitmap()
.load(thumbnail)
.withMaxSizePx()
.into(object: CustomTarget<Bitmap>() {
override fun onResourceReady(resource: Bitmap,transition: Transition<in Bitmap>?) {
if(tag == _notif_last_video) {
notifyMediaSession(video, resource)
_mediaSession?.setMetadata(
MediaMetadataCompat.Builder()
.putString(MediaMetadata.METADATA_KEY_ARTIST, video.author.name)
.putString(MediaMetadata.METADATA_KEY_TITLE, video.name)
.putLong(MediaMetadata.METADATA_KEY_DURATION, video.duration * 1000)
.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, resource)
.build());
if (tag != _notif_last_video) return
if (resource.isRecycled) {
notifyMediaSession(video, null)
return
}
val albumArt = resource.copy(
resource.config ?: Bitmap.Config.ARGB_8888,
false
)
_notif_last_bitmap = albumArt
notifyMediaSession(video, albumArt)
updateMediaMetadata(video, albumArt)
}
override fun onLoadCleared(placeholder: Drawable?) {
if(tag == _notif_last_video)
@@ -247,6 +254,19 @@ class MediaPlaybackService : Service() {
else
notifyMediaSession(video, null);
}
private fun updateMediaMetadata(video: IPlatformVideo, bitmap: Bitmap?) {
val builder = MediaMetadataCompat.Builder()
.putString(MediaMetadata.METADATA_KEY_ARTIST, video.author.name)
.putString(MediaMetadata.METADATA_KEY_TITLE, video.name)
.putLong(MediaMetadata.METADATA_KEY_DURATION, video.duration * 1000)
val safeBitmap = bitmap?.takeIf { !it.isRecycled }
if (safeBitmap != null) {
builder.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, safeBitmap)
}
_mediaSession?.setMetadata(builder.build())
}
private fun generateMediaAction(icon: Int, title: String, intent: PendingIntent) : NotificationCompat.Action {
return NotificationCompat.Action.Builder(icon, title, intent).build();
}
@@ -572,30 +572,39 @@ class StateApp {
DownloadService.getOrCreateService(context);
}
Logger.i(TAG, "MainApp Started: Initialize [AutoUpdate]");
val autoUpdateEnabled = Settings.instance.autoUpdate.isAutoUpdateEnabled();
val shouldDownload = Settings.instance.autoUpdate.shouldDownload();
val backgroundDownload = Settings.instance.autoUpdate.backgroundDownload == 1;
when {
//Background download
autoUpdateEnabled && shouldDownload && backgroundDownload -> {
StateUpdate.instance.setShouldBackgroundUpdate(true);
}
if (Settings.instance.autoUpdate.isAutoUpdateEnabled()) {
if (Settings.instance.autoUpdate.backgroundDownload == 1) {
Logger.i(TAG, "MainApp Started: Initialize [AutoUpdate Background]");
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build();
autoUpdateEnabled && !shouldDownload && backgroundDownload -> {
Logger.i(TAG, "Auto update skipped due to wrong network state");
}
val periodicRequest = PeriodicWorkRequest.Builder(
UpdateCheckWorker::class.java,
12, TimeUnit.HOURS
)
.setConstraints(constraints)
.build();
//Foreground download
autoUpdateEnabled -> {
val wm = WorkManager.getInstance(context);
wm.enqueueUniquePeriodicWork(
UpdateCheckWorker.UNIQUE_WORK_NAME,
ExistingPeriodicWorkPolicy.UPDATE,
periodicRequest
);
val oneTimeRequest = OneTimeWorkRequest.Builder(UpdateCheckWorker::class.java)
.setConstraints(constraints)
.build();
wm.enqueue(oneTimeRequest);
} else {
Logger.i(TAG, "MainApp Started: Initialize [AutoUpdate]");
scopeOrNull?.launch(Dispatchers.IO) {
StateUpdate.instance.checkForUpdates(context, false)
}
}
else -> {
Logger.i(TAG, "Auto update disabled");
}
} else {
Logger.i(TAG, "AutoUpdate disabled");
}
Logger.i(TAG, "MainApp Started: Initialize [Noisy]");
@@ -781,24 +790,20 @@ class StateApp {
Logger.i("StateApp", "No AutoBackup configured");
}
fun scheduleBackgroundWork(context: Context, active: Boolean = true, intervalMinutes: Int = 60 * 12) {
try {
val wm = WorkManager.getInstance(context);
if(active) {
if(BuildConfig.DEBUG)
if (active) {
if (BuildConfig.DEBUG)
UIDialogs.toast(context, "Scheduling background every ${intervalMinutes} minutes");
val req = PeriodicWorkRequest.Builder(BackgroundWorker::class.java, intervalMinutes.toLong(), TimeUnit.MINUTES, 5, TimeUnit.MINUTES)
.setConstraints(Constraints.Builder()
.setRequiredNetworkType(NetworkType.UNMETERED)
.build())
.build();
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.UNMETERED).build()).build();
wm.enqueueUniquePeriodicWork("backgroundSubscriptions", ExistingPeriodicWorkPolicy.UPDATE, req);
} else {
wm.cancelUniqueWork("backgroundSubscriptions");
}
else
wm.cancelAllWork();
} catch (e: Throwable) {
Logger.e(TAG, "Failed to schedule background subscription updates.", e)
UIDialogs.toast(context, "Background subscription update failed: " + e.message)
@@ -806,6 +811,7 @@ class StateApp {
}
private suspend fun migrateStores(context: Context, managedStores: List<ManagedStore<*>>, index: Int) {
if(managedStores.size <= index)
return;
@@ -903,15 +909,6 @@ class StateApp {
try {
if(FragmentedStorage.isInitialized && Settings.instance.downloads.shouldDownload())
StateDownloads.instance.checkForDownloadsTodos();
val autoUpdateEnabled = Settings.instance.autoUpdate.isAutoUpdateEnabled();
val shouldDownload = Settings.instance.autoUpdate.shouldDownload();
val backgroundDownload = Settings.instance.autoUpdate.backgroundDownload == 1;
if (autoUpdateEnabled && shouldDownload && backgroundDownload) {
StateUpdate.instance.setShouldBackgroundUpdate(true);
} else {
StateUpdate.instance.setShouldBackgroundUpdate(false);
}
} catch(ex: Throwable) {
Logger.w(TAG, "Failed to handle capabilities changed event", ex);
}
@@ -1,10 +1,12 @@
package com.futo.platformplayer.states
import android.content.ContentResolver
import android.content.ContentUris
import android.content.Intent
import android.database.Cursor
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.MediaStore
import android.provider.MediaStore.Audio.Artists
import android.webkit.MimeTypeMap
@@ -154,34 +156,101 @@ class StateLibrary {
fun getArtist(id: Long): Artist? {
return Artist.getArtist(id);
}
fun getVideos(
buckets: List<String>? = null,
pageSize: Int = 20
): IPager<IPlatformContent> {
val resolver = StateApp.instance.contextOrNull?.contentResolver ?: return EmptyPager()
val selection: String?
val selectionArgs: Array<String>?
fun getVideos(buckets: List<String>? = null): IPager<IPlatformContent> {
var query = if(buckets != null) "${MediaStore.Video.Media.BUCKET_DISPLAY_NAME} IN " + "(" + buckets.map { "'${it}'" }.joinToString(",") + ")" else null;
val cursor = StateApp.instance.contextOrNull?.contentResolver?.query(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, PROJECTION_VIDEO,
query,
null,
MediaStore.Video.Media.DATE_ADDED + " DESC") ?: return EmptyPager();
if (!buckets.isNullOrEmpty()) {
val placeholders = buckets.joinToString(",") { "?" }
selection = "${MediaStore.Video.Media.BUCKET_DISPLAY_NAME} IN ($placeholders)"
selectionArgs = buckets.toTypedArray()
} else {
selection = null
selectionArgs = null
}
//Ongoing usage of cursor..todo disposal
//return cursor.use {
cursor.moveToFirst();
val list = mutableListOf<IPlatformVideo>()
while(!cursor.isAfterLast && list.size < 10) {
list.add(videoFromCursor(cursor));
cursor.moveToNext();
val collectionUri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)
} else {
MediaStore.Video.Media.EXTERNAL_CONTENT_URI
}
var nextPageIndex = 0
fun loadPage(pageIndex: Int): List<IPlatformContent> {
Logger.i(TAG, "loadPage $pageIndex")
val offset = pageIndex * pageSize
val queryArgs = Bundle().apply {
selection?.let {
putString(ContentResolver.QUERY_ARG_SQL_SELECTION, it)
}
selectionArgs?.let {
putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, it)
}
putStringArray(
ContentResolver.QUERY_ARG_SORT_COLUMNS,
arrayOf(
MediaStore.Video.Media.DATE_ADDED,
MediaStore.Video.Media._ID
)
)
putInt(
ContentResolver.QUERY_ARG_SORT_DIRECTION,
ContentResolver.QUERY_SORT_DIRECTION_DESCENDING
)
putInt(ContentResolver.QUERY_ARG_LIMIT, pageSize)
putInt(ContentResolver.QUERY_ARG_OFFSET, offset)
}
return AdhocPager<IPlatformContent>({
val list = mutableListOf<IPlatformContent>()
while(!cursor.isAfterLast && list.size < 10) {
list.add(videoFromCursor(cursor));
cursor.moveToNext();
val cursor = resolver.query(
collectionUri,
PROJECTION_VIDEO,
queryArgs,
null
)
if (cursor == null) {
Logger.i(TAG, "loadPage $pageIndex null, returning empty list")
return emptyList()
}
cursor.use { c ->
if (!c.moveToFirst()) {
Logger.i(TAG, "loadPage $pageIndex moveToFirst failed, returning empty list")
return emptyList()
}
Logger.i(TAG, "Videos nextPage: ${list.size}")
return@AdhocPager list;
}, list);
//}
val list = ArrayList<IPlatformContent>(pageSize)
do {
list.add(videoFromCursor(c))
} while (c.moveToNext() && list.size < pageSize)
Logger.i(TAG, "loadPage $pageIndex found ${list.size} items")
return list
}
}
val firstPage = loadPage(0)
if (firstPage.isEmpty()) {
return EmptyPager()
}
nextPageIndex = 1
return AdhocPager<IPlatformContent>({
val page = loadPage(nextPageIndex)
nextPageIndex++
Logger.i(TAG, "loadPage nextPage: ${page.size}")
page
}, firstPage)
}
fun getRecentVideos(buckets: List<String>? = null, count: Int = 20): List<IPlatformVideo> {
val videoPager = getVideos(buckets);
val items = mutableListOf<IPlatformVideo>();
@@ -193,48 +262,80 @@ class StateLibrary {
return items;
}
private var _cacheBucketNames: List<Bucket>? = null;
fun getVideoBucketNames(): List<Bucket> {
if(Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU)
return listOf();
if(_cacheBucketNames != null)
return _cacheBucketNames ?: listOf();
try {
val cur: Cursor = StateApp.instance.contextOrNull?.contentResolver?.query(
MediaStore.Video.Media.EXTERNAL_CONTENT_URI, arrayOf(
MediaStore.Video.Media.BUCKET_ID,
MediaStore.Video.Media.BUCKET_DISPLAY_NAME,
), null, null, null
) ?: return listOf();
@Volatile
private var _cachedVideoBuckets: List<Bucket>? = null
private val _bucketCacheLock = Any()
return cur.use {
val buckets = mutableListOf<Bucket>();
val list = HashSet<Long>();
if (cur.moveToFirst()) {
var id: Long;
var bucket: String
do {
try {
id = cur.getLong(0);
bucket = cur.getStringOrNull(1) ?: continue;
if (!list.contains(id)) {
list.add(id);
buckets.add(Bucket(id, bucket));
}
} catch (ex: Throwable) {
Logger.e(TAG, "Failed to parse bucket due to ${ex.message}", ex);
}
} while (cur.moveToNext())
fun getVideoBucketNames(forceRefresh: Boolean = false): List<Bucket> {
if (!forceRefresh) {
_cachedVideoBuckets?.let { return it }
}
val resolver = StateApp.instance.contextOrNull?.contentResolver
?: return emptyList()
val projection = arrayOf(
MediaStore.Video.VideoColumns.BUCKET_ID,
MediaStore.Video.VideoColumns.BUCKET_DISPLAY_NAME
)
val sortOrder = "${MediaStore.Video.VideoColumns.BUCKET_DISPLAY_NAME} COLLATE NOCASE ASC"
val loadedBuckets: List<Bucket> = try {
resolver.query(
MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
projection,
null,
null,
sortOrder
)?.use { cursor ->
if (!cursor.moveToFirst()) {
return@use emptyList<Bucket>()
}
_cacheBucketNames = buckets.toList()
return@use _cacheBucketNames ?: listOf();
val idxId = cursor.getColumnIndexOrThrow(MediaStore.Video.VideoColumns.BUCKET_ID)
val idxName = cursor.getColumnIndexOrThrow(MediaStore.Video.VideoColumns.BUCKET_DISPLAY_NAME)
val seenIds = HashSet<Long>()
val buckets = ArrayList<Bucket>()
do {
try {
val id = cursor.getLong(idxId)
if (!seenIds.add(id)) {
continue
}
val name = cursor.getStringOrNull(idxName) ?: continue
buckets.add(Bucket(id, name))
} catch (e: Exception) {
Logger.e(TAG, "Failed to parse video bucket row: ${e.message}", e)
}
} while (cursor.moveToNext())
buckets
} ?: emptyList()
} catch (e: Exception) {
Logger.e(TAG, "Buckets loading failed, returning empty: ${e.message}", e)
emptyList()
}
if (loadedBuckets.isEmpty()) {
if (!forceRefresh) {
_cachedVideoBuckets?.let { return it }
}
return emptyList()
}
catch(ex: Throwable) {
Logger.e(TAG, "Buckets loading failed, returning empty");
return listOf();
synchronized(_bucketCacheLock) {
if (!forceRefresh) {
_cachedVideoBuckets?.let { return it }
}
_cachedVideoBuckets = loadedBuckets
return loadedBuckets
}
}
fun invalidateVideoBucketNamesCache() {
_cachedVideoBuckets = null
}
companion object {
@@ -10,6 +10,7 @@ import android.graphics.drawable.Drawable
import android.os.Build
import androidx.core.app.NotificationCompat
import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition
import com.futo.platformplayer.activities.MainActivity
@@ -22,6 +23,7 @@ import com.futo.platformplayer.serializers.PlatformContentSerializer
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.toHumanNowDiffString
import com.futo.platformplayer.toHumanNowDiffStringMinDay
import com.futo.platformplayer.withMaxSizePx
import java.time.OffsetDateTime
class StateNotifications {
@@ -96,6 +98,7 @@ class StateNotifications {
if(thumbnail != null)
Glide.with(context).asBitmap()
.load(thumbnail)
.withMaxSizePx()
.into(object: CustomTarget<Bitmap>() {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
notifyNewContent(context, manager, notificationChannel, id, content, resource);
@@ -247,17 +247,29 @@ class StatePlayer {
}
private fun createShuffledQueue() {
val currentItem = getCurrentQueueItem();
if (_queuePosition == -1 || currentItem == null) {
_queueShuffled = _queue.shuffled().toMutableList()
return;
if (_queue.isEmpty()) {
_queueShuffled = mutableListOf()
return
}
val nextItems = _queue.subList(Math.min(_queuePosition + 1, _queue.size - 1), _queue.size).shuffled();
val previousItems = _queue.subList(0, _queuePosition).shuffled();
_queueShuffled = (previousItems + currentItem + nextItems).toMutableList();
val currentItem = getCurrentQueueItem()
if (currentItem == null || _queuePosition !in _queue.indices) {
_queueShuffled = _queue.shuffled().toMutableList()
return
}
val previousItems = _queue
.take(_queuePosition)
.shuffled()
val nextItems = _queue
.drop(_queuePosition + 1)
.shuffled()
_queueShuffled = (previousItems + currentItem + nextItems).toMutableList()
}
private fun addToShuffledQueue(video: IPlatformVideo) {
val isLastVideo = _queuePosition + 1 >= _queue.size;
if (isLastVideo) {
@@ -15,146 +15,6 @@ import java.io.InputStream
import java.io.OutputStream
class StateUpdate {
private var _backgroundUpdateFinished = false;
private var _gettingOrDownloadingLastApk = false;
private var _shouldBackgroundUpdate = false;
private val _lockObject = Object();
private fun getOrDownloadLastApkFile(filesDir: File): File? {
try {
Logger.i(TAG, "Started getting or downloading latest APK file.");
if (!_shouldBackgroundUpdate) {
Logger.i(TAG, "Update download cancelled 1.");
return null;
}
Logger.i(TAG, "Started background update download.");
val client = ManagedHttpClient();
val latestVersion = downloadVersionCode(client);
if (!_shouldBackgroundUpdate) {
Logger.i(TAG, "Update download cancelled 2.");
return null;
}
if (latestVersion != null) {
val currentVersion = BuildConfig.VERSION_CODE;
Logger.i(TAG, "Current version ${currentVersion} latest version ${latestVersion}.");
if (latestVersion <= currentVersion) {
Logger.i(TAG, "Already up to date.");
_backgroundUpdateFinished = true;
return null;
}
val outputDirectory = File(filesDir, "autoupdate");
if (!outputDirectory.exists()) {
outputDirectory.mkdirs();
}
if (!_shouldBackgroundUpdate) {
Logger.i(TAG, "Update download cancelled 3.");
return null;
}
val apkOutputFile = File(outputDirectory, "last_version.apk");
val versionOutputFile = File(outputDirectory, "last_version.txt");
var cachedVersionInvalid = false;
if (!versionOutputFile.exists() || !apkOutputFile.exists()) {
Logger.i(TAG, "No downloaded version exists.");
cachedVersionInvalid = true;
} else {
try {
val downloadedVersion = versionOutputFile.readText().toInt();
Logger.i(TAG, "Downloaded version is $downloadedVersion.");
if (downloadedVersion != latestVersion) {
Logger.i(TAG, "Downloaded version is not newest version.");
cachedVersionInvalid = true;
}
}
catch(ex: Throwable) {
Logger.w(TAG, "Deleted version file as it was inaccessible");
versionOutputFile.delete();
cachedVersionInvalid = true;
}
}
if (!_shouldBackgroundUpdate) {
Logger.i(TAG, "Update download cancelled 4.");
return null;
}
if (cachedVersionInvalid) {
Logger.i(TAG, "Downloading new APK to '${apkOutputFile.path}'...");
downloadApkToFile(client, apkOutputFile) { !_shouldBackgroundUpdate };
versionOutputFile.writeText(latestVersion.toString());
Logger.i(TAG, "Downloaded APK to '${apkOutputFile.path}'.");
} else {
Logger.i(TAG, "Latest APK is already downloaded in '${apkOutputFile.path}'...");
}
if (!_shouldBackgroundUpdate) {
Logger.i(TAG, "Update download cancelled 5.");
return null;
}
return apkOutputFile;
} else {
Logger.w(TAG, "Failed to retrieve version from version URL.");
return null;
}
} catch (e: Throwable) {
Logger.e(TAG, "Failed to download APK.", e);
return null;
} finally {
_gettingOrDownloadingLastApk = false;
}
}
fun setShouldBackgroundUpdate(shouldBackgroundUpdate: Boolean) {
synchronized (_lockObject) {
if (_backgroundUpdateFinished) {
_shouldBackgroundUpdate = false;
return;
}
_shouldBackgroundUpdate = shouldBackgroundUpdate;
if (shouldBackgroundUpdate && !_gettingOrDownloadingLastApk) {
Logger.i(TAG, "Auto Updating in Background");
_gettingOrDownloadingLastApk = true;
StateApp.withContext { context ->
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try {
val file = getOrDownloadLastApkFile(context.filesDir);
if (file == null) {
Logger.i(TAG, "Failed to get or download update.");
return@launch;
}
withContext(Dispatchers.Main) {
try {
context.let { c ->
_backgroundUpdateFinished = true;
UIDialogs.showInstallDownloadedUpdateDialog(c, file);
};
Logger.i(TAG, "Showing install dialog for '${file.path}'.");
} catch (e: Throwable) {
context.let { c -> UIDialogs.toast(c, "Failed to show update dialog"); };
Logger.w(TAG, "Error occurred in update dialog.", e);
}
}
} catch (e: Throwable) {
Logger.e(TAG, "Failed to get last downloaded APK file.", e)
}
}
}
}
}
}
suspend fun checkForUpdates(context: Context, showUpToDateToast: Boolean, hideExceptionButtons: Boolean = false) = withContext(Dispatchers.IO) {
try {
val client = ManagedHttpClient();
@@ -196,25 +56,6 @@ class StateUpdate {
}
}
private fun downloadApkToFile(client: ManagedHttpClient, destinationFile: File, isCancelled: (() -> Boolean)? = null) {
var apkStream: InputStream? = null;
var outputStream: OutputStream? = null;
try {
val response = client.get(APK_URL);
if (response.isOk && response.body != null) {
apkStream = response.body.byteStream();
outputStream = destinationFile.outputStream();
apkStream.copyToOutputStream(outputStream, isCancelled);
apkStream.close();
outputStream.close();
}
} finally {
apkStream?.close();
outputStream?.close();
}
}
fun downloadVersionCode(client: ManagedHttpClient): Int? {
val response = client.get(VERSION_URL);
if (!response.isOk || response.body == null) {
@@ -267,6 +108,22 @@ class StateUpdate {
}
val CHANGELOG_BASE_URL = "https://releases.grayjay.app/changelogs";
fun getApkFile(context: Context, version: Int): File {
val dir = File(context.filesDir, "updates");
if (!dir.exists()) {
dir.mkdirs();
}
return File(dir, "app-${DESIRED_ABI}-${version}.apk");
}
fun getPartialApkFile(context: Context, version: Int): File {
val dir = File(context.filesDir, "updates");
if (!dir.exists()) {
dir.mkdirs();
}
return File(dir, "app-${DESIRED_ABI}-${version}.apk.part");
}
fun finish() {
_instance?.let {
_instance = null;
@@ -7,10 +7,12 @@ import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.models.Playlist
import com.futo.platformplayer.withMaxSizePx
class PlaylistsViewHolder : ViewHolder {
private val _root: ConstraintLayout;
@@ -44,6 +46,7 @@ class PlaylistsViewHolder : ViewHolder {
if (p.videos.isNotEmpty()) {
Glide.with(_imageThumbnail)
.load(p.videos[0].thumbnails.getMinimumThumbnail(380))
.withMaxSizePx()
.placeholder(R.drawable.placeholder_video_thumbnail)
.crossfade()
.into(_imageThumbnail);
@@ -12,6 +12,7 @@ import androidx.constraintlayout.widget.ConstraintLayout
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.constructs.Event1
@@ -23,6 +24,7 @@ import com.futo.platformplayer.toHumanNumber
import com.futo.platformplayer.toHumanTime
import com.futo.platformplayer.views.others.ProgressBar
import com.futo.platformplayer.views.platform.PlatformIndicator
import com.futo.platformplayer.withMaxSizePx
class VideoListEditorViewHolder : ViewHolder {
private val _root: ConstraintLayout;
@@ -89,6 +91,7 @@ class VideoListEditorViewHolder : ViewHolder {
fun bind(v: IPlatformVideo, canEdit: Boolean) {
Glide.with(_imageThumbnail)
.load(v.thumbnails.getHQThumbnail())
.withMaxSizePx()
.placeholder(R.drawable.placeholder_video_thumbnail)
.crossfade()
.into(_imageThumbnail);
@@ -7,6 +7,7 @@ import android.widget.ImageView
import android.widget.TextView
import androidx.core.view.isVisible
import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
@@ -16,6 +17,7 @@ import com.futo.platformplayer.states.Artist
import com.futo.platformplayer.toHumanNowDiffString
import com.futo.platformplayer.toHumanTime
import com.futo.platformplayer.views.adapters.AnyAdapter
import com.futo.platformplayer.withMaxSizePx
import com.google.android.material.imageview.ShapeableImageView
@@ -49,6 +51,7 @@ class LocalVideoTileViewHolder(val _viewGroup: ViewGroup) : AnyAdapter.AnyViewHo
Glide.with(it)
.load(content.thumbnails.getHQThumbnail())
.placeholder(R.drawable.unknown_music)
.withMaxSizePx()
.into(it)
else
Glide.with(it).load(R.drawable.unknown_music).into(it);
@@ -2,6 +2,7 @@ package com.futo.platformplayer.views.buttons
import android.content.Context
import android.graphics.Bitmap
import android.os.Looper
import android.util.AttributeSet
import android.util.TypedValue
import android.view.View
@@ -98,46 +99,58 @@ open class BigButton : LinearLayout {
return this;
}
fun withIcon(resourceId: Int, rounded: Boolean = false): BigButton {
private fun applyIcon(resourceId: Int, rounded: Boolean) {
if (resourceId != -1) {
_icon.visibility = View.VISIBLE;
_icon.setImageResource(resourceId);
} else
_icon.visibility = View.GONE;
if (rounded) {
val shapeAppearanceModel = ShapeAppearanceModel().toBuilder()
.setAllCornerSizes(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 16.0f, context.resources.displayMetrics))
.build();
_icon.scaleType = ImageView.ScaleType.FIT_CENTER;
_icon.shapeAppearanceModel = shapeAppearanceModel;
_icon.visibility = View.VISIBLE
_icon.setImageResource(resourceId)
} else {
_icon.scaleType = ImageView.ScaleType.CENTER_CROP;
_icon.shapeAppearanceModel = ShapeAppearanceModel();
_icon.visibility = View.GONE
}
return this;
applyRounded(rounded)
}
fun withIcon(resourceId: Int, rounded: Boolean = false): BigButton {
if (Looper.myLooper() == Looper.getMainLooper()) {
applyIcon(resourceId, rounded)
} else {
post { applyIcon(resourceId, rounded) }
}
return this
}
fun withIcon(bitmap: Bitmap, rounded: Boolean = false): BigButton {
_icon.visibility = View.VISIBLE;
_icon.setImageBitmap(bitmap);
if (rounded) {
val shapeAppearanceModel = ShapeAppearanceModel().toBuilder()
.setAllCornerSizes(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 16.0f, context.resources.displayMetrics))
.build();
_icon.scaleType = ImageView.ScaleType.FIT_CENTER;
_icon.shapeAppearanceModel = shapeAppearanceModel;
if (Looper.myLooper() == Looper.getMainLooper()) {
applyIcon(bitmap, rounded)
} else {
_icon.scaleType = ImageView.ScaleType.CENTER_CROP;
_icon.shapeAppearanceModel = ShapeAppearanceModel();
post { applyIcon(bitmap, rounded) }
}
return this
}
return this;
private fun applyRounded(rounded: Boolean) {
if (rounded) {
val radiusPx = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
16.0f,
context.resources.displayMetrics
)
val shapeAppearanceModel = ShapeAppearanceModel()
.toBuilder()
.setAllCornerSizes(radiusPx)
.build()
_icon.scaleType = ImageView.ScaleType.FIT_CENTER
_icon.shapeAppearanceModel = shapeAppearanceModel
} else {
_icon.scaleType = ImageView.ScaleType.CENTER_CROP
_icon.shapeAppearanceModel = ShapeAppearanceModel()
}
}
private fun applyIcon(bitmap: Bitmap, rounded: Boolean) {
_icon.visibility = View.VISIBLE
_icon.setImageBitmap(bitmap)
applyRounded(rounded)
}
fun withBackground(resourceId: Int): BigButton {
@@ -18,6 +18,7 @@ import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.DefaultTimeBar
import androidx.media3.ui.TimeBar
import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.api.media.models.chapters.IChapter
@@ -32,6 +33,7 @@ import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.views.TargetTapLoaderView
import com.futo.platformplayer.views.behavior.GestureControlView
import com.futo.platformplayer.withMaxSizePx
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -306,6 +308,7 @@ class CastView : ConstraintLayout {
Glide.with(_thumbnail)
.load(video.thumbnails.getHQThumbnail())
.placeholder(R.drawable.placeholder_video_thumbnail)
.withMaxSizePx()
.into(_thumbnail);
_textPosition.text = (position * 1000).formatDuration();
_textDuration.text = (video.duration * 1000).formatDuration();
@@ -6,6 +6,7 @@ import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.futo.platformplayer.*
import com.futo.platformplayer.downloads.VideoDownload
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
@@ -61,6 +62,7 @@ class ActiveDownloadItem: LinearLayout {
Glide.with(_videoImage)
.load(download.thumbnail)
.withMaxSizePx()
.crossfade()
.into(_videoImage);
@@ -5,9 +5,11 @@ import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.futo.platformplayer.R
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.models.PlaylistDownloaded
import com.futo.platformplayer.withMaxSizePx
class PlaylistDownloadItem(context: Context, playlistName: String, playlistThumbnail: String?, val obj: Any): LinearLayout(context) {
init { inflate(context, R.layout.list_downloaded_playlist, this) }
@@ -19,6 +21,7 @@ class PlaylistDownloadItem(context: Context, playlistName: String, playlistThumb
imageText.text = playlistName;
Glide.with(imageView)
.load(playlistThumbnail)
.withMaxSizePx()
.crossfade()
.into(imageView);
}
@@ -15,6 +15,7 @@ import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.PlayerControlView
import androidx.media3.ui.PlayerView
import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition
import com.futo.platformplayer.R
@@ -25,6 +26,7 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.helpers.VideoHelper
import com.futo.platformplayer.toHumanTime
import com.futo.platformplayer.video.PlayerManager
import com.futo.platformplayer.withMaxSizePx
class FutoThumbnailPlayer : FutoVideoPlayerBase {
@@ -135,7 +137,7 @@ class FutoThumbnailPlayer : FutoVideoPlayerBase {
if (videoSource == null && audioSource != null) {
val thumbnail = video.thumbnails.getHQThumbnail();
if (!thumbnail.isNullOrBlank()) {
Glide.with(videoView).asBitmap().load(thumbnail).into(_loadArtwork);
Glide.with(videoView).asBitmap().load(thumbnail).withMaxSizePx().into(_loadArtwork);
} else {
Glide.with(videoView).clear(_loadArtwork);
setArtwork(null);
@@ -54,6 +54,7 @@ import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.views.TargetTapLoaderView
import com.futo.platformplayer.views.behavior.GestureControlView
import com.futo.platformplayer.views.others.ProgressBar
import com.futo.platformplayer.withMaxSizePx
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
@@ -928,11 +929,9 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
override fun switchToAudioMode(video: IPlatformVideoDetails?) {
super.switchToAudioMode(video)
//This causes issues, and is in general confusing, needs improvements
/*
val thumbnail = video?.thumbnails?.getHQThumbnail()
if (!thumbnail.isNullOrBlank()) {
Glide.with(context).asBitmap().load(thumbnail)
Glide.with(context).asBitmap().load(thumbnail).withMaxSizePx()
.into(object : CustomTarget<Bitmap>() {
override fun onResourceReady(
resource: Bitmap,
@@ -946,6 +945,5 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
}
})
}
*/
}
}
@@ -11,11 +11,13 @@ import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.ContextCompat
import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.toHumanNowDiffString
import com.futo.platformplayer.toHumanNumber
import com.futo.platformplayer.withMaxSizePx
class UpNextView : LinearLayout {
private val _layoutContainer: LinearLayout;
@@ -160,6 +162,7 @@ class UpNextView : LinearLayout {
_textChannelName.text = nextItem.author.name;
Glide.with(_imageThumbnail)
.load(nextItem.thumbnails.getHQThumbnail())
.withMaxSizePx()
.placeholder(R.drawable.placeholder_video_thumbnail)
.into(_imageThumbnail);
Glide.with(_imageChannelThumbnail)
+1 -1
View File
@@ -106,7 +106,7 @@
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/update"
android:text="@string/download"
android:textSize="14dp"
android:textColor="@color/white"
android:fontFamily="@font/inter_regular"
+1
View File
@@ -25,6 +25,7 @@
<string name="subscriptions">Subscriptions</string>
<string name="loading">Loading</string>
<string name="retry">Retry</string>
<string name="install_failed_device_installer_broken">Failed to start system installer. Your devices ROM is not compatible with automatic updates.</string>
<string name="cancel">Cancel</string>
<string name="failed_to_retrieve_data_are_you_connected">Failed to retrieve data, are you connected?</string>
<string name="settings">Settings</string>