mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-29 11:03:01 +02:00
Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8437825dd1 | |||
| 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'
|
androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0'
|
||||||
|
|
||||||
//Rust casting SDK
|
//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
|
// Polycentricandroid includes this
|
||||||
exclude group: 'net.java.dev.jna'
|
exclude group: 'net.java.dev.jna'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -261,5 +261,12 @@
|
|||||||
android:name=".UpdateActionReceiver"
|
android:name=".UpdateActionReceiver"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".activities.InstallUpdateActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:theme="@style/Theme.App.TransparentNoUi"
|
||||||
|
android:excludeFromRecents="true"
|
||||||
|
android:finishOnTaskLaunch="true" />
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -415,6 +415,8 @@ class VideoUrlSource {
|
|||||||
this.url = obj.url;
|
this.url = obj.url;
|
||||||
if(obj.requestModifier)
|
if(obj.requestModifier)
|
||||||
this.requestModifier = obj.requestModifier;
|
this.requestModifier = obj.requestModifier;
|
||||||
|
this.language = obj?.language;
|
||||||
|
this.original = obj?.original;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
class VideoUrlWidevineSource extends VideoUrlSource {
|
class VideoUrlWidevineSource extends VideoUrlSource {
|
||||||
@@ -512,6 +514,8 @@ class HLSSource {
|
|||||||
this.language = obj.language;
|
this.language = obj.language;
|
||||||
if(obj.requestModifier)
|
if(obj.requestModifier)
|
||||||
this.requestModifier = obj.requestModifier;
|
this.requestModifier = obj.requestModifier;
|
||||||
|
this.language = obj?.language;
|
||||||
|
this.original = obj?.original;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
class DashSource {
|
class DashSource {
|
||||||
@@ -525,6 +529,8 @@ class DashSource {
|
|||||||
this.language = obj.language;
|
this.language = obj.language;
|
||||||
if(obj.requestModifier)
|
if(obj.requestModifier)
|
||||||
this.requestModifier = obj.requestModifier;
|
this.requestModifier = obj.requestModifier;
|
||||||
|
this.language = obj?.language;
|
||||||
|
this.original = obj?.original;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
class DashWidevineSource extends DashSource {
|
class DashWidevineSource extends DashSource {
|
||||||
@@ -550,6 +556,7 @@ class DashManifestRawSource {
|
|||||||
this.language = obj.language ?? Language.UNKNOWN;
|
this.language = obj.language ?? Language.UNKNOWN;
|
||||||
if(obj.requestModifier)
|
if(obj.requestModifier)
|
||||||
this.requestModifier = obj.requestModifier;
|
this.requestModifier = obj.requestModifier;
|
||||||
|
this.original = obj?.original;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -387,7 +387,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
@DropdownFieldOptionsId(R.array.audio_languages)
|
@DropdownFieldOptionsId(R.array.audio_languages)
|
||||||
var primaryLanguage: Int = 0;
|
var primaryLanguage: Int = 0;
|
||||||
|
|
||||||
fun getPrimaryLanguage(context: Context): String? {
|
fun getPrimaryLanguage(context: Context? = null): String? {
|
||||||
return when(primaryLanguage) {
|
return when(primaryLanguage) {
|
||||||
0 -> "en";
|
0 -> "en";
|
||||||
1 -> "es";
|
1 -> "es";
|
||||||
@@ -875,9 +875,9 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
@DropdownFieldOptionsId(R.array.auto_update_when_array)
|
@DropdownFieldOptionsId(R.array.auto_update_when_array)
|
||||||
var check: Int = 0;
|
var check: Int = 0;
|
||||||
|
|
||||||
@FormField(R.string.background_download, FieldForm.DROPDOWN, R.string.configure_if_background_download_should_be_used, 1)
|
@FormField(R.string.background_download, FieldForm.TOGGLE, R.string.configure_if_background_download_should_be_used, 1)
|
||||||
@DropdownFieldOptionsId(R.array.background_download)
|
//@DropdownFieldOptionsId(R.array.background_download)
|
||||||
var backgroundDownload: Int = 0;
|
var shouldBackgroundDownload: Boolean = false;
|
||||||
|
|
||||||
@FormField(R.string.download_when, FieldForm.DROPDOWN, R.string.configure_when_updates_should_be_downloaded, 2)
|
@FormField(R.string.download_when, FieldForm.DROPDOWN, R.string.configure_when_updates_should_be_downloaded, 2)
|
||||||
@DropdownFieldOptionsId(R.array.when_download)
|
@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 confirmButtonAction = Action(context.getString(R.string.confirm), action, ActionStyle.PRIMARY)
|
||||||
val cancelButtonAction = Action(context.getString(R.string.cancel), cancelAction ?: {}, ActionStyle.ACCENT)
|
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 confirmButtonAction = Action(context.getString(R.string.confirm), action, ActionStyle.PRIMARY)
|
||||||
val cancelButtonAction = Action(context.getString(R.string.cancel), cancelAction ?: {}, ActionStyle.ACCENT)
|
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)
|
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) {
|
fun showUpdateAvailableDialog(context: Context, lastVersion: Int, hideExceptionButtons: Boolean = false) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import android.content.ContentResolver
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.util.Log
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
@@ -74,6 +75,8 @@ import kotlinx.coroutines.withContext
|
|||||||
import java.io.ByteArrayInputStream
|
import java.io.ByteArrayInputStream
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.SettingsFragment
|
import com.futo.platformplayer.fragment.mainactivity.main.SettingsFragment
|
||||||
|
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuButtonList
|
||||||
|
import kotlin.collections.toList
|
||||||
|
|
||||||
class UISlideOverlays {
|
class UISlideOverlays {
|
||||||
companion object {
|
companion object {
|
||||||
@@ -573,6 +576,51 @@ class UISlideOverlays {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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();
|
||||||
|
var videoSourceItems = mutableListOf<SlideUpMenuItem>();
|
||||||
|
var selectedLanguage: String? = null;
|
||||||
|
val languageFilters = if(allLanguages.filter { it != null }.count() > 1)
|
||||||
|
SlideUpMenuButtonList(container.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;
|
||||||
|
|
||||||
|
if(languageFilters != null) items.add(languageFilters)
|
||||||
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.video), videoSources,
|
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.video), videoSources,
|
||||||
listOf((if (audioSources != null) listOf(SlideUpMenuItem(
|
listOf((if (audioSources != null) listOf(SlideUpMenuItem(
|
||||||
container.context,
|
container.context,
|
||||||
@@ -609,7 +657,13 @@ class UISlideOverlays {
|
|||||||
menu?.setOk(container.context.getString(R.string.download));
|
menu?.setOk(container.context.getString(R.string.download));
|
||||||
},
|
},
|
||||||
invokeParent = false
|
invokeParent = false
|
||||||
)
|
).apply {
|
||||||
|
videoSourceItems.add(this);
|
||||||
|
if(selectedLanguage != null) {
|
||||||
|
if(it.language != selectedLanguage)
|
||||||
|
this.visibility = View.GONE;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is JSDashManifestRawSource -> {
|
is JSDashManifestRawSource -> {
|
||||||
@@ -629,7 +683,13 @@ class UISlideOverlays {
|
|||||||
menu?.setOk(container.context.getString(R.string.download));
|
menu?.setOk(container.context.getString(R.string.download));
|
||||||
},
|
},
|
||||||
invokeParent = false
|
invokeParent = false
|
||||||
)
|
).apply {
|
||||||
|
videoSourceItems.add(this);
|
||||||
|
if(selectedLanguage != null) {
|
||||||
|
if(it.language != selectedLanguage)
|
||||||
|
this.visibility = View.GONE;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is IHLSManifestSource -> {
|
is IHLSManifestSource -> {
|
||||||
@@ -643,7 +703,13 @@ class UISlideOverlays {
|
|||||||
showHlsPicker(video, it, it.url, container)
|
showHlsPicker(video, it, it.url, container)
|
||||||
},
|
},
|
||||||
invokeParent = false
|
invokeParent = false
|
||||||
)
|
).apply {
|
||||||
|
videoSourceItems.add(this);
|
||||||
|
if(selectedLanguage != null) {
|
||||||
|
if(it.language != selectedLanguage)
|
||||||
|
this.visibility = View.GONE;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import android.content.Intent
|
|||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import com.futo.platformplayer.activities.MainActivity
|
import com.futo.platformplayer.activities.MainActivity
|
||||||
|
import com.futo.platformplayer.dialogs.AutoUpdateDialog
|
||||||
import com.futo.platformplayer.states.StateApp
|
import com.futo.platformplayer.states.StateApp
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
@@ -16,11 +17,12 @@ class UpdateActionReceiver : BroadcastReceiver() {
|
|||||||
UpdateNotificationManager.ACTION_UPDATE_NO -> handleUpdateNo(context)
|
UpdateNotificationManager.ACTION_UPDATE_NO -> handleUpdateNo(context)
|
||||||
UpdateNotificationManager.ACTION_UPDATE_NEVER -> handleUpdateNever(context)
|
UpdateNotificationManager.ACTION_UPDATE_NEVER -> handleUpdateNever(context)
|
||||||
UpdateNotificationManager.ACTION_DOWNLOAD_CANCEL -> handleDownloadCancel(context, intent)
|
UpdateNotificationManager.ACTION_DOWNLOAD_CANCEL -> handleDownloadCancel(context, intent)
|
||||||
UpdateNotificationManager.ACTION_INSTALL_NOW -> handleInstallNow(context, intent)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleUpdateYes(context: Context, intent: Intent) {
|
private fun handleUpdateYes(context: Context, intent: Intent) {
|
||||||
|
AutoUpdateDialog.currentDialog?.dismiss()
|
||||||
|
|
||||||
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
|
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
|
||||||
if (version == 0) {
|
if (version == 0) {
|
||||||
return
|
return
|
||||||
@@ -28,31 +30,19 @@ class UpdateActionReceiver : BroadcastReceiver() {
|
|||||||
|
|
||||||
NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_AVAILABLE)
|
NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_AVAILABLE)
|
||||||
|
|
||||||
if (Settings.instance.autoUpdate.backgroundDownload == 1) {
|
val serviceIntent = Intent(context, UpdateDownloadService::class.java).apply {
|
||||||
val serviceIntent = Intent(context, UpdateDownloadService::class.java).apply {
|
putExtra(UpdateDownloadService.EXTRA_VERSION, version)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
ContextCompat.startForegroundService(context, serviceIntent)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleUpdateNo(context: Context) {
|
private fun handleUpdateNo(context: Context) {
|
||||||
|
AutoUpdateDialog.currentDialog?.dismiss()
|
||||||
NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_AVAILABLE)
|
NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_AVAILABLE)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleUpdateNever(context: Context) {
|
private fun handleUpdateNever(context: Context) {
|
||||||
|
AutoUpdateDialog.currentDialog?.dismiss()
|
||||||
Settings.instance.autoUpdate.check = 1
|
Settings.instance.autoUpdate.check = 1
|
||||||
Settings.instance.save()
|
Settings.instance.save()
|
||||||
|
|
||||||
@@ -70,21 +60,4 @@ class UpdateActionReceiver : BroadcastReceiver() {
|
|||||||
|
|
||||||
NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_DOWNLOADING)
|
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
|
package com.futo.platformplayer
|
||||||
|
|
||||||
|
import android.app.Dialog
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
|
import android.os.SystemClock
|
||||||
|
import com.futo.platformplayer.UIDialogs.ActionStyle
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.states.StateApp
|
import com.futo.platformplayer.states.StateApp
|
||||||
import com.futo.platformplayer.states.StateUpdate
|
import com.futo.platformplayer.states.StateUpdate
|
||||||
@@ -21,6 +24,9 @@ class UpdateDownloadService : Service() {
|
|||||||
private const val MAX_RETRIES = 5
|
private const val MAX_RETRIES = 5
|
||||||
private const val INITIAL_BACKOFF_MS = 5_000L
|
private const val INITIAL_BACKOFF_MS = 5_000L
|
||||||
private const val BUFFER_SIZE = 8 * 1024
|
private const val BUFFER_SIZE = 8 * 1024
|
||||||
|
private const val MIN_PROGRESS_UPDATE_INTERVAL_MS = 500L
|
||||||
|
|
||||||
|
var updateDownloadedDialog: Dialog? = null
|
||||||
}
|
}
|
||||||
|
|
||||||
private val job = SupervisorJob()
|
private val job = SupervisorJob()
|
||||||
@@ -32,6 +38,8 @@ class UpdateDownloadService : Service() {
|
|||||||
@Volatile
|
@Volatile
|
||||||
private var cancelRequested: Boolean = false
|
private var cancelRequested: Boolean = false
|
||||||
|
|
||||||
|
private var lastProgressUpdateElapsedMs: Long = 0L
|
||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? = null
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
@@ -77,6 +85,16 @@ class UpdateDownloadService : Service() {
|
|||||||
job.cancel()
|
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) {
|
private suspend fun downloadApk(version: Int) {
|
||||||
val apkFile = StateUpdate.getApkFile(this, version)
|
val apkFile = StateUpdate.getApkFile(this, version)
|
||||||
val partialFile = StateUpdate.getPartialApkFile(this, version)
|
val partialFile = StateUpdate.getPartialApkFile(this, version)
|
||||||
@@ -186,12 +204,18 @@ class UpdateDownloadService : Service() {
|
|||||||
progress > 100 -> 100
|
progress > 100 -> 100
|
||||||
else -> progress
|
else -> progress
|
||||||
}
|
}
|
||||||
UpdateNotificationManager.updateDownloadProgress(this, version, safeProgress, false)
|
throttledUpdateDownloadProgress(version, safeProgress, indeterminate = false)
|
||||||
}
|
}
|
||||||
} else {
|
} 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()
|
output.flush()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -216,12 +240,19 @@ class UpdateDownloadService : Service() {
|
|||||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||||
StateApp.withContext { ctx ->
|
StateApp.withContext { ctx ->
|
||||||
try {
|
try {
|
||||||
UIDialogs.showConfirmationDialog(ctx, "Update downloaded, press confirm to install", {
|
updateDownloadedDialog = UIDialogs.showDialog(ctx, R.drawable.foreground,
|
||||||
UpdateNotificationManager.cancelAll(ctx)
|
"Update downloaded",
|
||||||
UpdateInstaller.startInstall(ctx, apkFile)
|
"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) {
|
} catch (t: Throwable) {
|
||||||
Logger.w(TAG, "Failed to show in-app update downloaded dialog", t)
|
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.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageInstaller
|
import android.content.pm.PackageInstaller
|
||||||
|
import android.graphics.drawable.Animatable
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
|
import android.view.View
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.receivers.InstallReceiver
|
import com.futo.platformplayer.receivers.InstallReceiver
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -17,20 +19,24 @@ import kotlinx.coroutines.withContext
|
|||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
|
import com.futo.platformplayer.dialogs.AutoUpdateDialog
|
||||||
|
import com.futo.platformplayer.states.StateApp
|
||||||
|
|
||||||
object UpdateInstaller {
|
object UpdateInstaller {
|
||||||
private const val TAG = "UpdateInstaller"
|
private const val TAG = "UpdateInstaller"
|
||||||
|
|
||||||
@SuppressLint("RequestInstallPackagesPolicy")
|
@SuppressLint("RequestInstallPackagesPolicy")
|
||||||
fun startInstall(context: Context, apkFile: File) {
|
fun startInstall(context: Context, version: Int, apkFile: File) {
|
||||||
if (!apkFile.exists()) {
|
if (!apkFile.exists()) {
|
||||||
Logger.w(TAG, "APK file does not exist: ${apkFile.absolutePath}")
|
Logger.w(TAG, "APK file does not exist: ${apkFile.absolutePath}")
|
||||||
UIDialogs.toast(context, "Update file missing")
|
UIDialogs.toast(context, "Update file missing")
|
||||||
|
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, "APK file does not exist.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (BuildConfig.IS_PLAYSTORE_BUILD) {
|
if (BuildConfig.IS_PLAYSTORE_BUILD) {
|
||||||
UIDialogs.toast(context, "Updates are managed by the Play Store")
|
UIDialogs.toast(context, "Updates are managed by the Play Store")
|
||||||
|
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, "Updates are managed by the Play Store.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +44,7 @@ object UpdateInstaller {
|
|||||||
val pm = context.packageManager
|
val pm = context.packageManager
|
||||||
if (!pm.canRequestPackageInstalls()) {
|
if (!pm.canRequestPackageInstalls()) {
|
||||||
UIDialogs.toast(context, "Allow this app to install updates, then try again")
|
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 {
|
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
|
||||||
data = "package:${context.packageName}".toUri()
|
data = "package:${context.packageName}".toUri()
|
||||||
@@ -53,8 +60,8 @@ object UpdateInstaller {
|
|||||||
GlobalScope.launch(Dispatchers.IO) {
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
var inputStream: InputStream? = null
|
var inputStream: InputStream? = null
|
||||||
var session: PackageInstaller.Session? = null
|
var session: PackageInstaller.Session? = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
||||||
val packageInstaller: PackageInstaller = context.packageManager.packageInstaller
|
val packageInstaller: PackageInstaller = context.packageManager.packageInstaller
|
||||||
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
|
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
|
||||||
val sessionId = packageInstaller.createSession(params)
|
val sessionId = packageInstaller.createSession(params)
|
||||||
@@ -68,10 +75,17 @@ object UpdateInstaller {
|
|||||||
session.fsync(sessionStream)
|
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 pendingIntent = getBroadcast(context, 0, intent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
|
||||||
val statusReceiver = pendingIntent.intentSender
|
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}")
|
Logger.i(TAG, "Committing install session for ${apkFile.absolutePath}")
|
||||||
session.commit(statusReceiver)
|
session.commit(statusReceiver)
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
@@ -80,10 +94,29 @@ object UpdateInstaller {
|
|||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
UIDialogs.toast(context, "Failed to install update: ${e.message}")
|
UIDialogs.toast(context, "Failed to install update: ${e.message}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, e.message)
|
||||||
} finally {
|
} finally {
|
||||||
session?.close()
|
session?.close()
|
||||||
inputStream?.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.Notification
|
||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
import android.app.PendingIntent.FLAG_MUTABLE
|
import android.app.PendingIntent.FLAG_MUTABLE
|
||||||
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
|
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
import android.app.PendingIntent.getBroadcast
|
import android.app.PendingIntent.getBroadcast
|
||||||
@@ -13,6 +14,7 @@ import android.content.pm.PackageManager
|
|||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.futo.platformplayer.activities.InstallUpdateActivity
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
object UpdateNotificationManager {
|
object UpdateNotificationManager {
|
||||||
@@ -25,6 +27,7 @@ object UpdateNotificationManager {
|
|||||||
const val ACTION_UPDATE_NEVER = "com.futo.platformplayer.UPDATE_NEVER"
|
const val ACTION_UPDATE_NEVER = "com.futo.platformplayer.UPDATE_NEVER"
|
||||||
const val ACTION_DOWNLOAD_CANCEL = "com.futo.platformplayer.UPDATE_CANCEL"
|
const val ACTION_DOWNLOAD_CANCEL = "com.futo.platformplayer.UPDATE_CANCEL"
|
||||||
const val ACTION_INSTALL_NOW = "com.futo.platformplayer.UPDATE_INSTALL"
|
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_VERSION = "version"
|
||||||
const val EXTRA_APK_PATH = "apk_path"
|
const val EXTRA_APK_PATH = "apk_path"
|
||||||
@@ -32,16 +35,55 @@ object UpdateNotificationManager {
|
|||||||
const val NOTIF_ID_AVAILABLE = 2001
|
const val NOTIF_ID_AVAILABLE = 2001
|
||||||
const val NOTIF_ID_DOWNLOADING = 2002
|
const val NOTIF_ID_DOWNLOADING = 2002
|
||||||
const val NOTIF_ID_READY = 2003
|
const val NOTIF_ID_READY = 2003
|
||||||
|
const val NOTIF_ID_INSTALL_FAILED = 2004
|
||||||
|
const val NOTIF_ID_INSTALL_SUCCEEDED = 2005
|
||||||
|
|
||||||
fun ensureChannel(context: Context) {
|
fun ensureChannel(context: Context) {
|
||||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
if (manager.getNotificationChannel(CHANNEL_ID) == null) {
|
if (manager.getNotificationChannel(CHANNEL_ID) == null) {
|
||||||
val channel = NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT)
|
val channel = NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT).apply {
|
||||||
channel.description = CHANNEL_DESCRIPTION
|
description = CHANNEL_DESCRIPTION
|
||||||
|
enableVibration(false)
|
||||||
|
enableLights(false)
|
||||||
|
setSound(null, null)
|
||||||
|
}
|
||||||
manager.createNotificationChannel(channel)
|
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) {
|
fun showUpdateAvailableNotification(context: Context, version: Int) {
|
||||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||||
return
|
return
|
||||||
@@ -70,9 +112,11 @@ object UpdateNotificationManager {
|
|||||||
.setContentText("A new version ($version) is available.")
|
.setContentText("A new version ($version) is available.")
|
||||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.addAction(0, "Download", yesPendingIntent)
|
.setContentIntent(yesPendingIntent)
|
||||||
.addAction(0, "Not now", noPendingIntent)
|
.setSilent(true)
|
||||||
.addAction(0, "Never", neverPendingIntent)
|
.addAction(0, "Never", neverPendingIntent)
|
||||||
|
.addAction(0, "Not now", noPendingIntent)
|
||||||
|
.addAction(0, "Download", yesPendingIntent)
|
||||||
|
|
||||||
NotificationManagerCompat.from(context).notify(NOTIF_ID_AVAILABLE, builder.build())
|
NotificationManagerCompat.from(context).notify(NOTIF_ID_AVAILABLE, builder.build())
|
||||||
}
|
}
|
||||||
@@ -95,8 +139,9 @@ object UpdateNotificationManager {
|
|||||||
.setSmallIcon(R.drawable.foreground)
|
.setSmallIcon(R.drawable.foreground)
|
||||||
.setContentTitle("Downloading update")
|
.setContentTitle("Downloading update")
|
||||||
.setContentText("Downloading version $version")
|
.setContentText("Downloading version $version")
|
||||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
|
.setSilent(true)
|
||||||
.addAction(0, "Cancel", cancelPendingIntent)
|
.addAction(0, "Cancel", cancelPendingIntent)
|
||||||
|
|
||||||
if (indeterminate) {
|
if (indeterminate) {
|
||||||
@@ -123,24 +168,17 @@ object UpdateNotificationManager {
|
|||||||
}
|
}
|
||||||
ensureChannel(context)
|
ensureChannel(context)
|
||||||
|
|
||||||
val installIntent = Intent(context, UpdateActionReceiver::class.java).apply {
|
val installIntent = InstallUpdateActivity.createIntent(context, version, apkFile.absolutePath)
|
||||||
action = ACTION_INSTALL_NOW
|
val installPendingIntent = PendingIntent.getActivity(context, REQUEST_CODE_INSTALL, installIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||||
putExtra(EXTRA_VERSION, version)
|
|
||||||
putExtra(EXTRA_APK_PATH, apkFile.absolutePath)
|
|
||||||
}
|
|
||||||
val installPendingIntent = getBroadcast(
|
|
||||||
context,
|
|
||||||
4,
|
|
||||||
installIntent,
|
|
||||||
FLAG_MUTABLE or FLAG_UPDATE_CURRENT
|
|
||||||
)
|
|
||||||
|
|
||||||
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
.setSmallIcon(R.drawable.foreground)
|
.setSmallIcon(R.drawable.foreground)
|
||||||
.setContentTitle("Update downloaded")
|
.setContentTitle("Update downloaded")
|
||||||
.setContentText("Tap to install version $version.")
|
.setContentText("Tap to install version $version.")
|
||||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setContentIntent(installPendingIntent)
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
|
.setSilent(true)
|
||||||
.addAction(0, "Install", installPendingIntent)
|
.addAction(0, "Install", installPendingIntent)
|
||||||
|
|
||||||
NotificationManagerCompat.from(context).notify(NOTIF_ID_READY, builder.build())
|
NotificationManagerCompat.from(context).notify(NOTIF_ID_READY, builder.build())
|
||||||
@@ -159,13 +197,37 @@ object UpdateNotificationManager {
|
|||||||
.setContentText(error?.message ?: "Unknown error")
|
.setContentText(error?.message ?: "Unknown error")
|
||||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
|
.setSilent(true)
|
||||||
|
|
||||||
NotificationManagerCompat.from(context).notify(NOTIF_ID_READY, builder.build())
|
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) {
|
fun cancelAll(context: Context) {
|
||||||
NotificationManagerCompat.from(context).cancel(NOTIF_ID_AVAILABLE)
|
NotificationManagerCompat.from(context).cancel(NOTIF_ID_AVAILABLE)
|
||||||
NotificationManagerCompat.from(context).cancel(NOTIF_ID_DOWNLOADING)
|
NotificationManagerCompat.from(context).cancel(NOTIF_ID_DOWNLOADING)
|
||||||
NotificationManagerCompat.from(context).cancel(NOTIF_ID_READY)
|
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> Enumeration<T>.toList(): List<T> = Collections.list(this)
|
||||||
|
|
||||||
fun <T> RequestBuilder<T>.withMaxSizePx(maxSizePx: Int = 1920, useCenterCrop: Boolean = false): RequestBuilder<T> {
|
fun <T> RequestBuilder<T>.withMaxSizePx(maxSizePx: Int = 1920): RequestBuilder<T> {
|
||||||
var builder = this
|
return this;
|
||||||
.downsample(DownsampleStrategy.AT_MOST)
|
//.downsample(DownsampleStrategy.AT_MOST)
|
||||||
.override(maxSizePx, maxSizePx)
|
//.override(maxSizePx, maxSizePx)
|
||||||
builder = if (useCenterCrop) {
|
//.centerInside()
|
||||||
builder.centerCrop()
|
|
||||||
} else {
|
|
||||||
builder.fitCenter()
|
|
||||||
}
|
|
||||||
|
|
||||||
return builder
|
|
||||||
}
|
}
|
||||||
@@ -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()
|
sharedPreferences.edit().putBoolean("IsFirstBoot", false).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && Settings.instance.autoUpdate.isAutoUpdateEnabled()) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && Settings.instance.autoUpdate.isAutoUpdateEnabled() && Settings.instance.autoUpdate.shouldBackgroundDownload) {
|
||||||
requestNotificationPermissions("Grayjay uses notifications to inform you when a new app update is available.");
|
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")
|
val submissionStatus = FragmentedStorage.get<StringStorage>("subscriptionSubmissionStatus")
|
||||||
@@ -1299,11 +1299,24 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
navigate(last.first, last.second, false, true);
|
navigate(last.first, last.second, false, true);
|
||||||
} else {
|
} else {
|
||||||
if (_fragVideoDetail.state == VideoDetailFragment.State.CLOSED) {
|
if (_fragVideoDetail.state == VideoDetailFragment.State.CLOSED) {
|
||||||
|
Logger.i(TAG, "Closing activity because _fragVideoDetail.state == closed");
|
||||||
finish();
|
finish();
|
||||||
} else {
|
} 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?", {
|
UIDialogs.showConfirmationDialog(this, "There is a video playing, are you sure you want to exit the app?", {
|
||||||
finish();
|
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 var priority: Boolean = false;
|
||||||
|
|
||||||
|
override val language: String? = null;
|
||||||
|
override val original: Boolean? = false;
|
||||||
|
|
||||||
constructor(url : String) {
|
constructor(url : String) {
|
||||||
this.url = url;
|
this.url = url;
|
||||||
}
|
}
|
||||||
|
|||||||
+3
@@ -12,6 +12,9 @@ class HLSManifestSource : IVideoSource, IHLSManifestSource {
|
|||||||
|
|
||||||
override var priority: Boolean = false;
|
override var priority: Boolean = false;
|
||||||
|
|
||||||
|
override val language: String? = null;
|
||||||
|
override val original: Boolean? = false;
|
||||||
|
|
||||||
constructor(url : String) {
|
constructor(url : String) {
|
||||||
this.url = url;
|
this.url = url;
|
||||||
}
|
}
|
||||||
|
|||||||
+3
@@ -14,6 +14,9 @@ class HLSVariantVideoUrlSource(
|
|||||||
override val priority: Boolean,
|
override val priority: Boolean,
|
||||||
val url: String
|
val url: String
|
||||||
) : IVideoUrlSource {
|
) : IVideoUrlSource {
|
||||||
|
override val language: String? = null;
|
||||||
|
override val original: Boolean? = false;
|
||||||
|
|
||||||
override fun getVideoUrl(): String {
|
override fun getVideoUrl(): String {
|
||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -9,4 +9,6 @@ interface IVideoSource {
|
|||||||
val bitrate : Int?;
|
val bitrate : Int?;
|
||||||
val duration: Long;
|
val duration: Long;
|
||||||
val priority: Boolean;
|
val priority: Boolean;
|
||||||
|
val language: String?;
|
||||||
|
val original: Boolean?;
|
||||||
}
|
}
|
||||||
+4
@@ -16,6 +16,10 @@ class LocalVideoSource : IVideoSource, IStreamMetaDataSource {
|
|||||||
|
|
||||||
override var priority: Boolean = false;
|
override var priority: Boolean = false;
|
||||||
|
|
||||||
|
override val language: String? = null;
|
||||||
|
override val original: Boolean? = false;
|
||||||
|
|
||||||
|
|
||||||
val filePath : String;
|
val filePath : String;
|
||||||
val fileSize : Long;
|
val fileSize : Long;
|
||||||
|
|
||||||
|
|||||||
+3
@@ -19,6 +19,9 @@ open class VideoUrlSource(
|
|||||||
) : IVideoUrlSource, IStreamMetaDataSource {
|
) : IVideoUrlSource, IStreamMetaDataSource {
|
||||||
override var streamMetaData: StreamMetaData? = null;
|
override var streamMetaData: StreamMetaData? = null;
|
||||||
|
|
||||||
|
override val language: String? = null;
|
||||||
|
override val original: Boolean? = false;
|
||||||
|
|
||||||
override fun getVideoUrl() : String {
|
override fun getVideoUrl() : String {
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -73,10 +73,10 @@ open class LocalVideoDetails(
|
|||||||
override val video: IVideoSourceDescriptor = (if(mimeType?.startsWith("audio/") ?: false)
|
override val video: IVideoSourceDescriptor = (if(mimeType?.startsWith("audio/") ?: false)
|
||||||
(LocalVideoUnMuxedSourceDescriptor(
|
(LocalVideoUnMuxedSourceDescriptor(
|
||||||
arrayOf(),
|
arrayOf(),
|
||||||
arrayOf(LocalAudioContentSource(url, mimeType ?: "", name))
|
arrayOf(LocalAudioContentSource(url, mimeType ?: "", name, duration))
|
||||||
))
|
))
|
||||||
else (LocalVideoMuxedSourceDescriptor(
|
else (LocalVideoMuxedSourceDescriptor(
|
||||||
LocalVideoContentSource(url, mimeType ?: "", name)
|
LocalVideoContentSource(url, mimeType ?: "", name, duration)
|
||||||
))
|
))
|
||||||
);
|
);
|
||||||
override val preview: ISerializedVideoSourceDescriptor? = null;
|
override val preview: ISerializedVideoSourceDescriptor? = null;
|
||||||
|
|||||||
+7
@@ -39,6 +39,10 @@ open class JSDashManifestRawSource(
|
|||||||
private val ctx = "DashRawSource"
|
private val ctx = "DashRawSource"
|
||||||
private val cfg = plugin.config
|
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 =
|
override val container: String =
|
||||||
_obj.getOrDefault<String>(cfg, "container", ctx, null) ?: "application/dash+xml"
|
_obj.getOrDefault<String>(cfg, "container", ctx, null) ?: "application/dash+xml"
|
||||||
|
|
||||||
@@ -185,6 +189,9 @@ class JSDashManifestMergingRawSource(
|
|||||||
override val priority: Boolean
|
override val priority: Boolean
|
||||||
get() = video.priority;
|
get() = video.priority;
|
||||||
|
|
||||||
|
override val language: String? get() = audio.language
|
||||||
|
override val original: Boolean? get() = audio.original;
|
||||||
|
|
||||||
override fun generateAsync(scope: CoroutineScope): V8Deferred<String?> {
|
override fun generateAsync(scope: CoroutineScope): V8Deferred<String?> {
|
||||||
val videoDashDef = video.generateAsync(scope);
|
val videoDashDef = video.generateAsync(scope);
|
||||||
val audioDashDef = audio.generateAsync(scope);
|
val audioDashDef = audio.generateAsync(scope);
|
||||||
|
|||||||
+6
@@ -21,6 +21,9 @@ class JSDashManifestSource : IVideoUrlSource, IDashManifestSource, JSSource {
|
|||||||
|
|
||||||
override var priority: Boolean = false;
|
override var priority: Boolean = false;
|
||||||
|
|
||||||
|
override val language: String?;
|
||||||
|
override val original: Boolean?;
|
||||||
|
|
||||||
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH, plugin, obj) {
|
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH, plugin, obj) {
|
||||||
val contextName = "DashSource";
|
val contextName = "DashSource";
|
||||||
val config = plugin.config;
|
val config = plugin.config;
|
||||||
@@ -29,6 +32,9 @@ class JSDashManifestSource : IVideoUrlSource, IDashManifestSource, JSSource {
|
|||||||
duration = _obj.getOrThrow(config, "duration", contextName);
|
duration = _obj.getOrThrow(config, "duration", contextName);
|
||||||
|
|
||||||
priority = obj.getOrNull(config, "priority", contextName) ?: false;
|
priority = obj.getOrNull(config, "priority", contextName) ?: false;
|
||||||
|
|
||||||
|
language = obj.getOrNull(config, "language", contextName);
|
||||||
|
original = obj.getOrNull(config, "original", contextName);
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getVideoUrl(): String {
|
override fun getVideoUrl(): String {
|
||||||
|
|||||||
+6
@@ -28,6 +28,9 @@ class JSDashManifestWidevineSource : IVideoUrlSource, IDashManifestSource,
|
|||||||
override val licenseUri: String
|
override val licenseUri: String
|
||||||
override val hasLicenseRequestExecutor: Boolean
|
override val hasLicenseRequestExecutor: Boolean
|
||||||
|
|
||||||
|
override val language: String?;
|
||||||
|
override val original: Boolean?;
|
||||||
|
|
||||||
@Suppress("ConvertSecondaryConstructorToPrimary")
|
@Suppress("ConvertSecondaryConstructorToPrimary")
|
||||||
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH, plugin, obj) {
|
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH, plugin, obj) {
|
||||||
val contextName = "DashWidevineSource"
|
val contextName = "DashWidevineSource"
|
||||||
@@ -40,6 +43,9 @@ class JSDashManifestWidevineSource : IVideoUrlSource, IDashManifestSource,
|
|||||||
|
|
||||||
licenseUri = _obj.getOrThrow(config, "licenseUri", contextName)
|
licenseUri = _obj.getOrThrow(config, "licenseUri", contextName)
|
||||||
hasLicenseRequestExecutor = obj.has("getLicenseRequestExecutor")
|
hasLicenseRequestExecutor = obj.has("getLicenseRequestExecutor")
|
||||||
|
|
||||||
|
language = _obj.getOrNull(config, "language", contextName);
|
||||||
|
original = _obj.getOrNull(config, "original", contextName);
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getLicenseRequestExecutor(): JSRequestExecutor? {
|
override fun getLicenseRequestExecutor(): JSRequestExecutor? {
|
||||||
|
|||||||
+6
@@ -21,6 +21,9 @@ class JSHLSManifestSource : IHLSManifestSource, JSSource {
|
|||||||
|
|
||||||
override var priority: Boolean = false;
|
override var priority: Boolean = false;
|
||||||
|
|
||||||
|
override val language: String?;
|
||||||
|
override val original: Boolean?;
|
||||||
|
|
||||||
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_HLS, plugin, obj) {
|
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_HLS, plugin, obj) {
|
||||||
val contextName = "HLSSource";
|
val contextName = "HLSSource";
|
||||||
val config = plugin.config;
|
val config = plugin.config;
|
||||||
@@ -30,5 +33,8 @@ class JSHLSManifestSource : IHLSManifestSource, JSSource {
|
|||||||
duration = _obj.getOrThrow<Int>(config, "duration", contextName).toLong();
|
duration = _obj.getOrThrow<Int>(config, "duration", contextName).toLong();
|
||||||
|
|
||||||
priority = obj.getOrNull(config, "priority", contextName) ?: false;
|
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 =
|
override var priority: Boolean =
|
||||||
_obj.getOrDefault<Boolean>(cfg, "priority", ctx, false) ?: false
|
_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 getVideoUrl(): String = url
|
||||||
|
|
||||||
override fun toString(): String =
|
override fun toString(): String =
|
||||||
|
|||||||
+2
-2
@@ -23,10 +23,10 @@ class LocalAudioContentSource : IAudioSource {
|
|||||||
|
|
||||||
var contentUrl: String;
|
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";
|
this.name = name ?: "File";
|
||||||
container = mime;
|
container = mime;
|
||||||
duration = 0;
|
this.duration = duration;
|
||||||
|
|
||||||
this.contentUrl = contentUrl;
|
this.contentUrl = contentUrl;
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-2
@@ -20,14 +20,17 @@ class LocalVideoContentSource: IVideoSource {
|
|||||||
override val duration: Long;
|
override val duration: Long;
|
||||||
override val priority: Boolean = false;
|
override val priority: Boolean = false;
|
||||||
|
|
||||||
|
override val language: String? = null;
|
||||||
|
override val original: Boolean? = false;
|
||||||
|
|
||||||
var contentUrl: String;
|
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";
|
this.name = name ?: "File";
|
||||||
width = 0;
|
width = 0;
|
||||||
height = 0;
|
height = 0;
|
||||||
container = mime;
|
container = mime;
|
||||||
duration = 0;
|
this.duration = duration;
|
||||||
this.contentUrl = contentUrl;
|
this.contentUrl = contentUrl;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+3
@@ -20,6 +20,9 @@ class LocalVideoFileSource: IVideoSource {
|
|||||||
override val duration: Long;
|
override val duration: Long;
|
||||||
override val priority: Boolean = false;
|
override val priority: Boolean = false;
|
||||||
|
|
||||||
|
override val language: String? = null;
|
||||||
|
override val original: Boolean? = null;
|
||||||
|
|
||||||
var file: File;
|
var file: File;
|
||||||
|
|
||||||
constructor(file: File) {
|
constructor(file: File) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.futo.platformplayer.casting
|
package com.futo.platformplayer.casting
|
||||||
|
|
||||||
|
import com.futo.platformplayer.constructs.Event0
|
||||||
import com.futo.platformplayer.constructs.Event1
|
import com.futo.platformplayer.constructs.Event1
|
||||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||||
import org.fcast.sender_sdk.Metadata
|
import org.fcast.sender_sdk.Metadata
|
||||||
@@ -16,6 +17,7 @@ abstract class CastingDevice {
|
|||||||
abstract val onDurationChanged: Event1<Double>
|
abstract val onDurationChanged: Event1<Double>
|
||||||
abstract val onVolumeChanged: Event1<Double>
|
abstract val onVolumeChanged: Event1<Double>
|
||||||
abstract val onSpeedChanged: Event1<Double>
|
abstract val onSpeedChanged: Event1<Double>
|
||||||
|
abstract val onMediaItemEnd: Event0
|
||||||
abstract var connectionState: CastConnectionState
|
abstract var connectionState: CastConnectionState
|
||||||
abstract val protocolType: CastProtocolType
|
abstract val protocolType: CastProtocolType
|
||||||
abstract var isPlaying: Boolean
|
abstract var isPlaying: Boolean
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ package com.futo.platformplayer.casting
|
|||||||
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import com.futo.platformplayer.BuildConfig
|
import com.futo.platformplayer.BuildConfig
|
||||||
|
import com.futo.platformplayer.constructs.Event0
|
||||||
import com.futo.platformplayer.constructs.Event1
|
import com.futo.platformplayer.constructs.Event1
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||||
|
import com.futo.polycentric.core.Event
|
||||||
import org.fcast.sender_sdk.ApplicationInfo
|
import org.fcast.sender_sdk.ApplicationInfo
|
||||||
import org.fcast.sender_sdk.GenericKeyEvent
|
import org.fcast.sender_sdk.KeyEvent
|
||||||
import org.fcast.sender_sdk.GenericMediaEvent
|
import org.fcast.sender_sdk.MediaEvent
|
||||||
import org.fcast.sender_sdk.PlaybackState
|
import org.fcast.sender_sdk.PlaybackState
|
||||||
import org.fcast.sender_sdk.Source
|
import org.fcast.sender_sdk.Source
|
||||||
import java.net.InetAddress
|
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.DeviceEventHandler as RsDeviceEventHandler;
|
||||||
import org.fcast.sender_sdk.DeviceConnectionState
|
import org.fcast.sender_sdk.DeviceConnectionState
|
||||||
import org.fcast.sender_sdk.DeviceFeature
|
import org.fcast.sender_sdk.DeviceFeature
|
||||||
|
import org.fcast.sender_sdk.EventSubscription
|
||||||
import org.fcast.sender_sdk.IpAddr
|
import org.fcast.sender_sdk.IpAddr
|
||||||
import org.fcast.sender_sdk.LoadRequest
|
import org.fcast.sender_sdk.LoadRequest
|
||||||
|
import org.fcast.sender_sdk.MediaItemEventType
|
||||||
import org.fcast.sender_sdk.Metadata
|
import org.fcast.sender_sdk.Metadata
|
||||||
import org.fcast.sender_sdk.ProtocolType
|
import org.fcast.sender_sdk.ProtocolType
|
||||||
import org.fcast.sender_sdk.urlFormatIpAddr
|
import org.fcast.sender_sdk.urlFormatIpAddr
|
||||||
@@ -63,6 +67,7 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
|
|||||||
var onDurationChanged = Event1<Double>()
|
var onDurationChanged = Event1<Double>()
|
||||||
var onVolumeChanged = Event1<Double>()
|
var onVolumeChanged = Event1<Double>()
|
||||||
var onSpeedChanged = Event1<Double>()
|
var onSpeedChanged = Event1<Double>()
|
||||||
|
var onMediaItemEnd = Event0()
|
||||||
|
|
||||||
override fun connectionStateChanged(state: DeviceConnectionState) {
|
override fun connectionStateChanged(state: DeviceConnectionState) {
|
||||||
onConnectionStateChanged.emit(state)
|
onConnectionStateChanged.emit(state)
|
||||||
@@ -92,12 +97,14 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
|
|||||||
// TODO
|
// TODO
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun keyEvent(event: GenericKeyEvent) {
|
override fun keyEvent(event: KeyEvent) {
|
||||||
// Unreachable
|
// Unreachable
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun mediaEvent(event: GenericMediaEvent) {
|
override fun mediaEvent(event: MediaEvent) {
|
||||||
// Unreachable
|
if (event.type == MediaItemEventType.END) {
|
||||||
|
onMediaItemEnd.emit()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun playbackError(message: String) {
|
override fun playbackError(message: String) {
|
||||||
@@ -127,6 +134,8 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
|
|||||||
get() = eventHandler.onVolumeChanged
|
get() = eventHandler.onVolumeChanged
|
||||||
override val onSpeedChanged: Event1<Double>
|
override val onSpeedChanged: Event1<Double>
|
||||||
get() = eventHandler.onSpeedChanged
|
get() = eventHandler.onSpeedChanged
|
||||||
|
override val onMediaItemEnd: Event0
|
||||||
|
get() = eventHandler.onMediaItemEnd
|
||||||
|
|
||||||
override fun resumePlayback() = device.resumePlayback()
|
override fun resumePlayback() = device.resumePlayback()
|
||||||
override fun pausePlayback() = device.pausePlayback()
|
override fun pausePlayback() = device.pausePlayback()
|
||||||
@@ -181,7 +190,8 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
|
|||||||
resumePosition = resumePosition,
|
resumePosition = resumePosition,
|
||||||
speed = speed,
|
speed = speed,
|
||||||
volume = volume,
|
volume = volume,
|
||||||
metadata = metadata
|
metadata = metadata,
|
||||||
|
requestHeaders = null,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -200,6 +210,7 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
|
|||||||
speed = speed,
|
speed = speed,
|
||||||
volume = volume,
|
volume = volume,
|
||||||
metadata = metadata,
|
metadata = metadata,
|
||||||
|
requestHeaders = null,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -227,6 +238,13 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
|
|||||||
eventHandler.onConnectionStateChanged.subscribe { newState ->
|
eventHandler.onConnectionStateChanged.subscribe { newState ->
|
||||||
when (newState) {
|
when (newState) {
|
||||||
is DeviceConnectionState.Connected -> {
|
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)
|
usedRemoteAddress = ipAddrToInetAddress(newState.usedRemoteAddr)
|
||||||
localAddress = ipAddrToInetAddress(newState.localAddr)
|
localAddress = ipAddrToInetAddress(newState.localAddr)
|
||||||
connectionState = CastConnectionState.CONNECTED
|
connectionState = CastConnectionState.CONNECTED
|
||||||
@@ -239,7 +257,7 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
DeviceConnectionState.Disconnected -> {
|
DeviceConnectionState.Disconnected -> {
|
||||||
connectionState = CastConnectionState.CONNECTING
|
connectionState = CastConnectionState.DISCONNECTED
|
||||||
onConnectionStateChanged.emit(CastConnectionState.DISCONNECTED)
|
onConnectionStateChanged.emit(CastConnectionState.DISCONNECTED)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -268,4 +286,4 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
|
|||||||
companion object {
|
companion object {
|
||||||
private val TAG = "CastingDeviceExp"
|
private val TAG = "CastingDeviceExp"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.futo.platformplayer.casting
|
package com.futo.platformplayer.casting
|
||||||
|
|
||||||
|
import com.futo.platformplayer.constructs.Event0
|
||||||
import com.futo.platformplayer.constructs.Event1
|
import com.futo.platformplayer.constructs.Event1
|
||||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||||
import kotlinx.serialization.KSerializer
|
import kotlinx.serialization.KSerializer
|
||||||
@@ -181,6 +182,7 @@ class CastingDeviceLegacyWrapper(val inner: CastingDeviceLegacy) : CastingDevice
|
|||||||
override val onDurationChanged: Event1<Double> get() = inner.onDurationChanged
|
override val onDurationChanged: Event1<Double> get() = inner.onDurationChanged
|
||||||
override val onVolumeChanged: Event1<Double> get() = inner.onVolumeChanged
|
override val onVolumeChanged: Event1<Double> get() = inner.onVolumeChanged
|
||||||
override val onSpeedChanged: Event1<Double> get() = inner.onSpeedChanged
|
override val onSpeedChanged: Event1<Double> get() = inner.onSpeedChanged
|
||||||
|
override val onMediaItemEnd: Event0 = Event0()
|
||||||
override var connectionState: CastConnectionState
|
override var connectionState: CastConnectionState
|
||||||
get() = inner.connectionState
|
get() = inner.connectionState
|
||||||
set(_) = Unit
|
set(_) = Unit
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import android.content.Context
|
|||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.core.net.toUri
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.Settings
|
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.HttpHeaders
|
||||||
import com.futo.platformplayer.api.http.server.ManagedHttpServer
|
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.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.HttpFileHandler
|
||||||
import com.futo.platformplayer.api.http.server.handlers.HttpFunctionHandler
|
import com.futo.platformplayer.api.http.server.handlers.HttpFunctionHandler
|
||||||
import com.futo.platformplayer.api.http.server.handlers.HttpProxyHandler
|
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.JSDashManifestRawAudioSource
|
||||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource
|
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.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.awaitCancelConverted
|
||||||
import com.futo.platformplayer.builders.DashBuilder
|
import com.futo.platformplayer.builders.DashBuilder
|
||||||
|
import com.futo.platformplayer.constructs.Event0
|
||||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||||
import com.futo.platformplayer.constructs.Event1
|
import com.futo.platformplayer.constructs.Event1
|
||||||
import com.futo.platformplayer.constructs.Event2
|
import com.futo.platformplayer.constructs.Event2
|
||||||
@@ -78,6 +83,7 @@ abstract class StateCasting {
|
|||||||
val onActiveDeviceTimeChanged = Event1<Double>();
|
val onActiveDeviceTimeChanged = Event1<Double>();
|
||||||
val onActiveDeviceDurationChanged = Event1<Double>();
|
val onActiveDeviceDurationChanged = Event1<Double>();
|
||||||
val onActiveDeviceVolumeChanged = Event1<Double>();
|
val onActiveDeviceVolumeChanged = Event1<Double>();
|
||||||
|
val onActiveDeviceMediaItemEnd = Event0()
|
||||||
var activeDevice: CastingDevice? = null;
|
var activeDevice: CastingDevice? = null;
|
||||||
private var _videoExecutor: JSRequestExecutor? = null
|
private var _videoExecutor: JSRequestExecutor? = null
|
||||||
private var _audioExecutor: JSRequestExecutor? = null
|
private var _audioExecutor: JSRequestExecutor? = null
|
||||||
@@ -141,6 +147,7 @@ abstract class StateCasting {
|
|||||||
device.onTimeChanged.clear();
|
device.onTimeChanged.clear();
|
||||||
device.onVolumeChanged.clear();
|
device.onVolumeChanged.clear();
|
||||||
device.onDurationChanged.clear();
|
device.onDurationChanged.clear();
|
||||||
|
device.onMediaItemEnd.clear();
|
||||||
ad.disconnect()
|
ad.disconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,6 +162,7 @@ abstract class StateCasting {
|
|||||||
device.onTimeChanged.clear();
|
device.onTimeChanged.clear();
|
||||||
device.onVolumeChanged.clear();
|
device.onVolumeChanged.clear();
|
||||||
device.onDurationChanged.clear();
|
device.onDurationChanged.clear();
|
||||||
|
device.onMediaItemEnd.clear();
|
||||||
activeDevice = null;
|
activeDevice = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,6 +226,9 @@ abstract class StateCasting {
|
|||||||
device.onTimeChanged.subscribe {
|
device.onTimeChanged.subscribe {
|
||||||
invokeInMainScopeIfRequired { onActiveDeviceTimeChanged.emit(it) };
|
invokeInMainScopeIfRequired { onActiveDeviceTimeChanged.emit(it) };
|
||||||
};
|
};
|
||||||
|
device.onMediaItemEnd.subscribe {
|
||||||
|
invokeInMainScopeIfRequired { onActiveDeviceMediaItemEnd.emit() }
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
device.connect();
|
device.connect();
|
||||||
@@ -228,6 +239,7 @@ abstract class StateCasting {
|
|||||||
device.onTimeChanged.clear();
|
device.onTimeChanged.clear();
|
||||||
device.onVolumeChanged.clear();
|
device.onVolumeChanged.clear();
|
||||||
device.onDurationChanged.clear();
|
device.onDurationChanged.clear();
|
||||||
|
device.onMediaItemEnd.clear();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,9 +247,9 @@ abstract class StateCasting {
|
|||||||
Logger.i(TAG, "Connect to device ${device.name}")
|
Logger.i(TAG, "Connect to device ${device.name}")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun metadataFromVideo(video: IPlatformVideoDetails): Metadata {
|
fun metadataFromVideo(video: IPlatformVideoDetails, videoThumbnailOverrideUrl: String? = null): Metadata {
|
||||||
return 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) {
|
} else if (audioSource is LocalAudioSource) {
|
||||||
Logger.i(TAG, "Casting as local audio");
|
Logger.i(TAG, "Casting as local audio");
|
||||||
castLocalAudio(video, audioSource, resumePosition, speed);
|
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) {
|
} else if (videoSource is JSDashManifestRawSource) {
|
||||||
Logger.i(TAG, "Casting as JSDashManifestRawSource video");
|
Logger.i(TAG, "Casting as JSDashManifestRawSource video");
|
||||||
castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, null, null, resumePosition, speed, castId, onLoadingEstimate, onLoading);
|
castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, null, null, resumePosition, speed, castId, onLoadingEstimate, onLoading);
|
||||||
@@ -461,6 +479,65 @@ abstract class StateCasting {
|
|||||||
}
|
}
|
||||||
return true;
|
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> {
|
private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double, speed: Double?) : List<String> {
|
||||||
val ad = activeDevice ?: return listOf();
|
val ad = activeDevice ?: return listOf();
|
||||||
|
|
||||||
@@ -1254,8 +1331,14 @@ abstract class StateCasting {
|
|||||||
return emptyList()
|
return emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var hasAudioInDash = false
|
||||||
for (representation in representationRegex.findAll(dashContent)) {
|
for (representation in representationRegex.findAll(dashContent)) {
|
||||||
val mediaType = representation.groups[1]?.value ?: throw Exception("Media type should be found")
|
val mediaType = representation.groups[1]?.value ?: throw Exception("Media type should be found")
|
||||||
|
|
||||||
|
if (mediaType.startsWith("audio/")) {
|
||||||
|
hasAudioInDash = true
|
||||||
|
}
|
||||||
|
|
||||||
dashContent = mediaInitializationRegex.replace(dashContent) {
|
dashContent = mediaInitializationRegex.replace(dashContent) {
|
||||||
if (it.range.first < representation.range.first || it.range.last > representation.range.last) {
|
if (it.range.first < representation.range.first || it.range.last > representation.range.last) {
|
||||||
return@replace it.value
|
return@replace it.value
|
||||||
@@ -1279,16 +1362,20 @@ abstract class StateCasting {
|
|||||||
throw Exception("Audio source without request executor not supported")
|
throw Exception("Audio source without request executor not supported")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (audioSource != null && audioSource.hasRequestExecutor) {
|
if (videoSource != null && videoSource.hasRequestExecutor) {
|
||||||
val oldExecutor = _audioExecutor;
|
val oldVideoExecutor = _videoExecutor
|
||||||
oldExecutor?.closeAsync();
|
oldVideoExecutor?.closeAsync()
|
||||||
_audioExecutor = audioSource.getRequestExecutor()
|
_videoExecutor = videoSource.getRequestExecutor()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (videoSource != null && videoSource.hasRequestExecutor) {
|
if (audioSource != null) {
|
||||||
val oldExecutor = _videoExecutor;
|
val oldExecutor = _audioExecutor
|
||||||
oldExecutor?.closeAsync();
|
oldExecutor?.closeAsync()
|
||||||
_videoExecutor = videoSource.getRequestExecutor()
|
_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
|
//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
|
}.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
).withTag("castDashRaw");
|
).withTag("castDashRaw");
|
||||||
}
|
}
|
||||||
if (audioSource != null) {
|
if (audioSource != null || (audioSource == null && hasAudioInDash)) {
|
||||||
_castServer.addHandlerWithAllowAllOptions(
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
HttpFunctionHandler("GET", audioPath) { httpContext ->
|
HttpFunctionHandler("GET", audioPath) { httpContext ->
|
||||||
val originalUrl = httpContext.query["url"]?.let { URLDecoder.decode(it, "UTF-8") } ?: return@HttpFunctionHandler
|
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.Settings
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.UpdateDownloadService
|
import com.futo.platformplayer.UpdateDownloadService
|
||||||
|
import com.futo.platformplayer.UpdateNotificationManager
|
||||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||||
import com.futo.platformplayer.copyToOutputStream
|
import com.futo.platformplayer.copyToOutputStream
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
@@ -36,6 +37,8 @@ import java.io.InputStream
|
|||||||
class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
|
class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
|
||||||
companion object {
|
companion object {
|
||||||
private val TAG = "AutoUpdateDialog";
|
private val TAG = "AutoUpdateDialog";
|
||||||
|
|
||||||
|
var currentDialog: AutoUpdateDialog? = null
|
||||||
}
|
}
|
||||||
|
|
||||||
private lateinit var _buttonNever: Button;
|
private lateinit var _buttonNever: Button;
|
||||||
@@ -62,12 +65,14 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
|
|||||||
_buttonShowChangelog = findViewById(R.id.button_show_changelog);
|
_buttonShowChangelog = findViewById(R.id.button_show_changelog);
|
||||||
|
|
||||||
_buttonNever.setOnClickListener {
|
_buttonNever.setOnClickListener {
|
||||||
|
UpdateNotificationManager.cancelAll(context)
|
||||||
Settings.instance.autoUpdate.check = 1;
|
Settings.instance.autoUpdate.check = 1;
|
||||||
Settings.instance.save();
|
Settings.instance.save();
|
||||||
dismiss();
|
dismiss();
|
||||||
};
|
};
|
||||||
|
|
||||||
_buttonClose.setOnClickListener {
|
_buttonClose.setOnClickListener {
|
||||||
|
UpdateNotificationManager.cancelAll(context)
|
||||||
dismiss();
|
dismiss();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -77,11 +82,13 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
_buttonUpdate.setOnClickListener {
|
_buttonUpdate.setOnClickListener {
|
||||||
|
UpdateNotificationManager.cancelAll(context)
|
||||||
|
|
||||||
if (_updating) {
|
if (_updating) {
|
||||||
return@setOnClickListener;
|
return@setOnClickListener;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Settings.instance.autoUpdate.backgroundDownload == 1) {
|
if (Settings.instance.autoUpdate.shouldBackgroundDownload) {
|
||||||
val ctx = context.applicationContext;
|
val ctx = context.applicationContext;
|
||||||
val intent = Intent(ctx, UpdateDownloadService::class.java);
|
val intent = Intent(ctx, UpdateDownloadService::class.java);
|
||||||
intent.putExtra(UpdateDownloadService.EXTRA_VERSION, _maxVersion);
|
intent.putExtra(UpdateDownloadService.EXTRA_VERSION, _maxVersion);
|
||||||
@@ -94,11 +101,13 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
currentDialog = this
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun dismiss() {
|
override fun dismiss() {
|
||||||
super.dismiss()
|
super.dismiss()
|
||||||
InstallReceiver.onReceiveResult.clear();
|
InstallReceiver.onReceiveResult.clear();
|
||||||
|
currentDialog = null
|
||||||
Logger.i(TAG, "Cleared InstallReceiver.onReceiveResult handler.")
|
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.ReturnCode
|
||||||
import com.arthenica.ffmpegkit.StatisticsCallback
|
import com.arthenica.ffmpegkit.StatisticsCallback
|
||||||
import com.futo.platformplayer.Settings
|
import com.futo.platformplayer.Settings
|
||||||
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
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.PlatformID
|
||||||
import com.futo.platformplayer.api.media.models.modifier.IRequestModifier
|
import com.futo.platformplayer.api.media.models.modifier.IRequestModifier
|
||||||
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
|
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.JSDashManifestRawSource
|
||||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
|
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
|
||||||
import com.futo.platformplayer.constructs.Event1
|
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.exceptions.DownloadException
|
||||||
import com.futo.platformplayer.helpers.FileHelper.Companion.sanitizeFileName
|
import com.futo.platformplayer.helpers.FileHelper.Companion.sanitizeFileName
|
||||||
import com.futo.platformplayer.helpers.VideoHelper
|
import com.futo.platformplayer.helpers.VideoHelper
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.others.Language
|
||||||
import com.futo.platformplayer.parsers.HLS
|
import com.futo.platformplayer.parsers.HLS
|
||||||
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
||||||
import com.futo.platformplayer.states.StateDownloads
|
import com.futo.platformplayer.states.StateDownloads
|
||||||
@@ -86,6 +91,9 @@ import kotlin.time.times
|
|||||||
class VideoDownload {
|
class VideoDownload {
|
||||||
var state: State = State.QUEUED;
|
var state: State = State.QUEUED;
|
||||||
|
|
||||||
|
@Contextual
|
||||||
|
@Transient
|
||||||
|
var plugin: IPlatformClient? = null;
|
||||||
var video: SerializedPlatformVideo? = null;
|
var video: SerializedPlatformVideo? = null;
|
||||||
var videoDetails: SerializedPlatformVideoDetails? = null;
|
var videoDetails: SerializedPlatformVideoDetails? = null;
|
||||||
|
|
||||||
@@ -101,6 +109,7 @@ class VideoDownload {
|
|||||||
|
|
||||||
var videoSource: VideoUrlSource?;
|
var videoSource: VideoUrlSource?;
|
||||||
var audioSource: AudioUrlSource?;
|
var audioSource: AudioUrlSource?;
|
||||||
|
var overrideResultAudioSource: IAudioSource? = null;
|
||||||
@Contextual
|
@Contextual
|
||||||
@Transient
|
@Transient
|
||||||
val videoSourceToUse: IVideoSource? get () = if(requiresLiveVideoSource) videoSourceLive as IVideoSource? else videoSource as IVideoSource?;
|
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
|
//Fetch full video object and determine source
|
||||||
if(video != null && videoDetails == null) {
|
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)
|
if(original !is IPlatformVideoDetails)
|
||||||
throw IllegalStateException("Original content is not media?");
|
throw IllegalStateException("Original content is not media?");
|
||||||
|
|
||||||
@@ -437,6 +446,11 @@ class VideoDownload {
|
|||||||
videoFileNameBase = "${videoDetails!!.id.value!!} [${actualVideoSource!!.width}x${actualVideoSource!!.height}]".sanitizeFileName();
|
videoFileNameBase = "${videoDetails!!.id.value!!} [${actualVideoSource!!.width}x${actualVideoSource!!.height}]".sanitizeFileName();
|
||||||
videoFileNameExt = videoContainerToExtension(actualVideoSource!!.container);
|
videoFileNameExt = videoContainerToExtension(actualVideoSource!!.container);
|
||||||
videoFilePath = File(downloadDir, videoFileName!!).absolutePath;
|
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) {
|
if(actualAudioSource != null) {
|
||||||
audioFileNameBase = "${videoDetails!!.id.value!!} [${actualAudioSource!!.language}-${actualAudioSource!!.bitrate}]".sanitizeFileName();
|
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 -> downloadFileSource("Video", client, if (actualVideoSource is JSSource) actualVideoSource else null, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
|
||||||
}
|
}
|
||||||
else if(actualVideoSource is JSDashManifestRawSource) {
|
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);
|
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 -> downloadFileSource("Audio", client, if (actualAudioSource is JSSource) actualAudioSource else null, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
|
||||||
}
|
}
|
||||||
else if(actualAudioSource is JSDashManifestRawAudioSource) {
|
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);
|
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) {
|
private suspend fun combineSegments(context: Context, segmentFiles: List<File>, targetFile: File) = withContext(Dispatchers.IO) {
|
||||||
suspendCancellableCoroutine { continuation ->
|
require(segmentFiles.isNotEmpty()) { "segmentFiles must not be empty" }
|
||||||
val fileList = File(context.cacheDir, "fileList-${UUID.randomUUID()}.txt")
|
|
||||||
fileList.writeText(segmentFiles.joinToString("\n") { "file '${it.absolutePath}'" })
|
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 { _ ->
|
val statisticsCallback = StatisticsCallback { _ ->
|
||||||
//TODO: Show progress?
|
//No callback
|
||||||
}
|
}
|
||||||
|
|
||||||
val executorService = Executors.newSingleThreadExecutor()
|
val executorService = Executors.newSingleThreadExecutor()
|
||||||
val session = FFmpegKit.executeAsync(cmd,
|
|
||||||
{ session ->
|
val session = FFmpegKit.executeAsync(
|
||||||
if (ReturnCode.isSuccess(session.returnCode)) {
|
cmd,
|
||||||
fileList.delete()
|
{ completedSession ->
|
||||||
|
executorService.shutdown()
|
||||||
|
|
||||||
|
if (ReturnCode.isSuccess(completedSession.returnCode)) {
|
||||||
continuation.resumeWith(Result.success(Unit))
|
continuation.resumeWith(Result.success(Unit))
|
||||||
} else {
|
} else {
|
||||||
val errorMessage = if (ReturnCode.isCancel(session.returnCode)) {
|
val errorMessage = if (ReturnCode.isCancel(completedSession.returnCode)) {
|
||||||
"Command cancelled"
|
"Command cancelled"
|
||||||
} else {
|
} 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))
|
continuation.resumeWithException(RuntimeException(errorMessage))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ Logger.v(TAG, it.message) },
|
{ log ->
|
||||||
|
Logger.v(TAG, log.message)
|
||||||
|
},
|
||||||
statisticsCallback,
|
statisticsCallback,
|
||||||
executorService
|
executorService
|
||||||
)
|
)
|
||||||
|
|
||||||
continuation.invokeOnCancellation {
|
continuation.invokeOnCancellation {
|
||||||
session.cancel()
|
session.cancel()
|
||||||
|
executorService.shutdownNow()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -856,14 +890,19 @@ class VideoDownload {
|
|||||||
return downloadedTotalLength
|
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())
|
if(targetFile.exists())
|
||||||
targetFile.delete();
|
targetFile.delete();
|
||||||
|
if(targetFileAudio?.exists() ?: false)
|
||||||
|
targetFileAudio.delete();
|
||||||
|
|
||||||
targetFile.createNewFile();
|
targetFile.createNewFile();
|
||||||
|
targetFileAudio?.createNewFile();
|
||||||
|
|
||||||
val sourceLength: Long?;
|
val sourceLength: Long?;
|
||||||
|
val sourceLengthAudio: Long?;
|
||||||
val fileStream = FileOutputStream(targetFile);
|
val fileStream = FileOutputStream(targetFile);
|
||||||
|
val fileStream2 = if(targetFileAudio != null) FileOutputStream(targetFileAudio) else null;
|
||||||
|
|
||||||
var executor: JSRequestExecutor? = null;
|
var executor: JSRequestExecutor? = null;
|
||||||
try{
|
try{
|
||||||
@@ -874,14 +913,27 @@ class VideoDownload {
|
|||||||
throw IllegalStateException("No manifest after generation");
|
throw IllegalStateException("No manifest after generation");
|
||||||
|
|
||||||
//TODO: Temporary naive assume single-sourced dash
|
//TODO: Temporary naive assume single-sourced dash
|
||||||
val foundTemplate = REGEX_DASH_TEMPLATE.find(manifest);
|
val foundTemplates = REGEX_DASH_TEMPLATE_WITH_MIME.findAll(manifest);
|
||||||
if(foundTemplate == null || foundTemplate.groupValues.size != 3)
|
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?)");
|
throw IllegalStateException("No SegmentTemplate found in manifest (unsupported dash?)");
|
||||||
val foundTemplateUrl = foundTemplate.groupValues[1];
|
val foundTemplateUrl = foundTemplate.groupValues[2];
|
||||||
val foundCues = REGEX_DASH_CUE.findAll(foundTemplate.groupValues[2]);
|
val foundCues = REGEX_DASH_CUE.findAll(foundTemplate.groupValues[3]).toList();
|
||||||
if(foundCues.count() <= 0)
|
if(foundCues.count() <= 0)
|
||||||
throw IllegalStateException("No Cues found in manifest (unsupported dash?)");
|
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)
|
executor = if(source is JSSource && source.hasRequestExecutor)
|
||||||
source.getRequestExecutor();
|
source.getRequestExecutor();
|
||||||
else
|
else
|
||||||
@@ -896,13 +948,17 @@ class VideoDownload {
|
|||||||
Logger.i(TAG, "Download $name Dash, CueCount: " + foundCues.count().toString());
|
Logger.i(TAG, "Download $name Dash, CueCount: " + foundCues.count().toString());
|
||||||
|
|
||||||
var written: Long = 0;
|
var written: Long = 0;
|
||||||
|
var written2: Long = 0;
|
||||||
var indexCounter = 0;
|
var indexCounter = 0;
|
||||||
|
var indexCounter2 = 0;
|
||||||
onProgress(foundCues.count().toLong(), 0, 0);
|
onProgress(foundCues.count().toLong(), 0, 0);
|
||||||
|
val totalCues = foundCues.count().toLong() + (foundCues2?.count()?.toLong() ?: 0)
|
||||||
|
val lastCue = foundCues.lastOrNull();
|
||||||
for(cue in foundCues) {
|
for(cue in foundCues) {
|
||||||
val t = cue.groupValues[1];
|
val t = cue.groupValues[1];
|
||||||
val d = cue.groupValues[2];
|
val d = cue.groupValues[2];
|
||||||
|
|
||||||
|
Logger.i(TAG, "Downloading cue ${indexCounter}")
|
||||||
val url = foundTemplateUrl.replace("\$Number\$", (indexCounter).toString());
|
val url = foundTemplateUrl.replace("\$Number\$", (indexCounter).toString());
|
||||||
val modified = modifier?.modifyRequest(url, mapOf());
|
val modified = modifier?.modifyRequest(url, mapOf());
|
||||||
|
|
||||||
@@ -918,17 +974,60 @@ class VideoDownload {
|
|||||||
speedTracker.addWork(data.size.toLong());
|
speedTracker.addWork(data.size.toLong());
|
||||||
written += data.size;
|
written += data.size;
|
||||||
|
|
||||||
onProgress(foundCues.count().toLong(), indexCounter.toLong(), speedTracker.lastSpeed);
|
onProgress(totalCues, indexCounter.toLong() + indexCounter2.toLong(), speedTracker.lastSpeed);
|
||||||
|
|
||||||
|
|
||||||
indexCounter++;
|
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;
|
sourceLength = written;
|
||||||
|
sourceLengthAudio = written2;
|
||||||
|
|
||||||
Logger.i(TAG, "$name downloadSource Finished");
|
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) {
|
catch(ioex: IOException) {
|
||||||
if(targetFile.exists() ?: false)
|
if(targetFile.exists() ?: false)
|
||||||
targetFile.delete();
|
targetFile.delete();
|
||||||
|
if(targetFileAudio?.exists() ?: false)
|
||||||
|
targetFileAudio.delete();
|
||||||
if(ioex.message?.contains("ENOSPC") ?: false)
|
if(ioex.message?.contains("ENOSPC") ?: false)
|
||||||
throw Exception("Not enough space on device", ioex);
|
throw Exception("Not enough space on device", ioex);
|
||||||
else
|
else
|
||||||
@@ -937,14 +1036,37 @@ class VideoDownload {
|
|||||||
catch(ex: Throwable) {
|
catch(ex: Throwable) {
|
||||||
if(targetFile.exists() ?: false)
|
if(targetFile.exists() ?: false)
|
||||||
targetFile.delete();
|
targetFile.delete();
|
||||||
|
if(targetFileAudio?.exists() ?: false)
|
||||||
|
targetFileAudio.delete();
|
||||||
throw ex;
|
throw ex;
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
fileStream.close();
|
fileStream.close();
|
||||||
|
fileStream2?.close();
|
||||||
executor?.closeAsync()
|
executor?.closeAsync()
|
||||||
}
|
}
|
||||||
|
if(sourceLengthAudio != null && sourceLengthAudio > 0)
|
||||||
|
audioFileSize = sourceLengthAudio
|
||||||
return sourceLength!!;
|
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 {
|
private fun downloadFileSource(name: String, client: ManagedHttpClient, source: JSSource?, videoUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||||
if(targetFile.exists())
|
if(targetFile.exists())
|
||||||
targetFile.delete();
|
targetFile.delete();
|
||||||
@@ -1304,7 +1426,7 @@ class VideoDownload {
|
|||||||
throw IllegalStateException("Expected size [${videoFileSize}], but found ${expectedFile.length()}");
|
throw IllegalStateException("Expected size [${videoFileSize}], but found ${expectedFile.length()}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if(audioSourceToUse != null) {
|
if(audioSourceToUse != null || (videoSourceToUse is IJSDashManifestRawSource)) {
|
||||||
if(audioFilePath == null)
|
if(audioFilePath == null)
|
||||||
throw IllegalStateException("Missing audio file name after download");
|
throw IllegalStateException("Missing audio file name after download");
|
||||||
val expectedFile = File(audioFilePath!!);
|
val expectedFile = File(audioFilePath!!);
|
||||||
@@ -1327,7 +1449,7 @@ class VideoDownload {
|
|||||||
Logger.i(TAG, "VideoDownload Complete [${name}]");
|
Logger.i(TAG, "VideoDownload Complete [${name}]");
|
||||||
val existing = StateDownloads.instance.getCachedVideo(id);
|
val existing = StateDownloads.instance.getCachedVideo(id);
|
||||||
val localVideoSource = videoFilePath?.let { LocalVideoSource.fromSource(videoSourceToUse!!, it, videoFileSize ?: 0, videoOverrideContainer) };
|
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) };
|
val localSubtitleSource = subtitleFilePath?.let { LocalSubtitleSource.fromSource(subtitleSource!!, it) };
|
||||||
|
|
||||||
if(localVideoSource != null && videoSourceToUse != null && videoSourceToUse is IStreamMetaDataSource)
|
if(localVideoSource != null && videoSourceToUse != null && videoSourceToUse is IStreamMetaDataSource)
|
||||||
@@ -1369,6 +1491,10 @@ class VideoDownload {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun cleanup(){
|
||||||
|
cleanupPluginClient()
|
||||||
|
}
|
||||||
|
|
||||||
enum class State {
|
enum class State {
|
||||||
QUEUED,
|
QUEUED,
|
||||||
PREPARING,
|
PREPARING,
|
||||||
@@ -1392,6 +1518,8 @@ class VideoDownload {
|
|||||||
const val GROUP_WATCHLATER= "WatchLater";
|
const val GROUP_WATCHLATER= "WatchLater";
|
||||||
|
|
||||||
val REGEX_DASH_TEMPLATE = Regex("<SegmentTemplate .*?media=\"(.*?)\".*?>(.*?)<\\/SegmentTemplate>", RegexOption.DOT_MATCHES_ALL);
|
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);
|
val REGEX_DASH_CUE = Regex("<S .*?t=\"([0-9]*?)\".*?d=\"([0-9]*?)\".*?\\/>", RegexOption.DOT_MATCHES_ALL);
|
||||||
|
|
||||||
fun videoContainerToExtension(container: String): String? {
|
fun videoContainerToExtension(container: String): String? {
|
||||||
@@ -1411,6 +1539,16 @@ class VideoDownload {
|
|||||||
return "video";//throw IllegalStateException("Unknown container: " + container)
|
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 {
|
fun audioContainerToExtension(container: String): String {
|
||||||
if (container.contains("audio/mp4"))
|
if (container.contains("audio/mp4"))
|
||||||
return "mp4a";
|
return "mp4a";
|
||||||
|
|||||||
+9
-8
@@ -155,15 +155,16 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
|||||||
StateApp.instance.setPrivacyMode(true);
|
StateApp.instance.setPrivacyMode(true);
|
||||||
UIDialogs.appToast("Privacy mode enabled");
|
UIDialogs.appToast("Privacy mode enabled");
|
||||||
|
|
||||||
UIDialogs.showDialog(it.context ?: return@setOnClickListener, R.drawable.incognito, "Privacy Mode",
|
if(Settings.instance.other.showPrivacyModeDialog)
|
||||||
"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.showDialog(it.context ?: return@setOnClickListener, R.drawable.incognito, "Privacy Mode",
|
||||||
UIDialogs.Action("Don't show again", {
|
"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,
|
||||||
Settings.instance.other.showPrivacyModeDialog = false;
|
UIDialogs.Action("Don't show again", {
|
||||||
Settings.instance.save();
|
Settings.instance.other.showPrivacyModeDialog = false;
|
||||||
}, UIDialogs.ActionStyle.NONE),
|
Settings.instance.save();
|
||||||
UIDialogs.Action("Understood", {
|
}, 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 androidx.viewpager2.widget.ViewPager2
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
|
import com.futo.platformplayer.Settings
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.UISlideOverlays
|
import com.futo.platformplayer.UISlideOverlays
|
||||||
import com.futo.platformplayer.api.media.PlatformID
|
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.others.CreatorThumbnail
|
||||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
|
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
|
||||||
import com.futo.platformplayer.views.subscriptions.SubscribeButton
|
import com.futo.platformplayer.views.subscriptions.SubscribeButton
|
||||||
|
import com.futo.platformplayer.withTimestamp
|
||||||
import com.futo.polycentric.core.ApiMethods
|
import com.futo.polycentric.core.ApiMethods
|
||||||
import com.futo.polycentric.core.PolycentricProfile
|
import com.futo.polycentric.core.PolycentricProfile
|
||||||
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
|
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
|
||||||
@@ -198,8 +200,12 @@ class ChannelFragment : MainFragment() {
|
|||||||
adapter.onContentClicked.subscribe { v, _ ->
|
adapter.onContentClicked.subscribe { v, _ ->
|
||||||
when (v) {
|
when (v) {
|
||||||
is IPlatformVideo -> {
|
is IPlatformVideo -> {
|
||||||
StatePlayer.instance.clearQueue()
|
//StatePlayer.instance.clearQueue()
|
||||||
fragment.navigate<VideoDetailFragment>(v).maximizeVideoDetail()
|
if (StatePlayer.instance.hasQueue) {
|
||||||
|
StatePlayer.instance.insertToQueue(v, true);
|
||||||
|
} else {
|
||||||
|
fragment.navigate<VideoDetailFragment>(v).maximizeVideoDetail();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is IPlatformPlaylist -> {
|
is IPlatformPlaylist -> {
|
||||||
@@ -244,7 +250,7 @@ class ChannelFragment : MainFragment() {
|
|||||||
adapter.onContentUrlClicked.subscribe { url, contentType ->
|
adapter.onContentUrlClicked.subscribe { url, contentType ->
|
||||||
when (contentType) {
|
when (contentType) {
|
||||||
ContentType.MEDIA -> {
|
ContentType.MEDIA -> {
|
||||||
StatePlayer.instance.clearQueue()
|
StatePlayer.instance.clearQueue();
|
||||||
fragment.navigate<VideoDetailFragment>(url).maximizeVideoDetail()
|
fragment.navigate<VideoDetailFragment>(url).maximizeVideoDetail()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,7 +409,7 @@ class ChannelFragment : MainFragment() {
|
|||||||
_fragment.topBar?.onShown(channel)
|
_fragment.topBar?.onShown(channel)
|
||||||
|
|
||||||
val buttons = arrayListOf(Pair(R.drawable.ic_playlist_add) {
|
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)
|
context.getString(R.string.do_you_want_to_convert_channel_channelname_to_a_playlist)
|
||||||
.replace("{channelName}", channel.name),
|
.replace("{channelName}", channel.name),
|
||||||
{
|
{
|
||||||
|
|||||||
+1
-1
@@ -55,7 +55,7 @@ class LoginFragment : MainFragment() {
|
|||||||
fun showLogin(config: SourcePluginConfig, callback: ((SourceAuth?) -> Unit)? = null) {
|
fun showLogin(config: SourcePluginConfig, callback: ((SourceAuth?) -> Unit)? = null) {
|
||||||
if(_callback != null) _callback?.invoke(null);
|
if(_callback != null) _callback?.invoke(null);
|
||||||
_callback = callback;
|
_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 _isActive: Boolean = false;
|
||||||
|
|
||||||
private var _viewDetail : VideoDetailView? = null;
|
var _viewDetail : VideoDetailView? = null;
|
||||||
private var _view : SingleViewTouchableMotionLayout? = null;
|
private var _view : SingleViewTouchableMotionLayout? = null;
|
||||||
|
|
||||||
var isFullscreen : Boolean = false;
|
var isFullscreen : Boolean = false;
|
||||||
@@ -372,14 +372,18 @@ class VideoDetailFragment() : MainFragment() {
|
|||||||
onMinimize.emit();
|
onMinimize.emit();
|
||||||
}
|
}
|
||||||
else if (state != State.MAXIMIZED && progress > 0.9) {
|
else if (state != State.MAXIMIZED && progress > 0.9) {
|
||||||
|
state = State.MAXIMIZED;
|
||||||
|
onMaximized.emit();
|
||||||
|
/*
|
||||||
if (_isInitialMaximize) {
|
if (_isInitialMaximize) {
|
||||||
state = State.CLOSED;
|
//state = State.CLOSED; Causes issues? might no longer be needed
|
||||||
_isInitialMaximize = false;
|
_isInitialMaximize = false;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
state = State.MAXIMIZED;
|
state = State.MAXIMIZED;
|
||||||
onMaximized.emit();
|
onMaximized.emit();
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isTransitioning && (progress > 0.6 || progress < 0.4)) {
|
if (isTransitioning && (progress > 0.6 || progress < 0.4)) {
|
||||||
@@ -450,7 +454,8 @@ class VideoDetailFragment() : MainFragment() {
|
|||||||
if (viewDetail.shouldEnterPictureInPicture) {
|
if (viewDetail.shouldEnterPictureInPicture) {
|
||||||
_leavingPiP = false
|
_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();
|
val params = _viewDetail?.getPictureInPictureParams();
|
||||||
if(params != null) {
|
if(params != null) {
|
||||||
Logger.i(TAG, "enterPictureInPictureMode")
|
Logger.i(TAG, "enterPictureInPictureMode")
|
||||||
|
|||||||
+68
-14
@@ -33,6 +33,7 @@ import android.widget.ImageButton
|
|||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
|
import androidx.compose.ui.text.toLowerCase
|
||||||
import androidx.constraintlayout.widget.ConstraintLayout
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.media3.common.C
|
import androidx.media3.common.C
|
||||||
@@ -723,15 +724,17 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
val activeDevice = StateCasting.instance.activeDevice;
|
val activeDevice = StateCasting.instance.activeDevice;
|
||||||
if (activeDevice != null) {
|
if (activeDevice != null) {
|
||||||
handlePlayChanged(it);
|
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) {
|
StateCasting.instance.onActiveDeviceTimeChanged.subscribe(this) {
|
||||||
if (_isCasting) {
|
if (_isCasting) {
|
||||||
setLastPositionMilliseconds((it * 1000.0).toLong(), true);
|
setLastPositionMilliseconds((it * 1000.0).toLong(), true);
|
||||||
@@ -1273,6 +1276,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
StateCasting.instance.onActiveDevicePlayChanged.remove(this);
|
StateCasting.instance.onActiveDevicePlayChanged.remove(this);
|
||||||
StateCasting.instance.onActiveDeviceTimeChanged.remove(this);
|
StateCasting.instance.onActiveDeviceTimeChanged.remove(this);
|
||||||
StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this);
|
StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this);
|
||||||
|
StateCasting.instance.onActiveDeviceMediaItemEnd.remove(this)
|
||||||
StateApp.instance.preventPictureInPicture.remove(this);
|
StateApp.instance.preventPictureInPicture.remove(this);
|
||||||
StatePlayer.instance.onQueueChanged.remove(this);
|
StatePlayer.instance.onQueueChanged.remove(this);
|
||||||
StatePlayer.instance.onVideoChanging.remove(this);
|
StatePlayer.instance.onVideoChanging.remove(this);
|
||||||
@@ -2420,9 +2424,54 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
|
|
||||||
val doDedup = Settings.instance.playback.simplifySources;
|
val doDedup = Settings.instance.playback.simplifySources;
|
||||||
|
|
||||||
val bestVideoSources = if(doDedup) (videoSources?.map { it.height * it.width }
|
val allLanguages = videoSources?.map { it.language }?.distinct() ?: listOf();
|
||||||
?.distinct()
|
val langResCombinations = if(videoSources != null) allLanguages.flatMap {
|
||||||
?.map { x -> VideoHelper.selectBestVideoSource(videoSources.filter { x == it.height * it.width }, -1, FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) }
|
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 }))
|
?.plus(videoSources.filter { it is IHLSManifestSource || it is IDashManifestSource }))
|
||||||
?.distinct()
|
?.distinct()
|
||||||
?.filterNotNull()
|
?.filterNotNull()
|
||||||
@@ -2528,11 +2577,10 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
call = { _player.selectAudioTrack(it.bitrate) });
|
call = { _player.selectAudioTrack(it.bitrate) });
|
||||||
}.toList().toTypedArray())
|
}.toList().toTypedArray())
|
||||||
else null,
|
else null,
|
||||||
|
if(languageFilters != null) languageFilters else null,
|
||||||
if(bestVideoSources.isNotEmpty())
|
if(bestVideoSources.isNotEmpty())
|
||||||
SlideUpMenuGroup(this.context, context.getString(R.string.video), "video",
|
SlideUpMenuGroup(this.context, context.getString(R.string.video), "video",
|
||||||
*bestVideoSources
|
(bestVideoSources.map {
|
||||||
.map {
|
|
||||||
val estSize = VideoHelper.estimateSourceSize(it);
|
val estSize = VideoHelper.estimateSourceSize(it);
|
||||||
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
|
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
|
||||||
SlideUpMenuItem(this.context,
|
SlideUpMenuItem(this.context,
|
||||||
@@ -2541,8 +2589,14 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
if (it.width > 0 && it.height > 0) "${it.width}x${it.height}" else "",
|
if (it.width > 0 && it.height > 0) "${it.width}x${it.height}" else "",
|
||||||
(prefix + it.codec.trim()).trim(),
|
(prefix + it.codec.trim()).trim(),
|
||||||
tag = it,
|
tag = it,
|
||||||
call = { handleSelectVideoTrack(it) });
|
call = { handleSelectVideoTrack(it) }).apply {
|
||||||
}.toList().toTypedArray())
|
videoSourceItems.add(this);
|
||||||
|
if(selectedLanguage != null) {
|
||||||
|
if(it.language != selectedLanguage)
|
||||||
|
this.visibility = View.GONE;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}).toList())
|
||||||
else null,
|
else null,
|
||||||
if(bestAudioSources.isNotEmpty())
|
if(bestAudioSources.isNotEmpty())
|
||||||
SlideUpMenuGroup(this.context, context.getString(R.string.audio), "audio",
|
SlideUpMenuGroup(this.context, context.getString(R.string.audio), "audio",
|
||||||
|
|||||||
+6
-1
@@ -1,5 +1,6 @@
|
|||||||
package com.futo.platformplayer.fragment.mainactivity.topbar
|
package com.futo.platformplayer.fragment.mainactivity.topbar
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
@@ -49,7 +50,11 @@ class GeneralTopBarFragment : TopFragment() {
|
|||||||
} else if (currentMain is PlaylistsFragment || currentMain is PlaylistFragment) {
|
} else if (currentMain is PlaylistsFragment || currentMain is PlaylistFragment) {
|
||||||
navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.PLAYLIST));
|
navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.PLAYLIST));
|
||||||
} else if (currentMain is LibraryFragment) {
|
} 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 {
|
} else {
|
||||||
navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.VIDEO));
|
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: 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 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(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>) : IVideoSource? {
|
fun selectBestVideoSource(sources: Iterable<IVideoSource>, desiredPixelCount : Int, prefContainers : Array<String>, preferredLanguage: String? = null) : IVideoSource? {
|
||||||
val targetVideo = if(desiredPixelCount > 0) {
|
val targetVideo = if(desiredPixelCount > 0) {
|
||||||
sources.toList().minByOrNull { x -> abs(x.height * x.width - desiredPixelCount) };
|
sources.toList().minByOrNull { x -> abs(x.height * x.width - desiredPixelCount) };
|
||||||
} else {
|
} else {
|
||||||
@@ -63,12 +63,34 @@ class VideoHelper {
|
|||||||
val hasPriority = sources.any { it.priority };
|
val hasPriority = sources.any { it.priority };
|
||||||
|
|
||||||
val targetPixelCount = if(targetVideo != null) targetVideo.width * targetVideo.height else desiredPixelCount;
|
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) };
|
sources.filter { it.priority }.sortedBy { x -> abs(x.height * x.width - targetPixelCount) };
|
||||||
} else {
|
} else {
|
||||||
sources.filter { it.height == (targetVideo?.height ?: 0) };
|
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();
|
var bestSource = altSources.firstOrNull();
|
||||||
for (prefContainer in prefContainers) {
|
for (prefContainer in prefContainers) {
|
||||||
val betterSource = altSources.firstOrNull { it.container == prefContainer };
|
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.activities.MainActivity
|
||||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||||
import com.futo.platformplayer.downloads.VideoDownload
|
import com.futo.platformplayer.downloads.VideoDownload
|
||||||
|
import com.futo.platformplayer.engine.exceptions.ScriptReloadRequiredException
|
||||||
import com.futo.platformplayer.exceptions.DownloadException
|
import com.futo.platformplayer.exceptions.DownloadException
|
||||||
import com.futo.platformplayer.getNowDiffMinutes
|
import com.futo.platformplayer.getNowDiffMinutes
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
@@ -169,6 +170,7 @@ class DownloadService : Service() {
|
|||||||
Thread.sleep(500);
|
Thread.sleep(500);
|
||||||
}
|
}
|
||||||
catch(ex: Throwable) {
|
catch(ex: Throwable) {
|
||||||
|
//if(ex is ScriptReloadRequiredException)
|
||||||
Logger.e(TAG, "Download failed", ex);
|
Logger.e(TAG, "Download failed", ex);
|
||||||
if(currentVideo.video == null && currentVideo.videoDetails == null) {
|
if(currentVideo.video == null && currentVideo.videoDetails == null) {
|
||||||
//Corrupt?
|
//Corrupt?
|
||||||
|
|||||||
@@ -573,7 +573,7 @@ class StateApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (Settings.instance.autoUpdate.isAutoUpdateEnabled()) {
|
if (Settings.instance.autoUpdate.isAutoUpdateEnabled()) {
|
||||||
if (Settings.instance.autoUpdate.backgroundDownload == 1) {
|
if (Settings.instance.autoUpdate.shouldBackgroundDownload) {
|
||||||
Logger.i(TAG, "MainApp Started: Initialize [AutoUpdate Background]");
|
Logger.i(TAG, "MainApp Started: Initialize [AutoUpdate Background]");
|
||||||
val constraints = Constraints.Builder()
|
val constraints = Constraints.Builder()
|
||||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ class StateDownloads {
|
|||||||
|
|
||||||
fun removeDownload(download: VideoDownload) {
|
fun removeDownload(download: VideoDownload) {
|
||||||
download.isCancelled = true;
|
download.isCancelled = true;
|
||||||
|
download.cleanup();
|
||||||
_downloading.delete(download);
|
_downloading.delete(download);
|
||||||
onDownloadsChanged.emit();
|
onDownloadsChanged.emit();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -344,7 +344,8 @@ class StateLibrary {
|
|||||||
MediaStore.Video.Media.DISPLAY_NAME,
|
MediaStore.Video.Media.DISPLAY_NAME,
|
||||||
MediaStore.Video.Media.DATE_ADDED,
|
MediaStore.Video.Media.DATE_ADDED,
|
||||||
MediaStore.Video.Media.MIME_TYPE,
|
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(
|
val PROJECTION_MEDIA = arrayOf(
|
||||||
MediaStore.Audio.Media._ID, //0
|
MediaStore.Audio.Media._ID, //0
|
||||||
@@ -487,9 +488,10 @@ class StateLibrary {
|
|||||||
"";
|
"";
|
||||||
|
|
||||||
|
|
||||||
val albumContentUrl = if(albumId > 0)
|
val albumArtBase = Uri.parse("content://media/external/audio/albumart")
|
||||||
ContentUris.withAppendedId(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, albumId)?.toString()
|
val albumContentUrl = if (albumId > 0)
|
||||||
else null;
|
ContentUris.withAppendedId(albumArtBase, albumId).toString()
|
||||||
|
else null
|
||||||
|
|
||||||
val dateObj = if(date > 0)
|
val dateObj = if(date > 0)
|
||||||
OffsetDateTime.ofInstant(Instant.ofEpochSecond(date), ZoneOffset.UTC)
|
OffsetDateTime.ofInstant(Instant.ofEpochSecond(date), ZoneOffset.UTC)
|
||||||
@@ -515,6 +517,8 @@ class StateLibrary {
|
|||||||
val date = cursor.getLong(2);
|
val date = cursor.getLong(2);
|
||||||
val contentType = cursor.getString(3);
|
val contentType = cursor.getString(3);
|
||||||
val category = cursor.getString(4);
|
val category = cursor.getString(4);
|
||||||
|
val durationMs = cursor.getLong(5)
|
||||||
|
val duration = if (durationMs > 0) durationMs / 1000 else -1
|
||||||
|
|
||||||
val idLong = id.toLongOrNull();
|
val idLong = id.toLongOrNull();
|
||||||
val contentUrl = if(idLong != null )
|
val contentUrl = if(idLong != null )
|
||||||
@@ -534,7 +538,7 @@ class StateLibrary {
|
|||||||
PlatformID("FILE", contentUrl, null, 0, -1),
|
PlatformID("FILE", contentUrl, null, 0, -1),
|
||||||
displayName, Thumbnails(arrayOf(
|
displayName, Thumbnails(arrayOf(
|
||||||
Thumbnail(contentUrl, 0)
|
Thumbnail(contentUrl, 0)
|
||||||
)), authorObj, contentUrl, -1, contentType, dateObj);
|
)), authorObj, contentUrl, duration, contentType, dateObj);
|
||||||
}
|
}
|
||||||
|
|
||||||
private var _instance : StateLibrary? = null;
|
private var _instance : StateLibrary? = null;
|
||||||
@@ -622,11 +626,12 @@ class Artist {
|
|||||||
val numTracks = cursor.getInt(2);
|
val numTracks = cursor.getInt(2);
|
||||||
val numAlbums = cursor.getInt(3);
|
val numAlbums = cursor.getInt(3);
|
||||||
|
|
||||||
val idLong = id.toLongOrNull();
|
val idLong = id.toLongOrNull()
|
||||||
val uri = if(idLong != null) ContentUris.withAppendedId(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, idLong) else null;
|
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? {
|
fun getArtist(id: Long): Artist? {
|
||||||
val resolver = StateApp.instance.contextOrNull?.contentResolver;
|
val resolver = StateApp.instance.contextOrNull?.contentResolver;
|
||||||
@@ -730,9 +735,10 @@ class Album {
|
|||||||
val numTracks = cursor.getInt(2);
|
val numTracks = cursor.getInt(2);
|
||||||
val artist = cursor.getString(3);
|
val artist = cursor.getString(3);
|
||||||
|
|
||||||
val idLong = id.toLongOrNull();
|
val idLong = id.toLongOrNull()
|
||||||
val uri = if(idLong != null) ContentUris.withAppendedId(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, idLong) else null;
|
val albumArtBase = Uri.parse("content://media/external/audio/albumart")
|
||||||
return Album(album, numTracks, artist, id, uri?.toString());
|
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> {
|
fun getAlbumTracks(albumId: Long): List<IPlatformVideo> {
|
||||||
|
|||||||
@@ -169,6 +169,9 @@ class StatePlugins {
|
|||||||
return false;
|
return false;
|
||||||
|
|
||||||
LoginFragment.showLogin(config) {//LoginActivity.showLogin(context, config) {
|
LoginFragment.showLogin(config) {//LoginActivity.showLogin(context, config) {
|
||||||
|
|
||||||
|
if(it == null)
|
||||||
|
return@showLogin;
|
||||||
try {
|
try {
|
||||||
StatePlugins.instance.setPluginAuth(config.id, it);
|
StatePlugins.instance.setPluginAuth(config.id, it);
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import com.futo.platformplayer.R
|
|||||||
import com.futo.platformplayer.Settings
|
import com.futo.platformplayer.Settings
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.casting.CastConnectionState
|
import com.futo.platformplayer.casting.CastConnectionState
|
||||||
|
import com.futo.platformplayer.casting.CastingDevice
|
||||||
import com.futo.platformplayer.casting.StateCasting
|
import com.futo.platformplayer.casting.StateCasting
|
||||||
import com.futo.platformplayer.constructs.Event1
|
import com.futo.platformplayer.constructs.Event1
|
||||||
|
|
||||||
@@ -22,18 +23,16 @@ class CastButton : androidx.appcompat.widget.AppCompatImageButton {
|
|||||||
visibility = View.GONE;
|
visibility = View.GONE;
|
||||||
}
|
}
|
||||||
|
|
||||||
StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, _ ->
|
StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { d, _ ->
|
||||||
updateCastState();
|
updateCastState(d);
|
||||||
};
|
};
|
||||||
|
|
||||||
updateCastState();
|
updateCastState(StateCasting.instance.activeDevice);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateCastState() {
|
private fun updateCastState(d: CastingDevice?) {
|
||||||
val c = context ?: return;
|
val c = context ?: return;
|
||||||
val d = StateCasting.instance.activeDevice;
|
|
||||||
|
|
||||||
val activeColor = ContextCompat.getColor(c, R.color.colorPrimary);
|
val activeColor = ContextCompat.getColor(c, R.color.colorPrimary);
|
||||||
val connectingColor = ContextCompat.getColor(c, R.color.gray_c3);
|
val connectingColor = ContextCompat.getColor(c, R.color.gray_c3);
|
||||||
val inactiveColor = ContextCompat.getColor(c, R.color.white);
|
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.downloads.VideoDownload
|
||||||
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.services.DownloadService
|
||||||
import com.futo.platformplayer.states.StateDownloads
|
import com.futo.platformplayer.states.StateDownloads
|
||||||
import com.futo.platformplayer.views.others.ProgressBar
|
import com.futo.platformplayer.views.others.ProgressBar
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -32,6 +33,7 @@ class ActiveDownloadItem: LinearLayout {
|
|||||||
private val _videoState: TextView;
|
private val _videoState: TextView;
|
||||||
|
|
||||||
private val _videoCancel: TextView;
|
private val _videoCancel: TextView;
|
||||||
|
private val _videoRetry: TextView;
|
||||||
|
|
||||||
private val _scope: CoroutineScope;
|
private val _scope: CoroutineScope;
|
||||||
|
|
||||||
@@ -51,13 +53,14 @@ class ActiveDownloadItem: LinearLayout {
|
|||||||
_videoSpeed = findViewById(R.id.download_video_speed);
|
_videoSpeed = findViewById(R.id.download_video_speed);
|
||||||
|
|
||||||
_videoCancel = findViewById(R.id.download_cancel);
|
_videoCancel = findViewById(R.id.download_cancel);
|
||||||
|
_videoRetry = findViewById(R.id.download_retry);
|
||||||
|
|
||||||
_videoName.text = download.name;
|
_videoName.text = download.name;
|
||||||
_videoDuration.text = download.videoEither.duration.toHumanTime(false);
|
_videoDuration.text = download.videoEither.duration.toHumanTime(false);
|
||||||
_videoAuthor.text = download.videoEither.author.name;
|
_videoAuthor.text = download.videoEither.author.name;
|
||||||
|
|
||||||
_videoState.setOnClickListener {
|
_videoState.setOnClickListener {
|
||||||
UIDialogs.toast(context, _videoState.text.toString(), false);
|
UIDialogs.appToast(_videoState.text.toString(), false);
|
||||||
}
|
}
|
||||||
|
|
||||||
Glide.with(_videoImage)
|
Glide.with(_videoImage)
|
||||||
@@ -72,6 +75,12 @@ class ActiveDownloadItem: LinearLayout {
|
|||||||
StateDownloads.instance.removeDownload(_download);
|
StateDownloads.instance.removeDownload(_download);
|
||||||
StateDownloads.instance.preventPlaylistDownload(_download);
|
StateDownloads.instance.preventPlaylistDownload(_download);
|
||||||
};
|
};
|
||||||
|
_videoRetry.setOnClickListener {
|
||||||
|
download.changeState(VideoDownload.State.QUEUED);
|
||||||
|
DownloadService.getOrCreateService(context) {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_download.onProgressChanged.subscribe(this) {
|
_download.onProgressChanged.subscribe(this) {
|
||||||
_scope.launch(Dispatchers.Main) {
|
_scope.launch(Dispatchers.Main) {
|
||||||
@@ -122,16 +131,19 @@ class ActiveDownloadItem: LinearLayout {
|
|||||||
VideoDownload.State.DOWNLOADING -> {
|
VideoDownload.State.DOWNLOADING -> {
|
||||||
_videoBar.visibility = VISIBLE;
|
_videoBar.visibility = VISIBLE;
|
||||||
_videoSpeed.visibility = VISIBLE;
|
_videoSpeed.visibility = VISIBLE;
|
||||||
|
_videoRetry.visibility = GONE;
|
||||||
};
|
};
|
||||||
VideoDownload.State.ERROR -> {
|
VideoDownload.State.ERROR -> {
|
||||||
_videoState.setTextColor(Color.RED);
|
_videoState.setTextColor(Color.RED);
|
||||||
_videoState.text = _download.error ?: context.getString(R.string.error);
|
_videoState.text = _download.error ?: context.getString(R.string.error);
|
||||||
_videoBar.visibility = GONE;
|
_videoBar.visibility = GONE;
|
||||||
_videoSpeed.visibility = GONE;
|
_videoSpeed.visibility = GONE;
|
||||||
|
_videoRetry.visibility = VISIBLE;
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
_videoBar.visibility = GONE;
|
_videoBar.visibility = GONE;
|
||||||
_videoSpeed.visibility = GONE;
|
_videoSpeed.visibility = GONE;
|
||||||
|
_videoRetry.visibility = GONE;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ class VideoListEditorView : FrameLayout {
|
|||||||
executeDelete()
|
executeDelete()
|
||||||
}, cancelAction = {
|
}, cancelAction = {
|
||||||
|
|
||||||
}, doNotAskAgainAction = {
|
}, dismissAction = {}, doNotAskAgainAction = {
|
||||||
Settings.instance.other.playlistDeleteConfirmation = false
|
Settings.instance.other.playlistDeleteConfirmation = false
|
||||||
Settings.instance.save()
|
Settings.instance.save()
|
||||||
})
|
})
|
||||||
|
|||||||
+30
-6
@@ -11,6 +11,7 @@ import androidx.core.content.ContextCompat
|
|||||||
import androidx.core.content.res.ResourcesCompat
|
import androidx.core.content.res.ResourcesCompat
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.constructs.Event1
|
import com.futo.platformplayer.constructs.Event1
|
||||||
|
import com.futo.platformplayer.dp
|
||||||
|
|
||||||
class SlideUpMenuButtonList : LinearLayout {
|
class SlideUpMenuButtonList : LinearLayout {
|
||||||
private val _root: LinearLayout;
|
private val _root: LinearLayout;
|
||||||
@@ -20,10 +21,16 @@ class SlideUpMenuButtonList : LinearLayout {
|
|||||||
var _activeText: String? = null;
|
var _activeText: String? = null;
|
||||||
val id: String?
|
val id: String?
|
||||||
|
|
||||||
constructor(context: Context, attrs: AttributeSet? = null, id: String? = null): super(context, attrs) {
|
val scrollable: Boolean;
|
||||||
this.id = id
|
|
||||||
|
|
||||||
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);
|
_root = findViewById(R.id.root);
|
||||||
}
|
}
|
||||||
@@ -37,8 +44,9 @@ class SlideUpMenuButtonList : LinearLayout {
|
|||||||
buttons.clear();
|
buttons.clear();
|
||||||
for (t in texts) {
|
for (t in texts) {
|
||||||
val button = LinearLayout(context);
|
val button = LinearLayout(context);
|
||||||
button.layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT).apply {
|
button.layoutParams = LinearLayout.LayoutParams(if(!scrollable) 0 else LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.MATCH_PARENT).apply {
|
||||||
weight = 1.0f;
|
if(!scrollable)
|
||||||
|
weight = 1.0f;
|
||||||
marginStart = marginLeft;
|
marginStart = marginLeft;
|
||||||
marginEnd = marginRight;
|
marginEnd = marginRight;
|
||||||
};
|
};
|
||||||
@@ -49,7 +57,11 @@ class SlideUpMenuButtonList : LinearLayout {
|
|||||||
onClick.emit(t);
|
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);
|
val text = TextView(context);
|
||||||
text.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT);
|
text.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT);
|
||||||
@@ -69,6 +81,18 @@ class SlideUpMenuButtonList : LinearLayout {
|
|||||||
fun setSelected(text: String) {
|
fun setSelected(text: String) {
|
||||||
buttons[_activeText]?.background = ContextCompat.getDrawable(context, R.drawable.background_slide_up_option);
|
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);
|
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;
|
_activeText = text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -489,7 +489,7 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
|||||||
|
|
||||||
StatePlayer.instance.onQueueChanged.subscribe(this) {
|
StatePlayer.instance.onQueueChanged.subscribe(this) {
|
||||||
CoroutineScope(Dispatchers.Main).launch(Dispatchers.Main) {
|
CoroutineScope(Dispatchers.Main).launch(Dispatchers.Main) {
|
||||||
setLoopVisible(!StatePlayer.instance.hasQueue)
|
//setLoopVisible(!StatePlayer.instance.hasQueue)
|
||||||
updateNextPrevious();
|
updateNextPrevious();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -886,12 +886,12 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
|||||||
}
|
}
|
||||||
fun updateLoopVideoUI() {
|
fun updateLoopVideoUI() {
|
||||||
if(StatePlayer.instance.loopVideo) {
|
if(StatePlayer.instance.loopVideo) {
|
||||||
_control_loop.setImageResource(R.drawable.ic_loop_active);
|
_control_loop.setImageResource(R.drawable.ic_repeat_one_active);
|
||||||
_control_loop_fullscreen.setImageResource(R.drawable.ic_loop_active);
|
_control_loop_fullscreen.setImageResource(R.drawable.ic_repeat_one_active);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
_control_loop.setImageResource(R.drawable.ic_loop);
|
_control_loop.setImageResource(R.drawable.ic_repeat_one);
|
||||||
_control_loop_fullscreen.setImageResource(R.drawable.ic_loop);
|
_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:ellipsize="end"
|
||||||
android:layout_marginEnd="10dp" />
|
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
|
<TextView
|
||||||
android:id="@+id/download_cancel"
|
android:id="@+id/download_cancel"
|
||||||
android:layout_width="60dp"
|
android:layout_width="60dp"
|
||||||
@@ -130,20 +145,20 @@
|
|||||||
android:background="@drawable/background_small_button"
|
android:background="@drawable/background_small_button"
|
||||||
android:textAlignment="center"
|
android:textAlignment="center"
|
||||||
android:text="@string/cancel" />
|
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>
|
</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>
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_height="wrap_content"
|
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:scaleType="fitCenter"
|
||||||
android:clickable="true"
|
android:clickable="true"
|
||||||
android:padding="12dp"
|
android:padding="12dp"
|
||||||
app:srcCompat="@drawable/ic_loop" />
|
app:srcCompat="@drawable/ic_repeat_one" />
|
||||||
<ImageButton
|
<ImageButton
|
||||||
android:id="@+id/button_settings"
|
android:id="@+id/button_settings"
|
||||||
android:layout_width="50dp"
|
android:layout_width="50dp"
|
||||||
|
|||||||
@@ -93,7 +93,7 @@
|
|||||||
android:scaleType="fitCenter"
|
android:scaleType="fitCenter"
|
||||||
android:clickable="true"
|
android:clickable="true"
|
||||||
android:padding="12dp"
|
android:padding="12dp"
|
||||||
app:srcCompat="@drawable/ic_loop" />
|
app:srcCompat="@drawable/ic_repeat_one" />
|
||||||
<ImageButton
|
<ImageButton
|
||||||
android:id="@+id/button_settings"
|
android:id="@+id/button_settings"
|
||||||
android:layout_width="50dp"
|
android:layout_width="50dp"
|
||||||
|
|||||||
@@ -116,4 +116,14 @@
|
|||||||
<item name="android:fontFamily">@font/inter_regular</item>
|
<item name="android:fontFamily">@font/inter_regular</item>
|
||||||
</style>
|
</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>
|
</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