mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-16 04:52:39 +02:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 34d2e62314 | |||
| 1075ded170 | |||
| 80bb15f3fb | |||
| 27a86a67f0 | |||
| 284b2a24f8 | |||
| 854d1506a6 | |||
| 811fd4e73e | |||
| 335988aa67 | |||
| 29a54fbed4 | |||
| 3a11d0d9d1 | |||
| bda534e485 | |||
| 09fd4c0881 | |||
| 1667866a35 | |||
| 035125d0f8 | |||
| 1bb0cdc405 | |||
| 86019c80a1 | |||
| 8c640d3def | |||
| 7ed1e8a28b | |||
| 3dcfe8c340 | |||
| 042ced81ef | |||
| b37f48380b | |||
| 0a02169782 | |||
| f12e4390f3 | |||
| 82ab45d04e | |||
| 7f77c39296 | |||
| 99eee4f6ee | |||
| 894e400819 |
+1
-1
@@ -232,7 +232,7 @@ dependencies {
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0'
|
||||
|
||||
//Rust casting SDK
|
||||
implementation('org.futo.gitlab.videostreaming.fcast-sdk-jitpack:sender-sdk-minimal:0.3.1') {
|
||||
implementation('org.futo.gitlab.videostreaming.fcast-sdk-jitpack:sender-sdk-minimal:0.4.0') {
|
||||
// Polycentricandroid includes this
|
||||
exclude group: 'net.java.dev.jna'
|
||||
}
|
||||
|
||||
@@ -261,5 +261,12 @@
|
||||
android:name=".UpdateActionReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<activity
|
||||
android:name=".activities.InstallUpdateActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.App.TransparentNoUi"
|
||||
android:excludeFromRecents="true"
|
||||
android:finishOnTaskLaunch="true" />
|
||||
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -415,6 +415,8 @@ class VideoUrlSource {
|
||||
this.url = obj.url;
|
||||
if(obj.requestModifier)
|
||||
this.requestModifier = obj.requestModifier;
|
||||
this.language = obj?.language;
|
||||
this.original = obj?.original;
|
||||
}
|
||||
}
|
||||
class VideoUrlWidevineSource extends VideoUrlSource {
|
||||
@@ -512,6 +514,8 @@ class HLSSource {
|
||||
this.language = obj.language;
|
||||
if(obj.requestModifier)
|
||||
this.requestModifier = obj.requestModifier;
|
||||
this.language = obj?.language;
|
||||
this.original = obj?.original;
|
||||
}
|
||||
}
|
||||
class DashSource {
|
||||
@@ -525,6 +529,8 @@ class DashSource {
|
||||
this.language = obj.language;
|
||||
if(obj.requestModifier)
|
||||
this.requestModifier = obj.requestModifier;
|
||||
this.language = obj?.language;
|
||||
this.original = obj?.original;
|
||||
}
|
||||
}
|
||||
class DashWidevineSource extends DashSource {
|
||||
@@ -550,6 +556,7 @@ class DashManifestRawSource {
|
||||
this.language = obj.language ?? Language.UNKNOWN;
|
||||
if(obj.requestModifier)
|
||||
this.requestModifier = obj.requestModifier;
|
||||
this.original = obj?.original;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -875,9 +875,9 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@DropdownFieldOptionsId(R.array.auto_update_when_array)
|
||||
var check: Int = 0;
|
||||
|
||||
@FormField(R.string.background_download, FieldForm.DROPDOWN, R.string.configure_if_background_download_should_be_used, 1)
|
||||
@DropdownFieldOptionsId(R.array.background_download)
|
||||
var backgroundDownload: Int = 0;
|
||||
@FormField(R.string.background_download, FieldForm.TOGGLE, R.string.configure_if_background_download_should_be_used, 1)
|
||||
//@DropdownFieldOptionsId(R.array.background_download)
|
||||
var shouldBackgroundDownload: Boolean = false;
|
||||
|
||||
@FormField(R.string.download_when, FieldForm.DROPDOWN, R.string.configure_when_updates_should_be_downloaded, 2)
|
||||
@DropdownFieldOptionsId(R.array.when_download)
|
||||
|
||||
@@ -17,7 +17,6 @@ class UpdateActionReceiver : BroadcastReceiver() {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,24 +30,10 @@ class UpdateActionReceiver : BroadcastReceiver() {
|
||||
|
||||
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)
|
||||
}
|
||||
val serviceIntent = Intent(context, UpdateDownloadService::class.java).apply {
|
||||
putExtra(UpdateDownloadService.EXTRA_VERSION, version)
|
||||
}
|
||||
ContextCompat.startForegroundService(context, serviceIntent)
|
||||
}
|
||||
|
||||
private fun handleUpdateNo(context: Context) {
|
||||
@@ -75,22 +60,4 @@ class UpdateActionReceiver : BroadcastReceiver() {
|
||||
|
||||
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)
|
||||
UpdateDownloadService.updateDownloadedDialog?.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import android.app.Dialog
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import android.os.SystemClock
|
||||
import com.futo.platformplayer.UIDialogs.ActionStyle
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateUpdate
|
||||
@@ -22,6 +24,7 @@ class UpdateDownloadService : Service() {
|
||||
private const val MAX_RETRIES = 5
|
||||
private const val INITIAL_BACKOFF_MS = 5_000L
|
||||
private const val BUFFER_SIZE = 8 * 1024
|
||||
private const val MIN_PROGRESS_UPDATE_INTERVAL_MS = 500L
|
||||
|
||||
var updateDownloadedDialog: Dialog? = null
|
||||
}
|
||||
@@ -35,6 +38,8 @@ class UpdateDownloadService : Service() {
|
||||
@Volatile
|
||||
private var cancelRequested: Boolean = false
|
||||
|
||||
private var lastProgressUpdateElapsedMs: Long = 0L
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
@@ -80,6 +85,16 @@ class UpdateDownloadService : Service() {
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
private fun throttledUpdateDownloadProgress(version: Int, progress: Int, indeterminate: Boolean) {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val force = progress == 100 && !indeterminate
|
||||
|
||||
if (force || now - lastProgressUpdateElapsedMs >= MIN_PROGRESS_UPDATE_INTERVAL_MS) {
|
||||
lastProgressUpdateElapsedMs = now
|
||||
UpdateNotificationManager.updateDownloadProgress(this, version, progress, indeterminate)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun downloadApk(version: Int) {
|
||||
val apkFile = StateUpdate.getApkFile(this, version)
|
||||
val partialFile = StateUpdate.getPartialApkFile(this, version)
|
||||
@@ -189,12 +204,18 @@ class UpdateDownloadService : Service() {
|
||||
progress > 100 -> 100
|
||||
else -> progress
|
||||
}
|
||||
UpdateNotificationManager.updateDownloadProgress(this, version, safeProgress, false)
|
||||
throttledUpdateDownloadProgress(version, safeProgress, indeterminate = false)
|
||||
}
|
||||
} else {
|
||||
UpdateNotificationManager.updateDownloadProgress(this, version, 0, true)
|
||||
throttledUpdateDownloadProgress(version, progress = 0, indeterminate = true)
|
||||
}
|
||||
}
|
||||
|
||||
if (!cancelRequested && totalBytes > 0L) {
|
||||
val finalProgress = 100
|
||||
throttledUpdateDownloadProgress(version, finalProgress, indeterminate = false)
|
||||
}
|
||||
|
||||
output.flush()
|
||||
}
|
||||
}
|
||||
@@ -219,10 +240,16 @@ class UpdateDownloadService : Service() {
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||
StateApp.withContext { ctx ->
|
||||
try {
|
||||
updateDownloadedDialog = UIDialogs.showConfirmationDialog(ctx, "Update downloaded, press confirm to install", {
|
||||
UpdateNotificationManager.cancelAll(ctx)
|
||||
UpdateInstaller.startInstall(ctx, apkFile)
|
||||
}, dismissAction = { updateDownloadedDialog = null })
|
||||
updateDownloadedDialog = UIDialogs.showDialog(ctx, R.drawable.foreground,
|
||||
"Update downloaded",
|
||||
"Would you like to install it now?", null, 0,
|
||||
UIDialogs.Action("Not now", {
|
||||
updateDownloadedDialog = null
|
||||
}, ActionStyle.NONE, true),
|
||||
UIDialogs.Action("Install", {
|
||||
UpdateNotificationManager.cancelAll(ctx)
|
||||
UpdateInstaller.startInstall(ctx, version, apkFile)
|
||||
}, ActionStyle.PRIMARY, true));
|
||||
} catch (t: Throwable) {
|
||||
Logger.w(TAG, "Failed to show in-app update downloaded dialog", t)
|
||||
updateDownloadedDialog = null
|
||||
|
||||
@@ -7,7 +7,9 @@ import android.app.PendingIntent.getBroadcast
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.graphics.drawable.Animatable
|
||||
import android.provider.Settings
|
||||
import android.view.View
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.receivers.InstallReceiver
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -17,20 +19,24 @@ import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import androidx.core.net.toUri
|
||||
import com.futo.platformplayer.dialogs.AutoUpdateDialog
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
|
||||
object UpdateInstaller {
|
||||
private const val TAG = "UpdateInstaller"
|
||||
|
||||
@SuppressLint("RequestInstallPackagesPolicy")
|
||||
fun startInstall(context: Context, apkFile: File) {
|
||||
fun startInstall(context: Context, version: Int, apkFile: File) {
|
||||
if (!apkFile.exists()) {
|
||||
Logger.w(TAG, "APK file does not exist: ${apkFile.absolutePath}")
|
||||
UIDialogs.toast(context, "Update file missing")
|
||||
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, "APK file does not exist.")
|
||||
return
|
||||
}
|
||||
|
||||
if (BuildConfig.IS_PLAYSTORE_BUILD) {
|
||||
UIDialogs.toast(context, "Updates are managed by the Play Store")
|
||||
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, "Updates are managed by the Play Store.")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -38,6 +44,7 @@ object UpdateInstaller {
|
||||
val pm = context.packageManager
|
||||
if (!pm.canRequestPackageInstalls()) {
|
||||
UIDialogs.toast(context, "Allow this app to install updates, then try again")
|
||||
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, "Install update permission was missing.")
|
||||
|
||||
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
|
||||
data = "package:${context.packageName}".toUri()
|
||||
@@ -53,8 +60,8 @@ object UpdateInstaller {
|
||||
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)
|
||||
@@ -68,10 +75,17 @@ object UpdateInstaller {
|
||||
session.fsync(sessionStream)
|
||||
}
|
||||
|
||||
val intent = Intent(context, InstallReceiver::class.java)
|
||||
val intent = Intent(context, InstallReceiver::class.java).apply {
|
||||
putExtra(UpdateNotificationManager.EXTRA_VERSION, version)
|
||||
putExtra(UpdateNotificationManager.EXTRA_APK_PATH, apkFile.absolutePath)
|
||||
}
|
||||
val pendingIntent = getBroadcast(context, 0, intent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
|
||||
val statusReceiver = pendingIntent.intentSender
|
||||
|
||||
InstallReceiver.onReceiveResult.subscribe(this) { message ->
|
||||
InstallReceiver.onReceiveResult.clear();
|
||||
onReceiveResult(context, version, apkFile, message);
|
||||
};
|
||||
Logger.i(TAG, "Committing install session for ${apkFile.absolutePath}")
|
||||
session.commit(statusReceiver)
|
||||
} catch (e: Throwable) {
|
||||
@@ -80,10 +94,29 @@ object UpdateInstaller {
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.toast(context, "Failed to install update: ${e.message}")
|
||||
}
|
||||
|
||||
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, e.message)
|
||||
} finally {
|
||||
session?.close()
|
||||
inputStream?.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onReceiveResult(context: Context, version: Int, apkFile: File, result: String?) {
|
||||
try {
|
||||
InstallReceiver.onReceiveResult.remove(this)
|
||||
|
||||
if (result.isNullOrEmpty()) {
|
||||
Logger.i(TAG, "Update install finished successfully")
|
||||
UpdateNotificationManager.showInstallSucceededNotification(context, version)
|
||||
} else {
|
||||
Logger.w(TAG, "Update install failed: $result")
|
||||
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, result)
|
||||
UIDialogs.showGeneralErrorDialog(context, "Install failed due to:\n$result")
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to handle install result", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.Manifest
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.PendingIntent.FLAG_MUTABLE
|
||||
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
|
||||
import android.app.PendingIntent.getBroadcast
|
||||
@@ -13,6 +14,7 @@ import android.content.pm.PackageManager
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.futo.platformplayer.activities.InstallUpdateActivity
|
||||
import java.io.File
|
||||
|
||||
object UpdateNotificationManager {
|
||||
@@ -25,6 +27,7 @@ object UpdateNotificationManager {
|
||||
const val ACTION_UPDATE_NEVER = "com.futo.platformplayer.UPDATE_NEVER"
|
||||
const val ACTION_DOWNLOAD_CANCEL = "com.futo.platformplayer.UPDATE_CANCEL"
|
||||
const val ACTION_INSTALL_NOW = "com.futo.platformplayer.UPDATE_INSTALL"
|
||||
private const val REQUEST_CODE_INSTALL = 1001
|
||||
|
||||
const val EXTRA_VERSION = "version"
|
||||
const val EXTRA_APK_PATH = "apk_path"
|
||||
@@ -32,16 +35,55 @@ object UpdateNotificationManager {
|
||||
const val NOTIF_ID_AVAILABLE = 2001
|
||||
const val NOTIF_ID_DOWNLOADING = 2002
|
||||
const val NOTIF_ID_READY = 2003
|
||||
const val NOTIF_ID_INSTALL_FAILED = 2004
|
||||
const val NOTIF_ID_INSTALL_SUCCEEDED = 2005
|
||||
|
||||
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
|
||||
val channel = NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT).apply {
|
||||
description = CHANNEL_DESCRIPTION
|
||||
enableVibration(false)
|
||||
enableLights(false)
|
||||
setSound(null, null)
|
||||
}
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
fun showInstallSucceededNotification(context: Context, version: Int) {
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||
return
|
||||
}
|
||||
|
||||
ensureChannel(context)
|
||||
|
||||
val launchIntent = context.packageManager
|
||||
.getLaunchIntentForPackage(context.packageName)
|
||||
?.apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED)
|
||||
}
|
||||
|
||||
val launchPendingIntent = launchIntent?.let {
|
||||
PendingIntent.getActivity(context, REQUEST_CODE_INSTALL, it, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
}
|
||||
|
||||
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.foreground)
|
||||
.setContentTitle("Update installed")
|
||||
.setContentText("Version $version installed. Tap to open.")
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setAutoCancel(true)
|
||||
.setSilent(true)
|
||||
|
||||
if (launchPendingIntent != null) {
|
||||
builder.setContentIntent(launchPendingIntent)
|
||||
builder.addAction(0, "Open app", launchPendingIntent)
|
||||
}
|
||||
|
||||
NotificationManagerCompat.from(context).notify(NOTIF_ID_INSTALL_SUCCEEDED, builder.build())
|
||||
}
|
||||
|
||||
fun showUpdateAvailableNotification(context: Context, version: Int) {
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||
return
|
||||
@@ -70,9 +112,11 @@ object UpdateNotificationManager {
|
||||
.setContentText("A new version ($version) is available.")
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setAutoCancel(true)
|
||||
.addAction(0, "Download", yesPendingIntent)
|
||||
.addAction(0, "Not now", noPendingIntent)
|
||||
.setContentIntent(yesPendingIntent)
|
||||
.setSilent(true)
|
||||
.addAction(0, "Never", neverPendingIntent)
|
||||
.addAction(0, "Not now", noPendingIntent)
|
||||
.addAction(0, "Download", yesPendingIntent)
|
||||
|
||||
NotificationManagerCompat.from(context).notify(NOTIF_ID_AVAILABLE, builder.build())
|
||||
}
|
||||
@@ -95,8 +139,9 @@ object UpdateNotificationManager {
|
||||
.setSmallIcon(R.drawable.foreground)
|
||||
.setContentTitle("Downloading update")
|
||||
.setContentText("Downloading version $version")
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setOngoing(true)
|
||||
.setSilent(true)
|
||||
.addAction(0, "Cancel", cancelPendingIntent)
|
||||
|
||||
if (indeterminate) {
|
||||
@@ -123,24 +168,17 @@ object UpdateNotificationManager {
|
||||
}
|
||||
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 installIntent = InstallUpdateActivity.createIntent(context, version, apkFile.absolutePath)
|
||||
val installPendingIntent = PendingIntent.getActivity(context, REQUEST_CODE_INSTALL, installIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
|
||||
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.foreground)
|
||||
.setContentTitle("Update downloaded")
|
||||
.setContentText("Tap to install version $version.")
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setContentIntent(installPendingIntent)
|
||||
.setAutoCancel(true)
|
||||
.setSilent(true)
|
||||
.addAction(0, "Install", installPendingIntent)
|
||||
|
||||
NotificationManagerCompat.from(context).notify(NOTIF_ID_READY, builder.build())
|
||||
@@ -159,13 +197,37 @@ object UpdateNotificationManager {
|
||||
.setContentText(error?.message ?: "Unknown error")
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setAutoCancel(true)
|
||||
.setSilent(true)
|
||||
|
||||
NotificationManagerCompat.from(context).notify(NOTIF_ID_READY, builder.build())
|
||||
}
|
||||
|
||||
fun showInstallFailedNotification(context: Context, version: Int, apkFile: File, error: String?) {
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED)
|
||||
return
|
||||
|
||||
ensureChannel(context)
|
||||
|
||||
val installIntent = InstallUpdateActivity.createIntent(context, version, apkFile.absolutePath)
|
||||
val installPendingIntent = PendingIntent.getActivity(context, REQUEST_CODE_INSTALL, installIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.foreground)
|
||||
.setContentTitle("Failed to install update")
|
||||
.setContentText(if (error != null && error.isNotBlank()) "$error Tap to try again." else "Tap to try again.")
|
||||
.setAutoCancel(true)
|
||||
.setSilent(true)
|
||||
.setContentIntent(installPendingIntent)
|
||||
.addAction(0, "Install again", installPendingIntent)
|
||||
|
||||
NotificationManagerCompat.from(context).notify(NOTIF_ID_INSTALL_FAILED, 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)
|
||||
NotificationManagerCompat.from(context).cancel(NOTIF_ID_INSTALL_FAILED)
|
||||
NotificationManagerCompat.from(context).cancel(NOTIF_ID_INSTALL_SUCCEEDED)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -445,8 +445,8 @@ fun addressScore(addr: InetAddress): Int {
|
||||
fun <T> Enumeration<T>.toList(): List<T> = Collections.list(this)
|
||||
|
||||
fun <T> RequestBuilder<T>.withMaxSizePx(maxSizePx: Int = 1920): RequestBuilder<T> {
|
||||
return this
|
||||
.downsample(DownsampleStrategy.AT_MOST)
|
||||
.override(maxSizePx, maxSizePx)
|
||||
.centerInside()
|
||||
return this;
|
||||
//.downsample(DownsampleStrategy.AT_MOST)
|
||||
//.override(maxSizePx, maxSizePx)
|
||||
//.centerInside()
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.futo.platformplayer.activities
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.UpdateInstaller
|
||||
import com.futo.platformplayer.UpdateNotificationManager
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import java.io.File
|
||||
|
||||
class InstallUpdateActivity : AppCompatActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
UpdateNotificationManager.cancelAll(this)
|
||||
|
||||
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
|
||||
val apkPath = intent.getStringExtra(UpdateNotificationManager.EXTRA_APK_PATH)
|
||||
|
||||
if (version == 0 || apkPath.isNullOrEmpty()) {
|
||||
Logger.w("InstallUpdateActivity", "Missing version or apkPath")
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
val apkFile = File(apkPath)
|
||||
if (!apkFile.exists()) {
|
||||
Logger.w("InstallUpdateActivity", "APK file does not exist: $apkPath")
|
||||
UIDialogs.Companion.toast(this, "Update file missing")
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
UpdateInstaller.startInstall(this, version, apkFile)
|
||||
finish()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun createIntent(context: Context, version: Int, apkPath: String): Intent =
|
||||
Intent(context, InstallUpdateActivity::class.java).apply {
|
||||
putExtra(UpdateNotificationManager.EXTRA_VERSION, version)
|
||||
putExtra(UpdateNotificationManager.EXTRA_APK_PATH, apkPath)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -618,8 +618,8 @@ 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.");
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && Settings.instance.autoUpdate.isAutoUpdateEnabled() && Settings.instance.autoUpdate.shouldBackgroundDownload) {
|
||||
requestNotificationPermissions("You have enabled background updating.\n\nGrayjay uses notifications to inform you when a new app update is available.");
|
||||
}
|
||||
|
||||
val submissionStatus = FragmentedStorage.get<StringStorage>("subscriptionSubmissionStatus")
|
||||
@@ -1299,10 +1299,19 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
navigate(last.first, last.second, false, true);
|
||||
} else {
|
||||
if (_fragVideoDetail.state == VideoDetailFragment.State.CLOSED) {
|
||||
Logger.i(TAG, "Closing activity because _fragVideoDetail.state == closed");
|
||||
finish();
|
||||
} else {
|
||||
//UIDialogs.toast("Grayjay continues in background because of an open video.")
|
||||
moveTaskToBack(false);
|
||||
if(Settings.instance.playback.isBackgroundPictureInPicture()) {
|
||||
try {
|
||||
_fragVideoDetail._viewDetail?.startPictureInPicture();
|
||||
_fragVideoDetail?.forcePictureInPicture();
|
||||
} catch (ex: Throwable) {
|
||||
} //Fail silently
|
||||
}
|
||||
else
|
||||
moveTaskToBack(false);
|
||||
/*
|
||||
UIDialogs.showConfirmationDialog(this, "There is a video playing, are you sure you want to exit the app?", {
|
||||
finish();
|
||||
|
||||
+3
@@ -12,6 +12,9 @@ class DashManifestSource : IVideoSource, IDashManifestSource {
|
||||
|
||||
override var priority: Boolean = false;
|
||||
|
||||
override val language: String? = null;
|
||||
override val original: Boolean? = false;
|
||||
|
||||
constructor(url : String) {
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
+3
@@ -12,6 +12,9 @@ class HLSManifestSource : IVideoSource, IHLSManifestSource {
|
||||
|
||||
override var priority: Boolean = false;
|
||||
|
||||
override val language: String? = null;
|
||||
override val original: Boolean? = false;
|
||||
|
||||
constructor(url : String) {
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
+3
@@ -14,6 +14,9 @@ class HLSVariantVideoUrlSource(
|
||||
override val priority: Boolean,
|
||||
val url: String
|
||||
) : IVideoUrlSource {
|
||||
override val language: String? = null;
|
||||
override val original: Boolean? = false;
|
||||
|
||||
override fun getVideoUrl(): String {
|
||||
return url
|
||||
}
|
||||
|
||||
+2
@@ -9,4 +9,6 @@ interface IVideoSource {
|
||||
val bitrate : Int?;
|
||||
val duration: Long;
|
||||
val priority: Boolean;
|
||||
val language: String?;
|
||||
val original: Boolean?;
|
||||
}
|
||||
+4
@@ -16,6 +16,10 @@ class LocalVideoSource : IVideoSource, IStreamMetaDataSource {
|
||||
|
||||
override var priority: Boolean = false;
|
||||
|
||||
override val language: String? = null;
|
||||
override val original: Boolean? = false;
|
||||
|
||||
|
||||
val filePath : String;
|
||||
val fileSize : Long;
|
||||
|
||||
|
||||
+3
@@ -19,6 +19,9 @@ open class VideoUrlSource(
|
||||
) : IVideoUrlSource, IStreamMetaDataSource {
|
||||
override var streamMetaData: StreamMetaData? = null;
|
||||
|
||||
override val language: String? = null;
|
||||
override val original: Boolean? = false;
|
||||
|
||||
override fun getVideoUrl() : String {
|
||||
return url;
|
||||
}
|
||||
|
||||
+7
@@ -39,6 +39,10 @@ open class JSDashManifestRawSource(
|
||||
private val ctx = "DashRawSource"
|
||||
private val cfg = plugin.config
|
||||
|
||||
override val language: String? = _obj.getOrDefault(cfg, "language", ctx, null);
|
||||
override val original: Boolean? = _obj.getOrDefault(cfg, "original", ctx, null);
|
||||
|
||||
|
||||
override val container: String =
|
||||
_obj.getOrDefault<String>(cfg, "container", ctx, null) ?: "application/dash+xml"
|
||||
|
||||
@@ -185,6 +189,9 @@ class JSDashManifestMergingRawSource(
|
||||
override val priority: Boolean
|
||||
get() = video.priority;
|
||||
|
||||
override val language: String? get() = audio.language
|
||||
override val original: Boolean? get() = audio.original;
|
||||
|
||||
override fun generateAsync(scope: CoroutineScope): V8Deferred<String?> {
|
||||
val videoDashDef = video.generateAsync(scope);
|
||||
val audioDashDef = audio.generateAsync(scope);
|
||||
|
||||
+6
@@ -21,6 +21,9 @@ class JSDashManifestSource : IVideoUrlSource, IDashManifestSource, JSSource {
|
||||
|
||||
override var priority: Boolean = false;
|
||||
|
||||
override val language: String?;
|
||||
override val original: Boolean?;
|
||||
|
||||
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH, plugin, obj) {
|
||||
val contextName = "DashSource";
|
||||
val config = plugin.config;
|
||||
@@ -29,6 +32,9 @@ class JSDashManifestSource : IVideoUrlSource, IDashManifestSource, JSSource {
|
||||
duration = _obj.getOrThrow(config, "duration", contextName);
|
||||
|
||||
priority = obj.getOrNull(config, "priority", contextName) ?: false;
|
||||
|
||||
language = obj.getOrNull(config, "language", contextName);
|
||||
original = obj.getOrNull(config, "original", contextName);
|
||||
}
|
||||
|
||||
override fun getVideoUrl(): String {
|
||||
|
||||
+6
@@ -28,6 +28,9 @@ class JSDashManifestWidevineSource : IVideoUrlSource, IDashManifestSource,
|
||||
override val licenseUri: String
|
||||
override val hasLicenseRequestExecutor: Boolean
|
||||
|
||||
override val language: String?;
|
||||
override val original: Boolean?;
|
||||
|
||||
@Suppress("ConvertSecondaryConstructorToPrimary")
|
||||
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH, plugin, obj) {
|
||||
val contextName = "DashWidevineSource"
|
||||
@@ -40,6 +43,9 @@ class JSDashManifestWidevineSource : IVideoUrlSource, IDashManifestSource,
|
||||
|
||||
licenseUri = _obj.getOrThrow(config, "licenseUri", contextName)
|
||||
hasLicenseRequestExecutor = obj.has("getLicenseRequestExecutor")
|
||||
|
||||
language = _obj.getOrNull(config, "language", contextName);
|
||||
original = _obj.getOrNull(config, "original", contextName);
|
||||
}
|
||||
|
||||
override fun getLicenseRequestExecutor(): JSRequestExecutor? {
|
||||
|
||||
+6
@@ -21,6 +21,9 @@ class JSHLSManifestSource : IHLSManifestSource, JSSource {
|
||||
|
||||
override var priority: Boolean = false;
|
||||
|
||||
override val language: String?;
|
||||
override val original: Boolean?;
|
||||
|
||||
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_HLS, plugin, obj) {
|
||||
val contextName = "HLSSource";
|
||||
val config = plugin.config;
|
||||
@@ -30,5 +33,8 @@ class JSHLSManifestSource : IHLSManifestSource, JSSource {
|
||||
duration = _obj.getOrThrow<Int>(config, "duration", contextName).toLong();
|
||||
|
||||
priority = obj.getOrNull(config, "priority", contextName) ?: false;
|
||||
|
||||
language = _obj.getOrNull(config, "language", contextName);
|
||||
original = _obj.getOrNull(config, "original", contextName);
|
||||
}
|
||||
}
|
||||
+3
@@ -44,6 +44,9 @@ open class JSVideoUrlSource(
|
||||
override var priority: Boolean =
|
||||
_obj.getOrDefault<Boolean>(cfg, "priority", ctx, false) ?: false
|
||||
|
||||
override val language: String? = _obj.getOrDefault(cfg, "language", ctx, null);
|
||||
override val original: Boolean? = _obj.getOrDefault(cfg, "original", ctx, null);
|
||||
|
||||
override fun getVideoUrl(): String = url
|
||||
|
||||
override fun toString(): String =
|
||||
|
||||
+3
@@ -20,6 +20,9 @@ class LocalVideoContentSource: IVideoSource {
|
||||
override val duration: Long;
|
||||
override val priority: Boolean = false;
|
||||
|
||||
override val language: String? = null;
|
||||
override val original: Boolean? = false;
|
||||
|
||||
var contentUrl: String;
|
||||
|
||||
constructor(contentUrl: String, mime: String, name: String? = null, duration: Long = 0) {
|
||||
|
||||
+3
@@ -20,6 +20,9 @@ class LocalVideoFileSource: IVideoSource {
|
||||
override val duration: Long;
|
||||
override val priority: Boolean = false;
|
||||
|
||||
override val language: String? = null;
|
||||
override val original: Boolean? = null;
|
||||
|
||||
var file: File;
|
||||
|
||||
constructor(file: File) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.futo.platformplayer.casting
|
||||
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
import org.fcast.sender_sdk.Metadata
|
||||
@@ -16,6 +17,7 @@ abstract class CastingDevice {
|
||||
abstract val onDurationChanged: Event1<Double>
|
||||
abstract val onVolumeChanged: Event1<Double>
|
||||
abstract val onSpeedChanged: Event1<Double>
|
||||
abstract val onMediaItemEnd: Event0
|
||||
abstract var connectionState: CastConnectionState
|
||||
abstract val protocolType: CastProtocolType
|
||||
abstract var isPlaying: Boolean
|
||||
|
||||
@@ -2,12 +2,14 @@ package com.futo.platformplayer.casting
|
||||
|
||||
import android.os.Build
|
||||
import com.futo.platformplayer.BuildConfig
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
import com.futo.polycentric.core.Event
|
||||
import org.fcast.sender_sdk.ApplicationInfo
|
||||
import org.fcast.sender_sdk.GenericKeyEvent
|
||||
import org.fcast.sender_sdk.GenericMediaEvent
|
||||
import org.fcast.sender_sdk.KeyEvent
|
||||
import org.fcast.sender_sdk.MediaEvent
|
||||
import org.fcast.sender_sdk.PlaybackState
|
||||
import org.fcast.sender_sdk.Source
|
||||
import java.net.InetAddress
|
||||
@@ -15,8 +17,10 @@ import org.fcast.sender_sdk.CastingDevice as RsCastingDevice;
|
||||
import org.fcast.sender_sdk.DeviceEventHandler as RsDeviceEventHandler;
|
||||
import org.fcast.sender_sdk.DeviceConnectionState
|
||||
import org.fcast.sender_sdk.DeviceFeature
|
||||
import org.fcast.sender_sdk.EventSubscription
|
||||
import org.fcast.sender_sdk.IpAddr
|
||||
import org.fcast.sender_sdk.LoadRequest
|
||||
import org.fcast.sender_sdk.MediaItemEventType
|
||||
import org.fcast.sender_sdk.Metadata
|
||||
import org.fcast.sender_sdk.ProtocolType
|
||||
import org.fcast.sender_sdk.urlFormatIpAddr
|
||||
@@ -63,6 +67,7 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
|
||||
var onDurationChanged = Event1<Double>()
|
||||
var onVolumeChanged = Event1<Double>()
|
||||
var onSpeedChanged = Event1<Double>()
|
||||
var onMediaItemEnd = Event0()
|
||||
|
||||
override fun connectionStateChanged(state: DeviceConnectionState) {
|
||||
onConnectionStateChanged.emit(state)
|
||||
@@ -92,12 +97,14 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
|
||||
// TODO
|
||||
}
|
||||
|
||||
override fun keyEvent(event: GenericKeyEvent) {
|
||||
override fun keyEvent(event: KeyEvent) {
|
||||
// Unreachable
|
||||
}
|
||||
|
||||
override fun mediaEvent(event: GenericMediaEvent) {
|
||||
// Unreachable
|
||||
override fun mediaEvent(event: MediaEvent) {
|
||||
if (event.type == MediaItemEventType.END) {
|
||||
onMediaItemEnd.emit()
|
||||
}
|
||||
}
|
||||
|
||||
override fun playbackError(message: String) {
|
||||
@@ -127,6 +134,8 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
|
||||
get() = eventHandler.onVolumeChanged
|
||||
override val onSpeedChanged: Event1<Double>
|
||||
get() = eventHandler.onSpeedChanged
|
||||
override val onMediaItemEnd: Event0
|
||||
get() = eventHandler.onMediaItemEnd
|
||||
|
||||
override fun resumePlayback() = device.resumePlayback()
|
||||
override fun pausePlayback() = device.pausePlayback()
|
||||
@@ -181,7 +190,8 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
|
||||
resumePosition = resumePosition,
|
||||
speed = speed,
|
||||
volume = volume,
|
||||
metadata = metadata
|
||||
metadata = metadata,
|
||||
requestHeaders = null,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -200,6 +210,7 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
|
||||
speed = speed,
|
||||
volume = volume,
|
||||
metadata = metadata,
|
||||
requestHeaders = null,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -227,6 +238,13 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
|
||||
eventHandler.onConnectionStateChanged.subscribe { newState ->
|
||||
when (newState) {
|
||||
is DeviceConnectionState.Connected -> {
|
||||
if (device.supportsFeature(DeviceFeature.MEDIA_EVENT_SUBSCRIPTION)) {
|
||||
try {
|
||||
device.subscribeEvent(EventSubscription.MediaItemEnd)
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Failed to subscribe to MediaItemEnd events: $e")
|
||||
}
|
||||
}
|
||||
usedRemoteAddress = ipAddrToInetAddress(newState.usedRemoteAddr)
|
||||
localAddress = ipAddrToInetAddress(newState.localAddr)
|
||||
connectionState = CastConnectionState.CONNECTED
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.futo.platformplayer.casting
|
||||
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
import kotlinx.serialization.KSerializer
|
||||
@@ -181,6 +182,7 @@ class CastingDeviceLegacyWrapper(val inner: CastingDeviceLegacy) : CastingDevice
|
||||
override val onDurationChanged: Event1<Double> get() = inner.onDurationChanged
|
||||
override val onVolumeChanged: Event1<Double> get() = inner.onVolumeChanged
|
||||
override val onSpeedChanged: Event1<Double> get() = inner.onSpeedChanged
|
||||
override val onMediaItemEnd: Event0 = Event0()
|
||||
override var connectionState: CastConnectionState
|
||||
get() = inner.connectionState
|
||||
set(_) = Unit
|
||||
|
||||
@@ -40,6 +40,7 @@ import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalAud
|
||||
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoContentSource
|
||||
import com.futo.platformplayer.awaitCancelConverted
|
||||
import com.futo.platformplayer.builders.DashBuilder
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.constructs.Event2
|
||||
@@ -82,6 +83,7 @@ abstract class StateCasting {
|
||||
val onActiveDeviceTimeChanged = Event1<Double>();
|
||||
val onActiveDeviceDurationChanged = Event1<Double>();
|
||||
val onActiveDeviceVolumeChanged = Event1<Double>();
|
||||
val onActiveDeviceMediaItemEnd = Event0()
|
||||
var activeDevice: CastingDevice? = null;
|
||||
private var _videoExecutor: JSRequestExecutor? = null
|
||||
private var _audioExecutor: JSRequestExecutor? = null
|
||||
@@ -145,6 +147,7 @@ abstract class StateCasting {
|
||||
device.onTimeChanged.clear();
|
||||
device.onVolumeChanged.clear();
|
||||
device.onDurationChanged.clear();
|
||||
device.onMediaItemEnd.clear();
|
||||
ad.disconnect()
|
||||
}
|
||||
|
||||
@@ -159,6 +162,7 @@ abstract class StateCasting {
|
||||
device.onTimeChanged.clear();
|
||||
device.onVolumeChanged.clear();
|
||||
device.onDurationChanged.clear();
|
||||
device.onMediaItemEnd.clear();
|
||||
activeDevice = null;
|
||||
}
|
||||
|
||||
@@ -222,6 +226,9 @@ abstract class StateCasting {
|
||||
device.onTimeChanged.subscribe {
|
||||
invokeInMainScopeIfRequired { onActiveDeviceTimeChanged.emit(it) };
|
||||
};
|
||||
device.onMediaItemEnd.subscribe {
|
||||
invokeInMainScopeIfRequired { onActiveDeviceMediaItemEnd.emit() }
|
||||
}
|
||||
|
||||
try {
|
||||
device.connect();
|
||||
@@ -232,6 +239,7 @@ abstract class StateCasting {
|
||||
device.onTimeChanged.clear();
|
||||
device.onVolumeChanged.clear();
|
||||
device.onDurationChanged.clear();
|
||||
device.onMediaItemEnd.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1323,8 +1331,14 @@ abstract class StateCasting {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
var hasAudioInDash = false
|
||||
for (representation in representationRegex.findAll(dashContent)) {
|
||||
val mediaType = representation.groups[1]?.value ?: throw Exception("Media type should be found")
|
||||
|
||||
if (mediaType.startsWith("audio/")) {
|
||||
hasAudioInDash = true
|
||||
}
|
||||
|
||||
dashContent = mediaInitializationRegex.replace(dashContent) {
|
||||
if (it.range.first < representation.range.first || it.range.last > representation.range.last) {
|
||||
return@replace it.value
|
||||
@@ -1348,16 +1362,20 @@ abstract class StateCasting {
|
||||
throw Exception("Audio source without request executor not supported")
|
||||
}
|
||||
|
||||
if (audioSource != null && audioSource.hasRequestExecutor) {
|
||||
val oldExecutor = _audioExecutor;
|
||||
oldExecutor?.closeAsync();
|
||||
_audioExecutor = audioSource.getRequestExecutor()
|
||||
if (videoSource != null && videoSource.hasRequestExecutor) {
|
||||
val oldVideoExecutor = _videoExecutor
|
||||
oldVideoExecutor?.closeAsync()
|
||||
_videoExecutor = videoSource.getRequestExecutor()
|
||||
}
|
||||
|
||||
if (videoSource != null && videoSource.hasRequestExecutor) {
|
||||
val oldExecutor = _videoExecutor;
|
||||
oldExecutor?.closeAsync();
|
||||
_videoExecutor = videoSource.getRequestExecutor()
|
||||
if (audioSource != null) {
|
||||
val oldExecutor = _audioExecutor
|
||||
oldExecutor?.closeAsync()
|
||||
_audioExecutor = audioSource.getRequestExecutor()
|
||||
} else if (hasAudioInDash && videoSource != null) {
|
||||
val oldExecutor = _audioExecutor
|
||||
oldExecutor?.closeAsync()
|
||||
_audioExecutor = _videoExecutor
|
||||
}
|
||||
|
||||
//TOOD: Else also handle the non request executor case, perhaps add ?url=$originalUrl to the query parameters, ... propagate this for all other flows also
|
||||
@@ -1388,7 +1406,7 @@ abstract class StateCasting {
|
||||
}.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("castDashRaw");
|
||||
}
|
||||
if (audioSource != null) {
|
||||
if (audioSource != null || (audioSource == null && hasAudioInDash)) {
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpFunctionHandler("GET", audioPath) { httpContext ->
|
||||
val originalUrl = httpContext.query["url"]?.let { URLDecoder.decode(it, "UTF-8") } ?: return@HttpFunctionHandler
|
||||
|
||||
@@ -21,6 +21,7 @@ import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.UpdateDownloadService
|
||||
import com.futo.platformplayer.UpdateNotificationManager
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.copyToOutputStream
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
@@ -64,12 +65,14 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
|
||||
_buttonShowChangelog = findViewById(R.id.button_show_changelog);
|
||||
|
||||
_buttonNever.setOnClickListener {
|
||||
UpdateNotificationManager.cancelAll(context)
|
||||
Settings.instance.autoUpdate.check = 1;
|
||||
Settings.instance.save();
|
||||
dismiss();
|
||||
};
|
||||
|
||||
_buttonClose.setOnClickListener {
|
||||
UpdateNotificationManager.cancelAll(context)
|
||||
dismiss();
|
||||
};
|
||||
|
||||
@@ -79,11 +82,13 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
|
||||
};
|
||||
|
||||
_buttonUpdate.setOnClickListener {
|
||||
UpdateNotificationManager.cancelAll(context)
|
||||
|
||||
if (_updating) {
|
||||
return@setOnClickListener;
|
||||
}
|
||||
|
||||
if (Settings.instance.autoUpdate.backgroundDownload == 1) {
|
||||
if (Settings.instance.autoUpdate.shouldBackgroundDownload) {
|
||||
val ctx = context.applicationContext;
|
||||
val intent = Intent(ctx, UpdateDownloadService::class.java);
|
||||
intent.putExtra(UpdateDownloadService.EXTRA_VERSION, _maxVersion);
|
||||
|
||||
@@ -9,7 +9,9 @@ import com.arthenica.ffmpegkit.FFmpegKit
|
||||
import com.arthenica.ffmpegkit.ReturnCode
|
||||
import com.arthenica.ffmpegkit.StatisticsCallback
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.media.IPlatformClient
|
||||
import com.futo.platformplayer.api.media.PlatformID
|
||||
import com.futo.platformplayer.api.media.models.modifier.IRequestModifier
|
||||
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
|
||||
@@ -40,10 +42,13 @@ import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManif
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptReloadRequiredException
|
||||
import com.futo.platformplayer.exceptions.DownloadException
|
||||
import com.futo.platformplayer.helpers.FileHelper.Companion.sanitizeFileName
|
||||
import com.futo.platformplayer.helpers.VideoHelper
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.others.Language
|
||||
import com.futo.platformplayer.parsers.HLS
|
||||
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
||||
import com.futo.platformplayer.states.StateDownloads
|
||||
@@ -86,6 +91,9 @@ import kotlin.time.times
|
||||
class VideoDownload {
|
||||
var state: State = State.QUEUED;
|
||||
|
||||
@Contextual
|
||||
@Transient
|
||||
var plugin: IPlatformClient? = null;
|
||||
var video: SerializedPlatformVideo? = null;
|
||||
var videoDetails: SerializedPlatformVideoDetails? = null;
|
||||
|
||||
@@ -101,6 +109,7 @@ class VideoDownload {
|
||||
|
||||
var videoSource: VideoUrlSource?;
|
||||
var audioSource: AudioUrlSource?;
|
||||
var overrideResultAudioSource: IAudioSource? = null;
|
||||
@Contextual
|
||||
@Transient
|
||||
val videoSourceToUse: IVideoSource? get () = if(requiresLiveVideoSource) videoSourceLive as IVideoSource? else videoSource as IVideoSource?;
|
||||
@@ -270,7 +279,7 @@ class VideoDownload {
|
||||
|
||||
//Fetch full video object and determine source
|
||||
if(video != null && videoDetails == null) {
|
||||
val original = StatePlatform.instance.getContentDetails(video!!.url).await();
|
||||
val original = if (plugin != null) plugin!!.getContentDetails(video!!.url) else StatePlatform.instance.getContentDetails(video!!.url)?.await();
|
||||
if(original !is IPlatformVideoDetails)
|
||||
throw IllegalStateException("Original content is not media?");
|
||||
|
||||
@@ -437,6 +446,11 @@ class VideoDownload {
|
||||
videoFileNameBase = "${videoDetails!!.id.value!!} [${actualVideoSource!!.width}x${actualVideoSource!!.height}]".sanitizeFileName();
|
||||
videoFileNameExt = videoContainerToExtension(actualVideoSource!!.container);
|
||||
videoFilePath = File(downloadDir, videoFileName!!).absolutePath;
|
||||
if(actualVideoSource is JSDashManifestRawSource && actualAudioSource == null) {
|
||||
audioFileNameBase = "${videoDetails!!.id.value!!}-[unknown]".sanitizeFileName();
|
||||
audioFileNameExt = videoAudioContainerToExtension(actualVideoSource!!.container);
|
||||
audioFilePath = File(downloadDir, audioFileName!!).absolutePath;
|
||||
}
|
||||
}
|
||||
if(actualAudioSource != null) {
|
||||
audioFileNameBase = "${videoDetails!!.id.value!!} [${actualAudioSource!!.language}-${actualAudioSource!!.bitrate}]".sanitizeFileName();
|
||||
@@ -490,7 +504,11 @@ class VideoDownload {
|
||||
else -> downloadFileSource("Video", client, if (actualVideoSource is JSSource) actualVideoSource else null, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
|
||||
}
|
||||
else if(actualVideoSource is JSDashManifestRawSource) {
|
||||
videoFileSize = downloadDashFileSource("Video", client, actualVideoSource, File(downloadDir, videoFileName!!), progressCallback);
|
||||
if(actualAudioSource == null)
|
||||
videoFileSize = downloadDashFileSource("Video", client, actualVideoSource, File(downloadDir, videoFileName!!), progressCallback, 3,
|
||||
File(downloadDir, audioFileName!!));
|
||||
else
|
||||
videoFileSize = downloadDashFileSource("Video", client, actualVideoSource, File(downloadDir, videoFileName!!), progressCallback, 1);
|
||||
}
|
||||
else throw NotImplementedError("NotImplemented video download: " + actualVideoSource.javaClass.name);
|
||||
});
|
||||
@@ -530,7 +548,7 @@ class VideoDownload {
|
||||
else -> downloadFileSource("Audio", client, if (actualAudioSource is JSSource) actualAudioSource else null, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
|
||||
}
|
||||
else if(actualAudioSource is JSDashManifestRawAudioSource) {
|
||||
audioFileSize = downloadDashFileSource("Audio", client, actualAudioSource, File(downloadDir, audioFileName!!), progressCallback);
|
||||
audioFileSize = downloadDashFileSource("Audio", client, actualAudioSource, File(downloadDir, audioFileName!!), progressCallback, 2);
|
||||
}
|
||||
else throw NotImplementedError("NotImplemented audio download: " + actualAudioSource.javaClass.name);
|
||||
});
|
||||
@@ -589,38 +607,54 @@ class VideoDownload {
|
||||
}
|
||||
|
||||
private suspend fun combineSegments(context: Context, segmentFiles: List<File>, targetFile: File) = withContext(Dispatchers.IO) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
val fileList = File(context.cacheDir, "fileList-${UUID.randomUUID()}.txt")
|
||||
fileList.writeText(segmentFiles.joinToString("\n") { "file '${it.absolutePath}'" })
|
||||
require(segmentFiles.isNotEmpty()) { "segmentFiles must not be empty" }
|
||||
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
val concatInput = buildString {
|
||||
append("concat:")
|
||||
append(
|
||||
segmentFiles.joinToString("|") { file ->
|
||||
file.absolutePath
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val cmd = "-i \"$concatInput\" -c copy \"${targetFile.absolutePath}\""
|
||||
|
||||
val cmd = "-f concat -safe 0 -i \"${fileList.absolutePath}\" -c copy \"${targetFile.absolutePath}\""
|
||||
val statisticsCallback = StatisticsCallback { _ ->
|
||||
//TODO: Show progress?
|
||||
//No callback
|
||||
}
|
||||
|
||||
val executorService = Executors.newSingleThreadExecutor()
|
||||
val session = FFmpegKit.executeAsync(cmd,
|
||||
{ session ->
|
||||
if (ReturnCode.isSuccess(session.returnCode)) {
|
||||
fileList.delete()
|
||||
|
||||
val session = FFmpegKit.executeAsync(
|
||||
cmd,
|
||||
{ completedSession ->
|
||||
executorService.shutdown()
|
||||
|
||||
if (ReturnCode.isSuccess(completedSession.returnCode)) {
|
||||
continuation.resumeWith(Result.success(Unit))
|
||||
} else {
|
||||
val errorMessage = if (ReturnCode.isCancel(session.returnCode)) {
|
||||
val errorMessage = if (ReturnCode.isCancel(completedSession.returnCode)) {
|
||||
"Command cancelled"
|
||||
} else {
|
||||
"Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}"
|
||||
"Command failed with state '${completedSession.state}' " +
|
||||
"and return code ${completedSession.returnCode}, " +
|
||||
"stack trace ${completedSession.failStackTrace}"
|
||||
}
|
||||
fileList.delete()
|
||||
continuation.resumeWithException(RuntimeException(errorMessage))
|
||||
}
|
||||
},
|
||||
{ Logger.v(TAG, it.message) },
|
||||
{ log ->
|
||||
Logger.v(TAG, log.message)
|
||||
},
|
||||
statisticsCallback,
|
||||
executorService
|
||||
)
|
||||
|
||||
continuation.invokeOnCancellation {
|
||||
session.cancel()
|
||||
executorService.shutdownNow()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -856,14 +890,19 @@ class VideoDownload {
|
||||
return downloadedTotalLength
|
||||
}
|
||||
|
||||
private fun downloadDashFileSource(name: String, client: ManagedHttpClient, source: IJSDashManifestRawSource, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||
private fun downloadDashFileSource(name: String, client: ManagedHttpClient, source: IJSDashManifestRawSource, targetFile: File, onProgress: (Long, Long, Long) -> Unit, downloadType: Int = 0, targetFileAudio: File? = null): Long {
|
||||
if(targetFile.exists())
|
||||
targetFile.delete();
|
||||
if(targetFileAudio?.exists() ?: false)
|
||||
targetFileAudio.delete();
|
||||
|
||||
targetFile.createNewFile();
|
||||
targetFileAudio?.createNewFile();
|
||||
|
||||
val sourceLength: Long?;
|
||||
val sourceLengthAudio: Long?;
|
||||
val fileStream = FileOutputStream(targetFile);
|
||||
val fileStream2 = if(targetFileAudio != null) FileOutputStream(targetFileAudio) else null;
|
||||
|
||||
var executor: JSRequestExecutor? = null;
|
||||
try{
|
||||
@@ -874,14 +913,27 @@ class VideoDownload {
|
||||
throw IllegalStateException("No manifest after generation");
|
||||
|
||||
//TODO: Temporary naive assume single-sourced dash
|
||||
val foundTemplate = REGEX_DASH_TEMPLATE.find(manifest);
|
||||
if(foundTemplate == null || foundTemplate.groupValues.size != 3)
|
||||
val foundTemplates = REGEX_DASH_TEMPLATE_WITH_MIME.findAll(manifest);
|
||||
val foundTemplate = when(downloadType) {
|
||||
1 -> foundTemplates.find({ it.groupValues[1].contains("video/") });
|
||||
2 -> foundTemplates.find({ it.groupValues[1].contains("audio/") });
|
||||
else -> foundTemplates.find({ it.groupValues[1].contains("video/") });
|
||||
}
|
||||
if(foundTemplate == null || foundTemplate.groupValues.size != 4)
|
||||
throw IllegalStateException("No SegmentTemplate found in manifest (unsupported dash?)");
|
||||
val foundTemplateUrl = foundTemplate.groupValues[1];
|
||||
val foundCues = REGEX_DASH_CUE.findAll(foundTemplate.groupValues[2]);
|
||||
val foundTemplateUrl = foundTemplate.groupValues[2];
|
||||
val foundCues = REGEX_DASH_CUE.findAll(foundTemplate.groupValues[3]).toList();
|
||||
if(foundCues.count() <= 0)
|
||||
throw IllegalStateException("No Cues found in manifest (unsupported dash?)");
|
||||
|
||||
val foundTemplate2 = if(downloadType == 3) foundTemplates.find({ it.groupValues[1].contains("audio/") }); else null;
|
||||
val foundTemplateUrl2 = if(foundTemplate2 != null) foundTemplate2.groupValues[2] else null;
|
||||
val foundCues2 = if(foundTemplate2 != null) REGEX_DASH_CUE.findAll(foundTemplate2.groupValues[3]).toList() else null;
|
||||
val foundCues2Downloaded = hashSetOf<MatchResult>();
|
||||
|
||||
if(foundTemplate2 != null)
|
||||
overrideResultAudioSource = LocalAudioSource((videoSource?.name)?.let { it + " [audio]" } ?: "audio", "", 0, 0, foundTemplate2.groupValues[1], REGEX_CODECS.find(foundTemplate2.groupValues[0])?.groupValues?.get(1) ?: "", Language.UNKNOWN);
|
||||
|
||||
executor = if(source is JSSource && source.hasRequestExecutor)
|
||||
source.getRequestExecutor();
|
||||
else
|
||||
@@ -896,13 +948,17 @@ class VideoDownload {
|
||||
Logger.i(TAG, "Download $name Dash, CueCount: " + foundCues.count().toString());
|
||||
|
||||
var written: Long = 0;
|
||||
var written2: Long = 0;
|
||||
var indexCounter = 0;
|
||||
var indexCounter2 = 0;
|
||||
onProgress(foundCues.count().toLong(), 0, 0);
|
||||
val totalCues = foundCues.count().toLong() + (foundCues2?.count()?.toLong() ?: 0)
|
||||
val lastCue = foundCues.lastOrNull();
|
||||
for(cue in foundCues) {
|
||||
val t = cue.groupValues[1];
|
||||
val d = cue.groupValues[2];
|
||||
|
||||
|
||||
Logger.i(TAG, "Downloading cue ${indexCounter}")
|
||||
val url = foundTemplateUrl.replace("\$Number\$", (indexCounter).toString());
|
||||
val modified = modifier?.modifyRequest(url, mapOf());
|
||||
|
||||
@@ -918,17 +974,60 @@ class VideoDownload {
|
||||
speedTracker.addWork(data.size.toLong());
|
||||
written += data.size;
|
||||
|
||||
onProgress(foundCues.count().toLong(), indexCounter.toLong(), speedTracker.lastSpeed);
|
||||
onProgress(totalCues, indexCounter.toLong() + indexCounter2.toLong(), speedTracker.lastSpeed);
|
||||
|
||||
|
||||
indexCounter++;
|
||||
|
||||
if(foundCues2 != null && foundTemplateUrl2 != null && fileStream2 != null) {
|
||||
val toDownload = if(lastCue != null && cue == lastCue)
|
||||
foundCues2.filter { !foundCues2Downloaded.contains(it) }.toList() else
|
||||
foundCues2.filter { !foundCues2Downloaded.contains(it) && (it.groupValues[1].toLong()) < t.toLong() }.toList();
|
||||
Logger.i(TAG, "Downloading audio cues (${toDownload.size})")
|
||||
for(cue2 in toDownload) {
|
||||
val index2 = foundCues2.indexOf(cue2);
|
||||
val t2 = cue2.groupValues[1];
|
||||
val d2 = cue2.groupValues[2];
|
||||
val url2 = foundTemplateUrl2!!.replace("\$Number\$", (index2).toString());
|
||||
val modified2 = modifier?.modifyRequest(url, mapOf());
|
||||
|
||||
val data = if(executor != null)
|
||||
executor.executeRequest("GET", modified2?.url ?: url2, null, modified2?.headers ?: mapOf());
|
||||
else {
|
||||
val resp = client.get(modified2?.url ?: url, modified2?.headers?.toMutableMap() ?: mutableMapOf());
|
||||
if(!resp.isOk)
|
||||
throw IllegalStateException("Dash request2 failed for index " + indexCounter.toString() + ", with code: " + resp.code.toString());
|
||||
resp.body!!.bytes()
|
||||
}
|
||||
fileStream2.write(data, 0, data.size);
|
||||
speedTracker.addWork(data.size.toLong());
|
||||
written2 += data.size;
|
||||
indexCounter2++;
|
||||
|
||||
foundCues2Downloaded.add(cue2);
|
||||
onProgress(totalCues, indexCounter.toLong() + indexCounter2.toLong(), speedTracker.lastSpeed);
|
||||
}
|
||||
}
|
||||
}
|
||||
sourceLength = written;
|
||||
sourceLengthAudio = written2;
|
||||
|
||||
Logger.i(TAG, "$name downloadSource Finished");
|
||||
}
|
||||
catch(scriptEx: ScriptReloadRequiredException) {
|
||||
if(targetFile.exists() ?: false)
|
||||
targetFile.delete();
|
||||
if(targetFileAudio?.exists() ?: false)
|
||||
targetFileAudio.delete();
|
||||
|
||||
createNewPluginClient();
|
||||
throw scriptEx;
|
||||
}
|
||||
catch(ioex: IOException) {
|
||||
if(targetFile.exists() ?: false)
|
||||
targetFile.delete();
|
||||
if(targetFileAudio?.exists() ?: false)
|
||||
targetFileAudio.delete();
|
||||
if(ioex.message?.contains("ENOSPC") ?: false)
|
||||
throw Exception("Not enough space on device", ioex);
|
||||
else
|
||||
@@ -937,14 +1036,37 @@ class VideoDownload {
|
||||
catch(ex: Throwable) {
|
||||
if(targetFile.exists() ?: false)
|
||||
targetFile.delete();
|
||||
if(targetFileAudio?.exists() ?: false)
|
||||
targetFileAudio.delete();
|
||||
throw ex;
|
||||
}
|
||||
finally {
|
||||
fileStream.close();
|
||||
fileStream2?.close();
|
||||
executor?.closeAsync()
|
||||
}
|
||||
if(sourceLengthAudio != null && sourceLengthAudio > 0)
|
||||
audioFileSize = sourceLengthAudio
|
||||
return sourceLength!!;
|
||||
}
|
||||
|
||||
fun createNewPluginClient() {
|
||||
UIDialogs.appToast("Download creating new client at request of plugin");
|
||||
cleanupPluginClient();
|
||||
plugin = (video?.url ?: videoDetails?.url)?.let { StatePlatform.instance.getContentClient(it) }?.let { if(it is JSClient) it else null }?.getCopy(false, true);
|
||||
plugin?.initialize();
|
||||
}
|
||||
fun cleanupPluginClient() {
|
||||
val oldPlugin = plugin;
|
||||
plugin = null;
|
||||
try {
|
||||
oldPlugin?.disable();
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e(TAG, "Failed to dispose download client: ${ex.message}" , ex);
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadFileSource(name: String, client: ManagedHttpClient, source: JSSource?, videoUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||
if(targetFile.exists())
|
||||
targetFile.delete();
|
||||
@@ -1304,7 +1426,7 @@ class VideoDownload {
|
||||
throw IllegalStateException("Expected size [${videoFileSize}], but found ${expectedFile.length()}");
|
||||
}
|
||||
}
|
||||
if(audioSourceToUse != null) {
|
||||
if(audioSourceToUse != null || (videoSourceToUse is IJSDashManifestRawSource)) {
|
||||
if(audioFilePath == null)
|
||||
throw IllegalStateException("Missing audio file name after download");
|
||||
val expectedFile = File(audioFilePath!!);
|
||||
@@ -1327,7 +1449,7 @@ class VideoDownload {
|
||||
Logger.i(TAG, "VideoDownload Complete [${name}]");
|
||||
val existing = StateDownloads.instance.getCachedVideo(id);
|
||||
val localVideoSource = videoFilePath?.let { LocalVideoSource.fromSource(videoSourceToUse!!, it, videoFileSize ?: 0, videoOverrideContainer) };
|
||||
val localAudioSource = audioFilePath?.let { LocalAudioSource.fromSource(audioSourceToUse!!, it, audioFileSize ?: 0, audioOverrideContainer) };
|
||||
val localAudioSource = audioFilePath?.let { LocalAudioSource.fromSource(overrideResultAudioSource ?: audioSourceToUse!!, it, audioFileSize ?: 0, audioOverrideContainer) };
|
||||
val localSubtitleSource = subtitleFilePath?.let { LocalSubtitleSource.fromSource(subtitleSource!!, it) };
|
||||
|
||||
if(localVideoSource != null && videoSourceToUse != null && videoSourceToUse is IStreamMetaDataSource)
|
||||
@@ -1369,6 +1491,10 @@ class VideoDownload {
|
||||
}
|
||||
}
|
||||
|
||||
fun cleanup(){
|
||||
cleanupPluginClient()
|
||||
}
|
||||
|
||||
enum class State {
|
||||
QUEUED,
|
||||
PREPARING,
|
||||
@@ -1392,6 +1518,8 @@ class VideoDownload {
|
||||
const val GROUP_WATCHLATER= "WatchLater";
|
||||
|
||||
val REGEX_DASH_TEMPLATE = Regex("<SegmentTemplate .*?media=\"(.*?)\".*?>(.*?)<\\/SegmentTemplate>", RegexOption.DOT_MATCHES_ALL);
|
||||
val REGEX_DASH_TEMPLATE_WITH_MIME = Regex("<Representation.*?mimeType=\\\"(.*?)\\\".*?>.*?<SegmentTemplate .*?media=\\\"(.*?)\\\".*?>(.*?)<\\/SegmentTemplate>", RegexOption.DOT_MATCHES_ALL);
|
||||
val REGEX_CODECS = Regex("codecs=\\\"(.*?)\\\"")
|
||||
val REGEX_DASH_CUE = Regex("<S .*?t=\"([0-9]*?)\".*?d=\"([0-9]*?)\".*?\\/>", RegexOption.DOT_MATCHES_ALL);
|
||||
|
||||
fun videoContainerToExtension(container: String): String? {
|
||||
@@ -1411,6 +1539,16 @@ class VideoDownload {
|
||||
return "video";//throw IllegalStateException("Unknown container: " + container)
|
||||
}
|
||||
|
||||
//TODO: Change usages of this to an accurate container instead of infering it.
|
||||
fun videoAudioContainerToExtension(container: String): String? {
|
||||
if (container.contains("video/mp4") || container == "application/vnd.apple.mpegurl")
|
||||
return "mp4a";
|
||||
else if (container.contains("video/webm"))
|
||||
return "webm";
|
||||
else
|
||||
return "mp4a";//throw IllegalStateException("Unknown container: " + container)
|
||||
}
|
||||
|
||||
fun audioContainerToExtension(container: String): String {
|
||||
if (container.contains("audio/mp4"))
|
||||
return "mp4a";
|
||||
|
||||
+9
-8
@@ -155,15 +155,16 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
StateApp.instance.setPrivacyMode(true);
|
||||
UIDialogs.appToast("Privacy mode enabled");
|
||||
|
||||
UIDialogs.showDialog(it.context ?: return@setOnClickListener, R.drawable.incognito, "Privacy Mode",
|
||||
"All requests will be processed anonymously (any logins will be disabled except for the personalized home page), local playback and history tracking will also be disabled.\n\nTap the icon to disable.", null, 0,
|
||||
UIDialogs.Action("Don't show again", {
|
||||
Settings.instance.other.showPrivacyModeDialog = false;
|
||||
Settings.instance.save();
|
||||
}, UIDialogs.ActionStyle.NONE),
|
||||
UIDialogs.Action("Understood", {
|
||||
if(Settings.instance.other.showPrivacyModeDialog)
|
||||
UIDialogs.showDialog(it.context ?: return@setOnClickListener, R.drawable.incognito, "Privacy Mode",
|
||||
"All requests will be processed anonymously (any logins will be disabled except for the personalized home page), local playback and history tracking will also be disabled.\n\nTap the icon to disable.", null, 0,
|
||||
UIDialogs.Action("Don't show again", {
|
||||
Settings.instance.other.showPrivacyModeDialog = false;
|
||||
Settings.instance.save();
|
||||
}, UIDialogs.ActionStyle.NONE),
|
||||
UIDialogs.Action("Understood", {
|
||||
|
||||
}, UIDialogs.ActionStyle.PRIMARY));
|
||||
}, UIDialogs.ActionStyle.PRIMARY));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@ class LoginFragment : MainFragment() {
|
||||
fun showLogin(config: SourcePluginConfig, callback: ((SourceAuth?) -> Unit)? = null) {
|
||||
if(_callback != null) _callback?.invoke(null);
|
||||
_callback = callback;
|
||||
StateApp.instance.activity?.navigate<LoginFragment>(config, false);
|
||||
StateApp.instance.activity?.navigate<LoginFragment>(config, true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+8
-3
@@ -50,7 +50,7 @@ class VideoDetailFragment() : MainFragment() {
|
||||
|
||||
private var _isActive: Boolean = false;
|
||||
|
||||
private var _viewDetail : VideoDetailView? = null;
|
||||
var _viewDetail : VideoDetailView? = null;
|
||||
private var _view : SingleViewTouchableMotionLayout? = null;
|
||||
|
||||
var isFullscreen : Boolean = false;
|
||||
@@ -372,14 +372,18 @@ class VideoDetailFragment() : MainFragment() {
|
||||
onMinimize.emit();
|
||||
}
|
||||
else if (state != State.MAXIMIZED && progress > 0.9) {
|
||||
state = State.MAXIMIZED;
|
||||
onMaximized.emit();
|
||||
/*
|
||||
if (_isInitialMaximize) {
|
||||
state = State.CLOSED;
|
||||
//state = State.CLOSED; Causes issues? might no longer be needed
|
||||
_isInitialMaximize = false;
|
||||
}
|
||||
else {
|
||||
state = State.MAXIMIZED;
|
||||
onMaximized.emit();
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
if (isTransitioning && (progress > 0.6 || progress < 0.4)) {
|
||||
@@ -450,7 +454,8 @@ class VideoDetailFragment() : MainFragment() {
|
||||
if (viewDetail.shouldEnterPictureInPicture) {
|
||||
_leavingPiP = false
|
||||
}
|
||||
if(Build.VERSION.SDK_INT < Build.VERSION_CODES.S && viewDetail.preventPictureInPicture == false && !StateCasting.instance.isCasting && Settings.instance.playback.isBackgroundPictureInPicture() && !viewDetail.isAudioOnlyUserAction) {
|
||||
val shouldPiP = Settings.instance.playback.isBackgroundPictureInPicture()
|
||||
if(Build.VERSION.SDK_INT < Build.VERSION_CODES.S && viewDetail.preventPictureInPicture == false && !StateCasting.instance.isCasting && shouldPiP && !viewDetail.isAudioOnlyUserAction) {
|
||||
val params = _viewDetail?.getPictureInPictureParams();
|
||||
if(params != null) {
|
||||
Logger.i(TAG, "enterPictureInPictureMode")
|
||||
|
||||
+20
-9
@@ -723,15 +723,17 @@ class VideoDetailView : ConstraintLayout {
|
||||
val activeDevice = StateCasting.instance.activeDevice;
|
||||
if (activeDevice != null) {
|
||||
handlePlayChanged(it);
|
||||
|
||||
val v = video;
|
||||
if (!it && v != null && v.duration - activeDevice.time.toLong() < 2L) {
|
||||
Log.i(TAG, "Next video (loop?)")
|
||||
nextVideo();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
StateCasting.instance.onActiveDeviceMediaItemEnd.subscribe(this) {
|
||||
val activeDevice = StateCasting.instance.activeDevice;
|
||||
if (activeDevice != null) {
|
||||
Log.i(TAG, "Next video (loop?)")
|
||||
nextVideo();
|
||||
}
|
||||
}
|
||||
|
||||
StateCasting.instance.onActiveDeviceTimeChanged.subscribe(this) {
|
||||
if (_isCasting) {
|
||||
setLastPositionMilliseconds((it * 1000.0).toLong(), true);
|
||||
@@ -1273,6 +1275,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
StateCasting.instance.onActiveDevicePlayChanged.remove(this);
|
||||
StateCasting.instance.onActiveDeviceTimeChanged.remove(this);
|
||||
StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this);
|
||||
StateCasting.instance.onActiveDeviceMediaItemEnd.remove(this)
|
||||
StateApp.instance.preventPictureInPicture.remove(this);
|
||||
StatePlayer.instance.onQueueChanged.remove(this);
|
||||
StatePlayer.instance.onVideoChanging.remove(this);
|
||||
@@ -2420,9 +2423,17 @@ class VideoDetailView : ConstraintLayout {
|
||||
|
||||
val doDedup = Settings.instance.playback.simplifySources;
|
||||
|
||||
val bestVideoSources = if(doDedup) (videoSources?.map { it.height * it.width }
|
||||
?.distinct()
|
||||
?.map { x -> VideoHelper.selectBestVideoSource(videoSources.filter { x == it.height * it.width }, -1, FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) }
|
||||
val allLanguages = videoSources?.map { it.language } ?: listOf();
|
||||
val langResCombinations = if(videoSources != null) allLanguages.flatMap {
|
||||
lang -> videoSources
|
||||
.filter { v -> v.language == lang }
|
||||
.map { it.height * it.width }
|
||||
.distinct()
|
||||
.map { res -> Pair(res, lang) }
|
||||
} else listOf();
|
||||
|
||||
val bestVideoSources = if(doDedup && videoSources != null) (langResCombinations
|
||||
?.map { comb -> VideoHelper.selectBestVideoSource(videoSources.filter { comb.first == it.height * it.width && comb.second == it.language }, -1, FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) }
|
||||
?.plus(videoSources.filter { it is IHLSManifestSource || it is IDashManifestSource }))
|
||||
?.distinct()
|
||||
?.filterNotNull()
|
||||
|
||||
+6
-1
@@ -1,5 +1,6 @@
|
||||
package com.futo.platformplayer.fragment.mainactivity.topbar
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
@@ -49,7 +50,11 @@ class GeneralTopBarFragment : TopFragment() {
|
||||
} else if (currentMain is PlaylistsFragment || currentMain is PlaylistFragment) {
|
||||
navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.PLAYLIST));
|
||||
} else if (currentMain is LibraryFragment) {
|
||||
navigate<LibrarySearchFragment>();
|
||||
if(Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
UIDialogs.toast("Your Android version is too old for Mediastore search", true);
|
||||
}
|
||||
else
|
||||
navigate<LibrarySearchFragment>();
|
||||
} else {
|
||||
navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.VIDEO));
|
||||
}
|
||||
|
||||
@@ -52,8 +52,8 @@ class VideoHelper {
|
||||
fun isDownloadable(source: IVideoSource) = (source is IVideoUrlSource || source is IHLSManifestSource || source is JSDashManifestRawSource) && source !is IWidevineSource
|
||||
fun isDownloadable(source: IAudioSource) = (source is IAudioUrlSource || source is IHLSManifestAudioSource || source is JSDashManifestRawAudioSource) && source !is IWidevineSource
|
||||
|
||||
fun selectBestVideoSource(desc: IVideoSourceDescriptor, desiredPixelCount : Int, prefContainers : Array<String>) : IVideoSource? = selectBestVideoSource(desc.videoSources.toList(), desiredPixelCount, prefContainers);
|
||||
fun selectBestVideoSource(sources: Iterable<IVideoSource>, desiredPixelCount : Int, prefContainers : Array<String>) : IVideoSource? {
|
||||
fun selectBestVideoSource(desc: IVideoSourceDescriptor, desiredPixelCount : Int, prefContainers : Array<String>, preferredLanguage: String? = null) : IVideoSource? = selectBestVideoSource(desc.videoSources.toList(), desiredPixelCount, prefContainers, preferredLanguage);
|
||||
fun selectBestVideoSource(sources: Iterable<IVideoSource>, desiredPixelCount : Int, prefContainers : Array<String>, preferredLanguage: String? = null) : IVideoSource? {
|
||||
val targetVideo = if(desiredPixelCount > 0) {
|
||||
sources.toList().minByOrNull { x -> abs(x.height * x.width - desiredPixelCount) };
|
||||
} else {
|
||||
@@ -63,12 +63,34 @@ class VideoHelper {
|
||||
val hasPriority = sources.any { it.priority };
|
||||
|
||||
val targetPixelCount = if(targetVideo != null) targetVideo.width * targetVideo.height else desiredPixelCount;
|
||||
val altSources = if(hasPriority) {
|
||||
|
||||
//Filter priority
|
||||
var altSources = if(hasPriority) {
|
||||
sources.filter { it.priority }.sortedBy { x -> abs(x.height * x.width - targetPixelCount) };
|
||||
} else {
|
||||
sources.filter { it.height == (targetVideo?.height ?: 0) };
|
||||
}
|
||||
|
||||
//Filter Original
|
||||
val hasOriginal = altSources.any { it.original == true };
|
||||
if(hasOriginal && Settings.instance.playback.preferOriginalAudio)
|
||||
altSources = altSources.filter { it.original == true };
|
||||
|
||||
//Filter Language
|
||||
val languageToFilter = if(preferredLanguage != null && altSources.any { it.language == preferredLanguage }) {
|
||||
preferredLanguage
|
||||
} else {
|
||||
if(altSources.any { it.language == Language.ENGLISH })
|
||||
Language.ENGLISH;
|
||||
else
|
||||
Language.UNKNOWN;
|
||||
}
|
||||
if(altSources.any { it.language == languageToFilter }) {
|
||||
altSources.filter { it.language == languageToFilter }.sortedBy { it.bitrate }.toList();
|
||||
} else {
|
||||
altSources.sortedBy { it.bitrate }
|
||||
}
|
||||
|
||||
var bestSource = altSources.firstOrNull();
|
||||
for (prefContainer in prefContainers) {
|
||||
val betterSource = altSources.firstOrNull { it.container == prefContainer };
|
||||
|
||||
@@ -15,6 +15,7 @@ import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.downloads.VideoDownload
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptReloadRequiredException
|
||||
import com.futo.platformplayer.exceptions.DownloadException
|
||||
import com.futo.platformplayer.getNowDiffMinutes
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
@@ -169,6 +170,7 @@ class DownloadService : Service() {
|
||||
Thread.sleep(500);
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
//if(ex is ScriptReloadRequiredException)
|
||||
Logger.e(TAG, "Download failed", ex);
|
||||
if(currentVideo.video == null && currentVideo.videoDetails == null) {
|
||||
//Corrupt?
|
||||
|
||||
@@ -573,7 +573,7 @@ class StateApp {
|
||||
}
|
||||
|
||||
if (Settings.instance.autoUpdate.isAutoUpdateEnabled()) {
|
||||
if (Settings.instance.autoUpdate.backgroundDownload == 1) {
|
||||
if (Settings.instance.autoUpdate.shouldBackgroundDownload) {
|
||||
Logger.i(TAG, "MainApp Started: Initialize [AutoUpdate Background]");
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
|
||||
@@ -179,6 +179,7 @@ class StateDownloads {
|
||||
|
||||
fun removeDownload(download: VideoDownload) {
|
||||
download.isCancelled = true;
|
||||
download.cleanup();
|
||||
_downloading.delete(download);
|
||||
onDownloadsChanged.emit();
|
||||
}
|
||||
|
||||
@@ -169,6 +169,9 @@ class StatePlugins {
|
||||
return false;
|
||||
|
||||
LoginFragment.showLogin(config) {//LoginActivity.showLogin(context, config) {
|
||||
|
||||
if(it == null)
|
||||
return@showLogin;
|
||||
try {
|
||||
StatePlugins.instance.setPluginAuth(config.id, it);
|
||||
} catch (e: Throwable) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.downloads.VideoDownload
|
||||
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.services.DownloadService
|
||||
import com.futo.platformplayer.states.StateDownloads
|
||||
import com.futo.platformplayer.views.others.ProgressBar
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -32,6 +33,7 @@ class ActiveDownloadItem: LinearLayout {
|
||||
private val _videoState: TextView;
|
||||
|
||||
private val _videoCancel: TextView;
|
||||
private val _videoRetry: TextView;
|
||||
|
||||
private val _scope: CoroutineScope;
|
||||
|
||||
@@ -51,13 +53,14 @@ class ActiveDownloadItem: LinearLayout {
|
||||
_videoSpeed = findViewById(R.id.download_video_speed);
|
||||
|
||||
_videoCancel = findViewById(R.id.download_cancel);
|
||||
_videoRetry = findViewById(R.id.download_retry);
|
||||
|
||||
_videoName.text = download.name;
|
||||
_videoDuration.text = download.videoEither.duration.toHumanTime(false);
|
||||
_videoAuthor.text = download.videoEither.author.name;
|
||||
|
||||
_videoState.setOnClickListener {
|
||||
UIDialogs.toast(context, _videoState.text.toString(), false);
|
||||
UIDialogs.appToast(_videoState.text.toString(), false);
|
||||
}
|
||||
|
||||
Glide.with(_videoImage)
|
||||
@@ -72,6 +75,12 @@ class ActiveDownloadItem: LinearLayout {
|
||||
StateDownloads.instance.removeDownload(_download);
|
||||
StateDownloads.instance.preventPlaylistDownload(_download);
|
||||
};
|
||||
_videoRetry.setOnClickListener {
|
||||
download.changeState(VideoDownload.State.QUEUED);
|
||||
DownloadService.getOrCreateService(context) {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
_download.onProgressChanged.subscribe(this) {
|
||||
_scope.launch(Dispatchers.Main) {
|
||||
@@ -122,16 +131,19 @@ class ActiveDownloadItem: LinearLayout {
|
||||
VideoDownload.State.DOWNLOADING -> {
|
||||
_videoBar.visibility = VISIBLE;
|
||||
_videoSpeed.visibility = VISIBLE;
|
||||
_videoRetry.visibility = GONE;
|
||||
};
|
||||
VideoDownload.State.ERROR -> {
|
||||
_videoState.setTextColor(Color.RED);
|
||||
_videoState.text = _download.error ?: context.getString(R.string.error);
|
||||
_videoBar.visibility = GONE;
|
||||
_videoSpeed.visibility = GONE;
|
||||
_videoRetry.visibility = VISIBLE;
|
||||
}
|
||||
else -> {
|
||||
_videoBar.visibility = GONE;
|
||||
_videoSpeed.visibility = GONE;
|
||||
_videoRetry.visibility = GONE;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +118,21 @@
|
||||
android:ellipsize="end"
|
||||
android:layout_marginEnd="10dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/downloaded_author"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_width="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:textSize="9dp"
|
||||
android:textColor="@color/gray_e0"
|
||||
android:fontFamily="@font/inter_extra_light"
|
||||
app:layout_constraintTop_toBottomOf="@id/downloaded_video_name"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
tools:text="ShortCircuit"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end"
|
||||
android:layout_marginStart="10dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/download_cancel"
|
||||
android:layout_width="60dp"
|
||||
@@ -130,20 +145,20 @@
|
||||
android:background="@drawable/background_small_button"
|
||||
android:textAlignment="center"
|
||||
android:text="@string/cancel" />
|
||||
<TextView
|
||||
android:id="@+id/download_retry"
|
||||
android:layout_width="60dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:padding="2dp"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintRight_toRightOf="@id/download_cancel"
|
||||
app:layout_constraintTop_toBottomOf="@id/download_cancel"
|
||||
android:textSize="10dp"
|
||||
android:background="@drawable/background_small_button"
|
||||
android:textAlignment="center"
|
||||
android:text="@string/retry" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/downloaded_author"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_width="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:textSize="9dp"
|
||||
android:textColor="@color/gray_e0"
|
||||
android:fontFamily="@font/inter_extra_light"
|
||||
tools:text="ShortCircuit"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end"
|
||||
android:layout_marginStart="10dp" />
|
||||
</LinearLayout>
|
||||
<LinearLayout
|
||||
android:layout_height="wrap_content"
|
||||
|
||||
@@ -116,4 +116,14 @@
|
||||
<item name="android:fontFamily">@font/inter_regular</item>
|
||||
</style>
|
||||
|
||||
<style name="Theme.App.TransparentNoUi" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
||||
<item name="android:windowIsTranslucent">true</item>
|
||||
<item name="android:windowBackground">@android:color/transparent</item>
|
||||
<item name="android:windowNoTitle">true</item>
|
||||
<item name="android:windowFullscreen">true</item>
|
||||
<item name="android:colorBackgroundCacheHint">@null</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
<item name="windowActionBar">false</item>
|
||||
</style>
|
||||
|
||||
</resources>
|
||||
Submodule app/src/stable/assets/sources/bilibili updated: 17d7aef314...b153339c93
Submodule app/src/stable/assets/sources/crunchyroll updated: 534bded369...a1714790c5
Submodule app/src/stable/assets/sources/curiositystream updated: f6eb2463f5...1ebf5da236
Submodule app/src/stable/assets/sources/odysee updated: 98a8df5a60...1c7a8a4974
Submodule app/src/stable/assets/sources/rumble updated: d24fc4cf8e...3b51471010
Submodule app/src/stable/assets/sources/tedtalks updated: b9528e44c5...292e459eef
Submodule app/src/stable/assets/sources/twitch updated: e4cdb5a32e...cebdad37a3
Submodule app/src/stable/assets/sources/youtube updated: ec5359ae16...079dc6e3dc
Submodule app/src/unstable/assets/sources/bilibili updated: 17d7aef314...b153339c93
Submodule app/src/unstable/assets/sources/crunchyroll updated: 534bded369...a1714790c5
Submodule app/src/unstable/assets/sources/curiositystream updated: f6eb2463f5...1ebf5da236
Submodule app/src/unstable/assets/sources/odysee updated: 98a8df5a60...1c7a8a4974
Submodule app/src/unstable/assets/sources/rumble updated: d24fc4cf8e...3b51471010
Submodule app/src/unstable/assets/sources/tedtalks updated: b9528e44c5...292e459eef
Submodule app/src/unstable/assets/sources/twitch updated: e4cdb5a32e...cebdad37a3
Submodule app/src/unstable/assets/sources/youtube updated: ec5359ae16...079dc6e3dc
Reference in New Issue
Block a user