mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-17 13:32:38 +02:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0fbe0bb438 | |||
| 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 | |||
| 68886502d1 | |||
| 26461c21c4 | |||
| 300466f722 | |||
| 961710cc8b | |||
| eba995f87d | |||
| a67244e79a | |||
| 70502a7651 | |||
| 36b4f5b41d | |||
| def39ba397 | |||
| 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -387,7 +387,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@DropdownFieldOptionsId(R.array.audio_languages)
|
||||
var primaryLanguage: Int = 0;
|
||||
|
||||
fun getPrimaryLanguage(context: Context): String? {
|
||||
fun getPrimaryLanguage(context: Context? = null): String? {
|
||||
return when(primaryLanguage) {
|
||||
0 -> "en";
|
||||
1 -> "es";
|
||||
@@ -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)
|
||||
|
||||
@@ -370,17 +370,19 @@ class UIDialogs {
|
||||
}
|
||||
|
||||
|
||||
fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null) {
|
||||
fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null, dismissAction: (() -> Unit)? = null): AlertDialog {
|
||||
val confirmButtonAction = Action(context.getString(R.string.confirm), action, ActionStyle.PRIMARY)
|
||||
val cancelButtonAction = Action(context.getString(R.string.cancel), cancelAction ?: {}, ActionStyle.ACCENT)
|
||||
showDialog(context, R.drawable.ic_error, text, null, null, 0, cancelButtonAction, confirmButtonAction)
|
||||
return showDialog(context, R.drawable.ic_error, text, null, null, 0, cancelButtonAction, confirmButtonAction).apply {
|
||||
setOnDismissListener { dismissAction?.invoke() }
|
||||
}
|
||||
}
|
||||
|
||||
fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null, doNotAskAgainAction: (() -> Unit)? = null) {
|
||||
fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null, dismissAction: (() -> Unit)? = null, doNotAskAgainAction: (() -> Unit)? = null): AlertDialog {
|
||||
val confirmButtonAction = Action(context.getString(R.string.confirm), action, ActionStyle.PRIMARY)
|
||||
val cancelButtonAction = Action(context.getString(R.string.cancel), cancelAction ?: {}, ActionStyle.ACCENT)
|
||||
val doNotAskAgain = Action(context.getString(R.string.do_not_ask_again), doNotAskAgainAction ?: {}, ActionStyle.NONE)
|
||||
showDialog(context, R.drawable.ic_error, text, null, null, 0, doNotAskAgain, cancelButtonAction, confirmButtonAction)
|
||||
return showDialog(context, R.drawable.ic_error, text, null, null, 0, doNotAskAgain, cancelButtonAction, confirmButtonAction)
|
||||
}
|
||||
|
||||
fun showUpdateAvailableDialog(context: Context, lastVersion: Int, hideExceptionButtons: Boolean = false) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.content.Intent
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.dialogs.AutoUpdateDialog
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import java.io.File
|
||||
|
||||
@@ -16,11 +17,12 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleUpdateYes(context: Context, intent: Intent) {
|
||||
AutoUpdateDialog.currentDialog?.dismiss()
|
||||
|
||||
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
|
||||
if (version == 0) {
|
||||
return
|
||||
@@ -28,31 +30,19 @@ 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) {
|
||||
AutoUpdateDialog.currentDialog?.dismiss()
|
||||
NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_AVAILABLE)
|
||||
}
|
||||
|
||||
private fun handleUpdateNever(context: Context) {
|
||||
AutoUpdateDialog.currentDialog?.dismiss()
|
||||
Settings.instance.autoUpdate.check = 1
|
||||
Settings.instance.save()
|
||||
|
||||
@@ -70,21 +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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package com.futo.platformplayer
|
||||
|
||||
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
|
||||
@@ -21,6 +24,9 @@ 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
|
||||
}
|
||||
|
||||
private val job = SupervisorJob()
|
||||
@@ -32,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 {
|
||||
@@ -77,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)
|
||||
@@ -186,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()
|
||||
}
|
||||
}
|
||||
@@ -216,12 +240,19 @@ class UpdateDownloadService : Service() {
|
||||
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)
|
||||
}, {})
|
||||
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)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,15 +444,9 @@ fun addressScore(addr: InetAddress): Int {
|
||||
|
||||
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
|
||||
fun <T> RequestBuilder<T>.withMaxSizePx(maxSizePx: Int = 1920): RequestBuilder<T> {
|
||||
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,11 +1299,24 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
navigate(last.first, last.second, false, true);
|
||||
} else {
|
||||
if (_fragVideoDetail.state == VideoDetailFragment.State.CLOSED) {
|
||||
Logger.i(TAG, "Closing activity because _fragVideoDetail.state == closed");
|
||||
finish();
|
||||
} else {
|
||||
//UIDialogs.toast("Grayjay continues in background because of an open video.")
|
||||
if(Settings.instance.playback.isBackgroundPictureInPicture()) {
|
||||
try {
|
||||
_fragVideoDetail._viewDetail?.startPictureInPicture();
|
||||
_fragVideoDetail?.forcePictureInPicture();
|
||||
} catch (ex: Throwable) {
|
||||
} //Fail silently
|
||||
}
|
||||
else
|
||||
moveTaskToBack(false);
|
||||
/*
|
||||
UIDialogs.showConfirmationDialog(this, "There is a video playing, are you sure you want to exit the app?", {
|
||||
finish();
|
||||
})
|
||||
*/
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+318
@@ -0,0 +1,318 @@
|
||||
package com.futo.platformplayer.api.http.server.handlers
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import android.provider.OpenableColumns
|
||||
import com.futo.platformplayer.api.http.server.HttpContext
|
||||
import com.futo.platformplayer.api.http.server.HttpHeaders
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
class HttpContentUriHandler(
|
||||
method: String,
|
||||
path: String,
|
||||
private val contentResolver: ContentResolver,
|
||||
private val uri: Uri,
|
||||
private val explicitContentType: String? = null
|
||||
) : HttpHandler(method, path) {
|
||||
|
||||
override fun handle(httpContext: HttpContext) {
|
||||
val resolver = contentResolver
|
||||
val requestHeaders = httpContext.headers
|
||||
val responseHeaders = this.headers.clone()
|
||||
|
||||
val meta = try {
|
||||
queryMetadata(resolver, uri)
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Failed to query metadata for $uri", e)
|
||||
httpContext.respondCode(404, responseHeaders)
|
||||
return
|
||||
}
|
||||
|
||||
val contentType = explicitContentType
|
||||
?: resolver.getType(uri)
|
||||
?: "application/octet-stream"
|
||||
responseHeaders["Content-Type"] = contentType
|
||||
|
||||
meta.lastModifiedMillis?.let { lastModified ->
|
||||
responseHeaders["Last-Modified"] = httpDateFormat.format(Date(lastModified))
|
||||
|
||||
val ifModifiedSinceHeader = requestHeaders["If-Modified-Since"]
|
||||
if (ifModifiedSinceHeader != null) {
|
||||
val ifModifiedSince = try {
|
||||
httpDateFormat.parse(ifModifiedSinceHeader)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
if (ifModifiedSince != null && lastModified <= ifModifiedSince.time) {
|
||||
httpContext.respondCode(304, responseHeaders)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val safeName = (meta.displayName ?: "content.bin").replace("\"", "\\\"")
|
||||
responseHeaders["Content-Disposition"] = "attachment; filename=\"$safeName\""
|
||||
|
||||
val length = meta.size
|
||||
if (length == null) {
|
||||
Logger.i(TAG, "Streaming $uri with unknown length; Range not supported")
|
||||
responseHeaders.remove("Content-Length")
|
||||
responseHeaders.remove("Content-Range")
|
||||
responseHeaders.remove("Accept-Ranges")
|
||||
|
||||
stream(
|
||||
httpContext = httpContext,
|
||||
resolver = resolver,
|
||||
uri = uri,
|
||||
statusCode = 200,
|
||||
headers = responseHeaders,
|
||||
start = null,
|
||||
length = null
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
responseHeaders["Accept-Ranges"] = "bytes"
|
||||
|
||||
val rangeHeader = requestHeaders["Range"]
|
||||
if (rangeHeader.isNullOrBlank()) {
|
||||
responseHeaders["Content-Length"] = length.toString()
|
||||
Logger.i(TAG, "Sending full content for $uri, length=$length")
|
||||
|
||||
stream(
|
||||
httpContext = httpContext,
|
||||
resolver = resolver,
|
||||
uri = uri,
|
||||
statusCode = 200,
|
||||
headers = responseHeaders,
|
||||
start = 0L,
|
||||
length = length
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val range = parseRange(rangeHeader, length)
|
||||
if (range == null) {
|
||||
Logger.w(TAG, "Invalid Range '$rangeHeader' for $uri (length=$length)")
|
||||
responseHeaders["Content-Range"] = "bytes */$length"
|
||||
httpContext.respondCode(416, responseHeaders)
|
||||
return
|
||||
}
|
||||
|
||||
val start = range.first
|
||||
val endInclusive = range.last
|
||||
val bytesToSend = endInclusive - start + 1
|
||||
|
||||
responseHeaders["Content-Range"] = "bytes $start-$endInclusive/$length"
|
||||
responseHeaders["Content-Length"] = bytesToSend.toString()
|
||||
Logger.i(TAG, "Sending range $start-$endInclusive (length=$bytesToSend) of $length for $uri")
|
||||
|
||||
stream(
|
||||
httpContext = httpContext,
|
||||
resolver = resolver,
|
||||
uri = uri,
|
||||
statusCode = 206,
|
||||
headers = responseHeaders,
|
||||
start = start,
|
||||
length = bytesToSend
|
||||
)
|
||||
}
|
||||
|
||||
data class ContentMeta(
|
||||
val displayName: String?,
|
||||
val size: Long?,
|
||||
val lastModifiedMillis: Long?
|
||||
)
|
||||
|
||||
private fun queryMetadata(resolver: ContentResolver, uri: Uri): ContentMeta {
|
||||
var displayName: String? = null
|
||||
var size: Long? = null
|
||||
var lastModifiedMillis: Long? = null
|
||||
|
||||
resolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
if (nameIndex != -1 && !cursor.isNull(nameIndex)) {
|
||||
displayName = cursor.getString(nameIndex)
|
||||
}
|
||||
|
||||
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||
if (sizeIndex != -1 && !cursor.isNull(sizeIndex)) {
|
||||
val s = cursor.getLong(sizeIndex)
|
||||
if (s >= 0) size = s // -1 means unknown
|
||||
}
|
||||
|
||||
val dateModifiedIndex = cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED)
|
||||
if (dateModifiedIndex != -1 && !cursor.isNull(dateModifiedIndex)) {
|
||||
val seconds = cursor.getLong(dateModifiedIndex)
|
||||
if (seconds > 0) {
|
||||
lastModifiedMillis = seconds * 1000L
|
||||
}
|
||||
}
|
||||
|
||||
if (lastModifiedMillis == null) {
|
||||
val dateAddedIndex = cursor.getColumnIndex(MediaStore.MediaColumns.DATE_ADDED)
|
||||
if (dateAddedIndex != -1 && !cursor.isNull(dateAddedIndex)) {
|
||||
val seconds = cursor.getLong(dateAddedIndex)
|
||||
if (seconds > 0) {
|
||||
lastModifiedMillis = seconds * 1000L
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (displayName == null) {
|
||||
displayName = uri.lastPathSegment
|
||||
}
|
||||
|
||||
if (size == null) {
|
||||
try {
|
||||
resolver.openAssetFileDescriptor(uri, "r")?.use { afd ->
|
||||
val assetLen = afd.length
|
||||
if (assetLen >= 0) {
|
||||
size = assetLen
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
return ContentMeta(
|
||||
displayName = displayName,
|
||||
size = size,
|
||||
lastModifiedMillis = lastModifiedMillis
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseRange(header: String, totalLength: Long): LongRange? {
|
||||
if (totalLength <= 0L) return null
|
||||
|
||||
val prefix = "bytes="
|
||||
if (!header.startsWith(prefix, ignoreCase = true)) return null
|
||||
|
||||
val spec = header.substring(prefix.length).trim()
|
||||
if (spec.isEmpty()) return null
|
||||
|
||||
if (spec.contains(",")) return null
|
||||
|
||||
val dashIndex = spec.indexOf('-')
|
||||
if (dashIndex < 0) return null
|
||||
|
||||
val startPart = spec.substring(0, dashIndex).trim()
|
||||
val endPart = spec.substring(dashIndex + 1).trim()
|
||||
|
||||
return when {
|
||||
startPart.isNotEmpty() -> {
|
||||
val start = startPart.toLongOrNull() ?: return null
|
||||
if (start < 0 || start >= totalLength) return null
|
||||
|
||||
val end = if (endPart.isNotEmpty()) {
|
||||
val rawEnd = endPart.toLongOrNull() ?: return null
|
||||
if (rawEnd < start) return null
|
||||
rawEnd.coerceAtMost(totalLength - 1)
|
||||
} else {
|
||||
totalLength - 1
|
||||
}
|
||||
|
||||
start..end
|
||||
}
|
||||
|
||||
endPart.isNotEmpty() -> {
|
||||
val suffixLen = endPart.toLongOrNull() ?: return null
|
||||
if (suffixLen <= 0L) return null
|
||||
|
||||
if (suffixLen >= totalLength) {
|
||||
0L..(totalLength - 1)
|
||||
} else {
|
||||
val start = totalLength - suffixLen
|
||||
val end = totalLength - 1
|
||||
start..end
|
||||
}
|
||||
}
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun stream(httpContext: HttpContext, resolver: ContentResolver, uri: Uri, statusCode: Int, headers: HttpHeaders, start: Long?, length: Long?) {
|
||||
try {
|
||||
val input = resolver.openInputStream(uri)
|
||||
if (input == null) {
|
||||
Logger.w(TAG, "Content not found: $uri")
|
||||
httpContext.respondCode(404, headers)
|
||||
return
|
||||
}
|
||||
|
||||
input.use { inputStream ->
|
||||
httpContext.respond(statusCode, headers) { outputStream ->
|
||||
try {
|
||||
val offset = start ?: 0L
|
||||
if (offset > 0L) {
|
||||
skipFully(inputStream, offset)
|
||||
}
|
||||
copyStream(inputStream, outputStream, length)
|
||||
outputStream.flush()
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Error while streaming $uri (start=$start, length=$length)", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: FileNotFoundException) {
|
||||
Logger.w(TAG, "Content not found: $uri", e)
|
||||
httpContext.respondCode(404, headers)
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Failed to open stream for $uri", e)
|
||||
httpContext.respondCode(500, headers)
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyStream(input: InputStream, output: OutputStream, limit: Long?) {
|
||||
val buffer = ByteArray(8192)
|
||||
if (limit == null) {
|
||||
while (true) {
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
output.write(buffer, 0, read)
|
||||
}
|
||||
} else {
|
||||
var remaining = limit
|
||||
while (remaining > 0L) {
|
||||
val toRead = remaining.coerceAtMost(buffer.size.toLong()).toInt()
|
||||
val read = input.read(buffer, 0, toRead)
|
||||
if (read < 0) break
|
||||
output.write(buffer, 0, read)
|
||||
remaining -= read.toLong()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun skipFully(input: InputStream, bytesToSkip: Long) {
|
||||
var remaining = bytesToSkip
|
||||
while (remaining > 0L) {
|
||||
val skipped = input.skip(remaining)
|
||||
if (skipped <= 0L) {
|
||||
val b = input.read()
|
||||
if (b == -1) break
|
||||
remaining -= 1L
|
||||
} else {
|
||||
remaining -= skipped
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "HttpContentUriHandler"
|
||||
|
||||
private val httpDateFormat = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss 'GMT'", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("GMT")
|
||||
}
|
||||
}
|
||||
}
|
||||
+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;
|
||||
}
|
||||
|
||||
+2
-2
@@ -73,10 +73,10 @@ open class LocalVideoDetails(
|
||||
override val video: IVideoSourceDescriptor = (if(mimeType?.startsWith("audio/") ?: false)
|
||||
(LocalVideoUnMuxedSourceDescriptor(
|
||||
arrayOf(),
|
||||
arrayOf(LocalAudioContentSource(url, mimeType ?: "", name))
|
||||
arrayOf(LocalAudioContentSource(url, mimeType ?: "", name, duration))
|
||||
))
|
||||
else (LocalVideoMuxedSourceDescriptor(
|
||||
LocalVideoContentSource(url, mimeType ?: "", name)
|
||||
LocalVideoContentSource(url, mimeType ?: "", name, duration)
|
||||
))
|
||||
);
|
||||
override val preview: ISerializedVideoSourceDescriptor? = null;
|
||||
|
||||
+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 =
|
||||
|
||||
+2
-2
@@ -23,10 +23,10 @@ class LocalAudioContentSource : IAudioSource {
|
||||
|
||||
var contentUrl: String;
|
||||
|
||||
constructor(contentUrl: String, mime: String, name: String? = null) {
|
||||
constructor(contentUrl: String, mime: String, name: String? = null, duration: Long = 0) {
|
||||
this.name = name ?: "File";
|
||||
container = mime;
|
||||
duration = 0;
|
||||
this.duration = duration;
|
||||
|
||||
this.contentUrl = contentUrl;
|
||||
}
|
||||
|
||||
+5
-2
@@ -20,14 +20,17 @@ 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) {
|
||||
constructor(contentUrl: String, mime: String, name: String? = null, duration: Long = 0) {
|
||||
this.name = name ?: "File";
|
||||
width = 0;
|
||||
height = 0;
|
||||
container = mime;
|
||||
duration = 0;
|
||||
this.duration = duration;
|
||||
this.contentUrl = contentUrl;
|
||||
}
|
||||
}
|
||||
+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
|
||||
@@ -239,7 +257,7 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
|
||||
}
|
||||
|
||||
DeviceConnectionState.Disconnected -> {
|
||||
connectionState = CastConnectionState.CONNECTING
|
||||
connectionState = CastConnectionState.DISCONNECTED
|
||||
onConnectionStateChanged.emit(CastConnectionState.DISCONNECTED)
|
||||
}
|
||||
}
|
||||
@@ -268,4 +286,4 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
|
||||
companion object {
|
||||
private val TAG = "CastingDeviceExp"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.content.Context
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.core.net.toUri
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
@@ -14,6 +15,7 @@ import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.http.server.HttpHeaders
|
||||
import com.futo.platformplayer.api.http.server.ManagedHttpServer
|
||||
import com.futo.platformplayer.api.http.server.handlers.HttpConstantHandler
|
||||
import com.futo.platformplayer.api.http.server.handlers.HttpContentUriHandler
|
||||
import com.futo.platformplayer.api.http.server.handlers.HttpFileHandler
|
||||
import com.futo.platformplayer.api.http.server.handlers.HttpFunctionHandler
|
||||
import com.futo.platformplayer.api.http.server.handlers.HttpProxyHandler
|
||||
@@ -34,8 +36,11 @@ import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManif
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
|
||||
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalAudioContentSource
|
||||
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoContentSource
|
||||
import com.futo.platformplayer.awaitCancelConverted
|
||||
import com.futo.platformplayer.builders.DashBuilder
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.constructs.Event2
|
||||
@@ -78,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
|
||||
@@ -141,6 +147,7 @@ abstract class StateCasting {
|
||||
device.onTimeChanged.clear();
|
||||
device.onVolumeChanged.clear();
|
||||
device.onDurationChanged.clear();
|
||||
device.onMediaItemEnd.clear();
|
||||
ad.disconnect()
|
||||
}
|
||||
|
||||
@@ -155,6 +162,7 @@ abstract class StateCasting {
|
||||
device.onTimeChanged.clear();
|
||||
device.onVolumeChanged.clear();
|
||||
device.onDurationChanged.clear();
|
||||
device.onMediaItemEnd.clear();
|
||||
activeDevice = null;
|
||||
}
|
||||
|
||||
@@ -218,6 +226,9 @@ abstract class StateCasting {
|
||||
device.onTimeChanged.subscribe {
|
||||
invokeInMainScopeIfRequired { onActiveDeviceTimeChanged.emit(it) };
|
||||
};
|
||||
device.onMediaItemEnd.subscribe {
|
||||
invokeInMainScopeIfRequired { onActiveDeviceMediaItemEnd.emit() }
|
||||
}
|
||||
|
||||
try {
|
||||
device.connect();
|
||||
@@ -228,6 +239,7 @@ abstract class StateCasting {
|
||||
device.onTimeChanged.clear();
|
||||
device.onVolumeChanged.clear();
|
||||
device.onDurationChanged.clear();
|
||||
device.onMediaItemEnd.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -235,9 +247,9 @@ abstract class StateCasting {
|
||||
Logger.i(TAG, "Connect to device ${device.name}")
|
||||
}
|
||||
|
||||
fun metadataFromVideo(video: IPlatformVideoDetails): Metadata {
|
||||
fun metadataFromVideo(video: IPlatformVideoDetails, videoThumbnailOverrideUrl: String? = null): Metadata {
|
||||
return Metadata(
|
||||
title = video.name, thumbnailUrl = video.thumbnails.getHQThumbnail()
|
||||
title = video.name, thumbnailUrl = videoThumbnailOverrideUrl ?: video.thumbnails.getHQThumbnail()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -371,6 +383,12 @@ abstract class StateCasting {
|
||||
} else if (audioSource is LocalAudioSource) {
|
||||
Logger.i(TAG, "Casting as local audio");
|
||||
castLocalAudio(video, audioSource, resumePosition, speed);
|
||||
} else if (videoSource is LocalVideoContentSource) {
|
||||
Logger.i(TAG, "Casting as local video");
|
||||
castLocalVideo(contentResolver, video, videoSource, resumePosition, speed);
|
||||
} else if (audioSource is LocalAudioContentSource) {
|
||||
Logger.i(TAG, "Casting as local audio");
|
||||
castLocalAudio(contentResolver, video, audioSource, resumePosition, speed);
|
||||
} else if (videoSource is JSDashManifestRawSource) {
|
||||
Logger.i(TAG, "Casting as JSDashManifestRawSource video");
|
||||
castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, null, null, resumePosition, speed, castId, onLoadingEstimate, onLoading);
|
||||
@@ -461,6 +479,65 @@ abstract class StateCasting {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private fun castLocalVideo(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: LocalVideoContentSource, resumePosition: Double, speed: Double?) : List<String> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
|
||||
val url = getLocalUrl(ad);
|
||||
val id = UUID.randomUUID();
|
||||
val videoPath = "/video-${id}"
|
||||
val videoUrl = url + videoPath;
|
||||
val thumbnailPath = "/thumbnail-${id}"
|
||||
val thumbnailUrl = url + thumbnailPath;
|
||||
val thumbnailContentUrl = video.thumbnails.getHQThumbnail()
|
||||
|
||||
if (thumbnailContentUrl != null) {
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpContentUriHandler("GET", thumbnailPath, contentResolver, thumbnailContentUrl.toUri())
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("cast");
|
||||
}
|
||||
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpContentUriHandler("GET", videoPath, contentResolver, videoSource.contentUrl.toUri())
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("cast");
|
||||
|
||||
Logger.i(TAG, "Casting local video (videoUrl: $videoUrl).");
|
||||
ad.loadVideo("BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video, if (thumbnailContentUrl != null) thumbnailUrl else null));
|
||||
|
||||
return listOf(videoUrl);
|
||||
}
|
||||
|
||||
private fun castLocalAudio(contentResolver: ContentResolver, video: IPlatformVideoDetails, audioSource: LocalAudioContentSource, resumePosition: Double, speed: Double?) : List<String> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
|
||||
val url = getLocalUrl(ad);
|
||||
val id = UUID.randomUUID();
|
||||
val audioPath = "/audio-${id}"
|
||||
val audioUrl = url + audioPath;
|
||||
val thumbnailPath = "/thumbnail-${id}"
|
||||
val thumbnailUrl = url + thumbnailPath;
|
||||
val thumbnailContentUrl = video.thumbnails.getHQThumbnail()
|
||||
|
||||
if (thumbnailContentUrl != null) {
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpContentUriHandler("GET", thumbnailPath, contentResolver, thumbnailContentUrl.toUri())
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("cast");
|
||||
}
|
||||
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpContentUriHandler("GET", audioPath, contentResolver, audioSource.contentUrl.toUri())
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("cast");
|
||||
|
||||
Logger.i(TAG, "Casting local audio (audioUrl: $audioUrl).");
|
||||
ad.loadVideo("BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video, if (thumbnailContentUrl != null) thumbnailUrl else null));
|
||||
|
||||
return listOf(audioUrl);
|
||||
}
|
||||
|
||||
private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double, speed: Double?) : List<String> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
|
||||
@@ -1254,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
|
||||
@@ -1279,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
|
||||
@@ -1319,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
|
||||
@@ -36,6 +37,8 @@ import java.io.InputStream
|
||||
class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
|
||||
companion object {
|
||||
private val TAG = "AutoUpdateDialog";
|
||||
|
||||
var currentDialog: AutoUpdateDialog? = null
|
||||
}
|
||||
|
||||
private lateinit var _buttonNever: Button;
|
||||
@@ -62,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();
|
||||
};
|
||||
|
||||
@@ -77,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);
|
||||
@@ -94,11 +101,13 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
|
||||
}
|
||||
};
|
||||
|
||||
currentDialog = this
|
||||
}
|
||||
|
||||
override fun dismiss() {
|
||||
super.dismiss()
|
||||
InstallReceiver.onReceiveResult.clear();
|
||||
currentDialog = null
|
||||
Logger.i(TAG, "Cleared InstallReceiver.onReceiveResult handler.")
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
-4
@@ -20,6 +20,7 @@ import androidx.lifecycle.lifecycleScope
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import com.bumptech.glide.Glide
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.UISlideOverlays
|
||||
import com.futo.platformplayer.api.media.PlatformID
|
||||
@@ -55,6 +56,7 @@ import com.futo.platformplayer.views.adapters.ChannelViewPagerAdapter
|
||||
import com.futo.platformplayer.views.others.CreatorThumbnail
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
|
||||
import com.futo.platformplayer.views.subscriptions.SubscribeButton
|
||||
import com.futo.platformplayer.withTimestamp
|
||||
import com.futo.polycentric.core.ApiMethods
|
||||
import com.futo.polycentric.core.PolycentricProfile
|
||||
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
|
||||
@@ -198,8 +200,12 @@ class ChannelFragment : MainFragment() {
|
||||
adapter.onContentClicked.subscribe { v, _ ->
|
||||
when (v) {
|
||||
is IPlatformVideo -> {
|
||||
StatePlayer.instance.clearQueue()
|
||||
fragment.navigate<VideoDetailFragment>(v).maximizeVideoDetail()
|
||||
//StatePlayer.instance.clearQueue()
|
||||
if (StatePlayer.instance.hasQueue) {
|
||||
StatePlayer.instance.insertToQueue(v, true);
|
||||
} else {
|
||||
fragment.navigate<VideoDetailFragment>(v).maximizeVideoDetail();
|
||||
}
|
||||
}
|
||||
|
||||
is IPlatformPlaylist -> {
|
||||
@@ -244,7 +250,7 @@ class ChannelFragment : MainFragment() {
|
||||
adapter.onContentUrlClicked.subscribe { url, contentType ->
|
||||
when (contentType) {
|
||||
ContentType.MEDIA -> {
|
||||
StatePlayer.instance.clearQueue()
|
||||
StatePlayer.instance.clearQueue();
|
||||
fragment.navigate<VideoDetailFragment>(url).maximizeVideoDetail()
|
||||
}
|
||||
|
||||
@@ -403,7 +409,7 @@ class ChannelFragment : MainFragment() {
|
||||
_fragment.topBar?.onShown(channel)
|
||||
|
||||
val buttons = arrayListOf(Pair(R.drawable.ic_playlist_add) {
|
||||
UIDialogs.showConfirmationDialog(context,
|
||||
val dialog = UIDialogs.showConfirmationDialog(context,
|
||||
context.getString(R.string.do_you_want_to_convert_channel_channelname_to_a_playlist)
|
||||
.replace("{channelName}", channel.name),
|
||||
{
|
||||
|
||||
+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")
|
||||
|
||||
+68
-14
@@ -33,6 +33,7 @@ import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.compose.ui.text.toLowerCase
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.media3.common.C
|
||||
@@ -723,15 +724,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 +1276,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 +2424,54 @@ 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 }?.distinct() ?: 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();
|
||||
|
||||
|
||||
Log.i(TAG, "Language count: ${allLanguages}");
|
||||
var videoSourceItems = mutableListOf<SlideUpMenuItem>();
|
||||
var selectedLanguage: String? = null;
|
||||
val languageFilters = if(allLanguages.filter { it != null }.count() > 1)
|
||||
SlideUpMenuButtonList(this.context, null, "language_filter", true).apply {
|
||||
var languageFilterLabels = allLanguages.filterNotNull().toList();
|
||||
val english = languageFilterLabels.find { it?.lowercase() == "en" };
|
||||
val originalLanguage = videoSources?.find { it.original == true }?.language;
|
||||
val primaryLanguage = Settings.instance.playback.getPrimaryLanguage();
|
||||
val hasPrimaryLanguage = videoSources?.any { it.language == primaryLanguage } ?: false;
|
||||
|
||||
if(english != null)
|
||||
languageFilterLabels = listOf(english).plus(languageFilterLabels.filter { it != english }).toList();
|
||||
if(primaryLanguage != null && languageFilterLabels.contains(primaryLanguage))
|
||||
languageFilterLabels = listOf(primaryLanguage).plus(languageFilterLabels.filter { it != primaryLanguage }).toList();
|
||||
if(originalLanguage != null)
|
||||
languageFilterLabels = listOf(originalLanguage).plus(languageFilterLabels.filter { it != originalLanguage }).toList();
|
||||
Log.i(TAG, "Language filtesr: ${languageFilterLabels.joinToString(", ")}");
|
||||
selectedLanguage = originalLanguage ?: (if(hasPrimaryLanguage) primaryLanguage else null);
|
||||
setButtons(languageFilterLabels, selectedLanguage);
|
||||
onClick.subscribe { selected ->
|
||||
setSelected(selected);
|
||||
|
||||
videoSourceItems.forEach {
|
||||
val item = it.itemTag;
|
||||
if(item is IVideoSource) {
|
||||
if(item.language == selected)
|
||||
it.visibility = View.VISIBLE;
|
||||
else
|
||||
it.visibility = View.GONE;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else null;
|
||||
|
||||
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()
|
||||
@@ -2528,11 +2577,10 @@ class VideoDetailView : ConstraintLayout {
|
||||
call = { _player.selectAudioTrack(it.bitrate) });
|
||||
}.toList().toTypedArray())
|
||||
else null,
|
||||
|
||||
if(languageFilters != null) languageFilters else null,
|
||||
if(bestVideoSources.isNotEmpty())
|
||||
SlideUpMenuGroup(this.context, context.getString(R.string.video), "video",
|
||||
*bestVideoSources
|
||||
.map {
|
||||
(bestVideoSources.map {
|
||||
val estSize = VideoHelper.estimateSourceSize(it);
|
||||
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
|
||||
SlideUpMenuItem(this.context,
|
||||
@@ -2541,8 +2589,14 @@ class VideoDetailView : ConstraintLayout {
|
||||
if (it.width > 0 && it.height > 0) "${it.width}x${it.height}" else "",
|
||||
(prefix + it.codec.trim()).trim(),
|
||||
tag = it,
|
||||
call = { handleSelectVideoTrack(it) });
|
||||
}.toList().toTypedArray())
|
||||
call = { handleSelectVideoTrack(it) }).apply {
|
||||
videoSourceItems.add(this);
|
||||
if(selectedLanguage != null) {
|
||||
if(it.language != selectedLanguage)
|
||||
this.visibility = View.GONE;
|
||||
}
|
||||
};
|
||||
}).toList())
|
||||
else null,
|
||||
if(bestAudioSources.isNotEmpty())
|
||||
SlideUpMenuGroup(this.context, context.getString(R.string.audio), "audio",
|
||||
|
||||
+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();
|
||||
}
|
||||
|
||||
@@ -344,7 +344,8 @@ class StateLibrary {
|
||||
MediaStore.Video.Media.DISPLAY_NAME,
|
||||
MediaStore.Video.Media.DATE_ADDED,
|
||||
MediaStore.Video.Media.MIME_TYPE,
|
||||
MediaStore.Video.Media.BUCKET_DISPLAY_NAME
|
||||
MediaStore.Video.Media.BUCKET_DISPLAY_NAME,
|
||||
MediaStore.Video.Media.DURATION
|
||||
);
|
||||
val PROJECTION_MEDIA = arrayOf(
|
||||
MediaStore.Audio.Media._ID, //0
|
||||
@@ -487,9 +488,10 @@ class StateLibrary {
|
||||
"";
|
||||
|
||||
|
||||
val albumContentUrl = if(albumId > 0)
|
||||
ContentUris.withAppendedId(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, albumId)?.toString()
|
||||
else null;
|
||||
val albumArtBase = Uri.parse("content://media/external/audio/albumart")
|
||||
val albumContentUrl = if (albumId > 0)
|
||||
ContentUris.withAppendedId(albumArtBase, albumId).toString()
|
||||
else null
|
||||
|
||||
val dateObj = if(date > 0)
|
||||
OffsetDateTime.ofInstant(Instant.ofEpochSecond(date), ZoneOffset.UTC)
|
||||
@@ -515,6 +517,8 @@ class StateLibrary {
|
||||
val date = cursor.getLong(2);
|
||||
val contentType = cursor.getString(3);
|
||||
val category = cursor.getString(4);
|
||||
val durationMs = cursor.getLong(5)
|
||||
val duration = if (durationMs > 0) durationMs / 1000 else -1
|
||||
|
||||
val idLong = id.toLongOrNull();
|
||||
val contentUrl = if(idLong != null )
|
||||
@@ -534,7 +538,7 @@ class StateLibrary {
|
||||
PlatformID("FILE", contentUrl, null, 0, -1),
|
||||
displayName, Thumbnails(arrayOf(
|
||||
Thumbnail(contentUrl, 0)
|
||||
)), authorObj, contentUrl, -1, contentType, dateObj);
|
||||
)), authorObj, contentUrl, duration, contentType, dateObj);
|
||||
}
|
||||
|
||||
private var _instance : StateLibrary? = null;
|
||||
@@ -622,11 +626,12 @@ class Artist {
|
||||
val numTracks = cursor.getInt(2);
|
||||
val numAlbums = cursor.getInt(3);
|
||||
|
||||
val idLong = id.toLongOrNull();
|
||||
val uri = if(idLong != null) ContentUris.withAppendedId(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, idLong) else null;
|
||||
val idLong = id.toLongOrNull()
|
||||
val uri = if (idLong != null)
|
||||
ContentUris.withAppendedId(MediaStore.Audio.Artists.EXTERNAL_CONTENT_URI, idLong)
|
||||
else null
|
||||
|
||||
return Artist(artist, numTracks, numAlbums, null, id, uri?.toString());
|
||||
}
|
||||
return Artist(artist, numTracks, numAlbums, null, id, uri?.toString()) }
|
||||
|
||||
fun getArtist(id: Long): Artist? {
|
||||
val resolver = StateApp.instance.contextOrNull?.contentResolver;
|
||||
@@ -730,9 +735,10 @@ class Album {
|
||||
val numTracks = cursor.getInt(2);
|
||||
val artist = cursor.getString(3);
|
||||
|
||||
val idLong = id.toLongOrNull();
|
||||
val uri = if(idLong != null) ContentUris.withAppendedId(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, idLong) else null;
|
||||
return Album(album, numTracks, artist, id, uri?.toString());
|
||||
val idLong = id.toLongOrNull()
|
||||
val albumArtBase = Uri.parse("content://media/external/audio/albumart")
|
||||
val uri = if (idLong != null) ContentUris.withAppendedId(albumArtBase, idLong) else null
|
||||
return Album(album, numTracks, artist, id, uri?.toString())
|
||||
}
|
||||
|
||||
fun getAlbumTracks(albumId: Long): List<IPlatformVideo> {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.casting.CastConnectionState
|
||||
import com.futo.platformplayer.casting.CastingDevice
|
||||
import com.futo.platformplayer.casting.StateCasting
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
|
||||
@@ -22,18 +23,16 @@ class CastButton : androidx.appcompat.widget.AppCompatImageButton {
|
||||
visibility = View.GONE;
|
||||
}
|
||||
|
||||
StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, _ ->
|
||||
updateCastState();
|
||||
StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { d, _ ->
|
||||
updateCastState(d);
|
||||
};
|
||||
|
||||
updateCastState();
|
||||
updateCastState(StateCasting.instance.activeDevice);
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateCastState() {
|
||||
private fun updateCastState(d: CastingDevice?) {
|
||||
val c = context ?: return;
|
||||
val d = StateCasting.instance.activeDevice;
|
||||
|
||||
val activeColor = ContextCompat.getColor(c, R.color.colorPrimary);
|
||||
val connectingColor = ContextCompat.getColor(c, R.color.gray_c3);
|
||||
val inactiveColor = ContextCompat.getColor(c, R.color.white);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ class VideoListEditorView : FrameLayout {
|
||||
executeDelete()
|
||||
}, cancelAction = {
|
||||
|
||||
}, doNotAskAgainAction = {
|
||||
}, dismissAction = {}, doNotAskAgainAction = {
|
||||
Settings.instance.other.playlistDeleteConfirmation = false
|
||||
Settings.instance.save()
|
||||
})
|
||||
|
||||
+30
-6
@@ -11,6 +11,7 @@ import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.res.ResourcesCompat
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.dp
|
||||
|
||||
class SlideUpMenuButtonList : LinearLayout {
|
||||
private val _root: LinearLayout;
|
||||
@@ -20,10 +21,16 @@ class SlideUpMenuButtonList : LinearLayout {
|
||||
var _activeText: String? = null;
|
||||
val id: String?
|
||||
|
||||
constructor(context: Context, attrs: AttributeSet? = null, id: String? = null): super(context, attrs) {
|
||||
this.id = id
|
||||
val scrollable: Boolean;
|
||||
|
||||
LayoutInflater.from(context).inflate(R.layout.overlay_slide_up_menu_button_list, this, true);
|
||||
constructor(context: Context, attrs: AttributeSet? = null, id: String? = null, scrollable: Boolean = false): super(context, attrs) {
|
||||
this.id = id
|
||||
this.scrollable = scrollable ?: false;
|
||||
|
||||
LayoutInflater.from(context).inflate(
|
||||
if(!scrollable)
|
||||
R.layout.overlay_slide_up_menu_button_list
|
||||
else R.layout.overlay_slide_up_menu_button_list_scrollable, this, true);
|
||||
|
||||
_root = findViewById(R.id.root);
|
||||
}
|
||||
@@ -37,8 +44,9 @@ class SlideUpMenuButtonList : LinearLayout {
|
||||
buttons.clear();
|
||||
for (t in texts) {
|
||||
val button = LinearLayout(context);
|
||||
button.layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT).apply {
|
||||
weight = 1.0f;
|
||||
button.layoutParams = LinearLayout.LayoutParams(if(!scrollable) 0 else LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.MATCH_PARENT).apply {
|
||||
if(!scrollable)
|
||||
weight = 1.0f;
|
||||
marginStart = marginLeft;
|
||||
marginEnd = marginRight;
|
||||
};
|
||||
@@ -49,7 +57,11 @@ class SlideUpMenuButtonList : LinearLayout {
|
||||
onClick.emit(t);
|
||||
};
|
||||
|
||||
button.setPadding(0, 0, 0, 0);
|
||||
val dp8 = 8.dp(resources)
|
||||
if(!scrollable)
|
||||
button.setPadding(0, 0, 0, 0);
|
||||
else
|
||||
button.setPadding(dp8, 0, dp8, 0);
|
||||
|
||||
val text = TextView(context);
|
||||
text.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT);
|
||||
@@ -69,6 +81,18 @@ class SlideUpMenuButtonList : LinearLayout {
|
||||
fun setSelected(text: String) {
|
||||
buttons[_activeText]?.background = ContextCompat.getDrawable(context, R.drawable.background_slide_up_option);
|
||||
buttons[text]?.background = ContextCompat.getDrawable(context, R.drawable.background_slide_up_option_selected);
|
||||
|
||||
|
||||
val dp8 = 8.dp(resources)
|
||||
if(!scrollable) {
|
||||
buttons[text]?.setPadding(0, 0, 0, 0);
|
||||
buttons[_activeText]?.setPadding(0, 0, 0, 0);
|
||||
}
|
||||
else {
|
||||
buttons[text]?.setPadding(dp8, 0, dp8, 0);
|
||||
buttons[_activeText]?.setPadding(dp8, 0, dp8, 0);
|
||||
}
|
||||
|
||||
_activeText = text;
|
||||
}
|
||||
}
|
||||
@@ -489,7 +489,7 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
||||
|
||||
StatePlayer.instance.onQueueChanged.subscribe(this) {
|
||||
CoroutineScope(Dispatchers.Main).launch(Dispatchers.Main) {
|
||||
setLoopVisible(!StatePlayer.instance.hasQueue)
|
||||
//setLoopVisible(!StatePlayer.instance.hasQueue)
|
||||
updateNextPrevious();
|
||||
}
|
||||
}
|
||||
@@ -886,12 +886,12 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
||||
}
|
||||
fun updateLoopVideoUI() {
|
||||
if(StatePlayer.instance.loopVideo) {
|
||||
_control_loop.setImageResource(R.drawable.ic_loop_active);
|
||||
_control_loop_fullscreen.setImageResource(R.drawable.ic_loop_active);
|
||||
_control_loop.setImageResource(R.drawable.ic_repeat_one_active);
|
||||
_control_loop_fullscreen.setImageResource(R.drawable.ic_repeat_one_active);
|
||||
}
|
||||
else {
|
||||
_control_loop.setImageResource(R.drawable.ic_loop);
|
||||
_control_loop_fullscreen.setImageResource(R.drawable.ic_loop);
|
||||
_control_loop.setImageResource(R.drawable.ic_repeat_one);
|
||||
_control_loop_fullscreen.setImageResource(R.drawable.ic_repeat_one);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M472.31,587.69L472.31,407.69L424.62,407.69L424.62,372.31L507.69,372.31L507.69,587.69L472.31,587.69ZM292.31,840L160,707.69L292.31,575.38L320.62,604.15L237.08,687.69L692.31,687.69L692.31,527.69L732.31,527.69L732.31,727.69L237.08,727.69L320.62,811.23L292.31,840ZM227.69,432.31L227.69,232.31L722.92,232.31L639.38,148.77L667.69,120L800,252.31L667.69,384.62L639.38,355.85L722.92,272.31L267.69,272.31L267.69,432.31L227.69,432.31Z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="@color/colorPrimary"
|
||||
android:pathData="M472.31,587.69L472.31,407.69L424.62,407.69L424.62,372.31L507.69,372.31L507.69,587.69L472.31,587.69ZM292.31,840L160,707.69L292.31,575.38L320.62,604.15L237.08,687.69L692.31,687.69L692.31,527.69L732.31,527.69L732.31,727.69L237.08,727.69L320.62,811.23L292.31,840ZM227.69,432.31L227.69,232.31L722.92,232.31L639.38,148.77L667.69,120L800,252.31L667.69,384.62L639.38,355.85L722.92,272.31L267.69,272.31L267.69,432.31L227.69,432.31Z"/>
|
||||
</vector>
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<HorizontalScrollView android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="35dp"
|
||||
android:layout_marginTop="10dp"
|
||||
android:id="@+id/root"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="0dp"
|
||||
android:paddingEnd="0dp">
|
||||
|
||||
</LinearLayout>
|
||||
</HorizontalScrollView>
|
||||
@@ -65,7 +65,7 @@
|
||||
android:scaleType="fitCenter"
|
||||
android:clickable="true"
|
||||
android:padding="12dp"
|
||||
app:srcCompat="@drawable/ic_loop" />
|
||||
app:srcCompat="@drawable/ic_repeat_one" />
|
||||
<ImageButton
|
||||
android:id="@+id/button_settings"
|
||||
android:layout_width="50dp"
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
android:scaleType="fitCenter"
|
||||
android:clickable="true"
|
||||
android:padding="12dp"
|
||||
app:srcCompat="@drawable/ic_loop" />
|
||||
app:srcCompat="@drawable/ic_repeat_one" />
|
||||
<ImageButton
|
||||
android:id="@+id/button_settings"
|
||||
android:layout_width="50dp"
|
||||
|
||||
@@ -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