mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-29 19:13:01 +02:00
Compare commits
73 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 | |||
| 49d59f4466 | |||
| 1c9becc2ba | |||
| 1cde591061 | |||
| 8ac18f053c | |||
| 56bdae9ff1 | |||
| 74ddfe9f0e | |||
| acb9500e2a | |||
| 45f621763a | |||
| 0abc65a9bd | |||
| 6d6309973e | |||
| 92ec085d25 | |||
| 767a8befaa | |||
| 09763320dd | |||
| 27fb2997f9 | |||
| 0f46bc5888 | |||
| dccf4fcf3c | |||
| da7fef1ecd | |||
| 58a89a00ef | |||
| f2efc603ba | |||
| efe074d272 | |||
| 8a9efd3a0f | |||
| 251302b9c3 | |||
| 5cdac1405e | |||
| 894e400819 | |||
| 565ea7cb8b | |||
| 9fa3e22d2e | |||
| 5548783337 | |||
| 0dca8798cb | |||
| d902306fe4 | |||
| baa2a4fcf3 | |||
| 8be7ad9f68 | |||
| 992cbcb3a0 | |||
| e0857aea9b | |||
| 50cd0723c9 | |||
| 4c4b322682 | |||
| 7cff8568c0 |
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:ea10d3c5562c9f449a4e89e9c3dfcf881ed79a952f3409bc005bcc62c2cf4b81
|
|
||||||
size 65512557
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:22c06ca0d1a5808b2fc0a12227d5915b3126bc0b9b1305cf6bab855f2ec6fcbb
|
||||||
|
size 36133152
|
||||||
+2
-2
@@ -181,7 +181,7 @@ dependencies {
|
|||||||
implementation 'com.google.code.gson:gson:2.13.2' //Used for complex/anonymous cases like during development conversions (eg. V8RemoteObject)
|
implementation 'com.google.code.gson:gson:2.13.2' //Used for complex/anonymous cases like during development conversions (eg. V8RemoteObject)
|
||||||
|
|
||||||
//JS
|
//JS
|
||||||
implementation 'com.caoccao.javet:javet-v8-android:5.0.1'
|
implementation 'com.caoccao.javet:javet-v8-android:4.1.5'
|
||||||
|
|
||||||
//Exoplayer
|
//Exoplayer
|
||||||
implementation 'androidx.media3:media3-exoplayer:1.8.0'
|
implementation 'androidx.media3:media3-exoplayer:1.8.0'
|
||||||
@@ -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'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,8 @@
|
|||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.FutoVideo"
|
android:theme="@style/Theme.FutoVideo"
|
||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
|
tools:replace="android:enableOnBackInvokedCallback"
|
||||||
|
android:enableOnBackInvokedCallback="false"
|
||||||
tools:targetApi="31"
|
tools:targetApi="31"
|
||||||
android:largeHeap="true">
|
android:largeHeap="true">
|
||||||
<provider
|
<provider
|
||||||
@@ -61,6 +63,7 @@
|
|||||||
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|uiMode"
|
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|uiMode"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar"
|
android:theme="@style/Theme.FutoVideo.NoActionBar"
|
||||||
|
android:windowSoftInputMode="adjustPan"
|
||||||
android:launchMode="singleInstance"
|
android:launchMode="singleInstance"
|
||||||
android:resizeableActivity="true"
|
android:resizeableActivity="true"
|
||||||
android:supportsPictureInPicture="true">
|
android:supportsPictureInPicture="true">
|
||||||
@@ -249,5 +252,21 @@
|
|||||||
android:name=".activities.QRCodeFullscreenActivity"
|
android:name=".activities.QRCodeFullscreenActivity"
|
||||||
android:screenOrientation="sensorPortrait"
|
android:screenOrientation="sensorPortrait"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||||
|
<service
|
||||||
|
android:name=".UpdateDownloadService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="dataSync" />
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".UpdateActionReceiver"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
|
<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)
|
||||||
@@ -1052,6 +1052,8 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
|
|
||||||
@FormField(R.string.polycentric_local_cache, FieldForm.TOGGLE, R.string.polycentric_local_cache_description, 7)
|
@FormField(R.string.polycentric_local_cache, FieldForm.TOGGLE, R.string.polycentric_local_cache_description, 7)
|
||||||
var polycentricLocalCache: Boolean = true;
|
var polycentricLocalCache: Boolean = true;
|
||||||
|
|
||||||
|
var showPrivacyModeDialog: Boolean = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@FormField(R.string.gesture_controls, FieldForm.GROUP, -1, 19)
|
@FormField(R.string.gesture_controls, FieldForm.GROUP, -1, 19)
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -403,13 +405,6 @@ class UIDialogs {
|
|||||||
dialog.setMaxVersion(lastVersion);
|
dialog.setMaxVersion(lastVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showInstallDownloadedUpdateDialog(context: Context, apkFile: File) {
|
|
||||||
val dialog = AutoUpdateDialog(context);
|
|
||||||
registerDialogOpened(dialog);
|
|
||||||
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
|
||||||
dialog.showPredownloaded(apkFile);
|
|
||||||
}
|
|
||||||
|
|
||||||
fun showMigrateDialog(context: Context, store: ManagedStore<*>, onConcluded: ()->Unit) {
|
fun showMigrateDialog(context: Context, store: ManagedStore<*>, onConcluded: ()->Unit) {
|
||||||
if(!store.hasMissingReconstructions())
|
if(!store.hasMissingReconstructions())
|
||||||
onConcluded();
|
onConcluded();
|
||||||
|
|||||||
@@ -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 -> {
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package com.futo.platformplayer
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.futo.platformplayer.activities.MainActivity
|
||||||
|
import com.futo.platformplayer.dialogs.AutoUpdateDialog
|
||||||
|
import com.futo.platformplayer.states.StateApp
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class UpdateActionReceiver : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
when (intent.action) {
|
||||||
|
UpdateNotificationManager.ACTION_UPDATE_YES -> handleUpdateYes(context, intent)
|
||||||
|
UpdateNotificationManager.ACTION_UPDATE_NO -> handleUpdateNo(context)
|
||||||
|
UpdateNotificationManager.ACTION_UPDATE_NEVER -> handleUpdateNever(context)
|
||||||
|
UpdateNotificationManager.ACTION_DOWNLOAD_CANCEL -> handleDownloadCancel(context, intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleUpdateYes(context: Context, intent: Intent) {
|
||||||
|
AutoUpdateDialog.currentDialog?.dismiss()
|
||||||
|
|
||||||
|
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
|
||||||
|
if (version == 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_AVAILABLE)
|
||||||
|
|
||||||
|
val serviceIntent = Intent(context, UpdateDownloadService::class.java).apply {
|
||||||
|
putExtra(UpdateDownloadService.EXTRA_VERSION, version)
|
||||||
|
}
|
||||||
|
ContextCompat.startForegroundService(context, serviceIntent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleUpdateNo(context: Context) {
|
||||||
|
AutoUpdateDialog.currentDialog?.dismiss()
|
||||||
|
NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_AVAILABLE)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleUpdateNever(context: Context) {
|
||||||
|
AutoUpdateDialog.currentDialog?.dismiss()
|
||||||
|
Settings.instance.autoUpdate.check = 1
|
||||||
|
Settings.instance.save()
|
||||||
|
|
||||||
|
UpdateNotificationManager.cancelAll(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleDownloadCancel(context: Context, intent: Intent) {
|
||||||
|
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
|
||||||
|
|
||||||
|
val cancelIntent = Intent(context, UpdateDownloadService::class.java).apply {
|
||||||
|
putExtra(UpdateDownloadService.EXTRA_CANCEL, true)
|
||||||
|
putExtra(UpdateDownloadService.EXTRA_VERSION, version)
|
||||||
|
}
|
||||||
|
ContextCompat.startForegroundService(context, cancelIntent)
|
||||||
|
|
||||||
|
NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_DOWNLOADING)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package com.futo.platformplayer
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.states.StateApp
|
||||||
|
import com.futo.platformplayer.states.StateUpdate
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
class UpdateCheckWorker(appContext: Context, workerParams: WorkerParameters) : CoroutineWorker(appContext, workerParams) {
|
||||||
|
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
if (!Settings.instance.autoUpdate.isAutoUpdateEnabled()) {
|
||||||
|
Logger.i(TAG, "Auto-update disabled, skipping worker run")
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val client = ManagedHttpClient()
|
||||||
|
val latestVersion = StateUpdate.Companion.instance.downloadVersionCode(client)
|
||||||
|
|
||||||
|
if (latestVersion == null) {
|
||||||
|
Logger.w(TAG, "Failed to fetch latest version in worker")
|
||||||
|
return@withContext Result.retry()
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentVersion = BuildConfig.VERSION_CODE
|
||||||
|
Logger.i(TAG, "Worker check: current=$currentVersion, latest=$latestVersion")
|
||||||
|
|
||||||
|
if (latestVersion <= currentVersion) {
|
||||||
|
return@withContext Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateNotificationManager.showUpdateAvailableNotification(applicationContext, latestVersion)
|
||||||
|
|
||||||
|
if (StateApp.instance.isMainActive) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
StateApp.withContext { ctx ->
|
||||||
|
try {
|
||||||
|
UIDialogs.showUpdateAvailableDialog(ctx, latestVersion, false)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Logger.w(TAG, "Failed to show in-app update dialog from worker", t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Result.success()
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Logger.w(TAG, "Exception in UpdateCheckWorker", t)
|
||||||
|
Result.retry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "UpdateCheckWorker"
|
||||||
|
const val UNIQUE_WORK_NAME = "updateCheck"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
package com.futo.platformplayer
|
||||||
|
|
||||||
|
import android.app.Dialog
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.os.SystemClock
|
||||||
|
import com.futo.platformplayer.UIDialogs.ActionStyle
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.states.StateApp
|
||||||
|
import com.futo.platformplayer.states.StateUpdate
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
|
||||||
|
class UpdateDownloadService : Service() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "UpdateDownloadService"
|
||||||
|
const val EXTRA_VERSION = "version"
|
||||||
|
const val EXTRA_CANCEL = "cancel"
|
||||||
|
private const val MAX_RETRIES = 5
|
||||||
|
private const val INITIAL_BACKOFF_MS = 5_000L
|
||||||
|
private const val BUFFER_SIZE = 8 * 1024
|
||||||
|
private const val MIN_PROGRESS_UPDATE_INTERVAL_MS = 500L
|
||||||
|
|
||||||
|
var updateDownloadedDialog: Dialog? = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private val job = SupervisorJob()
|
||||||
|
private val scope = CoroutineScope(Dispatchers.IO + job)
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var isDownloading: Boolean = false
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var cancelRequested: Boolean = false
|
||||||
|
|
||||||
|
private var lastProgressUpdateElapsedMs: Long = 0L
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
if (intent == null) {
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent.getBooleanExtra(EXTRA_CANCEL, false)) {
|
||||||
|
cancelRequested = true
|
||||||
|
Logger.i(TAG, "Download cancel requested")
|
||||||
|
stopForeground(Service.STOP_FOREGROUND_REMOVE)
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
val version = intent.getIntExtra(EXTRA_VERSION, 0)
|
||||||
|
if (version == 0) {
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDownloading) {
|
||||||
|
Logger.i(TAG, "Download already in progress, ignoring new start")
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
isDownloading = true
|
||||||
|
cancelRequested = false
|
||||||
|
|
||||||
|
val notification = UpdateNotificationManager.buildDownloadProgressNotification(this, version, 0, true)
|
||||||
|
startForeground(UpdateNotificationManager.NOTIF_ID_DOWNLOADING, notification)
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
downloadApk(version)
|
||||||
|
}
|
||||||
|
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
job.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun throttledUpdateDownloadProgress(version: Int, progress: Int, indeterminate: Boolean) {
|
||||||
|
val now = SystemClock.elapsedRealtime()
|
||||||
|
val force = progress == 100 && !indeterminate
|
||||||
|
|
||||||
|
if (force || now - lastProgressUpdateElapsedMs >= MIN_PROGRESS_UPDATE_INTERVAL_MS) {
|
||||||
|
lastProgressUpdateElapsedMs = now
|
||||||
|
UpdateNotificationManager.updateDownloadProgress(this, version, progress, indeterminate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun downloadApk(version: Int) {
|
||||||
|
val apkFile = StateUpdate.getApkFile(this, version)
|
||||||
|
val partialFile = StateUpdate.getPartialApkFile(this, version)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (apkFile.exists() && apkFile.length() > 0L) {
|
||||||
|
Logger.i(TAG, "APK already downloaded at ${apkFile.absolutePath}")
|
||||||
|
onDownloadComplete(version, apkFile)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var backoffMs = INITIAL_BACKOFF_MS
|
||||||
|
|
||||||
|
for (attempt in 0 until MAX_RETRIES) {
|
||||||
|
if (cancelRequested) {
|
||||||
|
Logger.i(TAG, "Download cancelled before attempt ${attempt + 1}")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
performDownload(StateUpdate.APK_URL, partialFile, version)
|
||||||
|
|
||||||
|
if (!cancelRequested) {
|
||||||
|
if (apkFile.exists()) {
|
||||||
|
apkFile.delete()
|
||||||
|
}
|
||||||
|
if (!partialFile.renameTo(apkFile)) {
|
||||||
|
throw IllegalStateException("Failed to rename partial APK file")
|
||||||
|
}
|
||||||
|
onDownloadComplete(version, apkFile)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
if (cancelRequested) {
|
||||||
|
Logger.i(TAG, "Download cancelled by user", t)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt == MAX_RETRIES - 1) {
|
||||||
|
Logger.e(TAG, "Download failed after ${attempt + 1} attempts", t)
|
||||||
|
UpdateNotificationManager.showDownloadFailedNotification(this, version, t)
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
Logger.w(TAG, "Download attempt ${attempt + 1} failed, retrying in ${backoffMs / 1000}s", t)
|
||||||
|
delay(backoffMs)
|
||||||
|
backoffMs *= 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isDownloading = false
|
||||||
|
cancelRequested = false
|
||||||
|
stopForeground(Service.STOP_FOREGROUND_REMOVE)
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun performDownload(url: String, partialFile: File, version: Int) {
|
||||||
|
var startOffset = if (partialFile.exists()) partialFile.length() else 0L
|
||||||
|
Logger.i(TAG, "Starting download. url=$url, existingBytes=$startOffset")
|
||||||
|
|
||||||
|
var connection: HttpURLConnection? = null
|
||||||
|
try {
|
||||||
|
connection = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||||
|
connectTimeout = 15_000
|
||||||
|
readTimeout = 30_000
|
||||||
|
if (startOffset > 0L) {
|
||||||
|
setRequestProperty("Range", "bytes=$startOffset-")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.connect()
|
||||||
|
val responseCode = connection.responseCode
|
||||||
|
|
||||||
|
if (responseCode == HttpURLConnection.HTTP_OK && startOffset > 0L) {
|
||||||
|
Logger.w(TAG, "Server ignored Range header, restarting download from scratch")
|
||||||
|
partialFile.delete()
|
||||||
|
startOffset = 0L
|
||||||
|
} else if (responseCode != HttpURLConnection.HTTP_OK &&
|
||||||
|
responseCode != HttpURLConnection.HTTP_PARTIAL) {
|
||||||
|
throw IllegalStateException("Unexpected HTTP response code $responseCode")
|
||||||
|
}
|
||||||
|
|
||||||
|
val contentLength = connection.contentLengthLong
|
||||||
|
val totalBytes = if (contentLength > 0L) startOffset + contentLength else -1L
|
||||||
|
|
||||||
|
val buffer = ByteArray(BUFFER_SIZE)
|
||||||
|
var downloaded = 0L
|
||||||
|
var lastProgress = -1
|
||||||
|
|
||||||
|
connection.inputStream.use { input ->
|
||||||
|
FileOutputStream(partialFile, startOffset > 0L).use { output ->
|
||||||
|
while (!cancelRequested) {
|
||||||
|
val read = input.read(buffer)
|
||||||
|
if (read == -1) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
output.write(buffer, 0, read)
|
||||||
|
downloaded += read
|
||||||
|
|
||||||
|
if (totalBytes > 0L) {
|
||||||
|
val progress = (((startOffset + downloaded) * 100L) / totalBytes).toInt()
|
||||||
|
if (progress != lastProgress) {
|
||||||
|
lastProgress = progress
|
||||||
|
val safeProgress = when {
|
||||||
|
progress < 0 -> 0
|
||||||
|
progress > 100 -> 100
|
||||||
|
else -> progress
|
||||||
|
}
|
||||||
|
throttledUpdateDownloadProgress(version, safeProgress, indeterminate = false)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throttledUpdateDownloadProgress(version, progress = 0, indeterminate = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cancelRequested && totalBytes > 0L) {
|
||||||
|
val finalProgress = 100
|
||||||
|
throttledUpdateDownloadProgress(version, finalProgress, indeterminate = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
output.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancelRequested) {
|
||||||
|
throw CancellationException("Download cancelled")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalBytes > 0L && startOffset + downloaded < totalBytes) {
|
||||||
|
throw IllegalStateException("Download incomplete: expected=$totalBytes, got=${startOffset + downloaded}")
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
connection?.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onDownloadComplete(version: Int, apkFile: File) {
|
||||||
|
Logger.i(TAG, "Download complete for version=$version, file=${apkFile.absolutePath}")
|
||||||
|
UpdateNotificationManager.showDownloadCompleteNotification(this, version, apkFile)
|
||||||
|
|
||||||
|
if (StateApp.instance.isMainActive) {
|
||||||
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||||
|
StateApp.withContext { ctx ->
|
||||||
|
try {
|
||||||
|
updateDownloadedDialog = UIDialogs.showDialog(ctx, R.drawable.foreground,
|
||||||
|
"Update downloaded",
|
||||||
|
"Would you like to install it now?", null, 0,
|
||||||
|
UIDialogs.Action("Not now", {
|
||||||
|
updateDownloadedDialog = null
|
||||||
|
}, ActionStyle.NONE, true),
|
||||||
|
UIDialogs.Action("Install", {
|
||||||
|
UpdateNotificationManager.cancelAll(ctx)
|
||||||
|
UpdateInstaller.startInstall(ctx, version, apkFile)
|
||||||
|
}, ActionStyle.PRIMARY, true));
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Logger.w(TAG, "Failed to show in-app update downloaded dialog", t)
|
||||||
|
updateDownloadedDialog = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package com.futo.platformplayer
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.PendingIntent.FLAG_MUTABLE
|
||||||
|
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
import android.app.PendingIntent.getBroadcast
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageInstaller
|
||||||
|
import android.graphics.drawable.Animatable
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.view.View
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.receivers.InstallReceiver
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.GlobalScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
import java.io.InputStream
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import com.futo.platformplayer.dialogs.AutoUpdateDialog
|
||||||
|
import com.futo.platformplayer.states.StateApp
|
||||||
|
|
||||||
|
object UpdateInstaller {
|
||||||
|
private const val TAG = "UpdateInstaller"
|
||||||
|
|
||||||
|
@SuppressLint("RequestInstallPackagesPolicy")
|
||||||
|
fun startInstall(context: Context, version: Int, apkFile: File) {
|
||||||
|
if (!apkFile.exists()) {
|
||||||
|
Logger.w(TAG, "APK file does not exist: ${apkFile.absolutePath}")
|
||||||
|
UIDialogs.toast(context, "Update file missing")
|
||||||
|
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, "APK file does not exist.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (BuildConfig.IS_PLAYSTORE_BUILD) {
|
||||||
|
UIDialogs.toast(context, "Updates are managed by the Play Store")
|
||||||
|
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, "Updates are managed by the Play Store.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val pm = context.packageManager
|
||||||
|
if (!pm.canRequestPackageInstalls()) {
|
||||||
|
UIDialogs.toast(context, "Allow this app to install updates, then try again")
|
||||||
|
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, "Install update permission was missing.")
|
||||||
|
|
||||||
|
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
|
||||||
|
data = "package:${context.packageName}".toUri()
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
context.startActivity(intent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to check unknown sources permission", t)
|
||||||
|
}
|
||||||
|
|
||||||
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
|
var inputStream: InputStream? = null
|
||||||
|
var session: PackageInstaller.Session? = null
|
||||||
|
try {
|
||||||
|
|
||||||
|
val packageInstaller: PackageInstaller = context.packageManager.packageInstaller
|
||||||
|
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
|
||||||
|
val sessionId = packageInstaller.createSession(params)
|
||||||
|
session = packageInstaller.openSession(sessionId)
|
||||||
|
|
||||||
|
inputStream = apkFile.inputStream()
|
||||||
|
val dataLength = apkFile.length()
|
||||||
|
|
||||||
|
session.openWrite("package", 0, dataLength).use { sessionStream ->
|
||||||
|
inputStream.copyToOutputStream(dataLength, sessionStream) { _ -> }
|
||||||
|
session.fsync(sessionStream)
|
||||||
|
}
|
||||||
|
|
||||||
|
val intent = Intent(context, InstallReceiver::class.java).apply {
|
||||||
|
putExtra(UpdateNotificationManager.EXTRA_VERSION, version)
|
||||||
|
putExtra(UpdateNotificationManager.EXTRA_APK_PATH, apkFile.absolutePath)
|
||||||
|
}
|
||||||
|
val pendingIntent = getBroadcast(context, 0, intent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
|
||||||
|
val statusReceiver = pendingIntent.intentSender
|
||||||
|
|
||||||
|
InstallReceiver.onReceiveResult.subscribe(this) { message ->
|
||||||
|
InstallReceiver.onReceiveResult.clear();
|
||||||
|
onReceiveResult(context, version, apkFile, message);
|
||||||
|
};
|
||||||
|
Logger.i(TAG, "Committing install session for ${apkFile.absolutePath}")
|
||||||
|
session.commit(statusReceiver)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.w(TAG, "Exception while installing update", e)
|
||||||
|
session?.abandon()
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
UIDialogs.toast(context, "Failed to install update: ${e.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, e.message)
|
||||||
|
} finally {
|
||||||
|
session?.close()
|
||||||
|
inputStream?.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onReceiveResult(context: Context, version: Int, apkFile: File, result: String?) {
|
||||||
|
try {
|
||||||
|
InstallReceiver.onReceiveResult.remove(this)
|
||||||
|
|
||||||
|
if (result.isNullOrEmpty()) {
|
||||||
|
Logger.i(TAG, "Update install finished successfully")
|
||||||
|
UpdateNotificationManager.showInstallSucceededNotification(context, version)
|
||||||
|
} else {
|
||||||
|
Logger.w(TAG, "Update install failed: $result")
|
||||||
|
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, result)
|
||||||
|
UIDialogs.showGeneralErrorDialog(context, "Install failed due to:\n$result")
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to handle install result", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
package com.futo.platformplayer
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.PendingIntent.FLAG_MUTABLE
|
||||||
|
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
import android.app.PendingIntent.getBroadcast
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.futo.platformplayer.activities.InstallUpdateActivity
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
object UpdateNotificationManager {
|
||||||
|
private const val CHANNEL_ID = "app_updates"
|
||||||
|
private const val CHANNEL_NAME = "App updates"
|
||||||
|
private const val CHANNEL_DESCRIPTION = "Notifications about new app versions"
|
||||||
|
|
||||||
|
const val ACTION_UPDATE_YES = "com.futo.platformplayer.UPDATE_YES"
|
||||||
|
const val ACTION_UPDATE_NO = "com.futo.platformplayer.UPDATE_NO"
|
||||||
|
const val ACTION_UPDATE_NEVER = "com.futo.platformplayer.UPDATE_NEVER"
|
||||||
|
const val ACTION_DOWNLOAD_CANCEL = "com.futo.platformplayer.UPDATE_CANCEL"
|
||||||
|
const val ACTION_INSTALL_NOW = "com.futo.platformplayer.UPDATE_INSTALL"
|
||||||
|
private const val REQUEST_CODE_INSTALL = 1001
|
||||||
|
|
||||||
|
const val EXTRA_VERSION = "version"
|
||||||
|
const val EXTRA_APK_PATH = "apk_path"
|
||||||
|
|
||||||
|
const val NOTIF_ID_AVAILABLE = 2001
|
||||||
|
const val NOTIF_ID_DOWNLOADING = 2002
|
||||||
|
const val NOTIF_ID_READY = 2003
|
||||||
|
const val NOTIF_ID_INSTALL_FAILED = 2004
|
||||||
|
const val NOTIF_ID_INSTALL_SUCCEEDED = 2005
|
||||||
|
|
||||||
|
fun ensureChannel(context: Context) {
|
||||||
|
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
if (manager.getNotificationChannel(CHANNEL_ID) == null) {
|
||||||
|
val channel = NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT).apply {
|
||||||
|
description = CHANNEL_DESCRIPTION
|
||||||
|
enableVibration(false)
|
||||||
|
enableLights(false)
|
||||||
|
setSound(null, null)
|
||||||
|
}
|
||||||
|
manager.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showInstallSucceededNotification(context: Context, version: Int) {
|
||||||
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureChannel(context)
|
||||||
|
|
||||||
|
val launchIntent = context.packageManager
|
||||||
|
.getLaunchIntentForPackage(context.packageName)
|
||||||
|
?.apply {
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED)
|
||||||
|
}
|
||||||
|
|
||||||
|
val launchPendingIntent = launchIntent?.let {
|
||||||
|
PendingIntent.getActivity(context, REQUEST_CODE_INSTALL, it, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||||
|
}
|
||||||
|
|
||||||
|
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.foreground)
|
||||||
|
.setContentTitle("Update installed")
|
||||||
|
.setContentText("Version $version installed. Tap to open.")
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setSilent(true)
|
||||||
|
|
||||||
|
if (launchPendingIntent != null) {
|
||||||
|
builder.setContentIntent(launchPendingIntent)
|
||||||
|
builder.addAction(0, "Open app", launchPendingIntent)
|
||||||
|
}
|
||||||
|
|
||||||
|
NotificationManagerCompat.from(context).notify(NOTIF_ID_INSTALL_SUCCEEDED, builder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showUpdateAvailableNotification(context: Context, version: Int) {
|
||||||
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureChannel(context)
|
||||||
|
|
||||||
|
val yesIntent = Intent(context, UpdateActionReceiver::class.java).apply {
|
||||||
|
action = ACTION_UPDATE_YES
|
||||||
|
putExtra(EXTRA_VERSION, version)
|
||||||
|
}
|
||||||
|
val yesPendingIntent = getBroadcast(context, 0, yesIntent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
|
||||||
|
val noIntent = Intent(context, UpdateActionReceiver::class.java).apply {
|
||||||
|
action = ACTION_UPDATE_NO
|
||||||
|
putExtra(EXTRA_VERSION, version)
|
||||||
|
}
|
||||||
|
val noPendingIntent = getBroadcast(context, 1, noIntent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
|
||||||
|
val neverIntent = Intent(context, UpdateActionReceiver::class.java).apply {
|
||||||
|
action = ACTION_UPDATE_NEVER
|
||||||
|
putExtra(EXTRA_VERSION, version)
|
||||||
|
}
|
||||||
|
val neverPendingIntent = getBroadcast(context, 2, neverIntent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
|
||||||
|
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.foreground)
|
||||||
|
.setContentTitle("Update available")
|
||||||
|
.setContentText("A new version ($version) is available.")
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setContentIntent(yesPendingIntent)
|
||||||
|
.setSilent(true)
|
||||||
|
.addAction(0, "Never", neverPendingIntent)
|
||||||
|
.addAction(0, "Not now", noPendingIntent)
|
||||||
|
.addAction(0, "Download", yesPendingIntent)
|
||||||
|
|
||||||
|
NotificationManagerCompat.from(context).notify(NOTIF_ID_AVAILABLE, builder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildDownloadProgressNotification(context: Context, version: Int, progress: Int, indeterminate: Boolean): Notification {
|
||||||
|
ensureChannel(context)
|
||||||
|
|
||||||
|
val cancelIntent = Intent(context, UpdateActionReceiver::class.java).apply {
|
||||||
|
action = ACTION_DOWNLOAD_CANCEL
|
||||||
|
putExtra(EXTRA_VERSION, version)
|
||||||
|
}
|
||||||
|
val cancelPendingIntent = getBroadcast(
|
||||||
|
context,
|
||||||
|
3,
|
||||||
|
cancelIntent,
|
||||||
|
FLAG_MUTABLE or FLAG_UPDATE_CURRENT
|
||||||
|
)
|
||||||
|
|
||||||
|
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.foreground)
|
||||||
|
.setContentTitle("Downloading update")
|
||||||
|
.setContentText("Downloading version $version")
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setSilent(true)
|
||||||
|
.addAction(0, "Cancel", cancelPendingIntent)
|
||||||
|
|
||||||
|
if (indeterminate) {
|
||||||
|
builder.setProgress(0, 0, true)
|
||||||
|
} else {
|
||||||
|
builder.setProgress(100, progress, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateDownloadProgress(context: Context, version: Int, progress: Int, indeterminate: Boolean) {
|
||||||
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val notification = buildDownloadProgressNotification(context, version, progress, indeterminate)
|
||||||
|
NotificationManagerCompat.from(context).notify(NOTIF_ID_DOWNLOADING, notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun showDownloadCompleteNotification(context: Context, version: Int, apkFile: File) {
|
||||||
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ensureChannel(context)
|
||||||
|
|
||||||
|
val installIntent = InstallUpdateActivity.createIntent(context, version, apkFile.absolutePath)
|
||||||
|
val installPendingIntent = PendingIntent.getActivity(context, REQUEST_CODE_INSTALL, installIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||||
|
|
||||||
|
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.foreground)
|
||||||
|
.setContentTitle("Update downloaded")
|
||||||
|
.setContentText("Tap to install version $version.")
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setContentIntent(installPendingIntent)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setSilent(true)
|
||||||
|
.addAction(0, "Install", installPendingIntent)
|
||||||
|
|
||||||
|
NotificationManagerCompat.from(context).notify(NOTIF_ID_READY, builder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun showDownloadFailedNotification(context: Context, version: Int, error: Throwable?) {
|
||||||
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ensureChannel(context)
|
||||||
|
|
||||||
|
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.foreground)
|
||||||
|
.setContentTitle("Failed to download update")
|
||||||
|
.setContentText(error?.message ?: "Unknown error")
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setSilent(true)
|
||||||
|
|
||||||
|
NotificationManagerCompat.from(context).notify(NOTIF_ID_READY, builder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showInstallFailedNotification(context: Context, version: Int, apkFile: File, error: String?) {
|
||||||
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED)
|
||||||
|
return
|
||||||
|
|
||||||
|
ensureChannel(context)
|
||||||
|
|
||||||
|
val installIntent = InstallUpdateActivity.createIntent(context, version, apkFile.absolutePath)
|
||||||
|
val installPendingIntent = PendingIntent.getActivity(context, REQUEST_CODE_INSTALL, installIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||||
|
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.foreground)
|
||||||
|
.setContentTitle("Failed to install update")
|
||||||
|
.setContentText(if (error != null && error.isNotBlank()) "$error Tap to try again." else "Tap to try again.")
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setSilent(true)
|
||||||
|
.setContentIntent(installPendingIntent)
|
||||||
|
.addAction(0, "Install again", installPendingIntent)
|
||||||
|
|
||||||
|
NotificationManagerCompat.from(context).notify(NOTIF_ID_INSTALL_FAILED, builder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelAll(context: Context) {
|
||||||
|
NotificationManagerCompat.from(context).cancel(NOTIF_ID_AVAILABLE)
|
||||||
|
NotificationManagerCompat.from(context).cancel(NOTIF_ID_DOWNLOADING)
|
||||||
|
NotificationManagerCompat.from(context).cancel(NOTIF_ID_READY)
|
||||||
|
NotificationManagerCompat.from(context).cancel(NOTIF_ID_INSTALL_FAILED)
|
||||||
|
NotificationManagerCompat.from(context).cancel(NOTIF_ID_INSTALL_SUCCEEDED)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,6 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
|
||||||
import android.icu.util.Output
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.os.OperationCanceledException
|
import android.os.OperationCanceledException
|
||||||
@@ -44,6 +42,9 @@ import java.util.*
|
|||||||
import java.util.concurrent.ThreadLocalRandom
|
import java.util.concurrent.ThreadLocalRandom
|
||||||
import java.util.zip.GZIPInputStream
|
import java.util.zip.GZIPInputStream
|
||||||
import java.util.zip.GZIPOutputStream
|
import java.util.zip.GZIPOutputStream
|
||||||
|
import androidx.core.graphics.scale
|
||||||
|
import com.bumptech.glide.RequestBuilder
|
||||||
|
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||||
|
|
||||||
private val _allowedCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz ";
|
private val _allowedCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz ";
|
||||||
fun getRandomString(sizeOfRandomString: Int): String {
|
fun getRandomString(sizeOfRandomString: Int): String {
|
||||||
@@ -114,23 +115,6 @@ fun DocumentFile.writeBytes(context: Context, byteArray: ByteArray) = context.co
|
|||||||
it.flush();
|
it.flush();
|
||||||
};
|
};
|
||||||
|
|
||||||
fun loadBitmap(url: String): Bitmap {
|
|
||||||
try {
|
|
||||||
val client = ManagedHttpClient();
|
|
||||||
val response = client.get(url);
|
|
||||||
if (response.isOk && response.body != null) {
|
|
||||||
val bitmapStream = response.body.byteStream();
|
|
||||||
val bitmap = BitmapFactory.decodeStream(bitmapStream);
|
|
||||||
return bitmap;
|
|
||||||
} else {
|
|
||||||
throw Exception("Failed to find data at URL.");
|
|
||||||
}
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.w("Utility", "Exception thrown while downloading bitmap.", e);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun TextView.setPlatformPlayerLinkMovementMethod(context: Context) {
|
fun TextView.setPlatformPlayerLinkMovementMethod(context: Context) {
|
||||||
this.movementMethod = PlatformLinkMovementMethod(context);
|
this.movementMethod = PlatformLinkMovementMethod(context);
|
||||||
}
|
}
|
||||||
@@ -458,4 +442,11 @@ 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): RequestBuilder<T> {
|
||||||
|
return this;
|
||||||
|
//.downsample(DownsampleStrategy.AT_MOST)
|
||||||
|
//.override(maxSizePx, maxSizePx)
|
||||||
|
//.centerInside()
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.futo.platformplayer.activities
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import com.futo.platformplayer.UIDialogs
|
||||||
|
import com.futo.platformplayer.UpdateInstaller
|
||||||
|
import com.futo.platformplayer.UpdateNotificationManager
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class InstallUpdateActivity : AppCompatActivity() {
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
UpdateNotificationManager.cancelAll(this)
|
||||||
|
|
||||||
|
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
|
||||||
|
val apkPath = intent.getStringExtra(UpdateNotificationManager.EXTRA_APK_PATH)
|
||||||
|
|
||||||
|
if (version == 0 || apkPath.isNullOrEmpty()) {
|
||||||
|
Logger.w("InstallUpdateActivity", "Missing version or apkPath")
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val apkFile = File(apkPath)
|
||||||
|
if (!apkFile.exists()) {
|
||||||
|
Logger.w("InstallUpdateActivity", "APK file does not exist: $apkPath")
|
||||||
|
UIDialogs.Companion.toast(this, "Update file missing")
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateInstaller.startInstall(this, version, apkFile)
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun createIntent(context: Context, version: Int, apkPath: String): Intent =
|
||||||
|
Intent(context, InstallUpdateActivity::class.java).apply {
|
||||||
|
putExtra(UpdateNotificationManager.EXTRA_VERSION, version)
|
||||||
|
putExtra(UpdateNotificationManager.EXTRA_APK_PATH, apkPath)
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import android.content.Intent
|
|||||||
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
|
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
|
import android.graphics.Color
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
@@ -244,19 +245,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
private val _notifPermission = "android.permission.POST_NOTIFICATIONS";
|
|
||||||
private val _notificationPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
|
|
||||||
if (isGranted)
|
|
||||||
UIDialogs.toast(this, "Notification permission granted");
|
|
||||||
else
|
|
||||||
UIDialogs.toast(this, "Notification permission denied");
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
fun requestNotificationPermissions() {
|
|
||||||
_notificationPermissionLauncher?.launch(_notifPermission);
|
|
||||||
}
|
|
||||||
|
|
||||||
val mainId = UUID.randomUUID().toString().substring(0, 5)
|
val mainId = UUID.randomUUID().toString().substring(0, 5)
|
||||||
|
|
||||||
@@ -332,6 +320,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
window.isNavigationBarContrastEnforced = false
|
||||||
|
}
|
||||||
|
|
||||||
//Preload common files to memory
|
//Preload common files to memory
|
||||||
FragmentedStorage.get<SubscriptionStorage>();
|
FragmentedStorage.get<SubscriptionStorage>();
|
||||||
FragmentedStorage.get<Settings>();
|
FragmentedStorage.get<Settings>();
|
||||||
@@ -418,12 +410,17 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
updateSegmentPaddings();
|
updateSegmentPaddings();
|
||||||
};
|
};
|
||||||
_fragVideoDetail.onTransitioning.subscribe {
|
_fragVideoDetail.onTransitioning.subscribe {
|
||||||
if (it || _fragVideoDetail.state != VideoDetailFragment.State.MINIMIZED)
|
if (it || _fragVideoDetail.state != VideoDetailFragment.State.MINIMIZED) {
|
||||||
|
Logger.i(TAG, "onTransition Setting elevation higher");
|
||||||
_fragContainerOverlay.elevation =
|
_fragContainerOverlay.elevation =
|
||||||
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 15f, resources.displayMetrics);
|
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 15f, resources.displayMetrics);
|
||||||
else
|
}
|
||||||
|
else {
|
||||||
|
Logger.i(TAG, "onTransition Setting elevation lower");
|
||||||
_fragContainerOverlay.elevation =
|
_fragContainerOverlay.elevation =
|
||||||
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics);
|
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_fragVideoDetail.onCloseEvent.subscribe {
|
_fragVideoDetail.onCloseEvent.subscribe {
|
||||||
@@ -621,6 +618,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
sharedPreferences.edit().putBoolean("IsFirstBoot", false).apply()
|
sharedPreferences.edit().putBoolean("IsFirstBoot", false).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && Settings.instance.autoUpdate.isAutoUpdateEnabled() && Settings.instance.autoUpdate.shouldBackgroundDownload) {
|
||||||
|
requestNotificationPermissions("You have enabled background updating.\n\nGrayjay uses notifications to inform you when a new app update is available.");
|
||||||
|
}
|
||||||
|
|
||||||
val submissionStatus = FragmentedStorage.get<StringStorage>("subscriptionSubmissionStatus")
|
val submissionStatus = FragmentedStorage.get<StringStorage>("subscriptionSubmissionStatus")
|
||||||
|
|
||||||
val numSubscriptions = StateSubscriptions.instance.getSubscriptionCount()
|
val numSubscriptions = StateSubscriptions.instance.getSubscriptionCount()
|
||||||
@@ -1298,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;
|
||||||
|
|||||||
+30
-4
@@ -17,11 +17,14 @@ import com.futo.platformplayer.getOrThrow
|
|||||||
import com.futo.platformplayer.invokeV8
|
import com.futo.platformplayer.invokeV8
|
||||||
import com.futo.platformplayer.invokeV8Void
|
import com.futo.platformplayer.invokeV8Void
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.states.StateApp
|
||||||
import com.futo.platformplayer.states.StateDeveloper
|
import com.futo.platformplayer.states.StateDeveloper
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import java.util.Base64
|
import java.util.Base64
|
||||||
|
|
||||||
class JSRequestExecutor {
|
class JSRequestExecutor: AutoCloseable {
|
||||||
private val _plugin: JSClient;
|
private val _plugin: JSClient;
|
||||||
private val _config: IV8PluginConfig;
|
private val _config: IV8PluginConfig;
|
||||||
private var _executor: V8ValueObject;
|
private var _executor: V8ValueObject;
|
||||||
@@ -29,6 +32,9 @@ class JSRequestExecutor {
|
|||||||
|
|
||||||
private val hasCleanup: Boolean;
|
private val hasCleanup: Boolean;
|
||||||
|
|
||||||
|
private var _cleanLock = Any();
|
||||||
|
private var _cleaned: Boolean = false;
|
||||||
|
|
||||||
constructor(plugin: JSClient, executor: V8ValueObject) {
|
constructor(plugin: JSClient, executor: V8ValueObject) {
|
||||||
this._plugin = plugin;
|
this._plugin = plugin;
|
||||||
this._executor = executor;
|
this._executor = executor;
|
||||||
@@ -102,8 +108,12 @@ class JSRequestExecutor {
|
|||||||
|
|
||||||
|
|
||||||
open fun cleanup() {
|
open fun cleanup() {
|
||||||
if (!hasCleanup || _executor.isClosed)
|
synchronized(_cleanLock) {
|
||||||
return;
|
if (!hasCleanup || _executor.isClosed || _cleaned)
|
||||||
|
return;
|
||||||
|
_cleaned = true;
|
||||||
|
}
|
||||||
|
Logger.i("JSRequestExecutor", "JSRequestExecutor cleanup requested");
|
||||||
_plugin.busy {
|
_plugin.busy {
|
||||||
if(_plugin is DevJSClient)
|
if(_plugin is DevJSClient)
|
||||||
StateDeveloper.instance.handleDevCall(_plugin.devID, "requestExecutor.executeRequest()") {
|
StateDeveloper.instance.handleDevCall(_plugin.devID, "requestExecutor.executeRequest()") {
|
||||||
@@ -125,9 +135,25 @@ class JSRequestExecutor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected fun finalize() {
|
override fun close() {
|
||||||
cleanup();
|
cleanup();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun closeAsync() {
|
||||||
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO){
|
||||||
|
try {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
catch(ex: Throwable) {
|
||||||
|
Logger.e("JSRequestExecutor", "Cleanup failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
protected fun finalize() {
|
||||||
|
cleanup();
|
||||||
|
}*/
|
||||||
}
|
}
|
||||||
|
|
||||||
//TODO: are these available..?
|
//TODO: are these available..?
|
||||||
|
|||||||
+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,12 +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) {
|
||||||
_audioExecutor = audioSource.getRequestExecutor()
|
val oldVideoExecutor = _videoExecutor
|
||||||
|
oldVideoExecutor?.closeAsync()
|
||||||
|
_videoExecutor = videoSource.getRequestExecutor()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (videoSource != null && videoSource.hasRequestExecutor) {
|
if (audioSource != null) {
|
||||||
_videoExecutor = videoSource.getRequestExecutor()
|
val oldExecutor = _audioExecutor
|
||||||
|
oldExecutor?.closeAsync()
|
||||||
|
_audioExecutor = audioSource.getRequestExecutor()
|
||||||
|
} else if (hasAudioInDash && videoSource != null) {
|
||||||
|
val oldExecutor = _audioExecutor
|
||||||
|
oldExecutor?.closeAsync()
|
||||||
|
_audioExecutor = _videoExecutor
|
||||||
}
|
}
|
||||||
|
|
||||||
//TOOD: Else also handle the non request executor case, perhaps add ?url=$originalUrl to the query parameters, ... propagate this for all other flows also
|
//TOOD: Else also handle the non request executor case, perhaps add ?url=$originalUrl to the query parameters, ... propagate this for all other flows also
|
||||||
@@ -1315,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
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ import com.google.gson.FieldAttributes
|
|||||||
import com.google.gson.GsonBuilder
|
import com.google.gson.GsonBuilder
|
||||||
import com.google.gson.JsonArray
|
import com.google.gson.JsonArray
|
||||||
import com.google.gson.JsonParser
|
import com.google.gson.JsonParser
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import java.lang.reflect.Field
|
import java.lang.reflect.Field
|
||||||
@@ -269,10 +271,12 @@ class DeveloperEndpoints(private val context: Context) {
|
|||||||
context.respondCode(403, "This plugin doesn't support auth");
|
context.respondCode(403, "This plugin doesn't support auth");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
LoginFragment.showLogin(config){
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||||
_testPluginVariables.clear();
|
LoginFragment.showLogin(config){
|
||||||
_testPlugin = V8Plugin(StateApp.instance.context, config, null, JSHttpClient(null, null, null, config), JSHttpClient(null, it, null, config));
|
_testPluginVariables.clear();
|
||||||
};
|
_testPlugin = V8Plugin(StateApp.instance.context, config, null, JSHttpClient(null, null, null, config), JSHttpClient(null, it, null, config));
|
||||||
|
};
|
||||||
|
}
|
||||||
/*
|
/*
|
||||||
LoginActivity.showLogin(StateApp.instance.context, config) {
|
LoginActivity.showLogin(StateApp.instance.context, config) {
|
||||||
_testPluginVariables.clear();
|
_testPluginVariables.clear();
|
||||||
|
|||||||
@@ -16,9 +16,12 @@ import android.widget.Button
|
|||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.Settings
|
import com.futo.platformplayer.Settings
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
|
import com.futo.platformplayer.UpdateDownloadService
|
||||||
|
import com.futo.platformplayer.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
|
||||||
@@ -34,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;
|
||||||
@@ -46,7 +51,6 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
|
|||||||
private var _maxVersion: Int = 0;
|
private var _maxVersion: Int = 0;
|
||||||
|
|
||||||
private var _updating: Boolean = false;
|
private var _updating: Boolean = false;
|
||||||
private var _apkFile: File? = null;
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
@@ -61,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();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -76,23 +82,32 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
_buttonUpdate.setOnClickListener {
|
_buttonUpdate.setOnClickListener {
|
||||||
|
UpdateNotificationManager.cancelAll(context)
|
||||||
|
|
||||||
if (_updating) {
|
if (_updating) {
|
||||||
return@setOnClickListener;
|
return@setOnClickListener;
|
||||||
}
|
}
|
||||||
|
|
||||||
_updating = true;
|
if (Settings.instance.autoUpdate.shouldBackgroundDownload) {
|
||||||
update();
|
val ctx = context.applicationContext;
|
||||||
|
val intent = Intent(ctx, UpdateDownloadService::class.java);
|
||||||
|
intent.putExtra(UpdateDownloadService.EXTRA_VERSION, _maxVersion);
|
||||||
|
ContextCompat.startForegroundService(ctx, intent);
|
||||||
|
UIDialogs.toast(context, "Downloading update in background");
|
||||||
|
dismiss();
|
||||||
|
} else {
|
||||||
|
_updating = true;
|
||||||
|
update();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
fun showPredownloaded(apkFile: File) {
|
currentDialog = this
|
||||||
_apkFile = apkFile;
|
|
||||||
super.show()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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.")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,21 +133,14 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
|
|||||||
GlobalScope.launch(Dispatchers.IO) {
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
var inputStream: InputStream? = null;
|
var inputStream: InputStream? = null;
|
||||||
try {
|
try {
|
||||||
val apkFile = _apkFile;
|
val client = ManagedHttpClient();
|
||||||
if (apkFile != null) {
|
val response = client.get(StateUpdate.APK_URL);
|
||||||
inputStream = apkFile.inputStream();
|
if (response.isOk && response.body != null) {
|
||||||
val dataLength = apkFile.length();
|
inputStream = response.body.byteStream();
|
||||||
|
val dataLength = response.body.contentLength();
|
||||||
install(inputStream, dataLength);
|
install(inputStream, dataLength);
|
||||||
} else {
|
} else {
|
||||||
val client = ManagedHttpClient();
|
throw Exception("Failed to download latest version of app.");
|
||||||
val response = client.get(StateUpdate.APK_URL);
|
|
||||||
if (response.isOk && response.body != null) {
|
|
||||||
inputStream = response.body.byteStream();
|
|
||||||
val dataLength = response.body.contentLength();
|
|
||||||
install(inputStream, dataLength);
|
|
||||||
} else {
|
|
||||||
throw Exception("Failed to download latest version of app.");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Logger.w(TAG, "Exception thrown while downloading and installing latest version of app.", e);
|
Logger.w(TAG, "Exception thrown while downloading and installing latest version of app.", e);
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
package com.futo.platformplayer.downloads
|
package com.futo.platformplayer.downloads
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.media.MediaCodec
|
||||||
|
import android.media.MediaExtractor
|
||||||
|
import android.media.MediaMuxer
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.arthenica.ffmpegkit.FFmpegKit
|
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
|
||||||
@@ -37,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
|
||||||
@@ -83,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;
|
||||||
|
|
||||||
@@ -98,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?;
|
||||||
@@ -267,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?");
|
||||||
|
|
||||||
@@ -434,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();
|
||||||
@@ -487,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);
|
||||||
});
|
});
|
||||||
@@ -527,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);
|
||||||
});
|
});
|
||||||
@@ -585,55 +606,60 @@ class VideoDownload {
|
|||||||
return cipher.doFinal(encryptedSegment)
|
return cipher.doFinal(encryptedSegment)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun remuxWithFfmpegInPlace(inputFile: File): Boolean {
|
private suspend fun combineSegments(context: Context, segmentFiles: List<File>, targetFile: File) = withContext(Dispatchers.IO) {
|
||||||
val inputPath = inputFile.absolutePath
|
require(segmentFiles.isNotEmpty()) { "segmentFiles must not be empty" }
|
||||||
if (!inputFile.exists()) {
|
|
||||||
Logger.w(TAG, "remuxWithFfmpegInPlace: input does not exist: $inputPath")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
val parent = inputFile.parentFile
|
suspendCancellableCoroutine { continuation ->
|
||||||
if (parent == null) {
|
val concatInput = buildString {
|
||||||
Logger.w(TAG, "remuxWithFfmpegInPlace: input has no parent: $inputPath")
|
append("concat:")
|
||||||
return false
|
append(
|
||||||
}
|
segmentFiles.joinToString("|") { file ->
|
||||||
|
file.absolutePath
|
||||||
val tmpFile = File(parent, inputFile.nameWithoutExtension + "_fixed." + inputFile.extension)
|
}
|
||||||
val cmd = buildString {
|
)
|
||||||
append("-y ")
|
|
||||||
append("-i \"").append(inputFile.absolutePath).append("\" ")
|
|
||||||
append("-c copy ")
|
|
||||||
append("-movflags +faststart ")
|
|
||||||
append("\"").append(tmpFile.absolutePath).append("\"")
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.i(TAG, "FFmpeg remux command: $cmd")
|
|
||||||
|
|
||||||
val session = FFmpegKit.execute(cmd)
|
|
||||||
val returnCode = session.returnCode
|
|
||||||
|
|
||||||
if (ReturnCode.isSuccess(returnCode)) {
|
|
||||||
val newLen = tmpFile.length()
|
|
||||||
|
|
||||||
if (!inputFile.delete()) {
|
|
||||||
Logger.w(TAG, "remuxWithFfmpegInPlace: failed to delete original: ${inputFile.absolutePath}")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!tmpFile.renameTo(inputFile)) {
|
val cmd = "-i \"$concatInput\" -c copy \"${targetFile.absolutePath}\""
|
||||||
Logger.w(TAG, "remuxWithFfmpegInPlace: failed to move tmp: ${tmpFile.absolutePath}")
|
|
||||||
} else {
|
val statisticsCallback = StatisticsCallback { _ ->
|
||||||
Logger.i(TAG, "remuxWithFfmpegInPlace: success for $inputPath (size=$newLen bytes)")
|
//No callback
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
val executorService = Executors.newSingleThreadExecutor()
|
||||||
} else {
|
|
||||||
Logger.e(TAG, "FFmpeg remux failed for $inputPath. rc=$returnCode, logs=${session.allLogsAsString}")
|
val session = FFmpegKit.executeAsync(
|
||||||
tmpFile.delete()
|
cmd,
|
||||||
return false
|
{ completedSession ->
|
||||||
|
executorService.shutdown()
|
||||||
|
|
||||||
|
if (ReturnCode.isSuccess(completedSession.returnCode)) {
|
||||||
|
continuation.resumeWith(Result.success(Unit))
|
||||||
|
} else {
|
||||||
|
val errorMessage = if (ReturnCode.isCancel(completedSession.returnCode)) {
|
||||||
|
"Command cancelled"
|
||||||
|
} else {
|
||||||
|
"Command failed with state '${completedSession.state}' " +
|
||||||
|
"and return code ${completedSession.returnCode}, " +
|
||||||
|
"stack trace ${completedSession.failStackTrace}"
|
||||||
|
}
|
||||||
|
continuation.resumeWithException(RuntimeException(errorMessage))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ log ->
|
||||||
|
Logger.v(TAG, log.message)
|
||||||
|
},
|
||||||
|
statisticsCallback,
|
||||||
|
executorService
|
||||||
|
)
|
||||||
|
|
||||||
|
continuation.invokeOnCancellation {
|
||||||
|
session.cancel()
|
||||||
|
executorService.shutdownNow()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, source: JSSource?, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
|
private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, source: JSSource?, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||||
if (targetFile.exists())
|
if (targetFile.exists())
|
||||||
targetFile.delete()
|
targetFile.delete()
|
||||||
|
|
||||||
@@ -678,6 +704,7 @@ class VideoDownload {
|
|||||||
.array()
|
.array()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val segmentFiles = arrayListOf<File>()
|
||||||
try {
|
try {
|
||||||
val playlistHeaders = mutableMapOf<String, String>()
|
val playlistHeaders = mutableMapOf<String, String>()
|
||||||
val modifiedPlaylistReq = modifier?.modifyRequest(hlsUrl, playlistHeaders)
|
val modifiedPlaylistReq = modifier?.modifyRequest(hlsUrl, playlistHeaders)
|
||||||
@@ -713,123 +740,134 @@ class VideoDownload {
|
|||||||
val mediaSequence = variantPlaylist.mediaSequence ?: 0L
|
val mediaSequence = variantPlaylist.mediaSequence ?: 0L
|
||||||
val rangeOffsets = mutableMapOf<String, Long>()
|
val rangeOffsets = mutableMapOf<String, Long>()
|
||||||
|
|
||||||
targetFile.outputStream().use { outStr ->
|
if (!variantPlaylist.mapUrl.isNullOrEmpty()) {
|
||||||
if (!variantPlaylist.mapUrl.isNullOrEmpty()) {
|
if (isCancelled) throw CancellationException("Cancelled")
|
||||||
if (isCancelled) throw CancellationException("Cancelled")
|
|
||||||
|
|
||||||
Logger.i(TAG, "Downloading HLS initialization map")
|
Logger.i(TAG, "Downloading HLS initialization map")
|
||||||
|
|
||||||
var mapRangeStart: Long? = null
|
var mapRangeStart: Long? = null
|
||||||
var mapRangeLength: Long? = null
|
var mapRangeLength: Long? = null
|
||||||
|
|
||||||
if (variantPlaylist.mapBytesLength > 0) {
|
if (variantPlaylist.mapBytesLength > 0) {
|
||||||
mapRangeLength = variantPlaylist.mapBytesLength
|
mapRangeLength = variantPlaylist.mapBytesLength
|
||||||
|
|
||||||
val mapUrl = variantPlaylist.mapUrl!!
|
val mapUrl = variantPlaylist.mapUrl
|
||||||
if (variantPlaylist.mapBytesStart >= 0) {
|
if (variantPlaylist.mapBytesStart >= 0) {
|
||||||
mapRangeStart = variantPlaylist.mapBytesStart
|
mapRangeStart = variantPlaylist.mapBytesStart
|
||||||
rangeOffsets[mapUrl] =
|
rangeOffsets[mapUrl] =
|
||||||
variantPlaylist.mapBytesStart + variantPlaylist.mapBytesLength
|
variantPlaylist.mapBytesStart + variantPlaylist.mapBytesLength
|
||||||
} else {
|
} else {
|
||||||
val offset = rangeOffsets[mapUrl] ?: 0L
|
val offset = rangeOffsets[mapUrl] ?: 0L
|
||||||
mapRangeStart = offset
|
mapRangeStart = offset
|
||||||
rangeOffsets[mapUrl] = offset + variantPlaylist.mapBytesLength
|
rangeOffsets[mapUrl] = offset + variantPlaylist.mapBytesLength
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var mapBytes = downloadBytes(variantPlaylist.mapUrl!!, mapRangeStart, mapRangeLength)
|
var mapBytes = downloadBytes(variantPlaylist.mapUrl!!, mapRangeStart, mapRangeLength)
|
||||||
|
|
||||||
if (useDecryption) {
|
if (useDecryption) {
|
||||||
val kb = keyBytes ?: throw IllegalStateException("Decryption key bytes are missing.")
|
val kb = keyBytes ?: throw IllegalStateException("Decryption key bytes are missing.")
|
||||||
val iv = staticIvBytes
|
val iv = staticIvBytes
|
||||||
?: throw UnsupportedOperationException("Encrypted EXT-X-MAP without explicit IV is not supported.")
|
?: throw UnsupportedOperationException("Encrypted EXT-X-MAP without explicit IV is not supported.")
|
||||||
mapBytes = decryptSegment(mapBytes, kb, iv)
|
mapBytes = decryptSegment(mapBytes, kb, iv)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mapBytes.size.toLong() > Int.MAX_VALUE) {
|
if (mapBytes.size.toLong() > Int.MAX_VALUE) {
|
||||||
throw IllegalStateException("HLS MAP segment too large to handle.")
|
throw IllegalStateException("HLS MAP segment too large to handle.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val segmentFile = File(context.cacheDir, "segment-${UUID.randomUUID()}")
|
||||||
|
val outStr = segmentFile.outputStream()
|
||||||
|
try {
|
||||||
|
segmentFiles.add(segmentFile)
|
||||||
outStr.write(mapBytes)
|
outStr.write(mapBytes)
|
||||||
outStr.flush()
|
outStr.flush()
|
||||||
downloadedTotalLength += mapBytes.size
|
} finally {
|
||||||
}
|
outStr.close()
|
||||||
|
|
||||||
val totalSegments = variantPlaylist.segments.size
|
|
||||||
var mediaSegmentIndex = 0
|
|
||||||
|
|
||||||
var bytesSinceLastSpeedUpdate = 0L
|
|
||||||
var lastSpeedUpdateTime = System.currentTimeMillis()
|
|
||||||
var lastSpeed = 0L
|
|
||||||
|
|
||||||
variantPlaylist.segments.forEachIndexed { index, segment ->
|
|
||||||
if (segment !is HLS.MediaSegment) return@forEachIndexed
|
|
||||||
if (isCancelled) throw CancellationException("Cancelled")
|
|
||||||
|
|
||||||
Logger.i(TAG, "Download '$name' segment $index sequential")
|
|
||||||
|
|
||||||
var rangeStart: Long? = null
|
|
||||||
var rangeLength: Long? = null
|
|
||||||
|
|
||||||
if (segment.bytesLength > 0) {
|
|
||||||
rangeLength = segment.bytesLength
|
|
||||||
|
|
||||||
val urlKey = segment.uri
|
|
||||||
if (segment.bytesStart >= 0) {
|
|
||||||
rangeStart = segment.bytesStart
|
|
||||||
rangeOffsets[urlKey] = segment.bytesStart + segment.bytesLength
|
|
||||||
} else {
|
|
||||||
val offset = rangeOffsets[urlKey] ?: 0L
|
|
||||||
rangeStart = offset
|
|
||||||
rangeOffsets[urlKey] = offset + segment.bytesLength
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var segmentBytes = downloadBytes(segment.uri, rangeStart, rangeLength)
|
|
||||||
|
|
||||||
if (useDecryption) {
|
|
||||||
val kb = keyBytes ?: throw IllegalStateException("Decryption key bytes are missing.")
|
|
||||||
val ivBytes = if (staticIvBytes != null) {
|
|
||||||
staticIvBytes!!
|
|
||||||
} else {
|
|
||||||
val sequenceNumber = mediaSequence + mediaSegmentIndex
|
|
||||||
buildSequenceIv(sequenceNumber)
|
|
||||||
}
|
|
||||||
|
|
||||||
segmentBytes = decryptSegment(segmentBytes, kb, ivBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
val segmentLength = segmentBytes.size.toLong()
|
|
||||||
if (segmentLength > Int.MAX_VALUE) {
|
|
||||||
throw IllegalStateException("HLS media segment too large to handle.")
|
|
||||||
}
|
|
||||||
|
|
||||||
val avgLen = if (index == 0) {
|
|
||||||
segmentLength
|
|
||||||
} else {
|
|
||||||
if (index > 0) downloadedTotalLength / index else segmentLength
|
|
||||||
}
|
|
||||||
val expectedTotal = avgLen * (totalSegments - 1) + segmentLength
|
|
||||||
|
|
||||||
outStr.write(segmentBytes)
|
|
||||||
downloadedTotalLength += segmentLength
|
|
||||||
|
|
||||||
bytesSinceLastSpeedUpdate += segmentLength
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
val elapsed = now - lastSpeedUpdateTime
|
|
||||||
if (elapsed >= 500 && bytesSinceLastSpeedUpdate > 0) {
|
|
||||||
lastSpeed = (bytesSinceLastSpeedUpdate * 1000L / elapsed)
|
|
||||||
bytesSinceLastSpeedUpdate = 0
|
|
||||||
lastSpeedUpdateTime = now
|
|
||||||
}
|
|
||||||
|
|
||||||
onProgress(expectedTotal, downloadedTotalLength, lastSpeed)
|
|
||||||
mediaSegmentIndex++
|
|
||||||
}
|
}
|
||||||
|
downloadedTotalLength += mapBytes.size
|
||||||
}
|
}
|
||||||
|
|
||||||
remuxWithFfmpegInPlace(targetFile)
|
val totalSegments = variantPlaylist.segments.size
|
||||||
|
var mediaSegmentIndex = 0
|
||||||
|
|
||||||
|
var bytesSinceLastSpeedUpdate = 0L
|
||||||
|
var lastSpeedUpdateTime = System.currentTimeMillis()
|
||||||
|
var lastSpeed = 0L
|
||||||
|
|
||||||
|
variantPlaylist.segments.forEachIndexed { index, segment ->
|
||||||
|
if (segment !is HLS.MediaSegment) return@forEachIndexed
|
||||||
|
if (isCancelled) throw CancellationException("Cancelled")
|
||||||
|
|
||||||
|
Logger.i(TAG, "Download '$name' segment $index sequential")
|
||||||
|
|
||||||
|
var rangeStart: Long? = null
|
||||||
|
var rangeLength: Long? = null
|
||||||
|
|
||||||
|
if (segment.bytesLength > 0) {
|
||||||
|
rangeLength = segment.bytesLength
|
||||||
|
|
||||||
|
val urlKey = segment.uri
|
||||||
|
if (segment.bytesStart >= 0) {
|
||||||
|
rangeStart = segment.bytesStart
|
||||||
|
rangeOffsets[urlKey] = segment.bytesStart + segment.bytesLength
|
||||||
|
} else {
|
||||||
|
val offset = rangeOffsets[urlKey] ?: 0L
|
||||||
|
rangeStart = offset
|
||||||
|
rangeOffsets[urlKey] = offset + segment.bytesLength
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var segmentBytes = downloadBytes(segment.uri, rangeStart, rangeLength)
|
||||||
|
|
||||||
|
if (useDecryption) {
|
||||||
|
val kb = keyBytes ?: throw IllegalStateException("Decryption key bytes are missing.")
|
||||||
|
val ivBytes = if (staticIvBytes != null) {
|
||||||
|
staticIvBytes
|
||||||
|
} else {
|
||||||
|
val sequenceNumber = mediaSequence + mediaSegmentIndex
|
||||||
|
buildSequenceIv(sequenceNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
segmentBytes = decryptSegment(segmentBytes, kb, ivBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
val segmentLength = segmentBytes.size.toLong()
|
||||||
|
if (segmentLength > Int.MAX_VALUE) {
|
||||||
|
throw IllegalStateException("HLS media segment too large to handle.")
|
||||||
|
}
|
||||||
|
|
||||||
|
val avgLen = if (index == 0) {
|
||||||
|
segmentLength
|
||||||
|
} else {
|
||||||
|
if (index > 0) downloadedTotalLength / index else segmentLength
|
||||||
|
}
|
||||||
|
val expectedTotal = avgLen * (totalSegments - 1) + segmentLength
|
||||||
|
|
||||||
|
val segmentFile = File(context.cacheDir, "segment-${UUID.randomUUID()}")
|
||||||
|
val outStr = segmentFile.outputStream()
|
||||||
|
try {
|
||||||
|
segmentFiles.add(segmentFile)
|
||||||
|
outStr.write(segmentBytes)
|
||||||
|
} finally {
|
||||||
|
outStr.close()
|
||||||
|
}
|
||||||
|
downloadedTotalLength += segmentLength
|
||||||
|
|
||||||
|
bytesSinceLastSpeedUpdate += segmentLength
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val elapsed = now - lastSpeedUpdateTime
|
||||||
|
if (elapsed >= 500 && bytesSinceLastSpeedUpdate > 0) {
|
||||||
|
lastSpeed = (bytesSinceLastSpeedUpdate * 1000L / elapsed)
|
||||||
|
bytesSinceLastSpeedUpdate = 0
|
||||||
|
lastSpeedUpdateTime = now
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress(expectedTotal, downloadedTotalLength, lastSpeed)
|
||||||
|
mediaSegmentIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
combineSegments(context, segmentFiles, targetFile)
|
||||||
Logger.i(TAG, "Finished HLS Source for $name")
|
Logger.i(TAG, "Finished HLS Source for $name")
|
||||||
} catch (ioex: IOException) {
|
} catch (ioex: IOException) {
|
||||||
if (targetFile.exists())
|
if (targetFile.exists())
|
||||||
@@ -843,19 +881,30 @@ class VideoDownload {
|
|||||||
targetFile.delete()
|
targetFile.delete()
|
||||||
throw ex
|
throw ex
|
||||||
}
|
}
|
||||||
|
finally {
|
||||||
|
for (segmentFile in segmentFiles) {
|
||||||
|
segmentFile.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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;
|
||||||
try{
|
try{
|
||||||
var manifest = source.manifest;
|
var manifest = source.manifest;
|
||||||
if(source.hasGenerate)
|
if(source.hasGenerate)
|
||||||
@@ -864,15 +913,28 @@ 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 executor = if(source is JSSource && source.hasRequestExecutor)
|
val foundTemplate2 = if(downloadType == 3) foundTemplates.find({ it.groupValues[1].contains("audio/") }); else null;
|
||||||
|
val foundTemplateUrl2 = if(foundTemplate2 != null) foundTemplate2.groupValues[2] else null;
|
||||||
|
val foundCues2 = if(foundTemplate2 != null) REGEX_DASH_CUE.findAll(foundTemplate2.groupValues[3]).toList() else null;
|
||||||
|
val foundCues2Downloaded = hashSetOf<MatchResult>();
|
||||||
|
|
||||||
|
if(foundTemplate2 != null)
|
||||||
|
overrideResultAudioSource = LocalAudioSource((videoSource?.name)?.let { it + " [audio]" } ?: "audio", "", 0, 0, foundTemplate2.groupValues[1], REGEX_CODECS.find(foundTemplate2.groupValues[0])?.groupValues?.get(1) ?: "", Language.UNKNOWN);
|
||||||
|
|
||||||
|
executor = if(source is JSSource && source.hasRequestExecutor)
|
||||||
source.getRequestExecutor();
|
source.getRequestExecutor();
|
||||||
else
|
else
|
||||||
null;
|
null;
|
||||||
@@ -886,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());
|
||||||
|
|
||||||
@@ -908,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
|
||||||
@@ -927,13 +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()
|
||||||
}
|
}
|
||||||
|
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();
|
||||||
@@ -1293,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!!);
|
||||||
@@ -1316,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)
|
||||||
@@ -1358,6 +1491,10 @@ class VideoDownload {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun cleanup(){
|
||||||
|
cleanupPluginClient()
|
||||||
|
}
|
||||||
|
|
||||||
enum class State {
|
enum class State {
|
||||||
QUEUED,
|
QUEUED,
|
||||||
PREPARING,
|
PREPARING,
|
||||||
@@ -1381,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? {
|
||||||
@@ -1400,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";
|
||||||
|
|||||||
+31
-9
@@ -102,6 +102,8 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
|||||||
|
|
||||||
private var currentButtonDefinitions: List<ButtonDefinition>? = null;
|
private var currentButtonDefinitions: List<ButtonDefinition>? = null;
|
||||||
|
|
||||||
|
private var moreColumns = 3;
|
||||||
|
|
||||||
constructor(fragment: MenuBottomBarFragment, inflater: LayoutInflater) : super(inflater.context) {
|
constructor(fragment: MenuBottomBarFragment, inflater: LayoutInflater) : super(inflater.context) {
|
||||||
_fragment = fragment;
|
_fragment = fragment;
|
||||||
_inflater = inflater;
|
_inflater = inflater;
|
||||||
@@ -152,6 +154,17 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
|||||||
else {
|
else {
|
||||||
StateApp.instance.setPrivacyMode(true);
|
StateApp.instance.setPrivacyMode(true);
|
||||||
UIDialogs.appToast("Privacy mode enabled");
|
UIDialogs.appToast("Privacy mode enabled");
|
||||||
|
|
||||||
|
if(Settings.instance.other.showPrivacyModeDialog)
|
||||||
|
UIDialogs.showDialog(it.context ?: return@setOnClickListener, R.drawable.incognito, "Privacy Mode",
|
||||||
|
"All requests will be processed anonymously (any logins will be disabled except for the personalized home page), local playback and history tracking will also be disabled.\n\nTap the icon to disable.", null, 0,
|
||||||
|
UIDialogs.Action("Don't show again", {
|
||||||
|
Settings.instance.other.showPrivacyModeDialog = false;
|
||||||
|
Settings.instance.save();
|
||||||
|
}, UIDialogs.ActionStyle.NONE),
|
||||||
|
UIDialogs.Action("Understood", {
|
||||||
|
|
||||||
|
}, UIDialogs.ActionStyle.PRIMARY));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,6 +183,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
|||||||
setMoreVisible(false);
|
setMoreVisible(false);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
moreColumns = columns;
|
||||||
val layoutManager = GridLayoutManager(context, columns, GridLayoutManager.VERTICAL, true);
|
val layoutManager = GridLayoutManager(context, columns, GridLayoutManager.VERTICAL, true);
|
||||||
_layoutMoreButtons.layoutManager = layoutManager;
|
_layoutMoreButtons.layoutManager = layoutManager;
|
||||||
|
|
||||||
@@ -321,29 +335,37 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
|||||||
_layoutMoreButtons.removeAllViews();
|
_layoutMoreButtons.removeAllViews();
|
||||||
|
|
||||||
var insertedButtons = 0;
|
var insertedButtons = 0;
|
||||||
|
//Force settings to be first
|
||||||
|
val settingsIndex = buttons.indexOfFirst { b -> b.id == 7 };
|
||||||
|
if (settingsIndex != -1) {
|
||||||
|
val button = buttons[settingsIndex]
|
||||||
|
buttons.removeAt(settingsIndex)
|
||||||
|
buttons.add(0, button)
|
||||||
|
//insertedButtons++;
|
||||||
|
}
|
||||||
//Force buy to be on top for more buttons
|
//Force buy to be on top for more buttons
|
||||||
val buyIndex = buttons.indexOfFirst { b -> b.id == 98 };
|
val buyIndex = buttons.indexOfFirst { b -> b.id == 98 };
|
||||||
if (buyIndex != -1) {
|
if (buyIndex != -1) {
|
||||||
val button = buttons[buyIndex]
|
val button = buttons[buyIndex]
|
||||||
buttons.removeAt(buyIndex)
|
buttons.removeAt(buyIndex)
|
||||||
buttons.add(0, button)
|
buttons.add(button)
|
||||||
insertedButtons++;
|
//insertedButtons++;
|
||||||
}
|
}
|
||||||
//Force faq to be second
|
//Force faq to be second
|
||||||
val faqIndex = buttons.indexOfFirst { b -> b.id == 97 };
|
val faqIndex = buttons.indexOfFirst { b -> b.id == 97 };
|
||||||
if (faqIndex != -1) {
|
if (faqIndex != -1) {
|
||||||
val button = buttons[faqIndex]
|
val button = buttons[faqIndex]
|
||||||
buttons.removeAt(faqIndex)
|
buttons.removeAt(faqIndex)
|
||||||
buttons.add(if (insertedButtons == 1) 1 else 0, button)
|
buttons.add(button)
|
||||||
insertedButtons++;
|
//insertedButtons++;
|
||||||
}
|
}
|
||||||
//Force privacy to be third
|
//Force privacy to be third
|
||||||
val privacyIndex = buttons.indexOfFirst { b -> b.id == 96 };
|
val privacyIndex = buttons.indexOfFirst { b -> b.id == 96 };
|
||||||
if (privacyIndex != -1) {
|
if (privacyIndex != -1) {
|
||||||
val button = buttons[privacyIndex]
|
val button = buttons[privacyIndex]
|
||||||
buttons.removeAt(privacyIndex)
|
buttons.removeAt(privacyIndex)
|
||||||
buttons.add(if (insertedButtons == 2) 2 else (if(insertedButtons == 1) 1 else 0), button)
|
buttons.add(button)
|
||||||
insertedButtons++;
|
//insertedButtons++;
|
||||||
}
|
}
|
||||||
|
|
||||||
val newButtons = mutableListOf<MenuButtonItem>();
|
val newButtons = mutableListOf<MenuButtonItem>();
|
||||||
@@ -591,7 +613,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
|||||||
ButtonDefinition(8, R.drawable.ic_chat, R.drawable.ic_chat_filled, R.string.comments, canToggle = true, { it.currentMain is CommentsFragment }, { it.navigate<CommentsFragment>(withHistory = false) }),
|
ButtonDefinition(8, R.drawable.ic_chat, R.drawable.ic_chat_filled, R.string.comments, canToggle = true, { it.currentMain is CommentsFragment }, { it.navigate<CommentsFragment>(withHistory = false) }),
|
||||||
ButtonDefinition(9, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscription_group_menu, canToggle = true, { it.currentMain is SubscriptionGroupListFragment }, { it.navigate<SubscriptionGroupListFragment>(withHistory = false) }),
|
ButtonDefinition(9, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscription_group_menu, canToggle = true, { it.currentMain is SubscriptionGroupListFragment }, { it.navigate<SubscriptionGroupListFragment>(withHistory = false) }),
|
||||||
ButtonDefinition(10, R.drawable.ic_help_square, R.drawable.ic_help_square_fill, R.string.tutorials, canToggle = true, { it.currentMain is TutorialFragment }, { it.navigate<TutorialFragment>(withHistory = false) }),
|
ButtonDefinition(10, R.drawable.ic_help_square, R.drawable.ic_help_square_fill, R.string.tutorials, canToggle = true, { it.currentMain is TutorialFragment }, { it.navigate<TutorialFragment>(withHistory = false) }),
|
||||||
ButtonDefinition(7, R.drawable.ic_settings, R.drawable.ic_settings_filled, R.string.settings, canToggle = false, { false }, {
|
ButtonDefinition(7, R.drawable.ic_settings, R.drawable.ic_settings_filled, R.string.settings, canToggle = false, { it.currentMain is SettingsFragment }, {
|
||||||
it.navigate<SettingsFragment>();
|
it.navigate<SettingsFragment>();
|
||||||
/*
|
/*
|
||||||
val c = it.context ?: return@ButtonDefinition;
|
val c = it.context ?: return@ButtonDefinition;
|
||||||
@@ -602,7 +624,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
|||||||
if (c is Activity) {
|
if (c is Activity) {
|
||||||
c.overridePendingTransition(R.anim.slide_in_up, R.anim.slide_darken);
|
c.overridePendingTransition(R.anim.slide_in_up, R.anim.slide_darken);
|
||||||
}*/
|
}*/
|
||||||
}),
|
}),/*
|
||||||
ButtonDefinition(96, R.drawable.ic_disabled_visible, R.drawable.ic_disabled_visible, R.string.privacy_mode, canToggle = true, { false }, {
|
ButtonDefinition(96, R.drawable.ic_disabled_visible, R.drawable.ic_disabled_visible, R.string.privacy_mode, canToggle = true, { false }, {
|
||||||
UIDialogs.showDialog(it.context ?: return@ButtonDefinition, R.drawable.ic_disabled_visible_purple, "Privacy Mode",
|
UIDialogs.showDialog(it.context ?: return@ButtonDefinition, R.drawable.ic_disabled_visible_purple, "Privacy Mode",
|
||||||
"All requests will be processed anonymously (any logins will be disabled except for the personalized home page), local playback and history tracking will also be disabled.\n\nTap the icon to disable.", null, 0,
|
"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,
|
||||||
@@ -612,7 +634,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
|||||||
UIDialogs.Action("Enable", {
|
UIDialogs.Action("Enable", {
|
||||||
StateApp.instance.setPrivacyMode(true);
|
StateApp.instance.setPrivacyMode(true);
|
||||||
}, UIDialogs.ActionStyle.PRIMARY));
|
}, UIDialogs.ActionStyle.PRIMARY));
|
||||||
}),
|
}),*/
|
||||||
ButtonDefinition(97, R.drawable.ic_quiz, R.drawable.ic_quiz_fill, R.string.faq, canToggle = true, { false }, {
|
ButtonDefinition(97, R.drawable.ic_quiz, R.drawable.ic_quiz_fill, R.string.faq, canToggle = true, { false }, {
|
||||||
it.navigate<BrowserFragment>(Settings.URL_FAQ, withHistory = false);
|
it.navigate<BrowserFragment>(Settings.URL_FAQ, withHistory = false);
|
||||||
})
|
})
|
||||||
|
|||||||
+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),
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
|||||||
protected val _toolbarContentView: LinearLayout;
|
protected val _toolbarContentView: LinearLayout;
|
||||||
protected val _bottomContentView: LinearLayout;
|
protected val _bottomContentView: LinearLayout;
|
||||||
|
|
||||||
private var _loading: Boolean = true;
|
private var _loading: Boolean = false;
|
||||||
|
|
||||||
private val _pagerLock = Object();
|
private val _pagerLock = Object();
|
||||||
private var _cache: ItemCache<TResult>? = null;
|
private var _cache: ItemCache<TResult>? = null;
|
||||||
@@ -180,10 +180,9 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
|||||||
|
|
||||||
val visibleItemCount = _recyclerResults.childCount;
|
val visibleItemCount = _recyclerResults.childCount;
|
||||||
val firstVisibleItem = recyclerData.layoutManager.findFirstVisibleItemPosition()
|
val firstVisibleItem = recyclerData.layoutManager.findFirstVisibleItemPosition()
|
||||||
//Logger.i(TAG, "onScrolled loadNextPage visibleItemCount=$visibleItemCount firstVisibleItem=$visibleItemCount")
|
//Logger.i(TAG, "onScrolled loadNextPage(): firstVisibleItem=$firstVisibleItem visibleItemCount=$visibleItemCount visibleThreshold=$visibleThreshold recyclerData.results.size=${recyclerData.results.size}")
|
||||||
|
|
||||||
if (!_loading && firstVisibleItem + visibleItemCount + visibleThreshold >= recyclerData.results.size && firstVisibleItem > 0) {
|
if (!_loading && firstVisibleItem + visibleItemCount + visibleThreshold >= recyclerData.results.size) {
|
||||||
//Logger.i(TAG, "onScrolled loadNextPage(): firstVisibleItem=$firstVisibleItem visibleItemCount=$visibleItemCount visibleThreshold=$visibleThreshold recyclerData.results.size=${recyclerData.results.size}")
|
|
||||||
loadNextPage();
|
loadNextPage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -197,57 +196,44 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun ensureEnoughContentVisible(filteredResults: List<TConverted>) {
|
private fun ensureEnoughContentVisible(filteredResults: List<TConverted>) {
|
||||||
val canScroll = if (recyclerData.results.isEmpty()) false else {
|
_recyclerResults.post {
|
||||||
val height = resources.displayMetrics.heightPixels;
|
val canScroll = _recyclerResults.canScrollVertically(1)
|
||||||
|
Logger.i(
|
||||||
|
TAG,
|
||||||
|
"ensureEnoughContentVisible loadNextPage canScroll=$canScroll _automaticNextPageCounter=$_automaticNextPageCounter"
|
||||||
|
)
|
||||||
|
if (!canScroll || filteredResults.isEmpty()) {
|
||||||
|
_automaticNextPageCounter++
|
||||||
|
if (_automaticNextPageCounter < _automaticBackoff.size) {
|
||||||
|
if (_automaticNextPageCounter > 0) {
|
||||||
|
val automaticNextPageCounterSaved = _automaticNextPageCounter;
|
||||||
|
fragment.lifecycleScope.launch(Dispatchers.Default) {
|
||||||
|
val backoff = _automaticBackoff[Math.min(
|
||||||
|
_automaticBackoff.size - 1,
|
||||||
|
_automaticNextPageCounter
|
||||||
|
)];
|
||||||
|
|
||||||
val layoutManager = recyclerData.layoutManager
|
|
||||||
val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition()
|
|
||||||
val firstVisibleItemView = if(firstVisibleItemPosition != RecyclerView.NO_POSITION) layoutManager.findViewByPosition(firstVisibleItemPosition) else null;
|
|
||||||
val lastVisibleItemPosition = layoutManager.findLastCompletelyVisibleItemPosition();
|
|
||||||
val lastVisibleItemView = if(lastVisibleItemPosition != RecyclerView.NO_POSITION) layoutManager.findViewByPosition(lastVisibleItemPosition) else null;
|
|
||||||
val rows = if(recyclerData.layoutManager is GridLayoutManager) Math.max(1, recyclerData.results.size / recyclerData.layoutManager.spanCount) else 1;
|
|
||||||
val rowsHeight = (firstVisibleItemView?.height ?: 0) * rows;
|
|
||||||
if(lastVisibleItemView != null && lastVisibleItemPosition == (recyclerData.results.size - 1)) {
|
|
||||||
false;
|
|
||||||
}
|
|
||||||
else if (firstVisibleItemView != null && height != null && rowsHeight < height) {
|
|
||||||
false;
|
|
||||||
} else {
|
|
||||||
true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.i(TAG, "ensureEnoughContentVisible loadNextPage canScroll=$canScroll _automaticNextPageCounter=$_automaticNextPageCounter")
|
|
||||||
if (!canScroll || filteredResults.isEmpty()) {
|
|
||||||
_automaticNextPageCounter++
|
|
||||||
if(_automaticNextPageCounter < _automaticBackoff.size) {
|
|
||||||
if(_automaticNextPageCounter > 0) {
|
|
||||||
val automaticNextPageCounterSaved = _automaticNextPageCounter;
|
|
||||||
fragment.lifecycleScope.launch(Dispatchers.Default) {
|
|
||||||
val backoff = _automaticBackoff[Math.min(_automaticBackoff.size - 1, _automaticNextPageCounter)];
|
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
setLoading(true);
|
|
||||||
}
|
|
||||||
delay(backoff.toLong());
|
|
||||||
if(automaticNextPageCounterSaved == _automaticNextPageCounter) {
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
loadNextPage();
|
setLoading(true);
|
||||||
|
}
|
||||||
|
delay(backoff.toLong());
|
||||||
|
if (automaticNextPageCounterSaved == _automaticNextPageCounter) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
loadNextPage();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
} else
|
||||||
withContext(Dispatchers.Main) {
|
loadNextPage();
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else
|
} else {
|
||||||
loadNextPage();
|
Logger.i(TAG, "ensureEnoughContentVisible automaticNextPageCounter reset");
|
||||||
|
_automaticNextPageCounter = 0;
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
Logger.i(TAG, "ensureEnoughContentVisible automaticNextPageCounter reset");
|
|
||||||
_automaticNextPageCounter = 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fun resetAutomaticNextPageCounter(){
|
fun resetAutomaticNextPageCounter(){
|
||||||
@@ -484,6 +470,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
|||||||
recyclerData.resultsUnfiltered.addAll(toAdd);
|
recyclerData.resultsUnfiltered.addAll(toAdd);
|
||||||
recyclerData.adapter.notifyDataSetChanged();
|
recyclerData.adapter.notifyDataSetChanged();
|
||||||
recyclerData.loadedFeedStyle = feedStyle;
|
recyclerData.loadedFeedStyle = feedStyle;
|
||||||
|
setLoading(false)
|
||||||
if(pager.hasMorePages())
|
if(pager.hasMorePages())
|
||||||
ensureEnoughContentVisible(filteredResults)
|
ensureEnoughContentVisible(filteredResults)
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-4
@@ -124,11 +124,10 @@ class LibraryFilesFragment : MainFragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
fun leaveDirectory() {
|
fun leaveDirectory() {
|
||||||
if(navStack.size > 1) {
|
if (navStack.size > 1) {
|
||||||
navStack.removeLast();
|
navStack.removeAt(navStack.size - 1)
|
||||||
openDirectory(navStack.last());
|
openDirectory(navStack.last())
|
||||||
}
|
}
|
||||||
else {}
|
|
||||||
}
|
}
|
||||||
fun openDirectory(stack: FileStack, addToStack: Boolean = false) {
|
fun openDirectory(stack: FileStack, addToStack: Boolean = false) {
|
||||||
if(addToStack)
|
if(addToStack)
|
||||||
|
|||||||
-1
@@ -96,7 +96,6 @@ class LibraryVideosFragment : MainFragment() {
|
|||||||
fun onShown() {
|
fun onShown() {
|
||||||
val initialAlbums = StateLibrary.instance.getAlbums();
|
val initialAlbums = StateLibrary.instance.getAlbums();
|
||||||
Logger.i(TAG, "Initial album count: " + initialAlbums.size);
|
Logger.i(TAG, "Initial album count: " + initialAlbums.size);
|
||||||
val buckets = StateLibrary.instance.getVideoBucketNames();
|
|
||||||
setPager(StateLibrary.instance.getVideos(fragment._toggleBuckets));
|
setPager(StateLibrary.instance.getVideos(fragment._toggleBuckets));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
@@ -16,6 +16,7 @@ import androidx.lifecycle.lifecycleScope
|
|||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
|
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||||
import com.futo.platformplayer.*
|
import com.futo.platformplayer.*
|
||||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
|
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
|
||||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
|
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
|
||||||
@@ -363,6 +364,7 @@ class RemotePlaylistFragment : MainFragment() {
|
|||||||
_imagePlaylistThumbnail.let {
|
_imagePlaylistThumbnail.let {
|
||||||
Glide.with(it)
|
Glide.with(it)
|
||||||
.load(video.thumbnails.getHQThumbnail())
|
.load(video.thumbnails.getHQThumbnail())
|
||||||
|
.withMaxSizePx()
|
||||||
.placeholder(R.drawable.placeholder_video_thumbnail)
|
.placeholder(R.drawable.placeholder_video_thumbnail)
|
||||||
.crossfade()
|
.crossfade()
|
||||||
.into(it);
|
.into(it);
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package com.futo.platformplayer.fragment.mainactivity.main
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.graphics.Bitmap
|
||||||
import android.graphics.drawable.Animatable
|
import android.graphics.drawable.Animatable
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
@@ -13,10 +15,15 @@ import android.widget.ImageView
|
|||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.constraintlayout.widget.ConstraintLayout
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
import androidx.core.graphics.drawable.toDrawable
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.media3.common.C
|
import androidx.media3.common.C
|
||||||
import androidx.media3.common.Format
|
import androidx.media3.common.Format
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import com.bumptech.glide.Glide
|
||||||
|
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||||
|
import com.bumptech.glide.request.target.CustomTarget
|
||||||
|
import com.bumptech.glide.request.transition.Transition
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.Settings
|
import com.futo.platformplayer.Settings
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
@@ -71,6 +78,7 @@ import com.futo.platformplayer.views.video.FutoShortPlayer
|
|||||||
import com.futo.platformplayer.views.video.FutoVideoPlayerBase
|
import com.futo.platformplayer.views.video.FutoVideoPlayerBase
|
||||||
import com.futo.platformplayer.views.video.FutoVideoPlayerBase.Companion.PREFERED_AUDIO_CONTAINERS
|
import com.futo.platformplayer.views.video.FutoVideoPlayerBase.Companion.PREFERED_AUDIO_CONTAINERS
|
||||||
import com.futo.platformplayer.views.video.FutoVideoPlayerBase.Companion.PREFERED_VIDEO_CONTAINERS
|
import com.futo.platformplayer.views.video.FutoVideoPlayerBase.Companion.PREFERED_VIDEO_CONTAINERS
|
||||||
|
import com.futo.platformplayer.withMaxSizePx
|
||||||
import com.futo.polycentric.core.ApiMethods
|
import com.futo.polycentric.core.ApiMethods
|
||||||
import com.futo.polycentric.core.ContentType
|
import com.futo.polycentric.core.ContentType
|
||||||
import com.futo.polycentric.core.Models
|
import com.futo.polycentric.core.Models
|
||||||
@@ -851,9 +859,8 @@ class ShortView : FrameLayout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val thumbnail = videoDetails.thumbnails.getHQThumbnail()
|
val thumbnail = videoDetails.thumbnails.getHQThumbnail()
|
||||||
/*
|
|
||||||
if (videoSource == null && !thumbnail.isNullOrBlank()) Glide.with(context).asBitmap()
|
if (videoSource == null && !thumbnail.isNullOrBlank()) Glide.with(context).asBitmap()
|
||||||
.load(thumbnail).into(object : CustomTarget<Bitmap>() {
|
.load(thumbnail).withMaxSizePx().into(object : CustomTarget<Bitmap>() {
|
||||||
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
||||||
player.setArtwork(resource.toDrawable(resources))
|
player.setArtwork(resource.toDrawable(resources))
|
||||||
}
|
}
|
||||||
@@ -863,7 +870,6 @@ class ShortView : FrameLayout {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
else player.setArtwork(null)
|
else player.setArtwork(null)
|
||||||
*/
|
|
||||||
|
|
||||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
+36
-27
@@ -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;
|
||||||
@@ -356,38 +356,46 @@ class VideoDetailFragment() : MainFragment() {
|
|||||||
override fun onTransitionChange(motionLayout: MotionLayout?, startId: Int, endId: Int, progress: Float) {
|
override fun onTransitionChange(motionLayout: MotionLayout?, startId: Int, endId: Int, progress: Float) {
|
||||||
_viewDetail?.stopAllGestures()
|
_viewDetail?.stopAllGestures()
|
||||||
|
|
||||||
if (state != State.MINIMIZED && progress < 0.1) {
|
if (!isTransitioning && (progress < 0.9 && progress > 0.1)) {
|
||||||
state = State.MINIMIZED;
|
|
||||||
isMinimizingFromFullScreen = false
|
|
||||||
onMinimize.emit();
|
|
||||||
}
|
|
||||||
else if (state != State.MAXIMIZED && progress > 0.9) {
|
|
||||||
if (_isInitialMaximize) {
|
|
||||||
state = State.CLOSED;
|
|
||||||
_isInitialMaximize = false;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
state = State.MAXIMIZED;
|
|
||||||
onMaximized.emit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isTransitioning && (progress > 0.95 || progress < 0.05)) {
|
|
||||||
isTransitioning = false;
|
|
||||||
onTransitioning.emit(isTransitioning);
|
|
||||||
|
|
||||||
if(isInPictureInPicture) leavePictureInPictureMode(false); //Workaround to prevent getting stuck in p2p
|
|
||||||
}
|
|
||||||
else if (!isTransitioning && (progress < 0.95 && progress > 0.05)) {
|
|
||||||
isTransitioning = true;
|
isTransitioning = true;
|
||||||
onTransitioning.emit(isTransitioning);
|
onTransitioning.emit(isTransitioning);
|
||||||
|
|
||||||
if(isInPictureInPicture) leavePictureInPictureMode(false); //Workaround to prevent getting stuck in p2p
|
if(isInPictureInPicture) leavePictureInPictureMode(false); //Workaround to prevent getting stuck in p2p
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
override fun onTransitionCompleted(motionLayout: MotionLayout?, currentId: Int) { }
|
override fun onTransitionCompleted(motionLayout: MotionLayout?, currentId: Int) {
|
||||||
|
val progress = motionLayout?.progress ?: return;
|
||||||
|
|
||||||
|
if (state != State.MINIMIZED && progress < 0.1) {
|
||||||
|
state = State.MINIMIZED;
|
||||||
|
isMinimizingFromFullScreen = false
|
||||||
|
onMinimize.emit();
|
||||||
|
}
|
||||||
|
else if (state != State.MAXIMIZED && progress > 0.9) {
|
||||||
|
state = State.MAXIMIZED;
|
||||||
|
onMaximized.emit();
|
||||||
|
/*
|
||||||
|
if (_isInitialMaximize) {
|
||||||
|
//state = State.CLOSED; Causes issues? might no longer be needed
|
||||||
|
_isInitialMaximize = false;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
state = State.MAXIMIZED;
|
||||||
|
onMaximized.emit();
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTransitioning && (progress > 0.6 || progress < 0.4)) {
|
||||||
|
isTransitioning = false;
|
||||||
|
onTransitioning.emit(isTransitioning);
|
||||||
|
|
||||||
|
if(isInPictureInPicture) leavePictureInPictureMode(false); //Workaround to prevent getting stuck in p2p
|
||||||
|
}
|
||||||
|
}
|
||||||
override fun onTransitionStarted(p0: MotionLayout?, p1: Int, p2: Int) { }
|
override fun onTransitionStarted(p0: MotionLayout?, p1: Int, p2: Int) { }
|
||||||
override fun onTransitionTrigger(p0: MotionLayout?, p1: Int, p2: Boolean, p3: Float) { }
|
override fun onTransitionTrigger(p0: MotionLayout?, p1: Int, p2: Boolean, p3: Float) {
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
_view?.let {
|
_view?.let {
|
||||||
@@ -446,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")
|
||||||
|
|||||||
+75
-17
@@ -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
|
||||||
@@ -42,6 +43,7 @@ import androidx.media3.datasource.HttpDataSource
|
|||||||
import androidx.media3.ui.PlayerControlView
|
import androidx.media3.ui.PlayerControlView
|
||||||
import androidx.media3.ui.TimeBar
|
import androidx.media3.ui.TimeBar
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
|
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||||
import com.bumptech.glide.request.target.CustomTarget
|
import com.bumptech.glide.request.target.CustomTarget
|
||||||
import com.bumptech.glide.request.transition.Transition
|
import com.bumptech.glide.request.transition.Transition
|
||||||
import com.futo.platformplayer.BuildConfig
|
import com.futo.platformplayer.BuildConfig
|
||||||
@@ -161,6 +163,7 @@ import com.futo.platformplayer.views.subscriptions.SubscribeButton
|
|||||||
import com.futo.platformplayer.views.video.FutoVideoPlayer
|
import com.futo.platformplayer.views.video.FutoVideoPlayer
|
||||||
import com.futo.platformplayer.views.video.FutoVideoPlayerBase
|
import com.futo.platformplayer.views.video.FutoVideoPlayerBase
|
||||||
import com.futo.platformplayer.views.videometa.UpNextView
|
import com.futo.platformplayer.views.videometa.UpNextView
|
||||||
|
import com.futo.platformplayer.withMaxSizePx
|
||||||
import com.futo.polycentric.core.ApiMethods
|
import com.futo.polycentric.core.ApiMethods
|
||||||
import com.futo.polycentric.core.ContentType
|
import com.futo.polycentric.core.ContentType
|
||||||
import com.futo.polycentric.core.Models
|
import com.futo.polycentric.core.Models
|
||||||
@@ -552,12 +555,12 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
_buttonMore = buttonMore;
|
_buttonMore = buttonMore;
|
||||||
updateMoreButtons();
|
updateMoreButtons();
|
||||||
|
|
||||||
val handleLoaderGameVisibilityChanged = { b: Boolean ->
|
val handleLoaderGameVisibilityChanged: (Boolean) -> Unit = { b: Boolean ->
|
||||||
_loaderGameVisible = b
|
_loaderGameVisible = b
|
||||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||||
onShouldEnterPictureInPictureChanged.emit()
|
onShouldEnterPictureInPictureChanged.emit()
|
||||||
|
updateResumeVisibilityFor(lastPositionMilliseconds)
|
||||||
}
|
}
|
||||||
updateResumeVisibilityFor(lastPositionMilliseconds)
|
|
||||||
}
|
}
|
||||||
_player.loaderGameVisibilityChanged.subscribe(handleLoaderGameVisibilityChanged)
|
_player.loaderGameVisibilityChanged.subscribe(handleLoaderGameVisibilityChanged)
|
||||||
_cast.loaderGameVisibilityChanged.subscribe(handleLoaderGameVisibilityChanged)
|
_cast.loaderGameVisibilityChanged.subscribe(handleLoaderGameVisibilityChanged)
|
||||||
@@ -721,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);
|
||||||
@@ -1271,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);
|
||||||
@@ -2049,7 +2055,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
} else {
|
} else {
|
||||||
val thumbnail = video.thumbnails.getHQThumbnail();
|
val thumbnail = video.thumbnails.getHQThumbnail();
|
||||||
if ((videoSource == null) && !thumbnail.isNullOrBlank()) // || _player.isAudioMode
|
if ((videoSource == null) && !thumbnail.isNullOrBlank()) // || _player.isAudioMode
|
||||||
Glide.with(context).asBitmap().load(thumbnail)
|
Glide.with(context).asBitmap().load(thumbnail).withMaxSizePx()
|
||||||
.into(object: CustomTarget<Bitmap>() {
|
.into(object: CustomTarget<Bitmap>() {
|
||||||
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
||||||
_player.setArtwork(BitmapDrawable(resources, resource));
|
_player.setArtwork(BitmapDrawable(resources, resource));
|
||||||
@@ -2418,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()
|
||||||
@@ -2526,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,
|
||||||
@@ -2539,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",
|
||||||
@@ -3357,9 +3413,11 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
false
|
false
|
||||||
else {
|
else {
|
||||||
isLoginStop = true;
|
isLoginStop = true;
|
||||||
|
onMinimize.emit();
|
||||||
StatePlugins.instance.loginPlugin(context, id) {
|
StatePlugins.instance.loginPlugin(context, id) {
|
||||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||||
fetchVideo();
|
fetchVideo();
|
||||||
|
onMaximize.emit(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
@@ -14,6 +14,7 @@ import android.widget.TextView
|
|||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.core.view.setPadding
|
import androidx.core.view.setPadding
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
|
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.UISlideOverlays
|
import com.futo.platformplayer.UISlideOverlays
|
||||||
@@ -28,6 +29,7 @@ import com.futo.platformplayer.toHumanDuration
|
|||||||
import com.futo.platformplayer.toHumanTime
|
import com.futo.platformplayer.toHumanTime
|
||||||
import com.futo.platformplayer.views.SearchView
|
import com.futo.platformplayer.views.SearchView
|
||||||
import com.futo.platformplayer.views.lists.VideoListEditorView
|
import com.futo.platformplayer.views.lists.VideoListEditorView
|
||||||
|
import com.futo.platformplayer.withMaxSizePx
|
||||||
|
|
||||||
abstract class VideoListEditorView : LinearLayout {
|
abstract class VideoListEditorView : LinearLayout {
|
||||||
private var _videoListEditorView: VideoListEditorView;
|
private var _videoListEditorView: VideoListEditorView;
|
||||||
@@ -211,6 +213,7 @@ abstract class VideoListEditorView : LinearLayout {
|
|||||||
_imagePlaylistThumbnail.let {
|
_imagePlaylistThumbnail.let {
|
||||||
Glide.with(it)
|
Glide.with(it)
|
||||||
.load(video.thumbnails.getHQThumbnail())
|
.load(video.thumbnails.getHQThumbnail())
|
||||||
|
.withMaxSizePx()
|
||||||
.placeholder(R.drawable.placeholder_video_thumbnail)
|
.placeholder(R.drawable.placeholder_video_thumbnail)
|
||||||
.crossfade()
|
.crossfade()
|
||||||
.into(it);
|
.into(it);
|
||||||
|
|||||||
+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 };
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import android.graphics.drawable.Drawable
|
|||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.RequestBuilder
|
import com.bumptech.glide.RequestBuilder
|
||||||
|
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||||
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions
|
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions
|
||||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||||
|
import com.futo.platformplayer.withMaxSizePx
|
||||||
|
|
||||||
class GlideHelper {
|
class GlideHelper {
|
||||||
|
|
||||||
@@ -14,7 +16,7 @@ class GlideHelper {
|
|||||||
fun ImageView.loadThumbnails(thumbnails: Thumbnails, isHQ: Boolean = true, continuation: ((RequestBuilder<Drawable>) -> Unit)? = null) {
|
fun ImageView.loadThumbnails(thumbnails: Thumbnails, isHQ: Boolean = true, continuation: ((RequestBuilder<Drawable>) -> Unit)? = null) {
|
||||||
val url = if(isHQ) thumbnails.getHQThumbnail() ?: thumbnails.getLQThumbnail() else thumbnails.getLQThumbnail();
|
val url = if(isHQ) thumbnails.getHQThumbnail() ?: thumbnails.getLQThumbnail() else thumbnails.getLQThumbnail();
|
||||||
|
|
||||||
val req = Glide.with(this).load(url);
|
val req = Glide.with(this).load(url).withMaxSizePx()
|
||||||
|
|
||||||
if (thumbnails.hasMultiple() && false) { //TODO: Resolve issue where fallback triggered on second loads?
|
if (thumbnails.hasMultiple() && false) { //TODO: Resolve issue where fallback triggered on second loads?
|
||||||
val fallbackUrl = if (isHQ) thumbnails.getLQThumbnail() else thumbnails.getHQThumbnail();
|
val fallbackUrl = if (isHQ) thumbnails.getLQThumbnail() else thumbnails.getHQThumbnail();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.futo.platformplayer.receivers
|
package com.futo.platformplayer.receivers
|
||||||
|
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
@@ -26,14 +27,24 @@ class InstallReceiver : BroadcastReceiver() {
|
|||||||
val activityIntent: Intent? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
val activityIntent: Intent? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
|
intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
|
||||||
} else {
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
intent.getParcelableExtra(Intent.EXTRA_INTENT)
|
intent.getParcelableExtra(Intent.EXTRA_INTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activityIntent == null) {
|
if (activityIntent == null) {
|
||||||
Logger.w(TAG, "Received STATUS_PENDING_USER_ACTION and activity intent is null.")
|
Logger.w(TAG, "Received STATUS_PENDING_USER_ACTION and activity intent is null.")
|
||||||
|
onReceiveResult.emit(context.getString(R.string.install_failed_device_installer_broken))
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
context.startActivity(activityIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
|
||||||
|
activityIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
|
||||||
|
try {
|
||||||
|
context.startActivity(activityIntent)
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
Logger.e(TAG, "System installer cannot handle CONFIRM_INSTALL intent. ROM is broken; falling back / reporting error.", e)
|
||||||
|
onReceiveResult.emit(context.getString(R.string.install_failed_device_installer_broken))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
PackageInstaller.STATUS_SUCCESS -> onReceiveResult.emit(null);
|
PackageInstaller.STATUS_SUCCESS -> onReceiveResult.emit(null);
|
||||||
PackageInstaller.STATUS_FAILURE -> onReceiveResult.emit(context.getString(R.string.general_failure));
|
PackageInstaller.STATUS_FAILURE -> onReceiveResult.emit(context.getString(R.string.general_failure));
|
||||||
@@ -45,6 +56,7 @@ class InstallReceiver : BroadcastReceiver() {
|
|||||||
PackageInstaller.STATUS_FAILURE_STORAGE -> onReceiveResult.emit(context.getString(R.string.not_enough_storage));
|
PackageInstaller.STATUS_FAILURE_STORAGE -> onReceiveResult.emit(context.getString(R.string.not_enough_storage));
|
||||||
else -> {
|
else -> {
|
||||||
val msg = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE);
|
val msg = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE);
|
||||||
|
Logger.w(TAG, "Received unknown install status $status, message=$msg")
|
||||||
onReceiveResult.emit(msg)
|
onReceiveResult.emit(msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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?
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import android.util.Log
|
|||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
|
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||||
import com.bumptech.glide.request.target.CustomTarget
|
import com.bumptech.glide.request.target.CustomTarget
|
||||||
import com.bumptech.glide.request.transition.Transition
|
import com.bumptech.glide.request.transition.Transition
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
@@ -38,6 +39,7 @@ import com.futo.platformplayer.receivers.MediaControlReceiver
|
|||||||
import com.futo.platformplayer.states.StatePlatform
|
import com.futo.platformplayer.states.StatePlatform
|
||||||
import com.futo.platformplayer.states.StatePlayer
|
import com.futo.platformplayer.states.StatePlayer
|
||||||
import com.futo.platformplayer.stores.FragmentedStorage
|
import com.futo.platformplayer.stores.FragmentedStorage
|
||||||
|
import com.futo.platformplayer.withMaxSizePx
|
||||||
|
|
||||||
class MediaPlaybackService : Service() {
|
class MediaPlaybackService : Service() {
|
||||||
private val TAG = "MediaPlaybackService";
|
private val TAG = "MediaPlaybackService";
|
||||||
@@ -172,21 +174,26 @@ class MediaPlaybackService : Service() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun closeMediaSession() {
|
fun closeMediaSession() {
|
||||||
Logger.v(TAG, "closeMediaSession");
|
Logger.v(TAG, "closeMediaSession")
|
||||||
stopForeground(STOP_FOREGROUND_REMOVE);
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
|
||||||
abandonAudioFocus()
|
abandonAudioFocus()
|
||||||
|
|
||||||
val notifManager = _notificationManager;
|
val notifManager = _notificationManager
|
||||||
Logger.i(TAG, "Cancelling playback notification (notifManager: ${notifManager != null})");
|
Logger.i(TAG, "Cancelling playback notification (notifManager: ${notifManager != null})")
|
||||||
notifManager?.cancel(MEDIA_NOTIF_ID);
|
notifManager?.cancel(MEDIA_NOTIF_ID)
|
||||||
_notif_last_video = null;
|
|
||||||
_notif_last_bitmap = null;
|
|
||||||
_mediaSession = null;
|
|
||||||
|
|
||||||
if(_instance == this)
|
_notif_last_video = null
|
||||||
_instance = null;
|
_notif_last_bitmap = null
|
||||||
this.stopSelf();
|
|
||||||
|
_mediaSession?.isActive = false
|
||||||
|
_mediaSession?.release()
|
||||||
|
_mediaSession = null
|
||||||
|
|
||||||
|
if (_instance == this)
|
||||||
|
_instance = null
|
||||||
|
|
||||||
|
stopSelf()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateMediaSession(videoUpdated: IPlatformVideo?) {
|
fun updateMediaSession(videoUpdated: IPlatformVideo?) {
|
||||||
@@ -206,37 +213,37 @@ class MediaPlaybackService : Service() {
|
|||||||
if(_notificationChannel == null || _mediaSession == null)
|
if(_notificationChannel == null || _mediaSession == null)
|
||||||
setupNotificationRequirements();
|
setupNotificationRequirements();
|
||||||
|
|
||||||
_mediaSession?.setMetadata(
|
updateMediaMetadata(video, lastBitmap)
|
||||||
MediaMetadataCompat.Builder()
|
|
||||||
.putString(MediaMetadata.METADATA_KEY_ARTIST, video.author.name)
|
|
||||||
.putString(MediaMetadata.METADATA_KEY_TITLE, video.name)
|
|
||||||
.putLong(MediaMetadata.METADATA_KEY_DURATION, video.duration * 1000)
|
|
||||||
.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, lastBitmap)
|
|
||||||
.build());
|
|
||||||
|
|
||||||
val thumbnail = video.thumbnails.getHQThumbnail();
|
val thumbnail = video.thumbnails.getHQThumbnail();
|
||||||
|
|
||||||
_notif_last_video = video;
|
_notif_last_video = video;
|
||||||
|
|
||||||
if(isUpdating)
|
if(isUpdating)
|
||||||
notifyMediaSession(video, _notif_last_bitmap);
|
notifyMediaSession(video, _notif_last_bitmap?.takeIf { !it.isRecycled });
|
||||||
else if(thumbnail != null) {
|
else if(thumbnail != null) {
|
||||||
notifyMediaSession(video, null);
|
notifyMediaSession(video, null);
|
||||||
val tag = video;
|
val tag = video;
|
||||||
Glide.with(this).asBitmap()
|
Glide.with(this).asBitmap()
|
||||||
.load(thumbnail)
|
.load(thumbnail)
|
||||||
|
.withMaxSizePx()
|
||||||
.into(object: CustomTarget<Bitmap>() {
|
.into(object: CustomTarget<Bitmap>() {
|
||||||
override fun onResourceReady(resource: Bitmap,transition: Transition<in Bitmap>?) {
|
override fun onResourceReady(resource: Bitmap,transition: Transition<in Bitmap>?) {
|
||||||
if(tag == _notif_last_video) {
|
if (tag != _notif_last_video) return
|
||||||
notifyMediaSession(video, resource)
|
if (resource.isRecycled) {
|
||||||
_mediaSession?.setMetadata(
|
notifyMediaSession(video, null)
|
||||||
MediaMetadataCompat.Builder()
|
return
|
||||||
.putString(MediaMetadata.METADATA_KEY_ARTIST, video.author.name)
|
|
||||||
.putString(MediaMetadata.METADATA_KEY_TITLE, video.name)
|
|
||||||
.putLong(MediaMetadata.METADATA_KEY_DURATION, video.duration * 1000)
|
|
||||||
.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, resource)
|
|
||||||
.build());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val albumArt = resource.copy(
|
||||||
|
resource.config ?: Bitmap.Config.ARGB_8888,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
|
||||||
|
_notif_last_bitmap = albumArt
|
||||||
|
|
||||||
|
notifyMediaSession(video, albumArt)
|
||||||
|
updateMediaMetadata(video, albumArt)
|
||||||
}
|
}
|
||||||
override fun onLoadCleared(placeholder: Drawable?) {
|
override fun onLoadCleared(placeholder: Drawable?) {
|
||||||
if(tag == _notif_last_video)
|
if(tag == _notif_last_video)
|
||||||
@@ -247,6 +254,19 @@ class MediaPlaybackService : Service() {
|
|||||||
else
|
else
|
||||||
notifyMediaSession(video, null);
|
notifyMediaSession(video, null);
|
||||||
}
|
}
|
||||||
|
private fun updateMediaMetadata(video: IPlatformVideo, bitmap: Bitmap?) {
|
||||||
|
val builder = MediaMetadataCompat.Builder()
|
||||||
|
.putString(MediaMetadata.METADATA_KEY_ARTIST, video.author.name)
|
||||||
|
.putString(MediaMetadata.METADATA_KEY_TITLE, video.name)
|
||||||
|
.putLong(MediaMetadata.METADATA_KEY_DURATION, video.duration * 1000)
|
||||||
|
|
||||||
|
val safeBitmap = bitmap?.takeIf { !it.isRecycled }
|
||||||
|
if (safeBitmap != null) {
|
||||||
|
builder.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, safeBitmap)
|
||||||
|
}
|
||||||
|
|
||||||
|
_mediaSession?.setMetadata(builder.build())
|
||||||
|
}
|
||||||
private fun generateMediaAction(icon: Int, title: String, intent: PendingIntent) : NotificationCompat.Action {
|
private fun generateMediaAction(icon: Int, title: String, intent: PendingIntent) : NotificationCompat.Action {
|
||||||
return NotificationCompat.Action.Builder(icon, title, intent).build();
|
return NotificationCompat.Action.Builder(icon, title, intent).build();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -436,9 +436,9 @@ class StateApp {
|
|||||||
try {
|
try {
|
||||||
val caFile = AppCaUpdater.ensureCaBundle(context)
|
val caFile = AppCaUpdater.ensureCaBundle(context)
|
||||||
Libcurl.setDefaultCAPath(caFile.absolutePath)
|
Libcurl.setDefaultCAPath(caFile.absolutePath)
|
||||||
|
Logger.i(TAG, "Libcurl initialized")
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
val fallback = File(context.noBackupFilesDir, "curl-ca-bundle.pem")
|
Logger.e(TAG, "Failed to initialize Libcurl", t);
|
||||||
if (fallback.exists()) Libcurl.setDefaultCAPath(fallback.absolutePath)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -572,30 +572,39 @@ class StateApp {
|
|||||||
DownloadService.getOrCreateService(context);
|
DownloadService.getOrCreateService(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.i(TAG, "MainApp Started: Initialize [AutoUpdate]");
|
if (Settings.instance.autoUpdate.isAutoUpdateEnabled()) {
|
||||||
val autoUpdateEnabled = Settings.instance.autoUpdate.isAutoUpdateEnabled();
|
if (Settings.instance.autoUpdate.shouldBackgroundDownload) {
|
||||||
val shouldDownload = Settings.instance.autoUpdate.shouldDownload();
|
Logger.i(TAG, "MainApp Started: Initialize [AutoUpdate Background]");
|
||||||
val backgroundDownload = Settings.instance.autoUpdate.backgroundDownload == 1;
|
val constraints = Constraints.Builder()
|
||||||
when {
|
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||||
//Background download
|
.build();
|
||||||
autoUpdateEnabled && shouldDownload && backgroundDownload -> {
|
|
||||||
StateUpdate.instance.setShouldBackgroundUpdate(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
autoUpdateEnabled && !shouldDownload && backgroundDownload -> {
|
val periodicRequest = PeriodicWorkRequest.Builder(
|
||||||
Logger.i(TAG, "Auto update skipped due to wrong network state");
|
UpdateCheckWorker::class.java,
|
||||||
}
|
12, TimeUnit.HOURS
|
||||||
|
)
|
||||||
|
.setConstraints(constraints)
|
||||||
|
.build();
|
||||||
|
|
||||||
//Foreground download
|
val wm = WorkManager.getInstance(context);
|
||||||
autoUpdateEnabled -> {
|
wm.enqueueUniquePeriodicWork(
|
||||||
|
UpdateCheckWorker.UNIQUE_WORK_NAME,
|
||||||
|
ExistingPeriodicWorkPolicy.UPDATE,
|
||||||
|
periodicRequest
|
||||||
|
);
|
||||||
|
|
||||||
|
val oneTimeRequest = OneTimeWorkRequest.Builder(UpdateCheckWorker::class.java)
|
||||||
|
.setConstraints(constraints)
|
||||||
|
.build();
|
||||||
|
wm.enqueue(oneTimeRequest);
|
||||||
|
} else {
|
||||||
|
Logger.i(TAG, "MainApp Started: Initialize [AutoUpdate]");
|
||||||
scopeOrNull?.launch(Dispatchers.IO) {
|
scopeOrNull?.launch(Dispatchers.IO) {
|
||||||
StateUpdate.instance.checkForUpdates(context, false)
|
StateUpdate.instance.checkForUpdates(context, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
else -> {
|
Logger.i(TAG, "AutoUpdate disabled");
|
||||||
Logger.i(TAG, "Auto update disabled");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.i(TAG, "MainApp Started: Initialize [Noisy]");
|
Logger.i(TAG, "MainApp Started: Initialize [Noisy]");
|
||||||
@@ -781,24 +790,20 @@ class StateApp {
|
|||||||
Logger.i("StateApp", "No AutoBackup configured");
|
Logger.i("StateApp", "No AutoBackup configured");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fun scheduleBackgroundWork(context: Context, active: Boolean = true, intervalMinutes: Int = 60 * 12) {
|
fun scheduleBackgroundWork(context: Context, active: Boolean = true, intervalMinutes: Int = 60 * 12) {
|
||||||
try {
|
try {
|
||||||
val wm = WorkManager.getInstance(context);
|
val wm = WorkManager.getInstance(context);
|
||||||
|
|
||||||
if(active) {
|
if (active) {
|
||||||
if(BuildConfig.DEBUG)
|
if (BuildConfig.DEBUG)
|
||||||
UIDialogs.toast(context, "Scheduling background every ${intervalMinutes} minutes");
|
UIDialogs.toast(context, "Scheduling background every ${intervalMinutes} minutes");
|
||||||
|
|
||||||
val req = PeriodicWorkRequest.Builder(BackgroundWorker::class.java, intervalMinutes.toLong(), TimeUnit.MINUTES, 5, TimeUnit.MINUTES)
|
val req = PeriodicWorkRequest.Builder(BackgroundWorker::class.java, intervalMinutes.toLong(), TimeUnit.MINUTES, 5, TimeUnit.MINUTES)
|
||||||
.setConstraints(Constraints.Builder()
|
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.UNMETERED).build()).build();
|
||||||
.setRequiredNetworkType(NetworkType.UNMETERED)
|
|
||||||
.build())
|
|
||||||
.build();
|
|
||||||
wm.enqueueUniquePeriodicWork("backgroundSubscriptions", ExistingPeriodicWorkPolicy.UPDATE, req);
|
wm.enqueueUniquePeriodicWork("backgroundSubscriptions", ExistingPeriodicWorkPolicy.UPDATE, req);
|
||||||
|
} else {
|
||||||
|
wm.cancelUniqueWork("backgroundSubscriptions");
|
||||||
}
|
}
|
||||||
else
|
|
||||||
wm.cancelAllWork();
|
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Logger.e(TAG, "Failed to schedule background subscription updates.", e)
|
Logger.e(TAG, "Failed to schedule background subscription updates.", e)
|
||||||
UIDialogs.toast(context, "Background subscription update failed: " + e.message)
|
UIDialogs.toast(context, "Background subscription update failed: " + e.message)
|
||||||
@@ -806,6 +811,7 @@ class StateApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private suspend fun migrateStores(context: Context, managedStores: List<ManagedStore<*>>, index: Int) {
|
private suspend fun migrateStores(context: Context, managedStores: List<ManagedStore<*>>, index: Int) {
|
||||||
if(managedStores.size <= index)
|
if(managedStores.size <= index)
|
||||||
return;
|
return;
|
||||||
@@ -903,15 +909,6 @@ class StateApp {
|
|||||||
try {
|
try {
|
||||||
if(FragmentedStorage.isInitialized && Settings.instance.downloads.shouldDownload())
|
if(FragmentedStorage.isInitialized && Settings.instance.downloads.shouldDownload())
|
||||||
StateDownloads.instance.checkForDownloadsTodos();
|
StateDownloads.instance.checkForDownloadsTodos();
|
||||||
|
|
||||||
val autoUpdateEnabled = Settings.instance.autoUpdate.isAutoUpdateEnabled();
|
|
||||||
val shouldDownload = Settings.instance.autoUpdate.shouldDownload();
|
|
||||||
val backgroundDownload = Settings.instance.autoUpdate.backgroundDownload == 1;
|
|
||||||
if (autoUpdateEnabled && shouldDownload && backgroundDownload) {
|
|
||||||
StateUpdate.instance.setShouldBackgroundUpdate(true);
|
|
||||||
} else {
|
|
||||||
StateUpdate.instance.setShouldBackgroundUpdate(false);
|
|
||||||
}
|
|
||||||
} catch(ex: Throwable) {
|
} catch(ex: Throwable) {
|
||||||
Logger.w(TAG, "Failed to handle capabilities changed event", ex);
|
Logger.w(TAG, "Failed to handle capabilities changed event", ex);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class StateAssets {
|
|||||||
if(part == "." || part == "..") {
|
if(part == "." || part == "..") {
|
||||||
if(parentAllowance <= 0)
|
if(parentAllowance <= 0)
|
||||||
throw IllegalStateException("Path [${path}] attempted to escape path..");
|
throw IllegalStateException("Path [${path}] attempted to escape path..");
|
||||||
parts1.removeLast();
|
parts1.removeAt(parts1.size - 1);
|
||||||
toSkip++;
|
toSkip++;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package com.futo.platformplayer.states
|
package com.futo.platformplayer.states
|
||||||
|
|
||||||
|
import android.content.ContentResolver
|
||||||
import android.content.ContentUris
|
import android.content.ContentUris
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.provider.MediaStore.Audio.Artists
|
import android.provider.MediaStore.Audio.Artists
|
||||||
import android.webkit.MimeTypeMap
|
import android.webkit.MimeTypeMap
|
||||||
@@ -154,34 +156,101 @@ class StateLibrary {
|
|||||||
fun getArtist(id: Long): Artist? {
|
fun getArtist(id: Long): Artist? {
|
||||||
return Artist.getArtist(id);
|
return Artist.getArtist(id);
|
||||||
}
|
}
|
||||||
|
fun getVideos(
|
||||||
|
buckets: List<String>? = null,
|
||||||
|
pageSize: Int = 20
|
||||||
|
): IPager<IPlatformContent> {
|
||||||
|
val resolver = StateApp.instance.contextOrNull?.contentResolver ?: return EmptyPager()
|
||||||
|
val selection: String?
|
||||||
|
val selectionArgs: Array<String>?
|
||||||
|
|
||||||
fun getVideos(buckets: List<String>? = null): IPager<IPlatformContent> {
|
if (!buckets.isNullOrEmpty()) {
|
||||||
var query = if(buckets != null) "${MediaStore.Video.Media.BUCKET_DISPLAY_NAME} IN " + "(" + buckets.map { "'${it}'" }.joinToString(",") + ")" else null;
|
val placeholders = buckets.joinToString(",") { "?" }
|
||||||
val cursor = StateApp.instance.contextOrNull?.contentResolver?.query(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, PROJECTION_VIDEO,
|
selection = "${MediaStore.Video.Media.BUCKET_DISPLAY_NAME} IN ($placeholders)"
|
||||||
query,
|
selectionArgs = buckets.toTypedArray()
|
||||||
null,
|
} else {
|
||||||
MediaStore.Video.Media.DATE_ADDED + " DESC") ?: return EmptyPager();
|
selection = null
|
||||||
|
selectionArgs = null
|
||||||
|
}
|
||||||
|
|
||||||
//Ongoing usage of cursor..todo disposal
|
val collectionUri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
//return cursor.use {
|
MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)
|
||||||
cursor.moveToFirst();
|
} else {
|
||||||
val list = mutableListOf<IPlatformVideo>()
|
MediaStore.Video.Media.EXTERNAL_CONTENT_URI
|
||||||
while(!cursor.isAfterLast && list.size < 10) {
|
}
|
||||||
list.add(videoFromCursor(cursor));
|
|
||||||
cursor.moveToNext();
|
var nextPageIndex = 0
|
||||||
|
fun loadPage(pageIndex: Int): List<IPlatformContent> {
|
||||||
|
Logger.i(TAG, "loadPage $pageIndex")
|
||||||
|
val offset = pageIndex * pageSize
|
||||||
|
|
||||||
|
val queryArgs = Bundle().apply {
|
||||||
|
selection?.let {
|
||||||
|
putString(ContentResolver.QUERY_ARG_SQL_SELECTION, it)
|
||||||
|
}
|
||||||
|
selectionArgs?.let {
|
||||||
|
putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, it)
|
||||||
|
}
|
||||||
|
|
||||||
|
putStringArray(
|
||||||
|
ContentResolver.QUERY_ARG_SORT_COLUMNS,
|
||||||
|
arrayOf(
|
||||||
|
MediaStore.Video.Media.DATE_ADDED,
|
||||||
|
MediaStore.Video.Media._ID
|
||||||
|
)
|
||||||
|
)
|
||||||
|
putInt(
|
||||||
|
ContentResolver.QUERY_ARG_SORT_DIRECTION,
|
||||||
|
ContentResolver.QUERY_SORT_DIRECTION_DESCENDING
|
||||||
|
)
|
||||||
|
|
||||||
|
putInt(ContentResolver.QUERY_ARG_LIMIT, pageSize)
|
||||||
|
putInt(ContentResolver.QUERY_ARG_OFFSET, offset)
|
||||||
}
|
}
|
||||||
|
|
||||||
return AdhocPager<IPlatformContent>({
|
val cursor = resolver.query(
|
||||||
val list = mutableListOf<IPlatformContent>()
|
collectionUri,
|
||||||
while(!cursor.isAfterLast && list.size < 10) {
|
PROJECTION_VIDEO,
|
||||||
list.add(videoFromCursor(cursor));
|
queryArgs,
|
||||||
cursor.moveToNext();
|
null
|
||||||
|
)
|
||||||
|
|
||||||
|
if (cursor == null) {
|
||||||
|
Logger.i(TAG, "loadPage $pageIndex null, returning empty list")
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor.use { c ->
|
||||||
|
if (!c.moveToFirst()) {
|
||||||
|
Logger.i(TAG, "loadPage $pageIndex moveToFirst failed, returning empty list")
|
||||||
|
return emptyList()
|
||||||
}
|
}
|
||||||
Logger.i(TAG, "Videos nextPage: ${list.size}")
|
|
||||||
return@AdhocPager list;
|
val list = ArrayList<IPlatformContent>(pageSize)
|
||||||
}, list);
|
do {
|
||||||
//}
|
list.add(videoFromCursor(c))
|
||||||
|
} while (c.moveToNext() && list.size < pageSize)
|
||||||
|
|
||||||
|
Logger.i(TAG, "loadPage $pageIndex found ${list.size} items")
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val firstPage = loadPage(0)
|
||||||
|
if (firstPage.isEmpty()) {
|
||||||
|
return EmptyPager()
|
||||||
|
}
|
||||||
|
nextPageIndex = 1
|
||||||
|
|
||||||
|
return AdhocPager<IPlatformContent>({
|
||||||
|
val page = loadPage(nextPageIndex)
|
||||||
|
nextPageIndex++
|
||||||
|
|
||||||
|
Logger.i(TAG, "loadPage nextPage: ${page.size}")
|
||||||
|
page
|
||||||
|
}, firstPage)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getRecentVideos(buckets: List<String>? = null, count: Int = 20): List<IPlatformVideo> {
|
fun getRecentVideos(buckets: List<String>? = null, count: Int = 20): List<IPlatformVideo> {
|
||||||
val videoPager = getVideos(buckets);
|
val videoPager = getVideos(buckets);
|
||||||
val items = mutableListOf<IPlatformVideo>();
|
val items = mutableListOf<IPlatformVideo>();
|
||||||
@@ -193,48 +262,80 @@ class StateLibrary {
|
|||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
private var _cacheBucketNames: List<Bucket>? = null;
|
@Volatile
|
||||||
fun getVideoBucketNames(): List<Bucket> {
|
private var _cachedVideoBuckets: List<Bucket>? = null
|
||||||
if(Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU)
|
private val _bucketCacheLock = Any()
|
||||||
return listOf();
|
|
||||||
if(_cacheBucketNames != null)
|
|
||||||
return _cacheBucketNames ?: listOf();
|
|
||||||
try {
|
|
||||||
val cur: Cursor = StateApp.instance.contextOrNull?.contentResolver?.query(
|
|
||||||
MediaStore.Video.Media.EXTERNAL_CONTENT_URI, arrayOf(
|
|
||||||
MediaStore.Video.Media.BUCKET_ID,
|
|
||||||
MediaStore.Video.Media.BUCKET_DISPLAY_NAME,
|
|
||||||
), null, null, null
|
|
||||||
) ?: return listOf();
|
|
||||||
|
|
||||||
return cur.use {
|
fun getVideoBucketNames(forceRefresh: Boolean = false): List<Bucket> {
|
||||||
val buckets = mutableListOf<Bucket>();
|
if (!forceRefresh) {
|
||||||
val list = HashSet<Long>();
|
_cachedVideoBuckets?.let { return it }
|
||||||
if (cur.moveToFirst()) {
|
}
|
||||||
var id: Long;
|
|
||||||
var bucket: String
|
val resolver = StateApp.instance.contextOrNull?.contentResolver
|
||||||
do {
|
?: return emptyList()
|
||||||
try {
|
|
||||||
id = cur.getLong(0);
|
val projection = arrayOf(
|
||||||
bucket = cur.getStringOrNull(1) ?: continue;
|
MediaStore.Video.VideoColumns.BUCKET_ID,
|
||||||
if (!list.contains(id)) {
|
MediaStore.Video.VideoColumns.BUCKET_DISPLAY_NAME
|
||||||
list.add(id);
|
)
|
||||||
buckets.add(Bucket(id, bucket));
|
|
||||||
}
|
val sortOrder = "${MediaStore.Video.VideoColumns.BUCKET_DISPLAY_NAME} COLLATE NOCASE ASC"
|
||||||
} catch (ex: Throwable) {
|
val loadedBuckets: List<Bucket> = try {
|
||||||
Logger.e(TAG, "Failed to parse bucket due to ${ex.message}", ex);
|
resolver.query(
|
||||||
}
|
MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
|
||||||
} while (cur.moveToNext())
|
projection,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
sortOrder
|
||||||
|
)?.use { cursor ->
|
||||||
|
if (!cursor.moveToFirst()) {
|
||||||
|
return@use emptyList<Bucket>()
|
||||||
}
|
}
|
||||||
_cacheBucketNames = buckets.toList()
|
|
||||||
return@use _cacheBucketNames ?: listOf();
|
val idxId = cursor.getColumnIndexOrThrow(MediaStore.Video.VideoColumns.BUCKET_ID)
|
||||||
|
val idxName = cursor.getColumnIndexOrThrow(MediaStore.Video.VideoColumns.BUCKET_DISPLAY_NAME)
|
||||||
|
val seenIds = HashSet<Long>()
|
||||||
|
val buckets = ArrayList<Bucket>()
|
||||||
|
|
||||||
|
do {
|
||||||
|
try {
|
||||||
|
val id = cursor.getLong(idxId)
|
||||||
|
if (!seenIds.add(id)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
val name = cursor.getStringOrNull(idxName) ?: continue
|
||||||
|
buckets.add(Bucket(id, name))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Failed to parse video bucket row: ${e.message}", e)
|
||||||
|
}
|
||||||
|
} while (cursor.moveToNext())
|
||||||
|
|
||||||
|
buckets
|
||||||
|
} ?: emptyList()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Buckets loading failed, returning empty: ${e.message}", e)
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadedBuckets.isEmpty()) {
|
||||||
|
if (!forceRefresh) {
|
||||||
|
_cachedVideoBuckets?.let { return it }
|
||||||
}
|
}
|
||||||
|
return emptyList()
|
||||||
}
|
}
|
||||||
catch(ex: Throwable) {
|
|
||||||
Logger.e(TAG, "Buckets loading failed, returning empty");
|
synchronized(_bucketCacheLock) {
|
||||||
return listOf();
|
if (!forceRefresh) {
|
||||||
|
_cachedVideoBuckets?.let { return it }
|
||||||
|
}
|
||||||
|
_cachedVideoBuckets = loadedBuckets
|
||||||
|
return loadedBuckets
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
fun invalidateVideoBucketNamesCache() {
|
||||||
|
_cachedVideoBuckets = null
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -243,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
|
||||||
@@ -386,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)
|
||||||
@@ -414,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 )
|
||||||
@@ -433,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;
|
||||||
@@ -521,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;
|
||||||
@@ -629,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> {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import android.graphics.drawable.Drawable
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
|
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||||
import com.bumptech.glide.request.target.CustomTarget
|
import com.bumptech.glide.request.target.CustomTarget
|
||||||
import com.bumptech.glide.request.transition.Transition
|
import com.bumptech.glide.request.transition.Transition
|
||||||
import com.futo.platformplayer.activities.MainActivity
|
import com.futo.platformplayer.activities.MainActivity
|
||||||
@@ -22,6 +23,7 @@ import com.futo.platformplayer.serializers.PlatformContentSerializer
|
|||||||
import com.futo.platformplayer.stores.FragmentedStorage
|
import com.futo.platformplayer.stores.FragmentedStorage
|
||||||
import com.futo.platformplayer.toHumanNowDiffString
|
import com.futo.platformplayer.toHumanNowDiffString
|
||||||
import com.futo.platformplayer.toHumanNowDiffStringMinDay
|
import com.futo.platformplayer.toHumanNowDiffStringMinDay
|
||||||
|
import com.futo.platformplayer.withMaxSizePx
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
|
|
||||||
class StateNotifications {
|
class StateNotifications {
|
||||||
@@ -96,6 +98,7 @@ class StateNotifications {
|
|||||||
if(thumbnail != null)
|
if(thumbnail != null)
|
||||||
Glide.with(context).asBitmap()
|
Glide.with(context).asBitmap()
|
||||||
.load(thumbnail)
|
.load(thumbnail)
|
||||||
|
.withMaxSizePx()
|
||||||
.into(object: CustomTarget<Bitmap>() {
|
.into(object: CustomTarget<Bitmap>() {
|
||||||
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
||||||
notifyNewContent(context, manager, notificationChannel, id, content, resource);
|
notifyNewContent(context, manager, notificationChannel, id, content, resource);
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
package com.futo.platformplayer.states
|
package com.futo.platformplayer.states
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.media3.common.C
|
import androidx.media3.common.C
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.exoplayer.DefaultLoadControl
|
import androidx.media3.exoplayer.DefaultLoadControl
|
||||||
|
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import androidx.media3.exoplayer.Renderer
|
||||||
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||||
|
import androidx.media3.exoplayer.text.TextOutput
|
||||||
|
import androidx.media3.exoplayer.text.TextRenderer
|
||||||
import androidx.media3.exoplayer.upstream.DefaultAllocator
|
import androidx.media3.exoplayer.upstream.DefaultAllocator
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.Settings
|
import com.futo.platformplayer.Settings
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
|
|
||||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
||||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
||||||
@@ -21,8 +26,10 @@ import com.futo.platformplayer.logging.Logger
|
|||||||
import com.futo.platformplayer.models.Playlist
|
import com.futo.platformplayer.models.Playlist
|
||||||
import com.futo.platformplayer.services.MediaPlaybackService
|
import com.futo.platformplayer.services.MediaPlaybackService
|
||||||
import com.futo.platformplayer.video.PlayerManager
|
import com.futo.platformplayer.video.PlayerManager
|
||||||
|
import com.google.common.collect.Iterables
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
|
||||||
/***
|
/***
|
||||||
* Used to keep track of queue and other player related stuff
|
* Used to keep track of queue and other player related stuff
|
||||||
*/
|
*/
|
||||||
@@ -240,17 +247,29 @@ class StatePlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun createShuffledQueue() {
|
private fun createShuffledQueue() {
|
||||||
val currentItem = getCurrentQueueItem();
|
if (_queue.isEmpty()) {
|
||||||
if (_queuePosition == -1 || currentItem == null) {
|
_queueShuffled = mutableListOf()
|
||||||
_queueShuffled = _queue.shuffled().toMutableList()
|
return
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val nextItems = _queue.subList(Math.min(_queuePosition + 1, _queue.size - 1), _queue.size).shuffled();
|
val currentItem = getCurrentQueueItem()
|
||||||
val previousItems = _queue.subList(0, _queuePosition).shuffled();
|
if (currentItem == null || _queuePosition !in _queue.indices) {
|
||||||
_queueShuffled = (previousItems + currentItem + nextItems).toMutableList();
|
_queueShuffled = _queue.shuffled().toMutableList()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val previousItems = _queue
|
||||||
|
.take(_queuePosition)
|
||||||
|
.shuffled()
|
||||||
|
|
||||||
|
val nextItems = _queue
|
||||||
|
.drop(_queuePosition + 1)
|
||||||
|
.shuffled()
|
||||||
|
|
||||||
|
_queueShuffled = (previousItems + currentItem + nextItems).toMutableList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private fun addToShuffledQueue(video: IPlatformVideo) {
|
private fun addToShuffledQueue(video: IPlatformVideo) {
|
||||||
val isLastVideo = _queuePosition + 1 >= _queue.size;
|
val isLastVideo = _queuePosition + 1 >= _queue.size;
|
||||||
if (isLastVideo) {
|
if (isLastVideo) {
|
||||||
@@ -662,6 +681,30 @@ class StatePlayer {
|
|||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
private fun createExoPlayer(context : Context): ExoPlayer {
|
private fun createExoPlayer(context : Context): ExoPlayer {
|
||||||
return ExoPlayer.Builder(context)
|
return ExoPlayer.Builder(context)
|
||||||
|
.setRenderersFactory(
|
||||||
|
object : DefaultRenderersFactory(context) {
|
||||||
|
override fun buildTextRenderers(
|
||||||
|
context: Context,
|
||||||
|
output: TextOutput,
|
||||||
|
outputLooper: Looper,
|
||||||
|
extensionRendererMode: Int,
|
||||||
|
out: java.util.ArrayList<Renderer>
|
||||||
|
) {
|
||||||
|
super.buildTextRenderers(
|
||||||
|
context,
|
||||||
|
output,
|
||||||
|
outputLooper,
|
||||||
|
extensionRendererMode,
|
||||||
|
out
|
||||||
|
)
|
||||||
|
(Iterables.getLast<Renderer?>(out) as TextRenderer)
|
||||||
|
.experimentalSetLegacyDecodingEnabled(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.setMediaSourceFactory(
|
||||||
|
DefaultMediaSourceFactory(context)
|
||||||
|
.experimentalParseSubtitlesDuringExtraction(false)
|
||||||
|
)
|
||||||
.setLoadControl(
|
.setLoadControl(
|
||||||
DefaultLoadControl.Builder()
|
DefaultLoadControl.Builder()
|
||||||
.setAllocator(DefaultAllocator(true, BUFFER_SIZE))
|
.setAllocator(DefaultAllocator(true, BUFFER_SIZE))
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -15,146 +15,6 @@ import java.io.InputStream
|
|||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
|
|
||||||
class StateUpdate {
|
class StateUpdate {
|
||||||
private var _backgroundUpdateFinished = false;
|
|
||||||
private var _gettingOrDownloadingLastApk = false;
|
|
||||||
private var _shouldBackgroundUpdate = false;
|
|
||||||
private val _lockObject = Object();
|
|
||||||
|
|
||||||
private fun getOrDownloadLastApkFile(filesDir: File): File? {
|
|
||||||
try {
|
|
||||||
Logger.i(TAG, "Started getting or downloading latest APK file.");
|
|
||||||
|
|
||||||
if (!_shouldBackgroundUpdate) {
|
|
||||||
Logger.i(TAG, "Update download cancelled 1.");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.i(TAG, "Started background update download.");
|
|
||||||
val client = ManagedHttpClient();
|
|
||||||
val latestVersion = downloadVersionCode(client);
|
|
||||||
if (!_shouldBackgroundUpdate) {
|
|
||||||
Logger.i(TAG, "Update download cancelled 2.");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (latestVersion != null) {
|
|
||||||
val currentVersion = BuildConfig.VERSION_CODE;
|
|
||||||
Logger.i(TAG, "Current version ${currentVersion} latest version ${latestVersion}.");
|
|
||||||
|
|
||||||
if (latestVersion <= currentVersion) {
|
|
||||||
Logger.i(TAG, "Already up to date.");
|
|
||||||
_backgroundUpdateFinished = true;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
val outputDirectory = File(filesDir, "autoupdate");
|
|
||||||
if (!outputDirectory.exists()) {
|
|
||||||
outputDirectory.mkdirs();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!_shouldBackgroundUpdate) {
|
|
||||||
Logger.i(TAG, "Update download cancelled 3.");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
val apkOutputFile = File(outputDirectory, "last_version.apk");
|
|
||||||
val versionOutputFile = File(outputDirectory, "last_version.txt");
|
|
||||||
|
|
||||||
var cachedVersionInvalid = false;
|
|
||||||
if (!versionOutputFile.exists() || !apkOutputFile.exists()) {
|
|
||||||
Logger.i(TAG, "No downloaded version exists.");
|
|
||||||
cachedVersionInvalid = true;
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
val downloadedVersion = versionOutputFile.readText().toInt();
|
|
||||||
Logger.i(TAG, "Downloaded version is $downloadedVersion.");
|
|
||||||
if (downloadedVersion != latestVersion) {
|
|
||||||
Logger.i(TAG, "Downloaded version is not newest version.");
|
|
||||||
cachedVersionInvalid = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch(ex: Throwable) {
|
|
||||||
Logger.w(TAG, "Deleted version file as it was inaccessible");
|
|
||||||
versionOutputFile.delete();
|
|
||||||
cachedVersionInvalid = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!_shouldBackgroundUpdate) {
|
|
||||||
Logger.i(TAG, "Update download cancelled 4.");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cachedVersionInvalid) {
|
|
||||||
Logger.i(TAG, "Downloading new APK to '${apkOutputFile.path}'...");
|
|
||||||
downloadApkToFile(client, apkOutputFile) { !_shouldBackgroundUpdate };
|
|
||||||
versionOutputFile.writeText(latestVersion.toString());
|
|
||||||
|
|
||||||
Logger.i(TAG, "Downloaded APK to '${apkOutputFile.path}'.");
|
|
||||||
} else {
|
|
||||||
Logger.i(TAG, "Latest APK is already downloaded in '${apkOutputFile.path}'...");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!_shouldBackgroundUpdate) {
|
|
||||||
Logger.i(TAG, "Update download cancelled 5.");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return apkOutputFile;
|
|
||||||
} else {
|
|
||||||
Logger.w(TAG, "Failed to retrieve version from version URL.");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.e(TAG, "Failed to download APK.", e);
|
|
||||||
return null;
|
|
||||||
} finally {
|
|
||||||
_gettingOrDownloadingLastApk = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setShouldBackgroundUpdate(shouldBackgroundUpdate: Boolean) {
|
|
||||||
synchronized (_lockObject) {
|
|
||||||
if (_backgroundUpdateFinished) {
|
|
||||||
_shouldBackgroundUpdate = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_shouldBackgroundUpdate = shouldBackgroundUpdate;
|
|
||||||
if (shouldBackgroundUpdate && !_gettingOrDownloadingLastApk) {
|
|
||||||
Logger.i(TAG, "Auto Updating in Background");
|
|
||||||
|
|
||||||
_gettingOrDownloadingLastApk = true;
|
|
||||||
StateApp.withContext { context ->
|
|
||||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
val file = getOrDownloadLastApkFile(context.filesDir);
|
|
||||||
if (file == null) {
|
|
||||||
Logger.i(TAG, "Failed to get or download update.");
|
|
||||||
return@launch;
|
|
||||||
}
|
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
try {
|
|
||||||
context.let { c ->
|
|
||||||
_backgroundUpdateFinished = true;
|
|
||||||
UIDialogs.showInstallDownloadedUpdateDialog(c, file);
|
|
||||||
};
|
|
||||||
Logger.i(TAG, "Showing install dialog for '${file.path}'.");
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
context.let { c -> UIDialogs.toast(c, "Failed to show update dialog"); };
|
|
||||||
Logger.w(TAG, "Error occurred in update dialog.", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.e(TAG, "Failed to get last downloaded APK file.", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun checkForUpdates(context: Context, showUpToDateToast: Boolean, hideExceptionButtons: Boolean = false) = withContext(Dispatchers.IO) {
|
suspend fun checkForUpdates(context: Context, showUpToDateToast: Boolean, hideExceptionButtons: Boolean = false) = withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val client = ManagedHttpClient();
|
val client = ManagedHttpClient();
|
||||||
@@ -196,25 +56,6 @@ class StateUpdate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun downloadApkToFile(client: ManagedHttpClient, destinationFile: File, isCancelled: (() -> Boolean)? = null) {
|
|
||||||
var apkStream: InputStream? = null;
|
|
||||||
var outputStream: OutputStream? = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
val response = client.get(APK_URL);
|
|
||||||
if (response.isOk && response.body != null) {
|
|
||||||
apkStream = response.body.byteStream();
|
|
||||||
outputStream = destinationFile.outputStream();
|
|
||||||
apkStream.copyToOutputStream(outputStream, isCancelled);
|
|
||||||
apkStream.close();
|
|
||||||
outputStream.close();
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
apkStream?.close();
|
|
||||||
outputStream?.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun downloadVersionCode(client: ManagedHttpClient): Int? {
|
fun downloadVersionCode(client: ManagedHttpClient): Int? {
|
||||||
val response = client.get(VERSION_URL);
|
val response = client.get(VERSION_URL);
|
||||||
if (!response.isOk || response.body == null) {
|
if (!response.isOk || response.body == null) {
|
||||||
@@ -267,6 +108,22 @@ class StateUpdate {
|
|||||||
}
|
}
|
||||||
val CHANGELOG_BASE_URL = "https://releases.grayjay.app/changelogs";
|
val CHANGELOG_BASE_URL = "https://releases.grayjay.app/changelogs";
|
||||||
|
|
||||||
|
fun getApkFile(context: Context, version: Int): File {
|
||||||
|
val dir = File(context.filesDir, "updates");
|
||||||
|
if (!dir.exists()) {
|
||||||
|
dir.mkdirs();
|
||||||
|
}
|
||||||
|
return File(dir, "app-${DESIRED_ABI}-${version}.apk");
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPartialApkFile(context: Context, version: Int): File {
|
||||||
|
val dir = File(context.filesDir, "updates");
|
||||||
|
if (!dir.exists()) {
|
||||||
|
dir.mkdirs();
|
||||||
|
}
|
||||||
|
return File(dir, "app-${DESIRED_ABI}-${version}.apk.part");
|
||||||
|
}
|
||||||
|
|
||||||
fun finish() {
|
fun finish() {
|
||||||
_instance?.let {
|
_instance?.let {
|
||||||
_instance = null;
|
_instance = null;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class SearchHistoryStorage : FragmentedStorageFileJson() {
|
|||||||
if (!lastQueries.contains(text)) {
|
if (!lastQueries.contains(text)) {
|
||||||
lastQueries.add(0, text);
|
lastQueries.add(0, text);
|
||||||
if (lastQueries.size > 10)
|
if (lastQueries.size > 10)
|
||||||
lastQueries.removeLast();
|
lastQueries.removeAt(lastQueries.size - 1);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
lastQueries.remove(text);
|
lastQueries.remove(text);
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ import android.widget.TextView
|
|||||||
import androidx.constraintlayout.widget.ConstraintLayout
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||||||
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
|
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.constructs.Event0
|
import com.futo.platformplayer.constructs.Event0
|
||||||
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
||||||
import com.futo.platformplayer.models.Playlist
|
import com.futo.platformplayer.models.Playlist
|
||||||
|
import com.futo.platformplayer.withMaxSizePx
|
||||||
|
|
||||||
class PlaylistsViewHolder : ViewHolder {
|
class PlaylistsViewHolder : ViewHolder {
|
||||||
private val _root: ConstraintLayout;
|
private val _root: ConstraintLayout;
|
||||||
@@ -44,6 +46,7 @@ class PlaylistsViewHolder : ViewHolder {
|
|||||||
if (p.videos.isNotEmpty()) {
|
if (p.videos.isNotEmpty()) {
|
||||||
Glide.with(_imageThumbnail)
|
Glide.with(_imageThumbnail)
|
||||||
.load(p.videos[0].thumbnails.getMinimumThumbnail(380))
|
.load(p.videos[0].thumbnails.getMinimumThumbnail(380))
|
||||||
|
.withMaxSizePx()
|
||||||
.placeholder(R.drawable.placeholder_video_thumbnail)
|
.placeholder(R.drawable.placeholder_video_thumbnail)
|
||||||
.crossfade()
|
.crossfade()
|
||||||
.into(_imageThumbnail);
|
.into(_imageThumbnail);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import androidx.constraintlayout.widget.ConstraintLayout
|
|||||||
import androidx.recyclerview.widget.ItemTouchHelper
|
import androidx.recyclerview.widget.ItemTouchHelper
|
||||||
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
|
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||||
import com.futo.platformplayer.constructs.Event1
|
import com.futo.platformplayer.constructs.Event1
|
||||||
@@ -23,6 +24,7 @@ import com.futo.platformplayer.toHumanNumber
|
|||||||
import com.futo.platformplayer.toHumanTime
|
import com.futo.platformplayer.toHumanTime
|
||||||
import com.futo.platformplayer.views.others.ProgressBar
|
import com.futo.platformplayer.views.others.ProgressBar
|
||||||
import com.futo.platformplayer.views.platform.PlatformIndicator
|
import com.futo.platformplayer.views.platform.PlatformIndicator
|
||||||
|
import com.futo.platformplayer.withMaxSizePx
|
||||||
|
|
||||||
class VideoListEditorViewHolder : ViewHolder {
|
class VideoListEditorViewHolder : ViewHolder {
|
||||||
private val _root: ConstraintLayout;
|
private val _root: ConstraintLayout;
|
||||||
@@ -89,6 +91,7 @@ class VideoListEditorViewHolder : ViewHolder {
|
|||||||
fun bind(v: IPlatformVideo, canEdit: Boolean) {
|
fun bind(v: IPlatformVideo, canEdit: Boolean) {
|
||||||
Glide.with(_imageThumbnail)
|
Glide.with(_imageThumbnail)
|
||||||
.load(v.thumbnails.getHQThumbnail())
|
.load(v.thumbnails.getHQThumbnail())
|
||||||
|
.withMaxSizePx()
|
||||||
.placeholder(R.drawable.placeholder_video_thumbnail)
|
.placeholder(R.drawable.placeholder_video_thumbnail)
|
||||||
.crossfade()
|
.crossfade()
|
||||||
.into(_imageThumbnail);
|
.into(_imageThumbnail);
|
||||||
|
|||||||
+3
@@ -7,6 +7,7 @@ import android.widget.ImageView
|
|||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
|
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||||
@@ -16,6 +17,7 @@ import com.futo.platformplayer.states.Artist
|
|||||||
import com.futo.platformplayer.toHumanNowDiffString
|
import com.futo.platformplayer.toHumanNowDiffString
|
||||||
import com.futo.platformplayer.toHumanTime
|
import com.futo.platformplayer.toHumanTime
|
||||||
import com.futo.platformplayer.views.adapters.AnyAdapter
|
import com.futo.platformplayer.views.adapters.AnyAdapter
|
||||||
|
import com.futo.platformplayer.withMaxSizePx
|
||||||
import com.google.android.material.imageview.ShapeableImageView
|
import com.google.android.material.imageview.ShapeableImageView
|
||||||
|
|
||||||
|
|
||||||
@@ -49,6 +51,7 @@ class LocalVideoTileViewHolder(val _viewGroup: ViewGroup) : AnyAdapter.AnyViewHo
|
|||||||
Glide.with(it)
|
Glide.with(it)
|
||||||
.load(content.thumbnails.getHQThumbnail())
|
.load(content.thumbnails.getHQThumbnail())
|
||||||
.placeholder(R.drawable.unknown_music)
|
.placeholder(R.drawable.unknown_music)
|
||||||
|
.withMaxSizePx()
|
||||||
.into(it)
|
.into(it)
|
||||||
else
|
else
|
||||||
Glide.with(it).load(R.drawable.unknown_music).into(it);
|
Glide.with(it).load(R.drawable.unknown_music).into(it);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.futo.platformplayer.views.buttons
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
|
import android.os.Looper
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
import android.util.TypedValue
|
import android.util.TypedValue
|
||||||
import android.view.View
|
import android.view.View
|
||||||
@@ -98,46 +99,58 @@ open class BigButton : LinearLayout {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
fun withIcon(resourceId: Int, rounded: Boolean = false): BigButton {
|
private fun applyIcon(resourceId: Int, rounded: Boolean) {
|
||||||
if (resourceId != -1) {
|
if (resourceId != -1) {
|
||||||
_icon.visibility = View.VISIBLE;
|
_icon.visibility = View.VISIBLE
|
||||||
_icon.setImageResource(resourceId);
|
_icon.setImageResource(resourceId)
|
||||||
} else
|
|
||||||
_icon.visibility = View.GONE;
|
|
||||||
|
|
||||||
if (rounded) {
|
|
||||||
val shapeAppearanceModel = ShapeAppearanceModel().toBuilder()
|
|
||||||
.setAllCornerSizes(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 16.0f, context.resources.displayMetrics))
|
|
||||||
.build();
|
|
||||||
|
|
||||||
_icon.scaleType = ImageView.ScaleType.FIT_CENTER;
|
|
||||||
_icon.shapeAppearanceModel = shapeAppearanceModel;
|
|
||||||
} else {
|
} else {
|
||||||
_icon.scaleType = ImageView.ScaleType.CENTER_CROP;
|
_icon.visibility = View.GONE
|
||||||
_icon.shapeAppearanceModel = ShapeAppearanceModel();
|
|
||||||
}
|
}
|
||||||
|
applyRounded(rounded)
|
||||||
return this;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun withIcon(resourceId: Int, rounded: Boolean = false): BigButton {
|
||||||
|
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||||
|
applyIcon(resourceId, rounded)
|
||||||
|
} else {
|
||||||
|
post { applyIcon(resourceId, rounded) }
|
||||||
|
}
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
fun withIcon(bitmap: Bitmap, rounded: Boolean = false): BigButton {
|
fun withIcon(bitmap: Bitmap, rounded: Boolean = false): BigButton {
|
||||||
_icon.visibility = View.VISIBLE;
|
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||||
_icon.setImageBitmap(bitmap);
|
applyIcon(bitmap, rounded)
|
||||||
|
|
||||||
if (rounded) {
|
|
||||||
val shapeAppearanceModel = ShapeAppearanceModel().toBuilder()
|
|
||||||
.setAllCornerSizes(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 16.0f, context.resources.displayMetrics))
|
|
||||||
.build();
|
|
||||||
|
|
||||||
_icon.scaleType = ImageView.ScaleType.FIT_CENTER;
|
|
||||||
_icon.shapeAppearanceModel = shapeAppearanceModel;
|
|
||||||
} else {
|
} else {
|
||||||
_icon.scaleType = ImageView.ScaleType.CENTER_CROP;
|
post { applyIcon(bitmap, rounded) }
|
||||||
_icon.shapeAppearanceModel = ShapeAppearanceModel();
|
|
||||||
}
|
}
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
return this;
|
private fun applyRounded(rounded: Boolean) {
|
||||||
|
if (rounded) {
|
||||||
|
val radiusPx = TypedValue.applyDimension(
|
||||||
|
TypedValue.COMPLEX_UNIT_DIP,
|
||||||
|
16.0f,
|
||||||
|
context.resources.displayMetrics
|
||||||
|
)
|
||||||
|
val shapeAppearanceModel = ShapeAppearanceModel()
|
||||||
|
.toBuilder()
|
||||||
|
.setAllCornerSizes(radiusPx)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
_icon.scaleType = ImageView.ScaleType.FIT_CENTER
|
||||||
|
_icon.shapeAppearanceModel = shapeAppearanceModel
|
||||||
|
} else {
|
||||||
|
_icon.scaleType = ImageView.ScaleType.CENTER_CROP
|
||||||
|
_icon.shapeAppearanceModel = ShapeAppearanceModel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyIcon(bitmap: Bitmap, rounded: Boolean) {
|
||||||
|
_icon.visibility = View.VISIBLE
|
||||||
|
_icon.setImageBitmap(bitmap)
|
||||||
|
applyRounded(rounded)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun withBackground(resourceId: Int): BigButton {
|
fun withBackground(resourceId: Int): BigButton {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import androidx.media3.common.util.UnstableApi
|
|||||||
import androidx.media3.ui.DefaultTimeBar
|
import androidx.media3.ui.DefaultTimeBar
|
||||||
import androidx.media3.ui.TimeBar
|
import androidx.media3.ui.TimeBar
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
|
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.Settings
|
import com.futo.platformplayer.Settings
|
||||||
import com.futo.platformplayer.api.media.models.chapters.IChapter
|
import com.futo.platformplayer.api.media.models.chapters.IChapter
|
||||||
@@ -32,6 +33,7 @@ import com.futo.platformplayer.logging.Logger
|
|||||||
import com.futo.platformplayer.states.StatePlayer
|
import com.futo.platformplayer.states.StatePlayer
|
||||||
import com.futo.platformplayer.views.TargetTapLoaderView
|
import com.futo.platformplayer.views.TargetTapLoaderView
|
||||||
import com.futo.platformplayer.views.behavior.GestureControlView
|
import com.futo.platformplayer.views.behavior.GestureControlView
|
||||||
|
import com.futo.platformplayer.withMaxSizePx
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -306,6 +308,7 @@ class CastView : ConstraintLayout {
|
|||||||
Glide.with(_thumbnail)
|
Glide.with(_thumbnail)
|
||||||
.load(video.thumbnails.getHQThumbnail())
|
.load(video.thumbnails.getHQThumbnail())
|
||||||
.placeholder(R.drawable.placeholder_video_thumbnail)
|
.placeholder(R.drawable.placeholder_video_thumbnail)
|
||||||
|
.withMaxSizePx()
|
||||||
.into(_thumbnail);
|
.into(_thumbnail);
|
||||||
_textPosition.text = (position * 1000).formatDuration();
|
_textPosition.text = (position * 1000).formatDuration();
|
||||||
_textDuration.text = (video.duration * 1000).formatDuration();
|
_textDuration.text = (video.duration * 1000).formatDuration();
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import android.widget.ImageView
|
|||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
|
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||||
import com.futo.platformplayer.*
|
import com.futo.platformplayer.*
|
||||||
import com.futo.platformplayer.downloads.VideoDownload
|
import com.futo.platformplayer.downloads.VideoDownload
|
||||||
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
||||||
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
|
||||||
@@ -31,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;
|
||||||
|
|
||||||
@@ -50,17 +53,19 @@ 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)
|
||||||
.load(download.thumbnail)
|
.load(download.thumbnail)
|
||||||
|
.withMaxSizePx()
|
||||||
.crossfade()
|
.crossfade()
|
||||||
.into(_videoImage);
|
.into(_videoImage);
|
||||||
|
|
||||||
@@ -70,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) {
|
||||||
@@ -120,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import android.widget.ImageView
|
|||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
|
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
||||||
import com.futo.platformplayer.models.PlaylistDownloaded
|
import com.futo.platformplayer.models.PlaylistDownloaded
|
||||||
|
import com.futo.platformplayer.withMaxSizePx
|
||||||
|
|
||||||
class PlaylistDownloadItem(context: Context, playlistName: String, playlistThumbnail: String?, val obj: Any): LinearLayout(context) {
|
class PlaylistDownloadItem(context: Context, playlistName: String, playlistThumbnail: String?, val obj: Any): LinearLayout(context) {
|
||||||
init { inflate(context, R.layout.list_downloaded_playlist, this) }
|
init { inflate(context, R.layout.list_downloaded_playlist, this) }
|
||||||
@@ -19,6 +21,7 @@ class PlaylistDownloadItem(context: Context, playlistName: String, playlistThumb
|
|||||||
imageText.text = playlistName;
|
imageText.text = playlistName;
|
||||||
Glide.with(imageView)
|
Glide.with(imageView)
|
||||||
.load(playlistThumbnail)
|
.load(playlistThumbnail)
|
||||||
|
.withMaxSizePx()
|
||||||
.crossfade()
|
.crossfade()
|
||||||
.into(imageView);
|
.into(imageView);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -15,6 +15,7 @@ import androidx.media3.common.util.UnstableApi
|
|||||||
import androidx.media3.ui.PlayerControlView
|
import androidx.media3.ui.PlayerControlView
|
||||||
import androidx.media3.ui.PlayerView
|
import androidx.media3.ui.PlayerView
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
|
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||||
import com.bumptech.glide.request.target.CustomTarget
|
import com.bumptech.glide.request.target.CustomTarget
|
||||||
import com.bumptech.glide.request.transition.Transition
|
import com.bumptech.glide.request.transition.Transition
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
@@ -25,6 +26,7 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
|||||||
import com.futo.platformplayer.helpers.VideoHelper
|
import com.futo.platformplayer.helpers.VideoHelper
|
||||||
import com.futo.platformplayer.toHumanTime
|
import com.futo.platformplayer.toHumanTime
|
||||||
import com.futo.platformplayer.video.PlayerManager
|
import com.futo.platformplayer.video.PlayerManager
|
||||||
|
import com.futo.platformplayer.withMaxSizePx
|
||||||
|
|
||||||
|
|
||||||
class FutoThumbnailPlayer : FutoVideoPlayerBase {
|
class FutoThumbnailPlayer : FutoVideoPlayerBase {
|
||||||
@@ -135,7 +137,7 @@ class FutoThumbnailPlayer : FutoVideoPlayerBase {
|
|||||||
if (videoSource == null && audioSource != null) {
|
if (videoSource == null && audioSource != null) {
|
||||||
val thumbnail = video.thumbnails.getHQThumbnail();
|
val thumbnail = video.thumbnails.getHQThumbnail();
|
||||||
if (!thumbnail.isNullOrBlank()) {
|
if (!thumbnail.isNullOrBlank()) {
|
||||||
Glide.with(videoView).asBitmap().load(thumbnail).into(_loadArtwork);
|
Glide.with(videoView).asBitmap().load(thumbnail).withMaxSizePx().into(_loadArtwork);
|
||||||
} else {
|
} else {
|
||||||
Glide.with(videoView).clear(_loadArtwork);
|
Glide.with(videoView).clear(_loadArtwork);
|
||||||
setArtwork(null);
|
setArtwork(null);
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import com.futo.platformplayer.states.StatePlayer
|
|||||||
import com.futo.platformplayer.views.TargetTapLoaderView
|
import com.futo.platformplayer.views.TargetTapLoaderView
|
||||||
import com.futo.platformplayer.views.behavior.GestureControlView
|
import com.futo.platformplayer.views.behavior.GestureControlView
|
||||||
import com.futo.platformplayer.views.others.ProgressBar
|
import com.futo.platformplayer.views.others.ProgressBar
|
||||||
|
import com.futo.platformplayer.withMaxSizePx
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.GlobalScope
|
import kotlinx.coroutines.GlobalScope
|
||||||
@@ -488,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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -885,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -928,11 +929,9 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
|||||||
override fun switchToAudioMode(video: IPlatformVideoDetails?) {
|
override fun switchToAudioMode(video: IPlatformVideoDetails?) {
|
||||||
super.switchToAudioMode(video)
|
super.switchToAudioMode(video)
|
||||||
|
|
||||||
//This causes issues, and is in general confusing, needs improvements
|
|
||||||
/*
|
|
||||||
val thumbnail = video?.thumbnails?.getHQThumbnail()
|
val thumbnail = video?.thumbnails?.getHQThumbnail()
|
||||||
if (!thumbnail.isNullOrBlank()) {
|
if (!thumbnail.isNullOrBlank()) {
|
||||||
Glide.with(context).asBitmap().load(thumbnail)
|
Glide.with(context).asBitmap().load(thumbnail).withMaxSizePx()
|
||||||
.into(object : CustomTarget<Bitmap>() {
|
.into(object : CustomTarget<Bitmap>() {
|
||||||
override fun onResourceReady(
|
override fun onResourceReady(
|
||||||
resource: Bitmap,
|
resource: Bitmap,
|
||||||
@@ -946,6 +945,5 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
*/
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+12
@@ -111,6 +111,10 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource {
|
|||||||
* @return This factory.
|
* @return This factory.
|
||||||
*/
|
*/
|
||||||
public Factory setRequestExecutor(@Nullable JSRequestExecutor requestExecutor) {
|
public Factory setRequestExecutor(@Nullable JSRequestExecutor requestExecutor) {
|
||||||
|
JSRequestExecutor oldExecutor = this.requestExecutor;
|
||||||
|
if(oldExecutor != null) {
|
||||||
|
oldExecutor.closeAsync();
|
||||||
|
}
|
||||||
this.requestExecutor = requestExecutor;
|
this.requestExecutor = requestExecutor;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
@@ -123,6 +127,10 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource {
|
|||||||
* @return This factory.
|
* @return This factory.
|
||||||
*/
|
*/
|
||||||
public Factory setRequestExecutor2(@Nullable JSRequestExecutor requestExecutor) {
|
public Factory setRequestExecutor2(@Nullable JSRequestExecutor requestExecutor) {
|
||||||
|
JSRequestExecutor oldExecutor = this.requestExecutor2;
|
||||||
|
if(oldExecutor != null) {
|
||||||
|
oldExecutor.closeAsync();
|
||||||
|
}
|
||||||
this.requestExecutor2 = requestExecutor;
|
this.requestExecutor2 = requestExecutor;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
@@ -508,6 +516,10 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void close() throws HttpDataSourceException {
|
public void close() throws HttpDataSourceException {
|
||||||
|
if(requestExecutor != null)
|
||||||
|
requestExecutor.closeAsync();
|
||||||
|
if(requestExecutor2 != null)
|
||||||
|
requestExecutor2.closeAsync();
|
||||||
try {
|
try {
|
||||||
@Nullable InputStream inputStream = this.inputStream;
|
@Nullable InputStream inputStream = this.inputStream;
|
||||||
if (inputStream != null) {
|
if (inputStream != null) {
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ import android.widget.TextView
|
|||||||
import androidx.constraintlayout.widget.ConstraintLayout
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
|
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||||
import com.futo.platformplayer.states.StatePlayer
|
import com.futo.platformplayer.states.StatePlayer
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.constructs.Event0
|
import com.futo.platformplayer.constructs.Event0
|
||||||
import com.futo.platformplayer.toHumanNowDiffString
|
import com.futo.platformplayer.toHumanNowDiffString
|
||||||
import com.futo.platformplayer.toHumanNumber
|
import com.futo.platformplayer.toHumanNumber
|
||||||
|
import com.futo.platformplayer.withMaxSizePx
|
||||||
|
|
||||||
class UpNextView : LinearLayout {
|
class UpNextView : LinearLayout {
|
||||||
private val _layoutContainer: LinearLayout;
|
private val _layoutContainer: LinearLayout;
|
||||||
@@ -160,6 +162,7 @@ class UpNextView : LinearLayout {
|
|||||||
_textChannelName.text = nextItem.author.name;
|
_textChannelName.text = nextItem.author.name;
|
||||||
Glide.with(_imageThumbnail)
|
Glide.with(_imageThumbnail)
|
||||||
.load(nextItem.thumbnails.getHQThumbnail())
|
.load(nextItem.thumbnails.getHQThumbnail())
|
||||||
|
.withMaxSizePx()
|
||||||
.placeholder(R.drawable.placeholder_video_thumbnail)
|
.placeholder(R.drawable.placeholder_video_thumbnail)
|
||||||
.into(_imageThumbnail);
|
.into(_imageThumbnail);
|
||||||
Glide.with(_imageChannelThumbnail)
|
Glide.with(_imageChannelThumbnail)
|
||||||
|
|||||||
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
@@ -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>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
@@ -77,12 +77,13 @@
|
|||||||
android:layout_width="50dp"
|
android:layout_width="50dp"
|
||||||
android:layout_height="50dp"
|
android:layout_height="50dp"
|
||||||
android:contentDescription="@string/cd_incognito_button"
|
android:contentDescription="@string/cd_incognito_button"
|
||||||
android:src="@drawable/ic_disabled_visible_purple"
|
android:src="@drawable/incognito_purple"
|
||||||
android:background="@drawable/background_button_round_black"
|
android:background="@drawable/background_button_round_black"
|
||||||
android:scaleType="fitCenter"
|
android:scaleType="fitCenter"
|
||||||
android:visibility="visible"
|
android:visibility="visible"
|
||||||
android:layout_marginLeft="10dp"
|
android:layout_marginLeft="10dp"
|
||||||
android:layout_marginBottom="10dp"
|
android:layout_marginBottom="10dp"
|
||||||
|
android:padding="8dp"
|
||||||
android:elevation="50dp"
|
android:elevation="50dp"
|
||||||
app:layout_constraintLeft_toLeftOf="parent"
|
app:layout_constraintLeft_toLeftOf="parent"
|
||||||
app:layout_constraintBottom_toTopOf="@id/toast_view" />
|
app:layout_constraintBottom_toTopOf="@id/toast_view" />
|
||||||
|
|||||||
@@ -106,7 +106,7 @@
|
|||||||
<TextView
|
<TextView
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="@string/update"
|
android:text="@string/download"
|
||||||
android:textSize="14dp"
|
android:textSize="14dp"
|
||||||
android:textColor="@color/white"
|
android:textColor="@color/white"
|
||||||
android:fontFamily="@font/inter_regular"
|
android:fontFamily="@font/inter_regular"
|
||||||
|
|||||||
@@ -75,7 +75,7 @@
|
|||||||
<ImageView
|
<ImageView
|
||||||
android:layout_width="30dp"
|
android:layout_width="30dp"
|
||||||
android:layout_height="30dp"
|
android:layout_height="30dp"
|
||||||
android:src="@drawable/ic_disabled_visible" />
|
android:src="@drawable/incognito" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
@@ -109,6 +109,7 @@
|
|||||||
app:layout_constraintBottom_toBottomOf="parent"
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
app:layout_constraintLeft_toLeftOf="parent"
|
app:layout_constraintLeft_toLeftOf="parent"
|
||||||
app:layout_constraintRight_toRightOf="parent"
|
app:layout_constraintRight_toRightOf="parent"
|
||||||
|
android:layoutDirection="rtl"
|
||||||
|
|
||||||
android:gravity="end">
|
android:gravity="end">
|
||||||
</androidx.recyclerview.widget.RecyclerView>
|
</androidx.recyclerview.widget.RecyclerView>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
<string name="subscriptions">Subscriptions</string>
|
<string name="subscriptions">Subscriptions</string>
|
||||||
<string name="loading">Loading</string>
|
<string name="loading">Loading</string>
|
||||||
<string name="retry">Retry</string>
|
<string name="retry">Retry</string>
|
||||||
|
<string name="install_failed_device_installer_broken">Failed to start system installer. Your device’s ROM is not compatible with automatic updates.</string>
|
||||||
<string name="cancel">Cancel</string>
|
<string name="cancel">Cancel</string>
|
||||||
<string name="failed_to_retrieve_data_are_you_connected">Failed to retrieve data, are you connected?</string>
|
<string name="failed_to_retrieve_data_are_you_connected">Failed to retrieve data, are you connected?</string>
|
||||||
<string name="settings">Settings</string>
|
<string name="settings">Settings</string>
|
||||||
|
|||||||
@@ -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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user