Compare commits

...

38 Commits

Author SHA1 Message Date
Kelvin 8437825dd1 apply language filters to downloads 2025-12-17 20:29:45 +01:00
Kelvin 0fbe0bb438 Add filters for video languages to resolve excessive sources 2025-12-17 19:43:56 +01:00
Kelvin 34d2e62314 sub mods 2025-12-17 16:27:12 +01:00
Kelvin 1075ded170 Language for video support, original for video support, deduplication fix for languages on videos, submods 2025-12-17 15:32:37 +01:00
Koen J 80bb15f3fb Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-12-15 10:03:34 +01:00
Koen J 27a86a67f0 Updated submodules and fixed casting for combined request executor. 2025-12-15 10:03:18 +01:00
Koen 284b2a24f8 Merge branch 'marcus/casting-sdk-updates' into 'master'
casting: subscribe to and handle MediaItemEnd events

See merge request videostreaming/grayjay!158
2025-12-15 09:01:31 +00:00
Kelvin K 854d1506a6 Compile fix 2025-12-11 17:17:42 -06:00
Kelvin K 811fd4e73e Improved dl 2025-12-11 17:16:31 -06:00
Kelvin K 335988aa67 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-12-11 14:16:07 -06:00
Kelvin K 29a54fbed4 Download support combined 2025-12-11 14:15:55 -06:00
Koen J 3a11d0d9d1 Fixed HLS downloading for Twitch, DialyMotion, Nebula. 2025-12-05 15:31:31 +01:00
Koen J bda534e485 Various updates to bg update flow:
- Throttled progress updates in notifications resolving the notifications not showing under some conditions.
- Properly cancel notifications when interacting with in-app dialogs.
- Added install failed notification.
- Added install success notification.
- Added default behavior for tapping on notifications.
- Fixed crash in install receiver.
2025-12-04 11:18:00 +01:00
Kelvin K 09fd4c0881 Fix it asking for background updating when not required 2025-12-03 18:37:06 -06:00
Kelvin K 1667866a35 Hotfix invalid closed state 2025-12-03 18:08:36 -06:00
Kelvin K 035125d0f8 Hotfix invalid closed state 2025-12-03 18:06:38 -06:00
Kelvin K 1bb0cdc405 Add exception handling for background updater 2025-12-03 12:49:08 -06:00
Kelvin K 86019c80a1 Fix in-video login flow 2025-12-03 11:58:00 -06:00
Kelvin K 8c640d3def Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-12-03 11:44:58 -06:00
Kelvin K 7ed1e8a28b NEw install dialog, incognito dont show fix, crash fix old android search library 2025-12-03 11:44:27 -06:00
Koen J 3dcfe8c340 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-12-03 18:19:26 +01:00
Koen J 042ced81ef Fix for update when app is fully killed. 2025-12-03 18:18:43 +01:00
Kelvin K b37f48380b Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-12-03 11:10:35 -06:00
Kelvin K 0a02169782 Fix PiP for back button 2025-12-03 11:10:22 -06:00
Koen J f12e4390f3 Changed the order of buttons. 2025-12-03 17:48:47 +01:00
Koen J 82ab45d04e Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-12-03 17:35:06 +01:00
Koen J 7f77c39296 Made notifications for update silent. 2025-12-03 17:33:41 +01:00
Kelvin K 99eee4f6ee Disable misbehaving thumbnail rendering 2025-12-03 10:27:40 -06:00
Kelvin K 68886502d1 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-12-03 09:58:16 -06:00
Kelvin K 26461c21c4 move to background on back with video 2025-12-03 09:58:08 -06:00
Koen J 300466f722 Update dialogs should nicely be hidden when interacting with notifications. 2025-12-03 16:33:51 +01:00
Koen J 961710cc8b Fixed thumbnails acting up and added support for library content thumbnail when casting. 2025-12-03 15:37:53 +01:00
Koen J eba995f87d Added support for casting local media content. 2025-12-03 14:09:33 +01:00
Koen a67244e79a Merge branch 'marcus/cast-dev-connection-state-fix' into 'master'
casting: set connectionState to correct value when disconnected

See merge request videostreaming/grayjay!160
2025-12-03 11:16:08 +00:00
Marcus Hanestad 70502a7651 casting: set connectionState to correct value when disconnected 2025-12-03 12:12:15 +01:00
Koen J 36b4f5b41d Potential fix for issue where cast icon doesn't properly turn blue at the right moment. 2025-12-03 12:04:54 +01:00
Kelvin K def39ba397 Diff loop icon, allow loop1 in playlist, fix queue clear on opening video on a channel page 2025-12-02 17:11:12 -06:00
Marcus Hanestad 894e400819 casting: subscribe to and handle MediaItemEnd events 2025-11-27 16:56:43 +01:00
75 changed files with 1295 additions and 233 deletions
+1 -1
View File
@@ -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'
} }
+7
View File
@@ -261,5 +261,12 @@
android:name=".UpdateActionReceiver" android:name=".UpdateActionReceiver"
android:exported="false" /> android:exported="false" />
<activity
android:name=".activities.InstallUpdateActivity"
android:exported="false"
android:theme="@style/Theme.App.TransparentNoUi"
android:excludeFromRecents="true"
android:finishOnTaskLaunch="true" />
</application> </application>
</manifest> </manifest>
+7
View File
@@ -415,6 +415,8 @@ class VideoUrlSource {
this.url = obj.url; this.url = obj.url;
if(obj.requestModifier) if(obj.requestModifier)
this.requestModifier = obj.requestModifier; this.requestModifier = obj.requestModifier;
this.language = obj?.language;
this.original = obj?.original;
} }
} }
class VideoUrlWidevineSource extends VideoUrlSource { class VideoUrlWidevineSource extends VideoUrlSource {
@@ -512,6 +514,8 @@ class HLSSource {
this.language = obj.language; this.language = obj.language;
if(obj.requestModifier) if(obj.requestModifier)
this.requestModifier = obj.requestModifier; this.requestModifier = obj.requestModifier;
this.language = obj?.language;
this.original = obj?.original;
} }
} }
class DashSource { class DashSource {
@@ -525,6 +529,8 @@ class DashSource {
this.language = obj.language; this.language = obj.language;
if(obj.requestModifier) if(obj.requestModifier)
this.requestModifier = obj.requestModifier; this.requestModifier = obj.requestModifier;
this.language = obj?.language;
this.original = obj?.original;
} }
} }
class DashWidevineSource extends DashSource { class DashWidevineSource extends DashSource {
@@ -550,6 +556,7 @@ class DashManifestRawSource {
this.language = obj.language ?? Language.UNKNOWN; this.language = obj.language ?? Language.UNKNOWN;
if(obj.requestModifier) if(obj.requestModifier)
this.requestModifier = obj.requestModifier; this.requestModifier = obj.requestModifier;
this.original = obj?.original;
} }
} }
@@ -387,7 +387,7 @@ class Settings : FragmentedStorageFileJson() {
@DropdownFieldOptionsId(R.array.audio_languages) @DropdownFieldOptionsId(R.array.audio_languages)
var primaryLanguage: Int = 0; var primaryLanguage: Int = 0;
fun getPrimaryLanguage(context: Context): String? { fun getPrimaryLanguage(context: Context? = null): String? {
return when(primaryLanguage) { return when(primaryLanguage) {
0 -> "en"; 0 -> "en";
1 -> "es"; 1 -> "es";
@@ -875,9 +875,9 @@ class Settings : FragmentedStorageFileJson() {
@DropdownFieldOptionsId(R.array.auto_update_when_array) @DropdownFieldOptionsId(R.array.auto_update_when_array)
var check: Int = 0; var check: Int = 0;
@FormField(R.string.background_download, FieldForm.DROPDOWN, R.string.configure_if_background_download_should_be_used, 1) @FormField(R.string.background_download, FieldForm.TOGGLE, R.string.configure_if_background_download_should_be_used, 1)
@DropdownFieldOptionsId(R.array.background_download) //@DropdownFieldOptionsId(R.array.background_download)
var backgroundDownload: Int = 0; var shouldBackgroundDownload: Boolean = false;
@FormField(R.string.download_when, FieldForm.DROPDOWN, R.string.configure_when_updates_should_be_downloaded, 2) @FormField(R.string.download_when, FieldForm.DROPDOWN, R.string.configure_when_updates_should_be_downloaded, 2)
@DropdownFieldOptionsId(R.array.when_download) @DropdownFieldOptionsId(R.array.when_download)
@@ -370,17 +370,19 @@ class UIDialogs {
} }
fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null) { fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null, dismissAction: (() -> Unit)? = null): AlertDialog {
val confirmButtonAction = Action(context.getString(R.string.confirm), action, ActionStyle.PRIMARY) val confirmButtonAction = Action(context.getString(R.string.confirm), action, ActionStyle.PRIMARY)
val cancelButtonAction = Action(context.getString(R.string.cancel), cancelAction ?: {}, ActionStyle.ACCENT) val cancelButtonAction = Action(context.getString(R.string.cancel), cancelAction ?: {}, ActionStyle.ACCENT)
showDialog(context, R.drawable.ic_error, text, null, null, 0, cancelButtonAction, confirmButtonAction) return showDialog(context, R.drawable.ic_error, text, null, null, 0, cancelButtonAction, confirmButtonAction).apply {
setOnDismissListener { dismissAction?.invoke() }
}
} }
fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null, doNotAskAgainAction: (() -> Unit)? = null) { fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null, dismissAction: (() -> Unit)? = null, doNotAskAgainAction: (() -> Unit)? = null): AlertDialog {
val confirmButtonAction = Action(context.getString(R.string.confirm), action, ActionStyle.PRIMARY) val confirmButtonAction = Action(context.getString(R.string.confirm), action, ActionStyle.PRIMARY)
val cancelButtonAction = Action(context.getString(R.string.cancel), cancelAction ?: {}, ActionStyle.ACCENT) val cancelButtonAction = Action(context.getString(R.string.cancel), cancelAction ?: {}, ActionStyle.ACCENT)
val doNotAskAgain = Action(context.getString(R.string.do_not_ask_again), doNotAskAgainAction ?: {}, ActionStyle.NONE) val doNotAskAgain = Action(context.getString(R.string.do_not_ask_again), doNotAskAgainAction ?: {}, ActionStyle.NONE)
showDialog(context, R.drawable.ic_error, text, null, null, 0, doNotAskAgain, cancelButtonAction, confirmButtonAction) return showDialog(context, R.drawable.ic_error, text, null, null, 0, doNotAskAgain, cancelButtonAction, confirmButtonAction)
} }
fun showUpdateAvailableDialog(context: Context, lastVersion: Int, hideExceptionButtons: Boolean = false) { fun showUpdateAvailableDialog(context: Context, lastVersion: Int, hideExceptionButtons: Boolean = false) {
@@ -5,6 +5,7 @@ import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.util.Log
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.annotation.OptIn import androidx.annotation.OptIn
@@ -74,6 +75,8 @@ import kotlinx.coroutines.withContext
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import androidx.core.net.toUri import androidx.core.net.toUri
import com.futo.platformplayer.fragment.mainactivity.main.SettingsFragment import com.futo.platformplayer.fragment.mainactivity.main.SettingsFragment
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuButtonList
import kotlin.collections.toList
class UISlideOverlays { class UISlideOverlays {
companion object { companion object {
@@ -573,6 +576,51 @@ class UISlideOverlays {
return null; return null;
} }
val allLanguages = videoSources?.map { it.language }?.distinct() ?: listOf();
val langResCombinations = if(videoSources != null) allLanguages.flatMap {
lang -> videoSources
.filter { v -> v.language == lang }
.map { it.height * it.width }
.distinct()
.map { res -> Pair(res, lang) }
} else listOf();
var videoSourceItems = mutableListOf<SlideUpMenuItem>();
var selectedLanguage: String? = null;
val languageFilters = if(allLanguages.filter { it != null }.count() > 1)
SlideUpMenuButtonList(container.context, null, "language_filter", true).apply {
var languageFilterLabels = allLanguages.filterNotNull().toList();
val english = languageFilterLabels.find { it?.lowercase() == "en" };
val originalLanguage = videoSources?.find { it.original == true }?.language;
val primaryLanguage = Settings.instance.playback.getPrimaryLanguage();
val hasPrimaryLanguage = videoSources?.any { it.language == primaryLanguage } ?: false;
if(english != null)
languageFilterLabels = listOf(english).plus(languageFilterLabels.filter { it != english }).toList();
if(primaryLanguage != null && languageFilterLabels.contains(primaryLanguage))
languageFilterLabels = listOf(primaryLanguage).plus(languageFilterLabels.filter { it != primaryLanguage }).toList();
if(originalLanguage != null)
languageFilterLabels = listOf(originalLanguage).plus(languageFilterLabels.filter { it != originalLanguage }).toList();
Log.i(TAG, "Language filtesr: ${languageFilterLabels.joinToString(", ")}");
selectedLanguage = originalLanguage ?: (if(hasPrimaryLanguage) primaryLanguage else null);
setButtons(languageFilterLabels, selectedLanguage);
onClick.subscribe { selected ->
setSelected(selected);
videoSourceItems.forEach {
val item = it.itemTag;
if(item is IVideoSource) {
if(item.language == selected)
it.visibility = View.VISIBLE;
else
it.visibility = View.GONE;
}
}
}
}
else null;
if(languageFilters != null) items.add(languageFilters)
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.video), videoSources, items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.video), videoSources,
listOf((if (audioSources != null) listOf(SlideUpMenuItem( listOf((if (audioSources != null) listOf(SlideUpMenuItem(
container.context, container.context,
@@ -609,7 +657,13 @@ class UISlideOverlays {
menu?.setOk(container.context.getString(R.string.download)); menu?.setOk(container.context.getString(R.string.download));
}, },
invokeParent = false invokeParent = false
) ).apply {
videoSourceItems.add(this);
if(selectedLanguage != null) {
if(it.language != selectedLanguage)
this.visibility = View.GONE;
}
}
} }
is JSDashManifestRawSource -> { is JSDashManifestRawSource -> {
@@ -629,7 +683,13 @@ class UISlideOverlays {
menu?.setOk(container.context.getString(R.string.download)); menu?.setOk(container.context.getString(R.string.download));
}, },
invokeParent = false invokeParent = false
) ).apply {
videoSourceItems.add(this);
if(selectedLanguage != null) {
if(it.language != selectedLanguage)
this.visibility = View.GONE;
}
}
} }
is IHLSManifestSource -> { is IHLSManifestSource -> {
@@ -643,7 +703,13 @@ class UISlideOverlays {
showHlsPicker(video, it, it.url, container) showHlsPicker(video, it, it.url, container)
}, },
invokeParent = false invokeParent = false
) ).apply {
videoSourceItems.add(this);
if(selectedLanguage != null) {
if(it.language != selectedLanguage)
this.visibility = View.GONE;
}
}
} }
else -> { else -> {
@@ -6,6 +6,7 @@ import android.content.Intent
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.dialogs.AutoUpdateDialog
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import java.io.File import java.io.File
@@ -16,11 +17,12 @@ class UpdateActionReceiver : BroadcastReceiver() {
UpdateNotificationManager.ACTION_UPDATE_NO -> handleUpdateNo(context) UpdateNotificationManager.ACTION_UPDATE_NO -> handleUpdateNo(context)
UpdateNotificationManager.ACTION_UPDATE_NEVER -> handleUpdateNever(context) UpdateNotificationManager.ACTION_UPDATE_NEVER -> handleUpdateNever(context)
UpdateNotificationManager.ACTION_DOWNLOAD_CANCEL -> handleDownloadCancel(context, intent) UpdateNotificationManager.ACTION_DOWNLOAD_CANCEL -> handleDownloadCancel(context, intent)
UpdateNotificationManager.ACTION_INSTALL_NOW -> handleInstallNow(context, intent)
} }
} }
private fun handleUpdateYes(context: Context, intent: Intent) { private fun handleUpdateYes(context: Context, intent: Intent) {
AutoUpdateDialog.currentDialog?.dismiss()
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0) val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
if (version == 0) { if (version == 0) {
return return
@@ -28,31 +30,19 @@ class UpdateActionReceiver : BroadcastReceiver() {
NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_AVAILABLE) NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_AVAILABLE)
if (Settings.instance.autoUpdate.backgroundDownload == 1) { val serviceIntent = Intent(context, UpdateDownloadService::class.java).apply {
val serviceIntent = Intent(context, UpdateDownloadService::class.java).apply { putExtra(UpdateDownloadService.EXTRA_VERSION, version)
putExtra(UpdateDownloadService.EXTRA_VERSION, version)
}
ContextCompat.startForegroundService(context, serviceIntent)
} else {
if (StateApp.instance.isMainActive) {
StateApp.withContext { ctx ->
UIDialogs.showUpdateAvailableDialog(ctx, version, false)
}
} else {
val startIntent = Intent(context, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
putExtra("SHOW_UPDATE_DIALOG_VERSION", version)
}
context.startActivity(startIntent)
}
} }
ContextCompat.startForegroundService(context, serviceIntent)
} }
private fun handleUpdateNo(context: Context) { private fun handleUpdateNo(context: Context) {
AutoUpdateDialog.currentDialog?.dismiss()
NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_AVAILABLE) NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_AVAILABLE)
} }
private fun handleUpdateNever(context: Context) { private fun handleUpdateNever(context: Context) {
AutoUpdateDialog.currentDialog?.dismiss()
Settings.instance.autoUpdate.check = 1 Settings.instance.autoUpdate.check = 1
Settings.instance.save() Settings.instance.save()
@@ -70,21 +60,4 @@ class UpdateActionReceiver : BroadcastReceiver() {
NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_DOWNLOADING) NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_DOWNLOADING)
} }
private fun handleInstallNow(context: Context, intent: Intent) {
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
val apkPath = intent.getStringExtra(UpdateNotificationManager.EXTRA_APK_PATH)
if (version == 0 || apkPath.isNullOrEmpty()) {
return
}
val apkFile = File(apkPath)
if (!apkFile.exists()) {
return
}
UpdateNotificationManager.cancelAll(context)
UpdateInstaller.startInstall(context, apkFile)
}
} }
@@ -1,8 +1,11 @@
package com.futo.platformplayer package com.futo.platformplayer
import android.app.Dialog
import android.app.Service import android.app.Service
import android.content.Intent import android.content.Intent
import android.os.IBinder import android.os.IBinder
import android.os.SystemClock
import com.futo.platformplayer.UIDialogs.ActionStyle
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateUpdate import com.futo.platformplayer.states.StateUpdate
@@ -21,6 +24,9 @@ class UpdateDownloadService : Service() {
private const val MAX_RETRIES = 5 private const val MAX_RETRIES = 5
private const val INITIAL_BACKOFF_MS = 5_000L private const val INITIAL_BACKOFF_MS = 5_000L
private const val BUFFER_SIZE = 8 * 1024 private const val BUFFER_SIZE = 8 * 1024
private const val MIN_PROGRESS_UPDATE_INTERVAL_MS = 500L
var updateDownloadedDialog: Dialog? = null
} }
private val job = SupervisorJob() private val job = SupervisorJob()
@@ -32,6 +38,8 @@ class UpdateDownloadService : Service() {
@Volatile @Volatile
private var cancelRequested: Boolean = false private var cancelRequested: Boolean = false
private var lastProgressUpdateElapsedMs: Long = 0L
override fun onBind(intent: Intent?): IBinder? = null override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -77,6 +85,16 @@ class UpdateDownloadService : Service() {
job.cancel() job.cancel()
} }
private fun throttledUpdateDownloadProgress(version: Int, progress: Int, indeterminate: Boolean) {
val now = SystemClock.elapsedRealtime()
val force = progress == 100 && !indeterminate
if (force || now - lastProgressUpdateElapsedMs >= MIN_PROGRESS_UPDATE_INTERVAL_MS) {
lastProgressUpdateElapsedMs = now
UpdateNotificationManager.updateDownloadProgress(this, version, progress, indeterminate)
}
}
private suspend fun downloadApk(version: Int) { private suspend fun downloadApk(version: Int) {
val apkFile = StateUpdate.getApkFile(this, version) val apkFile = StateUpdate.getApkFile(this, version)
val partialFile = StateUpdate.getPartialApkFile(this, version) val partialFile = StateUpdate.getPartialApkFile(this, version)
@@ -186,12 +204,18 @@ class UpdateDownloadService : Service() {
progress > 100 -> 100 progress > 100 -> 100
else -> progress else -> progress
} }
UpdateNotificationManager.updateDownloadProgress(this, version, safeProgress, false) throttledUpdateDownloadProgress(version, safeProgress, indeterminate = false)
} }
} else { } else {
UpdateNotificationManager.updateDownloadProgress(this, version, 0, true) throttledUpdateDownloadProgress(version, progress = 0, indeterminate = true)
} }
} }
if (!cancelRequested && totalBytes > 0L) {
val finalProgress = 100
throttledUpdateDownloadProgress(version, finalProgress, indeterminate = false)
}
output.flush() output.flush()
} }
} }
@@ -216,12 +240,19 @@ class UpdateDownloadService : Service() {
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
StateApp.withContext { ctx -> StateApp.withContext { ctx ->
try { try {
UIDialogs.showConfirmationDialog(ctx, "Update downloaded, press confirm to install", { updateDownloadedDialog = UIDialogs.showDialog(ctx, R.drawable.foreground,
UpdateNotificationManager.cancelAll(ctx) "Update downloaded",
UpdateInstaller.startInstall(ctx, apkFile) "Would you like to install it now?", null, 0,
}, {}) UIDialogs.Action("Not now", {
updateDownloadedDialog = null
}, ActionStyle.NONE, true),
UIDialogs.Action("Install", {
UpdateNotificationManager.cancelAll(ctx)
UpdateInstaller.startInstall(ctx, version, apkFile)
}, ActionStyle.PRIMARY, true));
} catch (t: Throwable) { } catch (t: Throwable) {
Logger.w(TAG, "Failed to show in-app update downloaded dialog", t) Logger.w(TAG, "Failed to show in-app update downloaded dialog", t)
updateDownloadedDialog = null
} }
} }
} }
@@ -7,7 +7,9 @@ import android.app.PendingIntent.getBroadcast
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageInstaller import android.content.pm.PackageInstaller
import android.graphics.drawable.Animatable
import android.provider.Settings import android.provider.Settings
import android.view.View
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.receivers.InstallReceiver import com.futo.platformplayer.receivers.InstallReceiver
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -17,20 +19,24 @@ import kotlinx.coroutines.withContext
import java.io.File import java.io.File
import java.io.InputStream import java.io.InputStream
import androidx.core.net.toUri import androidx.core.net.toUri
import com.futo.platformplayer.dialogs.AutoUpdateDialog
import com.futo.platformplayer.states.StateApp
object UpdateInstaller { object UpdateInstaller {
private const val TAG = "UpdateInstaller" private const val TAG = "UpdateInstaller"
@SuppressLint("RequestInstallPackagesPolicy") @SuppressLint("RequestInstallPackagesPolicy")
fun startInstall(context: Context, apkFile: File) { fun startInstall(context: Context, version: Int, apkFile: File) {
if (!apkFile.exists()) { if (!apkFile.exists()) {
Logger.w(TAG, "APK file does not exist: ${apkFile.absolutePath}") Logger.w(TAG, "APK file does not exist: ${apkFile.absolutePath}")
UIDialogs.toast(context, "Update file missing") UIDialogs.toast(context, "Update file missing")
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, "APK file does not exist.")
return return
} }
if (BuildConfig.IS_PLAYSTORE_BUILD) { if (BuildConfig.IS_PLAYSTORE_BUILD) {
UIDialogs.toast(context, "Updates are managed by the Play Store") UIDialogs.toast(context, "Updates are managed by the Play Store")
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, "Updates are managed by the Play Store.")
return return
} }
@@ -38,6 +44,7 @@ object UpdateInstaller {
val pm = context.packageManager val pm = context.packageManager
if (!pm.canRequestPackageInstalls()) { if (!pm.canRequestPackageInstalls()) {
UIDialogs.toast(context, "Allow this app to install updates, then try again") UIDialogs.toast(context, "Allow this app to install updates, then try again")
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, "Install update permission was missing.")
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply { val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
data = "package:${context.packageName}".toUri() data = "package:${context.packageName}".toUri()
@@ -53,8 +60,8 @@ object UpdateInstaller {
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
var inputStream: InputStream? = null var inputStream: InputStream? = null
var session: PackageInstaller.Session? = null var session: PackageInstaller.Session? = null
try { try {
val packageInstaller: PackageInstaller = context.packageManager.packageInstaller val packageInstaller: PackageInstaller = context.packageManager.packageInstaller
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL) val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
val sessionId = packageInstaller.createSession(params) val sessionId = packageInstaller.createSession(params)
@@ -68,10 +75,17 @@ object UpdateInstaller {
session.fsync(sessionStream) session.fsync(sessionStream)
} }
val intent = Intent(context, InstallReceiver::class.java) val intent = Intent(context, InstallReceiver::class.java).apply {
putExtra(UpdateNotificationManager.EXTRA_VERSION, version)
putExtra(UpdateNotificationManager.EXTRA_APK_PATH, apkFile.absolutePath)
}
val pendingIntent = getBroadcast(context, 0, intent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT) val pendingIntent = getBroadcast(context, 0, intent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
val statusReceiver = pendingIntent.intentSender val statusReceiver = pendingIntent.intentSender
InstallReceiver.onReceiveResult.subscribe(this) { message ->
InstallReceiver.onReceiveResult.clear();
onReceiveResult(context, version, apkFile, message);
};
Logger.i(TAG, "Committing install session for ${apkFile.absolutePath}") Logger.i(TAG, "Committing install session for ${apkFile.absolutePath}")
session.commit(statusReceiver) session.commit(statusReceiver)
} catch (e: Throwable) { } catch (e: Throwable) {
@@ -80,10 +94,29 @@ object UpdateInstaller {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
UIDialogs.toast(context, "Failed to install update: ${e.message}") UIDialogs.toast(context, "Failed to install update: ${e.message}")
} }
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, e.message)
} finally { } finally {
session?.close() session?.close()
inputStream?.close() inputStream?.close()
} }
} }
} }
private fun onReceiveResult(context: Context, version: Int, apkFile: File, result: String?) {
try {
InstallReceiver.onReceiveResult.remove(this)
if (result.isNullOrEmpty()) {
Logger.i(TAG, "Update install finished successfully")
UpdateNotificationManager.showInstallSucceededNotification(context, version)
} else {
Logger.w(TAG, "Update install failed: $result")
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, result)
UIDialogs.showGeneralErrorDialog(context, "Install failed due to:\n$result")
}
} catch (e: Throwable) {
Logger.e(TAG, "Failed to handle install result", e)
}
}
} }
@@ -4,6 +4,7 @@ import android.Manifest
import android.app.Notification import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent
import android.app.PendingIntent.FLAG_MUTABLE import android.app.PendingIntent.FLAG_MUTABLE
import android.app.PendingIntent.FLAG_UPDATE_CURRENT import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.app.PendingIntent.getBroadcast import android.app.PendingIntent.getBroadcast
@@ -13,6 +14,7 @@ import android.content.pm.PackageManager
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.futo.platformplayer.activities.InstallUpdateActivity
import java.io.File import java.io.File
object UpdateNotificationManager { object UpdateNotificationManager {
@@ -25,6 +27,7 @@ object UpdateNotificationManager {
const val ACTION_UPDATE_NEVER = "com.futo.platformplayer.UPDATE_NEVER" const val ACTION_UPDATE_NEVER = "com.futo.platformplayer.UPDATE_NEVER"
const val ACTION_DOWNLOAD_CANCEL = "com.futo.platformplayer.UPDATE_CANCEL" const val ACTION_DOWNLOAD_CANCEL = "com.futo.platformplayer.UPDATE_CANCEL"
const val ACTION_INSTALL_NOW = "com.futo.platformplayer.UPDATE_INSTALL" const val ACTION_INSTALL_NOW = "com.futo.platformplayer.UPDATE_INSTALL"
private const val REQUEST_CODE_INSTALL = 1001
const val EXTRA_VERSION = "version" const val EXTRA_VERSION = "version"
const val EXTRA_APK_PATH = "apk_path" const val EXTRA_APK_PATH = "apk_path"
@@ -32,16 +35,55 @@ object UpdateNotificationManager {
const val NOTIF_ID_AVAILABLE = 2001 const val NOTIF_ID_AVAILABLE = 2001
const val NOTIF_ID_DOWNLOADING = 2002 const val NOTIF_ID_DOWNLOADING = 2002
const val NOTIF_ID_READY = 2003 const val NOTIF_ID_READY = 2003
const val NOTIF_ID_INSTALL_FAILED = 2004
const val NOTIF_ID_INSTALL_SUCCEEDED = 2005
fun ensureChannel(context: Context) { fun ensureChannel(context: Context) {
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (manager.getNotificationChannel(CHANNEL_ID) == null) { if (manager.getNotificationChannel(CHANNEL_ID) == null) {
val channel = NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT) val channel = NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT).apply {
channel.description = CHANNEL_DESCRIPTION description = CHANNEL_DESCRIPTION
enableVibration(false)
enableLights(false)
setSound(null, null)
}
manager.createNotificationChannel(channel) manager.createNotificationChannel(channel)
} }
} }
fun showInstallSucceededNotification(context: Context, version: Int) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
return
}
ensureChannel(context)
val launchIntent = context.packageManager
.getLaunchIntentForPackage(context.packageName)
?.apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED)
}
val launchPendingIntent = launchIntent?.let {
PendingIntent.getActivity(context, REQUEST_CODE_INSTALL, it, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
}
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.foreground)
.setContentTitle("Update installed")
.setContentText("Version $version installed. Tap to open.")
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true)
.setSilent(true)
if (launchPendingIntent != null) {
builder.setContentIntent(launchPendingIntent)
builder.addAction(0, "Open app", launchPendingIntent)
}
NotificationManagerCompat.from(context).notify(NOTIF_ID_INSTALL_SUCCEEDED, builder.build())
}
fun showUpdateAvailableNotification(context: Context, version: Int) { fun showUpdateAvailableNotification(context: Context, version: Int) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
return return
@@ -70,9 +112,11 @@ object UpdateNotificationManager {
.setContentText("A new version ($version) is available.") .setContentText("A new version ($version) is available.")
.setPriority(NotificationCompat.PRIORITY_DEFAULT) .setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true) .setAutoCancel(true)
.addAction(0, "Download", yesPendingIntent) .setContentIntent(yesPendingIntent)
.addAction(0, "Not now", noPendingIntent) .setSilent(true)
.addAction(0, "Never", neverPendingIntent) .addAction(0, "Never", neverPendingIntent)
.addAction(0, "Not now", noPendingIntent)
.addAction(0, "Download", yesPendingIntent)
NotificationManagerCompat.from(context).notify(NOTIF_ID_AVAILABLE, builder.build()) NotificationManagerCompat.from(context).notify(NOTIF_ID_AVAILABLE, builder.build())
} }
@@ -95,8 +139,9 @@ object UpdateNotificationManager {
.setSmallIcon(R.drawable.foreground) .setSmallIcon(R.drawable.foreground)
.setContentTitle("Downloading update") .setContentTitle("Downloading update")
.setContentText("Downloading version $version") .setContentText("Downloading version $version")
.setPriority(NotificationCompat.PRIORITY_LOW) .setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setOngoing(true) .setOngoing(true)
.setSilent(true)
.addAction(0, "Cancel", cancelPendingIntent) .addAction(0, "Cancel", cancelPendingIntent)
if (indeterminate) { if (indeterminate) {
@@ -123,24 +168,17 @@ object UpdateNotificationManager {
} }
ensureChannel(context) ensureChannel(context)
val installIntent = Intent(context, UpdateActionReceiver::class.java).apply { val installIntent = InstallUpdateActivity.createIntent(context, version, apkFile.absolutePath)
action = ACTION_INSTALL_NOW val installPendingIntent = PendingIntent.getActivity(context, REQUEST_CODE_INSTALL, installIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
putExtra(EXTRA_VERSION, version)
putExtra(EXTRA_APK_PATH, apkFile.absolutePath)
}
val installPendingIntent = getBroadcast(
context,
4,
installIntent,
FLAG_MUTABLE or FLAG_UPDATE_CURRENT
)
val builder = NotificationCompat.Builder(context, CHANNEL_ID) val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.foreground) .setSmallIcon(R.drawable.foreground)
.setContentTitle("Update downloaded") .setContentTitle("Update downloaded")
.setContentText("Tap to install version $version.") .setContentText("Tap to install version $version.")
.setPriority(NotificationCompat.PRIORITY_DEFAULT) .setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(installPendingIntent)
.setAutoCancel(true) .setAutoCancel(true)
.setSilent(true)
.addAction(0, "Install", installPendingIntent) .addAction(0, "Install", installPendingIntent)
NotificationManagerCompat.from(context).notify(NOTIF_ID_READY, builder.build()) NotificationManagerCompat.from(context).notify(NOTIF_ID_READY, builder.build())
@@ -159,13 +197,37 @@ object UpdateNotificationManager {
.setContentText(error?.message ?: "Unknown error") .setContentText(error?.message ?: "Unknown error")
.setPriority(NotificationCompat.PRIORITY_DEFAULT) .setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true) .setAutoCancel(true)
.setSilent(true)
NotificationManagerCompat.from(context).notify(NOTIF_ID_READY, builder.build()) NotificationManagerCompat.from(context).notify(NOTIF_ID_READY, builder.build())
} }
fun showInstallFailedNotification(context: Context, version: Int, apkFile: File, error: String?) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED)
return
ensureChannel(context)
val installIntent = InstallUpdateActivity.createIntent(context, version, apkFile.absolutePath)
val installPendingIntent = PendingIntent.getActivity(context, REQUEST_CODE_INSTALL, installIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.foreground)
.setContentTitle("Failed to install update")
.setContentText(if (error != null && error.isNotBlank()) "$error Tap to try again." else "Tap to try again.")
.setAutoCancel(true)
.setSilent(true)
.setContentIntent(installPendingIntent)
.addAction(0, "Install again", installPendingIntent)
NotificationManagerCompat.from(context).notify(NOTIF_ID_INSTALL_FAILED, builder.build())
}
fun cancelAll(context: Context) { fun cancelAll(context: Context) {
NotificationManagerCompat.from(context).cancel(NOTIF_ID_AVAILABLE) NotificationManagerCompat.from(context).cancel(NOTIF_ID_AVAILABLE)
NotificationManagerCompat.from(context).cancel(NOTIF_ID_DOWNLOADING) NotificationManagerCompat.from(context).cancel(NOTIF_ID_DOWNLOADING)
NotificationManagerCompat.from(context).cancel(NOTIF_ID_READY) NotificationManagerCompat.from(context).cancel(NOTIF_ID_READY)
NotificationManagerCompat.from(context).cancel(NOTIF_ID_INSTALL_FAILED)
NotificationManagerCompat.from(context).cancel(NOTIF_ID_INSTALL_SUCCEEDED)
} }
} }
@@ -444,15 +444,9 @@ fun addressScore(addr: InetAddress): Int {
fun <T> Enumeration<T>.toList(): List<T> = Collections.list(this) fun <T> Enumeration<T>.toList(): List<T> = Collections.list(this)
fun <T> RequestBuilder<T>.withMaxSizePx(maxSizePx: Int = 1920, useCenterCrop: Boolean = false): RequestBuilder<T> { fun <T> RequestBuilder<T>.withMaxSizePx(maxSizePx: Int = 1920): RequestBuilder<T> {
var builder = this return this;
.downsample(DownsampleStrategy.AT_MOST) //.downsample(DownsampleStrategy.AT_MOST)
.override(maxSizePx, maxSizePx) //.override(maxSizePx, maxSizePx)
builder = if (useCenterCrop) { //.centerInside()
builder.centerCrop()
} else {
builder.fitCenter()
}
return builder
} }
@@ -0,0 +1,49 @@
package com.futo.platformplayer.activities
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UpdateInstaller
import com.futo.platformplayer.UpdateNotificationManager
import com.futo.platformplayer.logging.Logger
import java.io.File
class InstallUpdateActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
UpdateNotificationManager.cancelAll(this)
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
val apkPath = intent.getStringExtra(UpdateNotificationManager.EXTRA_APK_PATH)
if (version == 0 || apkPath.isNullOrEmpty()) {
Logger.w("InstallUpdateActivity", "Missing version or apkPath")
finish()
return
}
val apkFile = File(apkPath)
if (!apkFile.exists()) {
Logger.w("InstallUpdateActivity", "APK file does not exist: $apkPath")
UIDialogs.Companion.toast(this, "Update file missing")
finish()
return
}
UpdateInstaller.startInstall(this, version, apkFile)
finish()
}
companion object {
fun createIntent(context: Context, version: Int, apkPath: String): Intent =
Intent(context, InstallUpdateActivity::class.java).apply {
putExtra(UpdateNotificationManager.EXTRA_VERSION, version)
putExtra(UpdateNotificationManager.EXTRA_APK_PATH, apkPath)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
}
}
@@ -618,8 +618,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
sharedPreferences.edit().putBoolean("IsFirstBoot", false).apply() sharedPreferences.edit().putBoolean("IsFirstBoot", false).apply()
} }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && Settings.instance.autoUpdate.isAutoUpdateEnabled()) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && Settings.instance.autoUpdate.isAutoUpdateEnabled() && Settings.instance.autoUpdate.shouldBackgroundDownload) {
requestNotificationPermissions("Grayjay uses notifications to inform you when a new app update is available."); requestNotificationPermissions("You have enabled background updating.\n\nGrayjay uses notifications to inform you when a new app update is available.");
} }
val submissionStatus = FragmentedStorage.get<StringStorage>("subscriptionSubmissionStatus") val submissionStatus = FragmentedStorage.get<StringStorage>("subscriptionSubmissionStatus")
@@ -1299,11 +1299,24 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
navigate(last.first, last.second, false, true); navigate(last.first, last.second, false, true);
} else { } else {
if (_fragVideoDetail.state == VideoDetailFragment.State.CLOSED) { if (_fragVideoDetail.state == VideoDetailFragment.State.CLOSED) {
Logger.i(TAG, "Closing activity because _fragVideoDetail.state == closed");
finish(); finish();
} else { } else {
//UIDialogs.toast("Grayjay continues in background because of an open video.")
if(Settings.instance.playback.isBackgroundPictureInPicture()) {
try {
_fragVideoDetail._viewDetail?.startPictureInPicture();
_fragVideoDetail?.forcePictureInPicture();
} catch (ex: Throwable) {
} //Fail silently
}
else
moveTaskToBack(false);
/*
UIDialogs.showConfirmationDialog(this, "There is a video playing, are you sure you want to exit the app?", { UIDialogs.showConfirmationDialog(this, "There is a video playing, are you sure you want to exit the app?", {
finish(); finish();
}) })
*/
} }
} }
} }
@@ -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")
}
}
}
@@ -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;
} }
@@ -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;
} }
@@ -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
} }
@@ -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?;
} }
@@ -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;
@@ -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;
} }
@@ -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;
@@ -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);
@@ -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 {
@@ -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? {
@@ -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);
} }
} }
@@ -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 =
@@ -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;
} }
@@ -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;
} }
} }
@@ -20,6 +20,9 @@ class LocalVideoFileSource: IVideoSource {
override val duration: Long; override val duration: Long;
override val priority: Boolean = false; override val priority: Boolean = false;
override val language: String? = null;
override val original: Boolean? = null;
var file: File; var file: File;
constructor(file: File) { constructor(file: File) {
@@ -1,5 +1,6 @@
package com.futo.platformplayer.casting package com.futo.platformplayer.casting
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.models.CastingDeviceInfo import com.futo.platformplayer.models.CastingDeviceInfo
import org.fcast.sender_sdk.Metadata import org.fcast.sender_sdk.Metadata
@@ -16,6 +17,7 @@ abstract class CastingDevice {
abstract val onDurationChanged: Event1<Double> abstract val onDurationChanged: Event1<Double>
abstract val onVolumeChanged: Event1<Double> abstract val onVolumeChanged: Event1<Double>
abstract val onSpeedChanged: Event1<Double> abstract val onSpeedChanged: Event1<Double>
abstract val onMediaItemEnd: Event0
abstract var connectionState: CastConnectionState abstract var connectionState: CastConnectionState
abstract val protocolType: CastProtocolType abstract val protocolType: CastProtocolType
abstract var isPlaying: Boolean abstract var isPlaying: Boolean
@@ -2,12 +2,14 @@ package com.futo.platformplayer.casting
import android.os.Build import android.os.Build
import com.futo.platformplayer.BuildConfig import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.polycentric.core.Event
import org.fcast.sender_sdk.ApplicationInfo import org.fcast.sender_sdk.ApplicationInfo
import org.fcast.sender_sdk.GenericKeyEvent import org.fcast.sender_sdk.KeyEvent
import org.fcast.sender_sdk.GenericMediaEvent import org.fcast.sender_sdk.MediaEvent
import org.fcast.sender_sdk.PlaybackState import org.fcast.sender_sdk.PlaybackState
import org.fcast.sender_sdk.Source import org.fcast.sender_sdk.Source
import java.net.InetAddress import java.net.InetAddress
@@ -15,8 +17,10 @@ import org.fcast.sender_sdk.CastingDevice as RsCastingDevice;
import org.fcast.sender_sdk.DeviceEventHandler as RsDeviceEventHandler; import org.fcast.sender_sdk.DeviceEventHandler as RsDeviceEventHandler;
import org.fcast.sender_sdk.DeviceConnectionState import org.fcast.sender_sdk.DeviceConnectionState
import org.fcast.sender_sdk.DeviceFeature import org.fcast.sender_sdk.DeviceFeature
import org.fcast.sender_sdk.EventSubscription
import org.fcast.sender_sdk.IpAddr import org.fcast.sender_sdk.IpAddr
import org.fcast.sender_sdk.LoadRequest import org.fcast.sender_sdk.LoadRequest
import org.fcast.sender_sdk.MediaItemEventType
import org.fcast.sender_sdk.Metadata import org.fcast.sender_sdk.Metadata
import org.fcast.sender_sdk.ProtocolType import org.fcast.sender_sdk.ProtocolType
import org.fcast.sender_sdk.urlFormatIpAddr import org.fcast.sender_sdk.urlFormatIpAddr
@@ -63,6 +67,7 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
var onDurationChanged = Event1<Double>() var onDurationChanged = Event1<Double>()
var onVolumeChanged = Event1<Double>() var onVolumeChanged = Event1<Double>()
var onSpeedChanged = Event1<Double>() var onSpeedChanged = Event1<Double>()
var onMediaItemEnd = Event0()
override fun connectionStateChanged(state: DeviceConnectionState) { override fun connectionStateChanged(state: DeviceConnectionState) {
onConnectionStateChanged.emit(state) onConnectionStateChanged.emit(state)
@@ -92,12 +97,14 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
// TODO // TODO
} }
override fun keyEvent(event: GenericKeyEvent) { override fun keyEvent(event: KeyEvent) {
// Unreachable // Unreachable
} }
override fun mediaEvent(event: GenericMediaEvent) { override fun mediaEvent(event: MediaEvent) {
// Unreachable if (event.type == MediaItemEventType.END) {
onMediaItemEnd.emit()
}
} }
override fun playbackError(message: String) { override fun playbackError(message: String) {
@@ -127,6 +134,8 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
get() = eventHandler.onVolumeChanged get() = eventHandler.onVolumeChanged
override val onSpeedChanged: Event1<Double> override val onSpeedChanged: Event1<Double>
get() = eventHandler.onSpeedChanged get() = eventHandler.onSpeedChanged
override val onMediaItemEnd: Event0
get() = eventHandler.onMediaItemEnd
override fun resumePlayback() = device.resumePlayback() override fun resumePlayback() = device.resumePlayback()
override fun pausePlayback() = device.pausePlayback() override fun pausePlayback() = device.pausePlayback()
@@ -181,7 +190,8 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
resumePosition = resumePosition, resumePosition = resumePosition,
speed = speed, speed = speed,
volume = volume, volume = volume,
metadata = metadata metadata = metadata,
requestHeaders = null,
) )
) )
@@ -200,6 +210,7 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
speed = speed, speed = speed,
volume = volume, volume = volume,
metadata = metadata, metadata = metadata,
requestHeaders = null,
) )
) )
@@ -227,6 +238,13 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
eventHandler.onConnectionStateChanged.subscribe { newState -> eventHandler.onConnectionStateChanged.subscribe { newState ->
when (newState) { when (newState) {
is DeviceConnectionState.Connected -> { is DeviceConnectionState.Connected -> {
if (device.supportsFeature(DeviceFeature.MEDIA_EVENT_SUBSCRIPTION)) {
try {
device.subscribeEvent(EventSubscription.MediaItemEnd)
} catch (e: Exception) {
Logger.e(TAG, "Failed to subscribe to MediaItemEnd events: $e")
}
}
usedRemoteAddress = ipAddrToInetAddress(newState.usedRemoteAddr) usedRemoteAddress = ipAddrToInetAddress(newState.usedRemoteAddr)
localAddress = ipAddrToInetAddress(newState.localAddr) localAddress = ipAddrToInetAddress(newState.localAddr)
connectionState = CastConnectionState.CONNECTED connectionState = CastConnectionState.CONNECTED
@@ -239,7 +257,7 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
} }
DeviceConnectionState.Disconnected -> { DeviceConnectionState.Disconnected -> {
connectionState = CastConnectionState.CONNECTING connectionState = CastConnectionState.DISCONNECTED
onConnectionStateChanged.emit(CastConnectionState.DISCONNECTED) onConnectionStateChanged.emit(CastConnectionState.DISCONNECTED)
} }
} }
@@ -268,4 +286,4 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
companion object { companion object {
private val TAG = "CastingDeviceExp" private val TAG = "CastingDeviceExp"
} }
} }
@@ -1,5 +1,6 @@
package com.futo.platformplayer.casting package com.futo.platformplayer.casting
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.models.CastingDeviceInfo import com.futo.platformplayer.models.CastingDeviceInfo
import kotlinx.serialization.KSerializer import kotlinx.serialization.KSerializer
@@ -181,6 +182,7 @@ class CastingDeviceLegacyWrapper(val inner: CastingDeviceLegacy) : CastingDevice
override val onDurationChanged: Event1<Double> get() = inner.onDurationChanged override val onDurationChanged: Event1<Double> get() = inner.onDurationChanged
override val onVolumeChanged: Event1<Double> get() = inner.onVolumeChanged override val onVolumeChanged: Event1<Double> get() = inner.onVolumeChanged
override val onSpeedChanged: Event1<Double> get() = inner.onSpeedChanged override val onSpeedChanged: Event1<Double> get() = inner.onSpeedChanged
override val onMediaItemEnd: Event0 = Event0()
override var connectionState: CastConnectionState override var connectionState: CastConnectionState
get() = inner.connectionState get() = inner.connectionState
set(_) = Unit set(_) = Unit
@@ -6,6 +6,7 @@ import android.content.Context
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.core.net.toUri
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
@@ -14,6 +15,7 @@ import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.http.server.HttpHeaders import com.futo.platformplayer.api.http.server.HttpHeaders
import com.futo.platformplayer.api.http.server.ManagedHttpServer import com.futo.platformplayer.api.http.server.ManagedHttpServer
import com.futo.platformplayer.api.http.server.handlers.HttpConstantHandler import com.futo.platformplayer.api.http.server.handlers.HttpConstantHandler
import com.futo.platformplayer.api.http.server.handlers.HttpContentUriHandler
import com.futo.platformplayer.api.http.server.handlers.HttpFileHandler import com.futo.platformplayer.api.http.server.handlers.HttpFileHandler
import com.futo.platformplayer.api.http.server.handlers.HttpFunctionHandler import com.futo.platformplayer.api.http.server.handlers.HttpFunctionHandler
import com.futo.platformplayer.api.http.server.handlers.HttpProxyHandler import com.futo.platformplayer.api.http.server.handlers.HttpProxyHandler
@@ -34,8 +36,11 @@ import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManif
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalAudioContentSource
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoContentSource
import com.futo.platformplayer.awaitCancelConverted import com.futo.platformplayer.awaitCancelConverted
import com.futo.platformplayer.builders.DashBuilder import com.futo.platformplayer.builders.DashBuilder
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.models.CastingDeviceInfo import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.constructs.Event2
@@ -78,6 +83,7 @@ abstract class StateCasting {
val onActiveDeviceTimeChanged = Event1<Double>(); val onActiveDeviceTimeChanged = Event1<Double>();
val onActiveDeviceDurationChanged = Event1<Double>(); val onActiveDeviceDurationChanged = Event1<Double>();
val onActiveDeviceVolumeChanged = Event1<Double>(); val onActiveDeviceVolumeChanged = Event1<Double>();
val onActiveDeviceMediaItemEnd = Event0()
var activeDevice: CastingDevice? = null; var activeDevice: CastingDevice? = null;
private var _videoExecutor: JSRequestExecutor? = null private var _videoExecutor: JSRequestExecutor? = null
private var _audioExecutor: JSRequestExecutor? = null private var _audioExecutor: JSRequestExecutor? = null
@@ -141,6 +147,7 @@ abstract class StateCasting {
device.onTimeChanged.clear(); device.onTimeChanged.clear();
device.onVolumeChanged.clear(); device.onVolumeChanged.clear();
device.onDurationChanged.clear(); device.onDurationChanged.clear();
device.onMediaItemEnd.clear();
ad.disconnect() ad.disconnect()
} }
@@ -155,6 +162,7 @@ abstract class StateCasting {
device.onTimeChanged.clear(); device.onTimeChanged.clear();
device.onVolumeChanged.clear(); device.onVolumeChanged.clear();
device.onDurationChanged.clear(); device.onDurationChanged.clear();
device.onMediaItemEnd.clear();
activeDevice = null; activeDevice = null;
} }
@@ -218,6 +226,9 @@ abstract class StateCasting {
device.onTimeChanged.subscribe { device.onTimeChanged.subscribe {
invokeInMainScopeIfRequired { onActiveDeviceTimeChanged.emit(it) }; invokeInMainScopeIfRequired { onActiveDeviceTimeChanged.emit(it) };
}; };
device.onMediaItemEnd.subscribe {
invokeInMainScopeIfRequired { onActiveDeviceMediaItemEnd.emit() }
}
try { try {
device.connect(); device.connect();
@@ -228,6 +239,7 @@ abstract class StateCasting {
device.onTimeChanged.clear(); device.onTimeChanged.clear();
device.onVolumeChanged.clear(); device.onVolumeChanged.clear();
device.onDurationChanged.clear(); device.onDurationChanged.clear();
device.onMediaItemEnd.clear();
return; return;
} }
@@ -235,9 +247,9 @@ abstract class StateCasting {
Logger.i(TAG, "Connect to device ${device.name}") Logger.i(TAG, "Connect to device ${device.name}")
} }
fun metadataFromVideo(video: IPlatformVideoDetails): Metadata { fun metadataFromVideo(video: IPlatformVideoDetails, videoThumbnailOverrideUrl: String? = null): Metadata {
return Metadata( return Metadata(
title = video.name, thumbnailUrl = video.thumbnails.getHQThumbnail() title = video.name, thumbnailUrl = videoThumbnailOverrideUrl ?: video.thumbnails.getHQThumbnail()
) )
} }
@@ -371,6 +383,12 @@ abstract class StateCasting {
} else if (audioSource is LocalAudioSource) { } else if (audioSource is LocalAudioSource) {
Logger.i(TAG, "Casting as local audio"); Logger.i(TAG, "Casting as local audio");
castLocalAudio(video, audioSource, resumePosition, speed); castLocalAudio(video, audioSource, resumePosition, speed);
} else if (videoSource is LocalVideoContentSource) {
Logger.i(TAG, "Casting as local video");
castLocalVideo(contentResolver, video, videoSource, resumePosition, speed);
} else if (audioSource is LocalAudioContentSource) {
Logger.i(TAG, "Casting as local audio");
castLocalAudio(contentResolver, video, audioSource, resumePosition, speed);
} else if (videoSource is JSDashManifestRawSource) { } else if (videoSource is JSDashManifestRawSource) {
Logger.i(TAG, "Casting as JSDashManifestRawSource video"); Logger.i(TAG, "Casting as JSDashManifestRawSource video");
castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, null, null, resumePosition, speed, castId, onLoadingEstimate, onLoading); castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, null, null, resumePosition, speed, castId, onLoadingEstimate, onLoading);
@@ -461,6 +479,65 @@ abstract class StateCasting {
} }
return true; return true;
} }
private fun castLocalVideo(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: LocalVideoContentSource, resumePosition: Double, speed: Double?) : List<String> {
val ad = activeDevice ?: return listOf();
val url = getLocalUrl(ad);
val id = UUID.randomUUID();
val videoPath = "/video-${id}"
val videoUrl = url + videoPath;
val thumbnailPath = "/thumbnail-${id}"
val thumbnailUrl = url + thumbnailPath;
val thumbnailContentUrl = video.thumbnails.getHQThumbnail()
if (thumbnailContentUrl != null) {
_castServer.addHandlerWithAllowAllOptions(
HttpContentUriHandler("GET", thumbnailPath, contentResolver, thumbnailContentUrl.toUri())
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast");
}
_castServer.addHandlerWithAllowAllOptions(
HttpContentUriHandler("GET", videoPath, contentResolver, videoSource.contentUrl.toUri())
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast");
Logger.i(TAG, "Casting local video (videoUrl: $videoUrl).");
ad.loadVideo("BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video, if (thumbnailContentUrl != null) thumbnailUrl else null));
return listOf(videoUrl);
}
private fun castLocalAudio(contentResolver: ContentResolver, video: IPlatformVideoDetails, audioSource: LocalAudioContentSource, resumePosition: Double, speed: Double?) : List<String> {
val ad = activeDevice ?: return listOf();
val url = getLocalUrl(ad);
val id = UUID.randomUUID();
val audioPath = "/audio-${id}"
val audioUrl = url + audioPath;
val thumbnailPath = "/thumbnail-${id}"
val thumbnailUrl = url + thumbnailPath;
val thumbnailContentUrl = video.thumbnails.getHQThumbnail()
if (thumbnailContentUrl != null) {
_castServer.addHandlerWithAllowAllOptions(
HttpContentUriHandler("GET", thumbnailPath, contentResolver, thumbnailContentUrl.toUri())
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast");
}
_castServer.addHandlerWithAllowAllOptions(
HttpContentUriHandler("GET", audioPath, contentResolver, audioSource.contentUrl.toUri())
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast");
Logger.i(TAG, "Casting local audio (audioUrl: $audioUrl).");
ad.loadVideo("BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video, if (thumbnailContentUrl != null) thumbnailUrl else null));
return listOf(audioUrl);
}
private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double, speed: Double?) : List<String> { private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double, speed: Double?) : List<String> {
val ad = activeDevice ?: return listOf(); val ad = activeDevice ?: return listOf();
@@ -1254,8 +1331,14 @@ abstract class StateCasting {
return emptyList() return emptyList()
} }
var hasAudioInDash = false
for (representation in representationRegex.findAll(dashContent)) { for (representation in representationRegex.findAll(dashContent)) {
val mediaType = representation.groups[1]?.value ?: throw Exception("Media type should be found") val mediaType = representation.groups[1]?.value ?: throw Exception("Media type should be found")
if (mediaType.startsWith("audio/")) {
hasAudioInDash = true
}
dashContent = mediaInitializationRegex.replace(dashContent) { dashContent = mediaInitializationRegex.replace(dashContent) {
if (it.range.first < representation.range.first || it.range.last > representation.range.last) { if (it.range.first < representation.range.first || it.range.last > representation.range.last) {
return@replace it.value return@replace it.value
@@ -1279,16 +1362,20 @@ abstract class StateCasting {
throw Exception("Audio source without request executor not supported") throw Exception("Audio source without request executor not supported")
} }
if (audioSource != null && audioSource.hasRequestExecutor) { if (videoSource != null && videoSource.hasRequestExecutor) {
val oldExecutor = _audioExecutor; val oldVideoExecutor = _videoExecutor
oldExecutor?.closeAsync(); oldVideoExecutor?.closeAsync()
_audioExecutor = audioSource.getRequestExecutor() _videoExecutor = videoSource.getRequestExecutor()
} }
if (videoSource != null && videoSource.hasRequestExecutor) { if (audioSource != null) {
val oldExecutor = _videoExecutor; val oldExecutor = _audioExecutor
oldExecutor?.closeAsync(); oldExecutor?.closeAsync()
_videoExecutor = videoSource.getRequestExecutor() _audioExecutor = audioSource.getRequestExecutor()
} else if (hasAudioInDash && videoSource != null) {
val oldExecutor = _audioExecutor
oldExecutor?.closeAsync()
_audioExecutor = _videoExecutor
} }
//TOOD: Else also handle the non request executor case, perhaps add ?url=$originalUrl to the query parameters, ... propagate this for all other flows also //TOOD: Else also handle the non request executor case, perhaps add ?url=$originalUrl to the query parameters, ... propagate this for all other flows also
@@ -1319,7 +1406,7 @@ abstract class StateCasting {
}.withHeader("Access-Control-Allow-Origin", "*"), true }.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castDashRaw"); ).withTag("castDashRaw");
} }
if (audioSource != null) { if (audioSource != null || (audioSource == null && hasAudioInDash)) {
_castServer.addHandlerWithAllowAllOptions( _castServer.addHandlerWithAllowAllOptions(
HttpFunctionHandler("GET", audioPath) { httpContext -> HttpFunctionHandler("GET", audioPath) { httpContext ->
val originalUrl = httpContext.query["url"]?.let { URLDecoder.decode(it, "UTF-8") } ?: return@HttpFunctionHandler val originalUrl = httpContext.query["url"]?.let { URLDecoder.decode(it, "UTF-8") } ?: return@HttpFunctionHandler
@@ -21,6 +21,7 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UpdateDownloadService import com.futo.platformplayer.UpdateDownloadService
import com.futo.platformplayer.UpdateNotificationManager
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.copyToOutputStream import com.futo.platformplayer.copyToOutputStream
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
@@ -36,6 +37,8 @@ import java.io.InputStream
class AutoUpdateDialog(context: Context?) : AlertDialog(context) { class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
companion object { companion object {
private val TAG = "AutoUpdateDialog"; private val TAG = "AutoUpdateDialog";
var currentDialog: AutoUpdateDialog? = null
} }
private lateinit var _buttonNever: Button; private lateinit var _buttonNever: Button;
@@ -62,12 +65,14 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
_buttonShowChangelog = findViewById(R.id.button_show_changelog); _buttonShowChangelog = findViewById(R.id.button_show_changelog);
_buttonNever.setOnClickListener { _buttonNever.setOnClickListener {
UpdateNotificationManager.cancelAll(context)
Settings.instance.autoUpdate.check = 1; Settings.instance.autoUpdate.check = 1;
Settings.instance.save(); Settings.instance.save();
dismiss(); dismiss();
}; };
_buttonClose.setOnClickListener { _buttonClose.setOnClickListener {
UpdateNotificationManager.cancelAll(context)
dismiss(); dismiss();
}; };
@@ -77,11 +82,13 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
}; };
_buttonUpdate.setOnClickListener { _buttonUpdate.setOnClickListener {
UpdateNotificationManager.cancelAll(context)
if (_updating) { if (_updating) {
return@setOnClickListener; return@setOnClickListener;
} }
if (Settings.instance.autoUpdate.backgroundDownload == 1) { if (Settings.instance.autoUpdate.shouldBackgroundDownload) {
val ctx = context.applicationContext; val ctx = context.applicationContext;
val intent = Intent(ctx, UpdateDownloadService::class.java); val intent = Intent(ctx, UpdateDownloadService::class.java);
intent.putExtra(UpdateDownloadService.EXTRA_VERSION, _maxVersion); intent.putExtra(UpdateDownloadService.EXTRA_VERSION, _maxVersion);
@@ -94,11 +101,13 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
} }
}; };
currentDialog = this
} }
override fun dismiss() { override fun dismiss() {
super.dismiss() super.dismiss()
InstallReceiver.onReceiveResult.clear(); InstallReceiver.onReceiveResult.clear();
currentDialog = null
Logger.i(TAG, "Cleared InstallReceiver.onReceiveResult handler.") Logger.i(TAG, "Cleared InstallReceiver.onReceiveResult handler.")
} }
@@ -9,7 +9,9 @@ import com.arthenica.ffmpegkit.FFmpegKit
import com.arthenica.ffmpegkit.ReturnCode import com.arthenica.ffmpegkit.ReturnCode
import com.arthenica.ffmpegkit.StatisticsCallback import com.arthenica.ffmpegkit.StatisticsCallback
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.modifier.IRequestModifier import com.futo.platformplayer.api.media.models.modifier.IRequestModifier
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
@@ -40,10 +42,13 @@ import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManif
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.engine.exceptions.ScriptException
import com.futo.platformplayer.engine.exceptions.ScriptReloadRequiredException
import com.futo.platformplayer.exceptions.DownloadException import com.futo.platformplayer.exceptions.DownloadException
import com.futo.platformplayer.helpers.FileHelper.Companion.sanitizeFileName import com.futo.platformplayer.helpers.FileHelper.Companion.sanitizeFileName
import com.futo.platformplayer.helpers.VideoHelper import com.futo.platformplayer.helpers.VideoHelper
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.others.Language
import com.futo.platformplayer.parsers.HLS import com.futo.platformplayer.parsers.HLS
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
import com.futo.platformplayer.states.StateDownloads import com.futo.platformplayer.states.StateDownloads
@@ -86,6 +91,9 @@ import kotlin.time.times
class VideoDownload { class VideoDownload {
var state: State = State.QUEUED; var state: State = State.QUEUED;
@Contextual
@Transient
var plugin: IPlatformClient? = null;
var video: SerializedPlatformVideo? = null; var video: SerializedPlatformVideo? = null;
var videoDetails: SerializedPlatformVideoDetails? = null; var videoDetails: SerializedPlatformVideoDetails? = null;
@@ -101,6 +109,7 @@ class VideoDownload {
var videoSource: VideoUrlSource?; var videoSource: VideoUrlSource?;
var audioSource: AudioUrlSource?; var audioSource: AudioUrlSource?;
var overrideResultAudioSource: IAudioSource? = null;
@Contextual @Contextual
@Transient @Transient
val videoSourceToUse: IVideoSource? get () = if(requiresLiveVideoSource) videoSourceLive as IVideoSource? else videoSource as IVideoSource?; val videoSourceToUse: IVideoSource? get () = if(requiresLiveVideoSource) videoSourceLive as IVideoSource? else videoSource as IVideoSource?;
@@ -270,7 +279,7 @@ class VideoDownload {
//Fetch full video object and determine source //Fetch full video object and determine source
if(video != null && videoDetails == null) { if(video != null && videoDetails == null) {
val original = StatePlatform.instance.getContentDetails(video!!.url).await(); val original = if (plugin != null) plugin!!.getContentDetails(video!!.url) else StatePlatform.instance.getContentDetails(video!!.url)?.await();
if(original !is IPlatformVideoDetails) if(original !is IPlatformVideoDetails)
throw IllegalStateException("Original content is not media?"); throw IllegalStateException("Original content is not media?");
@@ -437,6 +446,11 @@ class VideoDownload {
videoFileNameBase = "${videoDetails!!.id.value!!} [${actualVideoSource!!.width}x${actualVideoSource!!.height}]".sanitizeFileName(); videoFileNameBase = "${videoDetails!!.id.value!!} [${actualVideoSource!!.width}x${actualVideoSource!!.height}]".sanitizeFileName();
videoFileNameExt = videoContainerToExtension(actualVideoSource!!.container); videoFileNameExt = videoContainerToExtension(actualVideoSource!!.container);
videoFilePath = File(downloadDir, videoFileName!!).absolutePath; videoFilePath = File(downloadDir, videoFileName!!).absolutePath;
if(actualVideoSource is JSDashManifestRawSource && actualAudioSource == null) {
audioFileNameBase = "${videoDetails!!.id.value!!}-[unknown]".sanitizeFileName();
audioFileNameExt = videoAudioContainerToExtension(actualVideoSource!!.container);
audioFilePath = File(downloadDir, audioFileName!!).absolutePath;
}
} }
if(actualAudioSource != null) { if(actualAudioSource != null) {
audioFileNameBase = "${videoDetails!!.id.value!!} [${actualAudioSource!!.language}-${actualAudioSource!!.bitrate}]".sanitizeFileName(); audioFileNameBase = "${videoDetails!!.id.value!!} [${actualAudioSource!!.language}-${actualAudioSource!!.bitrate}]".sanitizeFileName();
@@ -490,7 +504,11 @@ class VideoDownload {
else -> downloadFileSource("Video", client, if (actualVideoSource is JSSource) actualVideoSource else null, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback) else -> downloadFileSource("Video", client, if (actualVideoSource is JSSource) actualVideoSource else null, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
} }
else if(actualVideoSource is JSDashManifestRawSource) { else if(actualVideoSource is JSDashManifestRawSource) {
videoFileSize = downloadDashFileSource("Video", client, actualVideoSource, File(downloadDir, videoFileName!!), progressCallback); if(actualAudioSource == null)
videoFileSize = downloadDashFileSource("Video", client, actualVideoSource, File(downloadDir, videoFileName!!), progressCallback, 3,
File(downloadDir, audioFileName!!));
else
videoFileSize = downloadDashFileSource("Video", client, actualVideoSource, File(downloadDir, videoFileName!!), progressCallback, 1);
} }
else throw NotImplementedError("NotImplemented video download: " + actualVideoSource.javaClass.name); else throw NotImplementedError("NotImplemented video download: " + actualVideoSource.javaClass.name);
}); });
@@ -530,7 +548,7 @@ class VideoDownload {
else -> downloadFileSource("Audio", client, if (actualAudioSource is JSSource) actualAudioSource else null, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback) else -> downloadFileSource("Audio", client, if (actualAudioSource is JSSource) actualAudioSource else null, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
} }
else if(actualAudioSource is JSDashManifestRawAudioSource) { else if(actualAudioSource is JSDashManifestRawAudioSource) {
audioFileSize = downloadDashFileSource("Audio", client, actualAudioSource, File(downloadDir, audioFileName!!), progressCallback); audioFileSize = downloadDashFileSource("Audio", client, actualAudioSource, File(downloadDir, audioFileName!!), progressCallback, 2);
} }
else throw NotImplementedError("NotImplemented audio download: " + actualAudioSource.javaClass.name); else throw NotImplementedError("NotImplemented audio download: " + actualAudioSource.javaClass.name);
}); });
@@ -589,38 +607,54 @@ class VideoDownload {
} }
private suspend fun combineSegments(context: Context, segmentFiles: List<File>, targetFile: File) = withContext(Dispatchers.IO) { private suspend fun combineSegments(context: Context, segmentFiles: List<File>, targetFile: File) = withContext(Dispatchers.IO) {
suspendCancellableCoroutine { continuation -> require(segmentFiles.isNotEmpty()) { "segmentFiles must not be empty" }
val fileList = File(context.cacheDir, "fileList-${UUID.randomUUID()}.txt")
fileList.writeText(segmentFiles.joinToString("\n") { "file '${it.absolutePath}'" }) suspendCancellableCoroutine { continuation ->
val concatInput = buildString {
append("concat:")
append(
segmentFiles.joinToString("|") { file ->
file.absolutePath
}
)
}
val cmd = "-i \"$concatInput\" -c copy \"${targetFile.absolutePath}\""
val cmd = "-f concat -safe 0 -i \"${fileList.absolutePath}\" -c copy \"${targetFile.absolutePath}\""
val statisticsCallback = StatisticsCallback { _ -> val statisticsCallback = StatisticsCallback { _ ->
//TODO: Show progress? //No callback
} }
val executorService = Executors.newSingleThreadExecutor() val executorService = Executors.newSingleThreadExecutor()
val session = FFmpegKit.executeAsync(cmd,
{ session -> val session = FFmpegKit.executeAsync(
if (ReturnCode.isSuccess(session.returnCode)) { cmd,
fileList.delete() { completedSession ->
executorService.shutdown()
if (ReturnCode.isSuccess(completedSession.returnCode)) {
continuation.resumeWith(Result.success(Unit)) continuation.resumeWith(Result.success(Unit))
} else { } else {
val errorMessage = if (ReturnCode.isCancel(session.returnCode)) { val errorMessage = if (ReturnCode.isCancel(completedSession.returnCode)) {
"Command cancelled" "Command cancelled"
} else { } else {
"Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}" "Command failed with state '${completedSession.state}' " +
"and return code ${completedSession.returnCode}, " +
"stack trace ${completedSession.failStackTrace}"
} }
fileList.delete()
continuation.resumeWithException(RuntimeException(errorMessage)) continuation.resumeWithException(RuntimeException(errorMessage))
} }
}, },
{ Logger.v(TAG, it.message) }, { log ->
Logger.v(TAG, log.message)
},
statisticsCallback, statisticsCallback,
executorService executorService
) )
continuation.invokeOnCancellation { continuation.invokeOnCancellation {
session.cancel() session.cancel()
executorService.shutdownNow()
} }
} }
} }
@@ -856,14 +890,19 @@ class VideoDownload {
return downloadedTotalLength return downloadedTotalLength
} }
private fun downloadDashFileSource(name: String, client: ManagedHttpClient, source: IJSDashManifestRawSource, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long { private fun downloadDashFileSource(name: String, client: ManagedHttpClient, source: IJSDashManifestRawSource, targetFile: File, onProgress: (Long, Long, Long) -> Unit, downloadType: Int = 0, targetFileAudio: File? = null): Long {
if(targetFile.exists()) if(targetFile.exists())
targetFile.delete(); targetFile.delete();
if(targetFileAudio?.exists() ?: false)
targetFileAudio.delete();
targetFile.createNewFile(); targetFile.createNewFile();
targetFileAudio?.createNewFile();
val sourceLength: Long?; val sourceLength: Long?;
val sourceLengthAudio: Long?;
val fileStream = FileOutputStream(targetFile); val fileStream = FileOutputStream(targetFile);
val fileStream2 = if(targetFileAudio != null) FileOutputStream(targetFileAudio) else null;
var executor: JSRequestExecutor? = null; var executor: JSRequestExecutor? = null;
try{ try{
@@ -874,14 +913,27 @@ class VideoDownload {
throw IllegalStateException("No manifest after generation"); throw IllegalStateException("No manifest after generation");
//TODO: Temporary naive assume single-sourced dash //TODO: Temporary naive assume single-sourced dash
val foundTemplate = REGEX_DASH_TEMPLATE.find(manifest); val foundTemplates = REGEX_DASH_TEMPLATE_WITH_MIME.findAll(manifest);
if(foundTemplate == null || foundTemplate.groupValues.size != 3) val foundTemplate = when(downloadType) {
1 -> foundTemplates.find({ it.groupValues[1].contains("video/") });
2 -> foundTemplates.find({ it.groupValues[1].contains("audio/") });
else -> foundTemplates.find({ it.groupValues[1].contains("video/") });
}
if(foundTemplate == null || foundTemplate.groupValues.size != 4)
throw IllegalStateException("No SegmentTemplate found in manifest (unsupported dash?)"); throw IllegalStateException("No SegmentTemplate found in manifest (unsupported dash?)");
val foundTemplateUrl = foundTemplate.groupValues[1]; val foundTemplateUrl = foundTemplate.groupValues[2];
val foundCues = REGEX_DASH_CUE.findAll(foundTemplate.groupValues[2]); val foundCues = REGEX_DASH_CUE.findAll(foundTemplate.groupValues[3]).toList();
if(foundCues.count() <= 0) if(foundCues.count() <= 0)
throw IllegalStateException("No Cues found in manifest (unsupported dash?)"); throw IllegalStateException("No Cues found in manifest (unsupported dash?)");
val foundTemplate2 = if(downloadType == 3) foundTemplates.find({ it.groupValues[1].contains("audio/") }); else null;
val foundTemplateUrl2 = if(foundTemplate2 != null) foundTemplate2.groupValues[2] else null;
val foundCues2 = if(foundTemplate2 != null) REGEX_DASH_CUE.findAll(foundTemplate2.groupValues[3]).toList() else null;
val foundCues2Downloaded = hashSetOf<MatchResult>();
if(foundTemplate2 != null)
overrideResultAudioSource = LocalAudioSource((videoSource?.name)?.let { it + " [audio]" } ?: "audio", "", 0, 0, foundTemplate2.groupValues[1], REGEX_CODECS.find(foundTemplate2.groupValues[0])?.groupValues?.get(1) ?: "", Language.UNKNOWN);
executor = if(source is JSSource && source.hasRequestExecutor) executor = if(source is JSSource && source.hasRequestExecutor)
source.getRequestExecutor(); source.getRequestExecutor();
else else
@@ -896,13 +948,17 @@ class VideoDownload {
Logger.i(TAG, "Download $name Dash, CueCount: " + foundCues.count().toString()); Logger.i(TAG, "Download $name Dash, CueCount: " + foundCues.count().toString());
var written: Long = 0; var written: Long = 0;
var written2: Long = 0;
var indexCounter = 0; var indexCounter = 0;
var indexCounter2 = 0;
onProgress(foundCues.count().toLong(), 0, 0); onProgress(foundCues.count().toLong(), 0, 0);
val totalCues = foundCues.count().toLong() + (foundCues2?.count()?.toLong() ?: 0)
val lastCue = foundCues.lastOrNull();
for(cue in foundCues) { for(cue in foundCues) {
val t = cue.groupValues[1]; val t = cue.groupValues[1];
val d = cue.groupValues[2]; val d = cue.groupValues[2];
Logger.i(TAG, "Downloading cue ${indexCounter}")
val url = foundTemplateUrl.replace("\$Number\$", (indexCounter).toString()); val url = foundTemplateUrl.replace("\$Number\$", (indexCounter).toString());
val modified = modifier?.modifyRequest(url, mapOf()); val modified = modifier?.modifyRequest(url, mapOf());
@@ -918,17 +974,60 @@ class VideoDownload {
speedTracker.addWork(data.size.toLong()); speedTracker.addWork(data.size.toLong());
written += data.size; written += data.size;
onProgress(foundCues.count().toLong(), indexCounter.toLong(), speedTracker.lastSpeed); onProgress(totalCues, indexCounter.toLong() + indexCounter2.toLong(), speedTracker.lastSpeed);
indexCounter++; indexCounter++;
if(foundCues2 != null && foundTemplateUrl2 != null && fileStream2 != null) {
val toDownload = if(lastCue != null && cue == lastCue)
foundCues2.filter { !foundCues2Downloaded.contains(it) }.toList() else
foundCues2.filter { !foundCues2Downloaded.contains(it) && (it.groupValues[1].toLong()) < t.toLong() }.toList();
Logger.i(TAG, "Downloading audio cues (${toDownload.size})")
for(cue2 in toDownload) {
val index2 = foundCues2.indexOf(cue2);
val t2 = cue2.groupValues[1];
val d2 = cue2.groupValues[2];
val url2 = foundTemplateUrl2!!.replace("\$Number\$", (index2).toString());
val modified2 = modifier?.modifyRequest(url, mapOf());
val data = if(executor != null)
executor.executeRequest("GET", modified2?.url ?: url2, null, modified2?.headers ?: mapOf());
else {
val resp = client.get(modified2?.url ?: url, modified2?.headers?.toMutableMap() ?: mutableMapOf());
if(!resp.isOk)
throw IllegalStateException("Dash request2 failed for index " + indexCounter.toString() + ", with code: " + resp.code.toString());
resp.body!!.bytes()
}
fileStream2.write(data, 0, data.size);
speedTracker.addWork(data.size.toLong());
written2 += data.size;
indexCounter2++;
foundCues2Downloaded.add(cue2);
onProgress(totalCues, indexCounter.toLong() + indexCounter2.toLong(), speedTracker.lastSpeed);
}
}
} }
sourceLength = written; sourceLength = written;
sourceLengthAudio = written2;
Logger.i(TAG, "$name downloadSource Finished"); Logger.i(TAG, "$name downloadSource Finished");
} }
catch(scriptEx: ScriptReloadRequiredException) {
if(targetFile.exists() ?: false)
targetFile.delete();
if(targetFileAudio?.exists() ?: false)
targetFileAudio.delete();
createNewPluginClient();
throw scriptEx;
}
catch(ioex: IOException) { catch(ioex: IOException) {
if(targetFile.exists() ?: false) if(targetFile.exists() ?: false)
targetFile.delete(); targetFile.delete();
if(targetFileAudio?.exists() ?: false)
targetFileAudio.delete();
if(ioex.message?.contains("ENOSPC") ?: false) if(ioex.message?.contains("ENOSPC") ?: false)
throw Exception("Not enough space on device", ioex); throw Exception("Not enough space on device", ioex);
else else
@@ -937,14 +1036,37 @@ class VideoDownload {
catch(ex: Throwable) { catch(ex: Throwable) {
if(targetFile.exists() ?: false) if(targetFile.exists() ?: false)
targetFile.delete(); targetFile.delete();
if(targetFileAudio?.exists() ?: false)
targetFileAudio.delete();
throw ex; throw ex;
} }
finally { finally {
fileStream.close(); fileStream.close();
fileStream2?.close();
executor?.closeAsync() executor?.closeAsync()
} }
if(sourceLengthAudio != null && sourceLengthAudio > 0)
audioFileSize = sourceLengthAudio
return sourceLength!!; return sourceLength!!;
} }
fun createNewPluginClient() {
UIDialogs.appToast("Download creating new client at request of plugin");
cleanupPluginClient();
plugin = (video?.url ?: videoDetails?.url)?.let { StatePlatform.instance.getContentClient(it) }?.let { if(it is JSClient) it else null }?.getCopy(false, true);
plugin?.initialize();
}
fun cleanupPluginClient() {
val oldPlugin = plugin;
plugin = null;
try {
oldPlugin?.disable();
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to dispose download client: ${ex.message}" , ex);
}
}
private fun downloadFileSource(name: String, client: ManagedHttpClient, source: JSSource?, videoUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long { private fun downloadFileSource(name: String, client: ManagedHttpClient, source: JSSource?, videoUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
if(targetFile.exists()) if(targetFile.exists())
targetFile.delete(); targetFile.delete();
@@ -1304,7 +1426,7 @@ class VideoDownload {
throw IllegalStateException("Expected size [${videoFileSize}], but found ${expectedFile.length()}"); throw IllegalStateException("Expected size [${videoFileSize}], but found ${expectedFile.length()}");
} }
} }
if(audioSourceToUse != null) { if(audioSourceToUse != null || (videoSourceToUse is IJSDashManifestRawSource)) {
if(audioFilePath == null) if(audioFilePath == null)
throw IllegalStateException("Missing audio file name after download"); throw IllegalStateException("Missing audio file name after download");
val expectedFile = File(audioFilePath!!); val expectedFile = File(audioFilePath!!);
@@ -1327,7 +1449,7 @@ class VideoDownload {
Logger.i(TAG, "VideoDownload Complete [${name}]"); Logger.i(TAG, "VideoDownload Complete [${name}]");
val existing = StateDownloads.instance.getCachedVideo(id); val existing = StateDownloads.instance.getCachedVideo(id);
val localVideoSource = videoFilePath?.let { LocalVideoSource.fromSource(videoSourceToUse!!, it, videoFileSize ?: 0, videoOverrideContainer) }; val localVideoSource = videoFilePath?.let { LocalVideoSource.fromSource(videoSourceToUse!!, it, videoFileSize ?: 0, videoOverrideContainer) };
val localAudioSource = audioFilePath?.let { LocalAudioSource.fromSource(audioSourceToUse!!, it, audioFileSize ?: 0, audioOverrideContainer) }; val localAudioSource = audioFilePath?.let { LocalAudioSource.fromSource(overrideResultAudioSource ?: audioSourceToUse!!, it, audioFileSize ?: 0, audioOverrideContainer) };
val localSubtitleSource = subtitleFilePath?.let { LocalSubtitleSource.fromSource(subtitleSource!!, it) }; val localSubtitleSource = subtitleFilePath?.let { LocalSubtitleSource.fromSource(subtitleSource!!, it) };
if(localVideoSource != null && videoSourceToUse != null && videoSourceToUse is IStreamMetaDataSource) if(localVideoSource != null && videoSourceToUse != null && videoSourceToUse is IStreamMetaDataSource)
@@ -1369,6 +1491,10 @@ class VideoDownload {
} }
} }
fun cleanup(){
cleanupPluginClient()
}
enum class State { enum class State {
QUEUED, QUEUED,
PREPARING, PREPARING,
@@ -1392,6 +1518,8 @@ class VideoDownload {
const val GROUP_WATCHLATER= "WatchLater"; const val GROUP_WATCHLATER= "WatchLater";
val REGEX_DASH_TEMPLATE = Regex("<SegmentTemplate .*?media=\"(.*?)\".*?>(.*?)<\\/SegmentTemplate>", RegexOption.DOT_MATCHES_ALL); val REGEX_DASH_TEMPLATE = Regex("<SegmentTemplate .*?media=\"(.*?)\".*?>(.*?)<\\/SegmentTemplate>", RegexOption.DOT_MATCHES_ALL);
val REGEX_DASH_TEMPLATE_WITH_MIME = Regex("<Representation.*?mimeType=\\\"(.*?)\\\".*?>.*?<SegmentTemplate .*?media=\\\"(.*?)\\\".*?>(.*?)<\\/SegmentTemplate>", RegexOption.DOT_MATCHES_ALL);
val REGEX_CODECS = Regex("codecs=\\\"(.*?)\\\"")
val REGEX_DASH_CUE = Regex("<S .*?t=\"([0-9]*?)\".*?d=\"([0-9]*?)\".*?\\/>", RegexOption.DOT_MATCHES_ALL); val REGEX_DASH_CUE = Regex("<S .*?t=\"([0-9]*?)\".*?d=\"([0-9]*?)\".*?\\/>", RegexOption.DOT_MATCHES_ALL);
fun videoContainerToExtension(container: String): String? { fun videoContainerToExtension(container: String): String? {
@@ -1411,6 +1539,16 @@ class VideoDownload {
return "video";//throw IllegalStateException("Unknown container: " + container) return "video";//throw IllegalStateException("Unknown container: " + container)
} }
//TODO: Change usages of this to an accurate container instead of infering it.
fun videoAudioContainerToExtension(container: String): String? {
if (container.contains("video/mp4") || container == "application/vnd.apple.mpegurl")
return "mp4a";
else if (container.contains("video/webm"))
return "webm";
else
return "mp4a";//throw IllegalStateException("Unknown container: " + container)
}
fun audioContainerToExtension(container: String): String { fun audioContainerToExtension(container: String): String {
if (container.contains("audio/mp4")) if (container.contains("audio/mp4"))
return "mp4a"; return "mp4a";
@@ -155,15 +155,16 @@ class MenuBottomBarFragment : MainActivityFragment() {
StateApp.instance.setPrivacyMode(true); StateApp.instance.setPrivacyMode(true);
UIDialogs.appToast("Privacy mode enabled"); UIDialogs.appToast("Privacy mode enabled");
UIDialogs.showDialog(it.context ?: return@setOnClickListener, R.drawable.incognito, "Privacy Mode", if(Settings.instance.other.showPrivacyModeDialog)
"All requests will be processed anonymously (any logins will be disabled except for the personalized home page), local playback and history tracking will also be disabled.\n\nTap the icon to disable.", null, 0, UIDialogs.showDialog(it.context ?: return@setOnClickListener, R.drawable.incognito, "Privacy Mode",
UIDialogs.Action("Don't show again", { "All requests will be processed anonymously (any logins will be disabled except for the personalized home page), local playback and history tracking will also be disabled.\n\nTap the icon to disable.", null, 0,
Settings.instance.other.showPrivacyModeDialog = false; UIDialogs.Action("Don't show again", {
Settings.instance.save(); Settings.instance.other.showPrivacyModeDialog = false;
}, UIDialogs.ActionStyle.NONE), Settings.instance.save();
UIDialogs.Action("Understood", { }, UIDialogs.ActionStyle.NONE),
UIDialogs.Action("Understood", {
}, UIDialogs.ActionStyle.PRIMARY)); }, UIDialogs.ActionStyle.PRIMARY));
} }
} }
@@ -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 @@ 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);
} }
} }
@@ -50,7 +50,7 @@ class VideoDetailFragment() : MainFragment() {
private var _isActive: Boolean = false; private var _isActive: Boolean = false;
private var _viewDetail : VideoDetailView? = null; var _viewDetail : VideoDetailView? = null;
private var _view : SingleViewTouchableMotionLayout? = null; private var _view : SingleViewTouchableMotionLayout? = null;
var isFullscreen : Boolean = false; var isFullscreen : Boolean = false;
@@ -372,14 +372,18 @@ class VideoDetailFragment() : MainFragment() {
onMinimize.emit(); onMinimize.emit();
} }
else if (state != State.MAXIMIZED && progress > 0.9) { else if (state != State.MAXIMIZED && progress > 0.9) {
state = State.MAXIMIZED;
onMaximized.emit();
/*
if (_isInitialMaximize) { if (_isInitialMaximize) {
state = State.CLOSED; //state = State.CLOSED; Causes issues? might no longer be needed
_isInitialMaximize = false; _isInitialMaximize = false;
} }
else { else {
state = State.MAXIMIZED; state = State.MAXIMIZED;
onMaximized.emit(); onMaximized.emit();
} }
*/
} }
if (isTransitioning && (progress > 0.6 || progress < 0.4)) { if (isTransitioning && (progress > 0.6 || progress < 0.4)) {
@@ -450,7 +454,8 @@ class VideoDetailFragment() : MainFragment() {
if (viewDetail.shouldEnterPictureInPicture) { if (viewDetail.shouldEnterPictureInPicture) {
_leavingPiP = false _leavingPiP = false
} }
if(Build.VERSION.SDK_INT < Build.VERSION_CODES.S && viewDetail.preventPictureInPicture == false && !StateCasting.instance.isCasting && Settings.instance.playback.isBackgroundPictureInPicture() && !viewDetail.isAudioOnlyUserAction) { val shouldPiP = Settings.instance.playback.isBackgroundPictureInPicture()
if(Build.VERSION.SDK_INT < Build.VERSION_CODES.S && viewDetail.preventPictureInPicture == false && !StateCasting.instance.isCasting && shouldPiP && !viewDetail.isAudioOnlyUserAction) {
val params = _viewDetail?.getPictureInPictureParams(); val params = _viewDetail?.getPictureInPictureParams();
if(params != null) { if(params != null) {
Logger.i(TAG, "enterPictureInPictureMode") Logger.i(TAG, "enterPictureInPictureMode")
@@ -33,6 +33,7 @@ import android.widget.ImageButton
import android.widget.ImageView import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.compose.ui.text.toLowerCase
import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.media3.common.C import androidx.media3.common.C
@@ -723,15 +724,17 @@ class VideoDetailView : ConstraintLayout {
val activeDevice = StateCasting.instance.activeDevice; val activeDevice = StateCasting.instance.activeDevice;
if (activeDevice != null) { if (activeDevice != null) {
handlePlayChanged(it); handlePlayChanged(it);
val v = video;
if (!it && v != null && v.duration - activeDevice.time.toLong() < 2L) {
Log.i(TAG, "Next video (loop?)")
nextVideo();
}
} }
}; };
StateCasting.instance.onActiveDeviceMediaItemEnd.subscribe(this) {
val activeDevice = StateCasting.instance.activeDevice;
if (activeDevice != null) {
Log.i(TAG, "Next video (loop?)")
nextVideo();
}
}
StateCasting.instance.onActiveDeviceTimeChanged.subscribe(this) { StateCasting.instance.onActiveDeviceTimeChanged.subscribe(this) {
if (_isCasting) { if (_isCasting) {
setLastPositionMilliseconds((it * 1000.0).toLong(), true); setLastPositionMilliseconds((it * 1000.0).toLong(), true);
@@ -1273,6 +1276,7 @@ class VideoDetailView : ConstraintLayout {
StateCasting.instance.onActiveDevicePlayChanged.remove(this); StateCasting.instance.onActiveDevicePlayChanged.remove(this);
StateCasting.instance.onActiveDeviceTimeChanged.remove(this); StateCasting.instance.onActiveDeviceTimeChanged.remove(this);
StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this); StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this);
StateCasting.instance.onActiveDeviceMediaItemEnd.remove(this)
StateApp.instance.preventPictureInPicture.remove(this); StateApp.instance.preventPictureInPicture.remove(this);
StatePlayer.instance.onQueueChanged.remove(this); StatePlayer.instance.onQueueChanged.remove(this);
StatePlayer.instance.onVideoChanging.remove(this); StatePlayer.instance.onVideoChanging.remove(this);
@@ -2420,9 +2424,54 @@ class VideoDetailView : ConstraintLayout {
val doDedup = Settings.instance.playback.simplifySources; val doDedup = Settings.instance.playback.simplifySources;
val bestVideoSources = if(doDedup) (videoSources?.map { it.height * it.width } val allLanguages = videoSources?.map { it.language }?.distinct() ?: listOf();
?.distinct() val langResCombinations = if(videoSources != null) allLanguages.flatMap {
?.map { x -> VideoHelper.selectBestVideoSource(videoSources.filter { x == it.height * it.width }, -1, FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) } lang -> videoSources
.filter { v -> v.language == lang }
.map { it.height * it.width }
.distinct()
.map { res -> Pair(res, lang) }
} else listOf();
Log.i(TAG, "Language count: ${allLanguages}");
var videoSourceItems = mutableListOf<SlideUpMenuItem>();
var selectedLanguage: String? = null;
val languageFilters = if(allLanguages.filter { it != null }.count() > 1)
SlideUpMenuButtonList(this.context, null, "language_filter", true).apply {
var languageFilterLabels = allLanguages.filterNotNull().toList();
val english = languageFilterLabels.find { it?.lowercase() == "en" };
val originalLanguage = videoSources?.find { it.original == true }?.language;
val primaryLanguage = Settings.instance.playback.getPrimaryLanguage();
val hasPrimaryLanguage = videoSources?.any { it.language == primaryLanguage } ?: false;
if(english != null)
languageFilterLabels = listOf(english).plus(languageFilterLabels.filter { it != english }).toList();
if(primaryLanguage != null && languageFilterLabels.contains(primaryLanguage))
languageFilterLabels = listOf(primaryLanguage).plus(languageFilterLabels.filter { it != primaryLanguage }).toList();
if(originalLanguage != null)
languageFilterLabels = listOf(originalLanguage).plus(languageFilterLabels.filter { it != originalLanguage }).toList();
Log.i(TAG, "Language filtesr: ${languageFilterLabels.joinToString(", ")}");
selectedLanguage = originalLanguage ?: (if(hasPrimaryLanguage) primaryLanguage else null);
setButtons(languageFilterLabels, selectedLanguage);
onClick.subscribe { selected ->
setSelected(selected);
videoSourceItems.forEach {
val item = it.itemTag;
if(item is IVideoSource) {
if(item.language == selected)
it.visibility = View.VISIBLE;
else
it.visibility = View.GONE;
}
}
}
}
else null;
val bestVideoSources = if(doDedup && videoSources != null) (langResCombinations
?.map { comb -> VideoHelper.selectBestVideoSource(videoSources.filter { comb.first == it.height * it.width && comb.second == it.language }, -1, FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) }
?.plus(videoSources.filter { it is IHLSManifestSource || it is IDashManifestSource })) ?.plus(videoSources.filter { it is IHLSManifestSource || it is IDashManifestSource }))
?.distinct() ?.distinct()
?.filterNotNull() ?.filterNotNull()
@@ -2528,11 +2577,10 @@ class VideoDetailView : ConstraintLayout {
call = { _player.selectAudioTrack(it.bitrate) }); call = { _player.selectAudioTrack(it.bitrate) });
}.toList().toTypedArray()) }.toList().toTypedArray())
else null, else null,
if(languageFilters != null) languageFilters else null,
if(bestVideoSources.isNotEmpty()) if(bestVideoSources.isNotEmpty())
SlideUpMenuGroup(this.context, context.getString(R.string.video), "video", SlideUpMenuGroup(this.context, context.getString(R.string.video), "video",
*bestVideoSources (bestVideoSources.map {
.map {
val estSize = VideoHelper.estimateSourceSize(it); val estSize = VideoHelper.estimateSourceSize(it);
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else ""; val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
SlideUpMenuItem(this.context, SlideUpMenuItem(this.context,
@@ -2541,8 +2589,14 @@ class VideoDetailView : ConstraintLayout {
if (it.width > 0 && it.height > 0) "${it.width}x${it.height}" else "", if (it.width > 0 && it.height > 0) "${it.width}x${it.height}" else "",
(prefix + it.codec.trim()).trim(), (prefix + it.codec.trim()).trim(),
tag = it, tag = it,
call = { handleSelectVideoTrack(it) }); call = { handleSelectVideoTrack(it) }).apply {
}.toList().toTypedArray()) videoSourceItems.add(this);
if(selectedLanguage != null) {
if(it.language != selectedLanguage)
this.visibility = View.GONE;
}
};
}).toList())
else null, else null,
if(bestAudioSources.isNotEmpty()) if(bestAudioSources.isNotEmpty())
SlideUpMenuGroup(this.context, context.getString(R.string.audio), "audio", SlideUpMenuGroup(this.context, context.getString(R.string.audio), "audio",
@@ -1,5 +1,6 @@
package com.futo.platformplayer.fragment.mainactivity.topbar package com.futo.platformplayer.fragment.mainactivity.topbar
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
@@ -49,7 +50,11 @@ class GeneralTopBarFragment : TopFragment() {
} else if (currentMain is PlaylistsFragment || currentMain is PlaylistFragment) { } else if (currentMain is PlaylistsFragment || currentMain is PlaylistFragment) {
navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.PLAYLIST)); navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.PLAYLIST));
} else if (currentMain is LibraryFragment) { } else if (currentMain is LibraryFragment) {
navigate<LibrarySearchFragment>(); if(Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
UIDialogs.toast("Your Android version is too old for Mediastore search", true);
}
else
navigate<LibrarySearchFragment>();
} else { } else {
navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.VIDEO)); navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.VIDEO));
} }
@@ -52,8 +52,8 @@ class VideoHelper {
fun isDownloadable(source: IVideoSource) = (source is IVideoUrlSource || source is IHLSManifestSource || source is JSDashManifestRawSource) && source !is IWidevineSource fun isDownloadable(source: IVideoSource) = (source is IVideoUrlSource || source is IHLSManifestSource || source is JSDashManifestRawSource) && source !is IWidevineSource
fun isDownloadable(source: IAudioSource) = (source is IAudioUrlSource || source is IHLSManifestAudioSource || source is JSDashManifestRawAudioSource) && source !is IWidevineSource fun isDownloadable(source: IAudioSource) = (source is IAudioUrlSource || source is IHLSManifestAudioSource || source is JSDashManifestRawAudioSource) && source !is IWidevineSource
fun selectBestVideoSource(desc: IVideoSourceDescriptor, desiredPixelCount : Int, prefContainers : Array<String>) : IVideoSource? = selectBestVideoSource(desc.videoSources.toList(), desiredPixelCount, prefContainers); fun selectBestVideoSource(desc: IVideoSourceDescriptor, desiredPixelCount : Int, prefContainers : Array<String>, preferredLanguage: String? = null) : IVideoSource? = selectBestVideoSource(desc.videoSources.toList(), desiredPixelCount, prefContainers, preferredLanguage);
fun selectBestVideoSource(sources: Iterable<IVideoSource>, desiredPixelCount : Int, prefContainers : Array<String>) : IVideoSource? { fun selectBestVideoSource(sources: Iterable<IVideoSource>, desiredPixelCount : Int, prefContainers : Array<String>, preferredLanguage: String? = null) : IVideoSource? {
val targetVideo = if(desiredPixelCount > 0) { val targetVideo = if(desiredPixelCount > 0) {
sources.toList().minByOrNull { x -> abs(x.height * x.width - desiredPixelCount) }; sources.toList().minByOrNull { x -> abs(x.height * x.width - desiredPixelCount) };
} else { } else {
@@ -63,12 +63,34 @@ class VideoHelper {
val hasPriority = sources.any { it.priority }; val hasPriority = sources.any { it.priority };
val targetPixelCount = if(targetVideo != null) targetVideo.width * targetVideo.height else desiredPixelCount; val targetPixelCount = if(targetVideo != null) targetVideo.width * targetVideo.height else desiredPixelCount;
val altSources = if(hasPriority) {
//Filter priority
var altSources = if(hasPriority) {
sources.filter { it.priority }.sortedBy { x -> abs(x.height * x.width - targetPixelCount) }; sources.filter { it.priority }.sortedBy { x -> abs(x.height * x.width - targetPixelCount) };
} else { } else {
sources.filter { it.height == (targetVideo?.height ?: 0) }; sources.filter { it.height == (targetVideo?.height ?: 0) };
} }
//Filter Original
val hasOriginal = altSources.any { it.original == true };
if(hasOriginal && Settings.instance.playback.preferOriginalAudio)
altSources = altSources.filter { it.original == true };
//Filter Language
val languageToFilter = if(preferredLanguage != null && altSources.any { it.language == preferredLanguage }) {
preferredLanguage
} else {
if(altSources.any { it.language == Language.ENGLISH })
Language.ENGLISH;
else
Language.UNKNOWN;
}
if(altSources.any { it.language == languageToFilter }) {
altSources.filter { it.language == languageToFilter }.sortedBy { it.bitrate }.toList();
} else {
altSources.sortedBy { it.bitrate }
}
var bestSource = altSources.firstOrNull(); var bestSource = altSources.firstOrNull();
for (prefContainer in prefContainers) { for (prefContainer in prefContainers) {
val betterSource = altSources.firstOrNull { it.container == prefContainer }; val betterSource = altSources.firstOrNull { it.container == prefContainer };
@@ -15,6 +15,7 @@ import com.futo.platformplayer.Settings
import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.downloads.VideoDownload import com.futo.platformplayer.downloads.VideoDownload
import com.futo.platformplayer.engine.exceptions.ScriptReloadRequiredException
import com.futo.platformplayer.exceptions.DownloadException import com.futo.platformplayer.exceptions.DownloadException
import com.futo.platformplayer.getNowDiffMinutes import com.futo.platformplayer.getNowDiffMinutes
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
@@ -169,6 +170,7 @@ class DownloadService : Service() {
Thread.sleep(500); Thread.sleep(500);
} }
catch(ex: Throwable) { catch(ex: Throwable) {
//if(ex is ScriptReloadRequiredException)
Logger.e(TAG, "Download failed", ex); Logger.e(TAG, "Download failed", ex);
if(currentVideo.video == null && currentVideo.videoDetails == null) { if(currentVideo.video == null && currentVideo.videoDetails == null) {
//Corrupt? //Corrupt?
@@ -573,7 +573,7 @@ class StateApp {
} }
if (Settings.instance.autoUpdate.isAutoUpdateEnabled()) { if (Settings.instance.autoUpdate.isAutoUpdateEnabled()) {
if (Settings.instance.autoUpdate.backgroundDownload == 1) { if (Settings.instance.autoUpdate.shouldBackgroundDownload) {
Logger.i(TAG, "MainApp Started: Initialize [AutoUpdate Background]"); Logger.i(TAG, "MainApp Started: Initialize [AutoUpdate Background]");
val constraints = Constraints.Builder() val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED) .setRequiredNetworkType(NetworkType.CONNECTED)
@@ -179,6 +179,7 @@ class StateDownloads {
fun removeDownload(download: VideoDownload) { fun removeDownload(download: VideoDownload) {
download.isCancelled = true; download.isCancelled = true;
download.cleanup();
_downloading.delete(download); _downloading.delete(download);
onDownloadsChanged.emit(); onDownloadsChanged.emit();
} }
@@ -344,7 +344,8 @@ class StateLibrary {
MediaStore.Video.Media.DISPLAY_NAME, MediaStore.Video.Media.DISPLAY_NAME,
MediaStore.Video.Media.DATE_ADDED, MediaStore.Video.Media.DATE_ADDED,
MediaStore.Video.Media.MIME_TYPE, MediaStore.Video.Media.MIME_TYPE,
MediaStore.Video.Media.BUCKET_DISPLAY_NAME MediaStore.Video.Media.BUCKET_DISPLAY_NAME,
MediaStore.Video.Media.DURATION
); );
val PROJECTION_MEDIA = arrayOf( val PROJECTION_MEDIA = arrayOf(
MediaStore.Audio.Media._ID, //0 MediaStore.Audio.Media._ID, //0
@@ -487,9 +488,10 @@ class StateLibrary {
""; "";
val albumContentUrl = if(albumId > 0) val albumArtBase = Uri.parse("content://media/external/audio/albumart")
ContentUris.withAppendedId(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, albumId)?.toString() val albumContentUrl = if (albumId > 0)
else null; ContentUris.withAppendedId(albumArtBase, albumId).toString()
else null
val dateObj = if(date > 0) val dateObj = if(date > 0)
OffsetDateTime.ofInstant(Instant.ofEpochSecond(date), ZoneOffset.UTC) OffsetDateTime.ofInstant(Instant.ofEpochSecond(date), ZoneOffset.UTC)
@@ -515,6 +517,8 @@ class StateLibrary {
val date = cursor.getLong(2); val date = cursor.getLong(2);
val contentType = cursor.getString(3); val contentType = cursor.getString(3);
val category = cursor.getString(4); val category = cursor.getString(4);
val durationMs = cursor.getLong(5)
val duration = if (durationMs > 0) durationMs / 1000 else -1
val idLong = id.toLongOrNull(); val idLong = id.toLongOrNull();
val contentUrl = if(idLong != null ) val contentUrl = if(idLong != null )
@@ -534,7 +538,7 @@ class StateLibrary {
PlatformID("FILE", contentUrl, null, 0, -1), PlatformID("FILE", contentUrl, null, 0, -1),
displayName, Thumbnails(arrayOf( displayName, Thumbnails(arrayOf(
Thumbnail(contentUrl, 0) Thumbnail(contentUrl, 0)
)), authorObj, contentUrl, -1, contentType, dateObj); )), authorObj, contentUrl, duration, contentType, dateObj);
} }
private var _instance : StateLibrary? = null; private var _instance : StateLibrary? = null;
@@ -622,11 +626,12 @@ class Artist {
val numTracks = cursor.getInt(2); val numTracks = cursor.getInt(2);
val numAlbums = cursor.getInt(3); val numAlbums = cursor.getInt(3);
val idLong = id.toLongOrNull(); val idLong = id.toLongOrNull()
val uri = if(idLong != null) ContentUris.withAppendedId(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, idLong) else null; val uri = if (idLong != null)
ContentUris.withAppendedId(MediaStore.Audio.Artists.EXTERNAL_CONTENT_URI, idLong)
else null
return Artist(artist, numTracks, numAlbums, null, id, uri?.toString()); return Artist(artist, numTracks, numAlbums, null, id, uri?.toString()) }
}
fun getArtist(id: Long): Artist? { fun getArtist(id: Long): Artist? {
val resolver = StateApp.instance.contextOrNull?.contentResolver; val resolver = StateApp.instance.contextOrNull?.contentResolver;
@@ -730,9 +735,10 @@ class Album {
val numTracks = cursor.getInt(2); val numTracks = cursor.getInt(2);
val artist = cursor.getString(3); val artist = cursor.getString(3);
val idLong = id.toLongOrNull(); val idLong = id.toLongOrNull()
val uri = if(idLong != null) ContentUris.withAppendedId(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, idLong) else null; val albumArtBase = Uri.parse("content://media/external/audio/albumart")
return Album(album, numTracks, artist, id, uri?.toString()); val uri = if (idLong != null) ContentUris.withAppendedId(albumArtBase, idLong) else null
return Album(album, numTracks, artist, id, uri?.toString())
} }
fun getAlbumTracks(albumId: Long): List<IPlatformVideo> { fun getAlbumTracks(albumId: Long): List<IPlatformVideo> {
@@ -169,6 +169,9 @@ class StatePlugins {
return false; return false;
LoginFragment.showLogin(config) {//LoginActivity.showLogin(context, config) { LoginFragment.showLogin(config) {//LoginActivity.showLogin(context, config) {
if(it == null)
return@showLogin;
try { try {
StatePlugins.instance.setPluginAuth(config.id, it); StatePlugins.instance.setPluginAuth(config.id, it);
} catch (e: Throwable) { } catch (e: Throwable) {
@@ -8,6 +8,7 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.casting.CastConnectionState import com.futo.platformplayer.casting.CastConnectionState
import com.futo.platformplayer.casting.CastingDevice
import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
@@ -22,18 +23,16 @@ class CastButton : androidx.appcompat.widget.AppCompatImageButton {
visibility = View.GONE; visibility = View.GONE;
} }
StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, _ -> StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { d, _ ->
updateCastState(); updateCastState(d);
}; };
updateCastState(); updateCastState(StateCasting.instance.activeDevice);
} }
} }
private fun updateCastState() { private fun updateCastState(d: CastingDevice?) {
val c = context ?: return; val c = context ?: return;
val d = StateCasting.instance.activeDevice;
val activeColor = ContextCompat.getColor(c, R.color.colorPrimary); val activeColor = ContextCompat.getColor(c, R.color.colorPrimary);
val connectingColor = ContextCompat.getColor(c, R.color.gray_c3); val connectingColor = ContextCompat.getColor(c, R.color.gray_c3);
val inactiveColor = ContextCompat.getColor(c, R.color.white); val inactiveColor = ContextCompat.getColor(c, R.color.white);
@@ -11,6 +11,7 @@ import com.futo.platformplayer.*
import com.futo.platformplayer.downloads.VideoDownload import com.futo.platformplayer.downloads.VideoDownload
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.services.DownloadService
import com.futo.platformplayer.states.StateDownloads import com.futo.platformplayer.states.StateDownloads
import com.futo.platformplayer.views.others.ProgressBar import com.futo.platformplayer.views.others.ProgressBar
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -32,6 +33,7 @@ class ActiveDownloadItem: LinearLayout {
private val _videoState: TextView; private val _videoState: TextView;
private val _videoCancel: TextView; private val _videoCancel: TextView;
private val _videoRetry: TextView;
private val _scope: CoroutineScope; private val _scope: CoroutineScope;
@@ -51,13 +53,14 @@ class ActiveDownloadItem: LinearLayout {
_videoSpeed = findViewById(R.id.download_video_speed); _videoSpeed = findViewById(R.id.download_video_speed);
_videoCancel = findViewById(R.id.download_cancel); _videoCancel = findViewById(R.id.download_cancel);
_videoRetry = findViewById(R.id.download_retry);
_videoName.text = download.name; _videoName.text = download.name;
_videoDuration.text = download.videoEither.duration.toHumanTime(false); _videoDuration.text = download.videoEither.duration.toHumanTime(false);
_videoAuthor.text = download.videoEither.author.name; _videoAuthor.text = download.videoEither.author.name;
_videoState.setOnClickListener { _videoState.setOnClickListener {
UIDialogs.toast(context, _videoState.text.toString(), false); UIDialogs.appToast(_videoState.text.toString(), false);
} }
Glide.with(_videoImage) Glide.with(_videoImage)
@@ -72,6 +75,12 @@ class ActiveDownloadItem: LinearLayout {
StateDownloads.instance.removeDownload(_download); StateDownloads.instance.removeDownload(_download);
StateDownloads.instance.preventPlaylistDownload(_download); StateDownloads.instance.preventPlaylistDownload(_download);
}; };
_videoRetry.setOnClickListener {
download.changeState(VideoDownload.State.QUEUED);
DownloadService.getOrCreateService(context) {
}
}
_download.onProgressChanged.subscribe(this) { _download.onProgressChanged.subscribe(this) {
_scope.launch(Dispatchers.Main) { _scope.launch(Dispatchers.Main) {
@@ -122,16 +131,19 @@ class ActiveDownloadItem: LinearLayout {
VideoDownload.State.DOWNLOADING -> { VideoDownload.State.DOWNLOADING -> {
_videoBar.visibility = VISIBLE; _videoBar.visibility = VISIBLE;
_videoSpeed.visibility = VISIBLE; _videoSpeed.visibility = VISIBLE;
_videoRetry.visibility = GONE;
}; };
VideoDownload.State.ERROR -> { VideoDownload.State.ERROR -> {
_videoState.setTextColor(Color.RED); _videoState.setTextColor(Color.RED);
_videoState.text = _download.error ?: context.getString(R.string.error); _videoState.text = _download.error ?: context.getString(R.string.error);
_videoBar.visibility = GONE; _videoBar.visibility = GONE;
_videoSpeed.visibility = GONE; _videoSpeed.visibility = GONE;
_videoRetry.visibility = VISIBLE;
} }
else -> { else -> {
_videoBar.visibility = GONE; _videoBar.visibility = GONE;
_videoSpeed.visibility = GONE; _videoSpeed.visibility = GONE;
_videoRetry.visibility = GONE;
} }
} }
} }
@@ -77,7 +77,7 @@ class VideoListEditorView : FrameLayout {
executeDelete() executeDelete()
}, cancelAction = { }, cancelAction = {
}, doNotAskAgainAction = { }, dismissAction = {}, doNotAskAgainAction = {
Settings.instance.other.playlistDeleteConfirmation = false Settings.instance.other.playlistDeleteConfirmation = false
Settings.instance.save() Settings.instance.save()
}) })
@@ -11,6 +11,7 @@ import androidx.core.content.ContextCompat
import androidx.core.content.res.ResourcesCompat import androidx.core.content.res.ResourcesCompat
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.dp
class SlideUpMenuButtonList : LinearLayout { class SlideUpMenuButtonList : LinearLayout {
private val _root: LinearLayout; private val _root: LinearLayout;
@@ -20,10 +21,16 @@ class SlideUpMenuButtonList : LinearLayout {
var _activeText: String? = null; var _activeText: String? = null;
val id: String? val id: String?
constructor(context: Context, attrs: AttributeSet? = null, id: String? = null): super(context, attrs) { val scrollable: Boolean;
this.id = id
LayoutInflater.from(context).inflate(R.layout.overlay_slide_up_menu_button_list, this, true); constructor(context: Context, attrs: AttributeSet? = null, id: String? = null, scrollable: Boolean = false): super(context, attrs) {
this.id = id
this.scrollable = scrollable ?: false;
LayoutInflater.from(context).inflate(
if(!scrollable)
R.layout.overlay_slide_up_menu_button_list
else R.layout.overlay_slide_up_menu_button_list_scrollable, this, true);
_root = findViewById(R.id.root); _root = findViewById(R.id.root);
} }
@@ -37,8 +44,9 @@ class SlideUpMenuButtonList : LinearLayout {
buttons.clear(); buttons.clear();
for (t in texts) { for (t in texts) {
val button = LinearLayout(context); val button = LinearLayout(context);
button.layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT).apply { button.layoutParams = LinearLayout.LayoutParams(if(!scrollable) 0 else LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.MATCH_PARENT).apply {
weight = 1.0f; if(!scrollable)
weight = 1.0f;
marginStart = marginLeft; marginStart = marginLeft;
marginEnd = marginRight; marginEnd = marginRight;
}; };
@@ -49,7 +57,11 @@ class SlideUpMenuButtonList : LinearLayout {
onClick.emit(t); onClick.emit(t);
}; };
button.setPadding(0, 0, 0, 0); val dp8 = 8.dp(resources)
if(!scrollable)
button.setPadding(0, 0, 0, 0);
else
button.setPadding(dp8, 0, dp8, 0);
val text = TextView(context); val text = TextView(context);
text.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT); text.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT);
@@ -69,6 +81,18 @@ class SlideUpMenuButtonList : LinearLayout {
fun setSelected(text: String) { fun setSelected(text: String) {
buttons[_activeText]?.background = ContextCompat.getDrawable(context, R.drawable.background_slide_up_option); buttons[_activeText]?.background = ContextCompat.getDrawable(context, R.drawable.background_slide_up_option);
buttons[text]?.background = ContextCompat.getDrawable(context, R.drawable.background_slide_up_option_selected); buttons[text]?.background = ContextCompat.getDrawable(context, R.drawable.background_slide_up_option_selected);
val dp8 = 8.dp(resources)
if(!scrollable) {
buttons[text]?.setPadding(0, 0, 0, 0);
buttons[_activeText]?.setPadding(0, 0, 0, 0);
}
else {
buttons[text]?.setPadding(dp8, 0, dp8, 0);
buttons[_activeText]?.setPadding(dp8, 0, dp8, 0);
}
_activeText = text; _activeText = text;
} }
} }
@@ -489,7 +489,7 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
StatePlayer.instance.onQueueChanged.subscribe(this) { StatePlayer.instance.onQueueChanged.subscribe(this) {
CoroutineScope(Dispatchers.Main).launch(Dispatchers.Main) { CoroutineScope(Dispatchers.Main).launch(Dispatchers.Main) {
setLoopVisible(!StatePlayer.instance.hasQueue) //setLoopVisible(!StatePlayer.instance.hasQueue)
updateNextPrevious(); updateNextPrevious();
} }
} }
@@ -886,12 +886,12 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
} }
fun updateLoopVideoUI() { fun updateLoopVideoUI() {
if(StatePlayer.instance.loopVideo) { if(StatePlayer.instance.loopVideo) {
_control_loop.setImageResource(R.drawable.ic_loop_active); _control_loop.setImageResource(R.drawable.ic_repeat_one_active);
_control_loop_fullscreen.setImageResource(R.drawable.ic_loop_active); _control_loop_fullscreen.setImageResource(R.drawable.ic_repeat_one_active);
} }
else { else {
_control_loop.setImageResource(R.drawable.ic_loop); _control_loop.setImageResource(R.drawable.ic_repeat_one);
_control_loop_fullscreen.setImageResource(R.drawable.ic_loop); _control_loop_fullscreen.setImageResource(R.drawable.ic_repeat_one);
} }
} }
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M472.31,587.69L472.31,407.69L424.62,407.69L424.62,372.31L507.69,372.31L507.69,587.69L472.31,587.69ZM292.31,840L160,707.69L292.31,575.38L320.62,604.15L237.08,687.69L692.31,687.69L692.31,527.69L732.31,527.69L732.31,727.69L237.08,727.69L320.62,811.23L292.31,840ZM227.69,432.31L227.69,232.31L722.92,232.31L639.38,148.77L667.69,120L800,252.31L667.69,384.62L639.38,355.85L722.92,272.31L267.69,272.31L267.69,432.31L227.69,432.31Z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@color/colorPrimary"
android:pathData="M472.31,587.69L472.31,407.69L424.62,407.69L424.62,372.31L507.69,372.31L507.69,587.69L472.31,587.69ZM292.31,840L160,707.69L292.31,575.38L320.62,604.15L237.08,687.69L692.31,687.69L692.31,527.69L732.31,527.69L732.31,727.69L237.08,727.69L320.62,811.23L292.31,840ZM227.69,432.31L227.69,232.31L722.92,232.31L639.38,148.77L667.69,120L800,252.31L667.69,384.62L639.38,355.85L722.92,272.31L267.69,272.31L267.69,432.31L227.69,432.31Z"/>
</vector>
+28 -13
View File
@@ -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>
+1 -1
View File
@@ -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"
+10
View File
@@ -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>